sillyspec 3.33.3 → 3.33.5

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 (55) hide show
  1. package/.claude/skills/sillyspec-config/SKILL.md +48 -0
  2. package/.claude/skills/sillyspec-flow/SKILL.md +4 -0
  3. package/AGENTS.md +1 -1
  4. package/package.json +1 -1
  5. package/src/config-known-failures.js +258 -0
  6. package/src/config-schema.js +28 -0
  7. package/src/config-validate.js +180 -0
  8. package/src/decision-distill.js +26 -5
  9. package/src/decision-redomain.js +5 -4
  10. package/src/docs-check.js +8 -4
  11. package/src/doctor-diagnostics.js +89 -6
  12. package/src/flow-parity.js +22 -2
  13. package/src/flow-preflight.js +67 -0
  14. package/src/flow.js +252 -55
  15. package/src/fr-index.js +7 -3
  16. package/src/git-helper.js +82 -0
  17. package/src/handoff.js +4 -0
  18. package/src/hooks/worktree-guard.js +12 -3
  19. package/src/hot-cache.js +60 -0
  20. package/src/index.js +111 -7
  21. package/src/knowledge-graph.js +82 -2
  22. package/src/knowledge-match.js +18 -2
  23. package/src/knowledge-quicklog.js +17 -2
  24. package/src/knowledge-vector.js +5 -3
  25. package/src/machine-interface.js +34 -13
  26. package/src/progress/change-registry.js +15 -0
  27. package/src/progress/shared.js +4 -0
  28. package/src/progress/stage-machine.js +15 -2
  29. package/src/progress.js +5 -0
  30. package/src/redomain-unmapped.js +204 -0
  31. package/src/redomain.js +5 -3
  32. package/src/review-dispatch.js +208 -47
  33. package/src/run/bg-sync.js +4 -0
  34. package/src/run/code-face-key.js +4 -1
  35. package/src/run/command.js +20 -5
  36. package/src/run/complete-handlers.js +38 -4
  37. package/src/run/complete.js +185 -14
  38. package/src/run/gates.js +30 -1
  39. package/src/run/prompt.js +9 -1
  40. package/src/run/quick-audit.js +4 -2
  41. package/src/run/shared.js +18 -1
  42. package/src/run/stage.js +22 -19
  43. package/src/run/verify-quality-scan.js +51 -0
  44. package/src/runtime-paths.js +3 -0
  45. package/src/sillyhub-mcp/client.js +11 -6
  46. package/src/spawn-settle.js +47 -0
  47. package/src/spec-sync.js +89 -3
  48. package/src/stages/knowledge.js +25 -3
  49. package/src/timing.js +55 -0
  50. package/src/verify-postcheck.js +153 -11
  51. package/src/watcher.js +4 -0
  52. package/src/worktree-apply.js +67 -4
  53. package/src/worktree.js +80 -11
  54. package/src/yaml-scalar-unquote.js +43 -0
  55. package/templates/agents-instruction.md +1 -1
