sillyspec 3.28.10 → 3.28.12

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 (52) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/.husky/pre-commit +4 -0
  6. package/.husky/pre-push +3 -0
  7. package/AGENTS.md +1 -1
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +76 -6
  10. package/src/commit-guard.js +119 -0
  11. package/src/config-schema.js +23 -0
  12. package/src/design-facts.js +46 -9
  13. package/src/diagnostic-codes.js +95 -0
  14. package/src/docs-check.js +14 -10
  15. package/src/docs-debt.js +7 -2
  16. package/src/docs-gate.js +42 -15
  17. package/src/doctor-diagnostics.js +141 -1
  18. package/src/endpoint-extractor.js +50 -2
  19. package/src/foreign-declared.js +25 -16
  20. package/src/hooks/worktree-guard.js +75 -2
  21. package/src/index.js +129 -1
  22. package/src/machine-interface.js +63 -0
  23. package/src/probe7-anchor-check.js +20 -2
  24. package/src/progress/stage-machine.js +34 -5
  25. package/src/progress.js +1 -1
  26. package/src/quicklog.js +231 -1
  27. package/src/run/complete-handlers.js +172 -1
  28. package/src/run/complete.js +73 -3
  29. package/src/run/gate-snapshot.js +262 -0
  30. package/src/run/gates.js +41 -7
  31. package/src/run/prompt.js +79 -1
  32. package/src/run/shared.js +10 -0
  33. package/src/run/verify-quality-scan.js +151 -5
  34. package/src/stage-contract-spec.js +14 -0
  35. package/src/stage-contract.js +738 -8
  36. package/src/stage-review-checklist.js +16 -2
  37. package/src/stage-review.js +137 -2
  38. package/src/stages/archive.js +9 -5
  39. package/src/stages/brainstorm.js +7 -1
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/plan-postcheck.js +42 -5
  42. package/src/stages/plan.js +8 -1
  43. package/src/stages/quick.js +5 -5
  44. package/src/stages/verify.js +8 -3
  45. package/src/sync.js +69 -0
  46. package/src/task-review.js +9 -3
  47. package/src/verify-facts-schema.js +95 -10
  48. package/src/verify-postcheck.js +346 -28
  49. package/src/verify-probes.js +1221 -46
  50. package/src/worktree-apply.js +175 -3
  51. package/src/write-audit.js +55 -0
  52. package/.claude/full.json +0 -1
@@ -23,6 +23,7 @@ import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
23
23
  import { git, gitQuiet, safeGit, unquoteGitPath } from './git-helper.js';
24
24
  import { resolveLatestExecuteRunId, resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } from './task-review.js';
25
25
  import { collectActiveQuickGuardFiles } from './quicklog.js';
26
+ import { appendWriteAudit, WRITE_AUDIT_FILE_CAP } from './write-audit.js';
26
27
  import { detectCommittedDrift, formatCommittedDriftWarning } from './run/concurrent-detect.js';
27
28
 
28
29
  const CHANGES_REL = '.sillyspec/changes';
