@accelerator-mzq/forge 3.0.0 → 4.0.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 (105) hide show
  1. package/README.md +12 -12
  2. package/dist/cli/commands/ack.d.ts.map +1 -1
  3. package/dist/cli/commands/ack.js +87 -12
  4. package/dist/cli/commands/ack.js.map +1 -1
  5. package/dist/cli/commands/archive.d.ts +32 -33
  6. package/dist/cli/commands/archive.d.ts.map +1 -1
  7. package/dist/cli/commands/archive.js +339 -667
  8. package/dist/cli/commands/archive.js.map +1 -1
  9. package/dist/cli/commands/legacy-bridge.js +1 -1
  10. package/dist/cli/commands/legacy-bridge.js.map +1 -1
  11. package/dist/cli/commands/upgrade.d.ts.map +1 -1
  12. package/dist/cli/commands/upgrade.js +2 -51
  13. package/dist/cli/commands/upgrade.js.map +1 -1
  14. package/dist/cli/commands/validate.d.ts.map +1 -1
  15. package/dist/cli/commands/validate.js +4 -25
  16. package/dist/cli/commands/validate.js.map +1 -1
  17. package/dist/cli/index.js +0 -9
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/core/ack/marker-ack.d.ts +28 -0
  20. package/dist/core/ack/marker-ack.d.ts.map +1 -0
  21. package/dist/core/ack/marker-ack.js +155 -0
  22. package/dist/core/ack/marker-ack.js.map +1 -0
  23. package/dist/core/ack-log.d.ts.map +1 -1
  24. package/dist/core/ack-log.js +10 -7
  25. package/dist/core/ack-log.js.map +1 -1
  26. package/dist/core/archive/index.d.ts +2 -3
  27. package/dist/core/archive/index.d.ts.map +1 -1
  28. package/dist/core/archive/index.js +7 -5
  29. package/dist/core/archive/index.js.map +1 -1
  30. package/dist/core/archive/pause-decisions-fence.js +12 -12
  31. package/dist/core/archive/pause-decisions-fence.js.map +1 -1
  32. package/dist/core/archive/summary-builder.d.ts +26 -21
  33. package/dist/core/archive/summary-builder.d.ts.map +1 -1
  34. package/dist/core/archive/summary-builder.js +115 -223
  35. package/dist/core/archive/summary-builder.js.map +1 -1
  36. package/dist/core/archive/summary-render.d.ts +5 -3
  37. package/dist/core/archive/summary-render.d.ts.map +1 -1
  38. package/dist/core/archive/summary-render.js +38 -44
  39. package/dist/core/archive/summary-render.js.map +1 -1
  40. package/dist/core/lock.d.ts +46 -0
  41. package/dist/core/lock.d.ts.map +1 -0
  42. package/dist/core/lock.js +98 -0
  43. package/dist/core/lock.js.map +1 -0
  44. package/dist/core/markers/types.d.ts +27 -134
  45. package/dist/core/markers/types.d.ts.map +1 -1
  46. package/dist/core/markers/types.js +10 -1
  47. package/dist/core/markers/types.js.map +1 -1
  48. package/dist/core/migrate/index.js +1 -1
  49. package/dist/core/migrate/index.js.map +1 -1
  50. package/dist/core/monitor/artifact-observer.d.ts.map +1 -1
  51. package/dist/core/monitor/artifact-observer.js +28 -78
  52. package/dist/core/monitor/artifact-observer.js.map +1 -1
  53. package/dist/core/monitor/divergence-map.d.ts.map +1 -1
  54. package/dist/core/monitor/divergence-map.js +9 -7
  55. package/dist/core/monitor/divergence-map.js.map +1 -1
  56. package/dist/core/monitor/health-verdict.d.ts.map +1 -1
  57. package/dist/core/monitor/health-verdict.js +2 -1
  58. package/dist/core/monitor/health-verdict.js.map +1 -1
  59. package/dist/core/monitor/trace-store.d.ts +1 -1
  60. package/dist/core/monitor/trace-store.js +2 -2
  61. package/dist/core/monitor/trace-store.js.map +1 -1
  62. package/dist/core/monitor/types.d.ts +1 -1
  63. package/dist/core/monitor/types.d.ts.map +1 -1
  64. package/dist/core/monitor/types.js +0 -1
  65. package/dist/core/monitor/types.js.map +1 -1
  66. package/dist/core/schemas/archive-summary.d.ts +39 -109
  67. package/dist/core/schemas/archive-summary.d.ts.map +1 -1
  68. package/dist/core/schemas/archive-summary.js +15 -23
  69. package/dist/core/schemas/archive-summary.js.map +1 -1
  70. package/dist/core/templates/commands/ack-confirm.md +1 -1
  71. package/dist/core/templates/commands/apply.md +51 -114
  72. package/dist/core/templates/commands/archive.md +43 -160
  73. package/dist/core/templates/commands/review.md +49 -74
  74. package/dist/core/templates/commands/upgrade.md +21 -40
  75. package/dist/core/templates/commands/verify.md +43 -146
  76. package/dist/core/templates/skills/_shared/tier23-command-bridge.md +34 -0
  77. package/dist/core/templates/skills/exploring.md +3 -3
  78. package/dist/core/templates/skills/finishing-a-development-branch.md +6 -21
  79. package/dist/core/templates/skills/receiving-code-review.md +14 -45
  80. package/dist/core/templates/skills/requesting-code-review.md +11 -0
  81. package/dist/core/templates/skills/subagent-driven-development.md +33 -29
  82. package/dist/core/templates/skills/subagent-driven-discipline.md +28 -28
  83. package/dist/core/templates/skills/using-forge.md +25 -24
  84. package/dist/core/templates/skills/verification-before-completion.md +7 -18
  85. package/dist/core/templates/skills/verifying-three-dimensions.md +93 -169
  86. package/dist/core/templates/skills/writing-plans.md +6 -17
  87. package/dist/core/validate/archive-summary-schema.d.ts +1 -1
  88. package/dist/core/validate/archive-summary-schema.d.ts.map +1 -1
  89. package/dist/core/validate/archive-summary-schema.js +101 -125
  90. package/dist/core/validate/archive-summary-schema.js.map +1 -1
  91. package/dist/core/validate/index.d.ts +0 -1
  92. package/dist/core/validate/index.d.ts.map +1 -1
  93. package/dist/core/validate/index.js +2 -1
  94. package/dist/core/validate/index.js.map +1 -1
  95. package/dist/core/validate/marker-schema.d.ts +12 -4
  96. package/dist/core/validate/marker-schema.d.ts.map +1 -1
  97. package/dist/core/validate/marker-schema.js +98 -605
  98. package/dist/core/validate/marker-schema.js.map +1 -1
  99. package/dist/index.d.ts +1 -1
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +8 -1
  102. package/dist/index.js.map +1 -1
  103. package/package.json +5 -4
  104. package/scripts/codex-review-helper.mjs +337 -0
  105. package/src/core/codex-review/prompts/adversarial-default.md +105 -0
@@ -1,625 +1,352 @@
1
- // forge archive 子命令 — 归档 change(spec §3.5)
2
- // 支持:
3
- // forge archive <changeId> — 正常归档(检查 marker + hash + lock)
4
- // forge archive <changeId> --force — 接受 human-override 标记 git 项目
5
- // forge archive --recover — 从半完成状态恢复
6
- // forge archive --resume-summary <archiveId> 恢复半完成 .tmp summary rename
1
+ // forge archive 子命令 — v4 OpenSpec alignment 简版(plan-v4 Phase 1 Task 1.5)
2
+ //
3
+ // v4 BREAKING(沿 plan-v4 §6.1 v9 + §10.1.2):
4
+ // - 删反加固协议:transaction / lock / recover / resume-summary / 13 fence / process_evidence /
5
+ // ack-log / verify-findings / pause-decisions-fence / three-level-fence / legacy-exemption /
6
+ // version-retrograde / hash-mismatch fence / git integrity fence
7
+ // - 删 CLI flag:--recover / --resume-summary / --resume
8
+ // - 新增 CLI flag:--yes(沿 OpenSpec)/ --skip-specs(沿 OpenSpec)
9
+ // - 保留:--force(human-override / 非 git)/ --api(legacy-bridge sync-check 直连)
10
+ // - marker 校验 schema v1 → v2(VerifyMarker / ReviewMarker v2,无 hash/git/process_evidence)
11
+ // - spec deltas:沿用 forge 现有 readDeltas + applyDeltas(Path B — 不移植 OpenSpec specs-apply)
12
+ // - archive_summary v1 → v2(无 acked_warnings / pending_suggestions / process_evidence_summary)
13
+ // - 保留 legacy-bridge preflight/posthook 接口(本文件内 export,sync-check 双路径不变)
7
14
  import { Command } from 'commander';
8
- import { readFile, rm, rename, readdir, copyFile, mkdir, writeFile } from 'node:fs/promises';
15
+ import { readFile, writeFile, readdir, mkdir, rename, cp, rm, stat } from 'node:fs/promises';
9
16
  import { existsSync, statSync } from 'node:fs';
10
- import { execFileSync } from 'node:child_process';
17
+ import { createInterface } from 'node:readline/promises';
18
+ import { stdin, stdout } from 'node:process';
11
19
  import { join } from 'node:path';
12
- import { parse as parseYaml } from 'yaml';
20
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
13
21
  import { parseMarker } from '../../core/markers/index.js';
14
22
  import { validateMarkerSchema } from '../../core/validate/index.js';
15
- import { validateEvidence, validateReviewGitIntegrity, validateReviewOutcomes, } from '../../core/validate/marker-integrity.js';
16
- import { computeTasksHash, computeContentHash } from '../../core/hash/index.js';
17
- import { archiveTransaction } from '../../core/archive/transaction.js';
18
- import { acquireLock, LockHeldError } from '../../core/archive/lock.js';
19
- import { recover } from '../../core/archive/recover.js';
20
- import { promptRecoverChoice } from '../../core/archive/recover-prompt.js';
21
- import { applyDeltas } from '../../core/specs-sync/index.js';
23
+ import { validateChange } from '../../core/validate/change.js';
24
+ import { readDeltas, applyDeltas } from '../../core/specs-sync/index.js';
25
+ import { buildArchiveSummary } from '../../core/archive/summary-builder.js';
26
+ import { renderArchiveSummaryOutput } from '../../core/archive/summary-render.js';
27
+ import { generateBacklog } from '../../core/backlog/index.js';
28
+ import { appendTraceEvent } from '../../core/monitor/trace-store.js';
29
+ import { FORGE_VERSION } from '../../index.js';
30
+ // —— legacy-bridge sync-check 双路径依赖(仅 runArchivePreflight/PostHook + emit 用)——
22
31
  import { loadAnchorsFile } from '../../core/legacy-bridge/anchors.js';