@@ -479,15 +479,36 @@ function applyEntryToSections(sections, entry, headHash, sectionNumber, originId
479
479
  // INDEX.md decisions 路由行幂等维护(FR-05:写入责任在本模块;只动 ## Decisions 段内指向 decisions/ 的行)
480
480
  // ---------------------------------------------------------------------------
481
481
 
482
+ /** 路由行共享泛词(FR-02,2026-10-21-knowledge-tier-denoise):decisions 面 decision|决策 /
483
+ * FR 面 FR|需求|承接 曾是全体域共享关键词——查询含泛词即命中全部路由行,注入席位退化为
484
+ * INDEX 行序抽签(本仓 Decisions 段 30 行全带泛词实证)。模板侧已删;存量行由
485
+ * syncIndexRoutingLines(stripTokens) 幂等剥离。 */
486
+ export const DECISIONS_ROUTE_STRIP_TOKENS = ['decision', '决策']
487
+ export const FR_ROUTE_STRIP_TOKENS = ['FR', '需求', '承接']
488
+
489
+ /** 路由行关键词表剥泛词:`- kw1|kw2 → [..](..)` 关键词段剔除 tokens(精确等值,trim 后判),
490
+ * 域名与其余关键词保留;关键词表剥空则整行原样保留(不造坏行)。幂等:已剥行再剥零漂移。 */
491
+ function stripRouteTokens(line, tokens) {
492
+ if (!Array.isArray(tokens) || tokens.length === 0) return line
493
+ const m = String(line).match(/^(\s*-\s+)([^→]+?)(\s*→[\s\S]*)$/)
494
+ if (!m) return line
495
+ const kept = m[2].split('|').map((k) => k.trim()).filter(Boolean).filter((k) => !tokens.includes(k))
496
+ if (kept.length === 0) return line
497
+ return `${m[1]}${kept.join('|')}${m[3]}`
498
+ }
499
+
482
500
  /**
483
501
  * 域文件实况 ↔ INDEX 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行。
484
502
  * 2026-09-18-fr-index-l1 参数化:section/subdir/makeLine/matchTarget 经参(decisions 缺省不变),
485
503
  * fr 侧传 'FR 需求索引' 段与 fr/ 子目录——两类路由行各守各段互不干扰。
504
+ * stripTokens(FR-02):对保留行与新行关键词表幂等剥离共享泛词(缺省 [] 行为不变)。
486
505
  */
487
506
  export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
488
507
  const sectionName = opts.section || 'Decisions'
489
508
  const subdir = opts.subdir || 'decisions'
490
- const makeLine = opts.makeLine || ((d) => `- ${d}|decision|决策 → [${subdir}/${d}.md](${subdir}/${d}.md)`)
509
+ const stripTokens = Array.isArray(opts.stripTokens) ? opts.stripTokens : []
510
+ // 路由键=域名(FR-02 泛词退役后的唯一共享形态);makeLine 传参者自定(fr 面 auto- 伪域附裸段关键词)
511
+ const makeLine = opts.makeLine || ((d) => `- ${d} → [${subdir}/${d}.md](${subdir}/${d}.md)`)
491
512
  const esc = subdir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
492
513
  const targetRe = opts.matchTarget || new RegExp(`^-\\s+.*→\\s*\\[[^\\]]*\\]\\(${esc}/([^)#]+?)(?:\\.md)?(?:#[^)]*)?\\)\\s*$`)
493
514
  const domainDir = join(knowledgeRoot, subdir)
@@ -528,15 +549,15 @@ export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
528
549
  for (const l of body) {
529
550
  const m = l.match(targetRe)
530
551
  if (!m) { kept.push(l); continue } // 非路由行(注释/空行)原样保留
531
- // 目标域文件仍存在的路由行原样保留(关键词可能被人工扩充);失效行(文件已不存在)与重复行删
532
- if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(l) }
552
+ // 目标域文件仍存在的路由行保留(关键词可能被人工扩充——剥共享泛词,其余原样);失效行(文件已不存在)与重复行删
553
+ if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(stripRouteTokens(l, stripTokens)) }
533
554
  }
534
555
  // 补行插在段尾空行之前(段尾空行是与下一 `## ` 的分隔,新行追加其后会出现行间空洞)
535
556
  const tail = []
536
557
  while (kept.length > 0 && kept[kept.length - 1].trim() === '') tail.unshift(kept.pop())
537
558
  for (const d of desired) {
538
559
  const m = d.match(targetRe)
539
- if (m && !seen.has(m[1])) kept.push(d) // 缺行补
560
+ if (m && !seen.has(m[1])) kept.push(stripRouteTokens(d, stripTokens)) // 缺行补(新模板本无泛词,剥是对旧式 makeLine 的防御)
540
561
  }
541
562
  kept.push(...tail)
542
563
  const sepNeeded = secEnd < lines.length && kept.length > 0 && kept[kept.length - 1].trim() !== ''