@@ -549,10 +550,25 @@ function detectNoOpFiles(projectRoot, worktreePath, candidates) {
549
550
  * changes/ 实体在 specRoot,本地目录常为空——旧硬编码 CHANGES_REL 恒读空 → allow 集恒空 →
550
551
  * Gate1 整批误拦(sync.js BUG-01 同族)。
551
552
  *
553
+ * **.sillyspec/docs/ 条件白名单 + declaredFace 快照(D-003@v2,2026-09-16-friction5-hardening
554
+ * R3/FR-03)**:坑 apply-docs-sync-blocked(用户 2026-09-16 驾驭小结③)——filterDeliverableFiles
555
+ * 明确保留 `.sillyspec/docs/` 为交付物,而 allow 面(design §6 ∪ allowed_paths)不含 docs 时
556
+ * Gate1 又拦同一文件,「filter 保交付、Gate1 拦交付」两道口径矛盾,approved 文档同步照样 BLOCKED。
557
+ * 修正:聚合完成后仅当 main 仓声明面非空(mainSet.size > 0)才 mainSet.add('.sillyspec/docs/')
558
+ * (尾斜杠写法——pathMatches 目录前缀语义放行整目录,step3.5 getBlobHashMap 对目录条目 inert)。
559
+ * **必须条件加白**(design-grill M5):无条件加白会把空清单翻转成非空——design 与任务卡全缺的
560
+ * 存量变更从「fail-open(Gate1 跳过、patch 全量)」变成「docs-only 单条目面 → 非 docs 交付全
561
+ * BLOCKED + patch 静默收窄」,故空清单维持空(零行为变化)。加白前快照 declaredFace = 原
562
+ * 声明面(design §6 ∪ allowed_paths,不含白名单条目),以 Map 附挂属性 repoMap.declaredFace
563
+ * 带出(Map 方法全保留,既有调用点零破坏)——Gate1 hasAllowList 判定与 docs 越权审计报备均以
564
+ * declaredFace 为口径(M3:allowed_paths 已声明的 docs 文件不误报越权)。白名单只进 main 仓
565
+ * Set(`.sillyspec/docs/` 是主仓 specRoot 概念),跨仓 Map 切片不受影响。
566
+ *
552
567
  * @param {string} projectRoot - 主仓库根
553
568
  * @param {string} changeName - 变更名
554
569
  * @param {{ specBase?: string }} [opts] specBase 显式传入(平台模式必传 specRoot)
555
- * @returns {Map<string, Set<string>>} repoKey → 并集清单 Set(无 design 清单且无 task 卡片时为空 Map)
570
+ * @returns {Map<string, Set<string>>} repoKey → 并集清单 Set(无 design 清单且无 task 卡片时为空 Map);
571
+ * 附挂属性 declaredFace: Set<string>——main 仓加白前原声明面(审计口径,docs 白名单条目不在其中)
556
572
  */
557
573
  export function resolveApplyAllowSet(projectRoot, changeName, opts = {}) {
558
574
  const repoMap = new Map();
@@ -583,6 +599,12 @@ export function resolveApplyAllowSet(projectRoot, changeName, opts = {}) {
583
599
  for (const p of parseAllowedPaths(content)) repoSet.add(p);
584
600
  }
585
601
  }
602
+
603
+ // D-003@v2:.sillyspec/docs/ 条件加白——仅 main 仓声明面非空才加(空清单维持 fail-open,
604
+ // 见函数头 jsdoc)。加白前快照 declaredFace(原声明面)供 Gate1 审计口径用(Map 附挂带出)。
605
+ const declaredFace = new Set(mainSet);
606
+ if (mainSet.size > 0) mainSet.add('.sillyspec/docs/');
607
+ repoMap.declaredFace = declaredFace;
586
608
  return repoMap;
587
609
  }
588
610
 
