sillyspec 3.32.6 → 3.32.8

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.
package/AGENTS.md CHANGED
@@ -42,3 +42,5 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
42
42
  11. **禁止目录级 git add / git add -A**(多会话共享仓):目录级暂存会夹带并行会话的进行中文件甚至误删已提交文件。提交一律用显式 pathspec,且 **add 与 commit 用同一份清单**:`git commit -m "..." -- 文件1 文件2`——带 pathspec 的 commit 只提交这些路径,他侧已 staged 的条目既不被带走、也原样留给对方;裸 `git commit` 提交的是整个共享暂存区,他会话 add 过的文件会被一并扫入。核对暂存面固定用 `git diff --cached --name-only` 全量读取,禁用过滤式核对(过滤正是致盲原因);核对与提交分开两条命令执行,不得链在同一命令里(链行没有拦截点)。安装了 commit-guard hook 的仓会对超声明面的 staged 文件警告(只警告不阻断)。
43
43
  12. **不奉承用户**,禁止"你说得对"类话术,直接给结论、依据、方案。
44
44
  13. **边干边勾(任务进度唯一源=变更的 tasks.md)**:完成一个任务单元(实现到位 + 相关测试跑绿)当场勾对应格——`sillyspec task tick --change <名> --task task-NN`(即时回显进度与下一任务)或直接 Edit 翻格 `- [ ]`→`- [x]`,勿攒到收口一把勾。harness 自带的 TodoWrite 类工具是会话内便利面,不替代 tasks.md——平台进度投影、收口哨兵、断点恢复只读 tasks.md。