@@ -710,7 +731,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
710
731
  }
711
732
 
712
733
  // INDEX 路由行对账(仅在实际有条目写入时;零输出场景不动 INDEX)
713
- if (written.length > 0) syncIndexRoutingLines(knowledgeRoot)
734
+ if (written.length > 0) syncIndexRoutingLines(knowledgeRoot, { stripTokens: DECISIONS_ROUTE_STRIP_TOKENS })
714
735
 
715
736
  return { written, skipped: null, needsWait }
716
737
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { existsSync, readFileSync, unlinkSync } from 'fs'
20
20
  import { join } from 'path'
21
- import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
21
+ import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines, DECISIONS_ROUTE_STRIP_TOKENS } from './decision-distill.js'
22
22
  import { writeAtomicSync } from './fs-atomic.js'
23
23
 
24
24
  const DECISION_HEADER_RE = /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
@@ -33,8 +33,9 @@ function newDomainPreamble(domain) {
33
33
  ]
34
34
  }
35
35
 
36
- /** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份) */
37
- function sectionMeta(s) {
36
+ /** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份)。
37
+ * 导出供 redomain-unmapped 批量清偿驱动复用(FR-04,2026-10-21-knowledge-tier-denoise)。 */
38
+ export function sectionMeta(s) {
38
39
  const m = DECISION_HEADER_RE.exec(s.lines[0] || '')
39
40
  if (!m) return null
40
41
  const id = `${m[1]}@v${m[2]}`
@@ -135,7 +136,7 @@ export function redomainDecisionEntries({ knowledgeRoot, from, to, anchors = nul
135
136
  // INDEX 路由行:decisions 面默认参数(幂等;缺席行补上)
136
137
  const indexPath = join(knowledgeRoot, 'INDEX.md')
137
138
  const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
138
- try { syncIndexRoutingLines(knowledgeRoot) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
139
+ try { syncIndexRoutingLines(knowledgeRoot, { stripTokens: DECISIONS_ROUTE_STRIP_TOKENS }) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
139
140
  const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
140
141
 
141
142
  return {
package/src/docs-check.js CHANGED
@@ -18,6 +18,7 @@ import { join, resolve as pathResolve, relative as pathRelative, sep as pathSep
18
18
  import { execFileSync } from 'node:child_process'
19
19
  import jsYaml from 'js-yaml'
20
20
  import { parseModuleMapSimple } from './modules.js'
21
+ import { unquoteYamlScalar } from './yaml-scalar-unquote.js'
21
22
 
22
23
  // ── HEAD 模式内容解析层(坑 docs-gate-shared-worktree-parallel-block,2026-09-08 实证)──
23
24
  // pre-push gate 校验的语义对象是「被推送的提交树」,而磁盘工作区混有并行会话的未提交
@@ -967,10 +968,12 @@ export function extractKnownFailureKeys(yamlText) {
967
968
  if (!yamlText) return []
968
969
  const yaml = String(yamlText).replace(/\r\n?/g, '\n')
969
970
  // 贪婪 .* 同 verify-postcheck.extractKnownFailures(坑 known-failures-inline-nested-bracket:
970
- // 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)
971
+ // 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)。条目规范化三读侧统一走
972
+ // yaml-scalar-unquote(2026-10-21-known-failures-unescape:双引号反转义——写侧按 YAML 规范
973
+ // 转义的 \\. 读侧不还原会把豁免键留成字面双反斜杠)
971
974
  const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
972
975
  if (inline) {
973
- return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
976
+ return inline[1].split(',').map(s => unquoteYamlScalar(s)).filter(Boolean)
974
977
  }
975
978
  // 坑 verify-known-failures-comment-line-truncation → verify-known-failures-block-fragile-chain
976
979
  // (与 verify-postcheck.extractKnownFailures 口径互指对齐,逐字同步):块式捕获曾要求
@@ -995,10 +998,11 @@ export function extractKnownFailureKeys(yamlText) {
995
998
  .map(s => {
996
999
  const item = s.trim()
997
1000
  const full = item.match(/^(['"])(.*)\1$/)
998
- if (full) return full[2]
1001
+ // 引号值经 unquoteYamlScalar 反转义(与流式/verify-postcheck 三读侧同口径);裸值照旧剥行尾注释
1002
+ if (full) return unquoteYamlScalar(item)
999
1003
  const stripped = item.replace(/[ \t]+#.*$/, '').trim()
1000
1004
  const q2 = stripped.match(/^(['"])(.*)\1$/)
1001
- return q2 ? q2[2] : stripped
1005
+ return q2 ? unquoteYamlScalar(stripped) : stripped
1002
1006
  })
1003
1007
  .filter(Boolean)
1004
1008
  }
@@ -25,6 +25,8 @@
25
25
  import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
26
26
  import { runtimeTempPath } from './runtime-paths.js'
27
27
  import { safeGit } from './git-helper.js';
28
+ import { batchCatFileExists } from './git-helper.js';
29
+ import { batchStageBlobSha256 } from './git-helper.js';
28
30
  import { createHash } from 'crypto';
29
31
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
30
32
  import { join, relative, resolve, dirname } from 'path';
@@ -630,6 +632,59 @@ function localYamlHasPlatformSection(cwd) {
630
632
  }
631
633
  }
632
634
 
635
+ // ── local.yaml 完整性维度(2026-10-21-p0-p1-hardening FR-07 / dsh z2 实证)────────────
636
+ // agent 手改 local.yaml(PS5.1 Set-Content -Encoding UTF8)把 50KB 文件写成 GBK mojibake
637
+ // (不可逆,510 个 ?)——commands/known_failures/门禁全部 reader 对坏文件 fail-open/降级,
638
+ // doctor 却零提示(agent 原话推理实证「Doctor doesn't complain about local.yaml」),最终等
639
+ // 用户人工贴备份 2h13m。本维度把「文件存在但不可解析 / 含 BOM」显性化(只读,绝不自动修)。
640
+ export function detectLocalYamlIntegrity(cwd) {
641
+ const dim = {
642
+ name: 'local_yaml_integrity',
643
+ label: 'local.yaml 完整性',
644
+ safe_actions: [],
645
+ findings: [],
646
+ };
647
+ const p = join(cwd, '.sillyspec', 'local.yaml');
648
+ if (!existsSync(p)) return dim; // 无文件是合法状态(本地模式),不报
649
+ let text;
650
+ try {
651
+ text = readFileSync(p, 'utf8');
652
+ } catch (e) {
653
+ dim.findings.push(
654
+ `local_yaml_unreadable: local.yaml 读取失败(${String(e && e.message ? e.message : e).split('\n')[0]})——` +
655
+ `权限/占用问题;修复后用 sillyspec config known-failures list 校验键面`
656
+ );
657
+ return dim;
658
+ }
659
+ if (text.charCodeAt(0) === 0xfeff) {
660
+ dim.findings.push(
661
+ `local_yaml_bom: local.yaml 含 UTF-8 BOM——PS5.1 写文件常见(-Encoding UTF8 恒写 BOM);` +
662
+ `YAML 解析器多可容忍,但定向行扫描 reader 与 PowerShell 工具链易致盲。` +
663
+ `修复:node -e "const fs=require('fs');const p=process.argv[1];fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/^\\uFEFF/,''))" ${p}`
664
+ );
665
+ }
666
+ try {
667
+ jsYaml.load(text);
668
+ } catch (e) {
669
+ dim.findings.push(
670
+ `local_yaml_unparsable: local.yaml YAML 解析失败(${String(e && e.message ? e.message : e).split('\n')[0]})——` +
671
+ `commands/known_failures/门禁等全部 reader 对坏文件 fail-open 或降级,后续 gate 行为不可信` +
672
+ `(dsh z2 实证:手改炸文件后 doctor 失明,人工恢复耗时 2h13m)。` +
673
+ `修复:从备份/git 历史恢复或按 sillyspec config schema 重建;known_failures 键改用 ` +
674
+ `sillyspec config known-failures add/remove 管理(坏文件上该命令拒改并给指引)`
675
+ );
676
+ dim.safe_actions.push({
677
+ dimension: 'local_yaml_integrity',
678
+ action: 'restore_local_yaml',
679
+ target: p,
680
+ risk: 'confirm_required',
681
+ rationale: '坏配置是所有门的输运层——文件不可解析时任何 reader 行为都不可信,必须先恢复再谈其他',
682
+ next_step: `从备份/git 历史恢复或重建 ${p},然后 sillyspec config known-failures list 校验`,
683
+ });
684
+ }
685
+ return dim;
686
+ }
687
+
633
688
  function detectRepoNativeChain(cwd, pointer) {
634
689
  const dim = {
635
690
  name: 'repo_native_chain',
@@ -1023,6 +1078,17 @@ function detectApplyManifestDrift(cwd, specDir) {
1023
1078
  const scanned = manifests.slice(0, 5)
1024
1079
  const findings = []
1025
1080
  let checked = 0
1081
+ // ── staged 态批量预取(2026-10-21-protocol-tax task-07 / FR-07)──
1082
+ // 旧实现逐文件 `git show :<path>`(最近 5 份 manifest × 全部文件逐个 spawn,Windows 税
1083
+ // 可观);单次 `git cat-file --batch` 按输入序回流等价替换。miss=null=未暂存,与旧
1084
+ // gitQuiet null 同语义;整批失败空表全 miss(不洗白:暂存面查不到就当未暂存处理)。
1085
+ const stagedEntries = []
1086
+ for (const m of scanned) {
1087
+ for (const ent of m.files) {
1088
+ if (ent && typeof ent.path === 'string' && typeof ent.sha256 === 'string') stagedEntries.push(ent)
1089
+ }
1090
+ }
1091
+ const stagedMap = batchStageBlobSha256(cwd, stagedEntries.map((e) => e.path))
1026
1092
  for (const m of scanned) {
1027
1093
  for (const ent of m.files) {
1028
1094
  if (!ent || typeof ent.path !== 'string' || typeof ent.sha256 !== 'string') continue // 坏条目跳过
@@ -1036,10 +1102,8 @@ function detectApplyManifestDrift(cwd, specDir) {
1036
1102
  .update(Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1'))
1037
1103
  .digest('hex')
1038
1104
  } catch { /* 盘上无 → 丢失分支候选 */ }
1039
- // staged 态:git show :<path> buffer 直接 sha256(staged blob 即 LF 规范态,与写侧同基)
1040
- let stagedHash = null
1041
- const blob = gitQuiet(cwd, ['show', `:${ent.path}`], { encoding: 'buffer', timeout: 15000 })
1042
- if (Buffer.isBuffer(blob)) stagedHash = createHash('sha256').update(blob).digest('hex')
1105
+ // staged 态:查批量预取表(task-07;staged blob 即 LF 规范态,与写侧同基)
1106
+ const stagedHash = stagedMap.get(ent.path) ?? null
1043
1107
  const tag = `${m.change}×${ent.path}`
1044
1108
  if (worktreeHash === null && stagedHash === null) {
1045
1109
  findings.push(`${tag} 丢失(盘上与暂存区皆无;期望 ${short(ent.sha256)})`)
@@ -1233,6 +1297,22 @@ export function detectArchiveIntegrity(cwd, authoritySpecDir) {
1233
1297
  };
1234
1298
  }
1235
1299
  const offenders = [];
1300
+ // ── 锚点批量预检(2026-10-21-protocol-tax task-07 / FR-07)──
1301
+ // 旧实现主循环内逐 SHA 起 `git cat-file -e`(消费仓 681 归档 × ≤2 SHA × Windows 单 spawn
1302
+ // ~130ms ≈ 25s+,doctor 实测 41.8s 的 70% 在此)。改为循环前收集全量 40-hex SHA、单次
1303
+ // `git cat-file --batch-check` 判存,主循环 anchorOk 查表。等价性:missing/探针失败 =
1304
+ // 旧 safeGit error → 均按不可解析=悬空同向 fail;change-patch.json 缺失/不可解析 →
1305
+ // 两版同样不计锚点欠账(主循环 catch 语义原样)。
1306
+ const anchorShas = new Set();
1307
+ for (const name of names) {
1308
+ try {
1309
+ const cpMetaPre = JSON.parse(readFileSync(join(archiveDir, name, 'change-patch.json'), 'utf8'));
1310
+ for (const sha of [cpMetaPre?.head, cpMetaPre?.baseline]) {
1311
+ if (sha && /^[0-9a-f]{40}$/i.test(String(sha))) anchorShas.add(String(sha).toLowerCase());
1312
+ }
1313
+ } catch { /* change-patch.json 缺失/不可解析:主循环既有语义不变(不计锚点欠账) */ }
1314
+ }
1315
+ const anchorExists = batchCatFileExists(cwd, [...anchorShas]);
1236
1316
  // FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
1237
1317
  const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
1238
1318
  const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
@@ -1323,7 +1403,7 @@ export function detectArchiveIntegrity(cwd, authoritySpecDir) {
1323
1403
  const cpMeta = JSON.parse(readFileSync(cpJsonPath, 'utf8'));
1324
1404
  const anchorOk = (sha) => {
1325
1405
  if (!sha || !/^[0-9a-f]{40}$/i.test(String(sha))) return !sha; // 空锚不算悬空(无 git 基线形态)
1326
- return !safeGit(cwd, ['cat-file', '-e', `${sha}^{commit}`]).error;
1406
+ return anchorExists.get(String(sha).toLowerCase()) === true; // 查批量预检表(task-07/FR-07)
1327
1407
  };
1328
1408
  const headDangling = !anchorOk(cpMeta.head);
1329
1409
  const baseDangling = !anchorOk(cpMeta.baseline);
@@ -1606,7 +1686,10 @@ export async function runDoctorDiagnostics({ cwd }) {
1606
1686
  } catch { gateLeakRuntimeRoot = null }
1607
1687
  const gateSnapshotLeak = detectGateSnapshotLeak({ runtimeRoot: gateLeakRuntimeRoot })
1608
1688
  const knowledgeGraphIntegrity = detectKnowledgeGraphIntegrity(cwd, authoritySpecDir || join(cwd, '.sillyspec'))
1609
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity];
1689
+ // FR-07(2026-10-21-p0-p1-hardening / dsh z2 实证):local.yaml 完整性——此前 doctor 对
1690
+ // mojibake 坏文件完全失明(agent 手改炸文件后 doctor 报一堆别项零提示,等用户人工恢复 2h13m)
1691
+ const localYamlIntegrity = detectLocalYamlIntegrity(cwd);
1692
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity, localYamlIntegrity];
1610
1693
 
1611
1694
  return {
1612
1695
  dimensions,
@@ -522,6 +522,20 @@ export function detectPatchDrift({ cwd, change, freezeHead }) {
522
522
  * gate-snapshot 同款判定注入)时未提交 dirty 交付面一并入冻结——独占树内 dirty 全归属本变更;
523
523
  * 共享主仓无法归属 → 不入面只警告(他侧声明的 dirty 连警告都免)。
524
524
  */
525
+ /**
526
+ * dirty_gate_ignore 读取(2026-10-23-dirty-gate-ignore,module_gate_ignore/readFlowConfig
527
+ * 同款逐键正则防线):local.yaml 单行逗号串(目录/文件前缀,posix),列表块形态不支持。
528
+ * 三态 fail-soft——未配置/键值非法/local.yaml 不可读 → 空清单(dirty 门行为与现状逐字一致)。
529
+ */
530
+ export function readDirtyGateIgnore(specBase) {
531
+ try {
532
+ const raw = readFileSync(join(specBase, 'local.yaml'), 'utf8')
533
+ const m = raw.match(/^\s*dirty_gate_ignore\s*:\s*(.+)$/m)
534
+ if (!m) return []
535
+ return m[1].split(',').map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean)
536
+ } catch { return [] }
537
+ }
538
+
525
539
  export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive }) {
526
540
  const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
527
541
  && !isToolGovernancePath(f)
@@ -540,6 +554,12 @@ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive
540
554
  if (deliverable(path) && !dirty.includes(path)) dirty.push(path)
541
555
  }
542
556
  } catch { /* git 失败零 dirty 面 */ }
557
+ // 已知残渣前缀剔除(2026-10-23-dirty-gate-ignore,单一源):仓主显式声明的残渣面
558
+ // (quicklog/、测试夹具运行库等)不进 dirty 冻结缺口——剔除进 dirtyIgnored 随返回值
559
+ // 留证(权威门打 ℹ️ 留痕行);预检(flow-preflight.js)经本函数自动同源继承。
560
+ const ignorePrefixes = readDirtyGateIgnore(specBase)
561
+ const dirtyIgnored = ignorePrefixes.length > 0 ? dirty.filter((f) => ignorePrefixes.some((pfx) => f.startsWith(pfx))) : []
562
+ if (dirtyIgnored.length > 0) dirty = dirty.filter((f) => !dirtyIgnored.includes(f))
543
563
  // 归属切分单点:exclusive(worktree/--freeze-dirty)与共享主仓共用——他侧已声明 dirty 永不入冻
544
564
  // (评审 P2 修复:--freeze-dirty 承诺「非他侧声明」,全量冻入会造成跨变更审计双计;worktree
545
565
  // 下 own 恒为全量,切分无伤)
@@ -547,8 +567,8 @@ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive
547
567
  try {
548
568
  ownDirty = splitOwnVsForeignDiffFiles(cwd, change, dirty, { specBase }).own
549
569
  } catch { /* 归属切分失败:exclusive 保留全量(声明即边界),共享主仓退全量警告 */ }
550
- if (exclusive) return { files: [...new Set([...committed, ...ownDirty])], dirtyAdded: ownDirty, dirtyWarned: [] }
551
- return { files: [...committed], dirtyAdded: [], dirtyWarned: ownDirty }
570
+ if (exclusive) return { files: [...new Set([...committed, ...ownDirty])], dirtyAdded: ownDirty, dirtyWarned: [], dirtyIgnored }
571
+ return { files: [...committed], dirtyAdded: [], dirtyWarned: ownDirty, dirtyIgnored }
552
572
  }
553
573
 
554
574
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * flow-preflight.js — 收口预检(fail-together 一次报全,2026-10-23-flow-done-preflight)
3
+ *
4
+ * 背景(收口画像实证):621 次 flow done 调用/255 变更=2.44 次/变更,净浪费 152 次 RTT(24.5%)
5
+ * 源于逐门 fail-fast 串行拒绝;dirty-gap 门在实测门之后——56% 触发率的门每次拦截都先付一轮
6
+ * 完整实测(58s 起、大仓分钟级)。
7
+ *
8
+ * 本模块是纯前移报告层:实测门前只读收集「独立可算的便宜阻断条件」,由 flow.js 侧渲染一次
9
+ * 报全后拦在付费实测之前。判定与权威门同源同判定(复用同一批函数而非重写),旗标与守卫
10
+ * 逐条镜像 patch 子步:
11
+ * - dirty-gap 预判镜像 collectFreezeFiles dirtyWarned 非空 && !freezeDirty && !acceptDirtyGap
12
+ * && !turbo(turbo 下权威门降 advisory,镜像不拦)
13
+ * - orphan-face 预判镜像仅会话独占 worktree(shouldSkipGateSnapshotForWorktree)且
14
+ * !acceptOrphanFace
15
+ * - 守卫:baseline 缺席跳过(patch 子步整体跳过面);patch 子步已 done 跳过——预检绝不
16
+ * 重复拦已过权威门的面(断点续不误拦)
17
+ *
18
+ * 放弃项(评审已裁决,见 design 风险与死路):review.json schema 预验不做——schema 违规的
19
+ * 成本是「修正文件+重跑」一轮 RTT,与检测时点无关(同轮提前检测不省轮次);且豁免轮
20
+ * (--no-review)权威门不读文件,预检先验会多拦权威门不拦的面(判定漂移),三路皆负。
21
+ *
22
+ * fail-safe 单向:预检异常静默放行(返回空 findings)——漏报由权威门兜底(仍在原子步原序),
23
+ * 预检不构成新失败面;全程零写盘(不落冻结件、不 mark 子步、不写 flow-state)。
24
+ */
25
+ import { gitQuiet } from './git-helper.js'
26
+
27
+ /**
28
+ * 收集收口预检阻断条件(纯计算零打印)。
29
+ * @returns {{ findings: Array<{id:'dirty-gap',count:number,files:string[]} | {id:'orphan-face',commits:string[],count:number,files:string[]}> }}
30
+ */
31
+ export async function collectCloseoutPreflight({ cwd, specBase, change, st, freezeDirty = false, acceptDirtyGap = false, acceptOrphanFace = false, turbo = false }) {
32
+ const findings = []
33
+ const patchDone = st?.substeps?.patch === 'done'
34
+ // ── dirty-gap / orphan-face 预判(镜像 patch 子步判定链)──
35
+ if (!patchDone && st?.baseline_commit) {
36
+ try {
37
+ let exclusiveFrom = freezeDirty === true ? 'flag' : null
38
+ if (exclusiveFrom == null) {
39
+ try {
40
+ const { shouldSkipGateSnapshotForWorktree } = await import('./run/gate-snapshot.js')
41
+ if (shouldSkipGateSnapshotForWorktree(cwd)) exclusiveFrom = 'worktree'
42
+ } catch { /* 判定异常按共享主仓(权威门同款) */ }
43
+ }
44
+ const exclusive = exclusiveFrom != null
45
+ const committedRaw = String(gitQuiet(cwd, ['diff', '--name-only', `${st.baseline_commit}..HEAD`]) || '')
46
+ .split('\n').map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean)
47
+ const { filterCommittedFace, collectFreezeFiles, scanOrphanFace } = await import('./flow-parity.js')
48
+ const ownPrefix = `.sillyspec/changes/${change}/`
49
+ const committed = filterCommittedFace(committedRaw, ownPrefix)
50
+ const freeze = collectFreezeFiles({ cwd, specBase, change, committed, exclusive })
51
+ if (freeze.dirtyWarned.length > 0 && !freezeDirty && !acceptDirtyGap && !turbo) {
52
+ findings.push({ id: 'dirty-gap', count: freeze.dirtyWarned.length, files: freeze.dirtyWarned })
53
+ }
54
+ if (exclusiveFrom === 'worktree' && !acceptOrphanFace) {
55
+ try {
56
+ const orphan = scanOrphanFace(cwd, { baseline: st.baseline_commit })
57
+ if (orphan.files.length > 0) {
58
+ findings.push({ id: 'orphan-face', commits: orphan.commits, count: orphan.files.length, files: orphan.files })
59
+ }
60
+ } catch { /* orphan 预判 fail-safe(权威门同款不阻断) */ }
61
+ }
62
+ } catch { /* 预检 fail-safe:异常静默放行(权威门仍在 patch 子步原序原判定) */ }
63
+ }
64
+ return { findings }
65
+ }
66
+
67
+ export default { collectCloseoutPreflight }