@house365/comate 0.10.2 → 1.0.2-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +104 -64
  3. package/bin/cli.mjs +197 -190
  4. package/bin/doctor.mjs +29 -41
  5. package/bin/init-run.mjs +310 -368
  6. package/bin/init.mjs +53 -71
  7. package/bin/postinstall.mjs +56 -56
  8. package/docs/CHANGELOG-fix-simplification.md +145 -0
  9. package/docs/COMATE_BETA.md +90 -0
  10. package/docs/COMATE_MANUAL.md +239 -665
  11. package/docs/superpowers/plans/2026-10-14-review-fixes.md +48 -0
  12. package/docs/superpowers/plans/2026-10-14-workflow-quality.md +48 -0
  13. package/package.json +54 -55
  14. package/skills/comate/SKILL.md +42 -325
  15. package/skills/comate/reference/archive.md +50 -0
  16. package/skills/comate/reference/build.md +67 -0
  17. package/skills/comate/reference/fix.md +150 -0
  18. package/skills/comate/reference/open.md +162 -0
  19. package/skills/comate/reference/verify.md +123 -0
  20. package/skills/comate/scaffold/comate/README.md +18 -0
  21. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/architecture/README.md +5 -6
  22. package/skills/comate/scaffold/comate/comate/config.json +10 -0
  23. package/skills/comate/scaffold/comate/knowledge-base/README.md +42 -0
  24. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/README.md +2 -2
  25. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +47 -47
  26. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/requirements/_templates/PRD.md +4 -4
  27. package/skills/comate/scripts/comate.mjs +147 -0
  28. package/skills/comate/scripts/lib/acceptance.mjs +112 -0
  29. package/skills/comate/scripts/lib/brief.mjs +50 -0
  30. package/skills/comate/scripts/lib/checks.mjs +178 -0
  31. package/skills/comate/scripts/lib/cmd-archive.mjs +152 -0
  32. package/skills/comate/scripts/lib/cmd-decide.mjs +116 -0
  33. package/skills/comate/scripts/lib/cmd-init.mjs +75 -0
  34. package/skills/comate/scripts/lib/cmd-loop.mjs +273 -0
  35. package/skills/comate/scripts/lib/cmd-open.mjs +161 -0
  36. package/skills/comate/scripts/lib/cmd-status.mjs +121 -0
  37. package/skills/comate/scripts/lib/common.mjs +85 -0
  38. package/skills/comate/scripts/lib/docs.mjs +45 -0
  39. package/skills/comate/scripts/lib/finish.mjs +108 -0
  40. package/skills/comate/scripts/lib/git.mjs +112 -0
  41. package/skills/comate/scripts/lib/md.mjs +85 -0
  42. package/skills/comate/scripts/lib/next.mjs +217 -0
  43. package/skills/comate/scripts/lib/paths.mjs +96 -0
  44. package/skills/comate/scripts/lib/render.mjs +46 -0
  45. package/skills/comate/scripts/lib/spec.mjs +106 -0
  46. package/skills/comate/scripts/lib/state.mjs +128 -0
  47. package/skills/comate/scripts/lib/tasks.mjs +53 -0
  48. package/skills/comate/scripts/lib/templates.mjs +58 -0
  49. package/skills/comate/scripts/lib/util.mjs +108 -0
  50. package/skills/comate-kb-integration/SKILL.md +128 -128
  51. package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -84
  52. package/bin/cleanup-duplicate-superpowers.mjs +0 -119
  53. package/docs/CLEANUP_SUPERPOWERS.md +0 -128
  54. package/docs/COMATE_BEGINNER_GUIDE.md +0 -188
  55. package/docs/COMATE_INIT_DIRECTORY_GUIDE.md +0 -201
  56. package/docs/SUPERPOWERS_FIX_SUMMARY.md +0 -114
  57. package/skills/comate/reference/decision-points.md +0 -103
  58. package/skills/comate/reference/dirty-worktree.md +0 -58
  59. package/skills/comate/reference/prerequisites.md +0 -89
  60. package/skills/comate/scripts/comate-lib.sh +0 -559
  61. package/skills/comate/scripts/comate.sh +0 -2147
  62. package/skills/comate/tests/comate.bats +0 -1019
  63. package/skills/comate/tests/run.sh +0 -101
  64. package/skills/comate-archive/SKILL.md +0 -63
  65. package/skills/comate-build/SKILL.md +0 -278
  66. package/skills/comate-design/SKILL.md +0 -178
  67. package/skills/comate-fix/SKILL.md +0 -195
  68. package/skills/comate-gate/SKILL.md +0 -316
  69. package/skills/comate-gate/scaffold/docs/README.md +0 -36
  70. package/skills/comate-gate/scaffold/docs/knowledge-base/README.md +0 -47
  71. package/skills/comate-grill/SKILL.md +0 -185
  72. package/skills/comate-open/SKILL.md +0 -230
  73. package/skills/comate-verify/SKILL.md +0 -212
  74. /package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/README.md +0 -0
