@accelerator-mzq/forge 1.4.0 → 3.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 (85) hide show
  1. package/dist/cli/commands/archive.d.ts +73 -10
  2. package/dist/cli/commands/archive.d.ts.map +1 -1
  3. package/dist/cli/commands/archive.js +362 -89
  4. package/dist/cli/commands/archive.js.map +1 -1
  5. package/dist/cli/commands/legacy-bridge.d.ts +110 -0
  6. package/dist/cli/commands/legacy-bridge.d.ts.map +1 -1
  7. package/dist/cli/commands/legacy-bridge.js +1058 -438
  8. package/dist/cli/commands/legacy-bridge.js.map +1 -1
  9. package/dist/cli/commands/pause-capture.d.ts +24 -0
  10. package/dist/cli/commands/pause-capture.d.ts.map +1 -0
  11. package/dist/cli/commands/pause-capture.js +87 -0
  12. package/dist/cli/commands/pause-capture.js.map +1 -0
  13. package/dist/cli/index.js +3 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/core/ack-log.d.ts +17 -1
  16. package/dist/core/ack-log.d.ts.map +1 -1
  17. package/dist/core/ack-log.js.map +1 -1
  18. package/dist/core/archive/ack-log-consistency.d.ts.map +1 -1
  19. package/dist/core/archive/ack-log-consistency.js +2 -1
  20. package/dist/core/archive/ack-log-consistency.js.map +1 -1
  21. package/dist/core/archive/pause-decisions-fence.d.ts +24 -1
  22. package/dist/core/archive/pause-decisions-fence.d.ts.map +1 -1
  23. package/dist/core/archive/pause-decisions-fence.js +260 -46
  24. package/dist/core/archive/pause-decisions-fence.js.map +1 -1
  25. package/dist/core/backlog/assets/backlog-readme.md +4 -2
  26. package/dist/core/backlog/index.d.ts +1 -1
  27. package/dist/core/backlog/index.d.ts.map +1 -1
  28. package/dist/core/backlog/index.js +27 -8
  29. package/dist/core/backlog/index.js.map +1 -1
  30. package/dist/core/backlog/render.d.ts +48 -3
  31. package/dist/core/backlog/render.d.ts.map +1 -1
  32. package/dist/core/backlog/render.js +151 -15
  33. package/dist/core/backlog/render.js.map +1 -1
  34. package/dist/core/legacy-bridge/extractor.d.ts +79 -0
  35. package/dist/core/legacy-bridge/extractor.d.ts.map +1 -0
  36. package/dist/core/legacy-bridge/extractor.js +374 -0
  37. package/dist/core/legacy-bridge/extractor.js.map +1 -0
  38. package/dist/core/legacy-bridge/indexer.d.ts +8 -2
  39. package/dist/core/legacy-bridge/indexer.d.ts.map +1 -1
  40. package/dist/core/legacy-bridge/indexer.js +61 -9
  41. package/dist/core/legacy-bridge/indexer.js.map +1 -1
  42. package/dist/core/legacy-bridge/legacy-requirements.d.ts +53 -0
  43. package/dist/core/legacy-bridge/legacy-requirements.d.ts.map +1 -0
  44. package/dist/core/legacy-bridge/legacy-requirements.js +114 -0
  45. package/dist/core/legacy-bridge/legacy-requirements.js.map +1 -0
  46. package/dist/core/legacy-bridge/llm-task.d.ts +54 -0
  47. package/dist/core/legacy-bridge/llm-task.d.ts.map +1 -0
  48. package/dist/core/legacy-bridge/llm-task.js +87 -0
  49. package/dist/core/legacy-bridge/llm-task.js.map +1 -0
  50. package/dist/core/legacy-bridge/mapper.d.ts +8 -9
  51. package/dist/core/legacy-bridge/mapper.d.ts.map +1 -1
  52. package/dist/core/legacy-bridge/mapper.js +24 -39
  53. package/dist/core/legacy-bridge/mapper.js.map +1 -1
  54. package/dist/core/legacy-bridge/quality-judge.d.ts +5 -0
  55. package/dist/core/legacy-bridge/quality-judge.d.ts.map +1 -1
  56. package/dist/core/legacy-bridge/quality-judge.js +25 -21
  57. package/dist/core/legacy-bridge/quality-judge.js.map +1 -1
  58. package/dist/core/legacy-bridge/regenerator.d.ts +21 -1
  59. package/dist/core/legacy-bridge/regenerator.d.ts.map +1 -1
  60. package/dist/core/legacy-bridge/regenerator.js +132 -2
  61. package/dist/core/legacy-bridge/regenerator.js.map +1 -1
  62. package/dist/core/legacy-bridge/runners.d.ts +33 -0
  63. package/dist/core/legacy-bridge/runners.d.ts.map +1 -0
  64. package/dist/core/legacy-bridge/runners.js +77 -0
  65. package/dist/core/legacy-bridge/runners.js.map +1 -0
  66. package/dist/core/legacy-bridge/sync-check.d.ts +8 -0
  67. package/dist/core/legacy-bridge/sync-check.d.ts.map +1 -1
  68. package/dist/core/legacy-bridge/sync-check.js +83 -0
  69. package/dist/core/legacy-bridge/sync-check.js.map +1 -1
  70. package/dist/core/legacy-bridge/types.d.ts +2 -0
  71. package/dist/core/legacy-bridge/types.d.ts.map +1 -1
  72. package/dist/core/markers/types.d.ts +5 -0
  73. package/dist/core/markers/types.d.ts.map +1 -1
  74. package/dist/core/parse/unified-diff.d.ts +26 -0
  75. package/dist/core/parse/unified-diff.d.ts.map +1 -0
  76. package/dist/core/parse/unified-diff.js +61 -0
  77. package/dist/core/parse/unified-diff.js.map +1 -0
  78. package/dist/core/templates/commands/apply.md +12 -3
  79. package/dist/core/templates/commands/archive.md +15 -0
  80. package/dist/core/templates/commands/verify.md +2 -0
  81. package/dist/core/templates/skills/legacy-bridge-fulfillment.md +22 -0
  82. package/dist/core/validate/marker-schema.d.ts.map +1 -1
  83. package/dist/core/validate/marker-schema.js +15 -0
  84. package/dist/core/validate/marker-schema.js.map +1 -1
  85. package/package.json +21 -20
@@ -1,27 +1,72 @@
1
- // forge legacy-bridge 主命令 + 5 子命令骨架 — Plan 7 Phase A
1
+ // forge legacy-bridge 主命令 + 6 子命令 — Plan 7 Phase A / Layer 3b extract(D2)
2
2
  // 各子命令在后续 Phase B-D 填实;本 Task 仅完成骨架 + commander 结构
3
3
  // spec §2.1 子命令一览 + 决策 #22 LLM opt-in 流程
4
4
  import { Command } from 'commander';
5
- import { readFile, writeFile, mkdir } from 'node:fs/promises';
5
+ import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
6
6
  import { existsSync, statSync } from 'node:fs';
7
- import { join } from 'node:path';
7
+ import { join, relative, basename } from 'node:path';
8
8
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
9
- import Anthropic from '@anthropic-ai/sdk';
10
9
  import { acquireLockByPath, LockHeldError } from '../../core/archive/lock.js';
11
- import { loadAnchorsFile, getAuthoritativeAnchors, LegacyAnchorsError, } from '../../core/legacy-bridge/anchors.js';
12
- import { writeAck, checkAck, renderOptinPrompt } from '../../core/legacy-bridge/ack.js';
10
+ import { loadAnchorsFile, getAuthoritativeAnchors } from '../../core/legacy-bridge/anchors.js';
11
+ import { writeAck, checkAck } from '../../core/legacy-bridge/ack.js';
13
12
  import { formatRedactReport, redact } from '../../core/legacy-bridge/redact.js';
14
- import { runSyncCheck } from '../../core/legacy-bridge/sync-check.js';
15
- import { renderDiffMarkdown, renderDiffYaml, hasCriticalPending, } from '../../core/legacy-bridge/diff-report.js';
13
+ import { runSyncCheck, buildSyncCheckTask, applySyncCheckResult, } from '../../core/legacy-bridge/sync-check.js';
14
+ import { renderDiffMarkdown, renderDiffYaml } from '../../core/legacy-bridge/diff-report.js';
16
15
  import { resolveSyncState, ResolveError } from '../../core/legacy-bridge/resolve.js';
17
- import { regenerateRole, REGEN_FILENAMES, RegenOutputError, METADATA_ONLY_ROLES, } from '../../core/legacy-bridge/regenerator.js';
18
- import { stratifiedSample, judgeAllFacts, formatQualityReport, extractFactsFromOriginal, } from '../../core/legacy-bridge/quality-judge.js';
16
+ import { regenerateRole, REGEN_FILENAMES, RegenOutputError, METADATA_ONLY_ROLES, buildRegenerateRound1Tasks, applyRound1AndBuildRound2, applyRound2, wrapWithFrontmatterAndDisclaimer, validateRegenOutput, } from '../../core/legacy-bridge/regenerator.js';
17
+ import { stratifiedSample, judgeAllFacts, formatQualityReport, extractFactsFromOriginal, DEFAULT_FIDELITY_THRESHOLD, } from '../../core/legacy-bridge/quality-judge.js';
19
18
  import { estimateRegenerateCost, REGEN_WARN_USD, checkBudgetGate, countdown, } from '../../core/legacy-bridge/budget.js';
20
19
  import { computeAnchorHash } from '../../core/legacy-bridge/hash-anchor.js';
21
20
  import { readAnchorFile, readAnchorAsText } from '../../core/legacy-bridge/encoding.js';
22
- import { runMapper, writeMapperDraft } from '../../core/legacy-bridge/mapper.js';
23
- import { buildIndex, renderIndexMarkdown, } from '../../core/legacy-bridge/indexer.js';
21
+ import { writeMapperDraft, buildMapTask, applyMapResult } from '../../core/legacy-bridge/mapper.js';
22
+ import { readManifest, consumeManifest, manifestPath } from '../../core/legacy-bridge/llm-task.js';
23
+ import { ApiRunner, AgentHandoffRunner, readTaskResults, makeForgeApiClient, } from '../../core/legacy-bridge/runners.js';
24
+ import { buildIndexTask, applyIndexResult } from '../../core/legacy-bridge/indexer.js';
24
25
  import { FORGE_VERSION } from '../../index.js';
26
+ import { discoverSources, buildExtractTasks, applyExtractResult, } from '../../core/legacy-bridge/extractor.js';
27
+ import { loadLegacyRequirements, validateLegacyRequirementsFile, finalizeLegacyRequirements, LEGACY_REQUIREMENTS_FILE, LEGACY_REQUIREMENTS_DRAFT_FILE, LEGACY_REQUIREMENTS_DRAFT_MD, } from '../../core/legacy-bridge/legacy-requirements.js';
28
+ /** spec §5:opt-in gate —— agent 与 --api 两模式都先过。复用既有 checkAck。 */
29
+ export async function assertLlmOptIn(forgeRoot) {
30
+ const configPath = join(forgeRoot, 'config.yaml');
31
+ if (!existsSync(configPath))
32
+ return { ok: false, graceful: false, reason: 'forge/config.yaml 不存在,先跑 forge init' };
33
+ let config;
34
+ try {
35
+ config = parseYaml(await readFile(configPath, 'utf8'));
36
+ }
37
+ catch (e) {
38
+ return { ok: false, graceful: false, reason: `config.yaml 格式错误:${e.message}` };
39
+ }
40
+ // allow_llm_calls=false/缺失 → graceful skip(spec §4 点6:两模式都 graceful skip)
41
+ if (!config.legacy_bridge?.allow_llm_calls) {
42
+ return {
43
+ ok: false,
44
+ graceful: true,
45
+ reason: 'legacy_bridge.allow_llm_calls 未开启 — 跳过(graceful skip)',
46
+ };
47
+ }
48
+ // loadAnchorsFile 对「文件不存在」已返回 null;corrupt(LegacyAnchorsError)不能静默吞 ——
49
+ // 否则 contains_customer_data 的 GDPR 二次确认门会被绕过
50
+ let anchors;
51
+ try {
52
+ anchors = await loadAnchorsFile(forgeRoot);
53
+ }
54
+ catch (e) {
55
+ return {
56
+ ok: false,
57
+ graceful: false,
58
+ reason: `legacy-anchors.yaml 解析失败:${e.message};修复后重试`,
59
+ };
60
+ }
61
+ const ack = await checkAck(forgeRoot, config, anchors ?? { schema: 'forge-legacy-anchor/v1', anchors: [] });
62
+ if (!ack.ok)
63
+ return {
64
+ ok: false,
65
+ graceful: false,
66
+ reason: `LLM 数据传输 ack 未就绪:${ack.reason ?? '(unknown)'};跑 forge legacy-bridge --acknowledge-data-transfer`,
67
+ };
68
+ return { ok: true };
69
+ }
25
70
  /** 5 子命令通用退出码,与 forge 现有约定一致(spec §4.6) */