23
32
  import { checkAck } from '../../core/legacy-bridge/ack.js';
24
33
  import { buildSyncCheckTask, applySyncCheckResult } from '../../core/legacy-bridge/sync-check.js';
25
34
  import { AgentHandoffRunner, ApiRunner, makeForgeApiClient, } from '../../core/legacy-bridge/runners.js';
26
35
  import { renderDiffMarkdown, renderDiffYaml } from '../../core/legacy-bridge/diff-report.js';
27
36
  import { readAnchorFile } from '../../core/legacy-bridge/encoding.js';
28
- import { FORGE_VERSION } from '../../index.js';
29
- // Task 8 (plan-9a §9): cross-cutting fence framework — 9g 实施完整 13 不变量逻辑
30
- import { crossCuttingFenceCheck } from '../../core/archive/fence.js';
31
- // plan-9d Task 6:verify_findings fence + ack-log consistency
32
- import { validateVerifyFindingsFence } from '../../core/archive/verify-findings-fence.js';
33
- import { validateAckLogConsistency } from '../../core/archive/ack-log-consistency.js';
34
- // plan-9c Task 2:pause_decisions fence + Task 12:cross-check
35
- import { validatePauseDecisionsFence, crossCheckPauseDecisions, } from '../../core/archive/pause-decisions-fence.js';
36
- // plan-9e1 Task 4:三级 fence + summary builder + render + ScopeEntriesIntegrityError(v2 BLOCKER 4)
37
- import { validateThreeLevelFence } from '../../core/archive/three-level-fence.js';
38
- import { buildArchiveSummary, ScopeEntriesIntegrityError, } from '../../core/archive/summary-builder.js';
39
- import { renderArchiveSummaryOutput } from '../../core/archive/summary-render.js';
40
- // plan-backlog-registry Task 7:archive 成功后自动重生成 forge/backlog/
41
- import { generateBacklog } from '../../core/backlog/index.js';
42
- // plan-9e1 Task 5:resume-summary 子模式
43
- import { resumeArchiveSummary } from '../../core/archive/resume-summary.js';
44
- // plan-9j Task 5:legacy-exemption + version-retrograde fence
45
- import { validateLegacyExemption } from '../../core/archive/legacy-exemption.js';
46
- import { validateVersionRetrograde } from '../../core/archive/version-retrograde-fence.js';
37
+ // ============================================================================
38
+ // 本地 helper(沿 plan §10.1.2 Task 1.5 v6 修订)
39
+ // ============================================================================
47
40
  /**
48
- * 检测当前目录是否真实处于 git 工作树中
49
- * P1 修复:用真实 git 状态决定,不信 marker 字段
50
- * @param cwd 要检测的目录路径
51
- * @returns true = 真实 git 工作树, false = git
41
+ * getArchiveDate:返回 YYYY-MM-DD(沿 plan §6.1 v4 F-R3-2 — archive 主流程算一次)
42
+ *
43
+ * 关键:archive.ts 内只能调一次,后续 archiveName + posthook 共用同一变量,
44
+ * 防跨午夜 archive archiveName day N、posthook day N+1 不一致。
45
+ */
46
+ function getArchiveDate() {
47
+ return new Date().toISOString().slice(0, 10);
48
+ }
49
+ /**
50
+ * moveDirectory:rename 跨盘失败时降级到 cp + rm(沿 OpenSpec EPERM/EXDEV fallback)
51
+ *
52
+ * Windows 上 rename 同盘正常,跨盘抛 EXDEV;某些权限场景抛 EPERM。
53
+ * 失败时用 fs.cp 递归复制 + rm 删源,语义等同 mv。
52
54
  */
