sillyspec 3.28.12 → 3.29.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 (47) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -223
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
  4. package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
  5. package/package.json +1 -1
  6. package/src/blast-surface.js +168 -0
  7. package/src/ceremony-tier.js +359 -0
  8. package/src/change-risk-profile.js +75 -202
  9. package/src/config-schema.js +21 -0
  10. package/src/decision-distill.js +32 -25
  11. package/src/decisions-io.js +32 -0
  12. package/src/design-facts.js +1 -0
  13. package/src/doctor-diagnostics.js +148 -1
  14. package/src/fr-index.js +359 -0
  15. package/src/index.js +92 -5
  16. package/src/knowledge-stats.js +124 -2
  17. package/src/modules.js +28 -0
  18. package/src/prefill.js +491 -0
  19. package/src/probe7-anchor-check.js +8 -6
  20. package/src/quick-gate-profile.js +8 -4
  21. package/src/quick-session-owner.js +63 -0
  22. package/src/review-dispatch.js +473 -1
  23. package/src/review-material-pack.js +265 -0
  24. package/src/review-tier.js +185 -41
  25. package/src/run/archive-distill.js +35 -0
  26. package/src/run/command.js +158 -87
  27. package/src/run/complete-handlers.js +82 -1
  28. package/src/run/complete.js +37 -1
  29. package/src/run/gates.js +301 -19
  30. package/src/run/prompt.js +454 -21
  31. package/src/run/shared.js +36 -0
  32. package/src/run/verify-quality-scan.js +14 -6
  33. package/src/scope-audit.js +8 -1
  34. package/src/span-risk-surface.js +127 -0
  35. package/src/stage-contract-spec.js +2 -2
  36. package/src/stage-contract.js +172 -98
  37. package/src/stage-review.js +26 -2
  38. package/src/stages/brainstorm.js +34 -16
  39. package/src/stages/execute.js +10 -2
  40. package/src/stages/plan.js +29 -12
  41. package/src/stages/verify.js +7 -7
  42. package/src/taskcard.js +3 -0
  43. package/src/verify-postcheck.js +375 -20
  44. package/src/verify-probes.js +964 -30
  45. package/src/worktree.js +1 -1
  46. package/templates/prompts/taskcard-rules.md +1 -0
  47. package/templates/prompts/verify-probes.md +1 -1
@@ -33,6 +33,13 @@ import { pruneTimestampedEntries } from './runtime-hygiene.js';
33
33
  import { CHECK_SEVERITY } from './constants.js';
34
34
  import { checkPlatformManaged, isSelfReferentialSpecRoot, PLATFORM_MANAGED_FILENAME, QUICK_SID_RE, resolveRuntimeRoot } from './run/shared.js';
35
35
  import { readFrictionLedger } from './friction-ledger.js';
36
+ // FR 索引消费(2026-09-18-fr-index-l1 L1,D-007):D14 第四检查——epoch 后归档的索引在场+取代完整。
37
+ // fr-index 是纯 fs 叶子链(仅 decision-distill 底座),doctor 静态引入无环。
38
+ import { FR_INDEX_EPOCH, scanFrIndex, parseChangeRequirements } from './fr-index.js';
39
+ // 影子对照账消费(2026-09-18-ceremony-risk-pricing task-06 / FR-04 / R-04 / R-06):读
40
+ // stage-reviews-shadow/shadow-ledger.json 累计账。review-dispatch 依赖链(fs-atomic /
41
+ // stage-review / quicklog / ceremony-tier)均不回指 doctor,静态引入无环。
42
+ import { readShadowLedger, SHADOW_NAMESPACE_DIR, SHADOW_PROMOTION_SAMPLE_TARGET } from './review-dispatch.js';
36
43
 
37
44
  // db 角色标签