45
+
46
+ 00. **不要枚举定义开放世界 以及 不要限制死 开发语言和框架**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.32.6",
3
+ "version": "3.32.8",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -168,6 +168,17 @@ function looksLikePath(p) {
168
168
  */
169
169
  const INCIDENTAL_RE = /顺带修复|附带修复|顺带|drive-?by|incidental/i
170
170
 
171
+ /**
172
+ * 「<repo-key> 仓变更」段头识别(单一源——2026-10-13-h2-repo-heading-parse 起定义在
173
+ * change-list.js 并导出;原 stages/plan-postcheck.js 模块内常量上移,plan-postcheck 改
174
+ * import 同一符号)。段头为 h2/h3:h3 子段形态由本文件消费(currentRepo 设置器),
175
+ * h2 形态在生态中是既定约定(brainstorm 厚道模板教学、plan-postcheck 覆盖对账
176
+ * parseDesignCoverageByRepo、design-facts/docs-check 存在性核验、评审清单四处按此解析)。
177
+ * 容忍可选编号前缀、(…)备注后缀(全/半角括号)与尾随空白/冒号;捕获组 1 = repo-key
178
+ * (限字母数字._-);key 是否在注册表由调用方 fail-closed 校验。
179
+ */
180
+ export const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
181
+
171
182
  /**
172
183
  * 从清单子段标题 / 路径前缀解析跨仓 repoKey(scope-audit-cross-repo-blindness 改进点 2,
173
184
  * 2026-09-15):多仓变更 design 清单按「main 仓 / sub-xxx / spdemo」分段书写,主仓跑
@@ -222,14 +233,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
222
233
  const sectionMatch = content.match(FILE_LIST_SECTION_RE)
223
234
  if (!sectionMatch) return []
224
235
 
225
- // 从标题后开始,截取到下一个 ## 标题或文件末尾
236
+ // 从标题后开始,截取到下一个 ## 标题或文件末尾——「<注册key> 仓变更」段头(h2)是本节
237
+ // 子分段不截断(2026-10-13-h2-repo-heading-parse:段头是生态既定约定——brainstorm 模板/
238
+ // plan-postcheck 覆盖对账/design-facts 核验四处消费;此前 h2 段头在此终结「文件变更清单」
239
+ // 节,其后条目整段静默丢失,agent 照告警①书写即丢对账与冻结)。
226
240
  const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length)
227
- const nextSectionMatch = afterSection.match(/^##\s/m)
228
- const relevantContent = nextSectionMatch
229
- ? afterSection.slice(0, nextSectionMatch.index)
230
- : afterSection
231
-
232
- const lines = relevantContent.split('\n')
241
+ const lines = []
242
+ for (const line of afterSection.split('\n')) {
243
+ if (/^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) break
244
+ lines.push(line)
245
+ }
233
246
  let headerSkipped = false
234
247
  let pathColIdx = 1 // 默认第 2 列;解析表头后可重定位
235
248
  let opColIdx = -1 // 操作列下标(表头扫描后定位;-1 = 无操作列 → operation=null)
@@ -239,6 +252,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
239
252
  const entries = new Map() // path -> { path, operation, incidental, repo }(exclude 子段 delete)
240
253
 
241
254
  for (const line of lines) {
255
+ // 「<key> 仓变更」段头(h2/h3,REPO_SECTION_HEADER_RE):仓分段切换——key 须在注册表内
256
+ // (fail-closed:未注册含 main → 主仓 repo=null,与 detectRepoFromSubsection 同口径);
257
+ // 段头重置分类子段态(新分段从 include 起、操作类别清空)。
258
+ const repoHdr = line.match(REPO_SECTION_HEADER_RE)
259
+ if (repoHdr) {
260
+ listMode = 'include'
261
+ currentOp = null
262
+ currentRepo = (Array.isArray(repoKeys) && repoKeys.includes(repoHdr[1])) ? repoHdr[1] : null
263
+ continue
264
+ }
242
265
  // 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件 / ### sub-grid-security(跨仓段)
243
266
  const subHeader = line.match(/^###\s+(.+?)\s*$/)
244
267
  if (subHeader) {
@@ -229,6 +229,17 @@ export const LOCAL_YAML_SCHEMA = {
229
229
  { path: 'decisions.behind_threshold', type: 'integer', optional: true, status: 'live', readers: ['readDecisionRulesConfig (src/docs-check.js)'], desc: '决策 behind 复核阈值(锚定模块源码在「最近确认」commit 后的前进数),缺省 10;超阈报「决策待复核」提示。', example: '10' },
230
230
  ],
231
231
  },
232
+ {
233
+ id: 'review_probes',
234
+ title: '项目评审探针',
235
+ note: '评审任务书的项目特有必查面注入(2026-10-12-review-probe-injection,dogfood 实证:检查单由主代理手写时,主代理没想到的面无人查——三个生产缺陷全靠用户报障发现)。CLI 只做装配与容量治理(条目≤16/每条行≤12/单行≤300 字符/命令超时 10s/输出≤2KB·20 行),内容完全开放:零内容枚举、零语言/框架绑定,清单行与命令由项目自定。未配置=两面任务书(轻量 thin / 厚档 thick)与历史形态逐字一致(零回归)。读键容错:坏条目跳过+告警、命令失败/超时跳过该行源,永不阻断任务书渲染。',
236
+ keys: [
237
+ { path: 'review_probes.id', type: 'string', optional: false, status: 'live', readers: ['parseReviewProbes (src/review-probes.js) → renderReviewerTaskbook (src/flow-review.js, thin) + buildReviewerTaskBook (src/review-dispatch.js, thick)'], desc: '探针标识(自由文本 ≤64 字符,非空)——注入行前缀「<id>|」,供评审逐条对答。', example: 'startup-smoke' },
238
+ { path: 'review_probes.scope', type: 'string', optional: true, status: 'live', readers: ['resolveProbeLines (src/review-probes.js)'], desc: '注入哪面任务书:thin(轻量评审任务书)/ thick(厚档 stage 评审)/ all(缺省)。这是 CLI 自有的两个任务书面集合,非内容枚举。', example: 'all' },
239
+ { path: 'review_probes.lines', type: 'array', optional: true, status: 'live', readers: ['resolveProbeLines (src/review-probes.js)'], desc: '静态清单行(自由文本,内容完全开放——项目自定必查面,如「变更触及启动路径时核对新增注册项与既有注册互斥」)。与 command 至少一非空。', example: '["新增配置键须与既有键冲突扫描核对"]' },
240
+ { path: 'review_probes.command', type: 'string', optional: true, status: 'live', readers: ['resolveProbeLines (src/review-probes.js) → defaultRunCommand (spawnSync, timeout 10s)'], desc: '可选命令串(任意 shell,stdout 非空行追加为清单行,带 <id>| 前缀)——动态行源(如生成当前项目的高危面清单);输出帽 2KB/20 行,失败/超时 fail-soft 跳过。', example: 'node scripts/list-review-probes.js' },
241
+ ],
242
+ },
232
243
  {
233
244
  id: 'friction_hint',
234
245
  title: '摩擦提示开关',
@@ -445,6 +456,15 @@ modules:
445
456
  decisions:
446
457
  behind_threshold: 10
447
458
 
459
+ # ── 项目评审探针(评审任务书的项目特有必查面注入——CLI 只装配不枚举,内容完全开放、语言/框架无关;
460
+ # thin=轻量评审任务书 / thick=厚档 stage 评审 / all=两面;未配置零注入。命令行源超时 10s、输出帽 2KB/20 行)──
461
+ review_probes:
462
+ - id: startup-smoke # 探针标识(自由文本,注入行前缀「<id>|」)
463
+ scope: all # thin | thick | all(缺省 all)
464
+ lines: # 静态清单行(自由文本——项目自定必查面)
465
+ - 变更触及启动路径时,核对新增注册项与既有注册互斥
466
+ # command: node scripts/list-review-probes.js # 可选动态行源(任意 shell,stdout 非空行追加)
467
+
448
468
  # ── 摩擦提示开关(quick/verify 收尾的摩擦信号一行 advisory:gate 回滚/验证失败/审查打回计数)──
449
469
  # 默认 true 开启;false 一键全关(计数与提示双停,.runtime 零写入)
450
470
  friction_hint:
@@ -802,6 +802,42 @@ export function detectWorktreeHealth(cwd) {
802
802
  }
803
803
  }
804
804
 
805
+ /**
806
+ * 跨仓 worktree 注册健康(坑 cross-wt-ghost-metadata 缺口③,fire-equipment 2026-10-10 实证):
807
+ * 跨仓仓 .git/worktrees/ 里「目录已删、注册残留」的幽灵注册对 cleanup/归档链天然隐身
808
+ * (meta.json 随目录同亡,主仓侧任何清单都寻址不到)——从 local.yaml repos 注册表各仓的
809
+ * 注册面反扫是唯一可见面。只读 fail-soft:无 local.yaml / 无 repos 段 / git 不可达 →
810
+ * skipped 注记不判红(可选能力缺失不是病);发现幽灵 → WARNING + doctor 动作指引。
811
+ */
812
+ export function detectCrossWorktreeHealth(cwd) {
813
+ const base = { name: 'cross_worktree_health', label: '跨仓 worktree 注册健康', safe_actions: [] }
814
+ try {
815
+ if (!existsSync(join(cwd, '.sillyspec', 'local.yaml'))) {
816
+ return { ...base, findings: ['无 local.yaml(无跨仓注册表)——skipped'], pass: true, severity: null, skipped: true }
817
+ }
818
+ const { scanCrossWorktreeGhosts } = require_wtc()
819
+ const scan = scanCrossWorktreeGhosts(cwd)
820
+ if (scan.repos.length === 0) {
821
+ return { ...base, findings: ['local.yaml 无 repos: 段(无跨仓仓)——skipped'], pass: true, severity: null, skipped: true }
822
+ }
823
+ const findings = [`已扫 ${scan.repos.length} 个跨仓仓注册面(worktree list 对照实体目录)`]
824
+ for (const g of scan.ghosts) {
825
+ findings.push(
826
+ `跨仓 ${g.repoKey} 幽灵 worktree 注册:${g.worktreePath}(目录不存在${g.locked ? `;条目 locked——prune 静默跳过,需先 git -C ${g.repoRoot} worktree unlock ${g.worktreePath}` : ''})`,
827
+ )
828
+ }
829
+ for (const e of scan.errors) findings.push(`跨仓 ${e.repoKey} 注册面不可达:${e.error}`)
830
+ if (scan.ghosts.length > 0) {
831
+ base.safe_actions.push({ action: 'sillyspec doctor --prune-cross-worktrees(默认 dry-run 预览,--confirm 执行 prune;locked 条目需先手动 unlock)', risk: 'low' })
832
+ return { ...base, findings, pass: false, severity: CHECK_SEVERITY.WARNING }
833
+ }
834
+ if (scan.errors.length > 0) return { ...base, findings, pass: false, severity: CHECK_SEVERITY.WARNING }
835
+ return { ...base, findings, pass: true, severity: CHECK_SEVERITY.PASSED }
836
+ } catch (e) {
837
+ return { ...base, findings: [`探测降级(${e?.message || e})——skipped`], pass: true, severity: null, skipped: true }
838
+ }
839
+ }
840
+
805
841
  /**
806
842
  * 构建环境(FR-02):node 版本 vs package.json engines.node(^/>=/= 前缀简化比较,
807
843
  * 复杂区间 unknown 不判红)+ 包管理器存在性。无 package.json → skipped 注记。
@@ -1270,6 +1306,25 @@ export function detectArchiveIntegrity(cwd, authoritySpecDir) {
1270
1306
  }
1271
1307
  }
1272
1308
  }
1309
+ // ── ⑤ 收口留痕锚点悬空(2026-10-12-close-trace-freeze-integrity FR-03):worktree 内收口的
1310
+ // change-patch.json 记 worktree 本地 SHA,apply 重放回主仓后原对象清理——baseline/head 悬空、
1311
+ // git diff 不可解析、归档溯源死链(multi-agent-platform ws-ref 五变更取证)。doctor 只报不治
1312
+ // (safe_actions 指路),修复入口 sillyspec patch-reanchor --change <名>(幂等)。
1313
+ const cpJsonPath = join(dir, 'change-patch.json');
1314
+ if (existsSync(cpJsonPath)) {
1315
+ try {
1316
+ const cpMeta = JSON.parse(readFileSync(cpJsonPath, 'utf8'));
1317
+ const anchorOk = (sha) => {
1318
+ if (!sha || !/^[0-9a-f]{40}$/i.test(String(sha))) return !sha; // 空锚不算悬空(无 git 基线形态)
1319
+ return !safeGit(cwd, ['cat-file', '-e', `${sha}^{commit}`]).error;
1320
+ };
1321
+ const headDangling = !anchorOk(cpMeta.head);
1322
+ const baseDangling = !anchorOk(cpMeta.baseline);
1323
+ if (headDangling || baseDangling) {
1324
+ reasons.push(`change-patch.json 锚点悬空(${[headDangling ? 'head' : null, baseDangling ? 'baseline' : null].filter(Boolean).join('/')} 在主仓不可解析——worktree 收口后未回写;修复:sillyspec patch-reanchor --change ${name})`);
1325
+ }
1326
+ } catch { /* change-patch.json 不可解析不计锚点欠账(归档核心件检查另有面) */ }
1327
+ }
1273
1328
  if (reasons.length > 0) offenders.push({ name, reasons });
