sillyspec 3.32.5 → 3.32.7

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.5",
3
+ "version": "3.32.7",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -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,
@@ -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 侧同口径——治理工件目录不进
@@ -27,6 +27,15 @@ const PROMISE_RE = /at[- ]least[- ]once|exactly[- ]once|不丢失|不重复|不
27
27
  /** adopted(头脑风暴预段)design 无槽时的机制词全文扫描(窄表防误报)。 */
28
28
  const MECHANISM_RE = /乱序|并发写|竞态|死锁|事务隔离|AbortSignal|多实例串|游标推进/
29
29
 
30
+ /** 盲维四问「自述不适用」整声明变体(2026-10-12-review-na-variant):两仓实测 12/228 实质
31
+ * 作答误升级——整体不适用声明写成「四问全 n/a:」「均不适用」「边界并发四问:不适用:」等
32
+ * 前缀而非「不适用」开头,被 /^不适用/ 机械判成实质作答(误伤样例:纯 CSS 变更 hover-polish
33
+ * 起子代理评审)。窄口径只认整声明开头:可选前缀 + 可选冒号 + 可选限定词 + 不适用|n/a;
34
+ * 逐问编号形态(「1. 乱序:n/a。…」)不聚合——该形态常含逐维实质推理,升级是设计内行为。
35
+ * 前缀声明语义与旧 /^不适用/ 同型(「不适用,但…」旧版本就按开头豁免),加宽词表不加新
36
+ * 语义维度;漏报兜底:豁免面 1/4 抽查采样 + 承诺词/diff 原语/edit_ratio 三路独立信号。 */
37
+ const NA_ANSWER_RE = /^(?:盲维四问|边界并发四问|四问)?\s*[::]?\s*(?:全部|均|皆|都|全)?\s*(?:不适用|n\/a\b)/i
38
+
30
39
  /** design 作答面否定语境消解(2026-10-05-review-promise-negation):盲维第 4 问作答讨论
31
40
  * 作用域风险时,「无串台面」「不会串台」「杜绝串台」等否定短语按字面命中 PROMISE_RE 一票
32
41
  * 升级(实测:两行 console.log 的变更被定档高危起子代理评审)。「串台」是名词性风险词——
@@ -151,13 +160,13 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
151
160
  if (/<!--\s*AGENT:槽3/.test(dText)) {
152
161
  const answer = readSlotAnswer(dText, /^<!--\s*AGENT:槽3/)
153
162
  if (!answer) exemptEvidence.push('盲维四问未作答(将由槽位门拦收)')
154
- else if (/^不适用/.test(answer)) exemptEvidence.push('盲维四问自述不适用')
163
+ else if (NA_ANSWER_RE.test(answer)) exemptEvidence.push('盲维四问自述不适用')
155
164
  else reasons.push('盲维四问有实质作答(存在时序/并发/切换/作用域风险面)')
156
165
  } else if (/^##\s*边界与并发/m.test(dText)) {
157
166
  // v2 纯 markdown(2026-10-04-thin-docs-v2):边界节正文作答判定(同 v1 三态语义)
158
167
  const answer = readV2SectionAnswer(dText, DESIGN_QUESTIONS_FALLBACK.sections[2].heading, DESIGN_QUESTIONS_FALLBACK)
159
168
  if (!answer) exemptEvidence.push('盲维四问未作答(将由 v2 工件校验拦收)')
160
- else if (/^不适用/.test(answer)) exemptEvidence.push('盲维四问自述不适用')
169
+ else if (NA_ANSWER_RE.test(answer)) exemptEvidence.push('盲维四问自述不适用')
161
170
  else reasons.push('盲维四问有实质作答(存在时序/并发/切换/作用域风险面)')
162
171
  } else if (MECHANISM_RE.test(dText)) {
163
172
  reasons.push('头脑风暴设计含并发/时序类机制词(adopted 扫描)')
@@ -195,7 +204,7 @@ export function classifyReviewNeed({ changeDir, input = '', patchText = null, ed
195
204
  * 印出完整 sha 并要求 review.json 的 reviewedAgainst 照抄;漂移重冻结据此区分「对着旧冻结面的
196
205
  * 过期评审」与「对着当前 HEAD 的刚完成评审」(实测竞态:复审 PASS 落盘 26 秒后被误隔离)。
197
206
  */
