sillyspec 3.28.11 → 3.28.13

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.
@@ -6,7 +6,7 @@
6
6
  * integration-critical / deployment-critical),产出门控验证需求。
7
7
  */
8
8
  import { readFileSync, statSync } from 'fs'
9
- import { join } from 'path'
9
+ import { join, isAbsolute } from 'path'
10
10
 
11
11
  // ============ 向后兼容:旧的 INTEGRATION_CRITICAL_PATTERNS ============
12
12
 
@@ -312,7 +312,16 @@ export function detectChangeRisk({ designContent = '', planContent = '', changed
312
312
  // classifyReceiptCommandSource(facts.integrationRan 判定)与本分类同族口径,两处正则族
313
313
  // 保持同步演进)。smoke 冒烟命令族计 cross-layer(D-006 退役判据:批次 C commands.smoke
314
314
  // 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟即跨层实测,不得默认 build 误拦金路径)。
315
- const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
315
+ //
316
+ // 来源标记直判(2026-09-17-api-coverage-smoke task-02 / Grill B-1 修正,FR-03):条目 source
317
+ // 标记 'cli-noai-smoke'(verify-probes ensureSmokeReceiptSection 注入的机器段行尾注,
318
+ // parseEvidenceSlots additive 解析回填)→ 直判 cross-layer,**优先于命令词正则分类**——
319
+ // 修正点在标记识别而非命令词增补:node scripts/smoke.mjs / bash smoke.sh / python smoke.py
320
+ // 等脚本形态在既有正则下全判 build 会误拦金路径,且脚本形态词表不可枚举(正则族两文件零词
321
+ // 变动)。对齐目标:verify-probes.js classifyReceiptCommandSource(judgeIntegrationRan 消费)
322
+ // 同款 sourceMark 参数与标记族——**任一侧增改标记族必须双侧同步**(G-3 先例:producer 侧
323
+ // 曾漏 smoke 一词致口径分裂)。
324
+ export const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
316
325
  '\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
317
326
  '\\biwr\\b', '\\birm\\b', 'https?://',
318
327
  'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
@@ -320,8 +329,11 @@ const RECEIPT_SOURCE_CROSS_LAYER_RE = new RegExp([
320
329
  '\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
321
330
  '\\b(?:nc|netcat|telnet|socat)\\b',
322
331
  ].join('|'), 'i')
323
- const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
324
- function classifyReceiptSourceTag(command) {
332
+ export const RECEIPT_SOURCE_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
333
+ /** CLI 机器段来源标记(与 verify-probes.js SMOKE_RECEIPT_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
334
+ export const CLI_SMOKE_SOURCE_MARK = 'cli-noai-smoke'
335
+ function classifyReceiptSourceTag(command, sourceMark) {
336
+ if (sourceMark === CLI_SMOKE_SOURCE_MARK) return 'cross-layer'
325
337
  const cmd = String(command || '')
326
338
  if (RECEIPT_SOURCE_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
327
339
  if (RECEIPT_SOURCE_UNIT_RE.test(cmd)) return 'unit'
@@ -430,18 +442,28 @@ export function checkIntegrationEvidence(verifyContent, requiredVerification, op
430
442
  * 缺省按回执 command 来源就地分类(classifyReceiptSourceTag),未定类默认 'build'(向后
431
443
  * 兼容的 fail-closed 缺省);sourceTag ∈ {build, unit} 的回执不作集成实测绿判据(四条件
432
444
  * 全过也只到 non-green,reason 点名来源)——verify-postcheck 不在本调用链上,不受影响。
445
+ *
446
+ * r.source(2026-09-17-api-coverage-smoke task-02 / FR-03,Grill B-1):条目级来源标记
447
+ * (parseEvidenceSlots additive 第五字段,CLI 机器段行 'cli-noai-smoke' 尾注回填)透传给
448
+ * classifyReceiptSourceTag 直判 cross-layer——优先级在调用方 opts.sourceTag 声明源之下、
449
+ * 命令词正则分类之上;无标记条目分类路径逐字节不变。
433
450
  */
434
451
  function auditRuntimeReceipt(r, opts) {
435
452
  const out = { claim: r && r.claim, logPath: r && r.logPath, sourceTag: null, green: false, logExists: null, mtimeInWindow: null, failSignatures: 0, exitCode: r && typeof r.exitCode === 'number' ? r.exitCode : null, reason: '' }
436
453
  // 打标依据 = 命令来源声明(不解析日志内容,X-10):opts.sourceTag 调用方声明源优先,
454
+ // 其次条目 source 标记直判(task-02 Grill B-1——'cli-noai-smoke' 直判 cross-layer),
437
455
  // 否则按回执 command 分类;非法/缺省走分类,未定类默认 build。
438
456
  const declaredSource = opts && typeof opts.sourceTag === 'string'
439
457
  && ['cross-layer', 'build', 'unit'].includes(opts.sourceTag) ? opts.sourceTag : null
440
- out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command)
458
+ out.sourceTag = declaredSource || classifyReceiptSourceTag(r && r.command, r && r.source)
441
459
  if (!r || !r.logPath) { out.reason = '回执缺 logPath'; return out }
460
+ // 绝对路径直读(2026-09-17-api-coverage-smoke task-02):CLI 机器段(source=cli-noai-smoke)
461
+ // 的 logPath 是 quality-scan 记录的绝对路径实录——join(cwd, 绝对路径) 会拼出破损路径
462
+ // 误报「日志不存在」,isAbsolute 时跳过 join(verify-postcheck 测试输出解析同款口径)。
463
+ const resolveLog = (p) => (opts.cwd && !isAbsolute(String(p)) ? join(opts.cwd, p) : p)
442
464
  let content = null
443
465
  try {
444
- const abs = opts.cwd ? join(opts.cwd, r.logPath) : r.logPath
466
+ const abs = resolveLog(r.logPath)
445
467
  content = readFileSync(abs, 'utf8')
446
468
  out.logExists = true
447
469
  } catch {
@@ -452,7 +474,7 @@ function auditRuntimeReceipt(r, opts) {
452
474
  if (opts.verifyStartAt) {
453
475
  try {
454
476
  const startAt = new Date(opts.verifyStartAt).getTime()
455
- out.mtimeInWindow = statSync(opts.cwd ? join(opts.cwd, r.logPath) : r.logPath).mtimeMs >= startAt - 60_000
477
+ out.mtimeInWindow = statSync(resolveLog(r.logPath)).mtimeMs >= startAt - 60_000
456
478
  if (!out.mtimeInWindow) { out.reason = `日志 mtime 早于 verifyStartAt(${r.logPath})`; return out }
457
479
  } catch { out.mtimeInWindow = null }
458
480
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * commit-guard.js — pre-commit 提交边界守卫(fail-open,只警告不阻断)。
3
+ *
4
+ * 为什么需要(2026-09-17 实证):多会话共享仓里 git 暂存区是共享状态——他会话 git add 过的
5
+ * 文件会被本会话的裸 git commit 一并带走(当日事故:add 带 pathspec 但 commit 裸跑,并行
6
+ * 会话 5 个 staged 文件被扫入 fb5bc11,事后 reset --soft + pathspec 重提交外科手术拆回)。
7
+ * AGENTS.md 第 18 条已加厚为「add/commit 同清单 pathspec」,本守卫是同一约束的工具化兜底:
8
+ * 纪理会漏,钩子卡点不会漏(与 write-audit / concurrent-detect 同族——确定性的事 CLI 兜)。
9
+ *
10
+ * 信号(只警告,exit 恒 0):
11
+ * S1 quick 声明面对账:staged 含 .sillyspec/quicklog/patches/ql-<id>.json 时,读其
12
+ * rows[].declared 声明面——staged 中超出声明面的文件(quicklog 账本/补丁自身除外)
13
+ * 即警告「疑似扫入他者文件」。
14
+ * S2 跨变更目录:staged 里 .sillyspec/changes/<name>/(含 archive/)命中 ≥2 个不同
15
+ * name → 警告「正常提交只携带一个变更的面」。
16
+ * 命中即 stderr 警告 + appendWriteAudit 留痕(via: 'commit-guard')。
17
+ *
18
+ * fail-open 硬约束(钩子挂在所有会话共享的 .husky/ 上,守卫自身绝不拦任何人的提交):
19
+ * 非 git 仓 / git 失败 / ql json 损坏 / 任何异常 → 静默 exit 0;钩子侧再垫 || true 双保险。
20
+ */
21
+ import { execFileSync } from 'child_process'
22
+ import { readFileSync, existsSync } from 'fs'
23
+ import { join } from 'path'
24
+ import { pathToFileURL } from 'url'
25
+ import { appendWriteAudit } from './write-audit.js'
26
+
27
+ const QUICKLOG_PREFIX = '.sillyspec/quicklog/'
28
+ const QL_PATCH_JSON_RE = /^\.sillyspec\/quicklog\/patches\/ql-[^/]+\.json$/
29
+ const CHANGES_RE = /^\.sillyspec\/changes\/(?:archive\/)?([^/]+)\//
30
+
31
+ /**
32
+ * S1+S2 纯函数:staged 面 + ql patch json 映射 → 警告数组(无 git/文件系统依赖,可直测)。
33
+ * @param {string[]} stagedFiles staged 相对路径
34
+ * @param {Record<string, any>} qlPatches { json路径 → 解析结果 }(损坏的已由调用方跳过)
35
+ * @returns {{signal: string, files: string[], message: string}[]}
36
+ */
37
+ export function analyzeStagedFace(stagedFiles, qlPatches) {
38
+ const staged = Array.isArray(stagedFiles) ? stagedFiles.filter((f) => typeof f === 'string' && f) : []
39
+ const warnings = []
40
+ // S1 quick 声明面对账
41
+ const qlJsons = staged.filter((f) => QL_PATCH_JSON_RE.test(f))
42
+ if (qlJsons.length > 0) {
43
+ const declared = new Set()
44
+ for (const j of qlJsons) {
45
+ const parsed = (qlPatches || {})[j]
46
+ if (!parsed || !Array.isArray(parsed.rows)) continue
47
+ for (const r of parsed.rows) {
48
+ if (r && r.declared && typeof r.path === 'string') declared.add(r.path)
49
+ }
50
+ }
51
+ if (declared.size > 0) {
52
+ const offending = staged.filter((f) => !f.startsWith(QUICKLOG_PREFIX) && !declared.has(f))
53
+ if (offending.length > 0) {
54
+ warnings.push({
55
+ signal: 'quick_declared_face_exceeded',
56
+ files: offending,
57
+ message: `staged 超出 quick 声明面(${qlJsons.map((f) => f.split('/').pop()).join(', ')}):${offending.join('、')}——疑似扫入他者文件,核对是否该随本提交带走`,
58
+ })
59
+ }
60
+ }
61
+ }
62
+ // S2 跨变更目录
63
+ const changeNames = new Set()
64
+ for (const f of staged) {
65
+ const m = f.match(CHANGES_RE)
66
+ if (m) changeNames.add(m[1])
67
+ }
68
+ if (changeNames.size >= 2) {
69
+ warnings.push({
70
+ signal: 'multi_change_dirs_staged',
71
+ files: staged.filter((f) => CHANGES_RE.test(f)),
72
+ message: `staged 同时含 ${changeNames.size} 个变更目录(${[...changeNames].join('、')})——正常提交只携带一个变更的面,疑似夹带并行会话产物`,
73
+ })
74
+ }
75
+ return warnings
76
+ }
77
+
78
+ function main() {
79
+ const cwd = process.cwd()
80
+ let staged = []
81
+ try {
82
+ staged = execFileSync('git', ['diff', '--cached', '--name-only'], {
83
+ cwd,
84
+ encoding: 'utf8',
85
+ // stderr 必须管道化:execFileSync 默认把子进程 stderr 透传给父进程——非 git 目录下
86
+ // git 落 --no-index 模式报 unknown option 的 usage 长文会漏进守卫 stderr(噪声)。
87
+ stdio: ['ignore', 'pipe', 'pipe'],
88
+ })
89
+ .split(/\r?\n/)
90
+ .map((s) => s.trim())
91
+ .filter(Boolean)
92
+ } catch {
93
+ return 0 // 非 git 仓 / git 异常 → 放行
94
+ }
95
+ if (staged.length === 0) return 0
96
+ const qlPatches = {}
97
+ for (const f of staged) {
98
+ if (!QL_PATCH_JSON_RE.test(f)) continue
99
+ try {
100
+ qlPatches[f] = JSON.parse(readFileSync(join(cwd, f), 'utf8'))
101
+ } catch { /* 单个 json 损坏 → 跳过该信号源,不连坐 */ }
102
+ }
103
+ const warnings = analyzeStagedFace(staged, qlPatches)
104
+ if (warnings.length === 0) return 0
105
+ for (const w of warnings) process.stderr.write(`⚠️ [commit-guard] ${w.message}\n`)
106
+ const specDir = join(cwd, '.sillyspec')
107
+ if (existsSync(specDir)) {
108
+ appendWriteAudit(specDir, {
109
+ via: 'commit-guard',
110
+ files: [...new Set(warnings.flatMap((w) => w.files))].slice(0, 20),
111
+ detail: { signals: warnings.map((w) => ({ signal: w.signal, files: w.files.slice(0, 20) })) },
112
+ })
113
+ }
114
+ return 0
115
+ }
116
+
117
+ // 直接执行(node src/commit-guard.js)才跑 main;被 import 时不跑
118
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
119
+ if (invokedDirectly) process.exit(main())
@@ -70,6 +70,7 @@ export const LOCAL_YAML_SCHEMA = {
70
70
  { path: 'commands.build', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '构建命令。', example: 'npm run build' },
71
71
  { path: 'commands.test', type: 'string', optional: true, status: 'live', readers: ['extractTestCommand (src/verify-postcheck.js)', 'runQuickTestLintGate (src/run/quick-audit.js)', 'validateCommands (src/scan-postcheck.js)'], desc: '测试命令——verify 阶段 CLI 亲自执行此命令与 verify-result.md 对账,实测失败即阻断;quick --done 触及 src/test 时同款实测(P0-2 门禁)。', example: 'npm test' },
72
72
  { path: 'commands.lint', type: 'string', optional: true, status: 'live', readers: ['detectLocalYaml (src/local-detect.js)', 'runQuickTestLintGate (src/run/quick-audit.js)', 'validateCommands (src/scan-postcheck.js)'], desc: 'lint 命令——quick --done 触及 src/test 时 CLI 亲自执行(P0-2 门禁,失败阻断完成)。', example: 'npm run lint' },
73
+ { path: 'commands.smoke', type: 'string', optional: true, status: 'live', readers: ['executeVerifyQualityScan (src/run/verify-quality-scan.js)'], desc: '接口冒烟命令——verify 阶段 CLI 亲跑(脚本自理服务生命周期:后台起服+轮询就绪+finally 杀),300s 超时帽,失败/超时只记失败态不阻断本步、触发 PASS 封顶(D-002);指纹含此键,未变不重跑。', example: 'node scripts/smoke.mjs' },
73
74
  { path: 'commands.install', type: 'string', optional: true, status: 'live', readers: ['extractInstallCommand (src/worktree-deps.js)'], desc: '依赖安装命令(worktree 依赖 provisioning 用)。', example: 'npm install' },
74
75
  ],
75
76
  },
@@ -316,6 +317,7 @@ commands:
316
317
  build: npm run build
317
318
  test: npm test
318
319
  lint: npm run lint
320
+ # smoke: node scripts/smoke.mjs # verify 冒烟命令(可选;脚本自理服务生命周期,失败只封顶 PASS 不阻断)
319
321
  # install: npm install # worktree 依赖安装命令
320
322
 
321
323
  # ── SillyHub MCP 客户端凭据(派发到 worker 用)──
@@ -211,8 +211,9 @@ function parseModulePathsSubset(content) {
211
211
  /** 未注入 moduleIndex 时的尽力发现:knowledgeRoot 同级 docs/<项目>/modules/_module-map.yaml。
212
212
  * 多子项目仓(如本仓 sillyspec+dashboard)各项目有自己的 map——dogfood 实证(2026-08-24):
213
213
  * 只取首个命中会把主项目条目全部误落 unmapped(dashboard 的 map 按字母序先命中)。
214
- * 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。 */
215
- function discoverModuleIndex(knowledgeRoot) {
214
+ * 改为合并全部项目的 map(同名模块合并 paths/core_files 去重),失败归 null → 全部 unmapped。
215
+ * 2026-09-18-fr-index-l1:导出供 fr-index 域解析复用。 */
216
+ export function discoverModuleIndex(knowledgeRoot) {
216
217
  try {
217
218
  const docsDir = join(dirname(knowledgeRoot), 'docs')
218
219
  if (!existsSync(docsDir)) return null
@@ -319,15 +320,19 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
319
320
  return lines
320
321
  }
321
322
 
322
- /** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-` 头)。
323
+ /** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
323
324
  * 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
324
- * 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。 */
325
- function splitKnowledgeSections(content) {
325
+ * 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
326
+ * 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
327
+ * 正则与恒等 id),decisions 调用点零改动。 */
328
+ export function splitKnowledgeSections(content, opts = {}) {
329
+ const sectionRegex = opts.sectionRegex || /^## (D-\d+)@v(\d+)\s*(.*)$/
330
+ const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
326
331
  const sections = []
327
332
  const preamble = []
328
333
  let cur = null
329
334
  for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
330
- const m = line.match(/^## (D-\d+)@v(\d+)\s*(.*)$/)
335
+ const m = line.match(sectionRegex)
331
336
  if (m) {
332
337
  if (cur) sections.push(cur)
333
338
  cur = { number: m[1], version: parseInt(m[2], 10), title: m[3].trim(), lines: [line] }
@@ -338,7 +343,7 @@ function splitKnowledgeSections(content) {
338
343
  }
339
344
  if (cur) sections.push(cur)
340
345
  for (const s of sections) {
341
- s.id = `${s.number}@v${s.version}`
346
+ s.id = buildId(s.number, s.version)
342
347
  const cm = s.lines.find(l => /^变更[::]/.test(l))
343
348
  s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
344
349
  }
@@ -346,7 +351,7 @@ function splitKnowledgeSections(content) {
346
351
  }
347
352
 
348
353
  /** 段落重组落盘文本(幂等归一:段间恰好一个空行 + 文件单一尾换行) */
349
- function joinKnowledgeFile(preamble, sections) {
354
+ export function joinKnowledgeFile(preamble, sections) {
350
355
  const parts = []
351
356
  const pre = preamble.join('\n').replace(/\s+$/, '')
352
357
  if (pre) parts.push(pre)
@@ -394,19 +399,21 @@ function applyEntryToSections(sections, entry, headHash) {
394
399
  // INDEX.md decisions 路由行幂等维护(FR-05:写入责任在本模块;只动 ## Decisions 段内指向 decisions/ 的行)
395
400
  // ---------------------------------------------------------------------------
396
401
 
397
- /** INDEX 路由行格式与 knowledge-match 解析口径一致:`- <域>|decision|决策 → [decisions/<域>.md](decisions/<域>.md)` */
398
- function routingLine(domain) {
399
- return `- ${domain}|decision|决策 → [decisions/${domain}.md](decisions/${domain}.md)`
400
- }
401
-
402
- const ROUTING_TARGET_RE = /^-\s+.*→\s*\[[^\]]*\]\(decisions\/([^)#]+?)(?:\.md)?(?:#[^)]*)?\)\s*$/
403
-
404
- /** 域文件实况 ↔ ## Decisions 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行 */
405
- function syncIndexRoutingLines(knowledgeRoot) {
406
- const decisionsDir = join(knowledgeRoot, 'decisions')
402
+ /**
403
+ * 域文件实况 ↔ INDEX 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行。
404
+ * 2026-09-18-fr-index-l1 参数化:section/subdir/makeLine/matchTarget 经参(decisions 缺省不变),
405
+ * fr 侧传 'FR 需求索引' 段与 fr/ 子目录——两类路由行各守各段互不干扰。
406
+ */
407
+ export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
408
+ const sectionName = opts.section || 'Decisions'
409
+ const subdir = opts.subdir || 'decisions'
410
+ const makeLine = opts.makeLine || ((d) => `- ${d}|decision|决策 → [${subdir}/${d}.md](${subdir}/${d}.md)`)
411
+ const esc = subdir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
412
+ const targetRe = opts.matchTarget || new RegExp(`^-\\s+.*→\\s*\\[[^\\]]*\\]\\(${esc}/([^)#]+?)(?:\\.md)?(?:#[^)]*)?\\)\\s*$`)
413
+ const domainDir = join(knowledgeRoot, subdir)
407
414
  let domains
408
415
  try {
409
- domains = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
416
+ domains = readdirSync(domainDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, ''))
410
417
  } catch { return false }
411
418
  if (domains.length === 0) return false
412
419
 
@@ -417,29 +424,29 @@ function syncIndexRoutingLines(knowledgeRoot) {
417
424
  }
418
425
  const lines = original ? original.replace(/\r\n/g, '\n').split('\n') : ['# Knowledge Index', '']
419
426
 
420
- // 定位 ## Decisions 段边界
427
+ // 定位段边界(参数化段名)
421
428
  let secStart = -1
422
429
  let secEnd = lines.length
423
430
  for (let i = 0; i < lines.length; i++) {
424
431
  const h = lines[i].match(/^##\s+(.+?)\s*$/)
425
432
  if (!h) continue
426
433
  if (secStart === -1) {
427
- if (h[1] === 'Decisions') secStart = i + 1
434
+ if (h[1] === sectionName) secStart = i + 1
428
435
  } else { secEnd = i; break }
429
436
  }
430
437
 
431
- const desired = domains.map(routingLine)
438
+ const desired = domains.map(makeLine)
432
439
  if (secStart === -1) {
433
440
  // 无段则追加(保留原文件其余内容)
434
441
  while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
435
- lines.push('', '## Decisions', ...desired)
442
+ lines.push('', `## ${sectionName}`, ...desired)
436
443
  } else {
437
444
  const body = lines.slice(secStart, secEnd)
438
445
  const kept = []
439
446
  const seen = new Set()
440
447
  const domainSet = new Set(domains)
441
448
  for (const l of body) {
442
- const m = l.match(ROUTING_TARGET_RE)
449
+ const m = l.match(targetRe)
443
450
  if (!m) { kept.push(l); continue } // 非路由行(注释/空行)原样保留
444
451
  // 目标域文件仍存在的路由行原样保留(关键词可能被人工扩充);失效行(文件已不存在)与重复行删
445
452
  if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(l) }
@@ -448,7 +455,7 @@ function syncIndexRoutingLines(knowledgeRoot) {
448
455
  const tail = []
449
456
  while (kept.length > 0 && kept[kept.length - 1].trim() === '') tail.unshift(kept.pop())
450
457
  for (const d of desired) {
451
- const m = d.match(ROUTING_TARGET_RE)
458
+ const m = d.match(targetRe)
452
459
  if (m && !seen.has(m[1])) kept.push(d) // 缺行补
453
460
  }
454
461
  kept.push(...tail)
@@ -0,0 +1,95 @@
1
+ /**
2
+ * diagnostic-codes.js — machine-interface 族诊断码表单一源
3
+ *
4
+ * 为什么需要:信封 errors/warnings 是自由文本中文句子,程序化消费方(SillyHub/未来 MCP
5
+ * 消费者)无法按错误类型稳定分支——提示语改一个字就是一次静默破坏性变更。本表给
6
+ * gate/derive/progress show 三面的自产错误配稳定 snake_case 身份码(2026-09-17
7
+ * OpenSpec 源码对比三轮评审②号项,change 2026-09-17-mi-diagnostic-codes)。
8
+ *
9
+ * 纪律(D-002@v1 硬边界):
10
+ * - 恰 10 码:信封级 4(exit 2 面)+ check 级 6(gate/derive 失败面)。
11
+ * - 只码 machine-interface 族自产错误——stage-contract 校验器散文、doctor/validate/
12
+ * scope-audit 面首期不碰(码标识失败面,不标识每条消息)。
13
+ * - 扩码走变更流程追加;本表是码身份唯一源——契约文档诊断码目录(interface-contract.md)
14
+ * 与运行时发射不得出现表外码,test/diagnostic-codes-parity.test.mjs 双向钉死
15
+ * (OpenSpec 的 agent-contract 无 parity 测试,此为反超点)。
16
+ *
17
+ * 零依赖叶子模块(不 import 仓内任何其他 src 模块,防环)。
18
+ */
19
+
20
+ /**
21
+ * 码 → { surface: 消费命令面, exit: 该失败面的退出码语义, trigger: 触发条件 }。
22
+ * surface 取值 ∈ gate / derive / progress show(CLI 子命令视角)。
23
+ */
24
+ export const DIAGNOSTIC_CODES = Object.freeze({
25
+ // ── 信封级(无法核验,exit 2)──
26
+ db_missing: {
27
+ surface: ['gate', 'derive', 'progress show'],
28
+ exit: 2,
29
+ trigger: '进度库不存在(只读契约:不为其建库,fail-closed)',
30
+ },
31
+ change_not_found: {
32
+ surface: ['gate', 'derive'],
33
+ exit: 2,
34
+ trigger: '变更名在进度库无记录',
35
+ },
36
+ unknown_facet: {
37
+ surface: ['derive'],
38
+ exit: 2,
39
+ trigger: 'facet ∉ FACETS 白名单(用法错)',
40
+ },
41
+ internal_error: {
42
+ surface: ['gate', 'derive', 'progress show'],
43
+ exit: 2,
44
+ trigger: '内部异常兜底(stdout 仍为合法 JSON)',
45
+ },
46
+ // ── check 级(事实性阻断,exit 1)──
47
+ artifacts_invalid: {
48
+ surface: ['gate', 'derive'],
49
+ exit: 1,
50
+ trigger: 'runValidators 失败(gate artifacts check 与 derive artifacts facet 同源)',
51
+ },
52
+ design_file_ref_invalid: {
53
+ surface: ['gate'],
54
+ exit: 1,
55
+ trigger: 'design 文件清单行级核验失败(直承 design-facts.js 既有稳定码)',
56
+ },
57
+ transition_blocked: {
58
+ surface: ['gate'],
59
+ exit: 1,
60
+ trigger: 'checkTransition 不允许(含 failed_post_check 门控)',
61
+ },
62
+ execute_evidence_unchanged: {
63
+ surface: ['gate', 'derive'],
64
+ exit: 1,
65
+ trigger: 'base..head 无代码变更(checkbox ≠ implementation)',
66
+ },
67
+ task_reviews_invalid: {
68
+ surface: ['gate', 'derive'],
69
+ exit: 1,
70
+ trigger: 'validateTaskReviews 失败(review.json 缺失/schema/verdict=fail)',
71
+ },
72
+ verify_test_failed: {
73
+ surface: ['gate', 'derive'],
74
+ exit: 1,
75
+ trigger: 'commands.test 实测失败',
76
+ },
77
+ });
78
+
79
+ /** gate check id → 失败码(六 check 全覆盖;表外 id 返回 undefined,调用方不挂键)。 */
80
+ const CHECK_ID_TO_CODE = Object.freeze({
81
+ artifacts: 'artifacts_invalid',
82
+ 'design-file-list': 'design_file_ref_invalid',
83
+ transition: 'transition_blocked',
84
+ 'execute-evidence': 'execute_evidence_unchanged',
85
+ 'task-reviews': 'task_reviews_invalid',
86
+ 'verify-test': 'verify_test_failed',
87
+ });
88
+
89
+ /**
90
+ * @param {string} checkId - gate check id(artifacts/design-file-list/transition/execute-evidence/task-reviews/verify-test)
91
+ * @returns {string|undefined} 对应失败码;表外 id 返回 undefined(信封不挂 code 键)
92
+ */
93
+ export function checkCode(checkId) {
94
+ return CHECK_ID_TO_CODE[checkId];
95
+ }
@@ -33,6 +33,9 @@ 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';
36
39
 
37
40
  // db 角色标签
38
41
  const DB_ROLE = {
@@ -1125,6 +1128,170 @@ export function detectSelfMaintenanceTax(cwd, authoritySpecDir) {
1125
1128
  }
1126
1129
  }
1127
1130
 
1131
+ /**
1132
+ * D14 归档完整性重扫(archive_integrity,2026-09-17 OpenSpec validate --archived 对标)。
1133
+ *
1134
+ * 为什么需要:归档前的完成门禁很严(review.json 驱动勾选 / Wave 断言 / verify 对账),
1135
+ * 但目录搬进 changes/archive/ 后**没有任何机制回头看**——手工挪目录绕流程的自愈只在
1136
+ * 下次 CLI 触碰时生效,归档后手改 tasks.md / 删 plan.md 无人发现。本维度补事后重扫:
1137
+ * ① 任务 checkbox 全勾(tasks.md 优先,回退 plan.md——读侧兼容同 D5/progress 口径;
1138
+ * 归档后 runtime 工件已 prune,review.json 不可用,checkbox 是归档目录里唯一持久的完成面)
1139
+ * ② plan.md 在场(archive 自愈 findAlreadyArchivedDir 的基准文件)
1140
+ * ③ 注册表文件存在但读不了 → finding(OpenSpec #205 教训:读不了 ≠ 没任务,
1141
+ * 检查不许在什么都没看的情况下静默通过)
1142
+ * 语义:WARNING advisory 不阻断、只读无修复(钩子/门禁挂载是后续独立策略裁决);
1143
+ * 无 archive 目录 / 无 task 行 → pass(无任务视为完备,同 OpenSpec "nothing to complete")。
1144
+ */
1145
+ function detectArchiveIntegrity(cwd, authoritySpecDir) {
1146
+ const base = { name: 'archive_integrity', label: '归档完整性重扫', safe_actions: [] };
1147
+ if (!authoritySpecDir) {
1148
+ return { ...base, pass: true, severity: null, findings: ['无权威 specDir,跳过'] };
1149
+ }
1150
+ const archiveDir = join(authoritySpecDir, 'changes', 'archive');
1151
+ if (!existsSync(archiveDir)) {
1152
+ return { ...base, pass: true, severity: null, findings: ['无 changes/archive 目录(未归档/新仓),跳过'] };
1153
+ }
1154
+ let entries;
1155
+ try {
1156
+ entries = readdirSync(archiveDir, { withFileTypes: true });
1157
+ } catch (e) {
1158
+ return { ...base, pass: false, severity: CHECK_SEVERITY.WARNING, findings: [`changes/archive 目录不可读(${e?.message || e})——人工排查,不静默跳过`] };
1159
+ }
1160
+ const names = entries
1161
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
1162
+ .map((d) => d.name)
1163
+ .sort();
1164
+ if (names.length === 0) {
1165
+ return { ...base, pass: true, severity: null, findings: ['changes/archive 为空(无已归档变更)'], archive_count: 0, offenders: [] };
1166
+ }
1167
+ const offenders = [];
1168
+ // FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
1169
+ const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
1170
+ const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
1171
+ const frEntries = frEnabled ? scanFrIndex(frKnowledgeRoot) : [];
1172
+ for (const name of names) {
1173
+ const dir = join(archiveDir, name);
1174
+ const reasons = [];
1175
+ // ② plan.md 在场性
1176
+ const planPath = join(dir, 'plan.md');
1177
+ const hasPlan = existsSync(planPath);
1178
+ if (!hasPlan) reasons.push('plan.md 缺失(归档自愈基准文件)');
1179
+ // ①③ 任务注册表:tasks.md 优先回退 plan.md;存在但不可读必须显式报(区分「缺席」与「读不了」)。
1180
+ // 2026-08-20-task-truth-unify 之前的旧归档完成态勾在 plan.md(tasks.md 是留给后人的未勾任务清单)——
1181
+ // tasks.md 有 checkbox 行但 0 勾、而 plan.md 有 ≥1 勾时,完成证据在 plan.md,切 plan.md 为完成源
1182
+ // (真实仓首扫实证:2026-05-31-sqlite-migration tasks 0/15 + plan 15/15,43 份 offenders 大半为此误报)。
1183
+ const tasksPath = join(dir, 'tasks.md');
1184
+ const readRegistry = (p) => {
1185
+ const content = readFileSync(p, 'utf8');
1186
+ // 与 progress.js readPlanCheckboxStatus 同正则(task- 前缀锚定,x/X 计勾)
1187
+ const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
1188
+ let t = 0;
1189
+ let c = 0;
1190
+ let m;
1191
+ while ((m = re.exec(content)) !== null) {
1192
+ t++;
1193
+ if (m[1] !== ' ') c++;
1194
+ }
1195
+ return { total: t, checked: c };
1196
+ };
1197
+ const regPath = existsSync(tasksPath) ? tasksPath : hasPlan ? planPath : null;
1198
+ if (regPath) {
1199
+ try {
1200
+ let stat = readRegistry(regPath);
1201
+ if (regPath === tasksPath && stat.total > 0 && stat.checked === 0 && hasPlan) {
1202
+ try {
1203
+ const planStat = readRegistry(planPath);
1204
+ if (planStat.total > 0 && planStat.checked > 0) stat = planStat;
1205
+ } catch { /* plan.md 不可读 → 维持 tasks.md 0 勾口径(本就报未勾) */ }
1206
+ }
1207
+ if (stat.total > 0 && stat.checked < stat.total) {
1208
+ reasons.push(`${stat.total - stat.checked} 个任务未勾(${stat.checked}/${stat.total})`);
1209
+ }
1210
+ } catch (e) {
1211
+ reasons.push(`任务注册表不可读(${relative(archiveDir, regPath)}:${e?.code || e?.message || e})`);
1212
+ }
1213
+ }
1214
+ // ── ④ FR 索引在场与取代完整(2026-09-18-fr-index-l1 L1,D-007:epoch 分界第四检查)──
1215
+ // 豁免面:fr/ 目录不存在(仓未启用索引,全跳);无日期前缀/epoch 前归档(存量不回填);
1216
+ // quick-* 与无 requirements.md(quick/scale:small 无索引义务)。违者并入 offenders(豁免走既有账本)。
1217
+ if (frEnabled) {
1218
+ const dm = name.match(/^(\d{4}-\d{2}-\d{2})-/);
1219
+ if (dm && dm[1] >= FR_INDEX_EPOCH && existsSync(join(dir, 'requirements.md'))) {
1220
+ if (!frEntries.some((e) => e.change === name)) {
1221
+ reasons.push('FR 索引缺失(epoch 后归档应有索引条目——归档时 indexRequirements 未落,查 archive 步降级日志)');
1222
+ } else {
1223
+ try {
1224
+ const reqParsed = parseChangeRequirements(dir);
1225
+ const refIds = new Set();
1226
+ for (const fr of reqParsed.frs) for (const r of fr.supersedes) refIds.add(r);
1227
+ for (const refId of refIds) {
1228
+ if (!frEntries.some((e) => e.id === refId && e.supersededBy)) {
1229
+ reasons.push(`承接未翻取代(${refId} 被引用但索引中未标 superseded)`);
1230
+ }
1231
+ }
1232
+ } catch { /* requirements 解析失败归任务注册表口径,此处不重复报 */ }
1233
+ }
1234
+ }
1235
+ }
1236
+ if (reasons.length > 0) offenders.push({ name, reasons });
1237
+ }
1238
+
1239
+ // 豁免账本(2026-09-17 收尾裁决):老归档历史形态(早于 plan.md 流程的手工归档/远古命名)逐份豁免——
1240
+ // 照 docs-check skip 清单先例(39 处历史快照冻结),不伪造 plan.md 篡改历史;账本外继续亮红;
1241
+ // 纪律:新欠账禁止入账(入账须变更流程裁决);账本解析失败按无豁免 fail-safe。
1242
+ const exemptPath = join(authoritySpecDir, 'archive-integrity-exempt.yaml');
1243
+ let exemptNames = new Set();
1244
+ const exemptNotes = [];
1245
+ if (existsSync(exemptPath)) {
1246
+ try {
1247
+ const doc = jsYaml.load(readFileSync(exemptPath, 'utf8'));
1248
+ const entries = Array.isArray(doc && doc.entries) ? doc.entries : [];
1249
+ exemptNames = new Set(entries.filter((e) => e && typeof e.name === 'string').map((e) => e.name));
1250
+ } catch (e) {
1251
+ exemptNotes.push(`豁免账本解析失败(${e?.message || e})——按无豁免处理,红灯保持`);
1252
+ }
1253
+ }
1254
+ const exempted = offenders.filter((o) => exemptNames.has(o.name));
1255
+ const effective = offenders.filter((o) => !exemptNames.has(o.name));
1256
+ const staleExemptions = [...exemptNames].filter((n) => !offenders.some((o) => o.name === n));
1257
+ if (exempted.length > 0) {
1258
+ exemptNotes.push(`${exempted.length} 份豁免在案(历史形态,账本 archive-integrity-exempt.yaml)`);
1259
+ }
1260
+ if (staleExemptions.length > 0) {
1261
+ exemptNotes.push(`${staleExemptions.length} 条豁免已失效(对应归档不再欠账)可清理:${staleExemptions.slice(0, 5).join(', ')}${staleExemptions.length > 5 ? ' …' : ''}`);
1262
+ }
1263
+
1264
+ if (effective.length === 0) {
1265
+ const findings = [`${names.length} 份归档完整(任务全勾 + plan.md 在场${exempted.length > 0 ? `,另有 ${exempted.length} 份豁免在案` : ''})`];
1266
+ findings.push(...exemptNotes);
1267
+ return {
1268
+ ...base,
1269
+ pass: true,
1270
+ severity: null,
1271
+ findings,
1272
+ archive_count: names.length,
1273
+ offenders: [],
1274
+ exempted_count: exempted.length,
1275
+ stale_exemptions: staleExemptions,
1276
+ };
1277
+ }
1278
+ const findings = effective.slice(0, 10).map((o) => `${o.name}:${o.reasons.join(';')}`);
1279
+ if (effective.length > 10) findings.push(`…还有 ${effective.length - 10} 份`);
1280
+ findings.push(...exemptNotes);
1281
+ findings.push('语义:归档=全勾终态;未勾/缺文件多为归档后手改或手工搬目录绕流程——git log 定位改动来源,doctor 只读不自动修');
1282
+ return {
1283
+ ...base,
1284
+ pass: false,
1285
+ severity: CHECK_SEVERITY.WARNING,
1286
+ findings,
1287
+ safe_actions: [{ dimension: 'archive_integrity', action: 'manual_inspect', risk: 'manual_edit', rationale: '归档完整性破损', next_step: '逐份核对 offenders:git log --follow .sillyspec/changes/archive/<name>/tasks.md 定位改动者与意图,确属历史遗留走变更流程裁决入豁免账本(新欠账禁止入账),勿改语义' }],
1288
+ archive_count: names.length,
1289
+ offenders: effective,
1290
+ exempted_count: exempted.length,
1291
+ stale_exemptions: staleExemptions,
1292
+ };
1293
+ }
1294
+
1128
1295
  /**
1129
1296
  * renderDoctorSummary(FR-01,全新输出契约——2026-09-09-doctor-noai):逐维
1130
1297
  * ✅/⚠️/❌ + label + findings 首行 + safe_actions 提示行。顶层非 --json 命令与
@@ -1177,7 +1344,9 @@ export async function runDoctorDiagnostics({ cwd }) {
1177
1344
  const applyManifestDrift = detectApplyManifestDrift(cwd, authoritySpecDir)
1178
1345
  // task-02(2026-09-15-tax-governance):自维护税面(活跃 tally 列示 + 台账聚合 + 税重阈值,只读)
1179
1346
  const selfMaintenanceTax = detectSelfMaintenanceTax(cwd, authoritySpecDir)
1180
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax];
1347
+ // D14(2026-09-17 archive-rescan,OpenSpec validate --archived 对标):归档完整性事后重扫(只读 WARNING)
1348
+ const archiveIntegrity = detectArchiveIntegrity(cwd, authoritySpecDir)
1349
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity];
1181
1350
 
1182
1351
  return {
1183
1352
  dimensions,