38
45
  const DB_ROLE = {
@@ -1162,6 +1169,10 @@ function detectArchiveIntegrity(cwd, authoritySpecDir) {
1162
1169
  return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
1163
1170
  }
1164
1171
  const offenders = [];
1172
+ // FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
1173
+ const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
1174
+ const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
1175
+ const frEntries = frEnabled ? scanFrIndex(frKnowledgeRoot) : [];
1165
1176
  for (const name of names) {
1166
1177
  const dir = join(archiveDir, name);
1167
1178
  const reasons = [];
@@ -1204,6 +1215,28 @@ function detectArchiveIntegrity(cwd, authoritySpecDir) {
1204
1215
  reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
1205
1216
  }
1206
1217
  }
1218
+ // ── ④ FR 索引在场与取代完整(2026-09-18-fr-index-l1 L1,D-007:epoch 分界第四检查)──
1219
+ // 豁免面:fr/ 目录不存在(仓未启用索引,全跳);无日期前缀/epoch 前归档(存量不回填);
1220
+ // quick-* 与无 requirements.md(quick/scale:small 无索引义务)。违者并入 offenders(豁免走既有账本)。
1221
+ if (frEnabled) {
1222
+ const dm = name.match(/^(\d{4}-\d{2}-\d{2})-/);
1223
+ if (dm && dm[1] >= FR_INDEX_EPOCH && existsSync(join(dir, 'requirements.md'))) {
1224
+ if (!frEntries.some((e) => e.change === name)) {
1225
+ reasons.push('FR 索引缺失(epoch 后归档应有索引条目——归档时 indexRequirements 未落,查 archive 步降级日志)');
1226
+ } else {
1227
+ try {
1228
+ const reqParsed = parseChangeRequirements(dir);
1229
+ const refIds = new Set();
1230
+ for (const fr of reqParsed.frs) for (const r of fr.supersedes) refIds.add(r);
1231
+ for (const refId of refIds) {
1232
+ if (!frEntries.some((e) => e.id === refId && e.supersededBy)) {
1233
+ reasons.push(`承接未翻取代(${refId} 被引用但索引中未标 superseded)`);
1234
+ }
1235
+ }
1236
+ } catch { /* requirements 解析失败归任务注册表口径,此处不重复报 */ }
1237
+ }
1238
+ }
1239
+ }
1207
1240
  if (reasons.length > 0) offenders.push({ name, reasons });
1208
1241
  }
1209
1242
 
@@ -1263,6 +1296,117 @@ function detectArchiveIntegrity(cwd, authoritySpecDir) {
1263
1296
  };
1264
1297
  }
1265
1298
 