1274
1329
  }
1275
1330
 
@@ -1463,6 +1518,10 @@ export function renderDoctorSummary(diagnostics) {
1463
1518
  import * as _wtm from './worktree.js'
1464
1519
  function require_wtm() { return _wtm }
1465
1520
 
1521
+ // worktree-cross.js 延迟引用(防环同上:跨仓 worktree 模块不感知 doctor)
1522
+ import * as _wtc from './worktree-cross.js'
1523
+ function require_wtc() { return _wtc }
1524
+
1466
1525
  /**
1467
1526
  * 门禁快照泄漏探测(task-04,2026-09-24-gate-snapshot-lifecycle / FR-03 / D-007@v1):
1468
1527
  * 账本内「守卫通过 ∧ 超 TTL(env/24h 单源)∧ pid 明确死」的条目即失活残留——门禁快照
@@ -1513,6 +1572,7 @@ export async function runDoctorDiagnostics({ cwd }) {
1513
1572
  const lifecycleDoc = detectLifecycleDocStaleness(cwd);
1514
1573
 
1515
1574
  const worktreeHealth = detectWorktreeHealth(cwd)
1575
+ const crossWorktreeHealth = detectCrossWorktreeHealth(cwd)
1516
1576
  const buildEnv = detectBuildEnv(cwd)
1517
1577
  const mcpEndpoints = detectMcpEndpoints(cwd)
1518
1578
  // task-03(2026-09-14-apply-conflict-hardening):apply-manifest 漂移(advisory,
@@ -1539,7 +1599,7 @@ export async function runDoctorDiagnostics({ cwd }) {
1539
1599
  } catch { gateLeakRuntimeRoot = null }
1540
1600
  const gateSnapshotLeak = detectGateSnapshotLeak({ runtimeRoot: gateLeakRuntimeRoot })
1541
1601
  const knowledgeGraphIntegrity = detectKnowledgeGraphIntegrity(cwd, authoritySpecDir || join(cwd, '.sillyspec'))
1542
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity];
1602
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity];
1543
1603
 
1544
1604
  return {
1545
1605
  dimensions,
package/src/flow-draft.js CHANGED
@@ -65,6 +65,27 @@ export const DESIGN_QUESTIONS = {
65
65
  * 占位句自带词表字样的误放行由 pending 检查((待撰写 前缀)独立拦截,不依赖本判据)。 */
66
66
  const FR_STRENGTH_RE = /\b(SHALL|MUST|SHOULD)\b|必须|禁止/
67
67
 
68
+ /** 去 whitespace 字符 bigram Jaccard 相似度(design 问题行回填定位——2026-10-13-design-question-autorepair)。
69
+ * 改述行由锚问题衍生、共享绝大多数字符对,天然高于任何答案行;≥0.5 视为可原位替换。 */
70
+ function charBigramSimilarity(a, b) {
71
+ const grams = (s) => {
72
+ const t = String(s || '').replace(/\s+/g, '')
73
+ const set = new Set()
74
+ for (let i = 0; i < t.length - 1; i++) set.add(t.slice(i, i + 2))
75
+ return set
76
+ }
77
+ const A = grams(a), B = grams(b)
78
+ let inter = 0
79
+ for (const g of A) if (B.has(g)) inter++
80
+ const union = A.size + B.size - inter
81
+ return union === 0 ? 0 : inter / union
82
+ }
83
+
84
+ /** design 问题行回填阈值:bigram 相似度 ≥ 此值定位到改述行(插入点=其上方),低于则节首插入。
85
+ * 回填只插入永不删除(评审 P1 清偿:引用问题原文的作答行相似度可胜过真改述行,任何替换式
86
+ * 回填都有静默销毁 agent 内容的面——插入式把「绝不删除」做成结构不变量,不再依赖阈值甄别)。 */
87
+ const QUESTION_REPAIR_SIM_THRESHOLD = 0.5
88
+
68
89
  /** sidecar 台账路径(.runtime 下,verify-draft sidecar 同族;命名空间=文件:段键)。 */
69
90
  export function draftLedgerPath(runtimeRoot, changeName) {
70
91
  return join(runtimeRoot, `draft-ledger-${changeName}.json`)
@@ -303,25 +324,66 @@ function draftTasksV2({ change, criteria, withTasks }) {
303
324
  export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
304
325
  const violations = []
305
326
  const advisories = []
327
+ const repairs = []
306
328
  const anchorCriteria = Array.isArray(ledger?.anchor?.criteria) ? ledger.anchor.criteria : []
307
329
  const read = (f) => {
308
330
  try { return readFileSync(join(changeDir, f), 'utf8').replace(/\r\n/g, '\n') } catch { return null }
309
331
  }
310
332
 
311
- // design 四节:问题在场(对锚逐字)+ 实质作答
333
+ // design 四节:问题在场(对锚逐字)+ 实质作答。问题行漂移(节标题在场)→ 机器按锚回填
334
+ // (2026-10-13-design-question-autorepair:sess_dcb57f25 实证 Write 整文件重写滑误——原文
335
+ // 3 秒前刚 Read 过仍被整段改述,事前提示是概率性防护;确定防线=锚对比,修被拦后的恢复面:
336
+ // 高相似改述原位替换、低相似/删除节首插入,agent 作答零丢失,repairs 留痕交 flow.js 渲染)。
312
337
  if (!skipDesign) {
313
338
  const dText = read('design.md')
314
339
  if (dText === null) violations.push('design.md 缺失(v2 起草件在案)——删除后重入 flow start 补生成')
315
340
  else {
316
341
  const anchorSections = ledger?.anchor?.designQuestions?.sections || DESIGN_QUESTIONS.sections
317
- const dLines = dText.split('\n')
342
+ let dLines = dText.split('\n')
343
+ // 回填通道:改写先到不了归档(回填后进归档的就是锚文本,与人工恢复等价),写盘失败降级
344
+ // 回带全文的拒收文案(不抛异常阻断 CLI)。
345
+ const allQuestionLines = new Set(anchorSections.flatMap((s) => s.lines))
346
+ const repairedLines = [...dLines]
347
+ const repairEntries = []
348
+ for (const sec of anchorSections) {
349
+ for (const q of sec.lines) {
350
+ if (repairedLines.some((l) => l.trim() === q)) continue
351
+ const hIdx = repairedLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
352
+ if (hIdx === -1) continue // 节整删 → 下方校验环拒收(文案带全文)
353
+ let end = repairedLines.findIndex((l, i) => i > hIdx && /^##\s/.test(l))
354
+ if (end === -1) end = repairedLines.length
355
+ let bestIdx = -1, bestSim = 0
356
+ for (let i = hIdx + 1; i < end; i++) {
357
+ const t = repairedLines[i].trim()
358
+ if (!t || allQuestionLines.has(t)) continue
359
+ const sim = charBigramSimilarity(t, q)
360
+ if (sim > bestSim) { bestSim = sim; bestIdx = i }
361
+ }
362
+ // 只插入不替换(评审 P1 清偿):高相似定位 → 插在改述行上方(原文与改述相邻、可读性
363
+ // 最好);未定位 → 节首。任何路径都不改动既有行——误定位的最坏结果是插入位置欠佳。
364
+ const insertAt = bestIdx >= 0 && bestSim >= QUESTION_REPAIR_SIM_THRESHOLD
365
+ ? bestIdx
366
+ : (repairedLines[hIdx + 1] === '' ? hIdx + 2 : hIdx + 1)
367
+ repairedLines.splice(insertAt, 0, q, '')
368
+ repairEntries.push({ heading: sec.heading, q })
369
+ }
370
+ }
371
+ if (repairEntries.length > 0) {
372
+ try {
373
+ writeAtomicSync(join(changeDir, 'design.md'), repairedLines.join('\n'))
374
+ dLines = repairedLines
375
+ for (const e of repairEntries) repairs.push(`design「${e.heading}」问题行曾被改写——已按锚回填原文(agent 作答保留):${e.q}`)
376
+ } catch {
377
+ for (const e of repairEntries) violations.push(`design「${e.heading}」问题文本被改写——原文:${e.q}(机器回填写盘失败,请手工恢复此行,答案写在下方)`)
378
+ }
379
+ }
318
380
  for (const sec of anchorSections) {
319
381
  if (!dLines.some((l) => l.trim() === `## ${sec.heading}`)) {
320
- violations.push(`design「${sec.heading}」节被删(标题缺失)——恢复小节与四问原文后作答`)
382
+ violations.push(`design「${sec.heading}」节被删(标题缺失)——恢复小节与四问原文后作答;问题原文:${sec.lines.join('/')}`)
321
383
  continue
322
384
  }
323
385
  for (const q of sec.lines) {
324
- if (!dLines.some((l) => l.trim() === q)) violations.push(`design「${sec.heading}」问题文本被改写:「${q.slice(0, 30)}…」——恢复原文(问题钉死,答案写在下方)`)
386
+ if (!dLines.some((l) => l.trim() === q)) violations.push(`design「${sec.heading}」问题文本被改写——原文:${q}(问题钉死,答案写在下方)`)
325
387
  }
326
388
  // 实质作答:节内非空行 - 标题行 - 问题行 - 引导行(> 开头)≥1
327
389
  const start = dLines.findIndex((l) => l.trim() === `## ${sec.heading}`)
@@ -332,7 +394,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
332
394
  const t = l.trim()
333
395
  return t && !qSet.has(t) && !t.startsWith('>') && !t.startsWith('##')
334
396
  })
335
- if (!answered) violations.push(`design「${sec.heading}」未作答——每节至少一行(小改动可写「不适用:<理由>」)`)
397
+ if (!answered) violations.push(`design「${sec.heading}」未作答——每节至少一行(小改动可写「不适用:<理由>」);问题原文:${sec.lines.join('/')}`)
336
398
  }
337
399
  }
338
400
  }
@@ -399,7 +461,7 @@ export function verifyThinDocsV2({ changeDir, ledger, skipDesign = false }) {
399
461
  const inFr = rText !== null && rText.includes(c)
400
462
  if (!inFr) advisories.push(`成功标准第 ${i + 1} 条原文未在 requirements 见到(FR 标题锚漂移嫌疑——若属有意改写请核对语义未失真):${c.slice(0, 40)}${c.length > 40 ? '…' : ''}`)
401
463
  }
402
- return { violations, advisories }
464
+ return { violations, advisories, repairs }
403
465
  }
404
466
 
405
467
 
@@ -14,6 +14,9 @@
14
14
  * (2026-10-09-close-trace-single-set)——thin flow done 与 heavy execute --done 共用
15
15
  * 一处只写 change.patch + change-patch.json 两件(对账面并入 scopeAudit 子对象;
16
16
  * 此前 2026-10-07-unify-close-trace 的四件双轨形态收口为单套,旧名读侧兼容兜存量)。
17
+ * ⑤⑥⑦ evaluateModuleGate / appendChangeIndexEntries / renderThinDocsDebt
18
+ * (2026-10-12-thin-module-gate-and-feed 三档):未登记目录软门判定(拦但可签收)、
19
+ * 命中模块变更索引机械回写(sidecar 优先、幂等)、start 注入面欠账事实行(提示前移)。
17
20
  */
18
21
  import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
19
22
  import { join, relative } from 'node:path'
@@ -21,6 +24,7 @@ import { createHash } from 'node:crypto'
21
24
  import { execFileSync } from 'node:child_process'
22
25
  import { splitOwnVsForeignDiffFiles, commitAttributionForChange, isForeignByCommit, parseChangeNamesFromSubject } from './foreign-declared.js'
23
26
  import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
27
+ import { computeDocsDebt } from './docs-debt.js'
24
28
  import { writeAtomicSync } from './fs-atomic.js'
25
29
  import { readV2SectionAnswer } from './flow-review.js'
26
30
  import { DESIGN_QUESTIONS } from './flow-draft.js'
@@ -105,6 +109,142 @@ export function reconcileModuleDocs({ cwd, specBase, ownFiles, committedRaw }) {
105
109
  return { lines: out, hits, modules, uncoveredDirs, moduleMaps: maps.map((m) => `docs/${m.project}/modules/_module-map.yaml`) }
106
110
  }
107
111
 
112
+ /**
113
+ * ⑤ evaluateModuleGate:未登记目录软门判定(2026-10-12-thin-module-gate-and-feed 一档)。
114
+ * 消费 reconcileModuleDocs 结构化面——纯判定零 IO;门语义=拦但可签收(--accept-unmapped 留痕
115
+ * acceptedUnmappedDirs,镜像 acceptedDirtyGap 先例):未登记目录是直接信号(文件在、图没认),
116
+ * 但存量仓有合法未登记面(根级脚本/配置),误报硬拦会训练忽略报警(docs-gate 既有裁决)——
117
+ * 签收制让每次放行成为审计面可见决策。绿地(无图)不拦:无图时 reconcileModuleDocs 空面短路,
118
+ * 强制登记等于替 scan 做模块划分。
119
+ * @param {{moduleScope: {moduleMaps?: string[], uncoveredDirs?: Array<{dir: string, files: number}>}|null,
120
+ * ignore?: string[]}} opts ignore=local.yaml module_gate_ignore(目录前缀,posix)
121
+ * @returns {{block: boolean, dirs: Array<{dir: string, files: number}>, maps: string[]}}
122
+ */
123
+ export function evaluateModuleGate({ moduleScope, ignore } = {}) {
124
+ const scope = moduleScope || {}
125
+ const maps = Array.isArray(scope.moduleMaps) ? scope.moduleMaps : []
126
+ const ig = (Array.isArray(ignore) ? ignore : [])
127
+ .map((s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').trim()).filter(Boolean)
128
+ const dirs = (Array.isArray(scope.uncoveredDirs) ? scope.uncoveredDirs : [])
129
+ .map((d) => ({ dir: String((d && d.dir) || '').replace(/\\/g, '/').replace(/\/+$/, ''), files: Number(d && d.files) || 0 }))
130
+ .filter((d) => d.dir)
131
+ .filter((d) => !ig.some((p) => d.dir === p || d.dir.startsWith(p + '/')))
132
+ return { block: maps.length > 0 && dirs.length > 0, dirs, maps }
133
+ }
134
+
135
+ /**
136
+ * ⑥ appendChangeIndexEntries:命中模块变更索引机械回写(二档)。零语义判断——日期/变更名/
137
+ * 动机首行全是已知量,与 decision-distill 降 noAI 同性质。目标判定遵守 split-changelog 契约
138
+ * (modules.js):sidecar 在场优先追加 sidecar 勿写回卡;否则卡内「## 变更索引」节(存在才动,
139
+ * 节外逐字保留);两者皆无跳过。幂等去重=目标文本已含变更名即跳过(断点续跑防双写)。
140
+ * 行形态按目标既有体自适应(表格「| 日期 | 变更名 | 摘要 |」/ 列表「- 变更名 | 摘要」——
141
+ * module-changelog.js CHANGE_NAME_RE 两种都解析;空节按节标题「(表格…)」倾向表格)。
142
+ * @returns {{appended: string[], skipped: Array<{id: string, reason: string}>}} appended=specBase 相对 posix 路径
143
+ */
144
+ export function appendChangeIndexEntries({ specBase, modules, change, summary, date } = {}) {
145
+ const appended = []
146
+ const skipped = []
147
+ const safeSummary = String(summary || '').replace(/\|/g, '/').replace(/\s+/g, ' ').trim().slice(0, 60) || '轻量变更'
148
+ for (const m of (Array.isArray(modules) ? modules : [])) {
149
+ try {
150
+ if (!m || !m.id) continue
151
+ if (!m.doc || m.docMissing) { skipped.push({ id: m.id, reason: '无卡(未登记 doc 或卡缺失)' }); continue }
152
+ const cardAbs = join(specBase, String(m.doc).replace(/^\/+/, ''))
153
+ const sidecarAbs = cardAbs.replace(/\.md$/, '.changelog.md')
154
+ const useSidecar = existsSync(sidecarAbs)
155
+ const targetAbs = useSidecar ? sidecarAbs : (existsSync(cardAbs) ? cardAbs : null)
156
+ if (!targetAbs) { skipped.push({ id: m.id, reason: '无卡(doc 指向的文件不存在)' }); continue }
157
+ const text = readFileSync(targetAbs, 'utf8')
158
+ if (!useSidecar && !/^##\s*变更索引/m.test(text)) { skipped.push({ id: m.id, reason: '卡内无「## 变更索引」节' }); continue }
159
+ if (text.includes(change)) { skipped.push({ id: m.id, reason: '已记录(幂等跳过)' }); continue }
160
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
161
+ // 定位插入点与形态:sidecar=整文件体;卡=节内(下一个 ## 或 EOF 前)
162
+ let segStart = 0
163
+ let segEnd = lines.length
164
+ if (!useSidecar) {
165
+ segStart = lines.findIndex((l) => /^##\s*变更索引/.test(l))
166
+ if (segStart < 0) { skipped.push({ id: m.id, reason: '卡内无「## 变更索引」节' }); continue }
167
+ segStart += 1
168
+ segEnd = lines.length
169
+ for (let i = segStart; i < lines.length; i++) {
170
+ if (/^##\s/.test(lines[i])) { segEnd = i; break }
171
+ }
172
+ }
173
+ const body = lines.slice(segStart, segEnd)
174
+ const isTable = body.some((l) => /^\s*\|/.test(l)) || /^##\s*变更索引[^\n]*(表格/.test(lines[segStart - 1] || '')
175
+ const row = isTable ? `| ${date} | ${change} | ${safeSummary} |` : `- ${change} | ${safeSummary}`
176
+ let insertAt = segEnd
177
+ while (insertAt > segStart && lines[insertAt - 1].trim() === '') insertAt -= 1
178
+ // 空节(「(表格,初始为空)」模板):节标题后空一行再落行
179
+ if (insertAt === segStart && !useSidecar) insertAt = Math.min(segStart + 1, segEnd)
180
+ lines.splice(insertAt, 0, row)
181
+ writeFileSync(targetAbs, lines.join('\n'), 'utf8')
182
+ appended.push(String(m.doc).replace(/^\/+/, ''))
183
+ } catch (e) {
184
+ skipped.push({ id: (m && m.id) || '?', reason: `回写失败(fail-soft):${(e && e.message) || e}` })
185
+ }
186
+ }
187
+ return { appended, skipped }
188
+ }
189
+
190
+ /**
191
+ * ⑥b deriveRecordedChangeIndexTargets:断点续跑路径反查已回写目标(评审 P3 清偿)。distill
192
+ * 已 done 的重入轮次上进程内 changelogAppended 不跨轮——用与回写幂等去重同款判据
193
+ * (目标文本含变更名)从命中模块的卡/sidecar 反推文件集,供 extraDocs 精确入暂存。
194
+ * sidecar 含变更名优先记 sidecar(split-changelog 契约:已拆卡新条目只可能落 sidecar)。
195
+ * @returns {string[]} specBase 相对 posix 路径(与 appendChangeIndexEntries.appended 同口径)
196
+ */
197
+ export function deriveRecordedChangeIndexTargets({ specBase, modules, change } = {}) {
198
+ const out = []
199
+ for (const m of (Array.isArray(modules) ? modules : [])) {
200
+ try {
201
+ if (!m || !m.id || !m.doc || m.docMissing) continue
202
+ const docRel = String(m.doc).replace(/^\/+/, '')
203
+ const cardAbs = join(specBase, docRel)
204
+ const sidecarAbs = cardAbs.replace(/\.md$/, '.changelog.md')
205
+ if (existsSync(sidecarAbs)) {
206
+ if (readFileSync(sidecarAbs, 'utf8').includes(change)) out.push(docRel.replace(/\.md$/, '.changelog.md'))
207
+ continue
208
+ }
209
+ if (existsSync(cardAbs)) {
210
+ const txt = readFileSync(cardAbs, 'utf8')
211
+ if (/^##\s*变更索引/m.test(txt) && txt.includes(change)) out.push(docRel)
212
+ }
213
+ } catch { /* 单模块推导失败跳过(best-effort) */ }
214
+ }
215
+ return out
216
+ }
217
+
218
+ /**
219
+ * ⑦ renderThinDocsDebt:flow start 触碰命中模块的欠账事实行(三档——提示前移:动手前看到
220
+ * 「你要动的卡已落后」,比收口时警告有效)。computeDocsDebt(厚道 execute {DOCS_DEBT})同源
221
+ * 复用;只出 behind>0 或卡从未提交的模块(空债零输出);behind=null 降级注记不出(代理信号
222
+ * 不配噪音)。git 锚按 projectRoot;平台模式(specBase 在仓外)走 computeDocsDebt 既有降级。
223
+ * @returns {string[]} 空数组=无债/无图/全降级
224
+ */
225
+ export function renderThinDocsDebt({ specBase, projectRoot, changedFiles } = {}) {
226
+ const out = []
227
+ let maps = []
228
+ try { maps = collectModuleMaps({ cwd: projectRoot || specBase, specBase }) } catch { return out }
229
+ const rows = []
230
+ for (const map of maps) {
231
+ let r = null
232
+ try { r = computeDocsDebt({ projectRoot: projectRoot || specBase, specBase, projectName: map.project, changedFiles }) } catch { continue }
233
+ if (!r || !r.ok || !Array.isArray(r.entries)) continue
234
+ for (const e of r.entries) {
235
+ if (!e || !e.module) continue
236
+ if (e.neverCommitted === true) rows.push(` - ${e.module}:模块卡 ${e.doc || '?'} 从未提交(欠账最重)`)
237
+ else if (Number.isFinite(e.behind) && e.behind > 0) rows.push(` - ${e.module}:模块卡落后源码 ${e.behind} 个提交${e.doc ? `(${e.doc})` : ''}`)
238
+ }
239
+ }
240
+ if (rows.length > 0) {
241
+ out.push(`📚 模块卡欠账(behind 计数,advisory——本次若改行为/接口请顺带补卡,收口对账会再提醒):`)
242
+ out.push(...rows.slice(0, 5))
243
+ if (rows.length > 5) out.push(` (+${rows.length - 5} 条略)`)
244
+ }
245
+ return out
246
+ }
247
+
108
248
  /**
109
249
  * verify-result 机器回执(归档前合成,随变更目录留档)。
110
250
  */
@@ -189,9 +329,11 @@ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision,
189
329
 
190
330
  /**
191
331
  * patch 冻结面的提交面过滤(2026-09-27-tool-debt-cleanup 抽出为纯函数供单测锁定):
192
- * 非 .sillyspec/ 全留 + 本变更目录(治理工件)+ .sillyspec/docs/ 交付文档(dogfood 模块卡
193
- * 是交付物——2026-09-27-gate-docs-cleanup 评审 P2 实证旧口径把已提交模块卡漏出审计 patch)。
194
- * 他侧 .sillyspec/changes/**(quicklog/knowledge WIP)仍滤除。反斜杠归一。
332
+ * 非 .sillyspec/ 全留 + .sillyspec/docs/ 交付文档(dogfood 模块卡是交付物——2026-09-27-
333
+ * gate-docs-cleanup 评审 P2 实证旧口径把已提交模块卡漏出审计 patch)。
334
+ * .sillyspec/changes/** 全滤除,含本变更目录(2026-10-12-close-trace-freeze-integrity
335
+ * FR-01:工件随归档目录本体入库,patch 再冻是冗余且污染 totals、掩盖「可归属面为空」
336
+ * 哨兵;口径对齐 execute --done 沉淀资产面投影的 filterDeliverableFiles)。反斜杠归一。
195
337
  *
196
338
  * 提交事实归属补位(2026-10-05-diff-commit-attribution):现状「已提交即本变更的」前提是
197
339
  * 单会话——多会话共享仓 baseline..HEAD 混入他侧交付(评审 P2 实证:他会话 knowledge-stats
@@ -204,7 +346,7 @@ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision,
204
346
  export function filterCommittedFace(committedRaw, ownPrefix, opts = {}) {
205
347
  const base = committedRaw
206
348
  .map((f) => String(f).replace(/\\/g, '/'))
207
- .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith(ownPrefix) || p.startsWith('.sillyspec/docs/'))
349
+ .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith('.sillyspec/docs/'))
208
350
  try {
209
351
  const cwd = opts.cwd || process.cwd()
210
352
  const pm = /^\.sillyspec\/changes\/([^/]+)\/$/.exec(String(ownPrefix || ''))
@@ -301,6 +443,75 @@ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive
301
443
  return { files: [...committed], dirtyAdded: [], dirtyWarned: ownDirty }
302
444
  }
303
445
 
446
+ /**
447
+ * 会话专属 worktree 孤儿提交面扫描(2026-10-12-close-trace-freeze-integrity FR-02)。
448
+ *
449
+ * 事故形态(multi-agent-platform 2026-10-12-ws-ref-ws-name 取证):实现提交在会话 worktree
450
+ * 内完成但不在 flow done 时点 HEAD 的祖先链(reset/detach/平行分支)——baseline..HEAD 提交面
451
+ * 与 git status 脏面都看不到,冻结面静默缺实现(+38/-10 实现零留痕、归档后 apply 才找回)。
452
+ *
453
+ * 扫描面:本 worktree 的 HEAD reflog(linked worktree 独立 reflog,会话独占不污染)。取不在
454
+ * HEAD 祖先链、也不在 baseline 祖先链(flow start 前的既有历史不算缺口)的提交;其 patch-id
455
+ * 与 baseline..HEAD 任一提交等价(amend/rebase 残留同内容)视为已覆盖剔除;余者触及交付面
456
+ * 文件(非 .sillyspec/)即入缺口。只读不写,git 全失败返回空面(fail-safe)。
457
+ * 已知边界(钉行为见 test/close-trace-freeze-integrity.test.mjs「merge 提交自身不判」用例):
458
+ * merge 形态的孤儿提交经 `git show`(combined diff 常空)取不到文件面 → 自身不判;但其
459
+ * 侧支成员提交若发生在 HEAD 轨迹上(checkout 后 commit)reflog 仍可见、照判——盲区不掩盖
460
+ * 成员工作,留披露不静默。
461
+ *
462
+ * @param cwd 会话专属 worktree 根(调用方保证路径判定命中,本函数不复判)
463
+ * @param opts.baseline flow start 基线提交(不可解析时返回空面——区间无法建立)
464
+ * @returns {{ commits: string[], files: string[] }} commits 去重提交 SHA、files 并集交付文件(posix)
465
+ */
466
+ export function scanOrphanFace(cwd, { baseline } = {}) {
467
+ const empty = { commits: [], files: [] }
468
+ const git = (args, opts = {}) => {
469
+ try {
470
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, windowsHide: true, ...opts }).trim()
471
+ } catch { return null }
472
+ }
473
+ if (!baseline || !/^[0-9a-f]{4,40}$/i.test(baseline)) return empty
474
+ if (git(['cat-file', '-e', `${baseline}^{commit}`]) === null) return empty
475
+ const head = git(['rev-parse', 'HEAD'])
476
+ if (!head) return empty
477
+ // baseline..HEAD 提交集 + 各自 patch-id(等价覆盖面)
478
+ const inRange = String(git(['log', '--no-merges', '--format=%H', `${baseline}..${head}`]) || '')
479
+ .split('\n').map((s) => s.trim()).filter(Boolean)
480
+ const covered = new Set(inRange)
481
+ const patchIdOf = (commit) => {
482
+ const diff = git(['show', '--no-color', '--pretty=format:', '--patch', '--no-merges', commit])
483
+ if (!diff) return null
484
+ const out = git(['patch-id', '--stable'], { input: diff + '\n' })
485
+ return out ? out.split(/\s+/)[0] : null
486
+ }
487
+ const coveredIds = new Set()
488
+ for (const c of inRange) {
489
+ const pid = patchIdOf(c)
490
+ if (pid) coveredIds.add(pid)
491
+ }
492
+ // 本 worktree HEAD reflog 全轨迹(linked worktree 独立)
493
+ const reflogHashes = String(git(['reflog', 'show', '--format=%H', 'HEAD']) || '')
494
+ .split('\n').map((s) => s.trim()).filter(Boolean)
495
+ const isAncestor = (sha, of) => git(['merge-base', '--is-ancestor', sha, of], {}) !== null
496
+ const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
497
+ const orphanCommits = []
498
+ const orphanFiles = new Set()
499
+ for (const h of [...new Set(reflogHashes)]) {
500
+ if (covered.has(h)) continue
501
+ // exit code 0 = ancestor;execFileSync 抛非零 → 返回 null
502
+ if (isAncestor(h, head)) continue
503
+ if (isAncestor(h, baseline)) continue // flow start 前既有历史(worktree 建立期轨迹)
504
+ const pid = patchIdOf(h)
505
+ if (pid && coveredIds.has(pid)) continue // amend/rebase 等价残留
506
+ const files = String(git(['show', '--no-color', '--name-only', '--pretty=format:', '--no-merges', h]) || '')
507
+ .split('\n').map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean).filter(deliverable)
508
+ if (files.length === 0) continue // 只碰治理面的孤儿不入缺口
509
+ orphanCommits.push(h)
510
+ for (const f of files) orphanFiles.add(f)
511
+ }
512
+ return { commits: orphanCommits, files: [...orphanFiles].sort() }
513
+ }
514
+
304
515
  /**
305
516
  * thin 通道三态行构造(writeCloseTraceArtifacts 的 snapObj.rows 数据源,纯函数):
306
517
  * ownFiles 过 filterDeliverableFiles(与 scope-audit actual 侧同口径——治理工件目录不进