@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,152 @@
1
+ // archive: preflight -> record plan -> write main specs -> move dir -> commit -> finish.
2
+ // Every step checks the real files first, so a rerun after any interruption
3
+ // continues from where the evidence says it stopped, never re-overwriting.
4
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+
7
+ import { load, save } from './common.mjs';
8
+ import { docDrift } from './docs.mjs';
9
+ import { archivePaths, finishEvidence, runFinish } from './finish.mjs';
10
+ import { changedFiles, commitPaths, dirtyOutside, head, isAncestor, knownPath } from './git.mjs';
11
+ import { section } from './md.mjs';
12
+ import { changePaths } from './paths.mjs';
13
+ import { requireNode, setNode } from './state.mjs';
14
+ import { writeVerification } from './render.mjs';
15
+ import { BLOCK, CORRUPT, die, git, hashFile, readText, today } from './util.mjs';
16
+
17
+ export function cmdArchive(ctx, a) {
18
+ let { cp, st } = load(ctx, a._[0]);
19
+ if (st.step === 'done') {
20
+ if (a.flags['dry-run']) return { cp, st, done: `已归档。收尾:${finishEvidence(ctx, st).message}` };
21
+ return finish(ctx, cp, st);
22
+ }
23
+ requireNode(st, ['ArchiveReady'], 'archive');
24
+ const moved = cp.archived;
25
+ if (!moved) {
26
+ const problems = preflight(ctx, cp, st);
27
+ if (problems.length) die(BLOCK, `归档预检未通过:\n - ${problems.join('\n - ')}`, { change: st.name });
28
+ if (a.flags['dry-run']) return { cp, st, done: `预检通过(dry-run)。将执行:${planText(ctx, cp, st).join(';')}` };
29
+ } else if (a.flags['dry-run']) {
30
+ return { cp, st, done: '目录已移入 archive,下一步只剩提交与收尾' };
31
+ }
32
+
33
+ if (!st.archive) {
34
+ const dest = `${ctx.archiveRel}/${today()}-${st.name}`;
35
+ st.archive = { step: 'planned', dest, from: cp.rel, targets: targetsPlan(ctx, cp, st) };
36
+ save(cp, st, 'archive-plan', dest);
37
+ }
38
+ if (!moved) {
39
+ writeMainSpecs(ctx, cp, st);
40
+ st.archive.step = 'moving';
41
+ save(cp, st, 'archive-specs', '主 spec 已写入');
42
+ const destAbs = ctx.abs(st.archive.dest);
43
+ if (existsSync(destAbs)) die(CORRUPT, `归档目录已存在:${st.archive.dest}。运行 doctor 核对后手动处理。`, { change: st.name });
44
+ for (const f of readdirSync(cp.dir)) if (f.endsWith('.tmp') || f === 'verify-result.json') rmSync(`${cp.dir}/${f}`, { force: true });
45
+ mkdirSync(dirname(destAbs), { recursive: true });
46
+ try {
47
+ renameSync(cp.dir, destAbs);
48
+ } catch (err) {
49
+ die(BLOCK, `移动目录失败(${err.code}):关掉占用 ${cp.rel} 的程序后重跑 archive`, { change: st.name });
50
+ }
51
+ cp = { ...changePaths(ctx, st.name, st.archive.dest), archived: true };
52
+ }
53
+ st.archive.step = 'moved';
54
+ setNode(st, 'Done');
55
+ writeVerification(cp, st);
56
+ save(cp, st, 'archived', st.archive.dest);
57
+ commitArchive(ctx, cp, st);
58
+ return finish(ctx, cp, st);
59
+ }
60
+
61
+ function preflight(ctx, cp, st) {
62
+ const p = [...docDrift(ctx, cp, st)];
63
+ // After specs are written, an interrupted rename may legitimately leave the
64
+ // planned main spec files dirty. Their before/after hashes are checked below;
65
+ // all other files must still be clean before retrying archive.
66
+ const archiveWrites = st.archive
67
+ ? st.archive.targets.map((t) => `${ctx.specsRel}/${t.capability}/spec.md`)
68
+ : [];
69
+ const dirty = dirtyOutside(ctx, [cp.rel, ...archiveWrites]);
70
+ if (dirty.length) p.push(`工作区有未提交改动:${dirty.slice(0, 10).join(', ')}${dirty.length > 10 ? ' …' : ''},需要清理后再归档`);
71
+ const h = head(ctx);
72
+ const c = st.candidate?.commit;
73
+ if (!c) p.push('没有候选实现记录');
74
+ else if (h !== c) {
75
+ if (!isAncestor(ctx, c, h)) p.push(`HEAD ${h?.slice(0, 7)} 不包含已验收的候选 ${c.slice(0, 7)}`);
76
+ else {
77
+ const extra = changedFiles(ctx, c, h).filter((f) => !f.startsWith(`${cp.rel}/`));
78
+ if (extra.length) p.push(`验收后又提交了 change 目录外的改动(${extra.slice(0, 5).join(', ')}),需要重新验收:decide --revise-implementation`);
79
+ }
80
+ }
81
+ if (st.verifier?.independent !== false && st.acceptance.some((x) => x.result !== 'passed')) p.push('存在未通过的验收项');
82
+ const dest = st.archive?.dest ?? `${ctx.archiveRel}/${today()}-${st.name}`;
83
+ if (st.archive?.step !== 'moved' && existsSync(ctx.abs(dest))) p.push(`归档目录已存在:${dest}(同名 change 今天已归档过,手动处理后重试)`);
84
+ for (const t of st.spec_targets) {
85
+ const now = hashFile(ctx.abs(`${ctx.specsRel}/${t.capability}/spec.md`));
86
+ const after = t.op === 'remove' ? null : hashFile(`${cp.specs}/${t.capability}/spec.md`);
87
+ if (now === after && st.archive) continue;
88
+ if (now !== t.base_hash) {
89
+ p.push(t.op === 'create'
90
+ ? `主 spec ${t.capability} 已被其他 change 创建:重读新主 spec,改写本 change 的 spec,再 --revise-requirements 重新确认`
91
+ : `主 spec ${t.capability} 在确认后被改动(基线不符):重读新主 spec 并 rebase 本 change 的 spec,再 --revise-requirements 重新确认`);
92
+ }
93
+ }
94
+ return p;
95
+ }
96
+
97
+ function targetsPlan(ctx, cp, st) {
98
+ return st.spec_targets.map((t) => ({
99
+ capability: t.capability, op: t.op,
100
+ before: t.base_hash,
101
+ after: t.op === 'remove' ? null : hashFile(`${cp.specs}/${t.capability}/spec.md`),
102
+ }));
103
+ }
104
+
105
+ const planText = (ctx, cp, st) => [
106
+ ...st.spec_targets.map((t) => `${t.op} ${ctx.specsRel}/${t.capability}/spec.md`),
107
+ `移动 ${cp.rel} -> ${ctx.archiveRel}/${today()}-${st.name}`,
108
+ `提交 chore(comate): archive ${st.name}`,
109
+ `收尾 ${st.finish?.action ?? 'keep'}`,
110
+ ];
111
+
112
+ function writeMainSpecs(ctx, cp, st) {
113
+ for (const t of st.archive.targets) {
114
+ const mainRel = `${ctx.specsRel}/${t.capability}/spec.md`;
115
+ const now = hashFile(ctx.abs(mainRel));
116
+ if (now === t.after) continue;
117
+ if (now !== t.before) die(CORRUPT, `主 spec ${mainRel} 的内容既不是写入前也不是写入后,无法确认来源。运行 doctor,人工核对后处理。`, { change: st.name });
118
+ if (t.op === 'remove') {
119
+ const keep = `${cp.dir}/removed-specs/${t.capability}/spec.md`;
120
+ mkdirSync(dirname(keep), { recursive: true });
121
+ copyFileSync(ctx.abs(mainRel), keep);
122
+ rmSync(ctx.abs(`${ctx.specsRel}/${t.capability}`), { recursive: true, force: true });
123
+ } else {
124
+ mkdirSync(dirname(ctx.abs(mainRel)), { recursive: true });
125
+ writeFileSync(ctx.abs(mainRel), readFileSync(`${cp.specs}/${t.capability}/spec.md`));
126
+ }
127
+ }
128
+ }
129
+
130
+ function commitArchive(ctx, cp, st) {
131
+ const paths = archivePaths(ctx, st);
132
+ const live = paths.filter((p) => knownPath(ctx, p));
133
+ if (!live.length || !git(ctx.root, 'status', '--porcelain', '--', ...live)) return;
134
+ try {
135
+ commitPaths(ctx, paths, `chore(comate): archive ${st.name}`);
136
+ } catch (err) {
137
+ die(BLOCK, `目录已归档,但提交失败:${err.message}\n修好后重跑 node "$COMATE" archive ${st.name}`, { change: st.name });
138
+ }
139
+ }
140
+
141
+ function finish(ctx, cp, st) {
142
+ commitArchive(ctx, cp, st);
143
+ const brief = readText(cp.brief) ?? '';
144
+ const goal = section(brief, '目标')?.body.replace(/<!--[\s\S]*?-->/g, '').trim() ?? '';
145
+ const body = [
146
+ `## 目标\n\n${goal}`,
147
+ `## 验收\n\n${st.acceptance.map((x) => `- ${x.id} ${x.title}:${x.result}`).join('\n')}`,
148
+ `详见 \`${st.archive.dest}/verification.md\`。`,
149
+ ].join('\n\n');
150
+ const message = runFinish(ctx, st, body);
151
+ return { cp, st, done: `已归档到 ${st.archive.dest}。${message}` };
152
+ }
@@ -0,0 +1,116 @@
1
+ // decide: every user decision after open. All forms carry --expect.
2
+ import { candidateMoved, startChecking } from './cmd-loop.mjs';
3
+ import { load, queueRepairs, recordFailure, requireCleanTree, save } from './common.mjs';
4
+ import { loadConfig } from './checks.mjs';
5
+ import { finishEvidence, finishProblem } from './finish.mjs';
6
+ import { commitPaths, head } from './git.mjs';
7
+ import { freshLoop, requireExpect, requireNode, setNode } from './state.mjs';
8
+ import { repairEntryForItem } from './tasks.mjs';
9
+ import { BLOCK, USAGE, die, hashFile } from './util.mjs';
10
+
11
+ const CHOICES = ['accept', 'revise-implementation', 'revise-requirements', 'resolve', 'upgrade'];
12
+ const AFTER_OPEN = ['Building', 'Repairing', 'Checking', 'AwaitVerifier', 'AwaitAccept', 'Stuck', 'Blocked', 'Degraded', 'ArchiveReady'];
13
+
14
+ export function cmdDecide(ctx, a) {
15
+ const { cp, st } = load(ctx, a._[0]);
16
+ const picked = CHOICES.filter((c) => a.flags[c]);
17
+ if (picked.length !== 1) die(USAGE, `decide 需要且只能有一个选择:${CHOICES.map((c) => `--${c}`).join(' / ')}`);
18
+ const note = a.values.note?.trim();
19
+ const choice = picked[0];
20
+
21
+ if (choice === 'accept') {
22
+ const node = requireNode(st, ['AwaitAccept', 'Degraded', 'Done'], 'decide --accept');
23
+ requireExpect(st, a.values.expect);
24
+ const f = a.values.finish;
25
+ if (!f) die(USAGE, '--accept 需要 --finish keep|merge|pr|push');
26
+ const problem = finishProblem(ctx, st, f);
27
+ if (problem) die(BLOCK, problem, { change: st.name });
28
+ if (node === 'Done') {
29
+ // Archived, but the chosen finish could not complete: switch it.
30
+ if (finishEvidence(ctx, st).done) die(BLOCK, '收尾已完成,无需更改', { change: st.name });
31
+ st.finish = { action: f, by: 'user', at: new Date().toISOString() };
32
+ save(cp, st, 'refinish', f);
33
+ commitPaths(ctx, [cp.rel], `chore(comate): ${st.name} finish ${f}`);
34
+ return { cp, st, done: `收尾方式改为 ${f},重跑 archive 执行` };
35
+ }
36
+ st.finish = { action: f, by: 'user', at: new Date().toISOString() };
37
+ setNode(st, 'ArchiveReady');
38
+ save(cp, st, 'accept', `${st.verifier?.independent === false ? '接受降级结果,' : ''}收尾 ${f}`);
39
+ return { cp, st, done: `用户接受结果,收尾方式:${f}` };
40
+ }
41
+
42
+ if (choice === 'revise-implementation') {
43
+ const node = requireNode(st, ['AwaitAccept', 'Stuck', 'Degraded'], 'decide --revise-implementation');
44
+ requireExpect(st, a.values.expect);
45
+ if (!note) die(USAGE, '--revise-implementation 需要 --note "<要改什么 / 新的修复方向>"');
46
+ const entries = [`用户要求:${note}`];
47
+ if (node === 'Stuck') {
48
+ for (const id of st.loop.previous_unresolved) {
49
+ const x = st.acceptance.find((y) => y.id === id);
50
+ if (x && x.result !== 'pending') entries.push(repairEntryForItem(x));
51
+ }
52
+ Object.assign(st.loop, { fail_count: 0, no_progress_count: 0, previous_unresolved: [], stop_reason: null });
53
+ }
54
+ st.loop.iteration += 1;
55
+ st.verifier = null;
56
+ for (const x of st.acceptance) Object.assign(x, { result: 'pending', reason: null });
57
+ queueRepairs(st, entries);
58
+ setNode(st, 'Repairing');
59
+ save(cp, st, 'revise-implementation', note);
60
+ return { cp, st, done: '需求不变,回 build 修改实现' };
61
+ }
62
+
63
+ if (choice === 'revise-requirements') {
64
+ requireNode(st, AFTER_OPEN, 'decide --revise-requirements');
65
+ requireExpect(st, a.values.expect);
66
+ if (st.archive) die(BLOCK, '归档已经开始写主 spec,不能再改需求。先 archive 收尾,再开新 change。', { change: st.name });
67
+ resetToOpen(ctx, st);
68
+ save(cp, st, 'revise-requirements', note ?? '');
69
+ return { cp, st, done: `回到 open(goal_cycle ${st.loop.goal_cycle}):修改 brief/spec 后重新 prepare 并确认` };
70
+ }
71
+
72
+ if (choice === 'resolve') {
73
+ requireNode(st, ['Blocked'], 'decide --resolve');
74
+ requireExpect(st, a.values.expect);
75
+ if (!note) die(USAGE, '--resolve 需要 --note "<补充的信息>"');
76
+ st.candidate.handoff.notes.push({ at: new Date().toISOString(), note });
77
+ st.loop.error_count = 0;
78
+ if (head(ctx) !== st.candidate.commit) return candidateMoved(ctx, cp, st);
79
+ requireCleanTree(ctx, cp, st);
80
+ const failed = st.acceptance.filter((x) => x.result === 'failed');
81
+ if (failed.length) {
82
+ // Older mixed-result states did not register their failed round.
83
+ if (!st.loop.repairs) {
84
+ recordFailure(st, failed.map((x) => x.id), loadConfig(ctx));
85
+ st.loop.iteration += 1;
86
+ }
87
+ queueRepairs(st, [...failed.map(repairEntryForItem), `解除阻塞补充:${note}`]);
88
+ st.blockers = [];
89
+ setNode(st, st.loop.stop_reason ? 'Stuck' : 'Repairing');
90
+ save(cp, st, 'resolve', note);
91
+ return { cp, st, done: st.loop.stop_reason ? `阻塞已解除,${st.loop.stop_reason}` : '阻塞已解除,保留验收证据并回 build 修复未通过项' };
92
+ }
93
+ return startChecking(ctx, cp, st, 'resolve');
94
+ }
95
+
96
+ if (st.mode !== 'fix') die(BLOCK, '已经是 full 模式', { change: st.name });
97
+ requireNode(st, ['Drafting', ...AFTER_OPEN.filter((n) => n !== 'ArchiveReady')], 'decide --upgrade');
98
+ requireExpect(st, a.values.expect);
99
+ st.mode = 'full';
100
+ resetToOpen(ctx, st);
101
+ save(cp, st, 'upgrade', note ?? '范围超出小修');
102
+ return { cp, st, done: '已转为 full 模式:补全 brief 七章,重新 prepare 并请用户确认' };
103
+ }
104
+
105
+ // Back to Drafting for a new goal cycle. Registered removals survive (their
106
+ // baseline is re-checked at prepare); source pins move to the current content.
107
+ function resetToOpen(ctx, st) {
108
+ st.loop = freshLoop(st.loop.goal_cycle + 1);
109
+ Object.assign(st, {
110
+ acceptance: [], removed_scenarios: [], source_map: {}, candidate: null, verifier: null, blockers: [], finish: null,
111
+ confirm: { doc_hash: null, at: null, skip_reason: null },
112
+ spec_targets: st.spec_targets.filter((t) => t.op === 'remove'),
113
+ sources: st.sources.map((s) => ({ ...s, sha256: hashFile(ctx.abs(s.path)) ?? s.sha256 })),
114
+ });
115
+ setNode(st, 'Drafting');
116
+ }
@@ -0,0 +1,75 @@
1
+ // cmd-init: Initialize comate in a project.
2
+ import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { BASE, TEMP_DIR, makeContext } from './paths.mjs';
7
+ import { ensureDir, git, log, toPosix } from './util.mjs';
8
+
9
+ export function cmdInit(ctx) {
10
+ const changes = [];
11
+
12
+ // 如果没有 ctx(还不在 git 仓库或还未初始化),先创建一个临时的
13
+ if (!ctx) {
14
+ const cwd = toPosix(process.cwd());
15
+ const top = git(cwd, 'rev-parse', '--show-toplevel');
16
+ if (!top) {
17
+ log.fail('当前目录不在 git 仓库里。请先运行 git init。');
18
+ return { exit: 1 };
19
+ }
20
+ const root = toPosix(top);
21
+ ctx = {
22
+ root,
23
+ base: BASE,
24
+ tempDir: TEMP_DIR,
25
+ configRel: `${BASE}/config.json`,
26
+ abs: (rel) => join(root, rel),
27
+ };
28
+ }
29
+
30
+ // 1. 确保 .comate 临时目录存在
31
+ const tempDir = ctx.abs(ctx.tempDir);
32
+ if (!existsSync(tempDir)) {
33
+ ensureDir(tempDir);
34
+ changes.push(`创建临时文件目录 ${ctx.tempDir}/`);
35
+ }
36
+
37
+ // 2. 检查并更新 .gitignore
38
+ const gitignorePath = ctx.abs('.gitignore');
39
+ const gitignoreEntry = `\n${ctx.tempDir}\n`;
40
+
41
+ if (existsSync(gitignorePath)) {
42
+ const content = readFileSync(gitignorePath, 'utf8');
43
+ if (!content.includes(ctx.tempDir)) {
44
+ writeFileSync(gitignorePath, content.trimEnd() + gitignoreEntry);
45
+ changes.push(`已在 .gitignore 中添加 ${ctx.tempDir}`);
46
+ }
47
+ } else {
48
+ writeFileSync(gitignorePath, `${ctx.tempDir}\n`);
49
+ changes.push(`创建 .gitignore 并添加 ${ctx.tempDir}`);
50
+ }
51
+
52
+ // 3. 复制 scaffold 目录(如果尚未初始化)
53
+ const configPath = ctx.abs(ctx.configRel);
54
+ if (!existsSync(configPath)) {
55
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
56
+ const scaffoldSrc = join(scriptDir, '..', '..', 'scaffold', 'comate');
57
+
58
+ if (!existsSync(scaffoldSrc)) {
59
+ log.fail(`找不到 scaffold 目录:${scaffoldSrc}`);
60
+ return { lines: changes.map((c) => ` ✓ ${c}`), exit: 1 };
61
+ }
62
+
63
+ const targetDir = ctx.abs('comate');
64
+ ensureDir(targetDir);
65
+ cpSync(scaffoldSrc, targetDir, { recursive: true });
66
+ changes.push(`已从 scaffold 初始化 ${ctx.base}/`);
67
+ }
68
+
69
+ // 输出结果
70
+ if (changes.length === 0) {
71
+ return { lines: [' · 项目已初始化,无需操作'], exit: 0 };
72
+ } else {
73
+ return { lines: changes.map((c) => ` ✓ ${c}`), exit: 0 };
74
+ }
75
+ }
@@ -0,0 +1,273 @@
1
+ // build ⇄ verify: handoff (freeze candidate + run checks), verify results, probe.
2
+ import { rmSync } from 'node:fs';
3
+ import { relative, resolve } from 'node:path';
4
+
5
+ import { finishProblem } from './finish.mjs';
6
+ import { loadConfig, resolveChecks, runChecks, verifyPlan } from './checks.mjs';
7
+ import { inputPath, load, queueRepairs, recordFailure, requireCleanTree, requireNoDrift, save } from './common.mjs';
8
+ import { head, probeAdd, probeList, probeRemove } from './git.mjs';
9
+ import { writeVerification } from './render.mjs';
10
+ import { requireExpect, requireNode, setNode } from './state.mjs';
11
+ import { repairEntryForCheck, repairEntryForItem } from './tasks.mjs';
12
+ import { BLOCK, USAGE, die, randomId, readJson, toPosix } from './util.mjs';
13
+
14
+ const relIn = (ctx, abs) => toPosix(relative(ctx.root, abs));
15
+ const inChange = (cp, rel) => rel.startsWith(`${cp.rel}/`);
16
+ const dropIfInChange = (ctx, cp, abs) => inChange(cp, relIn(ctx, abs)) && rmSync(abs, { force: true });
17
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
18
+
19
+ export function cmdHandoff(ctx, a) {
20
+ const { cp, st } = load(ctx, a._[0]);
21
+ if (a.flags.recheck) {
22
+ requireNode(st, ['Checking'], 'handoff --recheck');
23
+ requireExpect(st, a.values.expect);
24
+ if (head(ctx) !== st.candidate.commit) return candidateMoved(ctx, cp, st);
25
+ requireCleanTree(ctx, cp, st);
26
+ return startChecking(ctx, cp, st, 'recheck');
27
+ }
28
+ requireNode(st, ['Building', 'Repairing'], 'handoff');
29
+ requireExpect(st, a.values.expect);
30
+ const file = inputPath(ctx, a.values.input, '--input');
31
+ const input = readJson(file, '--input');
32
+ if (!isObj(input)) die(USAGE, '--input 必须是 JSON 对象', { change: st.name });
33
+ const ids = st.acceptance.map((x) => x.id);
34
+
35
+ const problems = [];
36
+ if (typeof input.summary !== 'string' || !input.summary.trim()) problems.push('handoff.summary 必须写本轮改了什么');
37
+ for (const key of ['addressed', 'manual_checks', 'known_limits']) {
38
+ if (input[key] !== undefined && !Array.isArray(input[key])) problems.push(`handoff.${key} 必须是数组`);
39
+ }
40
+ for (const id of Array.isArray(input.addressed) ? input.addressed : []) if (!ids.includes(id)) problems.push(`handoff.addressed 里有未知验收 ID:${id}`);
41
+ for (const m of Array.isArray(input.manual_checks) ? input.manual_checks : []) if (!ids.includes(m?.id) || !m?.steps) problems.push(`manual_checks 每项需要合法 id 和 steps:${JSON.stringify(m)}`);
42
+ requireNoDrift(ctx, cp, st);
43
+ if (!Array.isArray(input.addressed) || input.addressed.length === 0) {
44
+ problems.push('handoff.addressed 必须是非空验收项 ID 数组');
45
+ } else {
46
+ const unresolved = st.loop.previous_unresolved;
47
+ const target = unresolved.length > 0 ? unresolved : ids;
48
+ const uncovered = target.filter((id) => !input.addressed.includes(id));
49
+ if (uncovered.length > 0) {
50
+ const hint = unresolved.length > 0 ? `本轮修复重点:${unresolved.join(', ')}` : `第一轮必须覆盖全部验收项`;
51
+ problems.push(`${hint};遗漏:${uncovered.join(', ')}`);
52
+ }
53
+ }
54
+ problems.push(...resolveChecks(ctx, loadConfig(ctx)).problems);
55
+ if (problems.length) die(BLOCK, `handoff 未通过:\n - ${problems.join('\n - ')}`, { change: st.name });
56
+ requireCleanTree(ctx, cp, st, [relIn(ctx, file)]);
57
+
58
+ const notes = st.candidate?.handoff?.notes ?? [];
59
+ st.candidate = {
60
+ commit: head(ctx),
61
+ at: new Date().toISOString(),
62
+ handoff: { summary: input.summary.trim(), addressed: input.addressed ?? [], manual_checks: input.manual_checks ?? [], known_limits: input.known_limits ?? [], notes },
63
+ checks: [],
64
+ };
65
+ st.loop.repairs = null;
66
+ dropIfInChange(ctx, cp, file);
67
+ return startChecking(ctx, cp, st, 'handoff');
68
+ }
69
+
70
+ // Checking: run the plan against the frozen candidate, then route.
71
+ export function startChecking(ctx, cp, st, event) {
72
+ const cfg = loadConfig(ctx);
73
+ for (const x of st.acceptance) Object.assign(x, { result: 'pending', reason: null });
74
+ st.verifier = null;
75
+ st.blockers = [];
76
+ setNode(st, 'Checking');
77
+ save(cp, st, event, `候选 ${st.candidate.commit.slice(0, 7)},执行检查`);
78
+
79
+ const { plan } = resolveChecks(ctx, cfg);
80
+ st.candidate.verify_plan = verifyPlan(plan);
81
+ const checks = runChecks(ctx, cfg, plan);
82
+ st.candidate.checks = checks;
83
+ const failed = checks.filter((c) => !c.ok);
84
+ if (failed.length) {
85
+ const stop = recordFailure(st, st.acceptance.map((x) => x.id), cfg);
86
+ st.loop.iteration += 1;
87
+ queueRepairs(st, failed.map(repairEntryForCheck));
88
+ setNode(st, stop ? 'Stuck' : 'Repairing');
89
+ save(cp, st, 'checks-failed', failed.map((c) => c.cmd).join(', '));
90
+ return { cp, st, done: `检查失败:${failed.map((c) => `${c.cmd}(${c.timed_out ? '超时' : `exit ${c.exit}`})`).join(';')}` };
91
+ }
92
+ st.loop.attempt = 1;
93
+ st.loop.error_count = 0;
94
+ st.verifier = { token: `v-${st.loop.iteration}-1-${randomId(3)}`, independent: true, risks: [], summary: null, reverse_checks: [] };
95
+ setNode(st, 'AwaitVerifier');
96
+ save(cp, st, 'checks-passed', `${checks.length} 条检查通过`);
97
+ return { cp, st, done: `检查通过(${checks.map((c) => c.cmd).join('、') || '无检查命令'}),签发 verifier token` };
98
+ }
99
+
100
+ // HEAD moved away from the frozen candidate: every result for it is void, and
101
+ // the round counts as a failure so moving HEAD can never dodge the budget.
102
+ export function candidateMoved(ctx, cp, st) {
103
+ const cfg = loadConfig(ctx);
104
+ const unresolved = st.loop.previous_unresolved.length ? st.loop.previous_unresolved : st.acceptance.map((x) => x.id);
105
+ const stop = recordFailure(st, unresolved, cfg);
106
+ st.loop.iteration += 1;
107
+ st.verifier = null;
108
+ for (const x of st.acceptance) Object.assign(x, { result: 'pending', reason: null });
109
+ queueRepairs(st, [`验收期间 HEAD 从候选 ${st.candidate.commit.slice(0, 7)} 变成了 ${head(ctx)?.slice(0, 7)}:确认新提交属于本 change 后重新 handoff`]);
110
+ setNode(st, stop ? 'Stuck' : 'Repairing');
111
+ save(cp, st, 'candidate-moved', `HEAD ${head(ctx)?.slice(0, 7)} ≠ 候选 ${st.candidate.commit.slice(0, 7)}`);
112
+ return { cp, st, done: '验收期间 HEAD 变了,本轮结果作废并计一次失败。确认改动后重新 handoff。' };
113
+ }
114
+
115
+ const RESULTS = { passed: 'passed', pass: 'passed', failed: 'failed', fail: 'failed', blocked: 'blocked' };
116
+
117
+ export function cmdVerify(ctx, a) {
118
+ const { cp, st } = load(ctx, a._[0]);
119
+ requireNode(st, ['AwaitVerifier'], 'verify');
120
+ requireNoDrift(ctx, cp, st);
121
+ const cfg = loadConfig(ctx);
122
+ const reissue = () => `v-${st.loop.iteration}-${st.loop.attempt}-${randomId(3)}`;
123
+
124
+ if (a.values.error !== undefined) {
125
+ st.loop.error_count += 1;
126
+ if (st.loop.error_count >= 3) {
127
+ st.blockers = [{ id: null, reason: `verifier 连续 ${st.loop.error_count} 次执行失败:${a.values.error}` }];
128
+ setNode(st, 'Blocked');
129
+ save(cp, st, 'verifier-error', a.values.error);
130
+ return { cp, st, done: 'verifier 连续失败 3 次,转为阻塞' };
131
+ }
132
+ st.loop.attempt += 1;
133
+ st.verifier.token = reissue();
134
+ save(cp, st, 'verifier-error', a.values.error);
135
+ return { cp, st, done: `verifier 出错(第 ${st.loop.error_count} 次),已重签 token,重新派发` };
136
+ }
137
+ if (a.flags.unavailable) {
138
+ requireExpect(st, a.values.expect);
139
+ if (head(ctx) !== st.candidate.commit) return candidateMoved(ctx, cp, st);
140
+ requireCleanTree(ctx, cp, st);
141
+
142
+ const hasTests = (st.candidate.checks ?? []).some((c) => c.stage === 'verify' && c.kind === 'test' && c.ok === true);
143
+ if (!hasTests) {
144
+ die(BLOCK, '没有自动测试可依赖,不能降级为「仅自动检查」。编译、语法检查或未知命令不算测试;自定义测试用 { "cmd": "<命令>", "kind": "test" } 声明,然后 decide --revise-requirements 重新确认并 handoff,或使用独立 verifier。', { change: st.name });
145
+ }
146
+
147
+ const currentPlan = verifyPlan(resolveChecks(ctx, cfg).plan);
148
+ const confirmedPlan = st.confirm?.verify_plan;
149
+ const candidatePlan = st.candidate.verify_plan;
150
+ if (!Array.isArray(confirmedPlan) || confirmedPlan.some((c) => typeof c !== 'object' || c === null) || !Array.isArray(candidatePlan)) {
151
+ die(BLOCK, '旧状态没有完整测试计划,不能降级。decide --revise-requirements 重新确认,或使用独立 verifier。', { change: st.name });
152
+ }
153
+ if (JSON.stringify(currentPlan) !== JSON.stringify(confirmedPlan) || JSON.stringify(candidatePlan) !== JSON.stringify(confirmedPlan)) {
154
+ die(BLOCK, '测试计划在确认后被改动(包括命令类型或 npm 脚本定义)。恢复测试计划后重新 handoff,或 decide --revise-requirements 重新确认。', { change: st.name });
155
+ }
156
+
157
+ Object.assign(st.verifier, { token: null, independent: false, summary: '平台没有子 agent 能力,只有自动检查' });
158
+ setNode(st, 'Degraded');
159
+ save(cp, st, 'verifier-unavailable', '');
160
+ writeVerification(cp, st);
161
+ return { cp, st, done: '独立 verifier 不可用,等待用户决定是否接受仅自动检查的结果' };
162
+ }
163
+
164
+ const file = inputPath(ctx, a.values.result, '--result');
165
+ const r = readJson(file, '--result');
166
+ if (!isObj(r)) die(USAGE, '--result 必须是 JSON 对象', { change: st.name });
167
+ if (r.token !== st.verifier.token) die(BLOCK, `verifier token 不匹配(结果 ${r.token ?? '无'},当前 ${st.verifier.token}):这是过期或串号的结果`, { change: st.name });
168
+ if (r.candidate !== st.candidate.commit) die(BLOCK, `结果针对的候选 ${r.candidate} 不是当前候选 ${st.candidate.commit}`, { change: st.name });
169
+ if (head(ctx) !== st.candidate.commit) {
170
+ dropIfInChange(ctx, cp, file);
171
+ return candidateMoved(ctx, cp, st);
172
+ }
173
+ requireCleanTree(ctx, cp, st, [relIn(ctx, file)]);
174
+ const problems = validateResult(r, st);
175
+ if (problems.length) die(BLOCK, `verifier 结果不完整,修正 ${relIn(ctx, file)} 后用同一 token 重交:\n - ${problems.join('\n - ')}`, { change: st.name });
176
+
177
+ const byId = new Map(r.acceptance.map((x) => [x.id, x]));
178
+ for (const x of st.acceptance) Object.assign(x, { result: RESULTS[byId.get(x.id).result], reason: byId.get(x.id).reason.trim() });
179
+ Object.assign(st.verifier, { risks: r.risks ?? [], summary: r.summary ?? null, reverse_checks: r.reverse_checks ?? [] });
180
+ dropIfInChange(ctx, cp, file);
181
+
182
+ const failed = st.acceptance.filter((x) => x.result === 'failed');
183
+ const blocked = st.acceptance.filter((x) => x.result === 'blocked');
184
+ let done;
185
+ if (!failed.length && !blocked.length) {
186
+ st.loop.previous_unresolved = [];
187
+ const action = cfg.auto_finish_action ?? 'keep';
188
+ const auto = cfg.finish === 'auto' && !st.verifier.risks.length && !finishProblem(ctx, st, action);
189
+ if (auto) st.finish = { action, by: 'config' };
190
+ setNode(st, auto ? 'ArchiveReady' : 'AwaitAccept');
191
+ done = `${st.acceptance.length} 项验收全部通过${auto ? `(finish: auto,直接归档,收尾 ${action})` : ''}`;
192
+ } else if (blocked.length && failed.length) {
193
+ // 混合阻塞:先解除 blocked,再修 failed
194
+ st.blockers = blocked.map((x) => ({ id: x.id, reason: x.reason }));
195
+ recordFailure(st, failed.map((x) => x.id), cfg);
196
+ st.loop.iteration += 1;
197
+ queueRepairs(st, failed.map(repairEntryForItem));
198
+ setNode(st, 'Blocked');
199
+ done = `${blocked.length} 项无法验证、${failed.length} 项未通过:${blocked.map((x) => x.id).join(', ')} 外部阻塞 + ${failed.map((x) => x.id).join(', ')} 待修复。补充信息后 decide --resolve 继续修 failed 项`;
200
+ } else if (failed.length) {
201
+ const unresolved = failed.map((x) => x.id);
202
+ const stop = recordFailure(st, unresolved, cfg);
203
+ st.loop.iteration += 1;
204
+ queueRepairs(st, failed.map(repairEntryForItem));
205
+ setNode(st, stop ? 'Stuck' : 'Repairing');
206
+ done = `${failed.length} 项未通过:${unresolved.join(', ')}${stop ? `;${stop}` : ',自动回 build 修复'}`;
207
+ } else {
208
+ st.blockers = blocked.map((x) => ({ id: x.id, reason: x.reason }));
209
+ setNode(st, 'Blocked');
210
+ done = `${blocked.length} 项无法验证,需要用户或外部信息`;
211
+ }
212
+ save(cp, st, 'verify-result', done);
213
+ writeVerification(cp, st);
214
+ return { cp, st, done };
215
+ }
216
+
217
+ export function validateResult(r, st) {
218
+ const p = [];
219
+ if (!Array.isArray(r.acceptance)) return ['acceptance 必须是数组'];
220
+ const ids = st.acceptance.map((x) => x.id);
221
+ const seen = new Map();
222
+ const validItems = []; // 收集有效项,供后续使用
223
+ for (const x of r.acceptance) {
224
+ if (!isObj(x)) {
225
+ p.push(`acceptance 里有非对象项:${JSON.stringify(x)}`);
226
+ continue;
227
+ }
228
+ validItems.push(x);
229
+ if (!ids.includes(x.id)) p.push(`未知验收 ID:${x.id}`);
230
+ seen.set(x.id, (seen.get(x.id) ?? 0) + 1);
231
+ if (!RESULTS[String(x.result).toLowerCase()]) p.push(`${x.id} 的 result 必须是 passed / failed / blocked`);
232
+ else x.result = String(x.result).toLowerCase();
233
+ if (typeof x.reason !== 'string' || !x.reason.trim()) p.push(`${x.id} 缺少 reason(写明证据)`);
234
+ }
235
+ for (const id of ids) {
236
+ if (!seen.has(id)) p.push(`漏报 ${id}`);
237
+ else if (seen.get(id) > 1) p.push(`${id} 报了 ${seen.get(id)} 次,每项恰好一次`);
238
+ }
239
+ const allPassed = validItems.every((x) => RESULTS[x.result] === 'passed');
240
+ const hasTests = (st.candidate.checks ?? []).some((c) => c.stage === 'verify' && c.ok === true);
241
+ const rev = r.reverse_checks ?? [];
242
+ if (!Array.isArray(rev)) p.push('reverse_checks 必须是数组');
243
+ else {
244
+ for (const c of rev) {
245
+ if (!isObj(c) || !ids.includes(c.target) || typeof c.mutation !== 'string' || !c.mutation.trim() || typeof c.test !== 'string' || !c.test.trim() || typeof c.failed_as_expected !== 'boolean') {
246
+ p.push(`反向验证「${c?.target ?? '(空)'}」必须包含合法验收 ID、mutation、test 和 failed_as_expected`);
247
+ } else if (c.failed_as_expected !== true) {
248
+ const targetItem = validItems.find((x) => x.id === c.target);
249
+ if (targetItem && RESULTS[targetItem.result] === 'passed') {
250
+ p.push(`反向验证「${c.target}」测试没有变红:测试证明不了行为,对应项应判 failed`);
251
+ }
252
+ }
253
+ }
254
+ if (allPassed && hasTests && rev.length === 0) p.push('全部通过时至少要有 1 条反向验证(reverse_checks),bug 修复必做');
255
+ }
256
+ if (r.risks !== undefined && !Array.isArray(r.risks)) p.push('risks 必须是数组');
257
+ return p;
258
+ }
259
+
260
+ const norm = (p) => toPosix(resolve(p)).toLowerCase();
261
+
262
+ export function cmdProbe(ctx, a) {
263
+ const { cp, st } = load(ctx, a._[0]);
264
+ if (a.values.remove) {
265
+ const hit = probeList(ctx).find((d) => norm(d) === norm(a.values.remove));
266
+ if (!hit) die(USAGE, `${a.values.remove} 不是 comate 建的 probe worktree(git worktree list 里没有),拒绝删除`);
267
+ probeRemove(ctx, hit);
268
+ return { lines: [`已删除 ${hit}`], json: { removed: hit } };
269
+ }
270
+ if (!st.candidate) die(BLOCK, '还没有候选实现,无法建 probe', { change: st.name });
271
+ const dir = probeAdd(ctx, st.name, st.candidate.commit);
272
+ return { lines: [dir], json: { dir, commit: st.candidate.commit, remove: `node "${ctx.script}" probe ${cp.name} --remove ${dir}` } };
273
+ }