From 798249728aafe283b89dde41e82ee7b9ba48f2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:27:53 +0800 Subject: [PATCH 1/7] fix(compat): tolerate CRLF briefs, normalize backslash paths, resolve rename paths, timeout git fetch --- cli.mjs | 5 +- lib/brief.mjs | 11 +- lib/domains/change.mjs | 4 +- lib/domains/commit.mjs | 4 +- lib/domains/issue.mjs | 20 ++-- lib/domains/publish.mjs | 20 ++-- lib/domains/release.mjs | 44 ++++--- lib/domains/sync.mjs | 2 +- lib/git.mjs | 3 +- lib/input.mjs | 5 + .../brief.md | 110 ++++++++++++++++++ test/cli.test.mjs | 33 ++++++ 12 files changed, 203 insertions(+), 58 deletions(-) create mode 100644 shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md diff --git a/cli.mjs b/cli.mjs index 6df63fe..cdf6e42 100755 --- a/cli.mjs +++ b/cli.mjs @@ -2,10 +2,9 @@ import { args, HELP } from './lib/args.mjs' import { out, fail } from './lib/output.mjs' import { plan } from './lib/plan.mjs' -import { root, repo } from './lib/git.mjs' -import { brief, write, tasks } from './lib/brief.mjs' +import { root } from './lib/git.mjs' +import { brief, write } from './lib/brief.mjs' import { confirm, name } from './lib/input.mjs' -import { pr } from './lib/github.mjs' import * as branch from './lib/domains/branch.mjs' import * as sync from './lib/domains/sync.mjs' import * as review from './lib/domains/review.mjs' diff --git a/lib/brief.mjs b/lib/brief.mjs index 0998d74..f3181b6 100644 --- a/lib/brief.mjs +++ b/lib/brief.mjs @@ -4,18 +4,21 @@ import { dirname, join } from 'node:path' export const ap = (r, n) => join(r, 'shadow-docs', 'changes', n, 'brief.md') export const xp = (r, n) => join(r, 'shadow-docs', 'changes', 'archive', n, 'brief.md') +// 读取容忍 LF/CRLF(Windows core.autocrlf 检出与手工编辑),写回恒为 LF export function brief(r, n, arch = false) { const path = arch ? xp(r, n) : ap(r, n) if (!existsSync(path)) throw Error('BRIEF_NOT_FOUND') - const t = readFileSync(path, 'utf8'), e = t.indexOf('\n---\n', 4) - if (e < 0) throw Error('BRIEF_FRONTMATTER_REQUIRED') - return { path, data: JSON.parse(t.slice(4, e)), body: t.slice(e + 5) } + const t = readFileSync(path, 'utf8'), open = /^---\r?\n/.exec(t) + if (!open) throw Error('BRIEF_FRONTMATTER_REQUIRED') + const s = open[0].length, e = /\r?\n---\r?\n/.exec(t.slice(s)) + if (!e) throw Error('BRIEF_FRONTMATTER_REQUIRED') + return { path, data: JSON.parse(t.slice(s, s + e.index)), body: t.slice(s + e.index + e[0].length) } } export function write(b) { mkdirSync(dirname(b.path), { recursive: true }) const t = b.path + `.tmp-${process.pid}` - writeFileSync(t, `---\n${JSON.stringify(b.data, null, 2)}\n---\n${b.body}`) + writeFileSync(t, `---\n${JSON.stringify(b.data, null, 2)}\n---\n${b.body.replaceAll('\r\n', '\n')}`) renameSync(t, b.path) } diff --git a/lib/domains/change.mjs b/lib/domains/change.mjs index 24aaccd..ea264a5 100644 --- a/lib/domains/change.mjs +++ b/lib/domains/change.mjs @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { brief, write, ap } from '../brief.mjs' -import { name, confirm, readBody } from '../input.mjs' +import { name, confirm, readBody, fileList } from '../input.mjs' export function create(r, o) { confirm(o) @@ -16,7 +16,7 @@ export function create(r, o) { status: 'draft', baseBranch: o['base-branch'] || 'main', branch: null, - files: String(o.files || '').split(',').filter(Boolean).sort(), + files: fileList(o.files), github: { repository: o.repository || null, issue: null, issueUrl: null, pullRequest: null, pullRequestUrl: null }, review: { conclusion: 'pending', verifiedCommit: null, verifiedAt: null }, workflow: { operation: null, checkpoint: null, planHash: null, updatedAt: null, lastError: null }, diff --git a/lib/domains/commit.mjs b/lib/domains/commit.mjs index 253f71f..c744101 100644 --- a/lib/domains/commit.mjs +++ b/lib/domains/commit.mjs @@ -1,10 +1,10 @@ import { git, repo } from '../git.mjs' import { brief, write } from '../brief.mjs' -import { name } from '../input.mjs' +import { name, fileList } from '../input.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) - return { name: n, files: String(o.files || '').split(',').filter(Boolean).sort(), message: o.message || null, brief: b.data, repo: q } + return { name: n, files: fileList(o.files), message: o.message || null, brief: b.data, repo: q } } export function execute(r, o, x, b) { diff --git a/lib/domains/issue.mjs b/lib/domains/issue.mjs index 0f9aa82..f7e247d 100644 --- a/lib/domains/issue.mjs +++ b/lib/domains/issue.mjs @@ -16,15 +16,13 @@ export async function planData(r, o) { } } -export function execute(r, o, x, b) { - return (async () => { - if (!x.title) throw Error('ISSUE_TITLE_REQUIRED') - const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) }) - b.data.github.repository = x.repository - b.data.github.issue = z.number - b.data.github.issueUrl = z.html_url - b.data.workflow.checkpoint = `issue:${z.number}` - write(b) - return { number: z.number, url: z.html_url } - })() +export async function execute(r, o, x, b) { + if (!x.title) throw Error('ISSUE_TITLE_REQUIRED') + const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) }) + b.data.github.repository = x.repository + b.data.github.issue = z.number + b.data.github.issueUrl = z.html_url + b.data.workflow.checkpoint = `issue:${z.number}` + write(b) + return { number: z.number, url: z.html_url } } diff --git a/lib/domains/publish.mjs b/lib/domains/publish.mjs index 1c8f367..3ad225e 100644 --- a/lib/domains/publish.mjs +++ b/lib/domains/publish.mjs @@ -9,15 +9,13 @@ export async function planData(r, o) { return { name: n, repository: repository(b, r), branch: b.data.branch || q.branch, baseBranch: b.data.baseBranch || 'main', title: o.title || n, body: o.body || '', head: q.head, brief: b.data } } -export function execute(r, o, x, b) { - return (async () => { - try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } - const { pr: z, created } = await ensurePr(x) - b.data.github.pullRequest = z.number - b.data.github.pullRequestUrl = z.html_url - b.data.status = 'published' - b.data.workflow.checkpoint = `pr:${z.number}` - write(b) - return { number: z.number, url: z.html_url, created } - })() +export async function execute(r, o, x, b) { + try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } + const { pr: z, created } = await ensurePr(x) + b.data.github.pullRequest = z.number + b.data.github.pullRequestUrl = z.html_url + b.data.status = 'published' + b.data.workflow.checkpoint = `pr:${z.number}` + write(b) + return { number: z.number, url: z.html_url, created } } diff --git a/lib/domains/release.mjs b/lib/domains/release.mjs index 2d49bfd..265f626 100644 --- a/lib/domains/release.mjs +++ b/lib/domains/release.mjs @@ -1,6 +1,6 @@ import { git, repo } from '../git.mjs' import { brief, write } from '../brief.mjs' -import { name } from '../input.mjs' +import { name, fileList } from '../input.mjs' import { ext } from '../errors.mjs' import { repository, ensurePr } from '../github.mjs' @@ -8,7 +8,7 @@ export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.release || {} return { name: n, - files: String(o.files ?? w.files ?? '').split(',').filter(Boolean).sort(), + files: fileList(o.files ?? w.files), message: o.message ?? w.message ?? null, title: o.title ?? w.title ?? n, body: o.body ?? w.body ?? '', @@ -21,25 +21,23 @@ export async function planData(r, o) { } } -export function execute(r, o, x, b) { - return (async () => { - let commit = null - if (!['committed', 'published'].includes(b.data.status)) { - if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED') - if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 }) - git(r, ['add', '--', ...x.files]) - git(r, ['commit', '-m', x.message]) - b.data.status = 'committed' - b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD']) - } - commit = b.data.workflow.checkpoint - try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } - const { pr: z, created } = await ensurePr(x) - b.data.github.pullRequest = z.number - b.data.github.pullRequestUrl = z.html_url - b.data.status = 'published' - b.data.workflow.checkpoint = `pr:${z.number}` - write(b) - return { commit, number: z.number, url: z.html_url, created } - })() +export async function execute(r, o, x, b) { + let commit = null + if (!['committed', 'published'].includes(b.data.status)) { + if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED') + if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 }) + git(r, ['add', '--', ...x.files]) + git(r, ['commit', '-m', x.message]) + b.data.status = 'committed' + b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD']) + } + commit = b.data.workflow.checkpoint + try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } + const { pr: z, created } = await ensurePr(x) + b.data.github.pullRequest = z.number + b.data.github.pullRequestUrl = z.html_url + b.data.status = 'published' + b.data.workflow.checkpoint = `pr:${z.number}` + write(b) + return { commit, number: z.number, url: z.html_url, created } } diff --git a/lib/domains/sync.mjs b/lib/domains/sync.mjs index 99124c0..301e43c 100644 --- a/lib/domains/sync.mjs +++ b/lib/domains/sync.mjs @@ -7,7 +7,7 @@ export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) const dirty = q.changedFiles.filter(x => !x.startsWith('shadow-docs/')) if (dirty.length) throw Error('DIRTY_WORKTREE') - try { git(r, ['fetch', 'origin', '--prune']) } catch { ext('GIT_FETCH_FAILED') } + try { git(r, ['fetch', 'origin', '--prune'], { timeout: 120000 }) } catch { ext('GIT_FETCH_FAILED') } const up = `origin/${b.data.baseBranch || q.branch}`, to = git(r, ['rev-parse', up]) try { git(r, ['merge-base', '--is-ancestor', q.head, to]) } catch { throw Error('SYNC_NOT_FAST_FORWARD') } return { name: n, from: q.head, to, upstream: up } diff --git a/lib/git.mjs b/lib/git.mjs index d353664..db78645 100644 --- a/lib/git.mjs +++ b/lib/git.mjs @@ -15,6 +15,7 @@ export function repo(r) { branch: git(r, ['branch', '--show-current']), head: git(r, ['rev-parse', 'HEAD']), clean: !s, - changedFiles: s ? s.split('\n').map(x => x.slice(3)).sort() : [], + // porcelain 重命名行为 "R old -> new",取箭头右侧的新路径 + changedFiles: s ? s.split('\n').map(x => x.slice(3).split(' -> ').pop()).sort() : [], } } diff --git a/lib/input.mjs b/lib/input.mjs index b6f00f1..e7614de 100644 --- a/lib/input.mjs +++ b/lib/input.mjs @@ -10,6 +10,11 @@ export function name(o) { return o.name } +// Windows 反斜杠入参归一为 git 输出的正斜杠路径,保证与 porcelain/conflict 可比对 +export function fileList(v) { + return String(v ?? '').split(',').map(x => x.trim().replaceAll('\\', '/').replace(/^\.\//, '')).filter(Boolean).sort() +} + export function readBody(o, n) { if (!o['body-file']) return `\n# ${n}\n\n## 任务\n\n` const p = o['body-file'] diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md new file mode 100644 index 0000000..518f965 --- /dev/null +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -0,0 +1,110 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-refactor-compat-and-domain-convergence", + "type": "refactor", + "scope": "lib", + "status": "branched", + "baseBranch": "main", + "branch": "refactor/20260917-refactor-compat-and-domain-convergence", + "files": [ + "README.md", + "cli.mjs", + "lib/brief.mjs", + "lib/domains/change.mjs", + "lib/domains/commit.mjs", + "lib/domains/index.mjs", + "lib/domains/issue.mjs", + "lib/domains/publish.mjs", + "lib/domains/release.mjs", + "lib/domains/sync.mjs", + "lib/errors.mjs", + "lib/git.mjs", + "lib/input.mjs", + "lib/steps.mjs", + "scripts/pack.mjs", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 1, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/1", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:1", + "planHash": "f4a230383bc3cb5fc6eac11d1f91ab32e3bfbeae1a76916687ef14d3d4adc53a", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "CLI 平台兼容性修复与领域架构收敛", + "body": "分析发现两类问题:CRLF 导致 Windows 用户 brief 解析全面失败(已复现实证);release 逻辑三处拷贝、路由器领域特例、index 校验语义分叉、错误构造三种风格并存。选定方案 B:兼容性修复 + 架构收敛,13 项任务分 3 个 Phase。详见 shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md", + "labels": [ + "refactor" + ] + } + } +} +--- + +# CLI 平台兼容性修复与领域架构收敛 + +## 动机 + +逐文件分析发现两类会持续产生成本的问题:一是 CRLF 兼容性缺口——`brief.mjs` 的 frontmatter 定界只认 LF,Windows 用户 `core.autocrlf=true` checkout 或手工编辑的 brief 会令所有命令报 `BRIEF_FRONTMATTER_REQUIRED`(已实验复现);二是架构渗漏——`release` 域整段复制 commit+publish 逻辑、路由器硬编码领域特例、`index` 域绕开通道自建一套 hash 校验(且与通用语义不一致)、错误构造三种风格并存。近期三次提交已证明团队在真实处理 node20/Windows 兼容问题,此时把兼容基线固化进解析层、把领域边界收敛干净,收益最高、回归面最小。 + +## 引用规范 + +- norms/code-style.md(通用规范) + - 当前结论: 渐进式治理——新代码必须守规范,修改旧代码只修与当前改动直接相关的问题;不以 300 行为机械拆分门槛;不添加未使用 import;不为未来场景提前抽象。 + - 适用 scope: 全仓 `cli.mjs`、`lib/` +- norms/code-style-packages.md(部分适用) + - 当前结论: 公共能力从稳定公开入口导出;变更导出入口时验证实际消费者。 + - 适用 scope: `lib/` 内部共享 helper(`errors`、`steps`、`input`) + +## 决策 + +- **选型:** 方案 B——兼容性修复 + 架构收敛 +- **对比方案:** 方案 A(只修兼容)会遗留 release 三处拷贝和路由领域渗漏,维护税持续发生;方案 C(微模块合并、数据驱动命令表、移除 --json)改动契约接口有兼容风险且违反「不为显然代码提前抽象」,均不选。 +- **理由:** 兼容缺口是用户可感知的正确性 bug,优先固化到解析层(读取容忍 CRLF、写入统一 LF),不散落逐域打补丁;架构收敛限定在「同逻辑多份拷贝」和「契约不一致」两处,遵循 code-style 渐进式治理,不顺手做方案 C 的格式化重构。`--json` 保留为兼容参数并文档化,不删除。 + +## 任务 + +### Phase 1 — 兼容性修复 + +- [x] brief 解析容忍 CRLF:frontmatter 定界按 `\r?\n---\r?\n` 匹配,`write()` 输出统一 LF —— `lib/brief.mjs` +- [x] `--files` 归一化:反斜杠转正斜杠、去空段、排序,收敛为 `fileList()` helper 供 change/commit/release 消费 —— `lib/input.mjs` — `lib/domains/change.mjs` `lib/domains/commit.mjs` `lib/domains/release.mjs` +- [x] `git status --porcelain` rename 行取 ` -> ` 右侧新路径,不再产出脏字符串 —— `lib/git.mjs` +- [x] `git fetch` 加 120s 超时,与 push 对齐 —— `lib/domains/sync.mjs` +- [x] 删除 cli.mjs 未使用 import(repo/tasks/pr),execute 的 async IIFE 改直接 `async function` —— `cli.mjs` — `lib/domains/publish.mjs` `lib/domains/issue.mjs` `lib/domains/release.mjs` + +### Phase 2 — 架构收敛 + +- [ ] 提取共享步骤模块:`commitStep(r, x)`、`pushAndOpenPr(r, x, b)`,commit/publish/release 三域改为复用 —— `lib/steps.mjs` — `lib/domains/commit.mjs` `lib/domains/publish.mjs` `lib/domains/release.mjs` +- [ ] plan 回写领域化:各域可选导出 `persistPlan(e, b)`,`planDomain` 改为 `mod.persistPlan?.(e, b)`,删除 `d === 'release'` / `d === 'issue'` 硬编码 —— `cli.mjs` — `lib/domains/release.mjs` `lib/domains/issue.mjs` +- [ ] index 并入统一通道:改造为 DOMAINS 成员(planData/execute + persistPlan),hash 校验语义与 executeDomain 对齐,保持 `index rebuild plan|execute` 外部契约不变 —— `cli.mjs` — `lib/domains/index.mjs` +- [ ] 错误构造统一:`lib/errors.mjs` 提供 `err(code, {status})`,全域错误改经它构造;错误码与退出码 1/2/3/4 语义文档化 —— `lib/errors.mjs` — `README.md` + +### Phase 3 — 回归与文档 + +- [ ] 新增测试:CRLF brief 读-改-写 round-trip、反斜杠 `--files` 与 conflict 交集匹配、rename 行 changedFiles 结果 —— `test/cli.test.mjs` +- [ ] pack.mjs 清理冗余动态 import;`tar` 缺失时给出明确报错提示 —— `scripts/pack.mjs` +- [ ] README 补命令总览、退出码表、`--json` 兼容参数说明;35 项存量测试全量回归 —— `README.md` — `test/cli.test.mjs` + +## 结果 + +- 实际耗时: — +- 验证: — + +## 知识评估 + +- **预期影响:** 新增 +- **候选卡片:** shadow-docs/knowledge/brief-frontmatter-crlf.md(domain: cli-infrastructure,scope: lib/brief.mjs) +- **理由:** CRLF 定界约定是本次验证出的非显然约束(brief 文件格式契约),后续任何触碰 brief 读写的变更都必须知道「读容忍 CRLF、写必须 LF」;沉淀为卡片防止回归。其余改动为常规架构收敛,无需卡片。 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 66d029c..eb973ea 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -176,6 +176,39 @@ test('task list exposes stable task identifiers', () => { assert.deepEqual(JSON.parse(result.stdout).data.tasks, [{ id: 'task-1', done: false, text: 'task-1 — `src/example.js` — implement' }]) }) +test('file lists normalize Windows backslash separators', () => { + const root = fixture() + const created = run(['change', 'create', '--name', 'backslash', '--type', 'fix', '--files', 'lib\\a.js,src\\example.js', '--confirm', '--json'], root) + assert.equal(created.status, 0, created.stderr) + const text = readFileSync(join(root, 'shadow-docs', 'changes', 'backslash', 'brief.md'), 'utf8') + assert.match(text, /"lib\/a.js",\s*"src\/example.js"/) + const conflict = run(['conflict', 'inspect', '--name', 'sample', '--json'], root) + assert.equal(conflict.status, 0, conflict.stderr) + assert.deepEqual(JSON.parse(conflict.stdout).data.overlaps, [{ change: 'backslash', files: ['src/example.js'] }]) +}) + +test('changed files resolve renamed porcelain entries to the new path', () => { + const root = fixture() + execFileSync('git', ['mv', 'README.md', 'DOCS.md'], { cwd: root }) + const result = run(['repo', 'inspect', '--json'], root) + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(JSON.parse(result.stdout).data.changedFiles, ['DOCS.md', 'shadow-docs/']) +}) + +test('brief parsing tolerates CRLF and writes back LF', () => { + const root = fixture() + const path = join(root, 'shadow-docs', 'changes', 'sample', 'brief.md') + writeFileSync(path, readFileSync(path, 'utf8').replaceAll('\n', '\r\n')) + const listed = run(['task', 'list', '--name', 'sample', '--json'], root) + assert.equal(listed.status, 0, listed.stderr) + assert.deepEqual(JSON.parse(listed.stdout).data.tasks, [{ id: 'task-1', done: false, text: 'task-1 — `src/example.js` — implement' }]) + const set = run(['task', 'set', '--name', 'sample', '--task', 'task-1', '--state', 'done', '--confirm', '--json'], root) + assert.equal(set.status, 0, set.stderr) + const raw = readFileSync(path, 'utf8') + assert.ok(!raw.includes('\r'), 'rewritten brief is LF-normalized') + assert.match(raw, /- \[x\] task-1/) +}) + test('mutating execute commands require confirmation', () => { const root = fixture() for (const args of [['branch'], ['sync'], ['review'], ['commit'], ['publish'], ['release'], ['reconcile'], ['archive']]) { From 10158ef3266ed0f6db122f9f75b73b3251ba82ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:36:17 +0800 Subject: [PATCH 2/7] refactor: extract shared commit/publish steps, persistPlan hook, unify index channel and error construction --- cli.mjs | 41 +++++++++++-------- lib/domains/archive.mjs | 5 ++- lib/domains/change.mjs | 3 +- lib/domains/commit.mjs | 12 ++---- lib/domains/index.mjs | 15 ++----- lib/domains/issue.mjs | 7 +++- lib/domains/publish.mjs | 15 +++---- lib/domains/release.mjs | 30 +++++--------- lib/domains/review.mjs | 7 ++-- lib/domains/sync.mjs | 6 +-- lib/domains/task.mjs | 5 ++- lib/errors.mjs | 10 ++++- lib/git.mjs | 3 +- lib/github.mjs | 14 +++---- lib/input.mjs | 11 ++--- lib/steps.mjs | 25 +++++++++++ .../brief.md | 14 +++---- 17 files changed, 122 insertions(+), 101 deletions(-) create mode 100644 lib/steps.mjs diff --git a/cli.mjs b/cli.mjs index cdf6e42..0a44618 100755 --- a/cli.mjs +++ b/cli.mjs @@ -5,6 +5,7 @@ import { plan } from './lib/plan.mjs' import { root } from './lib/git.mjs' import { brief, write } from './lib/brief.mjs' import { confirm, name } from './lib/input.mjs' +import { err } from './lib/errors.mjs' import * as branch from './lib/domains/branch.mjs' import * as sync from './lib/domains/sync.mjs' import * as review from './lib/domains/review.mjs' @@ -19,26 +20,29 @@ import * as task from './lib/domains/task.mjs' import * as inspect from './lib/domains/inspect.mjs' import * as index from './lib/domains/index.mjs' -const DOMAINS = { branch, sync, review, commit, publish, release, reconcile, archive, issue } +const DOMAINS = { branch, sync, review, commit, publish, release, reconcile, archive, issue, index } -// execute 与 plan 用同一 planData 重算并对比 hash,brief 里的 planHash 是 execute 的前置凭证 -async function executeDomain(d, r, o) { +// execute 与 plan 用同一 planData 重算并对比 hash。带 --name 的域以 brief 里的 planHash 为前置凭证; +// 无 brief 的域(如 index rebuild)--plan-hash 是唯一凭证 +async function executeDomain(c, mod, r, o) { confirm(o) - const mod = DOMAINS[d] - const e = plan(d, await mod.planData(r, o)) - if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw Error('PLAN_HASH_INVALID') - const n = name(o), b = brief(r, n), x = e.data - if (b.data.workflow.planHash !== e.planHash) throw Object.assign(Error(b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED'), { status: b.data.workflow.planHash ? 1 : 2 }) - return mod.execute(r, o, x, b) + const e = plan(c, await mod.planData(r, o)) + if (o['plan-hash'] && o['plan-hash'] !== e.planHash) throw err('PLAN_HASH_INVALID') + if (!o.name) { + if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2) + return mod.execute(r, o, e.data) + } + const b = brief(r, name(o)) + if (b.data.workflow.planHash !== e.planHash) throw err(b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', b.data.workflow.planHash ? 1 : 2) + return mod.execute(r, o, e.data, b) } -async function planDomain(d, r, o) { - const mod = DOMAINS[d] - const e = plan(d, await mod.planData(r, o)) +async function planDomain(c, mod, r, o) { + const e = plan(c, await mod.planData(r, o)) + if (!o.name) return e const b = brief(r, name(o)) b.data.workflow.planHash = e.planHash - if (d === 'release') b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body } - if (d === 'issue') b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels } + mod.persistPlan?.(b, e) write(b) return e } @@ -53,10 +57,11 @@ async function handle(r, p, o) { if (d === 'task' && a === 'set') return out({ ok: true, command: 'task.set', data: task.set(r, o) }) if (d === 'change' && a === 'create') return out({ ok: true, command: 'change.create', data: change.create(r, o) }) if (d === 'change' && a === 'approve') return out({ ok: true, command: 'change.approve', data: change.approve(r, o) }) - if (d === 'index' && a === 'rebuild' && s === 'plan') return out(index.rebuildPlan(r)) - if (d === 'index' && a === 'rebuild' && s === 'execute') return out({ ok: true, command: 'index.rebuild.execute', data: index.rebuildExecute(r, o) }) - if (Object.hasOwn(DOMAINS, d) && a === 'plan') return out(await planDomain(d, r, o)) - if (Object.hasOwn(DOMAINS, d) && a === 'execute') return out({ ok: true, command: `${d}.execute`, data: await executeDomain(d, r, o) }) + if (Object.hasOwn(DOMAINS, d)) { + const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d + if (verb === 'plan') return out(await planDomain(c, DOMAINS[d], r, o)) + if (verb === 'execute') return out({ ok: true, command: `${c}.execute`, data: await executeDomain(c, DOMAINS[d], r, o) }) + } return fail('UNKNOWN_COMMAND', `unsupported command: ${p.join(' ')}`) } diff --git a/lib/domains/archive.mjs b/lib/domains/archive.mjs index 567ca4a..8ebd0fa 100644 --- a/lib/domains/archive.mjs +++ b/lib/domains/archive.mjs @@ -5,12 +5,13 @@ import { pr } from '../github.mjs' import { buildIndex } from '../indexer.mjs' import { brief, write, xp } from '../brief.mjs' import { repo } from '../git.mjs' +import { err } from '../errors.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) - if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw Error('REVIEW_NOT_PASSED') + if (b.data.review?.conclusion !== 'passed' || b.data.review.verifiedCommit !== q.head) throw err('REVIEW_NOT_PASSED') const x = await pr(b, r) - if (!x.merged) throw Error('PR_NOT_MERGED') + if (!x.merged) throw err('PR_NOT_MERGED') return { name: n, pullRequest: x.number, head: q.head } } diff --git a/lib/domains/change.mjs b/lib/domains/change.mjs index ea264a5..bc17c5e 100644 --- a/lib/domains/change.mjs +++ b/lib/domains/change.mjs @@ -1,11 +1,12 @@ import { existsSync } from 'node:fs' import { brief, write, ap } from '../brief.mjs' import { name, confirm, readBody, fileList } from '../input.mjs' +import { err } from '../errors.mjs' export function create(r, o) { confirm(o) const n = name(o) - if (existsSync(ap(r, n))) throw Error('CHANGE_EXISTS') + if (existsSync(ap(r, n))) throw err('CHANGE_EXISTS') const b = { path: ap(r, n), data: { diff --git a/lib/domains/commit.mjs b/lib/domains/commit.mjs index c744101..21052af 100644 --- a/lib/domains/commit.mjs +++ b/lib/domains/commit.mjs @@ -1,6 +1,7 @@ -import { git, repo } from '../git.mjs' +import { repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name, fileList } from '../input.mjs' +import { commitStep } from '../steps.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) @@ -8,12 +9,7 @@ export async function planData(r, o) { } export function execute(r, o, x, b) { - if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED') - if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 }) - git(r, ['add', '--', ...x.files]) - git(r, ['commit', '-m', x.message]) - b.data.status = 'committed' - b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD']) + const commit = commitStep(r, x, b) write(b) - return { commit: b.data.workflow.checkpoint } + return { commit } } diff --git a/lib/domains/index.mjs b/lib/domains/index.mjs index f52ac67..857e49b 100644 --- a/lib/domains/index.mjs +++ b/lib/domains/index.mjs @@ -1,21 +1,12 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { plan } from '../plan.mjs' -import { confirm } from '../input.mjs' import { buildIndex } from '../indexer.mjs' -function rebuildData(r) { +export async function planData(r) { return { content: buildIndex(r), current: existsSync(join(r, 'shadow-docs', 'INDEX.md')) ? readFileSync(join(r, 'shadow-docs', 'INDEX.md'), 'utf8') : '' } } -export function rebuildPlan(r) { - return plan('index.rebuild', rebuildData(r)) -} - -export function rebuildExecute(r, o) { - confirm(o, true) - const e = plan('index.rebuild', rebuildData(r)) - if (e.planHash !== o['plan-hash']) throw Error('PLAN_HASH_INVALID') - writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), e.data.content) +export function execute(r, o, x) { + writeFileSync(join(r, 'shadow-docs', 'INDEX.md'), x.content) return { path: 'shadow-docs/INDEX.md' } } diff --git a/lib/domains/issue.mjs b/lib/domains/issue.mjs index f7e247d..1737626 100644 --- a/lib/domains/issue.mjs +++ b/lib/domains/issue.mjs @@ -2,6 +2,7 @@ import { api, repository } from '../github.mjs' import { brief, write } from '../brief.mjs' import { name } from '../input.mjs' import { repo } from '../git.mjs' +import { err } from '../errors.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.issuePlan || {} @@ -16,8 +17,12 @@ export async function planData(r, o) { } } +export function persistPlan(b, e) { + b.data.workflow.issuePlan = { title: e.data.title, body: e.data.body, labels: e.data.labels } +} + export async function execute(r, o, x, b) { - if (!x.title) throw Error('ISSUE_TITLE_REQUIRED') + if (!x.title) throw err('ISSUE_TITLE_REQUIRED') const z = await api(`/repos/${x.repository}/issues`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, labels: x.labels }) }) b.data.github.repository = x.repository b.data.github.issue = z.number diff --git a/lib/domains/publish.mjs b/lib/domains/publish.mjs index 3ad225e..9cf6ee6 100644 --- a/lib/domains/publish.mjs +++ b/lib/domains/publish.mjs @@ -1,8 +1,8 @@ -import { git, repo } from '../git.mjs' +import { repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name } from '../input.mjs' -import { ext } from '../errors.mjs' -import { repository, ensurePr } from '../github.mjs' +import { repository } from '../github.mjs' +import { pushAndOpenPr } from '../steps.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) @@ -10,12 +10,7 @@ export async function planData(r, o) { } export async function execute(r, o, x, b) { - try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } - const { pr: z, created } = await ensurePr(x) - b.data.github.pullRequest = z.number - b.data.github.pullRequestUrl = z.html_url - b.data.status = 'published' - b.data.workflow.checkpoint = `pr:${z.number}` + const z = await pushAndOpenPr(r, x, b) write(b) - return { number: z.number, url: z.html_url, created } + return z } diff --git a/lib/domains/release.mjs b/lib/domains/release.mjs index 265f626..681b55d 100644 --- a/lib/domains/release.mjs +++ b/lib/domains/release.mjs @@ -1,8 +1,8 @@ -import { git, repo } from '../git.mjs' +import { repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name, fileList } from '../input.mjs' -import { ext } from '../errors.mjs' -import { repository, ensurePr } from '../github.mjs' +import { repository } from '../github.mjs' +import { commitStep, pushAndOpenPr } from '../steps.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r), w = b.data.workflow.release || {} @@ -21,23 +21,13 @@ export async function planData(r, o) { } } +export function persistPlan(b, e) { + b.data.workflow.release = { files: e.data.files, message: e.data.message, title: e.data.title, body: e.data.body } +} + export async function execute(r, o, x, b) { - let commit = null - if (!['committed', 'published'].includes(b.data.status)) { - if (!x.message || !x.files.length) throw Error('COMMIT_INPUT_REQUIRED') - if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw Object.assign(Error('UNSUPPORTED_OPERATION'), { status: 4 }) - git(r, ['add', '--', ...x.files]) - git(r, ['commit', '-m', x.message]) - b.data.status = 'committed' - b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD']) - } - commit = b.data.workflow.checkpoint - try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } - const { pr: z, created } = await ensurePr(x) - b.data.github.pullRequest = z.number - b.data.github.pullRequestUrl = z.html_url - b.data.status = 'published' - b.data.workflow.checkpoint = `pr:${z.number}` + const commit = ['committed', 'published'].includes(b.data.status) ? b.data.workflow.checkpoint : commitStep(r, x, b) + const z = await pushAndOpenPr(r, x, b) write(b) - return { commit, number: z.number, url: z.html_url, created } + return { commit, ...z } } diff --git a/lib/domains/review.mjs b/lib/domains/review.mjs index dce0588..812566f 100644 --- a/lib/domains/review.mjs +++ b/lib/domains/review.mjs @@ -1,6 +1,7 @@ import { brief, write, tasks } from '../brief.mjs' import { name } from '../input.mjs' import { repo } from '../git.mjs' +import { err } from '../errors.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) @@ -9,10 +10,10 @@ export async function planData(r, o) { export function execute(r, o, x, b) { const ts = tasks(b.body) - if (ts.length && !ts.every(t => t.done)) throw Error('TASKS_NOT_COMPLETE') + if (ts.length && !ts.every(t => t.done)) throw err('TASKS_NOT_COMPLETE') const c = o.conclusion || 'passed' - if (!['passed', 'blocked'].includes(c)) throw Error('INVALID_CONCLUSION') - if (o.knowledge && !['新增', '更新', '废弃', '无需变更'].includes(o.knowledge)) throw Error('INVALID_KNOWLEDGE') + if (!['passed', 'blocked'].includes(c)) throw err('INVALID_CONCLUSION') + if (o.knowledge && !['新增', '更新', '废弃', '无需变更'].includes(o.knowledge)) throw err('INVALID_KNOWLEDGE') b.data.review = { conclusion: c, verifiedCommit: x.verifiedCommit, verifiedAt: c === 'passed' ? new Date().toISOString() : b.data.review?.verifiedAt || null } b.data.knowledge = o.knowledge ? { action: o.knowledge, target: o.target || null, reason: o.reason || null } : null if (c === 'passed') b.data.status = 'reviewed' diff --git a/lib/domains/sync.mjs b/lib/domains/sync.mjs index 301e43c..5bff6ab 100644 --- a/lib/domains/sync.mjs +++ b/lib/domains/sync.mjs @@ -1,15 +1,15 @@ import { git, repo } from '../git.mjs' import { brief } from '../brief.mjs' import { name } from '../input.mjs' -import { ext } from '../errors.mjs' +import { ext, err } from '../errors.mjs' export async function planData(r, o) { const n = name(o), b = brief(r, n), q = repo(r) const dirty = q.changedFiles.filter(x => !x.startsWith('shadow-docs/')) - if (dirty.length) throw Error('DIRTY_WORKTREE') + if (dirty.length) throw err('DIRTY_WORKTREE') try { git(r, ['fetch', 'origin', '--prune'], { timeout: 120000 }) } catch { ext('GIT_FETCH_FAILED') } const up = `origin/${b.data.baseBranch || q.branch}`, to = git(r, ['rev-parse', up]) - try { git(r, ['merge-base', '--is-ancestor', q.head, to]) } catch { throw Error('SYNC_NOT_FAST_FORWARD') } + try { git(r, ['merge-base', '--is-ancestor', q.head, to]) } catch { throw err('SYNC_NOT_FAST_FORWARD') } return { name: n, from: q.head, to, upstream: up } } diff --git a/lib/domains/task.mjs b/lib/domains/task.mjs index 3566682..404d80a 100644 --- a/lib/domains/task.mjs +++ b/lib/domains/task.mjs @@ -1,5 +1,6 @@ import { brief, write, tasks } from '../brief.mjs' import { name, confirm } from '../input.mjs' +import { err } from '../errors.mjs' export function list(r, o) { return { tasks: tasks(brief(r, name(o)).body) } @@ -8,13 +9,13 @@ export function list(r, o) { export function set(r, o) { confirm(o) const b = brief(r, name(o)), n = Number(String(o.task || '').replace('task-', '')) - if (!Number.isInteger(n) || !['todo', 'done'].includes(o.state)) throw Error('INVALID_TASK') + if (!Number.isInteger(n) || !['todo', 'done'].includes(o.state)) throw err('INVALID_TASK') let i = 0 b.body = b.body.replace(/^(\s*- \[)([ xX])(\]\s+.+)$/gm, (m, x, y, z) => { i++ return i === n ? `${x}${o.state === 'done' ? 'x' : ' '}${z}` : m }) - if (i < n) throw Error('TASK_NOT_FOUND') + if (i < n) throw err('TASK_NOT_FOUND') if (b.data.status === 'draft') b.data.status = 'implementing' write(b) return { task: `task-${n}`, state: o.state } diff --git a/lib/errors.mjs b/lib/errors.mjs index d47571e..1045f33 100644 --- a/lib/errors.mjs +++ b/lib/errors.mjs @@ -1 +1,9 @@ -export function ext(c, m = c) { throw Object.assign(Error(m), { code: c, status: 3 }) } +// 统一错误构造:code 为稳定机器契约(禁止本地化/随意改名),status 为退出码语义—— +// 1 校验或内部失败,2 缺少确认/plan-hash 凭证,3 外部依赖失败(git/API),4 越界的文件操作请求 +export function err(c, m = c, s = 1) { + return Object.assign(Error(m), { code: c, status: s }) +} + +export function ext(c, m = c) { + throw err(c, m, 3) +} diff --git a/lib/git.mjs b/lib/git.mjs index db78645..7cd8e3d 100644 --- a/lib/git.mjs +++ b/lib/git.mjs @@ -1,11 +1,12 @@ import { execFileSync } from 'node:child_process' +import { err } from './errors.mjs' export function git(r, a, o = {}) { return execFileSync('git', a, { cwd: r, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...o }).trim() } export function root() { - try { return git(process.cwd(), ['rev-parse', '--show-toplevel']) } catch { throw Error('NOT_GIT_REPOSITORY') } + try { return git(process.cwd(), ['rev-parse', '--show-toplevel']) } catch { throw err('NOT_GIT_REPOSITORY') } } export function repo(r) { diff --git a/lib/github.mjs b/lib/github.mjs index 9a25332..c4f1fac 100644 --- a/lib/github.mjs +++ b/lib/github.mjs @@ -1,7 +1,7 @@ import { request } from 'node:http' import { request as requestHttps } from 'node:https' import { git } from './git.mjs' -import { ext } from './errors.mjs' +import { ext, err } from './errors.mjs' export function token() { const t = process.env.GITHUB_TOKEN || process.env.GH_TOKEN @@ -23,12 +23,12 @@ export function api(path, init = {}) { clearTimeout(timer) let parsed = {} try { parsed = raw ? JSON.parse(raw) : {} } catch {} - if (res.statusCode < 200 || res.statusCode >= 300) return reject(Object.assign(Error(parsed.message || `HTTP ${res.statusCode}`), { code: 'GITHUB_API_ERROR', status: 3 })) + if (res.statusCode < 200 || res.statusCode >= 300) return reject(err('GITHUB_API_ERROR', parsed.message || `HTTP ${res.statusCode}`, 3)) resolve(parsed) }) }) const timer = setTimeout(() => req.destroy(new Error('API_TIMEOUT')), ms) - req.on('error', error => { clearTimeout(timer); reject(Object.assign(Error(error.message), { code: error.message === 'API_TIMEOUT' ? 'API_TIMEOUT' : 'GITHUB_API_ERROR', status: 3 })) }) + req.on('error', error => { clearTimeout(timer); reject(err(error.message === 'API_TIMEOUT' ? 'API_TIMEOUT' : 'GITHUB_API_ERROR', error.message, 3)) }) if (body) req.write(body) req.end() }) @@ -41,14 +41,14 @@ export function repository(b, r) { try { u = git(r, ['remote', 'get-url', 'origin']) } catch {} const m = u && u.match(/github\.com[:/](.+?)(?:\.git)?\/?$/) if (m) return m[1] - if (u) throw Object.assign(Error(`GITHUB_REPOSITORY_REQUIRED: origin (${u}) is not a GitHub remote; set --repository or brief.github.repository`), { code: 'GITHUB_REPOSITORY_REQUIRED' }) + if (u) throw err('GITHUB_REPOSITORY_REQUIRED', `GITHUB_REPOSITORY_REQUIRED: origin (${u}) is not a GitHub remote; set --repository or brief.github.repository`) } - throw Error('GITHUB_REPOSITORY_REQUIRED') + throw err('GITHUB_REPOSITORY_REQUIRED') } export async function pr(b, r) { const n = Number(b.data.github?.pullRequest) - if (!n) throw Error('PULL_REQUEST_REQUIRED') + if (!n) throw err('PULL_REQUEST_REQUIRED') return api(`/repos/${repository(b, r)}/pulls/${n}`) } @@ -59,6 +59,6 @@ export async function ensurePr(x) { const z = await api(`/repos/${x.repository}/pulls`, { method: 'POST', body: JSON.stringify({ title: x.title, body: x.body, head: x.branch, base: x.baseBranch }) }) return { pr: z, created: true } } catch (e) { - throw Object.assign(Error(`PR_CREATE_FAILED: ${e.message}`), { code: 'PR_CREATE_FAILED', status: 3 }) + throw err('PR_CREATE_FAILED', `PR_CREATE_FAILED: ${e.message}`, 3) } } diff --git a/lib/input.mjs b/lib/input.mjs index e7614de..5c8c2a4 100644 --- a/lib/input.mjs +++ b/lib/input.mjs @@ -1,12 +1,13 @@ import { existsSync, readFileSync } from 'node:fs' +import { err } from './errors.mjs' export function confirm(o, ph = false) { - if (!o.confirm) throw Object.assign(Error('CONFIRMATION_REQUIRED'), { status: 2 }) - if (ph && !o['plan-hash']) throw Object.assign(Error('PLAN_HASH_REQUIRED'), { status: 2 }) + if (!o.confirm) throw err('CONFIRMATION_REQUIRED', 'CONFIRMATION_REQUIRED', 2) + if (ph && !o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2) } export function name(o) { - if (!o.name) throw Error('NAME_REQUIRED') + if (!o.name) throw err('NAME_REQUIRED') return o.name } @@ -18,8 +19,8 @@ export function fileList(v) { export function readBody(o, n) { if (!o['body-file']) return `\n# ${n}\n\n## 任务\n\n` const p = o['body-file'] - if (!existsSync(p)) throw Error('BODY_FILE_NOT_FOUND') + if (!existsSync(p)) throw err('BODY_FILE_NOT_FOUND') const c = readFileSync(p, 'utf8').trim() - if (!c) throw Error('BODY_FILE_EMPTY') + if (!c) throw err('BODY_FILE_EMPTY') return `\n${c}\n` } diff --git a/lib/steps.mjs b/lib/steps.mjs new file mode 100644 index 0000000..0c3036b --- /dev/null +++ b/lib/steps.mjs @@ -0,0 +1,25 @@ +import { git } from './git.mjs' +import { ext, err } from './errors.mjs' +import { ensurePr } from './github.mjs' + +// 共享 git 提交步骤:显式文件边界校验、提交并回写 brief 状态;write(b) 由调用方负责 +export function commitStep(r, x, b) { + if (!x.message || !x.files.length) throw err('COMMIT_INPUT_REQUIRED') + if (x.files.some(f => ['.', '-A', '--all'].includes(f) || f.startsWith('../') || f.startsWith('/'))) throw err('UNSUPPORTED_OPERATION', 'UNSUPPORTED_OPERATION', 4) + git(r, ['add', '--', ...x.files]) + git(r, ['commit', '-m', x.message]) + b.data.status = 'committed' + b.data.workflow.checkpoint = git(r, ['rev-parse', 'HEAD']) + return b.data.workflow.checkpoint +} + +// 共享发布步骤:推送分支并创建或复用 PR +export async function pushAndOpenPr(r, x, b) { + try { git(r, ['push', '-u', 'origin', x.branch], { timeout: 120000 }) } catch { ext('GIT_PUSH_FAILED') } + const { pr: z, created } = await ensurePr(x) + b.data.github.pullRequest = z.number + b.data.github.pullRequestUrl = z.html_url + b.data.status = 'published' + b.data.workflow.checkpoint = `pr:${z.number}` + return { number: z.number, url: z.html_url, created } +} diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index 518f965..f5d7a8c 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -4,7 +4,7 @@ "name": "20260917-refactor-compat-and-domain-convergence", "type": "refactor", "scope": "lib", - "status": "branched", + "status": "committed", "baseBranch": "main", "branch": "refactor/20260917-refactor-compat-and-domain-convergence", "files": [ @@ -39,8 +39,8 @@ }, "workflow": { "operation": null, - "checkpoint": "issue:1", - "planHash": "f4a230383bc3cb5fc6eac11d1f91ab32e3bfbeae1a76916687ef14d3d4adc53a", + "checkpoint": "798249728aafe283b89dde41e82ee7b9ba48f2d6", + "planHash": "87558c9262f2572ccdfdfc8e1f0353e3d2f2b56f903505e8576affdabf3d574f", "updatedAt": null, "lastError": null, "issuePlan": { @@ -87,10 +87,10 @@ ### Phase 2 — 架构收敛 -- [ ] 提取共享步骤模块:`commitStep(r, x)`、`pushAndOpenPr(r, x, b)`,commit/publish/release 三域改为复用 —— `lib/steps.mjs` — `lib/domains/commit.mjs` `lib/domains/publish.mjs` `lib/domains/release.mjs` -- [ ] plan 回写领域化:各域可选导出 `persistPlan(e, b)`,`planDomain` 改为 `mod.persistPlan?.(e, b)`,删除 `d === 'release'` / `d === 'issue'` 硬编码 —— `cli.mjs` — `lib/domains/release.mjs` `lib/domains/issue.mjs` -- [ ] index 并入统一通道:改造为 DOMAINS 成员(planData/execute + persistPlan),hash 校验语义与 executeDomain 对齐,保持 `index rebuild plan|execute` 外部契约不变 —— `cli.mjs` — `lib/domains/index.mjs` -- [ ] 错误构造统一:`lib/errors.mjs` 提供 `err(code, {status})`,全域错误改经它构造;错误码与退出码 1/2/3/4 语义文档化 —— `lib/errors.mjs` — `README.md` +- [x] 提取共享步骤模块:`commitStep(r, x)`、`pushAndOpenPr(r, x, b)`,commit/publish/release 三域改为复用 —— `lib/steps.mjs` — `lib/domains/commit.mjs` `lib/domains/publish.mjs` `lib/domains/release.mjs` +- [x] plan 回写领域化:各域可选导出 `persistPlan(e, b)`,`planDomain` 改为 `mod.persistPlan?.(e, b)`,删除 `d === 'release'` / `d === 'issue'` 硬编码 —— `cli.mjs` — `lib/domains/release.mjs` `lib/domains/issue.mjs` +- [x] index 并入统一通道:改造为 DOMAINS 成员(planData/execute + persistPlan),hash 校验语义与 executeDomain 对齐,保持 `index rebuild plan|execute` 外部契约不变 —— `cli.mjs` — `lib/domains/index.mjs` +- [x] 错误构造统一:`lib/errors.mjs` 提供 `err(code, {status})`,全域错误改经它构造;错误码与退出码 1/2/3/4 语义文档化 —— `lib/errors.mjs` — `README.md` ### Phase 3 — 回归与文档 From f5a3dd43649666b2be2b49a7c04b0e9b2e051575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:38:39 +0800 Subject: [PATCH 3/7] docs(readme): document platform compat and --json param; fix(pack): relative tar paths for Git Bash --- README.md | 10 ++++++++-- scripts/pack.mjs | 17 ++++++++++++----- .../brief.md | 10 +++++----- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3f94701..e0a22ba 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,13 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute | `archive plan\|execute` | 归档已合并变更并重建 INDEX | | `index rebuild plan\|execute` | 重建变更索引 | -所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。 +所有输出为单行 JSON:成功 `{"ok":true,"command":...,"data":...}`,失败 `{"ok":false,"error":{"code","message"}}`。`--json` 参数为历史兼容保留,接受即无操作(输出恒为 JSON)。 + +## 平台兼容 + +- `--files` 路径参数接受 Windows 反斜杠写法(如 `lib\a.mjs`),自动归一为正斜杠并与 git 状态、conflict 比对对齐。 +- `brief.md` 解析容忍 LF/CRLF(兼容 Windows `core.autocrlf` 检出与手工编辑),CLI 写回一律统一为 LF。 +- git fetch/push 带 120 秒超时,避免凭据弹窗导致的永久挂起;GitHub API 超时见下方环境变量。 ## 退出码 @@ -47,7 +53,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute ## 开发 ```bash -npm test # node --test,35 个契约测试覆盖全部命令域 +npm test # node --test,38 个契约测试覆盖全部命令域 ``` 行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。 diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 04e3cbc..b3b9619 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node // 打包发布产物:dist/shadow-dev-cli-v.tar.gz,解包得到 shadow-dev-cli/ 目录 import { execFileSync } from 'node:child_process' -import { cpSync, mkdirSync, rmSync } from 'node:fs' +import { cpSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const root = dirname(dirname(fileURLToPath(import.meta.url))) -const { version } = JSON.parse(await import('node:fs').then(fs => fs.readFileSync(join(root, 'package.json'), 'utf8'))) +const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) const dist = join(root, 'dist') const staging = join(dist, 'staging') @@ -16,6 +16,13 @@ for (const f of ['cli.mjs', 'lib', 'package.json', 'README.md', 'LICENSE']) { cpSync(join(root, f), join(staging, 'shadow-dev-cli', f), { recursive: true }) } const artifact = join(dist, `shadow-dev-cli-v${version}.tar.gz`) -execFileSync('tar', ['-czf', artifact, '-C', staging, 'shadow-dev-cli']) -rmSync(staging, { recursive: true, force: true }) -console.log(artifact) +try { + // tar 必须用相对路径:Git Bash 的 tar 会把 "D:\..." 解析成"远程主机:路径" + execFileSync('tar', ['-czf', `dist/shadow-dev-cli-v${version}.tar.gz`, '-C', 'dist/staging', 'shadow-dev-cli'], { cwd: root }) + rmSync(staging, { recursive: true, force: true }) + console.log(artifact) +} catch { + rmSync(dist, { recursive: true, force: true }) + console.error('pack failed: system `tar` is required on PATH (Windows 10+ ships tar.exe natively; otherwise run from Git Bash)') + process.exitCode = 1 +} diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index f5d7a8c..1d99029 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -39,8 +39,8 @@ }, "workflow": { "operation": null, - "checkpoint": "798249728aafe283b89dde41e82ee7b9ba48f2d6", - "planHash": "87558c9262f2572ccdfdfc8e1f0353e3d2f2b56f903505e8576affdabf3d574f", + "checkpoint": "10158ef3266ed0f6db122f9f75b73b3251ba82ff", + "planHash": "4ba7c96d4827ea6037e95398a78dbc898135d008a6f08367b18ed7613171a8a5", "updatedAt": null, "lastError": null, "issuePlan": { @@ -94,9 +94,9 @@ ### Phase 3 — 回归与文档 -- [ ] 新增测试:CRLF brief 读-改-写 round-trip、反斜杠 `--files` 与 conflict 交集匹配、rename 行 changedFiles 结果 —— `test/cli.test.mjs` -- [ ] pack.mjs 清理冗余动态 import;`tar` 缺失时给出明确报错提示 —— `scripts/pack.mjs` -- [ ] README 补命令总览、退出码表、`--json` 兼容参数说明;35 项存量测试全量回归 —— `README.md` — `test/cli.test.mjs` +- [x] 新增测试:CRLF brief 读-改-写 round-trip、反斜杠 `--files` 与 conflict 交集匹配、rename 行 changedFiles 结果 —— `test/cli.test.mjs` +- [x] pack.mjs 清理冗余动态 import;`tar` 缺失时给出明确报错提示 —— `scripts/pack.mjs` +- [x] README 补命令总览、退出码表、`--json` 兼容参数说明;35 项存量测试全量回归 —— `README.md` — `test/cli.test.mjs` ## 结果 From 0c221d14e6a69262f6de78e58a5c4b187bd463c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:38:58 +0800 Subject: [PATCH 4/7] refactor(errors): route brief.mjs errors through err() (missed in previous checkpoint) --- lib/brief.mjs | 7 ++++--- .../brief.md | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/brief.mjs b/lib/brief.mjs index f3181b6..c331744 100644 --- a/lib/brief.mjs +++ b/lib/brief.mjs @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { err } from './errors.mjs' export const ap = (r, n) => join(r, 'shadow-docs', 'changes', n, 'brief.md') export const xp = (r, n) => join(r, 'shadow-docs', 'changes', 'archive', n, 'brief.md') @@ -7,11 +8,11 @@ export const xp = (r, n) => join(r, 'shadow-docs', 'changes', 'archive', n, 'bri // 读取容忍 LF/CRLF(Windows core.autocrlf 检出与手工编辑),写回恒为 LF export function brief(r, n, arch = false) { const path = arch ? xp(r, n) : ap(r, n) - if (!existsSync(path)) throw Error('BRIEF_NOT_FOUND') + if (!existsSync(path)) throw err('BRIEF_NOT_FOUND') const t = readFileSync(path, 'utf8'), open = /^---\r?\n/.exec(t) - if (!open) throw Error('BRIEF_FRONTMATTER_REQUIRED') + if (!open) throw err('BRIEF_FRONTMATTER_REQUIRED') const s = open[0].length, e = /\r?\n---\r?\n/.exec(t.slice(s)) - if (!e) throw Error('BRIEF_FRONTMATTER_REQUIRED') + if (!e) throw err('BRIEF_FRONTMATTER_REQUIRED') return { path, data: JSON.parse(t.slice(s, s + e.index)), body: t.slice(s + e.index + e[0].length) } } diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index 1d99029..98ff1d2 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -39,8 +39,8 @@ }, "workflow": { "operation": null, - "checkpoint": "10158ef3266ed0f6db122f9f75b73b3251ba82ff", - "planHash": "4ba7c96d4827ea6037e95398a78dbc898135d008a6f08367b18ed7613171a8a5", + "checkpoint": "f5a3dd43649666b2be2b49a7c04b0e9b2e051575", + "planHash": "836e507cb403fad1148cd06e184e2e14c0bdc1ad1298a2f1d901c76bb8a2a3e2", "updatedAt": null, "lastError": null, "issuePlan": { From e23e0a82417b84c9a52a32a22ded61cb60a03b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:42:33 +0800 Subject: [PATCH 5/7] refactor(cli): clarify plan-hash validation branch (review finding) --- cli.mjs | 5 ++++- .../brief.md | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cli.mjs b/cli.mjs index 0a44618..220ea4a 100755 --- a/cli.mjs +++ b/cli.mjs @@ -33,7 +33,10 @@ async function executeDomain(c, mod, r, o) { return mod.execute(r, o, e.data) } const b = brief(r, name(o)) - if (b.data.workflow.planHash !== e.planHash) throw err(b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', b.data.workflow.planHash ? 1 : 2) + if (b.data.workflow.planHash !== e.planHash) { + const code = b.data.workflow.planHash ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED' + throw err(code, code, b.data.workflow.planHash ? 1 : 2) + } return mod.execute(r, o, e.data, b) } diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index 98ff1d2..12a2b99 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -39,8 +39,8 @@ }, "workflow": { "operation": null, - "checkpoint": "f5a3dd43649666b2be2b49a7c04b0e9b2e051575", - "planHash": "836e507cb403fad1148cd06e184e2e14c0bdc1ad1298a2f1d901c76bb8a2a3e2", + "checkpoint": "0c221d14e6a69262f6de78e58a5c4b187bd463c2", + "planHash": "0537bf0c92920057c535f2985483ffdf66483996d4207803b1dc4d7ec04e84bf", "updatedAt": null, "lastError": null, "issuePlan": { @@ -100,8 +100,8 @@ ## 结果 -- 实际耗时: — -- 验证: — +- 实际耗时: 约 50 分钟 +- 验证: `node --test` 38/38 通过(35 存量 + 3 新增兼容契约);全部模块 `node --check` 通过;`scripts/pack.mjs` 实测产出 tar 并验证内容清单(顺带发现并修复 Git Bash 下 `D:\` 被 tar 误判为远程主机路径的真实缺陷);review 阶段修复 executeDomain 三元可读性问题后全量回归保持绿色。 ## 知识评估 From b5350b4b3d03758615241138f6c6a4ed41a8d99d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:52:36 +0800 Subject: [PATCH 6/7] docs(knowledge): add brief frontmatter CRLF contract card and project menu route --- .../brief.md | 17 ++++++---- .../knowledge/brief-frontmatter-crlf.md | 34 +++++++++++++++++++ shadow-docs/menu.md | 9 +++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 shadow-docs/knowledge/brief-frontmatter-crlf.md create mode 100644 shadow-docs/menu.md diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index 12a2b99..febff4e 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -4,7 +4,7 @@ "name": "20260917-refactor-compat-and-domain-convergence", "type": "refactor", "scope": "lib", - "status": "committed", + "status": "reviewed", "baseBranch": "main", "branch": "refactor/20260917-refactor-compat-and-domain-convergence", "files": [ @@ -33,14 +33,14 @@ "pullRequestUrl": null }, "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null + "conclusion": "passed", + "verifiedCommit": "e23e0a82417b84c9a52a32a22ded61cb60a03b89", + "verifiedAt": "2026-09-17T02:42:43.835Z" }, "workflow": { "operation": null, - "checkpoint": "0c221d14e6a69262f6de78e58a5c4b187bd463c2", - "planHash": "0537bf0c92920057c535f2985483ffdf66483996d4207803b1dc4d7ec04e84bf", + "checkpoint": "e23e0a82417b84c9a52a32a22ded61cb60a03b89", + "planHash": "22852f65a368780a83ff718ed7aec1cc1bb48c867f384918841ad3559f05e854", "updatedAt": null, "lastError": null, "issuePlan": { @@ -50,6 +50,11 @@ "refactor" ] } + }, + "knowledge": { + "action": "新增", + "target": "shadow-docs/knowledge/brief-frontmatter-crlf.md", + "reason": "CRLF 容忍读取/LF 统一写回是实证出的 brief 文件格式契约,触碰 brief 读写的所有变更必须遵守" } } --- diff --git a/shadow-docs/knowledge/brief-frontmatter-crlf.md b/shadow-docs/knowledge/brief-frontmatter-crlf.md new file mode 100644 index 0000000..da04a77 --- /dev/null +++ b/shadow-docs/knowledge/brief-frontmatter-crlf.md @@ -0,0 +1,34 @@ +--- +title: brief.md frontmatter 行尾契约 +domain: cli-infrastructure +keywords: [brief, frontmatter, CRLF, 行尾, autocrlf, BRIEF_FRONTMATTER_REQUIRED] +scope: [lib/brief.mjs, shadow-docs/changes] +status: active +source: + - changes/20260917-refactor-compat-and-domain-convergence/brief.md +verified: 2026-09-17 +--- + +# brief.md frontmatter 行尾契约 + +## 当前结论 + +brief 解析器对行尾**只读容忍、写必统一**:读取按 `\r?\n` 匹配 frontmatter 定界(兼容 Windows `core.autocrlf=true` 检出与手工编辑产生的 CRLF),写回一律输出 LF(含正文 `body` 的 CRLF→LF 归一)。frontmatter 的 JSON 值经 `JSON.parse` 后与行尾无关,因此 planHash 在 LF/CRLF 等价文件上一致。 + +## 执行约束 + +- 任何触碰 `lib/brief.mjs` 读写的变更必须保持「读容忍 CRLF、写恒 LF」,禁止把定界匹配改回仅 `\n---\n`。 +- 新增 brief 相关解析/生成逻辑时,测试必须覆盖 CRLF 文件的读-改-写 round-trip(契约测试 `brief parsing tolerates CRLF and writes back LF`)。 +- CLI 之外手工生成或转换 brief 时不依赖写侧归一——归一只发生在 `write()` 落盘路径。 + +## 适用边界 + +适用于所有由 shadow-dev CLI 读写的 `shadow-docs/changes/**/brief.md`。不适用于技能文档等自由格式 markdown(其行尾不受 CLI 解析约束)。 + +## 验证方式 + +`node --test test/cli.test.mjs` 全绿即契约成立;单点复验:将 LF brief 全文替换为 CRLF 后执行 `task list --name `,应返回 `ok:true`,随后一次带 `--confirm` 的写入命令应使文件回到纯 LF。 + +## 关联知识 + +- 无 diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md new file mode 100644 index 0000000..578d611 --- /dev/null +++ b/shadow-docs/menu.md @@ -0,0 +1,9 @@ +# shadow-dev-cli 项目菜单 + +> 项目级 Knowledge 路由,叠加在通用 `menu.md` 之上。 + +## 路由规则 + +| 技术域 | 关键词 | 应查阅 | +|--------|--------|--------| +| brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | From a0481a68dd6ae5cce0da7e89b867030d0bb6ea75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 10:52:54 +0800 Subject: [PATCH 7/7] chore(shadow-docs): checkpoint brief state (review b5350b4, feature issue 2) --- .../20260917-feature-human-cli-ux/brief.md | 99 +++++++++++++++++++ .../brief.md | 8 +- 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 shadow-docs/changes/20260917-feature-human-cli-ux/brief.md diff --git a/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md new file mode 100644 index 0000000..a593b7d --- /dev/null +++ b/shadow-docs/changes/20260917-feature-human-cli-ux/brief.md @@ -0,0 +1,99 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-feature-human-cli-ux", + "type": "feature", + "scope": "cli.mjs,lib", + "status": "proposed", + "baseBranch": "main", + "branch": null, + "files": [ + "README.md", + "cli.mjs", + "lib/args.mjs", + "lib/commands.mjs", + "lib/human.mjs", + "lib/i18n.mjs", + "lib/input.mjs", + "lib/output.mjs", + "test/cli.test.mjs" + ], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 2, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/2", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:2", + "planHash": "8d398b8b084c221c2d9d6c858a7e13a2daf4130dbef0f6ff6161c34ce2f31770", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "人用输出层:进出场提示、入参提示、结构化 help 与 zh/en 切换", + "body": "在不改动 stdout JSON 契约的前提下为 CLI 增加人用输出层:stderr 渲染进出场横幅、入参示例、错误本地化解释与 help 人读版;语言按 --lang > SHADOW_DEV_LANG > locale > zh 解析,SHADOW_DEV_QUIET 可关;nextStep 以稳定 key 写入 JSON 供 agent 消费。依赖 20260917-refactor-compat-and-domain-convergence 的 Phase 2。详见 shadow-docs/changes/20260917-feature-human-cli-ux/brief.md", + "labels": [ + "feature" + ] + } + } +} +--- + +# 人用输出层:进出场提示、入参提示、结构化 help 与 zh/en 切换 + +## 动机 + +CLI 当前是纯机器契约界面:无进出场反馈(44 秒的 release 全程静默)、help 是一行命令串、漏参只回 `NAME_REQUIRED` 不提示怎么传、无任何中文层。目标是在**零破坏 stdout JSON 契约**的前提下补上人用体验层。本变更依赖 `20260917-refactor-compat-and-domain-convergence` 的 Phase 2(错误构造统一 `err(code, {status})`、路由领域钩子),apply 顺序为先完成该变更 Phase 1/2。 + +## 引用规范 + +- norms/code-style.md(通用规范) + - 当前结论: 渐进式治理,一次变更只做直接相关的事;公共能力从稳定公开入口导出;不顺手重写无关代码。 + - 适用 scope: `cli.mjs`、`lib/` +- 架构决策(本 brief 确立,待沉淀卡片) + - 当前结论: stdout 为纯 JSON 机器契约,人类可读输出一律走 stderr;错误 code 不本地化,仅提示文案本地化。 + - 适用 scope: 全仓 + +## 决策 + +- **选型:** 方案 A——stderr 人用提示层。语言解析 `--lang zh|en` > `SHADOW_DEV_LANG` > 系统 locale 自动探测 > 默认 zh;提示可经 `SHADOW_DEV_QUIET=1` 关闭。下一步建议同时以 `data.nextStep`(稳定 key)additive 写入 JSON,agent 消费者白赚引导。 +- **对比方案:** 方案 B(TTY 探测双模式)使输出依赖运行环境,违背 deterministic 定位,且 agent harness 偶发 PTY 会撕裂契约;方案 C(JSON message 本地化)污染契约流,威胁 review gate 等文本匹配。均否。 +- **理由:** 双受众分层是本工具的根设计:机器读 stdout、人读 stderr,两渠道内容同源(命令目录驱动),无双份事实。help JSON 保持向后兼容(保留原字符串字段,新增结构化 commands)。颜色、TTY 适配为非目标。 + +## 任务 + +### Phase 1 — 事实源与语言基础(依赖 refactor Phase 2 完成) + +- [ ] 命令目录 `lib/commands.mjs`:13 个命令组的 usage/参数(名称/必填/说明)/示例/nextStep 结构化定义,HELP 字符串由目录派生 —— `lib/commands.mjs` `lib/args.mjs` +- [ ] i18n 词典 `lib/i18n.mjs`:zh/en 消息集 + 语言解析链(--lang > SHADOW_DEV_LANG > locale > zh),非法 --lang 报 usage 提示 —— `lib/i18n.mjs` `lib/args.mjs` + +### Phase 2 — 人用层渲染与接线 + +- [ ] `lib/human.mjs`:stderr 渲染器——进场横幅(命令+关键参数)、收场(✅ 结果摘要+耗时)、错误(code 本地化解释+该命令 usage 示例,参数表从命令目录派生)、`SHADOW_DEV_QUIET` 抑制 —— `lib/human.mjs` +- [ ] `cli.mjs` 接线:handle 出口统一挂进出场/错误渲染;成功结果按目录追加 `data.nextStep`(含参数化建议,如 approve 后提示 `branch plan --name `) —— `cli.mjs` `lib/output.mjs` +- [ ] help 升级:`help` 返回保留旧字符串字段并新增结构化 commands;`help ` 单命令详情(stdout JSON、stderr 人读版) —— `cli.mjs` `lib/commands.mjs` +- [ ] 漏参提示:全部验证错误(NAME_REQUIRED/CONFIRMATION_REQUIRED/PLAN_HASH_*/BRIEF_NOT_FOUND 等)的人用输出附带期望参数与示例 —— `lib/input.mjs` `lib/human.mjs` + +### Phase 3 — 回归与文档 + +- [ ] 契约测试:同命令在 `--lang zh`/`--lang en`/无 lang 下 stdout JSON 逐字节一致(nextStep 为稳定 key 非译文);stderr 含对应语言提示;`SHADOW_DEV_QUIET=1` 时 stderr 无输出;help 子命令断言 —— `test/cli.test.mjs` +- [ ] README:语言切换配置、stderr 人用层与 nextStep 说明、help 示例(与 refactor 变更的退出码表合流成稿) —— `README.md` + +## 结果 + +- 实际耗时: — +- 验证: — + +## 知识评估 + +- **预期影响:** 新增 +- **候选卡片:** shadow-docs/knowledge/cli-output-contract.md(domain: cli-infrastructure,scope: cli.mjs, lib/output.mjs, lib/human.mjs) +- **理由:** 「stdout 纯 JSON、人用输出走 stderr、code 不本地化」是分层根决策,后续任何输出面改动都必须遵守,属非显然约束,需沉淀防回归。 diff --git a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md index febff4e..f685262 100644 --- a/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md +++ b/shadow-docs/changes/20260917-refactor-compat-and-domain-convergence/brief.md @@ -34,13 +34,13 @@ }, "review": { "conclusion": "passed", - "verifiedCommit": "e23e0a82417b84c9a52a32a22ded61cb60a03b89", - "verifiedAt": "2026-09-17T02:42:43.835Z" + "verifiedCommit": "b5350b4b3d03758615241138f6c6a4ed41a8d99d", + "verifiedAt": "2026-09-17T02:52:37.604Z" }, "workflow": { "operation": null, - "checkpoint": "e23e0a82417b84c9a52a32a22ded61cb60a03b89", - "planHash": "22852f65a368780a83ff718ed7aec1cc1bb48c867f384918841ad3559f05e854", + "checkpoint": "b5350b4b3d03758615241138f6c6a4ed41a8d99d", + "planHash": "5651a9f270dc6328ab5439405896b18a180a7ebbdfd40981d7e09cb1d3c216a2", "updatedAt": null, "lastError": null, "issuePlan": {