26
71
  export const LB_EXIT_OK = 0;
27
72
  export const LB_EXIT_GENERAL_ERROR = 1;
@@ -29,216 +74,840 @@ export const LB_EXIT_BUSINESS_RULE_FAIL = 2;
29
74
  export const LB_EXIT_PARTIAL_SUCCESS = 3;
30
75
  export const LB_EXIT_DATA_CORRUPT = 4;
31
76
  export const LB_EXIT_LOCK_HELD = 5;
32
- /** 主命令 build:无参数走 help;含 --acknowledge-data-transfer 时进入 ack 流程(Phase B1 填) */
33
- export function buildLegacyBridgeCommand() {
34
- const cmd = new Command('legacy-bridge')
35
- .description('Brownfield onboarding:与老文档体系并存 + archivelegacy 单向同步(v0.2)')
36
- .option('--acknowledge-data-transfer', 'opt-in:ack 数据将被发送到 LLM provider(决策 #22)')
37
- .option('--acknowledge-customer-data', '同时 ack 含客户数据的 anchor(§4.5 GDPR 二次确认门)');
38
- cmd.action(async (opts) => {
39
- // M2 修:--acknowledge-customer-data 必须与 --acknowledge-data-transfer 同用,
40
- // 单独传不应静默走 help(用户不知 flag 没生效)
41
- if (opts.acknowledgeCustomerData && !opts.acknowledgeDataTransfer) {
42
- console.error('✗ --acknowledge-customer-data 必须与 --acknowledge-data-transfer 同时使用');
43
- process.exit(LB_EXIT_GENERAL_ERROR);
77
+ /**
78
+ * map 子命令三分支执行函数:
79
+ * 默认 emit manifest(agent 路径);
80
+ * --apply 读 agent 结果 写 draft yaml;
81
+ * --api 进程内调 Anthropic SDK → 写 draft yaml。
82
+ * 返回 exit code(0=成功,1=错误)
83
+ */
84
+ export async function runMapCommand(opts) {
85
+ const forgeRoot = join(opts.projectRoot, 'forge');
86
+ // I-1:--apply(消费 agent 结果) --api(进程内调 SDK)语义互斥,同传无意义
87
+ if (opts.apply && opts.api) {
88
+ console.error('✗ --apply 与 --api 互斥,不能同时使用');
89
+ return LB_EXIT_GENERAL_ERROR;
90
+ }
91
+ // --apply 不调 LLM,跳过 opt-in gate;emit / --api 先过 gate(spec §5)
92
+ if (!opts.apply) {
93
+ const optin = await assertLlmOptIn(forgeRoot);
94
+ if (!optin.ok) {
95
+ console.error(`✗ ${optin.reason}`);
96
+ return optin.graceful ? LB_EXIT_OK : LB_EXIT_GENERAL_ERROR;
44
97
  }
45
- if (opts.acknowledgeDataTransfer) {
46
- const forgeRoot = join(process.cwd(), 'forge');
47
- const configPath = join(forgeRoot, 'config.yaml');
48
- if (!existsSync(configPath)) {
49
- console.error('forge/config.yaml 不存在,先跑 forge init 初始化项目');
50
- process.exit(LB_EXIT_GENERAL_ERROR);
51
- }
52
- // I1 修:config.yaml 格式损坏时 parseYaml 抛异常,用 try/catch 包装给友好提示
53
- let config;
54
- try {
55
- config = parseYaml(await readFile(configPath, 'utf8'));
56
- }
57
- catch (e) {
58
- console.error(`forge/config.yaml 格式错误:${e.message}`);
59
- process.exit(LB_EXIT_GENERAL_ERROR);
60
- }
61
- if (!config.legacy_bridge?.allow_llm_calls) {
62
- console.error('✗ forge/config.yaml 未声明 legacy_bridge.allow_llm_calls: true,请先在 config 加该字段');
63
- process.exit(LB_EXIT_GENERAL_ERROR);
64
- }
65
- // 检 anchors 中是否有 contains_customer_data
66
- const anchors = await loadAnchorsFile(forgeRoot);
67
- const hasCustomerData = (anchors?.anchors ?? []).some((a) => a.contains_customer_data === true);
68
- if (hasCustomerData && !opts.acknowledgeCustomerData) {
69
- console.error('✗ legacy-anchors.yaml 标有 contains_customer_data=true 的 anchor;\n' +
70
- '请加 --acknowledge-customer-data 一并确认(§4.5 GDPR)');
71
- process.exit(LB_EXIT_GENERAL_ERROR);
72
- }
73
- await writeAck(forgeRoot, config, hasCustomerData);
74
- console.log(`✓ ack 已写入 forge/.cache/llm-ack.yaml(customer_data_acknowledged=${hasCustomerData})`);
75
- process.exit(LB_EXIT_OK);
98
+ }
99
+ if (opts.apply) {
100
+ // --apply 分支:读 manifest → 读 agent 结果 → 后处理 → 写 draft
101
+ // C-1:readManifest 在 manifest 损坏/篡改时抛错,包 try/catch 转友好错误
102
+ let manifest;
103
+ try {
104
+ manifest = await readManifest(forgeRoot, 'map');
76
105
  }
77
- cmd.help();
106
+ catch (e) {
107
+ console.error(`✗ map manifest 读取失败:${e.message}`);
108
+ return LB_EXIT_GENERAL_ERROR;
109
+ }
110
+ if (!manifest) {
111
+ console.error('✗ 无 map manifest;请先跑 forge legacy-bridge map');
112
+ return LB_EXIT_GENERAL_ERROR;
113
+ }
114
+ // C-1:readTaskResults 在结果文件缺失/JSON 截断时抛错,同样包 try/catch
115
+ let result;
116
+ try {
117
+ [result] = await readTaskResults(forgeRoot, manifest.tasks);
118
+ }
119
+ catch (e) {
120
+ console.error(`✗ agent 结果读取失败:${e.message}`);
121
+ return LB_EXIT_GENERAL_ERROR;
122
+ }
123
+ // I-2:manifest.tasks 为空时 result 为 undefined,显式守卫避免 TypeError
124
+ if (!result) {
125
+ console.error('✗ map manifest 的 tasks 为空,无 agent 结果');
126
+ return LB_EXIT_GENERAL_ERROR;
127
+ }
128
+ const allFiles = manifest.tasks[0]?.inputs.map((i) => i.source) ?? [];
129
+ // M-2:merge 模式下 legacy-anchors.yaml 损坏不能静默吞 —— 否则 merge 静默退化成 overwrite
130
+ const existing = opts.mode === 'merge'
131
+ ? ((await loadAnchorsFile(forgeRoot).catch((e) => {
132
+ console.warn(`⚠ legacy-anchors.yaml 读取失败,merge 退化为 overwrite:${e.message}`);
133
+ return null;
134
+ })) ?? undefined)
135
+ : undefined;
136
+ const out = applyMapResult(result.text, allFiles, { mode: opts.mode, existing });
137
+ await writeMapperDraft(forgeRoot, out);
138
+ await consumeManifest(forgeRoot, 'map');
139
+ console.log('✓ map draft 已写 forge/legacy-anchors-draft.yaml');
140
+ return LB_EXIT_OK;
141
+ }
142
+ // 准备 map task(确定性扫描 + redact + 拼 prompt)
143
+ const task = await buildMapTask({ projectRoot: opts.projectRoot, mode: opts.mode });
144
+ if (opts.api) {
145
+ // --api 分支:进程内调 Anthropic SDK
146
+ const client = (await makeForgeApiClient());
147
+ const [result] = await new ApiRunner(client).run([task]);
148
+ // I-2:ApiRunner 返回空数组时 result 为 undefined,显式守卫避免 TypeError
149
+ if (!result) {
150
+ console.error('✗ ApiRunner 未返回结果');
151
+ return LB_EXIT_GENERAL_ERROR;
152
+ }
153
+ // M-2:merge 模式下 legacy-anchors.yaml 损坏不能静默吞
154
+ const existing = opts.mode === 'merge'
155
+ ? ((await loadAnchorsFile(forgeRoot).catch((e) => {
156
+ console.warn(`⚠ legacy-anchors.yaml 读取失败,merge 退化为 overwrite:${e.message}`);
157
+ return null;
158
+ })) ?? undefined)
159
+ : undefined;
160
+ const out = applyMapResult(result.text, task.inputs.map((i) => i.source), { mode: opts.mode, existing });
161
+ await writeMapperDraft(forgeRoot, out);
162
+ console.log('✓ map draft 已写(--api 单进程)');
163
+ return LB_EXIT_OK;
164
+ }
165
+ // 默认 agent 模式:emit manifest 到 .cache,等 agent fulfill 后跑 --apply
166
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('map', 1, [task]);
167
+ console.log(`✓ map manifest 已写 ${manifestPath(forgeRoot, 'map')}\n → 请 fulfill 后跑 forge legacy-bridge map --apply(见 skill: legacy-bridge-fulfillment)`);
168
+ return LB_EXIT_OK;
169
+ }
170
+ /** 收集 whole-repo 代码文件路径(file tree 索引,供 prompt 内嵌) */
171
+ async function collectCodeIndex(repoRoot) {
172
+ const out = [];
173
+ const skip = new Set([
174
+ 'node_modules',
175
+ '.git',
176
+ 'dist',
177
+ 'dist-bundled',
178
+ 'build',
179
+ 'forge',
180
+ '.cache',
181
+ ]);
182
+ async function walk(dir) {
183
+ let entries;
184
+ try {
185
+ entries = await readdir(dir, { withFileTypes: true });
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ for (const ent of entries) {
191
+ if (skip.has(ent.name) || ent.name.startsWith('.'))
192
+ continue;
193
+ const full = join(dir, ent.name);
194
+ if (ent.isDirectory())
195
+ await walk(full);
196
+ else if (ent.isFile())
197
+ out.push(relative(repoRoot, full).split('\\').join('/'));
198
+ }
199
+ }
200
+ await walk(repoRoot);
201
+ return out.sort();
202
+ }
203
+ /**
204
+ * extract 子命令四分支:
205
+ * 默认 → 发现 + buildExtractTasks → emit manifest;
206
+ * --apply → 读 manifest + agent 结果 → applyExtractResult → 写 draft;
207
+ * --api → ApiRunner 进程内调 SDK → 同后处理;
208
+ * --finalize → 读 draft → finalizeLegacyRequirements → 写 legacy-requirements.yaml + 刷新 backlog。
209
+ */
210
+ export async function runExtractCommand(opts) {
211
+ const forgeRoot = join(opts.projectRoot, 'forge');
212
+ // 互斥校验:--apply / --api / --finalize 三者不能同传(spec §2)
213
+ const exclusive = [opts.apply, opts.api, opts.finalize].filter(Boolean).length;
214
+ if (exclusive > 1) {
215
+ console.error('✗ --apply / --api / --finalize 三者互斥,只能用其一');
216
+ return LB_EXIT_GENERAL_ERROR;
217
+ }
218
+ // --finalize 分支:纯确定性,不调 LLM,不过 opt-in gate(spec §6.2)
219
+ if (opts.finalize) {
220
+ return runExtractFinalize(forgeRoot);
221
+ }
222
+ // --apply 不调 LLM,跳过 opt-in gate;emit / --api 先过 gate(spec §5 / §5.3)
223
+ if (!opts.apply) {
224
+ const optin = await assertLlmOptIn(forgeRoot);
225
+ if (!optin.ok) {
226
+ console.error(`✗ ${optin.reason}`);
227
+ return optin.graceful ? LB_EXIT_OK : LB_EXIT_GENERAL_ERROR;
228
+ }
229
+ }
230
+ if (opts.apply) {
231
+ return runExtractApply(forgeRoot);
232
+ }
233
+ // emit / --api:确定性 prep
234
+ const sources = await discoverSources(opts.projectRoot);
235
+ if (sources.length === 0) {
236
+ console.error('✗ 未发现任何需求文档 / backlog 文件;检查仓库是否有 SRS/PRD/BACKLOG/TODO 命名的文件');
237
+ return LB_EXIT_GENERAL_ERROR;
238
+ }
239
+ const codeIndex = await collectCodeIndex(opts.projectRoot);
240
+ const tasks = await buildExtractTasks(opts.projectRoot, sources, codeIndex);
241
+ // 透明告知(spec §5.3 第 3 点)
242
+ console.log(`→ 本次将把 ${sources.length} 份发现文档交给 LLM(redact 已跑默认规则)`);
243
+ if (opts.api) {
244
+ const client = (await makeForgeApiClient());
245
+ const results = await new ApiRunner(client).run(tasks);
246
+ const confirmed = await loadLegacyRequirements(forgeRoot);
247
+ const apiInputs = results.map((r, i) => ({
248
+ text: r.text,
249
+ source: tasks[i].inputs[0].source,
250
+ kind: sources[i].kind,
251
+ }));
252
+ const out = applyExtractResult(apiInputs, confirmed);
253
+ await writeFile(join(forgeRoot, LEGACY_REQUIREMENTS_DRAFT_FILE), out.draftYaml, 'utf8');
254
+ await writeFile(join(forgeRoot, LEGACY_REQUIREMENTS_DRAFT_MD), out.draftMarkdown, 'utf8');
255
+ console.log('✓ extract draft 已写(--api 单进程)');
256
+ return LB_EXIT_OK;
257
+ }
258
+ // 默认 agent 模式:emit manifest。已确认 yaml 快照写进 meta —— `--apply` 据此做增量 diff,
259
+ // 不在 apply 时回读磁盘(emit 与 apply 之间 yaml 可能被改;快照随 manifest_hash 锁定基线,spec §4.1/§6.1)
260
+ const confirmedSnapshot = await loadLegacyRequirements(forgeRoot);
261
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('extract', 1, tasks, {
262
+ confirmed_snapshot: confirmedSnapshot,
78
263
  });
79
- // 5 个子命令骨架( Phase 填实)
80
- cmd
81
- .command('map')
82
- .description('扫 docs/ + src/LLM 推测 → legacy-anchors-draft.yaml(决策 #4)')
83
- .option('--merge', '与已存在 anchors.yaml 合并新发现项,保留用户审过部分(默认)', true)
84
- .option('--overwrite', '全量重生成(覆盖用户改动,需用户确认)')
85
- .option('--docs-paths <paths>', '逗号分隔的额外 docs 目录(默认扫 docs/ doc/ document/)')
86
- .option('--redact-report', '输出每条 redact 规则的命中数(决策 #20)')
87
- .action(async (opts) => {
88
- const projectRoot = process.cwd();
89
- const forgeRoot = join(projectRoot, 'forge');
90
- const configPath = join(forgeRoot, 'config.yaml');
91
- if (!existsSync(configPath)) {
92
- console.error('forge/config.yaml 不存在,先跑 forge init');
93
- process.exit(LB_EXIT_GENERAL_ERROR);
264
+ console.log(`✓ extract manifest 已写 ${manifestPath(forgeRoot, 'extract')}\n → 请 fulfill 后跑 forge legacy-bridge extract --apply(见 skill: legacy-bridge-fulfillment)`);
265
+ return LB_EXIT_OK;
266
+ }
267
+ /** --apply:读 manifest + agent 结果 draft */
268
+ async function runExtractApply(forgeRoot) {
269
+ let manifest;
270
+ try {
271
+ manifest = await readManifest(forgeRoot, 'extract');
272
+ }
273
+ catch (e) {
274
+ console.error(`✗ extract manifest 读取失败:${e.message}`);
275
+ return LB_EXIT_GENERAL_ERROR;
276
+ }
277
+ if (!manifest) {
278
+ console.error('✗ 无 extract manifest;请先跑 forge legacy-bridge extract');
279
+ return LB_EXIT_GENERAL_ERROR;
280
+ }
281
+ let results;
282
+ try {
283
+ results = await readTaskResults(forgeRoot, manifest.tasks);
284
+ }
285
+ catch (e) {
286
+ console.error(`✗ agent 结果读取失败:${e.message}`);
287
+ return LB_EXIT_GENERAL_ERROR;
288
+ }
289
+ // 每个 task 的来源文档 = task.inputs[0].source;kind 由 source 反查
290
+ const applyInputs = results.map((r, i) => {
291
+ const src = manifest.tasks[i].inputs[0].source;
292
+ return { text: r.text, source: src, kind: inferKind(src) };
293
+ });
294
+ // 增量 diff 基线取自 manifest.meta 的快照(emit 时写入),不回读磁盘 —— manifest_hash 已锁定该快照
295
+ const confirmed = (manifest.meta?.confirmed_snapshot ?? null);
296
+ let out;
297
+ try {
298
+ out = applyExtractResult(applyInputs, confirmed);
299
+ }
300
+ catch (e) {
301
+ console.error(`✗ extract 结果校验失败:${e.message}`);
302
+ return LB_EXIT_GENERAL_ERROR;
303
+ }
304
+ await writeFile(join(forgeRoot, LEGACY_REQUIREMENTS_DRAFT_FILE), out.draftYaml, 'utf8');
305
+ await writeFile(join(forgeRoot, LEGACY_REQUIREMENTS_DRAFT_MD), out.draftMarkdown, 'utf8');
306
+ await consumeManifest(forgeRoot, 'extract');
307
+ console.log(`✓ extract draft 已写 forge/${LEGACY_REQUIREMENTS_DRAFT_FILE} —— 审改后跑 extract --finalize`);
308
+ return LB_EXIT_OK;
309
+ }
310
+ /** 据文件名反查 kind(与 extractor.classifyKind 同口径:只看 basename) */
311
+ function inferKind(relPath) {
312
+ // 只看文件名本体 —— 否则 docs/issues/SRS.md 这类路径会被整路径里的 'issue' 误判为 issue-export
313
+ const name = basename(relPath).toLowerCase();
314
+ if (/issue/.test(name))
315
+ return 'issue-export';
316
+ if (/^(backlog|todo|roadmap)\b/.test(name))
317
+ return 'backlog-file';
318
+ return 'srs';
319
+ }
320
+ /** --finalize:读 draft → finalize → 写 legacy-requirements.yaml + 刷新 backlog */
321
+ async function runExtractFinalize(forgeRoot) {
322
+ const draftPath = join(forgeRoot, LEGACY_REQUIREMENTS_DRAFT_FILE);
323
+ let raw;
324
+ try {
325
+ raw = await readFile(draftPath, 'utf8');
326
+ }
327
+ catch {
328
+ console.error(`✗ 找不到 ${LEGACY_REQUIREMENTS_DRAFT_FILE};请先跑 forge legacy-bridge extract + --apply`);
329
+ return LB_EXIT_GENERAL_ERROR;
330
+ }
331
+ let draftFile;
332
+ try {
333
+ draftFile = validateLegacyRequirementsFile(parseYaml(raw));
334
+ }
335
+ catch (e) {
336
+ console.error(`✗ draft 校验失败:${e.message}`);
337
+ return LB_EXIT_GENERAL_ERROR;
338
+ }
339
+ const finalized = finalizeLegacyRequirements(draftFile.requirements);
340
+ await writeFile(join(forgeRoot, LEGACY_REQUIREMENTS_FILE), stringifyYaml(finalized), 'utf8');
341
+ console.log(`✓ ${LEGACY_REQUIREMENTS_FILE} 已写(${finalized.requirements.length} 条)`);
342
+ // 立即刷新 backlog(spec §6.2 第 6 步);archive 目录可能不存在 → buildBacklog 已容错(E1)
343
+ try {
344
+ const { generateBacklog } = await import('../../core/backlog/index.js');
345
+ await generateBacklog(forgeRoot);
346
+ console.log('✓ forge/backlog/ 已刷新');
347
+ }
348
+ catch (e) {
349
+ // legacy-requirements.yaml 已写成功,但 backlog 未刷新 → 部分成功(active.md 暂时陈旧)。
350
+ console.error(`⚠ legacy-requirements.yaml 已写,但 backlog 刷新失败:${e.message}\n → 请手动跑 forge backlog 刷新`);
351
+ return LB_EXIT_PARTIAL_SUCCESS;
352
+ }
353
+ return LB_EXIT_OK;
354
+ }
355
+ /**
356
+ * index 子命令三分支执行函数:
357
+ * 默认 → buildIndexTask → emit manifest(agent 路径);
358
+ * --apply → 读 agent 结果 → applyIndexResult → 写 index.md;
359
+ * --api → 进程内调 Anthropic SDK → 写 index.md。
360
+ * 返回 exit code(0=成功,1=错误)。
361
+ */
362
+ export async function runIndexCommand(opts) {
363
+ const forgeRoot = join(opts.projectRoot, 'forge');
364
+ // I-1:--apply 与 --api 语义互斥
365
+ if (opts.apply && opts.api) {
366
+ console.error('✗ --apply 与 --api 互斥,不能同时使用');
367
+ return LB_EXIT_GENERAL_ERROR;
368
+ }
369
+ // --apply 不调 LLM,跳过 opt-in gate;emit / --api 先过 gate
370
+ if (!opts.apply) {
371
+ const optin = await assertLlmOptIn(forgeRoot);
372
+ if (!optin.ok) {
373
+ console.error(`✗ ${optin.reason}`);
374
+ return optin.graceful ? LB_EXIT_OK : LB_EXIT_GENERAL_ERROR;
94
375
  }
95
- const config = parseYaml(await readFile(configPath, 'utf8'));
96
- const existingAnchors = await loadAnchorsFile(forgeRoot).catch(() => null);
97
- // mode 决策(M-2):--overwrite 优先;否则 merge(默认)
98
- const mode = opts.overwrite ? 'overwrite' : 'merge';
99
- if (mode === 'overwrite' && existingAnchors) {
100
- console.warn('⚠ --overwrite 将覆盖现有 legacy-anchors.yaml(用户审过的部分会丢);确认请按 Enter,Ctrl-C 取消');
101
- if (process.stdout.isTTY) {
102
- await new Promise((resolve) => process.stdin.once('data', () => resolve()));
103
- }
376
+ }
377
+ // anchors(--apply 也需要:applyIndexResult file 反查 role)
378
+ const anchors = await loadAnchorsFile(forgeRoot).catch((e) => {
379
+ console.warn(`⚠ legacy-anchors.yaml 读取失败:${e.message}`);
380
+ return null;
381
+ });
382
+ if (opts.apply) {
383
+ // --apply 分支:读 manifest agent 结果 → applyIndexResult → 写 index.md
384
+ let manifest;
385
+ try {
386
+ manifest = await readManifest(forgeRoot, 'index');
104
387
  }
105
- // ack 检查(决策 #22 LLM opt-in)
106
- const ack = await checkAck(forgeRoot, config, existingAnchors);
107
- if (!ack.ok) {
108
- console.error(renderOptinPrompt(ack.reason, ack.customerDataPaths));
109
- process.exit(LB_EXIT_GENERAL_ERROR);
388
+ catch (e) {
389
+ console.error(`✗ index manifest 读取失败:${e.message}`);
390
+ return LB_EXIT_GENERAL_ERROR;
110
391
  }
111
- //
112
- let release;
392
+ if (!manifest) {
393
+ console.error('✗ 无 index manifest;请先跑 forge legacy-bridge index');
394
+ return LB_EXIT_GENERAL_ERROR;
395
+ }
396
+ // 从 manifest.meta.prebuilt 取回 metadata-only 已预建项(null-task 分支写的)
397
+ const prebuilt = manifest.meta?.prebuilt ?? [];
398
+ let result;
113
399
  try {
114
- release = await acquireLockByPath(forgeRoot, 'legacy-bridge-map', 'legacy-bridge.lock');
400
+ [result] = await readTaskResults(forgeRoot, manifest.tasks);
115
401
  }
116
- catch (err) {
117
- if (err instanceof LockHeldError) {
118
- console.error(`✗ ${err.message}`);
119
- process.exit(LB_EXIT_LOCK_HELD);
402
+ catch (e) {
403
+ console.error(`✗ agent 结果读取失败:${e.message}`);
404
+ return LB_EXIT_GENERAL_ERROR;
405
+ }
406
+ if (!result) {
407
+ console.error('✗ index manifest 的 tasks 为空,无 agent 结果');
408
+ return LB_EXIT_GENERAL_ERROR;
409
+ }
410
+ // applyIndexResult:LLM 输出 {path:summary} + prebuilt → markdown
411
+ const file = anchors ?? { schema: 'forge-legacy-anchor/v1', anchors: [] };
412
+ const md = applyIndexResult(result.text, file, prebuilt);
413
+ const indexPath = join(forgeRoot, 'docs', 'index.md');
414
+ await mkdir(join(forgeRoot, 'docs'), { recursive: true });
415
+ await writeFile(indexPath, md, 'utf8');
416
+ await consumeManifest(forgeRoot, 'index');
417
+ console.log(`✓ index.md 已写 ${indexPath}`);
418
+ return LB_EXIT_OK;
419
+ }
420
+ // 需要 anchors 才能构建 task
421
+ if (!anchors) {
422
+ console.error('✗ legacy-anchors.yaml 不存在;先跑 forge legacy-bridge map 生成 draft');
423
+ return LB_EXIT_GENERAL_ERROR;
424
+ }
425
+ if (opts.api) {
426
+ // --api 分支:进程内调 Anthropic SDK,走与 agent 路径对称的 buildIndexTask + applyIndexResult
427
+ const { task, prebuilt } = await buildIndexTask(anchors, (anchor) => readAnchorAsText(anchor));
428
+ let resultText = '';
429
+ if (task !== null) {
430
+ const client = (await makeForgeApiClient());
431
+ const [result] = await new ApiRunner(client).run([task]);
432
+ if (!result) {
433
+ console.error('✗ ApiRunner 未返回结果');
434
+ return LB_EXIT_GENERAL_ERROR;
120
435
  }
121
- throw err;
436
+ resultText = result.text;
437
+ }
438
+ // task===null(全 metadata-only)→ resultText 保持 ''(applyIndexResult 对空串降级,仅渲染 prebuilt)
439
+ const md = applyIndexResult(resultText, anchors, prebuilt);
440
+ const indexPath = join(forgeRoot, 'docs', 'index.md');
441
+ await mkdir(join(forgeRoot, 'docs'), { recursive: true });
442
+ await writeFile(indexPath, md, 'utf8');
443
+ console.log(`✓ index.md 已写(--api 单进程)${indexPath}`);
444
+ return LB_EXIT_OK;
445
+ }
446
+ // 默认 agent 模式:buildIndexTask → 分析 null-task 分支
447
+ const { task, prebuilt } = await buildIndexTask(anchors, (anchor) => readAnchorAsText(anchor));
448
+ if (task === null) {
449
+ // null-task:所有 anchor 均为 metadata-only,无 LLM 工作
450
+ // 直接 applyIndexResult('', file, prebuilt) 写 index.md,不 emit
451
+ console.log('ℹ 所有 anchor 均为 metadata-only,无需 LLM;直接写 index.md');
452
+ const md = applyIndexResult('', anchors, prebuilt);
453
+ const indexPath = join(forgeRoot, 'docs', 'index.md');
454
+ await mkdir(join(forgeRoot, 'docs'), { recursive: true });
455
+ await writeFile(indexPath, md, 'utf8');
456
+ console.log(`✓ index.md 已写 ${indexPath}(metadata-only 路径)`);
457
+ return LB_EXIT_OK;
458
+ }
459
+ // task 非 null:emit manifest,prebuilt 存进 meta 供 --apply 取回
460
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('index', 1, [task], {
461
+ prebuilt: prebuilt,
462
+ });
463
+ console.log(`✓ index manifest 已写 ${manifestPath(forgeRoot, 'index')}\n → 请 fulfill 后跑 forge legacy-bridge index --apply`);
464
+ return LB_EXIT_OK;
465
+ }
466
+ /** 从 changes/<changeId> 目录读 proposal.md + specs/,拼 changeContext + affectedModules */
467
+ async function readChangeContext(forgeRoot, changeId) {
468
+ const changesDir = join(forgeRoot, 'changes', changeId);
469
+ let changeContext = '';
470
+ const affectedModules = [];
471
+ if (existsSync(join(changesDir, 'proposal.md'))) {
472
+ changeContext += await readFile(join(changesDir, 'proposal.md'), 'utf8');
473
+ }
474
+ if (existsSync(join(changesDir, 'specs'))) {
475
+ const files = await readdir(join(changesDir, 'specs'));
476
+ for (const f of files) {
477
+ const txt = await readFile(join(changesDir, 'specs', f), 'utf8');
478
+ changeContext += `\n## specs/${f}\n${txt}`;
479
+ affectedModules.push(f.replace(/\.md$/, ''));
122
480
  }
481
+ }
482
+ return { changeContext, affectedModules };
483
+ }
484
+ /**
485
+ * sync-check 子命令三分支执行函数:
486
+ * 默认 → buildSyncCheckTask → emit manifest(agent 路径);
487
+ * --apply → 读 agent 结果 → applySyncCheckResult → 写 sync-state yaml;
488
+ * --api → 进程内调 Anthropic SDK → 写 sync-state yaml。
489
+ * 返回 exit code(0=成功,1=错误)。
490
+ */
491
+ export async function runSyncCheckCommand(opts) {
492
+ const forgeRoot = join(opts.projectRoot, 'forge');
493
+ // I-1:--apply 与 --api 语义互斥
494
+ if (opts.apply && opts.api) {
495
+ console.error('✗ --apply 与 --api 互斥,不能同时使用');
496
+ return LB_EXIT_GENERAL_ERROR;
497
+ }
498
+ // --apply 不调 LLM,跳过 opt-in gate
499
+ if (!opts.apply) {
500
+ const optin = await assertLlmOptIn(forgeRoot);
501
+ if (!optin.ok) {
502
+ console.error(`✗ ${optin.reason}`);
503
+ return optin.graceful ? LB_EXIT_OK : LB_EXIT_GENERAL_ERROR;
504
+ }
505
+ }
506
+ if (opts.apply) {
507
+ // --apply 分支:读 manifest → 读 agent 结果 → applySyncCheckResult → 写 yaml
508
+ let manifest;
123
509
  try {
124
- // 动态加载 forge-eval/load-env( regenerate / sync-check 子命令)
125
- const evalLoadEnvPath = new URL('../../../forge-eval/load-env.js', import.meta.url).href;
126
- const { loadEnv } = (await import(/* @vite-ignore */ evalLoadEnvPath));
127
- const { anthropicApiKey } = loadEnv();
128
- // Anthropic overload signature 与 MapperClient 单签名接口不兼容,需 double-cast
129
- const client = new Anthropic({ apiKey: anthropicApiKey });
130
- const docsPaths = opts.docsPaths
131
- ? opts.docsPaths.split(',').map((s) => s.trim())
132
- : undefined;
133
- const out = await runMapper(client, {
134
- projectRoot,
135
- docsPaths,
136
- scanSrc: true,
137
- mode,
138
- existing: existingAnchors ?? undefined,
139
- });
140
- const { yamlPath, mdPath } = await writeMapperDraft(forgeRoot, out);
141
- console.log(`✓ wrote ${yamlPath}`);
142
- console.log(`✓ wrote ${mdPath}`);
143
- console.log(` 新增 ${out.newAnchors.length} 个 anchor(merge 保留 ${out.preservedAnchors.length});unmatched ${out.unmatched.length} 个文件需用户审`);
144
- console.log('下一步:审改 legacy-anchors-draft.yaml 后跑 mv legacy-anchors-draft.yaml legacy-anchors.yaml');
145
- process.exit(LB_EXIT_OK);
510
+ manifest = await readManifest(forgeRoot, 'sync-check');
146
511
  }
147
- finally {
148
- if (release)
149
- await release();
512
+ catch (e) {
513
+ console.error(`✗ sync-check manifest 读取失败:${e.message}`);
514
+ return LB_EXIT_GENERAL_ERROR;
515
+ }
516
+ if (!manifest) {
517
+ console.error('✗ 无 sync-check manifest;请先跑 forge legacy-bridge sync-check');
518
+ return LB_EXIT_GENERAL_ERROR;
519
+ }
520
+ let result;
521
+ try {
522
+ [result] = await readTaskResults(forgeRoot, manifest.tasks);
150
523
  }
524
+ catch (e) {
525
+ console.error(`✗ agent 结果读取失败:${e.message}`);
526
+ return LB_EXIT_GENERAL_ERROR;
527
+ }
528
+ if (!result) {
529
+ console.error('✗ sync-check manifest 的 tasks 为空,无 agent 结果');
530
+ return LB_EXIT_GENERAL_ERROR;
531
+ }
532
+ // I-3:changeId 在 emit 时已写进 manifest.meta;--apply 不带 --change-id 时回退取 meta,
533
+ // 避免写错文件名(emit 用 ch-001,apply 不带就写成 (latest-archive))
534
+ const applyChangeId = opts.changeId ?? manifest.meta?.changeId ?? '(latest-archive)';
535
+ // manifest.manifest_hash 作 produced_from(双路径 resume gate 复核用)
536
+ const syncState = applySyncCheckResult(result.text, applyChangeId, manifest.manifest_hash);
537
+ const stateDir = join(forgeRoot, 'legacy-sync-state');
538
+ await mkdir(stateDir, { recursive: true });
539
+ await writeFile(join(stateDir, `${applyChangeId}.md`), renderDiffMarkdown(syncState), 'utf8');
540
+ await writeFile(join(stateDir, `${applyChangeId}.yaml`), renderDiffYaml(syncState), 'utf8');
541
+ await consumeManifest(forgeRoot, 'sync-check');
542
+ console.log(`✓ sync-state 已写 forge/legacy-sync-state/${applyChangeId}.yaml`);
543
+ return LB_EXIT_OK;
544
+ }
545
+ const changeId = opts.changeId ?? '(latest-archive)';
546
+ // emit / --api 需要 anchors
547
+ const anchors = await loadAnchorsFile(forgeRoot).catch((e) => {
548
+ console.warn(`⚠ legacy-anchors.yaml 读取失败:${e.message}`);
549
+ return null;
151
550
  });
152
- cmd
153
- .command('regenerate')
154
- .description('LLM 复写规范 SRS/HLD/LLD/system-tests + LLM 抽样验证(决策 #14-#16)')
155
- .option('--role <role>', '仅复写指定 role(默认全 4 role)')
156
- .option('--dry-run', '不调 LLM,只估算 cost + 列要扫的文件(§4.4)')
157
- .option('--include-historical', '把 authoritative=false 历史版作背景(默认关)')
158
- .option('--redact-report', '输出每条 redact 规则的命中数')
159
- .option('--yes', '非 TTY 必须显式 ack 高 cost 才继续(M-4)')
160
- .option('--skip-quality', '跳过 quality-judge 双 LLM 抽样(性能 / 调试用;P7-02 默认跑)')
161
- .action(async (opts) => {
162
- const forgeRoot = join(process.cwd(), 'forge');
163
- const configPath = join(forgeRoot, 'config.yaml');
164
- if (!existsSync(configPath)) {
165
- console.error('forge/config.yaml 不存在,先跑 forge init');
166
- process.exit(LB_EXIT_GENERAL_ERROR);
551
+ if (!anchors) {
552
+ // 无 anchors → graceful skip(决策 #11)
553
+ console.log('no legacy anchors configured, skipping sync-check');
554
+ return LB_EXIT_OK;
555
+ }
556
+ const configPath = join(forgeRoot, 'config.yaml');
557
+ let config;
558
+ try {
559
+ config = parseYaml(await readFile(configPath, 'utf8'));
560
+ }
561
+ catch (e) {
562
+ console.error(`forge/config.yaml 格式错误:${e.message}`);
563
+ return LB_EXIT_GENERAL_ERROR;
564
+ }
565
+ const { changeContext, affectedModules } = await readChangeContext(forgeRoot, changeId);
566
+ const syncInput = {
567
+ changeId,
568
+ changeContext,
569
+ affectedModules,
570
+ anchors,
571
+ autoResolveCrossAnchor: config.legacy_bridge?.auto_resolve_cross_anchor ?? false,
572
+ mtimeOf: (p) => {
573
+ try {
574
+ return Math.floor(statSync(p).mtimeMs / 1000);
575
+ }
576
+ catch {
577
+ return 0;
578
+ }
579
+ },
580
+ };
581
+ if (opts.api) {
582
+ // --api 分支:进程内调 Anthropic SDK
583
+ const client = (await makeForgeApiClient());
584
+ const out = await runSyncCheck(client, syncInput, async (path) => (await readAnchorFile(path)).text);
585
+ const stateDir = join(forgeRoot, 'legacy-sync-state');
586
+ await mkdir(stateDir, { recursive: true });
587
+ await writeFile(join(stateDir, `${changeId}.md`), renderDiffMarkdown(out.syncState), 'utf8');
588
+ await writeFile(join(stateDir, `${changeId}.yaml`), renderDiffYaml(out.syncState), 'utf8');
589
+ // M-2:遍历 hashChecks 对 stale anchor warn(沿旧 action 写法,--api 才有 hashChecks)
590
+ for (const h of out.hashChecks) {
591
+ if (h.state === 'stale') {
592
+ console.warn(`⚠ anchor ${h.anchor.path} 已改动(用户改了 docs/legacy/);复写产物可能脱节`);
593
+ }
167
594
  }
168
- let config;
595
+ console.log(`✓ sync-state 已写(--api 单进程)`);
596
+ return LB_EXIT_OK;
597
+ }
598
+ // 默认 agent 模式:buildSyncCheckTask → null-task 分支 / emit manifest
599
+ const task = await buildSyncCheckTask(syncInput, async (path) => (await readAnchorFile(path)).text);
600
+ if (task === null) {
601
+ // null-task:无受影响 anchor 或全部读取失败 → graceful skip
602
+ console.log('ℹ 无受影响的 anchor,无需 sync-check');
603
+ return LB_EXIT_OK;
604
+ }
605
+ // I-3:changeId 也写进 meta,--apply 不带 --change-id 时可回退;
606
+ // gate_context='standalone' 标记本次非 archive 集成触发
607
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('sync-check', 1, [task], {
608
+ gate_context: 'standalone',
609
+ changeId,
610
+ });
611
+ console.log(`✓ sync-check manifest 已写 ${manifestPath(forgeRoot, 'sync-check')}\n → 请 fulfill 后跑 forge legacy-bridge sync-check --apply`);
612
+ return LB_EXIT_OK;
613
+ }
614
+ /**
615
+ * 三处成功路径共用的产物收尾 helper(I-1 + I-2 修):
616
+ * ① wrapWithFrontmatterAndDisclaimer 包 frontmatter + disclaimer(I-2);
617
+ * ② 写产物到 forge/docs/regenerated/<role>.md;
618
+ * ③ computeAnchorHash 回写 anchor.hash + last_regenerated 到 legacy-anchors.yaml(I-1)。
619
+ * 供 --api / --apply round=1 --skip-quality / --apply round=2 passed 三处复用。
620
+ */
621
+ async function finalizeRegenProduct(forgeRoot, outPath, regenBody, input, anchor, anchorsFile) {
622
+ // I-2:补 frontmatter + disclaimer(与 regenerateRole 单进程产物一致)
623
+ const fullMarkdown = wrapWithFrontmatterAndDisclaimer(regenBody, input);
624
+ await writeFile(outPath, fullMarkdown, 'utf8');
625
+ // I-1:anchor hash + last_regenerated 回写 legacy-anchors.yaml
626
+ const hash = await computeAnchorHash(anchor.path);
627
+ anchor.hash = hash ?? anchor.hash;
628
+ anchor.last_regenerated = new Date().toISOString();
629
+ await writeFile(join(forgeRoot, 'legacy-anchors.yaml'), stringifyYaml(anchorsFile), 'utf8');
630
+ }
631
+ /**
632
+ * regenerate 子命令三分支执行函数(单 role 双路径,Task 6.2 round-2 完整移植):
633
+ * --dry-run → 列 anchor + redact,不调 LLM 不 emit;
634
+ * 默认 → buildRegenerateRound1Tasks → emit round=1 manifest;
635
+ * --apply round=1 → applyRound1AndBuildRound2(--skip-quality 时直接写产物)→ emit round=2;
636
+ * --apply round=2 → applyRound2 → 写产物(含 frontmatter + anchor 回写)/ .partial;
637
+ * --api → 旧单进程行为忠实移植(成本闸/countdown/regenerateRole/P7-02 抽样/frontmatter/anchor 回写)。
638
+ * 返回 exit code(0=成功,1=错误,2=quality-fail,3=partial-success,5=lock-held)。
639
+ */
640
+ export async function runRegenerateCommand(opts) {
641
+ const forgeRoot = join(opts.projectRoot, 'forge');
642
+ // I-1:--apply 与 --api 语义互斥
643
+ if (opts.apply && opts.api) {
644
+ console.error('✗ --apply 与 --api 互斥,不能同时使用');
645
+ return LB_EXIT_GENERAL_ERROR;
646
+ }
647
+ // --apply 不调 LLM,跳过 opt-in gate;--dry-run 也不调 LLM(只读 anchor 跑 redact),
648
+ // 不发数据 → 不过 opt-in gate(对齐 task「--dry-run 优先于一切」);emit / --api 先过 gate
649
+ if (!opts.apply && !opts.dryRun) {
650
+ const optin = await assertLlmOptIn(forgeRoot);
651
+ if (!optin.ok) {
652
+ console.error(`✗ ${optin.reason}`);
653
+ return optin.graceful ? LB_EXIT_OK : LB_EXIT_GENERAL_ERROR;
654
+ }
655
+ }
656
+ // 读 config + anchors(emit / --apply / --api 均需要)
657
+ const configPath = join(forgeRoot, 'config.yaml');
658
+ if (!existsSync(configPath)) {
659
+ console.error('forge/config.yaml 不存在,先跑 forge init');
660
+ return LB_EXIT_GENERAL_ERROR;
661
+ }
662
+ let config;
663
+ try {
664
+ config = parseYaml(await readFile(configPath, 'utf8'));
665
+ }
666
+ catch (e) {
667
+ console.error(`forge/config.yaml 格式错误:${e.message}`);
668
+ return LB_EXIT_GENERAL_ERROR;
669
+ }
670
+ // 读 anchors:损坏 warn(不静默吞 LegacyAnchorsError)
671
+ const anchors = await loadAnchorsFile(forgeRoot).catch((e) => {
672
+ console.warn(`⚠ legacy-anchors.yaml 读取失败:${e.message}`);
673
+ return null;
674
+ });
675
+ if (!anchors) {
676
+ console.error('✗ legacy-anchors.yaml 不存在;先跑 forge legacy-bridge map 生成 draft');
677
+ return LB_EXIT_GENERAL_ERROR;
678
+ }
679
+ const regenLicense = config.legacy_bridge?.regen_license ?? 'derived-from-source';
680
+ const docsDir = join(forgeRoot, 'docs', 'regenerated');
681
+ // threshold 沿用 quality-judge DEFAULT_FIDELITY_THRESHOLD
682
+ const threshold = DEFAULT_FIDELITY_THRESHOLD;
683
+ // ----------------------------------------------------------------------
684
+ // --apply 分支:读 manifest → 分 round 处理(不调 LLM,跳过 gate / 成本闸)
685
+ // ----------------------------------------------------------------------
686
+ if (opts.apply) {
687
+ let manifest;
169
688
  try {
170
- config = parseYaml(await readFile(configPath, 'utf8'));
689
+ manifest = await readManifest(forgeRoot, 'regenerate');
171
690
  }
172
691
  catch (e) {
173
- console.error(`forge/config.yaml 格式错误:${e.message}`);
174
- process.exit(LB_EXIT_GENERAL_ERROR);
692
+ console.error(`✗ regenerate manifest 读取失败:${e.message}`);
693
+ return LB_EXIT_GENERAL_ERROR;
175
694
  }
176
- const anchors = await loadAnchorsFile(forgeRoot).catch((err) => {
177
- if (err instanceof LegacyAnchorsError) {
178
- console.error(`✗ ${err.message}`);
179
- process.exit(LB_EXIT_GENERAL_ERROR);
695
+ if (!manifest) {
696
+ console.error('✗ regenerate manifest;请先跑 forge legacy-bridge regenerate');
697
+ return LB_EXIT_GENERAL_ERROR;
698
+ }
699
+ // M-4:role 在 emit 时已定 → manifest.meta.role 优先于 opts.role;不一致时 warn
700
+ const metaRole = manifest.meta?.role;
701
+ if (metaRole && opts.role && metaRole !== opts.role) {
702
+ console.warn(`⚠ --role '${opts.role}' 与 manifest 记录的 role '${metaRole}' 不一致;以 manifest 为准`);
703
+ }
704
+ const taskRole = (metaRole ?? opts.role ?? 'requirements');
705
+ // 从 anchors 反查该 role 的 authoritative anchor(产物收尾要 anchor 回写 hash)
706
+ const applyAnchor = getAuthoritativeAnchors(anchors).find((a) => a.role === taskRole);
707
+ if (manifest.round === 1) {
708
+ // round=1 --apply:applyRound1AndBuildRound2
709
+ let results;
710
+ try {
711
+ results = await readTaskResults(forgeRoot, manifest.tasks);
180
712
  }
181
- throw err;
182
- });
183
- if (!anchors) {
184
- console.error('✗ legacy-anchors.yaml 不存在;请先跑 forge legacy-bridge map 生成 draft 后审改');
185
- process.exit(LB_EXIT_GENERAL_ERROR);
713
+ catch (e) {
714
+ console.error(`✗ agent 结果读取失败:${e.message}`);
715
+ return LB_EXIT_GENERAL_ERROR;
716
+ }
717
+ // 按 task.op 区分 regenerate / extract-facts 两结果
718
+ const regenResult = results.find((r) => r.op === 'regenerate');
719
+ const extractResult = results.find((r) => r.op === 'extract-facts');
720
+ if (!regenResult || !extractResult) {
721
+ console.error('✗ round=1 结果不完整:需要 regenerate + extract-facts 两个 task 结果');
722
+ return LB_EXIT_GENERAL_ERROR;
723
+ }
724
+ await mkdir(docsDir, { recursive: true });
725
+ const outPath = join(docsDir, REGEN_FILENAMES[taskRole]);
726
+ const partialPath = `${outPath}.partial`;
727
+ // --skip-quality:不 emit 轮2,直接把 round-1 regenBody 经 frontmatter 包装写产物
728
+ if (opts.skipQuality) {
729
+ // regenBody 仍需过 markdown 合法性校验(validateRegenOutput:空/过短/frontmatter/fence 不闭合);
730
+ // skip-quality 跳过 extract-facts 抽样,但产物校验不能省
731
+ try {
732
+ validateRegenOutput(regenResult.text, taskRole);
733
+ }
734
+ catch (e) {
735
+ if (e instanceof RegenOutputError) {
736
+ console.error(`✗ ${e.message}`);
737
+ await writeFile(partialPath, regenResult.text, 'utf8');
738
+ console.error(`✗ 已写 ${partialPath}`);
739
+ return LB_EXIT_PARTIAL_SUCCESS;
740
+ }
741
+ throw e;
742
+ }
743
+ if (!applyAnchor) {
744
+ console.error(`✗ role '${taskRole}' 在 legacy-anchors.yaml 无 authoritative anchor`);
745
+ return LB_EXIT_GENERAL_ERROR;
746
+ }
747
+ const input = {
748
+ role: taskRole,
749
+ authoritative: applyAnchor,
750
+ forgeVersion: FORGE_VERSION,
751
+ regenLicense,
752
+ globalRedactRules: anchors.redact,
753
+ };
754
+ await finalizeRegenProduct(forgeRoot, outPath, regenResult.text, input, applyAnchor, anchors);
755
+ await consumeManifest(forgeRoot, 'regenerate');
756
+ console.log(`✓ --skip-quality:跳过 quality-judge,直接写产物 ${outPath}`);
757
+ return LB_EXIT_OK;
758
+ }
759
+ // 正常路径:applyRound1AndBuildRound2 → emit 轮2
760
+ let round2Result;
761
+ try {
762
+ round2Result = applyRound1AndBuildRound2(regenResult.text, extractResult.text, taskRole);
763
+ }
764
+ catch (e) {
765
+ if (e instanceof RegenOutputError) {
766
+ // metadata-only role / extract-facts 空 → 写 .partial,不 emit 轮2
767
+ console.error(`✗ ${e.message}`);
768
+ await writeFile(partialPath, regenResult.text, 'utf8');
769
+ console.error(`✗ 已写 ${partialPath};用户决策:接受 .partial / 重写 prompt`);
770
+ return LB_EXIT_PARTIAL_SUCCESS;
771
+ }
772
+ throw e;
773
+ }
774
+ // 成功:emit round=2 manifest(meta 带 sampling / regenBody / role)
775
+ const { task: r2task, sampling } = round2Result;
776
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('regenerate', 2, [r2task], {
777
+ sampling: sampling,
778
+ regenBody: regenResult.text,
779
+ role: taskRole,
780
+ });
781
+ console.log(`✓ round=2 manifest 已写 ${manifestPath(forgeRoot, 'regenerate')}\n → 请 fulfill quality-judge task 后跑 forge legacy-bridge regenerate --apply`);
782
+ return LB_EXIT_OK;
186
783
  }
187
- // ack 检查(决策 #22)
188
- const ackResult = await checkAck(forgeRoot, config, anchors);
189
- if (!ackResult.ok) {
190
- console.error(renderOptinPrompt(ackResult.reason, ackResult.customerDataPaths));
191
- process.exit(LB_EXIT_GENERAL_ERROR);
784
+ if (manifest.round === 2) {
785
+ // round=2 --apply:applyRound2 写产物 / .partial
786
+ let results;
787
+ try {
788
+ results = await readTaskResults(forgeRoot, manifest.tasks);
789
+ }
790
+ catch (e) {
791
+ console.error(`✗ agent 结果读取失败:${e.message}`);
792
+ return LB_EXIT_GENERAL_ERROR;
793
+ }
794
+ const judgeResult = results.find((r) => r.op === 'quality-judge');
795
+ if (!judgeResult) {
796
+ console.error('✗ round=2 结果缺失:需要 quality-judge task 结果');
797
+ return LB_EXIT_GENERAL_ERROR;
798
+ }
799
+ // 从 manifest.meta.sampling 取回 SamplingOutput(JSON 往返后 as 类型)
800
+ const sampling = manifest.meta?.sampling;
801
+ if (!sampling) {
802
+ console.error('✗ manifest.meta.sampling 缺失,无法跑 applyRound2');
803
+ return LB_EXIT_GENERAL_ERROR;
804
+ }
805
+ const regenBody = manifest.meta?.regenBody ?? '';
806
+ const quality = applyRound2(judgeResult.text, sampling, threshold);
807
+ await mkdir(docsDir, { recursive: true });
808
+ const outPath = join(docsDir, REGEN_FILENAMES[taskRole]);
809
+ const partialPath = `${outPath}.partial`;
810
+ if (!quality.passed) {
811
+ // 不达标:写 .partial + quality YAML(spec §4.3 不变量)
812
+ await writeFile(partialPath, regenBody ||
813
+ '<!-- 复写正文(来自 manifest.meta.regenBody)为空,请检查 round=1 --apply 是否正确存储 -->', 'utf8');
814
+ const qualityFile = {
815
+ schema: 'forge-regen-quality/v1',
816
+ role: taskRole,
817
+ generated_at: new Date().toISOString(),
818
+ result: quality,
819
+ };
820
+ await writeFile(`${outPath}.partial.yaml`, stringifyYaml(qualityFile), 'utf8');
821
+ console.error(`✗ quality 不达标:total=${(quality.total_rate * 100).toFixed(1)}%,critical=${(quality.critical_rate * 100).toFixed(1)}%`);
822
+ console.error(formatQualityReport(taskRole, quality));
823
+ console.error(`✗ 已写 ${partialPath} 与 ${outPath}.partial.yaml`);
824
+ await consumeManifest(forgeRoot, 'regenerate');
825
+ return LB_EXIT_PARTIAL_SUCCESS;
826
+ }
827
+ // 达标:I-2 补 frontmatter + I-1 回写 anchor hash + 写产物
828
+ if (!applyAnchor) {
829
+ console.error(`✗ role '${taskRole}' 在 legacy-anchors.yaml 无 authoritative anchor`);
830
+ return LB_EXIT_GENERAL_ERROR;
831
+ }
832
+ const input = {
833
+ role: taskRole,
834
+ authoritative: applyAnchor,
835
+ forgeVersion: FORGE_VERSION,
836
+ regenLicense,
837
+ globalRedactRules: anchors.redact,
838
+ };
839
+ await finalizeRegenProduct(forgeRoot, outPath, regenBody, input, applyAnchor, anchors);
840
+ await consumeManifest(forgeRoot, 'regenerate');
841
+ console.log(`✓ quality 达标:total=${(quality.total_rate * 100).toFixed(1)}%,critical=${(quality.critical_rate * 100).toFixed(1)}%`);
842
+ console.log(`✓ 写产物 ${outPath}(含 frontmatter,anchor hash 已回写)`);
843
+ return LB_EXIT_OK;
192
844
  }
193
- // 估算 cost + budget gate
194
- // P7-03 修复:过滤掉 metadata-only role(spec §7 line 909)
195
- const authoritativeAnchors = getAuthoritativeAnchors(anchors)
196
- .filter((a) => !METADATA_ONLY_ROLES.includes(a.role))
197
- .filter((a) => !opts.role || a.role === opts.role);
198
- if (authoritativeAnchors.length === 0) {
199
- console.error(opts.role
200
- ? `✗ role '${opts.role}' 在 legacy-anchors.yaml 无 authoritative anchor`
201
- : '✗ legacy-anchors.yaml 无任何 authoritative=true anchor');
202
- process.exit(LB_EXIT_GENERAL_ERROR);
845
+ console.error(`✗ manifest.round=${String(manifest.round)} 不识别`);
846
+ return LB_EXIT_GENERAL_ERROR;
847
+ }
848
+ // ----------------------------------------------------------------------
849
+ // emit / --api / --dry-run 共用:确定 authoritative anchor + role
850
+ // ----------------------------------------------------------------------
851
+ const authoritativeAnchors = getAuthoritativeAnchors(anchors)
852
+ .filter((a) => !METADATA_ONLY_ROLES.includes(a.role))
853
+ .filter((a) => !opts.role || a.role === opts.role);
854
+ if (authoritativeAnchors.length === 0) {
855
+ console.error(opts.role
856
+ ? `✗ role '${opts.role}' 在 legacy-anchors.yaml 无 authoritative anchor`
857
+ : '✗ legacy-anchors.yaml 无任何 authoritative=true 的非 metadata-only anchor');
858
+ return LB_EXIT_GENERAL_ERROR;
859
+ }
860
+ // Task 6.2 单 role 接线(全量多 role 留给后续):取第一个
861
+ const anchor = authoritativeAnchors[0];
862
+ // I-3 修:--include-historical 时收集同 role 的 authoritative=false anchors 作背景输入
863
+ const historical = opts.includeHistorical
864
+ ? (anchors.anchors ?? []).filter((a) => a.role === anchor.role && a.authoritative === false)
865
+ : undefined;
866
+ const regenInput = {
867
+ role: anchor.role,
868
+ authoritative: anchor,
869
+ historical,
870
+ forgeVersion: FORGE_VERSION,
871
+ regenLicense,
872
+ globalRedactRules: anchors.redact,
873
+ };
874
+ // ----------------------------------------------------------------------
875
+ // --dry-run:不调 LLM、不 emit,列 anchor + 跑 redact(§4.4,优先于一切)
876
+ // ----------------------------------------------------------------------
877
+ if (opts.dryRun) {
878
+ const globalRules = anchors.redact ?? [];
879
+ const totalReport = { hitsByRule: {}, totalReplacements: 0, redactedText: '' };
880
+ for (const a of authoritativeAnchors) {
881
+ console.log(`[dry-run] role=${a.role} path=${a.path}`);
882
+ const text = await readAnchorAsText(a);
883
+ const customRules = [...globalRules, ...(a.redact ?? [])];
884
+ const report = redact(text, customRules);
885
+ for (const [name, count] of Object.entries(report.hitsByRule)) {
886
+ totalReport.hitsByRule[name] = (totalReport.hitsByRule[name] ?? 0) + count;
887
+ }
888
+ totalReport.totalReplacements += report.totalReplacements;
889
+ }
890
+ if (opts.redactReport) {
891
+ console.log(formatRedactReport(totalReport));
203
892
  }
893
+ return LB_EXIT_OK;
894
+ }
895
+ // ----------------------------------------------------------------------
896
+ // --api 分支:旧单进程行为忠实移植(regenerateRole + P7-02 双 LLM 抽样)
897
+ // ----------------------------------------------------------------------
898
+ if (opts.api) {
899
+ // I-1 修:成本闸 + countdown 只对 --api 触发 —— 默认 emit 路径只写 manifest JSON
900
+ // 到 .cache/,不发任何 LLM 请求、不耗 API 额度(agent 路径走会话额度),
901
+ // 故 emit / --apply 路径完全不经成本闸。--api 真在进程内调 API,成本闸语义对它正确。
204
902
  const estimated = estimateRegenerateCost(authoritativeAnchors.length);
205
903
  const gate = checkBudgetGate(estimated, REGEN_WARN_USD, opts.yes ?? false);
206
904
  console.error(gate.message);
207
905
  if (!gate.proceed) {
208
- process.exit(gate.exitCode);
906
+ return gate.exitCode;
209
907
  }
210
- if (gate.requiresCountdown && !opts.dryRun) {
908
+ if (gate.requiresCountdown) {
211
909
  await countdown(5);
212
910
  }
213
- if (opts.dryRun) {
214
- // §4.4 dry-run:不调 LLM,但会真读 anchor + 跑 redact;
215
- // Phase F follow-up:让 --dry-run --redact-report 可独立验证 redact 规则真生效
216
- // (release-gate-checklist §2.4.2 期望的输出路径)
217
- // xlsx 路径走 readAnchorAsText 真过 exceljs 解析;不支持特性时抛 ExcelParseError
218
- // 让 §2.4.6 也能在不调 LLM 的前提下端到端验证 Excel 解析
219
- // 全局 redact 规则 = anchors 文件级 redact + 每 anchor 自身 redact(redact() 内部合并)
220
- const globalRules = anchors.redact ?? [];
221
- // 累加汇总每 anchor 的命中数(redactedText 在 dry-run 不需要,仅占位)
222
- const totalReport = {
223
- hitsByRule: {},
224
- totalReplacements: 0,
225
- redactedText: '',
226
- };
227
- for (const a of authoritativeAnchors) {
228
- console.log(`[dry-run] role=${a.role} path=${a.path}`);
229
- const text = await readAnchorAsText(a);
230
- const customRules = [...globalRules, ...(a.redact ?? [])];
231
- const report = redact(text, customRules);
232
- for (const [name, count] of Object.entries(report.hitsByRule)) {
233
- totalReport.hitsByRule[name] = (totalReport.hitsByRule[name] ?? 0) + count;
234
- }
235
- totalReport.totalReplacements += report.totalReplacements;
236
- }
237
- if (opts.redactReport) {
238
- console.log(formatRedactReport(totalReport));
239
- }
240
- process.exit(LB_EXIT_OK);
241
- }
242
911
  // 锁(决策 #23):同时获 archive.lock + legacy-bridge.lock,顺序固定
243
912
  let releaseArchive;
244
913
  let releaseLb;
@@ -247,112 +916,80 @@ export function buildLegacyBridgeCommand() {
247
916
  releaseLb = await acquireLockByPath(forgeRoot, 'legacy-bridge-regenerate', 'legacy-bridge.lock');
248
917
  }
249
918
  catch (err) {
919
+ // C-1 修:第二把锁(legacy-bridge.lock)获取若抛非 LockHeldError(权限错 / 磁盘满),
920
+ // 第一把 archive.lock 已持有 —— 任何 catch 路径 rethrow / return 前都先释放它,防永久泄漏。
921
+ if (releaseArchive)
922
+ await releaseArchive();
250
923
  if (err instanceof LockHeldError) {
251
924
  console.error(`✗ ${err.message}`);
252
- // I-fix:即使一把已 acquired,catch 路径要释放它
253
- if (releaseArchive)
254
- await releaseArchive();
255
- process.exit(LB_EXIT_LOCK_HELD);
925
+ return LB_EXIT_LOCK_HELD;
256
926
  }
257
927
  throw err;
258
928
  }
259
929
  try {
260
- // 运行时动态加载 forge-eval/load-env(避免 src/ rootDir 静态分析边界限制;
261
- // 使用 Function constructor 跳过 TS static import trace)
262
- const evalLoadEnvPath = new URL('../../../forge-eval/load-env.js', import.meta.url).href;
263
- const { loadEnv } = (await import(/* @vite-ignore */ evalLoadEnvPath));
264
- const { anthropicApiKey } = loadEnv();
265
- // RegenerateClient / JudgeClient 结构相同;Anthropic overload signature 与单签名接口不兼容,需 double-cast
266
- const client = new Anthropic({
267
- apiKey: anthropicApiKey,
268
- });
269
- const regenLicense = config.legacy_bridge?.regen_license ?? 'derived-from-source';
270
- const docsDir = join(forgeRoot, 'docs', 'regenerated');
930
+ // RegenerateClient / JudgeClient 结构相同;复用 makeForgeApiClient,double-cast
931
+ const client = (await makeForgeApiClient());
271
932
  await mkdir(docsDir, { recursive: true });
272
- // P7-02 修复:跨 role 汇总 quality 状态;循环结束后统一 exit 2(决策 #16:无 retry)
273
- let anyQualityFailed = false;
274
- for (const anchor of authoritativeAnchors) {
275
- console.log(`→ regenerating role=${anchor.role} (model=claude-sonnet-4-6)`);
276
- // I-3 修:--include-historical 时收集同 role 的 authoritative=false anchors 作背景输入
277
- const historical = opts.includeHistorical
278
- ? (anchors.anchors ?? []).filter((a) => a.role === anchor.role && a.authoritative === false)
279
- : undefined;
280
- const out = await regenerateRole({
281
- role: anchor.role,
282
- authoritative: anchor,
283
- historical,
284
- forgeVersion: FORGE_VERSION,
285
- regenLicense,
286
- globalRedactRules: anchors.redact,
287
- }, client);
288
- if (opts.redactReport) {
289
- console.log(formatRedactReport(out.redactReport));
933
+ console.log(`→ regenerating role=${anchor.role} (model=claude-sonnet-4-6)`);
934
+ // regenerateRole 内含 redact + LLM 复写 + validateRegenOutput + frontmatter 包装
935
+ const out = await regenerateRole(regenInput, client);
936
+ if (opts.redactReport) {
937
+ console.log(formatRedactReport(out.redactReport));
938
+ }
939
+ const outPath = join(docsDir, REGEN_FILENAMES[anchor.role]);
940
+ const partialPath = `${outPath}.partial`;
941
+ const qualityYamlPath = `${outPath}.partial.yaml`;
942
+ // P7-02:默认跑 quality-judge 双 LLM 抽样;--skip-quality 跳过
943
+ if (!opts.skipQuality) {
944
+ const originalText = (await readAnchorFile(anchor.path)).text;
945
+ console.log(`→ extracting key facts from ${anchor.path} (model B)`);
946
+ const facts = await extractFactsFromOriginal(client, originalText);
947
+ if (facts.length === 0) {
948
+ console.warn(`⚠ 无法从原文抽取 key facts(LLM 输出非合法 JSON);quality-judge 跳过此 role`);
290
949
  }
291
- // P7-02 修复:regenerate 内置双 LLM 抽样验证(spec §3.1 line 336-339 / 决策 #16)
292
- // 默认走 quality-judge;--skip-quality 时跳过(性能 / 调试用)
293
- const outPath = join(docsDir, REGEN_FILENAMES[anchor.role]);
294
- const partialPath = `${outPath}.partial`;
295
- const qualityYamlPath = `${outPath}.partial.yaml`;
296
- if (!opts.skipQuality) {
297
- const originalText = (await readAnchorFile(anchor.path)).text;
298
- console.log(`→ extracting key facts from ${anchor.path} (model B)`);
299
- const facts = await extractFactsFromOriginal(client, originalText);
300
- if (facts.length === 0) {
301
- console.warn(`⚠ 无法从原文抽取 key facts(LLM 输出非合法 JSON);quality-judge 跳过此 role`);
302
- }
303
- else {
304
- const sampling = stratifiedSample({ allFacts: facts });
305
- const quality = await judgeAllFacts(client, out.body, sampling);
306
- if (!quality.passed) {
307
- anyQualityFailed = true;
308
- // 写 .partial + quality YAML(spec §4.3 不变量)
309
- await writeFile(partialPath, out.fullMarkdown, 'utf8');
310
- const qualityFile = {
311
- schema: 'forge-regen-quality/v1',
312
- role: anchor.role,
313
- generated_at: new Date().toISOString(),
314
- result: quality,
315
- };
316
- await writeFile(qualityYamlPath, stringifyYaml(qualityFile), 'utf8');
317
- console.error(`✗ role=${anchor.role} 保真率不达标:total=${(quality.total_rate * 100).toFixed(1)}%, critical=${(quality.critical_rate * 100).toFixed(1)}%`);
318
- console.error(formatQualityReport(anchor.role, quality));
319
- console.error(`✗ 已写 ${partialPath} 与 ${qualityYamlPath};用户决策:接受 .partial / 重写 prompt 重跑 / 手补`);
320
- // 不立即 exit,完成所有 role 后统一 exit 2(决策 #16:无 retry)
321
- continue;
322
- }
323
- console.log(`✓ quality 达标:total=${(quality.total_rate * 100).toFixed(1)}%, critical=${(quality.critical_rate * 100).toFixed(1)}%`);
950
+ else {
951
+ const sampling = stratifiedSample({ allFacts: facts });
952
+ const quality = await judgeAllFacts(client, out.body, sampling);
953
+ if (!quality.passed) {
954
+ // .partial + quality YAML(spec §4.3 不变量)
955
+ await writeFile(partialPath, out.fullMarkdown, 'utf8');
956
+ const qualityFile = {
957
+ schema: 'forge-regen-quality/v1',
958
+ role: anchor.role,
959
+ generated_at: new Date().toISOString(),
960
+ result: quality,
961
+ };
962
+ await writeFile(qualityYamlPath, stringifyYaml(qualityFile), 'utf8');
963
+ console.error(`✗ role=${anchor.role} 保真率不达标:total=${(quality.total_rate * 100).toFixed(1)}%, critical=${(quality.critical_rate * 100).toFixed(1)}%`);
964
+ console.error(formatQualityReport(anchor.role, quality));
965
+ console.error(`✗ 已写 ${partialPath} 与 ${qualityYamlPath}`);
966
+ return LB_EXIT_PARTIAL_SUCCESS;
324
967
  }
968
+ console.log(`✓ quality 达标:total=${(quality.total_rate * 100).toFixed(1)}%, critical=${(quality.critical_rate * 100).toFixed(1)}%`);
325
969
  }
326
- // 写产物
327
- await writeFile(outPath, out.fullMarkdown, 'utf8');
328
- console.log(`✓ wrote ${outPath} (${out.tokensUsed} tokens, ~$${out.estimatedCost.toFixed(3)})`);
329
- // 更新 anchor 的 hash + last_regenerated(写回 yaml)
330
- const hash = await computeAnchorHash(anchor.path);
331
- anchor.hash = hash ?? anchor.hash;
332
- anchor.last_regenerated = new Date().toISOString();
333
970
  }
334
- // 写回 anchors.yaml(更新 hash + last_regenerated)
971
+ // 写产物(out.fullMarkdown 已含 frontmatter)
972
+ await writeFile(outPath, out.fullMarkdown, 'utf8');
973
+ console.log(`✓ wrote ${outPath} (${out.tokensUsed} tokens, ~$${out.estimatedCost.toFixed(3)})`);
974
+ // I-1:anchor hash + last_regenerated 回写 legacy-anchors.yaml
975
+ const hash = await computeAnchorHash(anchor.path);
976
+ anchor.hash = hash ?? anchor.hash;
977
+ anchor.last_regenerated = new Date().toISOString();
335
978
  await writeFile(join(forgeRoot, 'legacy-anchors.yaml'), stringifyYaml(anchors), 'utf8');
336
979
  console.log(`✓ legacy-anchors.yaml hash + last_regenerated 已更新`);
337
- // P7-02 修复:任一 role 不达标 → exit 2(决策 #16 / spec §3.1 line 339,无 retry)
338
- // 注:process.exit 在 try 块内调用,但 finally 仍会跑(Node 行为);
339
- // anyQualityFailed 路径锁释放由 finally 负责(release 函数 idempotent,二次 rm 不存在文件不抛)
340
- if (anyQualityFailed) {
341
- process.exit(LB_EXIT_BUSINESS_RULE_FAIL);
342
- }
980
+ return LB_EXIT_OK;
343
981
  }
344
982
  catch (err) {
345
- // 捕获 RegenOutputError / 其他;先释放锁再抛
983
+ // RegenOutputError exit 2(决策 #16);先释放锁再返回
346
984
  if (err instanceof RegenOutputError) {
347
985
  console.error(`✗ ${err.message}`);
348
- // 释放锁并设 undefined → finally 块因此跳过重复释放(避免 idempotent 依赖)
349
986
  if (releaseLb)
350
987
  await releaseLb();
351
988
  if (releaseArchive)
352
989
  await releaseArchive();
353
990
  releaseLb = undefined;
354
991
  releaseArchive = undefined;
355
- process.exit(LB_EXIT_BUSINESS_RULE_FAIL);
992
+ return LB_EXIT_BUSINESS_RULE_FAIL;
356
993
  }
357
994
  throw err;
358
995
  }
@@ -362,184 +999,152 @@ export function buildLegacyBridgeCommand() {
362
999
  if (releaseArchive)
363
1000
  await releaseArchive();
364
1001
  }
1002
+ }
1003
+ // ----------------------------------------------------------------------
1004
+ // 默认 agent 模式:buildRegenerateRound1Tasks → emit round=1 manifest
1005
+ // ----------------------------------------------------------------------
1006
+ let round1Tasks;
1007
+ try {
1008
+ round1Tasks = await buildRegenerateRound1Tasks(regenInput);
1009
+ }
1010
+ catch (e) {
1011
+ if (e instanceof RegenOutputError) {
1012
+ // metadata-only role(理论上已被上面 filter 排除,防御性处理)
1013
+ console.error(`✗ ${e.message}`);
1014
+ return LB_EXIT_GENERAL_ERROR;
1015
+ }
1016
+ throw e;
1017
+ }
1018
+ await new AgentHandoffRunner(forgeRoot, FORGE_VERSION).emit('regenerate', 1, round1Tasks, {
1019
+ role: anchor.role,
365
1020
  });
366
- cmd
367
- .command('index')
368
- .description('为每个 anchor 生成 ~100 字 LLM 摘要(决策 #14 Layer 2)')
369
- .option('--yes', '非 TTY 必须显式 ack')
370
- .action(async (_opts) => {
371
- const forgeRoot = join(process.cwd(), 'forge');
372
- const configPath = join(forgeRoot, 'config.yaml');
373
- if (!existsSync(configPath)) {
374
- console.error('forge/config.yaml 不存在,先跑 forge init');
1021
+ console.log(`✓ regenerate round=1 manifest 已写 ${manifestPath(forgeRoot, 'regenerate')}\n → 请 fulfill 后跑 forge legacy-bridge regenerate --apply`);
1022
+ return LB_EXIT_OK;
1023
+ }
1024
+ /** 主命令 build:无参数走 help;含 --acknowledge-data-transfer 时进入 ack 流程(Phase B1 填) */
1025
+ export function buildLegacyBridgeCommand() {
1026
+ const cmd = new Command('legacy-bridge')
1027
+ .description('Brownfield onboarding:与老文档体系并存 + archive→legacy 单向同步(v0.2)')
1028
+ .option('--acknowledge-data-transfer', 'opt-in:ack 数据将被发送到 LLM provider(决策 #22)')
1029
+ .option('--acknowledge-customer-data', '同时 ack 含客户数据的 anchor(§4.5 GDPR 二次确认门)');
1030
+ cmd.action(async (opts) => {
1031
+ // M2 修:--acknowledge-customer-data 必须与 --acknowledge-data-transfer 同用,
1032
+ // 单独传不应静默走 help(用户不知 flag 没生效)
1033
+ if (opts.acknowledgeCustomerData && !opts.acknowledgeDataTransfer) {
1034
+ console.error('✗ --acknowledge-customer-data 必须与 --acknowledge-data-transfer 同时使用');
375
1035
  process.exit(LB_EXIT_GENERAL_ERROR);
376
1036
  }
377
- const config = parseYaml(await readFile(configPath, 'utf8'));
378
- const anchors = await loadAnchorsFile(forgeRoot).catch((err) => {
379
- if (err instanceof LegacyAnchorsError) {
380
- console.error(`✗ ${err.message}`);
1037
+ if (opts.acknowledgeDataTransfer) {
1038
+ const forgeRoot = join(process.cwd(), 'forge');
1039
+ const configPath = join(forgeRoot, 'config.yaml');
1040
+ if (!existsSync(configPath)) {
1041
+ console.error('forge/config.yaml 不存在,先跑 forge init 初始化项目');
381
1042
  process.exit(LB_EXIT_GENERAL_ERROR);
382
1043
  }
383
- throw err;
384
- });
385
- if (!anchors) {
386
- console.error('✗ legacy-anchors.yaml 不存在;先跑 forge legacy-bridge map 生成 draft');
387
- process.exit(LB_EXIT_GENERAL_ERROR);
388
- }
389
- const ack = await checkAck(forgeRoot, config, anchors);
390
- if (!ack.ok) {
391
- console.error(renderOptinPrompt(ack.reason, ack.customerDataPaths));
392
- process.exit(LB_EXIT_GENERAL_ERROR);
393
- }
394
- // 锁(同 regenerate:archive + legacy-bridge 双锁)
395
- let releaseLb;
396
- let releaseArchive;
397
- try {
398
- releaseArchive = await acquireLockByPath(forgeRoot, 'legacy-bridge-index', 'archive.lock');
399
- releaseLb = await acquireLockByPath(forgeRoot, 'legacy-bridge-index', 'legacy-bridge.lock');
400
- }
401
- catch (err) {
402
- if (err instanceof LockHeldError) {
403
- console.error(`✗ ${err.message}`);
404
- process.exit(LB_EXIT_LOCK_HELD);
1044
+ // I1 修:config.yaml 格式损坏时 parseYaml 抛异常,用 try/catch 包装给友好提示
1045
+ let config;
1046
+ try {
1047
+ config = parseYaml(await readFile(configPath, 'utf8'));
405
1048
  }
406
- throw err;
407
- }
408
- try {
409
- const evalLoadEnvPath = new URL('../../../forge-eval/load-env.js', import.meta.url).href;
410
- const { loadEnv } = (await import(/* @vite-ignore */ evalLoadEnvPath));
411
- const { anthropicApiKey } = loadEnv();
412
- // double-cast 同 mapper 子命令
413
- const client = new Anthropic({ apiKey: anthropicApiKey });
414
- const entries = await buildIndex(client, anchors);
415
- const md = renderIndexMarkdown(entries);
416
- const indexPath = join(forgeRoot, 'docs', 'index.md');
417
- await mkdir(join(forgeRoot, 'docs'), { recursive: true });
418
- await writeFile(indexPath, md, 'utf8');
419
- console.log(`✓ wrote ${indexPath} (${entries.length} entries)`);
1049
+ catch (e) {
1050
+ console.error(`forge/config.yaml 格式错误:${e.message}`);
1051
+ process.exit(LB_EXIT_GENERAL_ERROR);
1052
+ }
1053
+ if (!config.legacy_bridge?.allow_llm_calls) {
1054
+ console.error('✗ forge/config.yaml 未声明 legacy_bridge.allow_llm_calls: true,请先在 config 加该字段');
1055
+ process.exit(LB_EXIT_GENERAL_ERROR);
1056
+ }
1057
+ // anchors 中是否有 contains_customer_data
1058
+ const anchors = await loadAnchorsFile(forgeRoot);
1059
+ const hasCustomerData = (anchors?.anchors ?? []).some((a) => a.contains_customer_data === true);
1060
+ if (hasCustomerData && !opts.acknowledgeCustomerData) {
1061
+ console.error('✗ legacy-anchors.yaml 标有 contains_customer_data=true 的 anchor;\n' +
1062
+ '请加 --acknowledge-customer-data 一并确认(§4.5 GDPR)');
1063
+ process.exit(LB_EXIT_GENERAL_ERROR);
1064
+ }
1065
+ await writeAck(forgeRoot, config, hasCustomerData);
1066
+ console.log(`✓ ack 已写入 forge/.cache/llm-ack.yaml(customer_data_acknowledged=${hasCustomerData})`);
420
1067
  process.exit(LB_EXIT_OK);
421
1068
  }
422
- finally {
423
- if (releaseLb)
424
- await releaseLb();
425
- if (releaseArchive)
426
- await releaseArchive();
427
- }
1069
+ cmd.help();
1070
+ });
1071
+ // 5 个子命令骨架(各 Phase 填实)
1072
+ cmd
1073
+ .command('map')
1074
+ .description('扫 docs/ + src/ → LLM 推测 → legacy-anchors-draft.yaml(决策 #4)')
1075
+ .option('--merge', '与已存在 anchors.yaml 合并新发现项,保留用户审过部分(默认)', true)
1076
+ .option('--overwrite', '全量重生成(覆盖用户改动)')
1077
+ .option('--docs-paths <paths>', '逗号分隔的额外 docs 目录(默认扫 docs/ doc/ document/)')
1078
+ .option('--redact-report', '输出每条 redact 规则的命中数(决策 #20)')
1079
+ .option('--apply', '消费 agent 已写入的结果文件 → 产 draft yaml(默认 agent 路径第二步)')
1080
+ .option('--api', '进程内直接调 Anthropic SDK(跳过 agent 交接;需 ANTHROPIC_API_KEY)')
1081
+ .action(async (opts) => {
1082
+ // mode 决策(M-2):--overwrite 优先;否则 merge(默认)
1083
+ const mode = opts.overwrite ? 'overwrite' : 'merge';
1084
+ // 调 runMapCommand 完成三分支逻辑(emit / --apply / --api)
1085
+ const code = await runMapCommand({
1086
+ projectRoot: process.cwd(),
1087
+ mode,
1088
+ apply: opts.apply ?? false,
1089
+ api: opts.api ?? false,
1090
+ });
1091
+ process.exit(code);
1092
+ });
1093
+ cmd
1094
+ .command('regenerate')
1095
+ .description('LLM 复写规范 SRS/HLD/LLD/system-tests + 双 LLM 抽样验证(决策 #14-#16)')
1096
+ .option('--role <role>', '仅复写指定 role(默认全 4 role)')
1097
+ .option('--dry-run', '不调 LLM,只估算 cost + 列要扫的文件(§4.4)')
1098
+ .option('--include-historical', '把 authoritative=false 历史版作背景(默认关)')
1099
+ .option('--redact-report', '输出每条 redact 规则的命中数')
1100
+ .option('--yes', '非 TTY 必须显式 ack 高 cost 才继续(M-4)')
1101
+ .option('--skip-quality', '跳过 quality-judge 双 LLM 抽样(性能 / 调试用;P7-02 默认跑)')
1102
+ .option('--apply', '消费 agent 已写入的结果文件 → 写产物(默认 agent 路径第二步)')
1103
+ .option('--api', '进程内直接调 Anthropic SDK(跳过 agent 交接;需 ANTHROPIC_API_KEY)')
1104
+ .action(async (opts) => {
1105
+ // Task 6.2 round-2:无条件路由到 runRegenerateCommand(默认 emit / --apply / --api)
1106
+ // 与 index / sync-check 一致:收集所有 option,无条件调,process.exit(code)
1107
+ const code = await runRegenerateCommand({
1108
+ projectRoot: process.cwd(),
1109
+ role: opts.role,
1110
+ apply: opts.apply ?? false,
1111
+ api: opts.api ?? false,
1112
+ dryRun: opts.dryRun ?? false,
1113
+ includeHistorical: opts.includeHistorical ?? false,
1114
+ redactReport: opts.redactReport ?? false,
1115
+ yes: opts.yes ?? false,
1116
+ skipQuality: opts.skipQuality ?? false,
1117
+ });
1118
+ process.exit(code);
1119
+ });
1120
+ cmd
1121
+ .command('index')
1122
+ .description('为每个 anchor 生成 ~100 字 LLM 摘要(决策 #14 Layer 2)')
1123
+ .option('--yes', '非 TTY 必须显式 ack')
1124
+ .option('--apply', '消费 agent 已写入的结果文件 → 写 index.md(默认 agent 路径第二步)')
1125
+ .option('--api', '进程内直接调 Anthropic SDK(跳过 agent 交接;需 ANTHROPIC_API_KEY)')
1126
+ .action(async (opts) => {
1127
+ const code = await runIndexCommand({
1128
+ projectRoot: process.cwd(),
1129
+ apply: opts.apply ?? false,
1130
+ api: opts.api ?? false,
1131
+ });
1132
+ process.exit(code);
428
1133
  });
429
1134
  cmd
430
1135
  .command('sync-check')
431
1136
  .description('检测 change 影响的老锚点是否需更新 → 5 档差异报告(决策 #5/#19)')
432
1137
  .option('--change-id <id>', '指定 change-id;默认取最近一次 archive')
1138
+ .option('--apply', '消费 agent 已写入的结果文件 → 写 sync-state yaml(默认 agent 路径第二步)')
1139
+ .option('--api', '进程内直接调 Anthropic SDK(跳过 agent 交接;需 ANTHROPIC_API_KEY)')
433
1140
  .action(async (opts) => {
434
- const forgeRoot = join(process.cwd(), 'forge');
435
- const configPath = join(forgeRoot, 'config.yaml');
436
- if (!existsSync(configPath)) {
437
- console.error('forge/config.yaml 不存在,先跑 forge init');
438
- process.exit(LB_EXIT_GENERAL_ERROR);
439
- }
440
- const config = parseYaml(await readFile(configPath, 'utf8'));
441
- const anchors = await loadAnchorsFile(forgeRoot).catch((err) => {
442
- if (err instanceof LegacyAnchorsError) {
443
- console.error(`✗ ${err.message}`);
444
- process.exit(LB_EXIT_GENERAL_ERROR);
445
- }
446
- throw err;
1141
+ const code = await runSyncCheckCommand({
1142
+ projectRoot: process.cwd(),
1143
+ changeId: opts.changeId,
1144
+ apply: opts.apply ?? false,
1145
+ api: opts.api ?? false,
447
1146
  });
448
- // 决策 #11:无 anchors → graceful skip(exit 0)
449
- if (!anchors) {
450
- console.log('no legacy anchors configured, skipping sync-check');
451
- process.exit(LB_EXIT_OK);
452
- return;
453
- }
454
- // ack 检查(若 allow_llm_calls=false 也 graceful skip,决策 #22)
455
- const ackResult = await checkAck(forgeRoot, config, anchors);
456
- if (!ackResult.ok && ackResult.reason === 'allow_llm_calls=false') {
457
- console.log('legacy_bridge.allow_llm_calls=false, sync-check skipped');
458
- process.exit(LB_EXIT_OK);
459
- return;
460
- }
461
- if (!ackResult.ok) {
462
- console.error(renderOptinPrompt(ackResult.reason, ackResult.customerDataPaths));
463
- process.exit(LB_EXIT_GENERAL_ERROR);
464
- return;
465
- }
466
- // 拼 change context(读 proposal.md + specs/)
467
- const changeId = opts.changeId ?? '(latest-archive)';
468
- const changesDir = join(forgeRoot, 'changes', changeId);
469
- let changeContext = '';
470
- const affectedModules = [];
471
- if (existsSync(join(changesDir, 'proposal.md'))) {
472
- changeContext += await readFile(join(changesDir, 'proposal.md'), 'utf8');
473
- }
474
- if (existsSync(join(changesDir, 'specs'))) {
475
- const { readdir } = await import('node:fs/promises');
476
- const files = await readdir(join(changesDir, 'specs'));
477
- for (const f of files) {
478
- const txt = await readFile(join(changesDir, 'specs', f), 'utf8');
479
- changeContext += `\n## specs/${f}\n${txt}`;
480
- // 推测 module:文件名去 .md 即可(简化)
481
- affectedModules.push(f.replace(/\.md$/, ''));
482
- }
483
- }
484
- // 锁(legacy-bridge-sync-check 单锁,不与 archive 双重持锁)
485
- let release;
486
- try {
487
- release = await acquireLockByPath(forgeRoot, 'legacy-bridge-sync-check', 'legacy-bridge.lock');
488
- }
489
- catch (err) {
490
- if (err instanceof LockHeldError) {
491
- console.error(`✗ ${err.message}`);
492
- process.exit(LB_EXIT_LOCK_HELD);
493
- return;
494
- }
495
- throw err;
496
- }
497
- try {
498
- // 运行时动态加载 forge-eval/load-env(避免 src/ rootDir 静态分析边界限制)
499
- const evalLoadEnvPath = new URL('../../../forge-eval/load-env.js', import.meta.url).href;
500
- const { loadEnv } = (await import(/* @vite-ignore */ evalLoadEnvPath));
501
- const { anthropicApiKey } = loadEnv();
502
- // Anthropic overload 与单签名接口不兼容,double-cast(与 regenerate 一致)
503
- const client = new Anthropic({ apiKey: anthropicApiKey });
504
- const out = await runSyncCheck(client, {
505
- changeId,
506
- changeContext,
507
- affectedModules,
508
- anchors,
509
- autoResolveCrossAnchor: config.legacy_bridge?.auto_resolve_cross_anchor ?? false,
510
- mtimeOf: (p) => {
511
- try {
512
- return Math.floor(statSync(p).mtimeMs / 1000);
513
- }
514
- catch {
515
- return 0;
516
- }
517
- },
518
- }, async (path) => (await readAnchorFile(path)).text);
519
- // 写 markdown + yaml 双栈
520
- const stateDir = join(forgeRoot, 'legacy-sync-state');
521
- await mkdir(stateDir, { recursive: true });
522
- await writeFile(join(stateDir, `${changeId}.md`), renderDiffMarkdown(out.syncState), 'utf8');
523
- await writeFile(join(stateDir, `${changeId}.yaml`), renderDiffYaml(out.syncState), 'utf8');
524
- const counts = out.syncState.diffs.length;
525
- const critPending = hasCriticalPending(out.syncState);
526
- console.log(`⚠ ${counts} 项老文档可能需更新 — 详见 forge/legacy-sync-state/${changeId}.md`);
527
- // hash 过期 warn(决策 §4.3)
528
- for (const h of out.hashChecks) {
529
- if (h.state === 'stale') {
530
- console.warn(`⚠ anchor ${h.anchor.path} 已改动(用户改了 docs/legacy/);复写产物可能脱节`);
531
- }
532
- }
533
- // enforce_sync 已在 archive preflight 处理,sync-check 命令本身不阻塞(spec §2.5 post-archive)
534
- if (critPending) {
535
- console.error(`⚠ 含 critical 未 resolve 项;在 enforce_sync=true 模式下,下次 archive 前请跑 forge legacy-bridge resolve ${changeId}`);
536
- }
537
- process.exit(LB_EXIT_OK);
538
- }
539
- finally {
540
- if (release)
541
- await release();
542
- }
1147
+ process.exit(code);
543
1148
  });
544
1149
  cmd
545
1150
  .command('resolve <change-id>')
@@ -581,6 +1186,21 @@ export function buildLegacyBridgeCommand() {
581
1186
  await release();
582
1187
  }
583
1188
  });
1189
+ cmd
1190
+ .command('extract')
1191
+ .description('扫全仓库老文档 → LLM 抽需求 + 判实现 → legacy-requirements.yaml(Layer 3b)')
1192
+ .option('--apply', '消费 agent 已写入的结果文件 → 产 draft yaml')
1193
+ .option('--api', '进程内直接调 Anthropic SDK(需 ANTHROPIC_API_KEY)')
1194
+ .option('--finalize', '读 draft → 分配 ID → 转正为 legacy-requirements.yaml')
1195
+ .action(async (opts) => {
1196
+ const code = await runExtractCommand({
1197
+ projectRoot: process.cwd(),
1198
+ apply: opts.apply ?? false,
1199
+ api: opts.api ?? false,
1200
+ finalize: opts.finalize ?? false,
1201
+ });
1202
+ process.exit(code);
1203
+ });
584
1204
  return cmd;
585
1205
  }
586
1206
  //# sourceMappingURL=legacy-bridge.js.map