198
- export function renderReviewerTaskbook({ change, changeDir, head = null }) {
207
+ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLines = null }) {
199
208
  const headFull = String(head || '').trim()
200
209
  // 前轮 findings 注入(2026-10-07-wave-auto-adopt-review-dedup task-02):复审场景(前轮 FAIL/
201
210
  // 评审隔离重评)下任务书带上前轮清单——已报项只验修复与回归,重点找新增问题,勿整轮重报
@@ -252,6 +261,15 @@ export function renderReviewerTaskbook({ change, changeDir, head = null }) {
252
261
  ` 没有任何声明边界时写「无声明边界」。未裁决=未审。`,
253
262
  ` 4. 作答中的防护声明(如有):声称的防护机制在代码里真实存在且生效?`,
254
263
  ` 5. 生成物同步(如涉及):类型/契约文件与实现对齐?`,
264
+ // 项目评审探针(2026-10-12-review-probe-injection):local.yaml review_probes 数据驱动注入——
265
+ // 检查单生产权收归 CLI 的项目特有面(dogfood 实证:主代理手写清单没想到的面无人查)。
266
+ // 未配置零注入——本段缺省时任务书与历史形态逐字一致(零回归)。
267
+ ...(Array.isArray(probeLines) && probeLines.length > 0 ? [
268
+ ``,
269
+ `【项目评审探针(local.yaml review_probes 注入——CLI 单源生产,项目特有面必查)】`,
270
+ ...probeLines.map((l) => ` - ${l}`),
271
+ ` (逐条作答:pass 给证据锚点 / gap 给缺件 / fail 给违诺实证——与上方检查单同口径,未答=未审)`,
272
+ ] : []),
255
273
  ``,
256
274
  `【纪律】只读——禁止修改任何文件、禁止 git 操作;唯一产物是 review.json。`,
257
275
  // 代行惯例(坑 flow-done-sentinel-token-split ②,2026-10-08 实证):宿主无已注册子代理
@@ -263,7 +281,14 @@ export function renderReviewerTaskbook({ change, changeDir, head = null }) {
263
281
  `{`,
264
282
  ` "schemaVersion": 1, "change": "${change}", "reviewer": "subagent",`,
265
283
  ` "verdict": "PASS" | "FAIL",`,
266
- ` "reviewedAgainst": "<评审对象 HEAD 完整 sha——照抄任务书标注的 sha>",`,
284
+ // reviewedAgainst 双形态(2026-10-12-tick-evidence-multi-anchor):跨仓冻结面在场时对象
285
+ // 形态优先(各仓 rev-parse HEAD 可机核「对着哪个面评审」);单仓维持单字符串形态零变化。
286
+ ...(readCrossRepoKeys(changeDir) ? [
287
+ ` "reviewedAgainst": { "workspace": "<主仓 HEAD 完整 sha——照抄任务书标注>", "repos": { "<仓键>": "<该仓 git rev-parse HEAD 完整 sha>" } },`,
288
+ ` (跨仓冻结面在场——对象形态优先;仓键=change-patch.json repos 的 key,各仓 sha 在该仓根 rev-parse 取;字符串单 sha 形态仍兼容)`,
289
+ ] : [
290
+ ` "reviewedAgainst": "<评审对象 HEAD 完整 sha——照抄任务书标注的 sha>",`,
291
+ ]),
267
292
  ` "findings": [ { "severity": "P1|P2|P3", "title": "…", "evidence": "代码锚点+机理一句话", "location": "file:line" } ],`,
268
293
  ` "dimensionNotes": { "乱序": "ok|finding|n/a", "并发": "ok|finding|n/a", "切换": "ok|finding|n/a", "作用域": "ok|finding|n/a" },`,
269
294
  ` "reviewedAt": "<ISO 时间>"`,
@@ -274,10 +299,30 @@ export function renderReviewerTaskbook({ change, changeDir, head = null }) {
274
299
  return taskbook
275
300
  }
276
301
 
302
+ /** 锚 sha 形态(7-40 位 hex)。 */
303
+ const isAnchorSha = (v) => typeof v === 'string' && /^[0-9a-f]{7,40}$/i.test(v.trim())
304
+
305
+ /**
306
+ * 变更的跨仓冻结面仓键(无 change-patch.json / 无 repos / 不可读 → null=单仓形态)。
307
+ * 形状对齐 cross-repo patch 冻结件(repos[] 条目含 key)。
308
+ */
309
+ function readCrossRepoKeys(changeDir) {
310
+ try {
311
+ const cp = JSON.parse(readFileSync(join(changeDir, 'change-patch.json'), 'utf8'))
312
+ if (cp && Array.isArray(cp.repos)) {
313
+ const keys = cp.repos.map((x) => x && (x.key || x.repoKey)).filter(Boolean)
314
+ return keys.length > 0 ? keys : null
315
+ }
316
+ } catch { /* 单仓/不可读 → null */ }
317
+ return null
318
+ }
319
+
277
320
  /**
278
321
  * review.json 校验(三态:ok / schema 错误清单)。FAIL 或含 P1 由调用方拦截。
279
- * reviewedAgainst(评审对象锚,2026-10-06-review-anchor-and-negation):可选字段——缺省
280
- * 容忍(旧产物向后兼容),存在时须为 7-40 位 hex sha。
322
+ * reviewedAgainst(评审对象锚):可选字段——缺省容忍(旧产物向后兼容)。双形态
323
+ * (2026-10-12-tick-evidence-multi-anchor):字符串(既有,7-40 hex)或对象
324
+ * {workspace: sha, repos: {<key>: sha}}——跨仓交付的「对着哪个面评审」可机核;
325
+ * 仓键开放(local.yaml repos 注册表键空间,CLI 不枚举校验键集合)。
281
326
  */
282
327
  export function validateReviewJson(path) {
283
328
  let raw
@@ -287,9 +332,24 @@ export function validateReviewJson(path) {
287
332
  const errors = []
288
333
  if (r.schemaVersion !== 1) errors.push('schemaVersion 必须为 1')
289
334
  if (!['PASS', 'FAIL'].includes(r.verdict)) errors.push('verdict 必须为 PASS|FAIL')
290
- if (r.reviewedAgainst !== undefined
291
- && (typeof r.reviewedAgainst !== 'string' || !/^[0-9a-f]{7,40}$/i.test(r.reviewedAgainst.trim()))) {
292
- errors.push('reviewedAgainst 须为 7-40 位 hex sha(git rev-parse HEAD 输出,照抄任务书标注)')
335
+ if (r.reviewedAgainst !== undefined) {
336
+ if (typeof r.reviewedAgainst === 'string') {
337
+ if (!isAnchorSha(r.reviewedAgainst)) errors.push('reviewedAgainst 须为 7-40 位 hex sha(git rev-parse HEAD 输出,照抄任务书标注)')
338
+ } else if (r.reviewedAgainst && typeof r.reviewedAgainst === 'object' && !Array.isArray(r.reviewedAgainst)) {
339
+ if (!isAnchorSha(r.reviewedAgainst.workspace)) errors.push('reviewedAgainst 对象形态须含 workspace: 7-40 位 hex sha(主仓评审对象锚)')
340
+ if (r.reviewedAgainst.repos !== undefined) {
341
+ const repos = r.reviewedAgainst.repos
342
+ if (!repos || typeof repos !== 'object' || Array.isArray(repos)) {
343
+ errors.push('reviewedAgainst.repos 须为对象(仓键 → sha;仓键取 local.yaml repos 注册表键)')
344
+ } else {
345
+ for (const [k, v] of Object.entries(repos)) {
346
+ if (!isAnchorSha(v)) errors.push(`reviewedAgainst.repos.${k} 须为 7-40 位 hex sha(该仓 git rev-parse HEAD)`)
347
+ }
348
+ }
349
+ }
350
+ } else {
351
+ errors.push('reviewedAgainst 须为 7-40 位 hex sha 字符串,或 {workspace: sha, repos: {<仓键>: sha}} 对象')
352
+ }
293
353
  }
294
354
  if (!Array.isArray(r.findings)) errors.push('findings 必须为数组')
295
355
  else {
@@ -310,6 +370,9 @@ export function validateReviewJson(path) {
310
370
  * 落盘 26 秒后被「重冻结时刻在场=过期」口径误隔离)。reviewedAgainst 缺省/读失败/
311
371
  * 格式非法一律 false(fail-safe:宁可多评一次,不放行无锚证据)。
312
372
  * 匹配口径:双向前缀(reviewedAgainst 允许短 sha 形态,与完整 head 任一方向前缀命中即锚定)。
373
+ * 对象形态(2026-10-12-tick-evidence-multi-anchor)取 workspace 锚参与判定——主仓漂移
374
+ * 判定面不变;repos 各仓锚是记录面(跨仓 HEAD 逐机不可靠 rev-parse,不参与判定),
375
+ * workspace 缺失/非法判 false fail-safe。
313
376
  * @param {string} reviewPath - review.json 路径
314
377
  * @param {string} head - 当前完整 HEAD sha
315
378
  * @returns {boolean}
@@ -319,7 +382,9 @@ export function reviewAnchoredToHead(reviewPath, head) {
319
382
  if (!h) return false
320
383
  try {
321
384
  const r = JSON.parse(readFileSync(reviewPath, 'utf8'))
322
- const ra = typeof r?.reviewedAgainst === 'string' ? r.reviewedAgainst.trim().toLowerCase() : ''
385
+ const raRaw = r?.reviewedAgainst
386
+ const ra = typeof raRaw === 'string' ? raRaw.trim().toLowerCase()
387
+ : (raRaw && typeof raRaw === 'object' && typeof raRaw.workspace === 'string' ? raRaw.workspace.trim().toLowerCase() : '')
323
388
  if (!/^[0-9a-f]{7,40}$/.test(ra)) return false
324
389
  return h === ra || h.startsWith(ra) || ra.startsWith(h)
325
390
  } catch { return false }