@@ -0,0 +1,161 @@
1
+ // new / open --prepare / open --confirm / rename / spec remove|restore
2
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { relative, resolve } from 'node:path';
4
+
5
+ import { collectAcceptance, checkCoverage } from './acceptance.mjs';
6
+ import { checkBrief, noBehaviorReason } from './brief.mjs';
7
+ import { loadConfig } from './checks.mjs';
8
+ import { enterBuild, load, save } from './common.mjs';
9
+ import { changeSpecList, computeDocHash, mainSpecText, sourceDrift } from './docs.mjs';
10
+ import { branchExists, currentBranch, defaultBranch, dirtyOutside, head } from './git.mjs';
11
+ import { archivedDir, changePaths, requireInitialized, validateName } from './paths.mjs';
12
+ import { freshLoop, newState, requireExpect, requireNode, saveState, setNode } from './state.mjs';
13
+ import { checkChangeSpec } from './spec.mjs';
14
+ import { BLOCK, USAGE, die, gitOk, hashFile, readText, toPosix } from './util.mjs';
15
+ import { briefTemplate } from './templates.mjs';
16
+
17
+ export function cmdNew(ctx, a) {
18
+ requireInitialized(ctx);
19
+ const name = a._[0];
20
+ validateName(name);
21
+ const mode = a.values.mode ?? 'full';
22
+ if (!['full', 'fix'].includes(mode)) die(USAGE, `--mode 只能是 full 或 fix,收到 '${mode}'`);
23
+ const cp = changePaths(ctx, name);
24
+ if (existsSync(cp.dir)) die(BLOCK, `change '${name}' 已存在:${cp.rel}`);
25
+ if (archivedDir(ctx, name)) die(BLOCK, `已有同名的归档 change(${archivedDir(ctx, name)}),换一个名称`);
26
+ if (!head(ctx)) die(BLOCK, '仓库还没有任何提交。先做一次初始提交再新建 change。');
27
+
28
+ const sources = a.sources.map((raw) => {
29
+ const abs = resolve(process.cwd(), raw);
30
+ const rel = toPosix(relative(ctx.root, abs));
31
+ if (rel.startsWith('..') || !existsSync(abs)) die(USAGE, `--source 必须是仓库内存在的文件:${raw}`);
32
+ return { path: rel, sha256: hashFile(abs) };
33
+ });
34
+
35
+ const cfg = loadConfig(ctx);
36
+ let branch = currentBranch(ctx);
37
+ if (!branch) die(BLOCK, '当前处于 detached HEAD,先切到一个分支。');
38
+ const baseBranch = a.values.base ?? (cfg.protected_branches.includes(branch) ? branch : defaultBranch(ctx) ?? branch);
39
+ if (a.values.base && !branchExists(ctx, a.values.base)) die(USAGE, `--base 分支不存在:${a.values.base}`);
40
+ if (cfg.protected_branches.includes(branch)) {
41
+ const dirty = dirtyOutside(ctx, []);
42
+ if (dirty.length) die(BLOCK, `当前在受保护分支 ${branch},需要新建 comate/${name},但工作区有未提交改动:\n ${dirty.slice(0, 10).join('\n ')}`);
43
+ const target = `comate/${name}`;
44
+ if (branchExists(ctx, target)) die(BLOCK, `分支 ${target} 已存在`);
45
+ gitOk(ctx.root, 'checkout', '-q', '-b', target, baseBranch);
46
+ branch = target;
47
+ }
48
+ const baseRef = gitOk(ctx.root, 'rev-parse', baseBranch === branch ? 'HEAD' : baseBranch);
49
+
50
+ mkdirSync(cp.specs, { recursive: true });
51
+ writeFileSync(cp.brief, briefTemplate(mode, sources.length > 0));
52
+ const st = newState({ name, mode, workspace: { branch, base_branch: baseBranch, base_ref: baseRef }, sources });
53
+ saveState(cp.state, st, 'new', `${mode} 模式,分支 ${branch}`);
54
+ return { cp, st, done: `已创建 ${cp.rel}(${mode},分支 ${branch},基线 ${baseBranch})` };
55
+ }
56
+
57
+ export function cmdOpen(ctx, a) {
58
+ const { cp, st } = load(ctx, a._[0]);
59
+ if (a.flags.prepare) return prepare(ctx, cp, st);
60
+ if (a.flags.confirm) return confirm(ctx, cp, st, a);
61
+ return die(USAGE, 'open 需要 --prepare 或 --confirm --expect <v>:confirm');
62
+ }
63
+
64
+ function prepare(ctx, cp, st) {
65
+ requireNode(st, ['Drafting', 'AwaitConfirm'], 'open --prepare');
66
+ const briefText = readText(cp.brief);
67
+ if (briefText === null) die(BLOCK, `找不到 ${cp.briefRel}`, { change: st.name });
68
+ const problems = checkBrief(briefText, st.mode);
69
+ const specs = changeSpecList(cp);
70
+ for (const { cap, text } of specs) problems.push(...checkChangeSpec(cap, text));
71
+
72
+ const removals = st.spec_targets.filter((t) => t.op === 'remove');
73
+ for (const r of removals) {
74
+ if (specs.some((s) => s.cap === r.capability)) problems.push(`${r.capability} 同时登记了删除又写了 change spec,二选一(spec restore 取消删除)`);
75
+ if (hashFile(ctx.abs(`${ctx.specsRel}/${r.capability}/spec.md`)) !== r.base_hash) problems.push(`主 spec ${r.capability} 在登记删除后被改动,重新 spec remove`);
76
+ }
77
+ if (specs.length === 0 && removals.length === 0 && !noBehaviorReason(briefText, st.mode) && st.mode === 'full') {
78
+ problems.push('没有任何 specs/<capability>/spec.md。有产品行为变化就写完整目标 spec;确实没有,在「# 范围」写一行「无行为变更:<具体理由>」');
79
+ }
80
+ problems.push(...sourceDrift(ctx, st).map((d) => `${d}(new 时钉住的版本已变,需求来源要重新确认)`));
81
+ if (problems.length) die(BLOCK, `open --prepare 未通过:\n - ${problems.join('\n - ')}`, { change: st.name });
82
+
83
+ const mainSpecs = {};
84
+ for (const cap of [...specs.map((s) => s.cap), ...removals.map((r) => r.capability)]) mainSpecs[cap] = mainSpecText(ctx, cap);
85
+ const { acceptance, removed } = collectAcceptance({ briefText, changeSpecs: specs, mainSpecs, removals: removals.map((r) => r.capability) });
86
+ if (acceptance.length === 0) die(BLOCK, '没有抽到任何验收项:「验收示例」写列表项,或在 spec 里写 Scenario', { change: st.name });
87
+ const cov = checkCoverage(briefText, acceptance, st.sources.length > 0);
88
+ if (cov.problems.length) die(BLOCK, `来源覆盖未通过:\n - ${cov.problems.join('\n - ')}`, { change: st.name });
89
+
90
+ st.spec_targets = [
91
+ ...specs.map(({ cap }) => {
92
+ const main = hashFile(ctx.abs(`${ctx.specsRel}/${cap}/spec.md`));
93
+ return { capability: cap, op: main ? 'replace' : 'create', base_hash: main };
94
+ }),
95
+ ...removals,
96
+ ];
97
+ Object.assign(st, { acceptance, removed_scenarios: removed, source_map: cov.sourceMap });
98
+ st.confirm = { doc_hash: computeDocHash(cp, st), at: null, skip_reason: null };
99
+
100
+ if (st.mode === 'fix') {
101
+ st.confirm.at = new Date().toISOString();
102
+ st.confirm.skip_reason = 'fix 模式:open --prepare 通过后免确认直接进入 build(范围超出小修时用 decide --upgrade 转 full)';
103
+ enterBuild(ctx, cp, st, 'prepare', `fix 直通 build,${acceptance.length} 项验收`);
104
+ return { cp, st, done: `fix 模式:${acceptance.length} 项验收已冻结,直接进入 build` };
105
+ }
106
+ setNode(st, 'AwaitConfirm');
107
+ save(cp, st, 'prepare', `${acceptance.length} 项验收待确认`);
108
+ return { cp, st, done: `校验通过,抽出 ${acceptance.length} 项验收(${acceptance.filter((x) => x.kind === 'regression').length} 项回归)` };
109
+ }
110
+
111
+ function confirm(ctx, cp, st, a) {
112
+ requireNode(st, ['AwaitConfirm'], 'open --confirm');
113
+ requireExpect(st, a.values.expect);
114
+ if (computeDocHash(cp, st) !== st.confirm.doc_hash) {
115
+ die(BLOCK, 'brief/spec 在 prepare 之后又改过,用户看到的摘要已过期。重跑 open --prepare 再请用户确认。', { change: st.name });
116
+ }
117
+ const drift = sourceDrift(ctx, st);
118
+ if (drift.length) die(BLOCK, `源文件变化:${drift.join(';')}`, { change: st.name });
119
+ st.confirm.at = new Date().toISOString();
120
+ enterBuild(ctx, cp, st, 'confirm', '用户确认需求与验收项');
121
+ return { cp, st, done: `需求已确认,${st.acceptance.length} 项验收冻结,进入 build` };
122
+ }
123
+
124
+ export function cmdRename(ctx, a) {
125
+ const [name, next] = a._;
126
+ const { cp, st } = load(ctx, name);
127
+ requireNode(st, ['Drafting', 'AwaitConfirm'], 'rename');
128
+ validateName(next);
129
+ const to = changePaths(ctx, next);
130
+ if (existsSync(to.dir) || archivedDir(ctx, next)) die(BLOCK, `change '${next}' 已存在`);
131
+ renameSync(cp.dir, to.dir);
132
+ st.name = next;
133
+ if (st.workspace.branch === `comate/${name}` && currentBranch(ctx) === `comate/${name}` && !branchExists(ctx, `comate/${next}`)) {
134
+ gitOk(ctx.root, 'branch', '-m', `comate/${name}`, `comate/${next}`);
135
+ st.workspace.branch = `comate/${next}`;
136
+ }
137
+ if (st.phase === 'open' && st.step === 'confirm') st.confirm.doc_hash = computeDocHash(to, st);
138
+ saveState(to.state, st, 'rename', `${name} -> ${next}`);
139
+ return { cp: to, st, done: `已改名 ${name} -> ${next}` };
140
+ }
141
+
142
+ export function cmdSpec(ctx, a) {
143
+ const [sub, name, cap] = a._;
144
+ if (!['remove', 'restore'].includes(sub) || !cap) die(USAGE, '用法:spec remove|restore <name> <capability>');
145
+ const { cp, st } = load(ctx, name);
146
+ requireNode(st, ['Drafting', 'AwaitConfirm'], `spec ${sub}`);
147
+ const has = st.spec_targets.some((t) => t.op === 'remove' && t.capability === cap);
148
+ if (sub === 'remove') {
149
+ const h = hashFile(ctx.abs(`${ctx.specsRel}/${cap}/spec.md`));
150
+ if (!h) die(BLOCK, `主 spec 不存在:${ctx.specsRel}/${cap}/spec.md`);
151
+ if (has) die(BLOCK, `${cap} 已登记删除`);
152
+ st.spec_targets.push({ capability: cap, op: 'remove', base_hash: h });
153
+ } else {
154
+ if (!has) die(BLOCK, `${cap} 没有登记删除`);
155
+ st.spec_targets = st.spec_targets.filter((t) => !(t.op === 'remove' && t.capability === cap));
156
+ }
157
+ setNode(st, 'Drafting');
158
+ st.loop = freshLoop(st.loop.goal_cycle);
159
+ save(cp, st, `spec-${sub}`, cap);
160
+ return { cp, st, done: `${sub === 'remove' ? '已登记删除' : '已取消删除'} ${cap},重跑 open --prepare` };
161
+ }
@@ -0,0 +1,121 @@
1
+ // status (read-only routing), doctor (health + safe repairs), import-specs.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ import { loadConfig, resolveChecks } from './checks.mjs';
7
+ import { load } from './common.mjs';
8
+ import { docDrift } from './docs.mjs';
9
+ import { probeList, probeRemove } from './git.mjs';
10
+ import { activeChanges, capabilities, changePaths, requireInitialized } from './paths.mjs';
11
+ import { loadState, nodeOf } from './state.mjs';
12
+ import { git, hashFile } from './util.mjs';
13
+
14
+ export function cmdStatus(ctx, a) {
15
+ requireInitialized(ctx);
16
+ if (a._[0]) return load(ctx, a._[0]);
17
+ const names = activeChanges(ctx);
18
+ if (names.length === 1) return load(ctx, names[0]);
19
+ const list = names.map((n) => {
20
+ try {
21
+ const st = loadState(changePaths(ctx, n).state);
22
+ return { name: n, node: nodeOf(st), phase: st.phase, step: st.step, iteration: st.loop.iteration, mode: st.mode };
23
+ } catch (err) {
24
+ return { name: n, error: err.message.split('\n')[0] };
25
+ }
26
+ });
27
+ const next = names.length === 0
28
+ ? { disposition: 'continue', action: 'new', message: '没有进行中的 change。按需求拟一个 kebab-case 名称后新建:', command: ['$COMATE', 'new', '<name>', '--mode', 'full'], changes: [] }
29
+ : { disposition: 'await-user', action: 'select-change', message: `有 ${names.length} 个进行中的 change。用户描述能对应其中一个就直接选,否则请用户选择:`, changes: list, command: null };
30
+ const lines = [`CHANGES ${names.length}`, ...list.map((c) => ` ${c.name} ${c.error ? `损坏:${c.error}` : `${c.phase}/${c.step} iteration ${c.iteration}${c.mode === 'fix' ? ' [fix]' : ''}`}`), `NEXT ${next.message}`];
31
+ if (next.command) lines.push(` node "$COMATE" new <name> [--mode full|fix] [--source <file>]`);
32
+ else lines.push(' node "$COMATE" status <name>');
33
+ return { lines, json: { ...next, comate: ctx.script } };
34
+ }
35
+
36
+ export function cmdDoctor(ctx, a) {
37
+ const out = [];
38
+ const add = (level, msg) => out.push({ level, msg });
39
+ const major = Number(process.versions.node.split('.')[0]);
40
+ add(major >= 20 ? 'ok' : 'fail', `Node ${process.versions.node}${major >= 20 ? '' : '(需要 >= 20)'}`);
41
+ const g = spawnSync('git', ['--version'], { encoding: 'utf8' });
42
+ add(g.status === 0 ? 'ok' : 'fail', g.status === 0 ? g.stdout.trim() : '找不到 git');
43
+ const gh = spawnSync('gh', ['--version'], { encoding: 'utf8' });
44
+ add(gh.status === 0 ? 'ok' : 'warn', gh.status === 0 ? 'gh 可用(支持 PR 收尾)' : '没有 gh CLI:收尾方式 pr 不可用');
45
+ if (!ctx) return report(out, a, '不在 git 仓库里');
46
+
47
+ if (!existsSync(ctx.abs(ctx.configRel))) {
48
+ add('warn', `未初始化:没有 ${ctx.configRel}(运行 comate init)`);
49
+ } else {
50
+ try {
51
+ const cfg = loadConfig(ctx);
52
+ add('ok', `${ctx.configRel} 合法`);
53
+ for (const p of resolveChecks(ctx, cfg).problems) add('warn', p);
54
+ } catch (err) {
55
+ add('fail', err.message);
56
+ }
57
+ }
58
+ if (existsSync(ctx.abs('openspec/changes'))) {
59
+ const legacy = readdirSync(ctx.abs('openspec/changes')).filter((d) => existsSync(ctx.abs(`openspec/changes/${d}/.comate.yaml`)));
60
+ if (legacy.length) add('warn', `发现 0.x 进行中的 change(${legacy.join(', ')}):1.0 不自动转换,请用 comate 0.10.x 完成后再升级`);
61
+ if (existsSync(ctx.abs('openspec/specs'))) add('warn', 'openspec/specs 下有旧主规格:comate import-specs --dry-run 查看迁移清单');
62
+ }
63
+
64
+ for (const name of activeChanges(ctx)) {
65
+ const cp = changePaths(ctx, name);
66
+ let st;
67
+ try {
68
+ st = loadState(cp.state);
69
+ } catch (err) {
70
+ add('fail', `${name}:${err.message.split('\n')[0]}(手动核对 ${cp.stateRel},或从 git 历史恢复:git checkout -- ${cp.stateRel})`);
71
+ continue;
72
+ }
73
+ if (st.name !== name) add('fail', `${name}:state.json 里的 name 是 '${st.name}',与目录名不符`);
74
+ const drift = st.phase === 'open' ? [] : docDrift(ctx, cp, st);
75
+ for (const d of drift) add('warn', `${name}:${d}`);
76
+ if (st.archive) add('warn', `${name}:归档中断(${st.archive.step}),重跑 node "$COMATE" archive ${name} 按证据继续`);
77
+ const tmps = readdirSync(cp.dir).filter((f) => f.endsWith('.tmp'));
78
+ if (tmps.length) {
79
+ if (a.flags.repair) for (const f of tmps) rmSync(join(cp.dir, f), { force: true });
80
+ add(a.flags.repair ? 'ok' : 'warn', `${name}:${a.flags.repair ? '已清理' : '残留'}临时文件 ${tmps.join(', ')}${a.flags.repair ? '' : '(doctor --repair 清理)'}`);
81
+ }
82
+ if (!drift.length && !st.archive && !tmps.length) add('ok', `${name}:${st.phase}/${st.step},v${st.version}`);
83
+ }
84
+ const probes = probeList(ctx);
85
+ if (probes.length) {
86
+ if (a.flags.repair) for (const d of probes) probeRemove(ctx, d);
87
+ add(a.flags.repair ? 'ok' : 'warn', `${a.flags.repair ? '已清理' : '残留'} ${probes.length} 个反向验证 worktree${a.flags.repair ? '' : '(doctor --repair 清理)'}`);
88
+ }
89
+ if (a.flags.repair) git(ctx.root, 'worktree', 'prune');
90
+ return report(out, a);
91
+ }
92
+
93
+ function report(out, a, extra) {
94
+ const mark = { ok: '✓', warn: '⚠', fail: '✗' };
95
+ const lines = out.map((c) => ` ${mark[c.level]} ${c.msg}`);
96
+ if (extra) lines.push(` · ${extra}`);
97
+ const failed = out.some((c) => c.level === 'fail');
98
+ return { lines, json: { ok: !failed, checks: out }, exit: failed ? 1 : 0 };
99
+ }
100
+
101
+ export function cmdImportSpecs(ctx, a) {
102
+ requireInitialized(ctx);
103
+ const src = ctx.abs('openspec/specs');
104
+ const plan = capabilities(src).map((cap) => {
105
+ const from = `openspec/specs/${cap}/spec.md`;
106
+ const to = `${ctx.specsRel}/${cap}/spec.md`;
107
+ const d = hashFile(ctx.abs(to));
108
+ const action = d === null ? 'copy' : d === hashFile(ctx.abs(from)) ? 'same' : 'conflict';
109
+ return { capability: cap, from, to, action };
110
+ });
111
+ if (!a.flags['dry-run']) {
112
+ for (const p of plan.filter((x) => x.action === 'copy')) {
113
+ mkdirSync(dirname(ctx.abs(p.to)), { recursive: true });
114
+ copyFileSync(ctx.abs(p.from), ctx.abs(p.to));
115
+ }
116
+ }
117
+ const label = { copy: a.flags['dry-run'] ? '将复制' : '已复制', same: '已存在且相同', conflict: '冲突(目标已存在且不同,未覆盖)' };
118
+ const lines = plan.length ? plan.map((p) => ` ${label[p.action]} ${p.from} -> ${p.to}`) : [' openspec/specs 下没有可迁移的主规格'];
119
+ if (!a.flags['dry-run'] && plan.some((p) => p.action === 'copy')) lines.push(`核对后提交:git add ${ctx.specsRel} && git commit -m "chore(comate): import specs"`);
120
+ return { lines, json: { dry_run: Boolean(a.flags['dry-run']), plan }, exit: plan.some((p) => p.action === 'conflict') ? 1 : 0 };
121
+ }
@@ -0,0 +1,85 @@
1
+ // Helpers shared by the command modules.
2
+ import { existsSync } from 'node:fs';
3
+ import { isAbsolute, resolve } from 'node:path';
4
+
5
+ import { loadConfig, resolveChecks, verifyPlan } from './checks.mjs';
6
+ import { docDrift } from './docs.mjs';
7
+ import { commitPaths, dirtyOutside, forkPoint } from './git.mjs';
8
+ import { locateChange } from './paths.mjs';
9
+ import { loadState, saveState, setNode } from './state.mjs';
10
+ import { BLOCK, USAGE, die, ensureDir, log } from './util.mjs';
11
+
12
+ export function load(ctx, name) {
13
+ if (!name) die(USAGE, '缺少 change 名称');
14
+ const cp = locateChange(ctx, name);
15
+ return { cp, st: loadState(cp.state) };
16
+ }
17
+
18
+ export const save = (cp, st, event, message) => saveState(cp.state, st, event, message);
19
+
20
+ export function requireNoDrift(ctx, cp, st) {
21
+ const drift = docDrift(ctx, cp, st);
22
+ if (drift.length) {
23
+ die(BLOCK, `需求在确认后发生变化:\n - ${drift.join('\n - ')}\n误改就还原文件;需求确实要变就 decide --revise-requirements。`, { change: st.name });
24
+ }
25
+ }
26
+
27
+ // A path the agent passed on the command line: cwd first, then repo root.
28
+ export function inputPath(ctx, raw, flag) {
29
+ if (!raw) die(USAGE, `缺少 ${flag} <file.json>`);
30
+ if (isAbsolute(raw)) return raw;
31
+ const fromCwd = resolve(process.cwd(), raw);
32
+ return existsSync(fromCwd) ? fromCwd : resolve(ctx.root, raw);
33
+ }
34
+
35
+ // open -> build: record the real fork point, freeze iteration 1, commit the
36
+ // confirmed documents. A failed commit (hook) is a warning, not a stop: the
37
+ // archive commit carries the change directory in the end either way.
38
+ export function enterBuild(ctx, cp, st, event, message) {
39
+ ensureDir(ctx.abs(ctx.tempDir)); // 确保 .comate 目录存在
40
+ st.workspace.base_ref = forkPoint(ctx, st.workspace.base_ref, st.workspace.base_branch);
41
+ st.loop.iteration = 1;
42
+
43
+ // 保存确认时的测试计划指纹,用于降级判断
44
+ const cfg = loadConfig(ctx);
45
+ const { plan } = resolveChecks(ctx, cfg);
46
+ st.confirm.verify_plan = verifyPlan(plan);
47
+
48
+ setNode(st, 'Building');
49
+ save(cp, st, event, message);
50
+ try {
51
+ commitPaths(ctx, [cp.rel], `docs(comate): ${st.name}`);
52
+ } catch (err) {
53
+ log.warn(`提示:需求文档未能提交(${err.message.split('\n')[0]}),build 开始前请手动提交 ${cp.rel}`);
54
+ }
55
+ }
56
+
57
+ // Unresolved-set bookkeeping. Progress = this round's unresolved set is a
58
+ // strict subset of the last one. Two stalls in a row -> hint; three -> stop.
59
+ // Repair items remain in state and are included in the next build dispatch.
60
+ export function queueRepairs(st, entries) {
61
+ st.loop.repairs = { iteration: st.loop.iteration, entries };
62
+ }
63
+
64
+ export function requireCleanTree(ctx, cp, st, extra = []) {
65
+ const dirty = dirtyOutside(ctx, [cp.rel, ...extra]);
66
+ if (dirty.length) {
67
+ die(BLOCK, `工作区有未提交改动:${dirty.slice(0, 10).join(', ')}${dirty.length > 10 ? ' …' : ''}\n验收只针对已提交的候选:属于本 change 就提交后重新 handoff,否则撤回。`, { change: st.name });
68
+ }
69
+ }
70
+
71
+ export function recordFailure(st, unresolved, cfg) {
72
+ const prev = new Set(st.loop.previous_unresolved);
73
+ const progress = prev.size > 0 && unresolved.length < prev.size && unresolved.every((id) => prev.has(id));
74
+ st.loop.fail_count += 1;
75
+ st.loop.no_progress_count = progress || prev.size === 0 ? 0 : st.loop.no_progress_count + 1;
76
+ st.loop.previous_unresolved = unresolved;
77
+ if (st.loop.fail_count >= cfg.max_verify_failures) {
78
+ st.loop.stop_reason = `验收失败已达上限 ${cfg.max_verify_failures} 次`;
79
+ } else if (st.loop.no_progress_count >= 3) {
80
+ st.loop.stop_reason = '连续 3 轮未解决集合没有缩小(停滞)';
81
+ } else {
82
+ st.loop.stop_reason = null;
83
+ }
84
+ return st.loop.stop_reason;
85
+ }
@@ -0,0 +1,45 @@
1
+ // Requirement documents of a change as one unit: which specs exist, the
2
+ // confirmed doc_hash, and source-file drift. Build, verify and archive all
3
+ // re-check these, so an edit after confirmation is caught wherever it happens.
4
+ import { hashFile, hashText, readText } from './util.mjs';
5
+ import { capabilities } from './paths.mjs';
6
+
7
+ export function changeSpecList(cp) {
8
+ return capabilities(cp.specs).map((cap) => ({ cap, text: readText(`${cp.specs}/${cap}/spec.md`) }));
9
+ }
10
+
11
+ export function mainSpecText(ctx, cap) {
12
+ return readText(ctx.abs(`${ctx.specsRel}/${cap}/spec.md`));
13
+ }
14
+
15
+ // brief + every change spec + registered removals. tasks.md is deliberately
16
+ // excluded: it is the build checklist and may change freely.
17
+ export function computeDocHash(cp, st) {
18
+ const parts = [`brief\n${hashText(readText(cp.brief) ?? '')}`];
19
+ for (const { cap, text } of changeSpecList(cp)) parts.push(`spec:${cap}\n${hashText(text)}`);
20
+ const removes = st.spec_targets.filter((t) => t.op === 'remove').map((t) => t.capability).sort();
21
+ parts.push(`remove\n${removes.join(',')}`);
22
+ return hashText(parts.join('\n'));
23
+ }
24
+
25
+ export function sourceDrift(ctx, st) {
26
+ const out = [];
27
+ for (const s of st.sources) {
28
+ const now = hashFile(ctx.abs(s.path));
29
+ if (now === null) out.push(`源文件已不存在:${s.path}`);
30
+ else if (now !== s.sha256) out.push(`源文件已修改:${s.path}`);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // Problems that mean the confirmed requirements no longer match the disk.
36
+ // Once archive has started writing main specs, a source that is itself a main
37
+ // spec legitimately changes, so sources are no longer compared.
38
+ export function docDrift(ctx, cp, st) {
39
+ if (!st.confirm.doc_hash) return [];
40
+ const out = st.archive ? [] : sourceDrift(ctx, st);
41
+ if (computeDocHash(cp, st) !== st.confirm.doc_hash) {
42
+ out.unshift('brief.md 或 specs/ 在确认后被修改(验收项已冻结)');
43
+ }
44
+ return out;
45
+ }
@@ -0,0 +1,108 @@
1
+ // Finish actions after the archive commit: keep / merge / push / pr. Nothing is
2
+ // written to state afterwards (that would dirty the tree the archive just
3
+ // committed), so whether a finish already happened is read from git itself.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { resolve } from 'node:path';
6
+
7
+ import { dirtyOutside, hasRemote, isAncestor, knownPath, worktreeFor } from './git.mjs';
8
+ import { BLOCK, die, git, gitOk } from './util.mjs';
9
+
10
+ const sha = (ctx, ref) => git(ctx.root, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`);
11
+ const same = (a, b) => resolve(a).toLowerCase() === resolve(b).toLowerCase();
12
+ const run = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, encoding: 'utf8' });
13
+ const why = (r) => (r.error ? r.error.message : `${r.stderr || r.stdout}`.trim() || `exit ${r.status}`);
14
+
15
+ // Why a finish action cannot run here, or null. Shared by decide --accept and
16
+ // finish: auto so an impossible action is refused before the archive commit.
17
+ export function finishProblem(ctx, st, action) {
18
+ const { branch, base_branch: base } = st.workspace;
19
+ if (!['keep', 'merge', 'pr', 'push'].includes(action)) return '收尾方式只能是 keep / merge / pr / push';
20
+ if ((action === 'pr' || action === 'push') && !hasRemote(ctx)) return '没有 origin 远端,不能 push / pr';
21
+ if (action === 'merge' && branch === base) return `工作分支就是 ${base},无需合并,选 keep`;
22
+ if (action === 'merge' && !sha(ctx, base)) return `目标分支 ${base} 不存在`;
23
+ return null;
24
+ }
25
+
26
+ // What the archive commit contains: touched capability dirs, old and new change dir.
27
+ export const archivePaths = (ctx, st) => [
28
+ ...st.archive.targets.map((t) => `${ctx.specsRel}/${t.capability}`),
29
+ st.archive.from,
30
+ st.archive.dest,
31
+ ];
32
+
33
+ // A created PR is remembered in local git config (not in the committed tree).
34
+ const prKey = (st) => `comate.${st.name}.pr`;
35
+
36
+ // Local evidence only — no network, so `status` stays fast and offline-safe.
37
+ export function finishEvidence(ctx, st) {
38
+ const f = st.finish;
39
+ const { branch, base_branch: base } = st.workspace;
40
+ if (st.archive) {
41
+ const live = archivePaths(ctx, st).filter((p) => knownPath(ctx, p));
42
+ if (live.length && git(ctx.root, 'status', '--porcelain', '--', ...live)) return { done: false, message: '归档目录已移动,但归档提交还没完成。' };
43
+ }
44
+ if (!f || f.action === 'keep') return { done: true, message: `分支 ${branch} 已保留。` };
45
+ if (f.action === 'merge') {
46
+ const b = sha(ctx, branch);
47
+ const t = sha(ctx, base);
48
+ const done = branch === base || Boolean(b && t && isAncestor(ctx, b, t));
49
+ return { done, message: done ? `已合并到 ${base}。` : `尚未合并到 ${base}。` };
50
+ }
51
+ const b = sha(ctx, branch);
52
+ const pushed = Boolean(b && b === sha(ctx, `refs/remotes/origin/${branch}`));
53
+ if (!pushed) return { done: false, message: `${branch} 尚未推送。` };
54
+ if (f.action === 'push') return { done: true, message: `已推送 ${branch}。` };
55
+ const pr = git(ctx.root, 'config', '--get', prKey(st));
56
+ return pr ? { done: true, message: `PR:${pr}` } : { done: false, message: `${branch} 已推送,但还没有确认 PR 已创建。` };
57
+ }
58
+
59
+ export function runFinish(ctx, st, prBody) {
60
+ const f = st.finish;
61
+ const { branch, base_branch: base } = st.workspace;
62
+ const fail = (msg) => die(BLOCK, `归档已完成,但收尾「${f.action}」没有完成:${msg}\n处理后重跑 node "$COMATE" archive ${st.name}`, { change: st.name });
63
+ if (!f || f.action === 'keep') return `分支 ${branch} 已保留,未合并、未推送。`;
64
+
65
+ if (f.action === 'merge') {
66
+ if (finishEvidence(ctx, st).done) return `${branch} 已在 ${base} 中。`;
67
+ const wt = worktreeFor(ctx, base);
68
+ if (wt && !same(wt, ctx.root)) {
69
+ if (git(wt, 'status', '--porcelain')) fail(`${base} 签出在 ${wt},那里有未提交改动`);
70
+ const r = run('git', ['-C', wt, 'merge', '--no-ff', '--no-edit', branch]);
71
+ if (r.status !== 0) {
72
+ run('git', ['-C', wt, 'merge', '--abort']);
73
+ fail(`在 ${wt} 合并冲突或失败,已中止合并:${why(r)}`);
74
+ }
75
+ return `已在 ${wt} 把 ${branch} 合并到 ${base}(--no-ff)。`;
76
+ }
77
+ const dirty = dirtyOutside(ctx, []);
78
+ if (dirty.length) fail(`当前工作区有未提交改动,无法切到 ${base}:${dirty.slice(0, 5).join(', ')}`);
79
+ gitOk(ctx.root, 'checkout', '-q', base);
80
+ const r = run('git', ['merge', '--no-ff', '--no-edit', branch], ctx.root);
81
+ if (r.status !== 0) {
82
+ run('git', ['merge', '--abort'], ctx.root);
83
+ run('git', ['checkout', '-q', branch], ctx.root);
84
+ fail(`合并冲突或失败,已中止并切回 ${branch}:${why(r)}`);
85
+ }
86
+ return `已把 ${branch} 合并到 ${base}(--no-ff),当前在 ${base}。是否推送远端由你决定。`;
87
+ }
88
+
89
+ if (!hasRemote(ctx)) fail('没有 origin 远端');
90
+ if (!finishEvidence(ctx, { ...st, finish: { action: 'push' } }).done) {
91
+ const r = run('git', ['push', '-u', 'origin', branch], ctx.root);
92
+ if (r.status !== 0) fail(`git push 失败:${why(r)}`);
93
+ }
94
+ if (f.action === 'push') return `已推送 ${branch} 到 origin。`;
95
+
96
+ const gh = run('gh', ['--version']);
97
+ if (gh.error || gh.status !== 0) fail('找不到 gh CLI(分支已推送,可手动创建 PR)');
98
+ const view = run('gh', ['pr', 'view', branch, '--json', 'url', '-q', '.url'], ctx.root);
99
+ if (view.status === 0 && view.stdout.trim()) {
100
+ git(ctx.root, 'config', prKey(st), view.stdout.trim());
101
+ return `PR 已存在:${view.stdout.trim()}`;
102
+ }
103
+ const r = run('gh', ['pr', 'create', '--base', base, '--head', branch, '--title', `comate: ${st.name}`, '--body', prBody], ctx.root);
104
+ if (r.status !== 0) fail(`gh pr create 失败:${why(r)}`);
105
+ const url = r.stdout.trim().split('\n').at(-1);
106
+ git(ctx.root, 'config', prKey(st), url);
107
+ return `已创建 PR:${url}`;
108
+ }
@@ -0,0 +1,112 @@
1
+ // Git operations on the project root. Every helper takes ctx so the cwd the
2
+ // script happened to start in never matters.
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { git, gitOk, toPosix } from './util.mjs';
9
+
10
+ const status = (ctx, ...args) => spawnSync('git', ['-c', 'core.quotepath=false', ...args], { cwd: ctx.root, encoding: 'utf8' }).status;
11
+
12
+ export const head = (ctx) => git(ctx.root, 'rev-parse', '--verify', 'HEAD');
13
+ export const currentBranch = (ctx) => git(ctx.root, 'symbolic-ref', '--quiet', '--short', 'HEAD');
14
+ export const branchExists = (ctx, b) => status(ctx, 'show-ref', '--verify', '--quiet', `refs/heads/${b}`) === 0;
15
+ export const isAncestor = (ctx, a, b) => status(ctx, 'merge-base', '--is-ancestor', a, b) === 0;
16
+ export const hasRemote = (ctx, r = 'origin') => git(ctx.root, 'remote', 'get-url', r) !== null;
17
+
18
+ export function defaultBranch(ctx) {
19
+ const remote = git(ctx.root, 'symbolic-ref', 'refs/remotes/origin/HEAD')?.replace('refs/remotes/origin/', '');
20
+ if (remote) return remote;
21
+ for (const b of ['main', 'master']) if (branchExists(ctx, b)) return b;
22
+ return currentBranch(ctx);
23
+ }
24
+
25
+ // Newest ancestor of HEAD among the recorded ref and HEAD's merge-base with the
26
+ // base branch (local and remote, either may be stale). Open can take days while
27
+ // the base moves on, so the ref recorded at `new` alone is not enough.
28
+ export function forkPoint(ctx, recorded, baseBranch) {
29
+ const h = head(ctx);
30
+ if (!h) return recorded;
31
+ const refs = [recorded];
32
+ if (baseBranch) refs.push(git(ctx.root, 'merge-base', 'HEAD', baseBranch), git(ctx.root, 'merge-base', 'HEAD', `origin/${baseBranch}`));
33
+ const cands = [...new Set(refs.filter(Boolean).map((r) => git(ctx.root, 'rev-parse', '--verify', `${r}^{commit}`)))].filter(
34
+ (c) => c && isAncestor(ctx, c, h),
35
+ );
36
+ if (!cands.length) return h;
37
+ return cands.find((c) => cands.every((o) => isAncestor(ctx, o, c))) ?? cands[0];
38
+ }
39
+
40
+ // Paths with uncommitted changes (staged, unstaged, untracked) outside `allowed` prefixes.
41
+ export function dirtyOutside(ctx, allowed = []) {
42
+ const out = git(ctx.root, 'status', '--porcelain=v1', '-z', '--untracked-files=all') ?? '';
43
+ const parts = out.split('\0').filter(Boolean);
44
+ const paths = [];
45
+ for (let i = 0; i < parts.length; i++) {
46
+ const code = parts[i].slice(0, 2);
47
+ paths.push(parts[i].slice(3));
48
+ if (code.includes('R') || code.includes('C')) i++;
49
+ }
50
+ // 自动排除 .comate 临时目录
51
+ const allowedWithTemp = [...allowed, ctx.tempDir];
52
+ return paths.filter((p) => !allowedWithTemp.some((a) => p === a || p.startsWith(a.endsWith('/') ? a : `${a}/`)));
53
+ }
54
+
55
+ export function isClean(ctx, rel) {
56
+ const tracked = status(ctx, 'ls-files', '--error-unmatch', '--', rel) === 0;
57
+ return tracked && (git(ctx.root, 'status', '--porcelain', '--', rel) ?? '') === '';
58
+ }
59
+
60
+ // Commit exactly `paths` (other staged work is left staged, not swept in).
61
+ // Returns the new sha, or null when there was nothing to commit.
62
+ // On disk, in the index, or in HEAD — a staged deletion is only visible in HEAD.
63
+ export const knownPath = (ctx, p) =>
64
+ existsSync(ctx.abs(p)) || Boolean(git(ctx.root, 'ls-files', '--', p)) || Boolean(git(ctx.root, 'ls-tree', '-r', '--name-only', 'HEAD', '--', p));
65
+
66
+ export function commitPaths(ctx, paths, message) {
67
+ const keep = paths.filter((p) => knownPath(ctx, p));
68
+ if (!keep.length) return null;
69
+ const addable = keep.filter((p) => existsSync(ctx.abs(p)) || git(ctx.root, 'ls-files', '--', p));
70
+ if (addable.length) gitOk(ctx.root, 'add', '-A', '--', ...addable);
71
+ if (status(ctx, 'diff', '--cached', '--quiet', '--', ...keep) === 0) return null;
72
+ gitOk(ctx.root, 'commit', '-q', '-m', message, '--', ...keep);
73
+ return head(ctx);
74
+ }
75
+
76
+ export const changedFiles = (ctx, from, to) =>
77
+ (git(ctx.root, 'diff', '--name-only', `${from}..${to}`) ?? '').split('\n').filter(Boolean);
78
+
79
+ export function worktreeFor(ctx, branch) {
80
+ const out = git(ctx.root, 'worktree', 'list', '--porcelain') ?? '';
81
+ let path = null;
82
+ for (const line of out.split('\n')) {
83
+ if (line.startsWith('worktree ')) path = line.slice(9).trim();
84
+ else if (line.trim() === `branch refs/heads/${branch}`) return toPosix(path);
85
+ }
86
+ return null;
87
+ }
88
+
89
+ export const remoteSha = (ctx, branch) => git(ctx.root, 'ls-remote', '--heads', 'origin', branch)?.split(/\s+/)[0] || null;
90
+
91
+ // Throwaway worktree at `commit` in the OS temp dir, for reverse checks that
92
+ // break code on purpose without touching the main working tree.
93
+ export function probeAdd(ctx, name, commit) {
94
+ const dir = toPosix(mkdtempSync(join(tmpdir(), `comate-probe-${name}-`)));
95
+ rmSync(dir, { recursive: true, force: true });
96
+ gitOk(ctx.root, 'worktree', 'add', '--detach', dir, commit);
97
+ return dir;
98
+ }
99
+
100
+ export function probeRemove(ctx, dir) {
101
+ git(ctx.root, 'worktree', 'remove', '--force', dir);
102
+ rmSync(dir, { recursive: true, force: true });
103
+ git(ctx.root, 'worktree', 'prune');
104
+ }
105
+
106
+ export function probeList(ctx) {
107
+ const out = git(ctx.root, 'worktree', 'list', '--porcelain') ?? '';
108
+ return out
109
+ .split('\n')
110
+ .filter((l) => l.startsWith('worktree ') && /comate-probe-/.test(l))
111
+ .map((l) => toPosix(l.slice(9).trim()));
112
+ }