53
- function isProjectActuallyGit(cwd) {
55
+ async function moveDirectory(src, dst) {
54
56
  try {
55
- execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
56
- cwd,
57
- encoding: 'utf8',
58
- stdio: ['ignore', 'pipe', 'ignore'],
59
- });
60
- return true;
57
+ await rename(src, dst);
58
+ }
59
+ catch (err) {
60
+ const code = err.code;
61
+ if (code === 'EXDEV' || code === 'EPERM' || code === 'EACCES') {
62
+ // 跨盘 / 权限错 → cp + rm 模拟 mv
63
+ await cp(src, dst, { recursive: true });
64
+ await rm(src, { recursive: true, force: true });
65
+ return;
66
+ }
67
+ throw err;
68
+ }
69
+ }
70
+ /**
71
+ * renderDeltasSummary:把 SpecDelta[] 渲染成用户可读字符串(供 confirm 前显示)
72
+ *
73
+ * 沿 plan §6.1 v6 修订(Codex v5 F-R5-2):archive.ts 内本地 helper(~10 行),
74
+ * 不在 specs-sync/ 新增 — Path B "无新增代码" 仅指 specs-sync/ 不变。
75
+ */
76
+ function renderDeltasSummary(deltas) {
77
+ const lines = ['Spec deltas to apply:'];
78
+ for (const d of deltas) {
79
+ lines.push(` - ${d.name}: ${d.operation}`);
80
+ }
81
+ return lines.join('\n');
82
+ }
83
+ /**
84
+ * selectChange:在 forge/changes/ 下交互式让 user 选 changeId(若没传 argv)
85
+ *
86
+ * 沿 OpenSpec archive.ts 风格:列出 active change(忽略 archive/ 子目录),按 mtime 倒序,
87
+ * 提示用户输入序号。空目录返 undefined。
88
+ */
89
+ async function selectChange(changesDir) {
90
+ const entries = await readdir(changesDir).catch(() => []);
91
+ const candidates = [];
92
+ for (const name of entries) {
93
+ if (name === 'archive')
94
+ continue;
95
+ const stats = await stat(join(changesDir, name)).catch(() => null);
96
+ if (!stats || !stats.isDirectory())
97
+ continue;
98
+ candidates.push({ name, mtime: stats.mtimeMs });
99
+ }
100
+ if (candidates.length === 0)
101
+ return undefined;
102
+ // 按 mtime 倒序(最近修改的优先)
103
+ candidates.sort((a, b) => b.mtime - a.mtime);
104
+ console.log('Active changes:');
105
+ for (let i = 0; i < candidates.length; i++) {
106
+ console.log(` ${i + 1}. ${candidates[i].name}`);
107
+ }
108
+ const rl = createInterface({ input: stdin, output: stdout });
109
+ try {
110
+ const ans = (await rl.question(`Select change number (1-${candidates.length}): `)).trim();
111
+ const idx = Number(ans) - 1;
112
+ if (!Number.isInteger(idx) || idx < 0 || idx >= candidates.length) {
113
+ console.error(`✗ Invalid selection: ${ans}`);
114
+ return undefined;
115
+ }
116
+ return candidates[idx].name;
61
117
  }
62
- catch {
63
- return false;
118
+ finally {
119
+ rl.close();
64
120
  }
65
121
  }
122
+ /**
123
+ * confirmPrompt:Y/n 交互(--yes 时跳过 — 由 caller 判断)
124
+ *
125
+ * defaultYes=true:回车 = yes(spec deltas 等 safe-by-default 场景)
126
+ * defaultYes=false:回车 = no(incomplete tasks 等 dangerous-by-default 场景,沿 plan §6.1
127
+ * line 326 `default: false`;Codex Phase 1 review F-2 修订)
128
+ * 沿 forge 现有 readline/promises 惯例(upgrade.ts:144 / migrate/index.ts 同模式)。
129
+ */
130
+ async function confirmPrompt(message, defaultYes) {
131
+ const rl = createInterface({ input: stdin, output: stdout });
132
+ try {
133
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
134
+ const ans = (await rl.question(`${message} ${hint} `)).trim().toLowerCase();
135
+ if (ans === '')
136
+ return defaultYes;
137
+ return ans === 'y' || ans === 'yes';
138
+ }
139
+ finally {
140
+ rl.close();
141
+ }
142
+ }
143
+ /**
144
+ * countIncompleteTasks:数 tasks.md 内未打勾的 task(- [ ] 行计数)
145
+ *
146
+ * 沿 OpenSpec archive.ts — 未完成 task 时 prompt confirm(--yes 跳过)
147
+ */
148
+ async function countIncompleteTasks(changeDir) {
149
+ const tasksPath = join(changeDir, 'tasks.md');
150
+ if (!existsSync(tasksPath))
151
+ return 0;
152
+ const text = await readFile(tasksPath, 'utf8');
153
+ // 匹配 "- [ ] " 行(空格代表未打勾)
154
+ const matches = text.match(/^\s*- \[ \]\s+/gm);
155
+ return matches ? matches.length : 0;
156
+ }
157
+ // ============================================================================
158
+ // buildArchiveCommand — commander 工厂(沿 plan §6.1 v7 F-R6-1)
159
+ // ============================================================================
160
+ /**
161
+ * Forge archive 命令工厂 — v4 简版(沿 plan §6.1 v9 伪代码)
162
+ *
163
+ * 9 步主流程:
164
+ * 1. forgeRoot + changeId selection
165
+ * 2. legacy-bridge preflight(可 graceful skip)
166
+ * 3. spec validate(blocking,永远跑)
167
+ * 4. marker check + v2 schema validate
168
+ * 5. task progress confirm(--yes 跳过)
169
+ * 6. spec deltas(--skip-specs 跳;否则 readDeltas + confirm + applyDeltas)
170
+ * 7. mv change → archive/<archiveDate>-<changeId>(算一次 archiveDate)
171
+ * 8. buildArchiveSummary + 写 archive_summary.yaml
172
+ * 9. legacy-bridge posthook + generateBacklog + appendTraceEvent
173
+ */
66
174
  export function buildArchiveCommand() {
67
- return (new Command('archive')
175
+ return new Command('archive')
68
176
  .argument('[changeId]', 'change directory id (e.g., add-login)')
69
177
  .description('Archive a verified+reviewed change')
70
- .option('--force', 'accept human-override or non-git review markers')
71
- .option('--recover', 'recover from a half-completed archive transaction')
72
- .option('--resume-summary <archiveId>', 'resume rename archive_summary.tmp.yaml archive_summary.yaml (rare half-completed state, plan-9e1)')
73
- // Task 6.3:--api 模式 — preflight/posthook 的 sync-check 进程内直连 API 跑完,
74
- // 不 emit manifest、不写暂停态、不 halt(高保证 CI 场景绕过 agent-pause)
178
+ .option('--force', 'accept human-override markers')
179
+ .option('--yes', 'auto-accept all interactive confirms (CI mode)')
180
+ .option('--skip-specs', 'skip applying spec deltas to forge/specs/')
75
181
  .option('--api', '直连 Anthropic API 跑 sync-check(不 emit manifest、不暂停)')
76
- // Task 6.4:--resume — agent 履行 manifest 并跑 sync-check --apply 后,续跑 archive 主流程
77
- .option('--resume', 'agent 履行后续跑 archive(gate 复核:produced_from 绑定 + critical 幂等重评)')
78
182
  .action(async (changeId, opts) => {
79
- const forgeRoot = join(process.cwd(), 'forge');
80
- // —— --recover 独立路径 ——
81
- if (opts.recover) {
82
- let release;
83
- try {
84
- release = await acquireLock(forgeRoot, 'recover');
85
- const r = await recover(forgeRoot);
86
- console.log(r.message);
87
- // Plan 6:case C 处理 — 完整性 check + 交互二选一
88
- if (r.case === 'C' && r.caseCData) {
89
- const { backupIntegrity, archiveIntegrity } = r.caseCData;
90
- // 任一不完整 → 退出码 4(spec §3.5 case C 末尾"任一不完整 → 输出诊断 + 退出码 4")
91
- if (!backupIntegrity.ok || !archiveIntegrity.ok) {
92
- console.error('✗ case C 完整性 check 失败,需人工介入:');
93
- if (!backupIntegrity.ok)
94
- console.error(` backup: ${backupIntegrity.reason}`);
95
- if (!archiveIntegrity.ok)
96
- console.error(` archive: ${archiveIntegrity.reason}`);
97
- const rel = release;
98
- release = undefined;
99
- if (rel)
100
- await rel();
101
- process.exit(4);
102
- }
103
- // 两者都完整 → 交互式二选一
104
- const choice = await promptRecoverChoice();
105
- if (choice === 'complete-archive') {
106
- // 完成归档:重跑 Sync(applyDeltas)+ 删 backup
107
- await applyDeltas(r.caseCData.currentSpecsDir, r.caseCData.deltas);
108
- await rm(r.caseCData.backupDir, { recursive: true, force: true });
109
- console.log('✓ case C 选择 [1] 完成归档:Sync 重跑成功,backup 已清理');
110
- }
111
- else {
112
- // 撤销归档:从 backup 恢复 forge/specs/ + 反向 rename
113
- await restoreSpecsFromBackup(r.caseCData.backupDir, r.caseCData.currentSpecsDir);
114
- // rename 失败时 specs 已还原但 archive 仍在原位,整体可重入(再跑 --recover 仍可完成)
115
- await rename(r.caseCData.archiveChangeDir, join(forgeRoot, 'changes', r.caseCData.changeOrigId));
116
- await rm(r.caseCData.backupDir, { recursive: true, force: true });
117
- console.log('✓ case C 选择 [2] 撤销归档:specs 已还原,archive 反向 rename 完成');
118
- }
119
- const rel = release;
120
- release = undefined;
121
- if (rel)
122
- await rel();
123
- process.exit(0);
124
- }
125
- if (r.case === 'corrupt') {
126
- // C2 修复:先 release lock 再 exit,避免 process.exit 跳过 finally
127
- // 置 undefined 防止 finally 再次调用
128
- const rel = release;
129
- release = undefined;
130
- if (rel)
131
- await rel();
132
- process.exit(4);
133
- }
134
- // C2 修复:先 release lock 再 exit,置 undefined 防止 finally 重复调用
135
- const rel = release;
136
- release = undefined;
137
- if (rel)
138
- await rel();
139
- process.exit(0);
140
- }
141
- catch (err) {
142
- if (err instanceof LockHeldError) {
143
- console.error(err.message);
144
- // LockHeldError 时 release 未赋值,无需 release
145
- process.exit(5);
146
- }
147
- throw err;
148
- }
149
- finally {
150
- if (release)
151
- await release();
152
- }
153
- }
154
- // —— plan-9e1 Task 5:--resume-summary 独立路径(v2 BLOCKER 1 + 5 修订)——
155
- if (opts.resumeSummary) {
156
- let release;
157
- try {
158
- release = await acquireLock(forgeRoot, 'resume-summary');
159
- const r = await resumeArchiveSummary(forgeRoot, opts.resumeSummary);
160
- if (r.kind === 'ok') {
161
- console.log(r.message);
162
- const rel = release;
163
- release = undefined;
164
- if (rel)
165
- await rel();
166
- process.exit(0);
167
- }
168
- // v2 BLOCKER 5:corrupt → exit 3(沿 master §3.12.3 corrupt 档)
169
- if (r.kind === 'corrupt') {
170
- console.error(r.message);
171
- const rel = release;
172
- release = undefined;
173
- if (rel)
174
- await rel();
175
- process.exit(3);
176
- }
177
- // conflict / missing 都是 business-fail → exit 1
178
- console.error(r.message);
179
- const rel = release;
180
- release = undefined;
181
- if (rel)
182
- await rel();
183
- process.exit(1);
184
- }
185
- catch (err) {
186
- if (err instanceof LockHeldError) {
187
- // v2 BLOCKER 1:lock-held → exit 2(对齐 master §3.12.3 freeze;v0.4 --recover exit 5 是遗留)
188
- console.error(err.message);
189
- process.exit(2);
190
- }
191
- throw err;
192
- }
193
- finally {
194
- if (release)
195
- await release();
196
- }
183
+ const targetPath = process.cwd();
184
+ const forgeRoot = join(targetPath, 'forge');
185
+ const changesDir = join(forgeRoot, 'changes');
186
+ const archiveDir = join(changesDir, 'archive');
187
+ const mainSpecsDir = join(forgeRoot, 'specs');
188
+ // —— 1. forge/changes/ 存在性 + changeId 选择 ——
189
+ if (!existsSync(changesDir)) {
190
+ console.error("✗ No forge/changes/ directory. Run 'forge init' first.");
191
+ process.exit(1);
197
192
  }
198
- // —— Task 6.4:--resume 独立路径(agent 履行后 gate 复核 + 续跑 archive)——
199
- // I-2:暂停态文件此处不删 —— 仅记下路径,延后到 archiveTransaction 成功后才删。
200
- // 若 gate 通过但后续 marker check / fence / archiveTransaction 任一失败,
201
- // 暂停态保留 → 用户修好后可再 `forge archive --resume` 重入(否则会因
202
- // 「无暂停态文件」被拒,--resume 失去重入性陷入死角)。
203
- let resumePausePath;
204
- if (opts.resume) {
193
+ if (!changeId) {
194
+ changeId = await selectChange(changesDir);
205
195
  if (!changeId) {
206
- console.error('✗ --resume 需要 changeId');
196
+ console.error('✗ No active change selected.');
207
197
  process.exit(1);
208
198
  }
209
- // gate 复核:produced_from 绑定 + critical 幂等重评
210
- const gateResult = await resumeArchiveGateCheck(forgeRoot, changeId);
211
- if (!gateResult.ok) {
212
- console.error(`✗ --resume gate 复核失败:${gateResult.reason}`);
213
- process.exit(2); // business-rule-fail
214
- }
215
- // gate 通过 → 记下暂停态路径(延后删),fall-through 续跑正常 archive 主流程
216
- resumePausePath = join(forgeRoot, '.cache', `archive-pause-${changeId}.json`);
217
- console.log(`✓ --resume gate 通过;续跑 archive ${changeId}…`);
218
- // --resume 续跑后,opts.resume=true 不影响后续 opts 读取(changeId 已确认存在)
219
199
  }
220
- // —— 正常 archive 路径 ——
221
- if (!changeId) {
222
- console.error('changeId required (unless using --recover)');
200
+ const changeDir = join(changesDir, changeId);
201
+ if (!existsSync(changeDir) || !statSync(changeDir).isDirectory()) {
202
+ console.error(`✗ Change '${changeId}' not found in forge/changes/`);
223
203
  process.exit(1);
224
204
  }
225
- // C2 修复:改为 let,允许在 process.exit 前先 release
226
- let archiveRelease;
227
- try {
228
- archiveRelease = await acquireLock(forgeRoot, 'archive');
229
- // Plan 7 PREFLIGHT(spec §2.5 line 183-204):acquireLock 之后、archive 严格门禁(marker check)之前。
230
- // 设计意图:用户立即拿到 sync-state 报告,不被 marker 失败遮蔽。
231
- // 决策 #23:复用 archive.lock,不再 acquire legacy-bridge.lock。
232
- // preflight 不再 process.exit,改返 PreflightResult,caller 在 try 块内手动 release+exit
233
- // marker check 现有 inline-release convention 一致(避免 process.exit 跳过 finally 致锁残留)
234
- // Task 6.3:--api 透传 —— agent 模式 emit manifest+halt;--api 模式进程内跑 sync-check
235
- // Task 6.4:--resume 时跳过 preflight — gate 复核已在 --resume 分支完成(暂停态也已清除),
236
- // 再跑 preflight 会因 enforce_sync=true 再次 emit manifest + halt,造成死循环。
237
- if (!opts.resume) {
238
- const preflightResult = await runArchivePreflight(forgeRoot, changeId, {
239
- api: opts.api ?? false,
240
- });
241
- if (preflightResult.kind !== 'ok') {
242
- console.error(preflightResult.message);
243
- await archiveRelease();
244
- process.exit(2);
245
- }
246
- }
247
- // plan-9g 默认开启 14 不变量 fence(brainstorm v6 删两 opt-in flag);所有 archive 调用都跑
248
- const fenceResult = await crossCuttingFenceCheck(join(forgeRoot, 'changes', changeId));
249
- if (!fenceResult.ok) {
250
- console.error('✗ cross-cutting fence rejected archive:');
251
- for (const r of fenceResult.results) {
252
- if (!r.ok)
253
- console.error(` - ${r.invariant}: ${r.reason}`);
254
- }
255
- const rel = archiveRelease;
256
- archiveRelease = undefined;
257
- if (rel)
258
- await rel();
259
- process.exit(1);
260
- }
261
- const changeDir = join(forgeRoot, 'changes', changeId);
262
- const verifyPath = join(changeDir, '.verify-passed');
263
- const reviewPath = join(changeDir, '.review-passed');
264
- // 步骤 1:检查 .verify-passed 和 .review-passed 都存在
265
- if (!existsSync(verifyPath)) {
266
- console.error(`✗ archive 拒绝:缺 .verify-passed (in ${changeDir})`);
267
- // C2 修复:先 release lock 再 exit
268
- await archiveRelease();
269
- process.exit(2);
270
- }
271
- if (!existsSync(reviewPath)) {
272
- console.error(`✗ archive 拒绝:缺 .review-passed (in ${changeDir})`);
273
- // C2 修复:先 release lock 再 exit
274
- await archiveRelease();
275
- process.exit(2);
276
- }
277
- // 步骤 2:解析 + schema 校验两个 marker
278
- const verifyText = await readFile(verifyPath, 'utf8');
279
- const reviewText = await readFile(reviewPath, 'utf8');
280
- const verifyMarker = parseMarker(verifyText);
281
- const reviewMarker = parseMarker(reviewText);
282
- // 步骤 2a:schema 结构校验
283
- const verifyResult = validateMarkerSchema(verifyMarker, verifyPath);
284
- if (!verifyResult.valid) {
285
- console.error(`✗ .verify-passed marker schema 校验失败:${verifyResult.errors[0]?.message}`);
286
- // C2 修复:先 release lock 再 exit
287
- await archiveRelease();
288
- process.exit(2);
289
- }
290
- const reviewResult = validateMarkerSchema(reviewMarker, reviewPath);
291
- if (!reviewResult.valid) {
292
- console.error(`✗ .review-passed marker schema 校验失败:${reviewResult.errors[0]?.message}`);
293
- // C2 修复:先 release lock 再 exit
294
- await archiveRelease();
295
- process.exit(2);
296
- }
297
- // 步骤 2b:P1.1 — schema 类型限定(.verify-passed 必须是 forge-verify/v1)
298
- // 通过 unknown 中转以绕过 TS 的交叉类型检查
299
- const verifyRec = verifyMarker;
300
- const reviewRec = reviewMarker;
301
- if (verifyRec['schema'] !== 'forge-verify/v1') {
302
- console.error(`✗ .verify-passed 必须是 forge-verify/v1,实际:${String(verifyRec['schema'])}`);
303
- await archiveRelease();
304
- process.exit(2);
305
- }
306
- if (reviewRec['schema'] !== 'forge-review/v1') {
307
- console.error(`✗ .review-passed 必须是 forge-review/v1,实际:${String(reviewRec['schema'])}`);
308
- await archiveRelease();
309
- process.exit(2);
310
- }
311
- // 步骤 3:重算 tasks_hash 和 content_hash + 比对
312
- const tasksContent = await readFile(join(changeDir, 'tasks.md'), 'utf8');
313
- const tasksHashNow = computeTasksHash(tasksContent);
314
- const contentHashNow = await computeContentHash(changeDir);
315
- // 将 marker 转为 Record 以便访问字段
316
- const vRec = verifyRec;
317
- const rRec = reviewRec;
318
- // 比对 verify marker 里的 hash
319
- if (tasksHashNow !== vRec['tasks_hash'] || contentHashNow !== vRec['content_hash']) {
320
- console.error('✗ .verify-passed marker 已过期(tasks/content hash 不匹配),请重跑 verify');
321
- // C2 修复:先 release lock 再 exit
322
- // plan-9d Task 6 v2 M-1 修订:hash mismatch = fence business-fail,exit 1(沿 master §3.12.3)
323
- await archiveRelease();
324
- process.exit(1);
325
- }
326
- // 比对 review marker 里的 hash
327
- if (tasksHashNow !== rRec['tasks_hash'] || contentHashNow !== rRec['content_hash']) {
328
- console.error('✗ .review-passed marker 已过期(tasks/content hash 不匹配),请重跑 review');
329
- // C2 修复:先 release lock 再 exit
330
- // plan-9d Task 6 v2 M-1 修订:hash mismatch = fence business-fail,exit 1
331
- await archiveRelease();
332
- process.exit(1);
333
- }
334
- // 步骤 3.4(plan-9j Task 5):legacy-exemption + version-retrograde fence
335
- // 沿 design §3.4.4.1 + §3.4.4.3 — 在 evidence 校验之前拦截 legacy marker 异常
336
- const legacyVerifyResult = validateLegacyExemption(verifyRec, verifyPath);
337
- if (!legacyVerifyResult.valid) {
338
- console.error('✗ legacy-exemption fence 拒签(verify-passed):');
339
- for (const e of legacyVerifyResult.errors)
340
- console.error(` - ${e.field}: ${e.message}`);
341
- await archiveRelease();
342
- process.exit(1);
343
- }
344
- const legacyReviewResult = validateLegacyExemption(reviewRec, reviewPath);
345
- if (!legacyReviewResult.valid) {
346
- console.error('✗ legacy-exemption fence 拒签(review-passed):');
347
- for (const e of legacyReviewResult.errors)
348
- console.error(` - ${e.field}: ${e.message}`);
349
- await archiveRelease();
350
- process.exit(1);
351
- }
352
- const retrogradeVerifyResult = await validateVersionRetrograde(verifyPath, verifyRec, process.cwd());
353
- if (!retrogradeVerifyResult.valid) {
354
- console.error('✗ version-retrograde fence 拒签(verify-passed):');
355
- for (const e of retrogradeVerifyResult.errors)
356
- console.error(` - ${e.field}: ${e.message}`);
357
- await archiveRelease();
358
- process.exit(1);
359
- }
360
- const retrogradeReviewResult = await validateVersionRetrograde(reviewPath, reviewRec, process.cwd());
361
- if (!retrogradeReviewResult.valid) {
362
- console.error('✗ version-retrograde fence 拒签(review-passed):');
363
- for (const e of retrogradeReviewResult.errors)
364
- console.error(` - ${e.field}: ${e.message}`);
365
- await archiveRelease();
366
- process.exit(1);
367
- }
368
- // 步骤 3.5:P1.2 — 验证 evidence 完整性
369
- const evResult = await validateEvidence(verifyRec, verifyPath);
370
- if (!evResult.valid) {
371
- console.error('✗ verify evidence 校验失败:');
372
- for (const e of evResult.errors)
373
- console.error(` - ${e.field}: ${e.message}`);
374
- // plan-9d Task 6 v2 M-1 修订:evidence 校验失败 = fence business-fail,exit 1
375
- await archiveRelease();
376
- process.exit(1);
377
- }
378
- // 步骤 3.6:plan-9d Task 6 — verify_findings fence 三级 × resolved × ack 矩阵 + finding_hash 篡改拒签
379
- const vfResult = validateVerifyFindingsFence(verifyRec, verifyPath);
380
- if (!vfResult.valid) {
381
- console.error('✗ verify_findings fence 拒签:');
382
- for (const e of vfResult.errors)
383
- console.error(` - ${e.field}: ${e.message}`);
384
- await archiveRelease();
385
- process.exit(1);
386
- }
387
- // 步骤 3.7:plan-9c Task 2 — pause_decisions fence(option 1-4 五类业务校验 + CRITICAL 重定向)
388
- // v2 codex MAJOR 4 修订:对 verifyRec + reviewRec 都跑 fence
389
- // v4 codex NEW-MAJOR A6 + B4 联动:fence 不再需要 ctx 参数(B4 改用 parseMarkdown 局部段校验,
390
- // 不再调 validateScopeEntries → ctx unused → 沿 YAGNI 移除)
391
- // Task 12:从 verify marker 取链固化值组成 pauseFenceOpts(design §6.2 verify 是最后 freeze)
392
- // marker 由 AI 产出,字段运行时可能是 string / null / undefined — 断言须含 null,
393
- // 否则 null 会被错误窄化为 string,绕过 checkOption2TaskChecked 的 == null fail-closed 检查
394
- const pauseFenceOpts = {
395
- verifyAckLogTailHash: verifyRec['ack_log_tail_hash'],
396
- verifyAckLogEntryCount: verifyRec['ack_log_entry_count'],
397
- changeId,
398
- };
399
- const pdVerifyResult = await validatePauseDecisionsFence(verifyRec, changeDir, process.cwd(), pauseFenceOpts, verifyPath);
400
- if (!pdVerifyResult.valid) {
401
- console.error('✗ pause_decisions fence 拒签(verify-passed):');
402
- for (const e of pdVerifyResult.errors)
403
- console.error(` - ${e.field}: ${e.message}`);
404
- await archiveRelease();
405
- process.exit(1);
406
- }
407
- // 两处调用都传同一 pauseFenceOpts(都用 verify marker 的 tail/count — design §6.2)
408
- const pdReviewResult = await validatePauseDecisionsFence(reviewRec, changeDir, process.cwd(), pauseFenceOpts, reviewPath);
409
- if (!pdReviewResult.valid) {
410
- console.error('✗ pause_decisions fence 拒签(review-passed):');
411
- for (const e of pdReviewResult.errors)
412
- console.error(` - ${e.field}: ${e.message}`);
413
- await archiveRelease();
414
- process.exit(1);
415
- }
416
- // 步骤 3.7.1:Task 12 — verify/review pause_decisions cross-check(design §6.5)
417
- // 对两侧共有 id 的 pause_decision 逐字段比对;单侧独有 id 不拒签
418
- const vDecisions = Array.isArray(verifyRec['pause_decisions'])
419
- ? verifyRec['pause_decisions']
420
- : [];
421
- const rDecisions = Array.isArray(reviewRec['pause_decisions'])
422
- ? reviewRec['pause_decisions']
423
- : [];
424
- const ccResult = crossCheckPauseDecisions(vDecisions, rDecisions, verifyPath);
425
- if (!ccResult.valid) {
426
- console.error('✗ pause_decisions verify/review cross-check 拒签:');
427
- for (const e of ccResult.errors)
428
- console.error(` - ${e.field}: ${e.message}`);
429
- await archiveRelease();
430
- process.exit(1);
431
- }
432
- // 步骤 3.8:plan-9d Task 6 v2 B-4 — ack-log 一致性 cross-check
433
- // v3 codex BLOCKER 2 修订:对 verifyRec + reviewRec 都跑 cross-check
434
- // (review marker 同样可承载 pause_decisions superset additive,沿 9c Task 1 schema)
435
- const ackVerifyResult = await validateAckLogConsistency(changeDir, verifyRec, changeId);
436
- if (!ackVerifyResult.valid) {
437
- console.error('✗ ack-log 一致性校验失败(verify-passed):');
438
- for (const e of ackVerifyResult.errors)
439
- console.error(` - ${e.field}: ${e.message}`);
440
- await archiveRelease();
441
- process.exit(1);
442
- }
443
- const ackReviewResult = await validateAckLogConsistency(changeDir, reviewRec, changeId);
444
- if (!ackReviewResult.valid) {
445
- console.error('✗ ack-log 一致性校验失败(review-passed):');
446
- for (const e of ackReviewResult.errors)
447
- console.error(` - ${e.field}: ${e.message}`);
448
- await archiveRelease();
449
- process.exit(1);
450
- }
451
- // 步骤 3.9:plan-9e1 Task 4 — 三级业务行为 fence(沿 design §2.4.2)
452
- // CRITICAL+resolved=false 拒签(sanity 双重保险)/ WARNING+resolved=false+无 ack 拒签 /
453
- // WARNING+resolved=false+acked 通过 / SUGGESTION+resolved=false 通过(handoff to backlog)
454
- const tlfResult = validateThreeLevelFence(verifyRec, reviewRec, verifyPath, reviewPath);
455
- if (!tlfResult.valid) {
456
- console.error('✗ 三级业务行为 fence 拒签:');
457
- for (const e of tlfResult.errors)
458
- console.error(` - ${e.field}: ${e.message}`);
459
- await archiveRelease();
460
- process.exit(1);
461
- }
462
- // 步骤 4:human-override + 真实 git 状态校验 + outcomes 校验
463
- const verifyBy = vRec['verified_by'];
464
- const reviewBy = rRec['reviewed_by'];
465
- const reviewGit = reviewRec['git'];
466
- const markerSaysGit = reviewGit?.['is_git_repo'] === true;
467
- // P1 修复:用真实 git 状态决定,不信 marker 字段
468
- const projectIsGit = isProjectActuallyGit(process.cwd());
469
- // 4a. human-override 必须 --force
470
- if (verifyBy === 'human-override' || reviewBy === 'human-override') {
471
- if (!opts.force) {
472
- console.error('✗ human-override 标记需要 --force 接受\n' +
473
- ' verified_by=' +
474
- verifyBy +
475
- ' reviewed_by=' +
476
- reviewBy);
477
- // C2 修复:先 release lock 再 exit
478
- await archiveRelease();
479
- process.exit(2);
480
- }
481
- }
482
- // 4b. marker 与真实 git 状态不一致 → 可疑伪造,拒绝(--force 也不能覆盖)
483
- if (projectIsGit && !markerSaysGit) {
484
- console.error('✗ review marker 标记 is_git_repo=false,但项目实际是 git。可能为伪造,拒绝(即使 --force)');
485
- await archiveRelease();
486
- process.exit(2);
487
- }
488
- if (!projectIsGit && markerSaysGit) {
489
- console.error('✗ review marker 标记 is_git_repo=true,但项目实际不是 git。可能为伪造,拒绝');
490
- await archiveRelease();
491
- process.exit(2);
492
- }
493
- // 4c. 真非 git → 必须 --force(spec §3.4 要求)
494
- if (!projectIsGit && !opts.force) {
495
- console.error(`✗ 非 git 项目下 review 标记不绑定代码 diff,archive 必须 --force 才接受`);
496
- await archiveRelease();
497
- process.exit(2);
498
- }
499
- // 4d. 真 git → 跑 git integrity(重算 head + diff_hash)
500
- if (projectIsGit) {
501
- const gitResult = await validateReviewGitIntegrity(reviewRec, process.cwd(), reviewPath);
502
- if (!gitResult.valid) {
503
- console.error('✗ review git 完整性校验失败:');
504
- for (const e of gitResult.errors)
505
- console.error(` - ${e.field}: ${e.message}`);
506
- await archiveRelease();
507
- process.exit(2);
508
- }
205
+ // —— 2. legacy-bridge preflight(brownfield 同步检查;graceful skip 路径多)——
206
+ const preflightResult = await runArchivePreflight(forgeRoot, changeId, {
207
+ api: opts.api ?? false,
208
+ });
209
+ if (preflightResult.kind !== 'ok') {
210
+ console.error(preflightResult.message);
211
+ process.exit(2);
212
+ }
213
+ // —— 3. Spec validate(永远跑,blocking sanity check fence)——
214
+ // v9 修订(Codex v8 F-R8-1):删 --no-validate flag,archive 永远 validate
215
+ const validateResult = await validateChange(changeDir);
216
+ if (!validateResult.valid) {
217
+ console.error('✗ Spec validate failed:');
218
+ for (const e of validateResult.errors) {
219
+ console.error(` - ${e.artifact}${e.field ? `.${e.field}` : ''}: ${e.message}`);
509
220
  }
510
- // 4e. review_outcomes:accepted=true 必须 resolved=true(已实现,不变)
511
- const outResult = validateReviewOutcomes(reviewRec, reviewPath);
512
- if (!outResult.valid) {
513
- console.error('✗ review_outcomes 校验失败:');
514
- for (const e of outResult.errors)
515
- console.error(` - ${e.field}: ${e.message}`);
516
- await archiveRelease();
517
- process.exit(2);
221
+ process.exit(1);
222
+ }
223
+ // —— 4. marker 存在性 + v2 schema validate ——
224
+ const verifyPath = join(changeDir, '.verify-passed');
225
+ const reviewPath = join(changeDir, '.review-passed');
226
+ if (!existsSync(verifyPath)) {
227
+ console.error(`✗ Missing .verify-passed in ${changeDir}. Run /forge:verify first.`);
228
+ process.exit(1);
229
+ }
230
+ if (!existsSync(reviewPath)) {
231
+ console.error(`✗ Missing .review-passed in ${changeDir}. Run /forge:review first.`);
232
+ process.exit(1);
233
+ }
234
+ const verifyText = await readFile(verifyPath, 'utf8');
235
+ const reviewText = await readFile(reviewPath, 'utf8');
236
+ const verifyMarker = parseMarker(verifyText);
237
+ const reviewMarker = parseMarker(reviewText);
238
+ const verifyValid = validateMarkerSchema(verifyMarker, verifyPath);
239
+ if (!verifyValid.valid) {
240
+ console.error('✗ .verify-passed schema invalid:');
241
+ for (const e of verifyValid.errors) {
242
+ console.error(` - ${e.field ?? 'marker'}: ${e.message}`);
518
243
  }
519
- // 步骤 4.5:plan-9e1 Task 4 — 构造 archive_summary(传给 transaction 落 .tmp)
520
- // v2 BLOCKER 4 修订:try/catch ScopeEntriesIntegrityError → fence business-fail exit 1
521
- const archiveDate = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
522
- let archiveSummary;
523
- try {
524
- // plan-9e2 Task 2 同步改:加 fenceResult 入参(沿 archive.ts:231 现有 crossCuttingFenceCheck 输出) Task 4 进一步整理调用顺序
525
- archiveSummary = await buildArchiveSummary(verifyRec, reviewRec, changeDir, // 沿用 line 213 已定义的 changeDir
526
- changeId, fenceResult);
244
+ process.exit(1);
245
+ }
246
+ const reviewValid = validateMarkerSchema(reviewMarker, reviewPath);
247
+ if (!reviewValid.valid) {
248
+ console.error('✗ .review-passed schema invalid:');
249
+ for (const e of reviewValid.errors) {
250
+ console.error(` - ${e.field ?? 'marker'}: ${e.message}`);
527
251
  }
528
- catch (err) {
529
- if (err instanceof ScopeEntriesIntegrityError) {
530
- console.error(`✗ scope-entries 完整性 fence 拒签(${err.artifactName} section ${err.anchorId}):${err.message}`);
531
- await archiveRelease();
532
- process.exit(1);
533
- }
534
- throw err;
252
+ process.exit(1);
253
+ }
254
+ // schema 字段 v2 类型限定(双保险)
255
+ if (verifyMarker.schema !== 'forge-verify/v2') {
256
+ console.error(`✗ .verify-passed must be forge-verify/v2, got: ${String(verifyMarker.schema)}`);
257
+ process.exit(1);
258
+ }
259
+ if (reviewMarker.schema !== 'forge-review/v2') {
260
+ console.error(`✗ .review-passed must be forge-review/v2, got: ${String(reviewMarker.schema)}`);
261
+ process.exit(1);
262
+ }
263
+ // —— 4b. human-override 处理 ——
264
+ if ((verifyMarker.verified_by === 'human-override' ||
265
+ reviewMarker.reviewed_by === 'human-override') &&
266
+ !opts.force) {
267
+ console.error('✗ human-override marker requires --force\n' +
268
+ ` verified_by=${verifyMarker.verified_by} reviewed_by=${reviewMarker.reviewed_by}`);
269
+ process.exit(1);
270
+ }
271
+ // —— 5. Task progress confirm(未完成 + 非 --yes 才弹)——
272
+ const incomplete = await countIncompleteTasks(changeDir);
273
+ if (incomplete > 0 && !opts.yes) {
274
+ // dangerous-by-default:回车 = NO(沿 plan §6.1 line 326 + Codex Phase 1 F-2)
275
+ const proceed = await confirmPrompt(`${incomplete} incomplete task(s) in tasks.md. Continue archive anyway?`, false);
276
+ if (!proceed) {
277
+ console.log('Archive cancelled.');
278
+ return;
535
279
  }
536
- // 步骤 5:调 archiveTransaction(Move→Sync,含 .tmp 写 / rename / 回滚)
537
- await archiveTransaction({ forgeRoot, changeId, archiveDate, archiveSummary });
538
- // I-2:archiveTransaction await 成功返回(失败会 throw 走外层 catch)即 transaction 成功点 ——
539
- // 此处删 --resume 暂停态文件(正常收尾)。transaction 之前任一步失败时暂停态保留,
540
- // 用户修好后可再 --resume 重入。仅 opts.resume 路径有此文件,非 resume 路径 undefined 跳过。
541
- if (resumePausePath) {
542
- // transaction 已成功;删暂停态失败(罕见权限错)不应让 archive 报错 —— 降级为 warn
543
- try {
544
- await rm(resumePausePath, { force: true });
280
+ }
281
+ // —— 6. Spec deltas 应用 — Path B 用 forge 现有 readDeltas + applyDeltas ——
282
+ let deltas = [];
283
+ if (!opts.skipSpecs) {
284
+ const changeSpecsDir = join(changeDir, 'specs');
285
+ deltas = await readDeltas(changeSpecsDir, mainSpecsDir);
286
+ if (deltas.length > 0) {
287
+ console.log(renderDeltasSummary(deltas));
288
+ if (!opts.yes) {
289
+ // safe-by-default:回车 = YES(spec deltas 是 archive 正常路径,plan §6.1 line 345 `default: true`)
290
+ const proceed = await confirmPrompt('Apply spec deltas?', true);
291
+ if (!proceed) {
292
+ console.log('Skipping spec deltas. Continuing with archive.');
293
+ deltas = [];
294
+ }
545
295
  }
546
- catch (e) {
547
- console.warn(`⚠ archive 已完成,但暂停态文件 ${resumePausePath} 清理失败(可手动删除):${e.message}`);
296
+ if (deltas.length > 0) {
297
+ await applyDeltas(mainSpecsDir, deltas);
548
298
  }
549
299
  }
550
- // 步骤 5.5:Plan 7 post-archive hook(enforce_sync=false 时不阻塞,只产报告)
551
- // Task 6.3 I-2:透传 archiveDate(避免 posthook 自己算 new Date() 跨午夜偏一天)
552
- // Task 6.3 C-1:透传 --api
553
- await runArchivePostHook(forgeRoot, changeId, {
554
- archiveDate,
555
- api: opts.api ?? false,
556
- });
557
- // 步骤 6:plan-9e1 Task 4 — 渲染 archive_summary 输出(沿 design §2.4.4)
558
- const archiveDirName = `${archiveDate}-${changeId}`;
559
- console.log(renderArchiveSummaryOutput(archiveSummary, archiveDirName));
560
- // 步骤 6.5:plan-backlog-registry — 重生成 forge/backlog/ 注册表
561
- // 失败不回滚 archive(archive 主流程已成功,backlog 是衍生产物)
562
- try {
563
- const bl = await generateBacklog(forgeRoot);
564
- console.log(`Backlog: forge/backlog/active.md (${bl.openCount} open, ${bl.warningCount} warnings)`);
565
- }
566
- catch (blErr) {
567
- const m = blErr instanceof Error ? blErr.message : String(blErr);
568
- console.error(`⚠ backlog 重生成失败(不影响 archive):${m} —— 可手动跑 \`forge backlog\``);
569
- }
570
300
  }
571
- catch (err) {
572
- // exit code 映射 — spec §3.5
573
- if (err instanceof LockHeldError) {
574
- // lock 被占用 exit 5
575
- console.error(err.message);
576
- if (archiveRelease)
577
- await archiveRelease();
578
- process.exit(5);
579
- }
580
- const msg = err instanceof Error ? err.message : String(err);
581
- if (msg.includes('AND rollback failed')) {
582
- // 同步失败且回滚也失败 → 需人工介入 → exit 3
583
- console.error(`✗ ${msg}`);
584
- if (archiveRelease)
585
- await archiveRelease();
586
- process.exit(3);
587
- }
588
- if (msg.includes('rolled back')) {
589
- // 同步失败但回滚成功 → 可重试 → exit 2
590
- console.error(`✗ ${msg}`);
591
- if (archiveRelease)
592
- await archiveRelease();
593
- process.exit(2);
594
- }
595
- // 兜底:未知错误 → exit 1
596
- console.error(`✗ ${msg}`);
597
- if (archiveRelease)
598
- await archiveRelease();
301
+ // —— 7. archiveDate(算一次 — F-R3-2)+ mv changeDir → archive/<archiveName> ——
302
+ const archiveDate = getArchiveDate();
303
+ const archiveName = `${archiveDate}-${changeId}`;
304
+ const archivePath = join(archiveDir, archiveName);
305
+ if (existsSync(archivePath)) {
306
+ console.error(`✗ Archive '${archiveName}' already exists.`);
599
307
  process.exit(1);
600
308
  }
601
- finally {
602
- // finally 作为最终兜底(catch 内 process.exit 会跳过 finally,release 函数幂等)
603
- if (archiveRelease)
604
- await archiveRelease();
309
+ await mkdir(archiveDir, { recursive: true });
310
+ await moveDirectory(changeDir, archivePath);
311
+ // —— 8. buildArchiveSummary(v2 新 signature)+ 写 archive_summary.yaml ——
312
+ const summary = await buildArchiveSummary({
313
+ archivePath,
314
+ changeId,
315
+ verifyMarker,
316
+ reviewMarker,
317
+ deltas,
318
+ });
319
+ await writeFile(join(archivePath, 'archive_summary.yaml'), stringifyYaml(summary), 'utf8');
320
+ // —— 9a. legacy-bridge posthook(透传 archiveDate 防跨午夜偏)——
321
+ await runArchivePostHook(forgeRoot, changeId, {
322
+ archiveDate,
323
+ api: opts.api ?? false,
324
+ });
325
+ // —— 9b. 打印用户输出(渲染 summary)——
326
+ console.log(renderArchiveSummaryOutput(summary, archiveName));
327
+ // —— 9c. 自动重生成 backlog(失败仅 warn,不回滚 archive)——
328
+ try {
329
+ const bl = await generateBacklog(forgeRoot);
330
+ console.log(`Backlog: forge/backlog/active.md (${bl.openCount} open, ${bl.warningCount} warnings)`);
605
331
  }
606
- }));
607
- }
608
- /**
609
- * backup 目录把所有备份的 specs 文件还原到 currentSpecsDir(case C [2] 撤销归档用)
610
- *
611
- * 简单语义:backup 内每个 .md 文件 copy 回 currentSpecsDir/ 同名位置,
612
- * 对应的 currentSpecsDir 内文件被覆盖。
613
- * 不删除 currentSpecsDir 中"backup 没有的文件"(理由:那些是 archive 没影响的旧文件)。
614
- */
615
- async function restoreSpecsFromBackup(backupDir, currentSpecsDir) {
616
- const entries = await readdir(backupDir);
617
- await mkdir(currentSpecsDir, { recursive: true });
618
- for (const name of entries) {
619
- if (!name.endsWith('.md'))
620
- continue;
621
- await copyFile(join(backupDir, name), join(currentSpecsDir, name));
622
- }
332
+ catch (e) {
333
+ console.warn(`⚠ backlog 重生成失败(不影响 archive):${e.message} —— 可手动跑 \`forge backlog\``);
334
+ }
335
+ // —— 9d. 顺手 emit monitor trace event(沿 plan §10.3 Task 3.15)——
336
+ appendTraceEvent(targetPath, {
337
+ ts: new Date().toISOString(),
338
+ schema: 'forge-monitor-trace/v1',
339
+ change_id: changeId,
340
+ stage: 'archive',
341
+ layer: 'cli',
342
+ event: 'change_archived',
343
+ data: {
344
+ archive_name: archiveName,
345
+ spec_updates: deltas.length,
346
+ forge_version: FORGE_VERSION,
347
+ },
348
+ });
349
+ });
623
350
  }
624
351
  /**
625
352
  * Plan 7 §2.5 + Task 6.3:archive preflight — enforce_sync=true 时拦截 sync 差异。
@@ -631,24 +358,18 @@ async function restoreSpecsFromBackup(backupDir, currentSpecsDir) {
631
358
  * 4. legacy_bridge.enforce_sync != true
632
359
  * 5. 无受影响 anchor / 全部读取失败(buildSyncCheckTask 返 null)
633
360
  *
634
- * 双模式(ack 就绪后):
635
- * - **agent 模式(默认,不带 --api)**:emit sync-check manifest .cache +
636
- * 写暂停态文件 {kind:'halted-for-fulfillment'},archive halt agent fulfill。
637
- * - **--api 模式**:进程内直连 Anthropic API 跑 sync-check + 写 sync-state →
638
- * 含 critical pending 返 {kind:'critical-pending'};否则返 {kind:'ok'} 续跑 archive。
361
+ * 双模式:
362
+ * - **agent 模式(默认)**:emit sync-check manifest + 暂停态 → halted-for-fulfillment
363
+ * - **--api 模式**:进程内直连 API 跑 sync-check + 写 sync-state critical-pending ok
639
364
  *
640
- * ack 不就绪(两模式共有)返 {kind:'ack-missing'}
641
- *
642
- * **不再调 process.exit / console.error**:caller(archive 命令)负责打印消息 +
643
- * release archive.lock + exit,避免本函数内 process.exit 跳过 caller finally
644
- * 导致 archive.lock 文件残留。
365
+ * ack 不就绪 → ack-missing。
645
366
  */
646
367
  export async function runArchivePreflight(forgeRoot, changeId, opts = { api: false }) {
647
368
  const configPath = join(forgeRoot, 'config.yaml');
648
369
  if (!existsSync(configPath))
649
370
  return { kind: 'ok' };
650
371
  const config = parseYaml(await readFile(configPath, 'utf8'));
651
- // §2.5:仅当 legacy-anchors.yaml 存在 AND allow_llm_calls=true AND enforce_sync=true 时进入 preflight
372
+ // §2.5:仅当 legacy-anchors.yaml 存在 AND allow_llm_calls AND enforce_sync 时进入
652
373
  const anchors = await loadAnchorsFile(forgeRoot).catch(() => null);
653
374
  if (!anchors)
654
375
  return { kind: 'ok' };
@@ -663,25 +384,19 @@ export async function runArchivePreflight(forgeRoot, changeId, opts = { api: fal
663
384
  message: `legacy_bridge.enforce_sync=true 但 ack 未就绪:${ack.reason};请先跑 forge legacy-bridge --acknowledge-data-transfer`,
664
385
  };
665
386
  }
666
- // 拼 change context(I-3:gate 已加载的 config + anchors 传入,不再二次 IO)
667
- // preflight 时 change 还在 forge/changes/<id>/(archived=false,无需 archiveDate)
387
+ // 拼 change context(I-3:gate 已加载的 config + anchors 传入)
668
388
  const ctx = await buildSyncCheckChangeContext(forgeRoot, changeId, { archived: false }, config, anchors);
669
389
  if (opts.api) {
670
- // —— --api 模式:进程内直连 API 跑 sync-check,不 emit manifest、不写暂停态 ——
390
+ // —— --api 模式:进程内直连 API 跑 sync-check ——
671
391
  const task = await buildSyncCheckTask(ctx, async (p) => (await readAnchorFile(p)).text);
672
392
  if (!task)
673
- return { kind: 'ok' }; // 无受影响 anchor → 无需 sync-check,续跑 archive
674
- // 进程内调 Anthropic SDK(client 构造方式与旧 archive 代码一致:forge-eval/load-env)
393
+ return { kind: 'ok' };
675
394
  const client = (await makeForgeApiClient());
676
395
  const results = await new ApiRunner(client).run([task]);
677
- // --api 无 manifest,produced_from 标 'api-inline' sentinel(区别于 agent 路径的 manifest_hash;
678
- // 该 sync-state 不经 resume gate,Task 6.4 据此 sentinel 可识别 --api 来源)
679
396
  const syncState = applySyncCheckResult(results[0]?.text ?? '', changeId, 'api-inline');
680
- // 写 sync-state 到旧路径(沿旧 preflight 写盘方式)
681
397
  await mkdir(join(forgeRoot, 'legacy-sync-state'), { recursive: true });
682
398
  await writeFile(join(forgeRoot, 'legacy-sync-state', `${changeId}.md`), renderDiffMarkdown(syncState), 'utf8');
683
399
  await writeFile(join(forgeRoot, 'legacy-sync-state', `${changeId}.yaml`), renderDiffYaml(syncState), 'utf8');
684
- // 含 critical pending → critical-pending(死变体的归宿:archive 主流程 kind!=='ok' 会 halt)
685
400
  const criticalDiffs = syncState.diffs.filter((d) => d.severity === 'critical' && d.status === 'pending');
686
401
  if (criticalDiffs.length > 0) {
687
402
  return {
@@ -693,13 +408,10 @@ export async function runArchivePreflight(forgeRoot, changeId, opts = { api: fal
693
408
  }
694
409
  return { kind: 'ok' };
695
410
  }
696
- // —— agent 模式(默认):emit sync-check manifest + 写暂停态文件,halt archive ——
411
+ // —— agent 模式(默认):emit sync-check manifest + 暂停态 ——
697
412
  const emitResult = await emitPreflightSyncCheck(forgeRoot, changeId, ctx);
698
- if (emitResult.kind === 'skip') {
699
- // 无受影响 anchor / 全部读取失败 → graceful skip,继续 archive
413
+ if (emitResult.kind === 'skip')
700
414
  return { kind: 'ok' };
701
- }
702
- // halted-for-fulfillment:manifest 已写,暂停态已写,告知 caller halt
703
415
  return {
704
416
  kind: 'halted-for-fulfillment',
705
417
  message: `sync-check manifest 已 emit(preflight);\n` +
@@ -708,11 +420,10 @@ export async function runArchivePreflight(forgeRoot, changeId, opts = { api: fal
708
420
  };
709
421
  }
710
422
  /**
711
- * Plan 7 §2.5 + Task 6.3:post-archive hook — 不阻塞,仅在 enforce_sync=false 时跑(产报告)。
712
- * enforce_sync=true 已由 preflight 跑过;graceful skip 路径同 preflight。
423
+ * Plan 7 §2.5 + Task 6.3:post-archive hook — 不阻塞,enforce_sync=false 时跑(产报告)。
713
424
  *
714
- * agent 模式:emit sync-check manifest(非阻塞,archive 已完成,fulfill 后跑 --apply 出报告)
715
- * --api 模式:进程内直连 API 跑 sync-check + 写 sync-state(非阻塞,直接出报告)
425
+ * agent 模式:emit sync-check manifest(非阻塞,archive 已完成)
426
+ * --api 模式:进程内直连 API 跑 sync-check + 写 sync-state(非阻塞)
716
427
  *
717
428
  * I-2:archiveDate 由 archive 主流程透传(不自己算 new Date(),避免跨午夜偏一天)。
718
429
  */
@@ -730,18 +441,14 @@ export async function runArchivePostHook(forgeRoot, changeId, opts) {
730
441
  return;
731
442
  const ack = await checkAck(forgeRoot, config, anchors);
732
443
  if (!ack.ok)
733
- return; // ack 不就绪 → graceful skip
734
- // 拼 change context(I-3:gate 已加载的 config + anchors 传入;I-2:archiveDate 透传)
735
- // posthook 时 change 已归档到 archive/<archiveDate>-<id>/
444
+ return;
736
445
  const ctx = await buildSyncCheckChangeContext(forgeRoot, changeId, { archived: true, archiveDate: opts.archiveDate }, config, anchors);
737
446
  if (opts.api) {
738
- // —— --api 模式:进程内直连 API 跑 sync-check + 写 sync-state(非阻塞)——
739
- // archive transaction 已完成;posthook 的 API 调用失败不应让 archive 报 exit 1 ——
740
- // I-A:整段包 try/catch,失败降级为 warn(agent 模式 posthook 只写本地文件无此风险)
447
+ // —— --api 模式:进程内直连 API 跑 sync-check + 写 sync-state(非阻塞,失败仅 warn)——
741
448
  try {
742
449
  const task = await buildSyncCheckTask(ctx, async (p) => (await readAnchorFile(p)).text);
743
450
  if (!task)
744
- return; // 无受影响 anchor → graceful skip
451
+ return;
745
452
  const client = (await makeForgeApiClient());
746
453
  const results = await new ApiRunner(client).run([task]);
747
454
  const syncState = applySyncCheckResult(results[0]?.text ?? '', changeId, 'api-inline');
@@ -755,12 +462,11 @@ export async function runArchivePostHook(forgeRoot, changeId, opts) {
755
462
  }
756
463
  return;
757
464
  }
758
- // —— agent 模式(默认):emit sync-check manifest(非阻塞,archive 已完成)——
465
+ // —— agent 模式(默认):emit sync-check manifest(非阻塞)——
759
466
  const emitResult = await emitPostHookSyncCheck(forgeRoot, changeId, ctx);
760
467
  if (emitResult.kind === 'emitted') {
761
468
  console.log(`⚠ sync-check manifest 已 emit(posthook);forge agent 履行后跑 forge legacy-bridge sync-check --apply --change-id ${changeId} 可得报告`);
762
469
  }
763
- // kind='skip':无受影响 anchor / 全部读取失败 → graceful skip,无输出
764
470
  }
765
471
  /**
766
472
  * 抽出:合并 runArchivePreflight/runArchivePostHook 两处重复的 change context 拼装。
@@ -768,25 +474,18 @@ export async function runArchivePostHook(forgeRoot, changeId, opts) {
768
474
  * archived=false → forge/changes/<id>/;
769
475
  * archived=true → forge/changes/archive/<archiveDate>-<id>/。
770
476
  *
771
- * I-3:config 与 anchors 由调用方(preflight/posthook 的 gate 处)已加载并传入,
772
- * 本函数不再二次 readFile/loadAnchorsFile —— 消除 double-IO + 避免
773
- * loadAnchorsFile 损坏时裸抛 LegacyAnchorsError 穿透成 unhandled rejection。
774
- * I-2:archived=true 时用调用方透传的 archiveDate(archive 主流程算出的那个),
775
- * 不再 new Date() —— 避免 archive 跨午夜完成时目录名偏一天致静默 skip。
477
+ * I-3:config 与 anchors 由调用方已加载并传入,避免 double-IO。
478
+ * I-2:archived=true 时用调用方透传的 archiveDate(防跨午夜偏)。
776
479
  */
777
480
  async function buildSyncCheckChangeContext(forgeRoot, changeId, opts, config, anchors) {
778
- // 决定 change 目录:归档后在 archive/<archiveDate>-<id>/,归档前在 changes/<id>/
779
- // archived=true 时 archiveDate 必传(由 posthook 透传 archive 主流程算出的值)
780
481
  const changesDir = opts.archived && opts.archiveDate
781
482
  ? join(forgeRoot, 'changes', 'archive', `${opts.archiveDate}-${changeId}`)
782
483
  : join(forgeRoot, 'changes', changeId);
783
484
  let changeContext = '';
784
485
  const affectedModules = [];
785
- // 读 proposal.md
786
486
  if (existsSync(join(changesDir, 'proposal.md'))) {
787
487
  changeContext += await readFile(join(changesDir, 'proposal.md'), 'utf8');
788
488
  }
789
- // 读 specs/ 下各 .md,拼 changeContext + 收集 affectedModules
790
489
  const specsDir = join(changesDir, 'specs');
791
490
  if (existsSync(specsDir)) {
792
491
  for (const f of await readdir(specsDir)) {
@@ -800,7 +499,6 @@ async function buildSyncCheckChangeContext(forgeRoot, changeId, opts, config, an
800
499
  affectedModules,
801
500
  anchors,
802
501
  autoResolveCrossAnchor: config.legacy_bridge?.auto_resolve_cross_anchor ?? false,
803
- // mtime 提供者:用于 cross-anchor 决策
804
502
  mtimeOf: (p) => {
805
503
  try {
806
504
  return Math.floor(statSync(p).mtimeMs / 1000);
@@ -820,19 +518,13 @@ async function buildSyncCheckChangeContext(forgeRoot, changeId, opts, config, an
820
518
  * @param ctx 已拼好的 SyncCheckInput(可选;不传则内部自拼,供测试直接调用)
821
519
  */
822
520
  export async function emitPreflightSyncCheck(forgeRoot, changeId, ctx) {
823
- // ctx 未传(测试直接调用)→ 自行加载 config + anchors 拼 context
824
521
  const syncCtx = ctx ?? (await buildSyncCheckContextStandalone(forgeRoot, changeId, false));
825
522
  if (!syncCtx)
826
- return { kind: 'skip' }; // standalone 加载失败(无 config/anchors)
827
- // 构建 LlmTask(确定性 prep:findAffectedAnchors + redact)
523
+ return { kind: 'skip' };
828
524
  const task = await buildSyncCheckTask(syncCtx, async (p) => (await readAnchorFile(p)).text);
829
- // 无受影响 anchor 或全部读取失败 → graceful skip
830
525
  if (!task)
831
526
  return { kind: 'skip' };
832
- // emit sync-check manifest 到 forge/.cache/legacy-bridge-task-sync-check.json
833
- // meta 键名与 Task 6.2 runSyncCheckCommand emit 保持一致:changeId(驼峰)
834
527
  const manifest = await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('sync-check', 1, [task], { gate_context: 'archive-preflight', changeId });
835
- // 写暂停态文件:archive-pause-<changeId>.json
836
528
  await mkdir(join(forgeRoot, '.cache'), { recursive: true });
837
529
  await writeFile(join(forgeRoot, '.cache', `archive-pause-${changeId}.json`), JSON.stringify({
838
530
  changeId,
@@ -842,29 +534,21 @@ export async function emitPreflightSyncCheck(forgeRoot, changeId, ctx) {
842
534
  return { kind: 'halted-for-fulfillment' };
843
535
  }
844
536
  /**
845
- * agent 模式:enforce_sync=false 时 post-archive emit sync-check manifest(非阻塞,无暂停态)(Task 6.3)
537
+ * agent 模式:enforce_sync=false 时 post-archive emit sync-check manifest(非阻塞)。
846
538
  *
847
539
  * archive 已完成;fulfill 后跑 forge legacy-bridge sync-check --apply 出报告。
848
- * 调用前 gate 检查由 runArchivePostHook 负责。
849
- *
850
- * @param ctx 已拼好的 SyncCheckInput(可选;不传则内部自拼,供测试直接调用)
851
540
  */
852
541
  export async function emitPostHookSyncCheck(forgeRoot, changeId, ctx) {
853
- // ctx 未传(测试直接调用)→ 自行加载 config + anchors 拼 context(archived=true)
854
542
  const syncCtx = ctx ?? (await buildSyncCheckContextStandalone(forgeRoot, changeId, true));
855
543
  if (!syncCtx)
856
- return { kind: 'skip' }; // standalone 加载失败
857
- // 构建 LlmTask
544
+ return { kind: 'skip' };
858
545
  const task = await buildSyncCheckTask(syncCtx, async (p) => (await readAnchorFile(p)).text);
859
- // 无受影响 anchor → graceful skip
860
546
  if (!task)
861
547
  return { kind: 'skip' };
862
- // emit sync-check manifest(meta 键名与 Task 6.2 一致:changeId 驼峰)
863
548
  await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('sync-check', 1, [task], {
864
549
  gate_context: 'archive-posthook',
865
550
  changeId,
866
551
  });
867
- // 非阻塞:不写暂停态文件(archive 已完成)
868
552
  return { kind: 'emitted' };
869
553
  }
870
554
  /**
@@ -872,7 +556,7 @@ export async function emitPostHookSyncCheck(forgeRoot, changeId, ctx) {
872
556
  * (不经 runArchivePreflight/runArchivePostHook gate)时,自行加载 config + anchors。
873
557
  *
874
558
  * archived=true 时用「今日」作 archiveDate —— 仅 standalone 测试路径,生产路径
875
- * 由 runArchivePostHook 透传准确的 archiveDate(I-2 已修主路径跨午夜 bug)
559
+ * 由 runArchivePostHook 透传准确的 archiveDate。
876
560
  *
877
561
  * 返回 null:config 缺失 / anchors 缺失 → 调用方按 skip 处理。
878
562
  */
@@ -887,31 +571,24 @@ async function buildSyncCheckContextStandalone(forgeRoot, changeId, archived) {
887
571
  return buildSyncCheckChangeContext(forgeRoot, changeId, { archived, archiveDate: archived ? new Date().toISOString().slice(0, 10) : undefined }, config, anchors);
888
572
  }
889
573
  /**
890
- * Task 6.4:forge archive --resume 的 gate 复核。
574
+ * Task 6.4:forge archive --resume 的 gate 复核(legacy 留存 — v4 archive 主流程已无 --resume,
575
+ * 但 tests/cli/legacy-bridge/archive-dualpath.test.ts 直接 import 测试此 gate 逻辑,需保留)。
891
576
  *
892
577
  * 复核两项:
893
- * ① 产物绑定 — sync-stateproduced_from 必须等于暂停态文件的 manifest_hash,
894
- * 确保 sync-check --apply 产出的 sync-state 确实来自本次 preflight emit manifest。
895
- * ② critical 幂等重评 — sync-state 中不得有 severity=critical + status=pending 的差异;
896
- * 若有,说明 agent 未完全 resolve,不可续跑 archive。
578
+ * ① 产物绑定 — sync-state.produced_from 必须等于暂停态文件的 manifest_hash
579
+ * critical 幂等重评 sync-state 中不得有 severity=critical + status=pending 的差异
897
580
  *
898
- * @param forgeRoot forge/ 根目录路径
899
- * @param changeId change id(e.g. 'add-pay')
900
- * @returns { ok: true } 表示 gate 通过;{ ok: false, reason } 表示拒绝并附原因
581
+ * v4 备注:archive 命令本身不再调用此函数;legacy-bridge sync-check 命令(若有 resume 路径)
582
+ * 可调用此函数验证 sync-state 绑定。本函数留存为 sync-check 子系统的 building block。
901
583
  */
902
584
  export async function resumeArchiveGateCheck(forgeRoot, changeId) {
903
- // 读暂停态文件:forge/.cache/archive-pause-<changeId>.json
904
585
  const pausePath = join(forgeRoot, '.cache', `archive-pause-${changeId}.json`);
905
586
  if (!existsSync(pausePath)) {
906
587
  return {
907
588
  ok: false,
908
- reason: `无暂停态文件 archive-pause-${changeId}.json;archive 未在 preflight 暂停,无需 --resume`,
589
+ reason: `无暂停态文件 archive-pause-${changeId}.json;archive 未在 preflight 暂停,无需 resume`,
909
590
  };
910
591
  }
911
- // 暂停态文件格式(Task 6.3 emitPreflightSyncCheck 写入):{ changeId, paused_step, manifest_hash }
912
- // I-1:parse 包 try/catch —— 文件截断/手动误编辑致 JSON 损坏时转 business-fail,
913
- // 用户看得出是哪个文件坏(否则裸 SyntaxError 被 action 外层兜底当未知错误)。
914
- // readFile 的 ENOENT 竞态(existsSync 后文件被删)也顺带被这层 catch 兜住,可接受。
915
592
  let pause;
916
593
  try {
917
594
  pause = JSON.parse(await readFile(pausePath, 'utf8'));
@@ -922,7 +599,6 @@ export async function resumeArchiveGateCheck(forgeRoot, changeId) {
922
599
  reason: `暂停态文件 ${pausePath} 解析失败(可能损坏):${err.message}`,
923
600
  };
924
601
  }
925
- // 读 sync-state YAML:forge/legacy-sync-state/<changeId>.yaml
926
602
  const statePath = join(forgeRoot, 'legacy-sync-state', `${changeId}.yaml`);
927
603
  if (!existsSync(statePath)) {
928
604
  return {
@@ -930,7 +606,6 @@ export async function resumeArchiveGateCheck(forgeRoot, changeId) {
930
606
  reason: `sync-state 缺失(forge/legacy-sync-state/${changeId}.yaml);请先让 agent fulfill manifest 后跑 forge legacy-bridge sync-check --apply --change-id ${changeId}`,
931
607
  };
932
608
  }
933
- // I-1:同样包 try/catch —— sync-state YAML 损坏时转 business-fail
934
609
  let state;
935
610
  try {
936
611
  state = parseYaml(await readFile(statePath, 'utf8'));
@@ -941,15 +616,12 @@ export async function resumeArchiveGateCheck(forgeRoot, changeId) {
941
616
  reason: `sync-state 文件 ${statePath} 解析失败(可能损坏):${err.message}`,
942
617
  };
943
618
  }
944
- // ① 产物绑定:sync-state.produced_from 必须等于暂停态的 manifest_hash
945
619
  if (state.produced_from !== pause.manifest_hash) {
946
620
  return {
947
621
  ok: false,
948
622
  reason: `produced_from(${state.produced_from ?? 'undefined'}) ≠ 暂停态 manifest_hash(${pause.manifest_hash});sync-state 非本次 --apply 产物,请重新履行 manifest 并跑 sync-check --apply`,
949
623
  };
950
624
  }
951
- // ② critical 幂等重评:sync-state 中不得有 critical+pending 差异
952
- // 注:archive.ts Task 6.3 已用内联 filter 而非 hasCriticalPending import,此处保持一致
953
625
  const criticalPending = (state.diffs ?? []).filter((d) => d.severity === 'critical' && d.status === 'pending');
954
626
  if (criticalPending.length > 0) {
955
627
  return {