1299
+ // ── 影子对照维度(2026-09-18-ceremony-risk-pricing task-06 / FR-04 / R-04 / R-06 / D-007@v1)──
1300
+
1301
+ /**
1302
+ * 轻仪影子期对照(ceremony_shadow):读 stage-reviews-shadow/ 对照账,输出轻/重 catch 差异
1303
+ * 报告(影子重仪式抓到、主线轻仪没抓到的问题清单)+ 转正判据计数——N/10 轻档对照样本且
1304
+ * (漏检=0 ∨ 漏检均 advisory 级(checklist result=gap;result=fail 为非 advisory))→ 达标。
1305
+ *
1306
+ * 语义(降级先例对齐 detectLifecycleDocStaleness):
1307
+ * - 无影子数据(账不存在 / 零条目零 skip)→ pass + 跳过注记,不误报;
1308
+ * - local.yaml ceremony.shadow: off → 已转正,pass + 终态统计(转正后不再报缺口);
1309
+ * - 影子期 on:达标 → pass +「可转正」提示;未达标 → pass:false + WARNING(advisory 公示
1310
+ * 缺口——影子链路只记账不阻断,本维度同样不进任何 gate / verify / archive 判定面)。
1311
+ * R-04 公示:findings 明示影子期 token 不降反升属「花钱买标定」+ local.yaml 开关位置 + 判据。
1312
+ * 转正动作本身是人工置 ceremony.shadow: off——CLI 只出判据不出手(safe_actions 只描述)。
1313
+ */
1314
+ function detectCeremonyShadowComparison(cwd, authoritySpecDir) {
1315
+ const base = { name: 'ceremony_shadow', label: '影子对照', safe_actions: [] };
1316
+ try {
1317
+ const pointer = resolvePointer(cwd);
1318
+ const runtimeRoot = resolveRuntimeRoot(
1319
+ { runtimeRoot: pointer.present && pointer.runtimeRoot && existsSync(pointer.runtimeRoot) ? pointer.runtimeRoot : null },
1320
+ authoritySpecDir || join(cwd, '.sillyspec'),
1321
+ );
1322
+ const ledger = readShadowLedger(runtimeRoot);
1323
+ const entries = Array.isArray(ledger.entries) ? ledger.entries : [];
1324
+ const skips = Array.isArray(ledger.skips) ? ledger.skips : [];
1325
+ if (entries.length === 0 && skips.length === 0) {
1326
+ return { ...base, pass: true, severity: null, findings: [`无影子对照数据(${SHADOW_NAMESPACE_DIR}/ 未开账——影子期未跑或已转正),跳过`] };
1327
+ }
1328
+ // 开关面(R-04 公示位置;读取容错对齐 readCeremonyLocalConfig 口径——本维度同步,内联读)
1329
+ let shadowOn = true;
1330
+ try {
1331
+ const lp = join(cwd, '.sillyspec', 'local.yaml');
1332
+ if (existsSync(lp)) {
1333
+ const doc = jsYaml.load(readFileSync(lp, 'utf8'));
1334
+ if (doc && typeof doc === 'object' && doc.ceremony && typeof doc.ceremony === 'object' && typeof doc.ceremony.shadow === 'boolean') {
1335
+ shadowOn = doc.ceremony.shadow;
1336
+ }
1337
+ }
1338
+ } catch { /* 开关读取失败按缺省 on */ }
1339
+
1340
+ const completed = entries.filter((e) => e && e.state === 'completed');
1341
+ const inFlight = entries.filter((e) => e && e.state === 'in-flight');
1342
+ const dead = entries.filter((e) => e && (e.state === 'failed' || e.state === 'abandoned' || e.state === 'bad-review'));
1343
+
1344
+ // catch 差异聚合:漏检 = 影子抓到而主线轻仪没抓到(result=fail → 非 advisory 级;gap → advisory 级)
1345
+ const missed = [];
1346
+ for (const e of completed) {
1347
+ for (const m of (e.diff && Array.isArray(e.diff.missedByLight) ? e.diff.missedByLight : [])) {
1348
+ missed.push({ change: e.change, stage: e.stage, item: m.item, result: m.result, note: m.note });
1349
+ }
1350
+ }
1351
+ const nonAdvisory = missed.filter((m) => m.result === 'fail');
1352
+ const verdictDelta = completed.filter((e) => e.diff && e.diff.verdictDelta);
1353
+
1354
+ const findings = [];
1355
+ // ① catch 差异报告(影子抓到主线没抓到的问题列表)
1356
+ if (missed.length > 0) {
1357
+ for (const m of missed.slice(0, 5)) {
1358
+ findings.push(`影子抓到主线漏检(${m.change}/${m.stage}):[${m.result}] ${String(m.item).slice(0, 100)}${m.note ? `——${String(m.note).slice(0, 60)}` : ''}`);
1359
+ }
1360
+ if (missed.length > 5) findings.push(`…还有 ${missed.length - 5} 项漏检(明细见 ${SHADOW_NAMESPACE_DIR}/shadow-ledger.json)`);
1361
+ } else if (completed.length > 0) {
1362
+ findings.push(`已完成 ${completed.length} 例轻/重对照,零漏检——轻仪 catch 面与重仪式一致`);
1363
+ }
1364
+ if (verdictDelta.length > 0) findings.push(`verdict 对照:${verdictDelta.length}/${completed.length} 例主线轻仪通过而影子重仪式判 fail`);
1365
+ if (inFlight.length > 0) findings.push(`在途影子派发 ${inFlight.length} 条(回收走 dispatchShadowReview mode='status'——影子结论不进任何主线判定)`);
1366
+ if (dead.length > 0) findings.push(`失败/未回收影子派发 ${dead.length} 条(fire-and-forget 记账,不补派不阻断)`);
1367
+ if (skips.length > 0) {
1368
+ const lastSkip = skips[skips.length - 1];
1369
+ findings.push(`跳过留痕 ${skips.length} 条(档位非轻档/开关 off/平台通道不可用——最近一条:${lastSkip && lastSkip.reason ? String(lastSkip.reason).slice(0, 80) : '未知原因'})`);
1370
+ }
1371
+
1372
+ const N = completed.length;
1373
+ const target = SHADOW_PROMOTION_SAMPLE_TARGET;
1374
+ // ② 转正判据(计数逻辑非文案——acceptance:N≥10 且漏检=0 ∨ 漏检均 advisory 级)
1375
+ const promotable = N >= target && nonAdvisory.length === 0;
1376
+
1377
+ if (!shadowOn) {
1378
+ findings.push(`ceremony.shadow: off——轻仪已转正(影子对照账保留供审计:N=${N},累计漏检 ${missed.length} 项)`);
1379
+ return { ...base, pass: true, severity: CHECK_SEVERITY.PASSED, findings };
1380
+ }
1381
+
1382
+ findings.push(`转正进度:${N}/${target} 轻档对照样本,漏检 ${missed.length} 项(非 advisory 级 ${nonAdvisory.length} 项)——判据:N≥${target} 且漏检=0 或漏检均 advisory 级(checklist result=gap)`);
1383
+ // ③ R-04 token 面注记(影子期开销公示,防困惑)
1384
+ findings.push('影子期 token 不降反升属「花钱买标定」(重仪式后台并行跑攒对照证据)——开关 local.yaml ceremony.shadow(当前 on),转正由人工置 off,CLI 只出判据不出手');
1385
+
1386
+ if (N === 0) {
1387
+ findings.push('尚无完成对照——转正判据未开始累计(在途/失败条目不计入 N)');
1388
+ return { ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings };
1389
+ }
1390
+ if (promotable) {
1391
+ findings.push(`影子期达标可转正:N=${N}≥${target} 且漏检均为 advisory 级(或零漏检)——可人工置 local.yaml ceremony.shadow: off`);
1392
+ return {
1393
+ ...base, pass: true, severity: CHECK_SEVERITY.PASSED, findings,
1394
+ safe_actions: [{ dimension: 'ceremony_shadow', action: 'promote_light_ceremony', risk: 'manual_edit', rationale: '影子期达标,轻仪可转正', next_step: '人工编辑 .sillyspec/local.yaml 置 ceremony.shadow: off(CLI 只出判据不出手)' }],
1395
+ };
1396
+ }
1397
+ const gaps = [];
1398
+ if (N < target) gaps.push(`还差 ${target - N} 个轻档对照样本`);
1399
+ if (nonAdvisory.length > 0) gaps.push(`${nonAdvisory.length} 项非 advisory 级(fail)漏检待补救核对`);
1400
+ findings.push(`转正判据未达标:${gaps.join(';')}`);
1401
+ return {
1402
+ ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings,
1403
+ safe_actions: [{ dimension: 'ceremony_shadow', action: 'review_missed_findings', risk: 'advisory', rationale: '影子抓到主线漏检项——影子结论不阻断,人工裁决是否主线补救', next_step: `核对 ${SHADOW_NAMESPACE_DIR}/shadow-ledger.json 漏检明细(非 advisory 级项优先)` }],
1404
+ };
1405
+ } catch (e) {
1406
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true };
1407
+ }
1408
+ }
1409
+
1266
1410
  /**
1267
1411
  * renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
1268
1412
  * ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
@@ -1317,7 +1461,10 @@ export async function runDoctorDiagnostics({ cwd }) {
1317
1461
  const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
1318
1462
  // D14(2026-09-17 archive-rescan,OpenSpec validate --archived 对标):归档完整性事后重扫(只读 WARNING)
1319
1463
  const archiveIntegrity = detectArchiveIntegrity(cwd, authoritySpecDir)
1320
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity];
1464
+ // task-06(2026-09-18-ceremony-risk-pricing / FR-04 / R-04):影子对照——轻/重 catch 差异报告 +
1465
+ // 转正判据计数(纯增量挂载,只读不阻断;无影子数据跳过不误报)
1466
+ const ceremonyShadow = detectCeremonyShadowComparison(cwd, authoritySpecDir)
1467
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow];
1321
1468
 
1322
1469
  return {
1323
1470
  dimensions,
@@ -0,0 +1,359 @@
1
+ /**
2
+ * fr-index.js — FR 稳定索引核心(L1:稳定 id + knowledge 索引 + 取代标记)
3
+ *
4
+ * 2026-09-18-fr-index-l1(三轮评审+战略定位定稿):requirements.md 归档后首次被语义消费——
5
+ * 全局稳定 id(FR-<域>-NNN,archive 时 CLI 发号)+ knowledge/fr/<域>.md 索引 + 承接引用驱动的
6
+ * 取代链。定位=**L3 的证据发生器**:四类遥测事件(fr-inject/fr-supersede/fr-duplicate-warning/
7
+ * fr-unreferenced,由消费方经 knowledge-hits.js 落盘)供 20-30 change 后裁决活规格真源——
8
+ * 含证伪出口(design 实验裁决条款:承接引用率趋零/链无人跟/重复靠人眼 ⇒ 可杀可冻 L3,索引降级检索面)。
9
+ *
10
+ * 纪律:
11
+ * - CLI 单一写入方:索引只在 indexRequirements 内部写(archive noAI 步调用);
12
+ * - 幂等键=全局 id(「来源变更」字段变更名命中即 no-op——同变更重跑零新增零漂移);
13
+ * - 承接 id 不存在 → warnings 收集不阻断(typo 不炸归档);
14
+ * - 未引用旧 FR 的删除/修改零判定(D-002 边界——undeclared deletion 是 L3 合并门禁领地,
15
+ * L1 只经 unreferenced 计数采观察信号,显式「不算 L3 门禁」);
16
+ * - 域文件读写复用 decision-distill 参数化底座(splitKnowledgeSections/joinKnowledgeFile/
17
+ * syncIndexRoutingLines/discoverModuleIndex),不复制实现。
18
+ */
19
+
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
21
+ import { join } from 'path';
22
+ import {
23
+ splitKnowledgeSections,
24
+ joinKnowledgeFile,
25
+ syncIndexRoutingLines,
26
+ discoverModuleIndex,
27
+ } from './decision-distill.js';
28
+
29
+ /** epoch:此前归档不检查/不索引(存量 93 份不回填——不伪造历史,D-007)。 */
30
+ export const FR_INDEX_EPOCH = '2026-09-18';
31
+
32
+ const FR_DIR = 'fr';
33
+ /** FR 节头:## FR-<域>-NNN 标题(域=[a-z0-9-] **含连字符**——真实模块 id 多为 cli-entry/core-engine 等连字符形态,
34
+ * R1 审查阻断①实证:漏 - 会让连字符域的发号/幂等/解析/承接全断。第二组为非捕获可选占位,
35
+ * 三组结构与底座 splitKnowledgeSections 的 decisions 正则对齐(m[3]=标题,勿改组序)。 */
36
+ const FR_SECTION_RE = /^## (FR-[a-z0-9-]+-\d+)(?:@v(\d+))?\s*(.*)$/;
37
+ const FR_GLOBAL_ID_RE = /^FR-[a-z0-9-]+-\d+$/;
38
+
39
+ /**
40
+ * 解析变更 requirements.md 的 FR 块。
41
+ * 形态(brainstorm step8 指引):`### FR-NN: 标题` + 可选 `承接: FR-<域>-NNN[, ...]` 行 +
42
+ * Given/When/Then 块(场景名取 `#### 场景:X` 或 `**场景:X**` 行;无场景名的 GWT 块按序号占位)。
43
+ * @returns {{ missing: boolean, frs: Array<{local:string,title:string,supersedes:string[],scenarios:string[]}>, malformed: string[] }}
44
+ */
45
+ export function parseChangeRequirements(changeDir) {
46
+ const p = join(changeDir, 'requirements.md');
47
+ if (!existsSync(p)) return { missing: true, frs: [], malformed: [] };
48
+ let content;
49
+ try {
50
+ content = readFileSync(p, 'utf8');
51
+ } catch {
52
+ return { missing: true, frs: [], malformed: [] };
53
+ }
54
+ const frs = [];
55
+ const malformed = [];
56
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
57
+ let cur = null;
58
+ for (const line of lines) {
59
+ const h = line.match(/^###\s+(FR-\d+)\s*[::]\s*(.*)$/);
60
+ if (h) {
61
+ if (cur) frs.push(cur);
62
+ cur = { local: h[1], title: (h[2] || '').trim(), supersedes: [], scenarios: [] };
63
+ continue;
64
+ }
65
+ if (!cur) continue;
66
+ const s = line.match(/^\s*承接\s*[::]\s*(.+)$/);
67
+ if (s) {
68
+ for (const tok of s[1].split(/[,,、]/)) {
69
+ const id = tok.trim();
70
+ if (!id) continue;
71
+ if (FR_GLOBAL_ID_RE.test(id)) cur.supersedes.push(id);
72
+ else malformed.push(`${cur.local} 的承接行含非全局 id 形态「${id}」(应为 FR-<域>-NNN)`);
73
+ }
74
+ continue;
75
+ }
76
+ const sc = line.match(/^\s*(?:#{2,4}\s*场景\s*[::]\s*(.+)|\*\*场景\s*[::]\s*([^*]+)\*\*)\s*$/);
77
+ if (sc) {
78
+ const name = ((sc[1] || sc[2] || '')).trim();
79
+ cur.scenarios.push(name || `场景${cur.scenarios.length + 1}`);
80
+ }
81
+ }
82
+ if (cur) frs.push(cur);
83
+ return { missing: false, frs, malformed };
84
+ }
85
+
86
+ /** 域解析(导出供注入/软门消费):变更自身 design.md 文件变更清单表行(剥 NEW: 前缀)× moduleIndex paths 前缀匹配;无匹配 → unmapped。 */
87
+ export function resolveTouchedDomains(changeDir, moduleIndex) {
88
+ const domains = new Set();
89
+ const designPath = join(changeDir, 'design.md');
90
+ if (moduleIndex && existsSync(designPath)) {
91
+ let design;
92
+ try {
93
+ design = readFileSync(designPath, 'utf8');
94
+ } catch {
95
+ design = '';
96
+ }
97
+ for (const line of design.replace(/\r\n/g, '\n').split('\n')) {
98
+ const m = line.match(/^\|\s*(?:新增|修改|删除)\s*\|\s*(?:NEW:)?([^\s|]+)\s*\|/);
99
+ if (!m) continue;
100
+ const filePath = m[1].trim();
101
+ for (const [modId, mod] of Object.entries(moduleIndex)) {
102
+ const paths = (mod && Array.isArray(mod.paths)) ? mod.paths : [];
103
+ if (paths.some((pp) => filePath === pp || filePath.startsWith(pp.endsWith('/') ? pp : pp + '/'))) {
104
+ domains.add(modId);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ if (domains.size === 0) domains.add('unmapped');
110
+ return [...domains];
111
+ }
112
+
113
+ /** 域文件路径与域索引读写(复用参数化底座;FR 节无版本段,id 恒等)。 */
114
+ function frDirPath(knowledgeRoot) {
115
+ return join(knowledgeRoot, FR_DIR);
116
+ }
117
+
118
+ function loadDomainSections(knowledgeRoot, domain) {
119
+ const p = join(frDirPath(knowledgeRoot), `${domain}.md`);
120
+ if (!existsSync(p)) {
121
+ return {
122
+ preamble: [
123
+ '---',
124
+ `author: sillyspec-fr-index`,
125
+ `created_at: ${new Date().toISOString()}`,
126
+ '---',
127
+ '',
128
+ `# FR 索引 — ${domain}`,
129
+ '',
130
+ '> fr-index 从归档变更 requirements.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为机械解析契约,勿手改。',
131
+ '> superseded 条目保留供取代链回溯;brainstorm 注入默认只给 active。',
132
+ '',
133
+ ],
134
+ sections: [],
135
+ };
136
+ }
137
+ try {
138
+ return splitKnowledgeSections(readFileSync(p, 'utf8'), {
139
+ sectionRegex: FR_SECTION_RE,
140
+ buildId: (n) => n,
141
+ });
142
+ } catch {
143
+ return { preamble: [], sections: [] };
144
+ }
145
+ }
146
+
147
+ function renderFrLines(entry, headHash) {
148
+ const lines = [`## ${entry.id} ${entry.title}`];
149
+ // 归属行键用「变更:」——与 decision-distill splitKnowledgeSections 的机械解析契约对齐
150
+ // (design 接口定义示例中的「来源变更」即本字段,语义等价,取解析器认的字面)
151
+ lines.push(`变更:${entry.change}`);
152
+ lines.push(`状态:${entry.supersededBy ? 'superseded' : 'active'}`);
153
+ if (entry.supersededBy) lines.push(`superseded_by:${entry.supersededBy}`);
154
+ if (entry.supersededOf) lines.push(`取代链:${entry.supersededOf} ← 本条目(${entry.change} 承接)`);
155
+ lines.push(`摘要:${(entry.scenarios || []).slice(0, 5).join(';') || '(无场景名)'}`);
156
+ lines.push(`最近确认:${headHash || ''}`);
157
+ return lines;
158
+ }
159
+
160
+ /** 全域条目扫描:domain → sections(供发号取 max、承接翻链、在场性检查)。 */
161
+ function scanAllDomains(knowledgeRoot) {
162
+ const out = new Map();
163
+ try {
164
+ for (const f of readdirSync(frDirPath(knowledgeRoot))) {
165
+ if (!f.endsWith('.md')) continue;
166
+ const domain = f.replace(/\.md$/, '');
167
+ out.set(domain, loadDomainSections(knowledgeRoot, domain));
168
+ }
169
+ } catch { /* 目录不存在 → 空索引 */ }
170
+ return out;
171
+ }
172
+
173
+ function nextIdForDomain(domain, allSections) {
174
+ let max = 0;
175
+ const re = new RegExp(`^FR-${domain.replace(/[-]/g, '-')}-(\\d+)$`);
176
+ for (const st of allSections.values()) {
177
+ for (const s of st.sections) {
178
+ const m = (s.number || '').match(re);
179
+ if (m) max = Math.max(max, parseInt(m[1], 10));
180
+ }
181
+ }
182
+ return `FR-${domain}-${String(max + 1).padStart(3, '0')}`;
183
+ }
184
+
185
+ /**
186
+ * 归档索引主入口(幂等):解析 → 域解析 → 发号 → 承接翻链 → 写域文件 → INDEX 路由。
187
+ * @param {{ changeDir: string, knowledgeRoot: string, headHash?: string, cwd?: string }} args
188
+ * @returns {{ skipped?: string, written: Array<{file,id,action}>, superseded: Array<{from,to,change}>, unreferenced: Array<{domain,count}>, warnings: string[] }}
189
+ */
190
+ export function indexRequirements({ changeDir, knowledgeRoot, headHash = '' }) {
191
+ const changeName = changeDir.split(/[\\/]/).pop();
192
+ const parsed = parseChangeRequirements(changeDir);
193
+ if (parsed.missing) {
194
+ return { skipped: 'requirements.md 不存在(quick/scale:small 无索引义务)', written: [], superseded: [], unreferenced: [], warnings: [] };
195
+ }
196
+ if (parsed.frs.length === 0) {
197
+ return { skipped: 'requirements.md 无 FR 块', written: [], superseded: [], unreferenced: [], warnings: [] };
198
+ }
199
+
200
+ const warnings = [...parsed.malformed];
201
+ const moduleIndex = discoverModuleIndex(knowledgeRoot);
202
+ const domains = resolveTouchedDomains(changeDir, moduleIndex);
203
+ const all = scanAllDomains(knowledgeRoot);
204
+
205
+ // 幂等闸门:任一域文件已有本变更「来源变更」条目 → 全量 no-op(同变更重跑零新增零漂移)
206
+ for (const st of all.values()) {
207
+ if (st.sections.some((s) => s.change === changeName)) {
208
+ return { written: [], superseded: [], unreferenced: [], warnings };
209
+ }
210
+ }
211
+
212
+ // 承接校验:引用的全局 id 必须在现有索引中存在(typo → warn 不阻断)
213
+ const knownIds = new Set();
214
+ for (const st of all.values()) for (const s of st.sections) knownIds.add(s.number);
215
+ const refIds = new Set();
216
+ for (const fr of parsed.frs) for (const id of fr.supersedes) refIds.add(id);
217
+ for (const id of refIds) {
218
+ if (!knownIds.has(id)) warnings.push(`承接引用的 ${id} 不在索引中(域拼错或号不存在)——该引用不翻链,请核对`);
219
+ }
220
+
221
+ // 发号 + 写入(每 FR 落其首个触达域;多域变更按域去重逐条落)
222
+ const written = [];
223
+ const superseded = [];
224
+ const perDomainCounter = new Map();
225
+ const ensureDomain = (domain) => {
226
+ if (!all.has(domain)) all.set(domain, loadDomainSections(knowledgeRoot, domain));
227
+ return all.get(domain);
228
+ };
229
+ // 发号与翻链:每条 FR 落**首个触达域**(primary domain)取一个全局 id——幂等键=全局 id 单一身份
230
+ // (R1 审查缺口:per-domain × per-FR 会给同一 FR 发 N 个 id,违背 D-001 单一身份);
231
+ // 承接翻链可跨全域命中目标条目(新条目在 primary 域,旧条目可在任何域)。
232
+ const dirtyDomains = new Set();
233
+ const primaryDomain = domains[0];
234
+ const st = ensureDomain(primaryDomain);
235
+ for (const fr of parsed.frs) {
236
+ const id = nextIdForDomain(primaryDomain, all);
237
+ perDomainCounter.set(primaryDomain, (perDomainCounter.get(primaryDomain) || 0) + 1);
238
+ st.sections.push({
239
+ number: id,
240
+ version: NaN,
241
+ id,
242
+ title: fr.title,
243
+ change: changeName,
244
+ lines: renderFrLines({ id, title: fr.title, change: changeName, scenarios: fr.scenarios }, headHash),
245
+ });
246
+ dirtyDomains.add(primaryDomain);
247
+ written.push({ file: `${FR_DIR}/${primaryDomain}.md`, id, action: 'added' });
248
+ // 本 FR 的承接引用 → 翻旧条目(跨全域扫描目标段;就地补丁不动原摘要——R1 审查缺口:整段重写会抹掉摘要)
249
+ for (const refId of fr.supersedes) {
250
+ if (!knownIds.has(refId)) continue;
251
+ for (const [st2Domain, st2] of all.entries()) {
252
+ const target = st2.sections.find((s) => s.number === refId && !s.lines.some((l) => l.startsWith('superseded_by:')));
253
+ if (!target) continue;
254
+ // 就地补丁:状态翻 superseded + 插 superseded_by + 追取代链注记 + 刷新最近确认(摘要/标题保留)
255
+ target.lines = target.lines
256
+ .map((l) => (l.startsWith('状态:') ? `状态:superseded` : l.startsWith('最近确认:') ? `最近确认:${headHash || ''}` : l))
257
+ .filter((l) => !l.startsWith('superseded_by:') && !l.startsWith('取代链:'));
258
+ const stateIdx = target.lines.findIndex((l) => l.startsWith('状态:'));
259
+ target.lines.splice(stateIdx + 1, 0, `superseded_by:${id}`, `取代链:${refId} ← ${id}(${changeName} 承接)`);
260
+ dirtyDomains.add(st2Domain);
261
+ superseded.push({ from: refId, to: id, change: changeName });
262
+ }
263
+ }
264
+ }
265
+
266
+ // unreferenced 探针(D-008 护栏③):触达域 active 条目未被本次承接引用的计数——观察信号,不算 L3 门禁
267
+ const unreferenced = [];
268
+ for (const domain of domains) {
269
+ const stx = all.get(domain);
270
+ if (!stx) continue;
271
+ const activeIds = stx.sections
272
+ .filter((s) => !s.lines.some((l) => l.startsWith('superseded_by:')))
273
+ .map((s) => s.number)
274
+ .filter((n) => n && !written.some((w) => w.id === n));
275
+ const count = activeIds.filter((id) => !refIds.has(id)).length;
276
+ if (count > 0) unreferenced.push({ domain, count });
277
+ }
278
+
279
+ // 落盘(dirty 集=新条目域 ∪ 翻链命中域——R1 审查阻断②:漏翻链域会返回值谎报成功但盘上丢失)
280
+ mkdirSync(frDirPath(knowledgeRoot), { recursive: true });
281
+ for (const d of dirtyDomains) {
282
+ const stx = all.get(d);
283
+ writeFileSync(join(frDirPath(knowledgeRoot), `${d}.md`), joinKnowledgeFile(stx.preamble, stx.sections));
284
+ }
285
+ if (written.length > 0 || superseded.length > 0) {
286
+ syncIndexRoutingLines(knowledgeRoot, {
287
+ section: 'FR 需求索引',
288
+ subdir: FR_DIR,
289
+ makeLine: (d) => `- ${d}|FR|需求|承接 → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`,
290
+ });
291
+ }
292
+
293
+ return { written, superseded, unreferenced, warnings };
294
+ }
295
+
296
+ /**
297
+ * 全量索引扫描(doctor D14 第四检查消费):全部域的全部条目(含 superseded)拍平。
298
+ * @returns {Array<{domain,id,title,change,supersededBy:string|null}>} fr/ 目录不存在 → []
299
+ */
300
+ export function scanFrIndex(knowledgeRoot) {
301
+ const out = [];
302
+ let files = [];
303
+ try {
304
+ files = readdirSync(frDirPath(knowledgeRoot)).filter((f) => f.endsWith('.md'));
305
+ } catch {
306
+ return out;
307
+ }
308
+ for (const f of files) {
309
+ const domain = f.replace(/\.md$/, '');
310
+ const st = loadDomainSections(knowledgeRoot, domain);
311
+ for (const s of st.sections) {
312
+ const sup = s.lines.find((l) => l.startsWith('superseded_by:'));
313
+ out.push({
314
+ domain,
315
+ id: s.number,
316
+ title: s.title || '',
317
+ change: s.change || '',
318
+ supersededBy: sup ? sup.replace(/^superseded_by:\s*/, '').trim() : null,
319
+ });
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * 注入源:触达域的 active 条目(superseded 默认藏——D-004)。
327
+ * @returns {Array<{domain,id,title,change,scenarios:string[]}>}
328
+ */
329
+ export function readActiveFrDigest(knowledgeRoot, domains) {
330
+ const out = [];
331
+ for (const domain of domains || []) {
332
+ const st = loadDomainSections(knowledgeRoot, domain);
333
+ for (const s of st.sections) {
334
+ if (s.lines.some((l) => l.startsWith('superseded_by:'))) continue;
335
+ const scenarioLine = s.lines.find((l) => l.startsWith('摘要:'));
336
+ const scenarios = scenarioLine ? scenarioLine.replace(/^摘要:\s*/, '').split(';').map((x) => x.trim()).filter(Boolean) : [];
337
+ out.push({ domain, id: s.number, title: s.title || '', change: s.change || '', scenarios });
338
+ }
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * 标题 bigram 重叠率(中英混排零依赖):字符 bigram 集合 |A∩B| / |A∪B|。
345
+ */
346
+ export function frTitleOverlap(a, b) {
347
+ const grams = (s) => {
348
+ const t = String(s || '').replace(/\s+/g, '');
349
+ const set = new Set();
350
+ for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2));
351
+ return set;
352
+ };
353
+ const A = grams(a);
354
+ const B = grams(b);
355
+ if (A.size === 0 || B.size === 0) return 0;
356
+ let inter = 0;
357
+ for (const g of A) if (B.has(g)) inter++;
358
+ return inter / (A.size + B.size - inter);
359
+ }