@@ -680,8 +702,87 @@ function writeApplyManifest({ projectRoot, specBase, changeName, baseHash, files
680
702
  return { written: true, file: manifestPath, files: entries };
681
703
  }
682
704
 
705
+ // ── db/*.sql 声明门(2026-09-17-pass-cap-semantics task-04 / FR-05 / FR-06 / D-007 / D-012)──
706
+ // 文法实现单点:verify-probes 的 parseDbScriptDeclarations(X-03 双形态:声明行 + 回执
707
+ // command)/ parseHandoverRows(task-01 导出),禁二份副本。顶层 await 动态绑定
708
+ // (verify-probes :1630 stage-contract 同款先例;已核 verify-probes 静态闭包与其顶层
709
+ // await 链均不回指本文件,任一加载序无环)。绑定失败降级 null → 门 fail-open(warn
710
+ // 放行)——门自身异常不锁死 apply/归档主流程。
711
+ let dbScriptDeclareParser = null
712
+ let handoverRowsParser = null
713
+ try {
714
+ ;({ parseDbScriptDeclarations: dbScriptDeclareParser, parseHandoverRows: handoverRowsParser } = await import('./verify-probes.js'))
715
+ } catch { /* fail-open:绑定失败时下方门函数降级 warn 放行 */ }
716
+
683
717
  /**
684
- * 坑 mixed-baseline-drift-hint(ql-20260915-004):apply 成功尾声混合基线 advisory。
718
+ * db/*.sql 执行声明对账门(task-04 兜底侧,apply 尾声与 archive --confirm 前置共用同一函数)。
719
+ * 纯「文件集 × 文本声明」对账,零连库(design 非目标——不做 information_schema 对账)。
720
+ *
721
+ * 口径:
722
+ * - 入参文件集 posix 化后取 ^db/ 开头且 .sql 结尾者(与声明文法产出 db/<file>.sql 同形,
723
+ * 对账零变换;深路径 src/.../db/x.sql 不进门——声明文法同样不认该形态,两侧一致无死锁);
724
+ * - 声明集 = parseDbScriptDeclarations(verifyMd),verifyMd 自 <changeDir>/verify-result.md
725
+ * 读取(changeDir 经 resolveActiveOrArchiveChangeDir 归档感知,specBase 缺省时与
726
+ * writeApplyManifest 同链兜底:透传 specBase > 平台指针 > 本地 .sillyspec);
727
+ * - verify-result.md 缺失(ENOENT)= 空声明集 → 有 db 交付即拦(fail-closed 正常判定路径);
728
+ * - 互斥分支(D-005@v2:db-script 恒 blocking):声明集全命中 × handover 存在 db-script
729
+ * 类型行 → 两面矛盾 error。数据源 facts.handover(verify-facts.json,{ count, items[] }
730
+ * 形态)优先,缺 facts 时 parseHandoverRows(verifyMd) 兜底,两源任一命中即拦。
731
+ *
732
+ * fail 分布:正常判定路径 fail-closed(缺声明/互斥 → ok:false + 修复指引);门自身异常
733
+ * (绑定失败 / verifyMd 读取抛非 ENOENT 异常 / facts 解析异常走 md 兜底)fail-open
734
+ * (ok:true + warning)。
735
+ *
736
+ * @param {{ projectRoot: string, specBase?: string, changeName: string, files: string[] }} p
737
+ * @returns {{ ok: true, warning?: string } | { ok: false, error: string }}
738
+ */
739
+ export function checkDbScriptDeclarationGate({ projectRoot, specBase, changeName, files }) {
740
+ const dbFiles = [...new Set((Array.isArray(files) ? files : [])
741
+ .map((f) => String(f || '').replace(/\\/g, '/')).filter(Boolean))]
742
+ .filter((f) => /^db\/[\w./-]+\.sql$/.test(f)).sort()
743
+ if (dbFiles.length === 0) return { ok: true } // 无 db/*.sql 交集 → 零行为变化(存量兼容)
744
+ if (!dbScriptDeclareParser) {
745
+ return { ok: true, warning: `db 声明门降级放行(fail-open):verify-probes 文法绑定不可用——本次未对账以下 db 脚本执行声明,请人工确认已对目标库执行:${dbFiles.join(', ')}` }
746
+ }
747
+ const base = specBase || _silentPointerSpecRoot(projectRoot) || join(projectRoot, '.sillyspec')
748
+ const changeDir = resolveActiveOrArchiveChangeDir(base, changeName)
749
+ let verifyMd = ''
750
+ try {
751
+ verifyMd = readFileSync(join(changeDir, 'verify-result.md'), 'utf8')
752
+ } catch (e) {
753
+ if (e && e.code === 'ENOENT') verifyMd = '' // verify-result.md 缺失 = 空声明集(走下方缺声明拦截)
754
+ else return { ok: true, warning: `db 声明门降级放行(fail-open):verify-result.md 读取异常(${(e && e.message) || e})——本次未对账以下 db 脚本执行声明,请人工确认:${dbFiles.join(', ')}` }
755
+ }
756
+ const declared = new Set(dbScriptDeclareParser(verifyMd))
757
+ const missing = dbFiles.filter((f) => !declared.has(f))
758
+ if (missing.length > 0) {
759
+ return { ok: false, error: [
760
+ `db 脚本执行声明缺失:以下 ${missing.length} 个 db/*.sql 交付文件未在 verify-result.md 声明已对目标库执行(fix.sql 双门兜底,D-007/D-012——覆盖 verify 后新增 sql 的时序窗口):`,
761
+ ...missing.map((f) => ` - ${f}`),
762
+ `修复:先在 ${join(changeDir, 'verify-result.md').replace(/\\/g, '/')} 声明已执行——补「已对目标库执行:db/<file>.sql」声明行,或「## 集成验证回执」条目 command 含 db/<file>.sql(X-03 双形态,含 log 路径可选),补后重跑本命令。`,
763
+ ].join('\n') }
764
+ }
765
+ // 互斥分支:声明全在场 × handover db-script 行在场 = 「已执行」vs「移交待执行」两面矛盾
766
+ let handoverItems = null
767
+ try {
768
+ const facts = JSON.parse(readFileSync(join(changeDir, 'verify-facts.json'), 'utf8'))
769
+ if (facts && facts.handover && Array.isArray(facts.handover.items)) handoverItems = facts.handover.items
770
+ } catch { /* facts 缺失/损坏 → parseHandoverRows(verifyMd) 兜底 */ }
771
+ if (!handoverItems) {
772
+ try { handoverItems = handoverRowsParser ? handoverRowsParser(verifyMd) : [] } catch { handoverItems = [] }
773
+ }
774
+ if (handoverItems.some((h) => h && String(h.type || '').toLowerCase().replace(/[\s_]+/g, '-') === 'db-script')) {
775
+ return { ok: false, error: [
776
+ `db-script 移交项与执行声明互斥(D-005@v2):verify-result.md 已声明以下 db 脚本对目标库执行,但「移交项(结构化)」存在 type=db-script 行(承认该脚本待执行)——两面矛盾,二选一以真实状态为准:`,
777
+ ...dbFiles.map((f) => ` - ${f}`),
778
+ `修复:确已执行 → 从 verify-result.md「## 移交项(结构化)」删除该 db-script 行后重跑;确未执行 → 删除对应「已对目标库执行」声明行/回执条目后重跑(届时按缺声明门指引处理)。`,
779
+ ].join('\n') }
780
+ }
781
+ return { ok: true }
782
+ }
783
+
784
+ /**
785
+ * 坑 mixed-baseline-drift-hint(ql-20260915-004):apply 尾声混合基线 advisory。
685
786
  *
686
787
  * 主仓自本变更基点(baseHash)后的已提交推进若触及本变更相关文件(含 .test./.spec. 变体),
687
788
  * worktree 快照内验证过的测试断言可能基于旧内容——apply 落地的是混合基线,提示 agent 合并态
@@ -1188,6 +1289,16 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1188
1289
  result.errors.push(...crossApplied.errors);
1189
1290
  return result; // 跨仓未落地 = 该仓 task 实际未完成,主仓 apply 不可继续
1190
1291
  }
1292
+ // --- db/*.sql 声明门·跨仓面(task-04 / FR-05):跨仓 apply 成功面 out.applied[].changedFiles
1293
+ // 统一过同一门(声明文法跨仓同源——verify-result.md 仍在主仓 changes/<name>/ 下)。跨仓
1294
+ // apply 物理动作已发生,门拦的是流程收口(ok 不置 true + 修复指引,补声明重跑幂等)。
1295
+ const crossDbFiles = crossApplied.applied.flatMap((a) => (a && Array.isArray(a.changedFiles) ? a.changedFiles : []))
1296
+ const dbGateCross = checkDbScriptDeclarationGate({
1297
+ projectRoot, changeName, files: crossDbFiles,
1298
+ specBase: (ctx.platformOpts && ctx.platformOpts.specRoot) || undefined,
1299
+ })
1300
+ if (!dbGateCross.ok) { result.errors.push(dbGateCross.error); return result }
1301
+ if (dbGateCross.warning) result.warnings = (result.warnings || []).concat([dbGateCross.warning])
1191
1302
  }
1192
1303
  const cross = validateCrossRepoNoOp(ctx, projectRoot, changeName);
1193
1304
  for (const e of cross.errors) result.errors.push(e);
@@ -1359,7 +1470,29 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1359
1470
  ]);
1360
1471
  }
1361
1472
  const allowSet = allowMap.get('main') || new Set();
1362
- const hasAllowList = allowSet.size > 0;
1473
+ // D-003@v2(R3/FR-03):hasAllowList 与 docs 审计报备以 declaredFace(design §6 ∪
1474
+ // allowed_paths 原面,resolveApplyAllowSet 加白前快照、Map 附挂带出)为口径——Gate1 违规
1475
+ // 判定仍消费 allowSet(已含 .sillyspec/docs/ 白名单条目 = 放行 docs)。旧调用路径拿不到
1476
+ // declaredFace 时回退 mainSet 判定保持兼容(条件加白下两者空性等价,纯口径统一)。
1477
+ const declaredFace = (allowMap && allowMap.declaredFace instanceof Set) ? allowMap.declaredFace : allowSet;
1478
+ const hasAllowList = declaredFace.size > 0;
1479
+
1480
+ // --- 3c. docs 白名单审计报备(D-003@v2,风险 R-01 应对)---
1481
+ // 白名单放行是有意的语义放宽(approved 文档同步不再拦),审计面由此报备行保住:实际
1482
+ // changedFiles 中以 .sillyspec/docs/ 开头且不被 declaredFace 任一条目 pathMatches 覆盖的
1483
+ // 文件 = 纯靠白名单放行(design §6 / 任务卡 allowed_paths 均未声明)——逐名列出供追认/
1484
+ // 审计,docs-check 独立校验内容质量。declaredFace 命中(已声明)不报备(M3 口径)。
1485
+ // 无清单(fail-open)时白名单未生效,不报备(与旧版零行为差异)。
1486
+ {
1487
+ const docsAdmitted = hasAllowList
1488
+ ? changedFiles.filter(f => f.startsWith('.sillyspec/docs/') && ![...declaredFace].some(ap => pathMatches(f, ap)))
1489
+ : [];
1490
+ if (docsAdmitted.length > 0) {
1491
+ result.warnings = (result.warnings || []).concat([
1492
+ `${docsAdmitted.length} 个 .sillyspec/docs/ 文件经模块文档白名单放行(未在 design §6/任务卡 allowed_paths 声明,docs-check 独立校验内容):${docsAdmitted.join('、')}`
1493
+ ]);
1494
+ }
1495
+ }
1363
1496
 
1364
1497
  // --- 3.5 主干已提交推进检测:hashMismatch 前移(Grill P0 修复,design §step 顺序修正)---
1365
1498
  // 原在 step5b,但 step4.5/5a dirty 拦截短路在 step5b 之前,致 rescue 拿不到 hashMismatchFiles
@@ -1808,6 +1941,15 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1808
1941
  const patchContent = Buffer.concat(patchParts);
1809
1942
  if (patchContent.length === 0) {
1810
1943
  // patch 为空(清单中部分文件可能没实际变更)
1944
+ // --- db/*.sql 声明门·出口①(task-04 / FR-05):空 patch 出口的 merge 写回面过门 ---
1945
+ //(写回批末 add 已先行,本出口唯一落盘面 = mergedDirtyFiles ∪ mergedMismatchFiles,
1946
+ //与下方 manifest files 同口径;门失败 → result.ok 不置 true,走既有 error 通道)
1947
+ const dbGateEmpty = checkDbScriptDeclarationGate({
1948
+ projectRoot, specBase: allowSpecBase, changeName,
1949
+ files: [...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])],
1950
+ })
1951
+ if (!dbGateEmpty.ok) { result.errors.push(dbGateEmpty.error); return result }
1952
+ if (dbGateEmpty.warning) result.warnings = (result.warnings || []).concat([dbGateEmpty.warning])
1811
1953
  result.ok = true;
1812
1954
  // apply-manifest(task-01 出口③变体):4.5 三方合并全部消解重叠时,merge 写回面是
1813
1955
  // 本出口唯一落盘面——manifest 仍须覆盖(写回批末 add 已先行,指纹取 staged 态)。
@@ -1877,6 +2019,18 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
1877
2019
  return result;
1878
2020
  }
1879
2021
 
2022
+ // --- 7.1 db/*.sql 声明门·出口②(task-04 / FR-05 / D-007 / D-012 兜底第一门)---
2023
+ // patch 主路径 apply 文件集(patch 实际落盘集 ∪ 4.5 三方合并写回集,与 7.2 manifestFace
2024
+ // 同口径)∩ db/*.sql 逐文件对账 verify-result.md 执行声明(X-03 双形态文法,verify-probes
2025
+ // 单点实现)——缺失/与 db-script handover 互斥 → result.errors 阻断(result.ok 不置 true,
2026
+ // 走既有 apply error 通道,不抛异常);无 db/*.sql 交集零行为变化;门异常 fail-open。
2027
+ const dbGateMain = checkDbScriptDeclarationGate({
2028
+ projectRoot, specBase: allowSpecBase, changeName,
2029
+ files: [...patchFiles, ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])],
2030
+ })
2031
+ if (!dbGateMain.ok) { result.errors.push(dbGateMain.error); return result }
2032
+ if (dbGateMain.warning) result.warnings = (result.warnings || []).concat([dbGateMain.warning])
2033
+
1880
2034
  result.ok = true;
1881
2035
 
1882
2036
  // --- 7.2 apply 成功尾声写 apply-manifest.json(task-01 / FR-02 / D-001@v1 后半)---
@@ -2400,6 +2554,17 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
2400
2554
  return result;
2401
2555
  }
2402
2556
 
2557
+ // --- db/*.sql 声明门·出口③(task-04 / FR-05):merge 成功出口——changedFiles(落地校验过
2558
+ // 的交付集)∪ 两个三方合并写回面(与下方 manifestFace 同口径)过同一门;门失败 →
2559
+ // result.ok 不置 true,走既有 error 通道。specBase 为 applyWorktree 透传值(可能 undefined,
2560
+ // 门函数内 projectRoot 兜底链)。---
2561
+ const dbGateMerge = checkDbScriptDeclarationGate({
2562
+ projectRoot, specBase, changeName,
2563
+ files: [...(result.changedFiles || []), ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])],
2564
+ })
2565
+ if (!dbGateMerge.ok) { result.errors.push(dbGateMerge.error); return result }
2566
+ if (dbGateMerge.warning) result.warnings = (result.warnings || []).concat([dbGateMerge.warning])
2567
+
2403
2568
  result.ok = true;
2404
2569
  // apply 成功尾声写 apply-manifest.json(task-01 出口②:applyWorktree 显式 --merge 与
2405
2570
  // ENOBUFS 自动降级两条提前 return 入口共用本成功出口)。merge 已 commit → staged 态 =
@@ -2410,6 +2575,13 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
2410
2575
  // manifestFace 提升到 try 外(坑 mixed-baseline-drift-hint ql-20260915-004:drift 提示在
2411
2576
  // manifest 写点后消费同面,try 内 const 出块即失域)
2412
2577
  const manifestFace = [...new Set([...(result.changedFiles || []), ...(result.mergedDirtyFiles || []), ...(result.mergedMismatchFiles || [])])];
2578
+ // 批量写入留痕(2026-09-17 用户反馈⑥②法证收口):apply 是主仓面的批量写入(patch/merge
2579
+ // 落盘)——审计行记 时间/触发面/交付文件集/删除面(fail-open,specBase 与 manifest 同源)。
2580
+ try {
2581
+ const auditFace = manifestFace.slice(0, WRITE_AUDIT_FILE_CAP)
2582
+ .concat(manifestFace.length > WRITE_AUDIT_FILE_CAP ? [`…+${manifestFace.length - WRITE_AUDIT_FILE_CAP}`] : []);
2583
+ appendWriteAudit(specBase, { via: 'worktree-apply', change: changeName, files: auditFace, deleted: (result.deletedFiles || []).length, merge: true });
2584
+ } catch { /* 审计 fail-open */ }
2413
2585
  try {
2414
2586
  const mf = writeApplyManifest({
2415
2587
  projectRoot, specBase, changeName,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * write-audit.js — 批量写入方留痕(2026-09-17 用户驾驭小结⑥②法证收口,ql 20260917-006)。
3
+ *
4
+ * 背景:平台内容回放(platform resolve --take-platform / pull --spec)与 worktree apply 都是
5
+ * 「单命令内批量整文件重写、逐字节保留来源行尾」——sillyspec 自产文件一律 LF,而回放路径会把
6
+ * 他机编辑器写出的 CRLF 内容原样落盘。事后定位只能靠 mtime 考古(2026-09-17 实证:3 文件毫秒
7
+ * 级同 mtime + CRLF + 内容增量,「平台同步回写」被冤枉一整天后才还原真凶候选)。本模块在每个
8
+ * 批量写入口追加一条 JSONL 审计行(.sillyspec/.runtime/write-audit.jsonl):时间 / 触发命令 /
9
+ * 文件集 / CRLF 命中——下次「18:09 谁动了 design.md」查一行即中。
10
+ *
11
+ * 约束:fail-open(任何异常只丢审计不改写入行为);零依赖;单行 appendFileSync(并行会话
12
+ * 各自整行写入,Windows 下最坏交错按行丢失计,不损主行为)。
13
+ */
14
+ import { appendFileSync, mkdirSync } from 'fs'
15
+ import { join, dirname } from 'path'
16
+
17
+ const CRLF_BUF = Buffer.from('\r\n')
18
+
19
+ /**
20
+ * 追加一条批量写入审计记录。
21
+ * @param {string} specDir .sillyspec 根(审计落 .runtime/write-audit.jsonl)
22
+ * @param {{ via: string, change?: string, files?: string[], crlfPaths?: string[], detail?: object }} record
23
+ * via=触发命令锚('platform-resolve-take-platform' | 'pull-spec-bundle' | 'worktree-apply')
24
+ * @returns {boolean} 是否落盘成功(fail-open:false 只损失审计)
25
+ */
26
+ export function appendWriteAudit(specDir, record) {
27
+ try {
28
+ const p = join(specDir, '.runtime', 'write-audit.jsonl')
29
+ mkdirSync(dirname(p), { recursive: true })
30
+ appendFileSync(p, JSON.stringify({ ts: new Date().toISOString(), ...record }) + '\n', 'utf8')
31
+ return true
32
+ } catch { return false }
33
+ }
34
+
35
+ /**
36
+ * CRLF 命中探测(法证关键字段:本仓 sillyspec 自产文件一律 LF——落盘后呈 CRLF 的文件
37
+ * 几乎必来自「他机内容逐字节回放」或仓外工具写入)。
38
+ * @param {Array<[string, Buffer|string]>} entries [path, 写入内容] 对
39
+ * @returns {string[]} 含 \r\n 的路径(探测失败的条目不连坐)
40
+ */
41
+ export function detectCrlfFiles(entries) {
42
+ const out = []
43
+ for (const pair of Array.isArray(entries) ? entries : []) {
44
+ if (!Array.isArray(pair)) continue
45
+ const [path, data] = pair
46
+ try {
47
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data))
48
+ if (buf.includes(CRLF_BUF)) out.push(path)
49
+ } catch { /* 单条探测失败跳过 */ }
50
+ }
51
+ return out
52
+ }
53
+
54
+ /** 文件清单截断帽(防大树/大面把审计行撑爆;截断时附 +N 标记由调用方自理) */
55
+ export const WRITE_AUDIT_FILE_CAP = 20