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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * review-probes.js — 项目评审探针(2026-10-12-review-probe-injection)。
3
+ *
4
+ * 定位:把「项目特有的评审必查面」从主代理手写检查单收归 CLI 单源注入——dogfood 实证
5
+ * (2026-10-10~12 fire-equipment 会话):三个生产缺陷(dva 双注册、MyBatis typeAliases
6
+ * 撞名、原型表头未实现)全部由用户报障发现,独立评审零捕获,根因是检查单由主代理手写、
7
+ * 主代理没想到的面无人查。
8
+ *
9
+ * 开放世界红线(用户 2026-10-12 定规):CLI 零内容枚举、零语言/框架绑定——探针是纯数据
10
+ * (local.yaml `review_probes` 段:id 自由文本 / lines 自由文本清单行 / command 任意 shell
11
+ * 命令,stdout 非空行追加),CLI 只做装配与容量治理。scope 枚举(thin|thick|all)是 CLI
12
+ * 自有的两个任务书面(轻量 renderReviewerTaskbook / 厚档 buildReviewerTaskBook),非内容
13
+ * 枚举。与 ceremony/docs-check 等 local.yaml 段同消费哲学:读键容错、缺省空、未配置零注入。
14
+ *
15
+ * 容量帽:防误配把任务书撑爆或命令拖死渲染——条目 ≤16、每条静态行 ≤12、单行 ≤300 字符、
16
+ * 命令超时 10s、命令输出 ≤2KB 且 ≤20 行;超帽截断并在 provenance 留痕(不静默吞)。
17
+ * fail-soft:坏条目跳过+告警、命令失败/超时跳过该行源——探针面永不阻断任务书渲染。
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { spawnSync } from 'node:child_process'
22
+ import yaml from 'js-yaml'
23
+
24
+ export const PROBE_LIMITS = {
25
+ MAX_PROBES: 16,
26
+ MAX_LINES_PER_PROBE: 12,
27
+ MAX_LINE_CHARS: 300,
28
+ COMMAND_TIMEOUT_MS: 10_000,
29
+ COMMAND_OUTPUT_MAX_BYTES: 2048,
30
+ COMMAND_OUTPUT_MAX_LINES: 20,
31
+ }
32
+
33
+ /** scope 合法面(CLI 自有任务书面集合——封闭小集,非内容枚举)。 */
34
+ const SCOPES = new Set(['thin', 'thick', 'all'])
35
+
36
+ const cleanLine = (s) => String(s ?? '').replace(/\s+$/, '')
37
+
38
+ /**
39
+ * 校验+归一探针声明(纯函数,零抛出)。
40
+ * @param {unknown} raw - local.yaml review_probes 段原值
41
+ * @returns {{ probes: Array<{id:string, scope:string, lines:string[], command:string|null}>, warnings: string[] }}
42
+ */
43
+ export function parseReviewProbes(raw) {
44
+ const warnings = []
45
+ if (raw == null) return { probes: [], warnings }
46
+ if (!Array.isArray(raw)) return { probes: [], warnings: ['review_probes 段非数组——整段忽略'] }
47
+ const probes = []
48
+ raw.slice(0, PROBE_LIMITS.MAX_PROBES).forEach((entry, i) => {
49
+ const label = `review_probes[${i}]`
50
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
51
+ warnings.push(`${label} 非对象——跳过`); return
52
+ }
53
+ const id = cleanLine(entry.id)
54
+ if (!id || id.length > 64) { warnings.push(`${label} id 缺失或超 64 字符——跳过`); return }
55
+ let scope = entry.scope == null ? 'all' : String(entry.scope)
56
+ if (!SCOPES.has(scope)) { warnings.push(`${label}(${id}) scope 非法(${scope},合法 thin|thick|all)——按 all 处理`); scope = 'all' }
57
+ const lines = Array.isArray(entry.lines)
58
+ ? entry.lines.map(cleanLine).filter((l) => l.length > 0)
59
+ : []
60
+ if (lines.length > PROBE_LIMITS.MAX_LINES_PER_PROBE) {
61
+ warnings.push(`${label}(${id}) lines ${lines.length} 行超帽 ${PROBE_LIMITS.MAX_LINES_PER_PROBE}——截断`)
62
+ lines.length = PROBE_LIMITS.MAX_LINES_PER_PROBE
63
+ }
64
+ for (const l of lines) {
65
+ if (l.length > PROBE_LIMITS.MAX_LINE_CHARS) {
66
+ warnings.push(`${label}(${id}) 单行超 ${PROBE_LIMITS.MAX_LINE_CHARS} 字符——截断`)
67
+ break
68
+ }
69
+ }
70
+ const linesCapped = lines.map((l) => l.length > PROBE_LIMITS.MAX_LINE_CHARS ? l.slice(0, PROBE_LIMITS.MAX_LINE_CHARS) : l)
71
+ const command = typeof entry.command === 'string' && entry.command.trim() ? entry.command : null
72
+ if (linesCapped.length === 0 && !command) { warnings.push(`${label}(${id}) lines 与 command 均空——跳过`); return }
73
+ probes.push({ id, scope, lines: linesCapped, command })
74
+ })
75
+ if (raw.length > PROBE_LIMITS.MAX_PROBES) {
76
+ warnings.push(`review_probes ${raw.length} 条超帽 ${PROBE_LIMITS.MAX_PROBES}——仅取前 ${PROBE_LIMITS.MAX_PROBES} 条`)
77
+ }
78
+ return { probes, warnings }
79
+ }
80
+
81
+ /**
82
+ * 装载 local.yaml 的 review_probes 段(IO,容错缺省空——无段/无文件/解析失败均零探针零阻断)。
83
+ * @returns {{ probes: Array, warnings: string[] }}
84
+ */
85
+ export function loadReviewProbes(specBase) {
86
+ try {
87
+ const p = join(specBase || '', 'local.yaml')
88
+ if (!existsSync(p)) return { probes: [], warnings: [] }
89
+ const doc = yaml.load(readFileSync(p, 'utf8'))
90
+ const section = doc && typeof doc === 'object' ? doc.review_probes : null
91
+ return parseReviewProbes(section)
92
+ } catch (e) {
93
+ return { probes: [], warnings: [`review_probes 装载失败(${e && e.message ? e.message : e})——按未配置处理`] }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 命令输出封帽(纯函数,评审 P3①② 清偿):字节帽按 UTF-8 实字节数(非 UTF-16 字符数——中文
99
+ * 场景旧口径实际可注入 ~3× 标称值);行帽 20;命令输出行同享静态行单行 300 字符帽。
100
+ * 多字节截断可能切在字符中间(Buffer subarray + utf8 解码产生替换符)——留痕已示截断,可接受。
101
+ */
102
+ export function capCommandOutput(raw) {
103
+ let out = String(raw ?? '')
104
+ const provenance = []
105
+ const bytes = Buffer.byteLength(out, 'utf8')
106
+ if (bytes > PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES) {
107
+ out = Buffer.from(out, 'utf8').subarray(0, PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES).toString('utf8')
108
+ provenance.push(`命令输出超 ${PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES}B 截断`)
109
+ }
110
+ let lines = out.split(/\r?\n/).map(cleanLine).filter((l) => l.length > 0)
111
+ if (lines.some((l) => l.length > PROBE_LIMITS.MAX_LINE_CHARS)) {
112
+ lines = lines.map((l) => (l.length > PROBE_LIMITS.MAX_LINE_CHARS ? l.slice(0, PROBE_LIMITS.MAX_LINE_CHARS) : l))
113
+ provenance.push(`命令输出单行超 ${PROBE_LIMITS.MAX_LINE_CHARS} 字符截断`)
114
+ }
115
+ if (lines.length > PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES) {
116
+ lines = lines.slice(0, PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES)
117
+ provenance.push(`命令输出超 ${PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES} 行截断`)
118
+ }
119
+ return { lines, provenance }
120
+ }
121
+
122
+ /** 缺省命令执行器:shell 执行 + 超时 + 输出帽(字节/行/单行三帽,超帽截断留痕)。 */
123
+ function defaultRunCommand(command) {
124
+ const r = spawnSync(command, { shell: true, timeout: PROBE_LIMITS.COMMAND_TIMEOUT_MS, encoding: 'utf8', windowsHide: true })
125
+ if (r.error || r.status !== 0) {
126
+ const why = r.error && r.error.code === 'ETIMEDOUT' ? `超时 >${PROBE_LIMITS.COMMAND_TIMEOUT_MS}ms` : `退出码 ${r.status ?? '?'}`
127
+ return { ok: false, warning: `命令失败(${why})`, lines: [] }
128
+ }
129
+ const { lines, provenance } = capCommandOutput(r.stdout)
130
+ return { ok: true, lines, provenance }
131
+ }
132
+
133
+ /**
134
+ * 解析某任务书面的探针清单行(纯函数,runCommand 注入可测)。
135
+ * @param {{ probes: Array, scope?: 'thin'|'thick', runCommand?: (cmd:string)=>{ok:boolean,lines:string[],warning?:string,provenance?:string[]} }} opts
136
+ * @returns {{ lines: string[], provenance: string[] }}
137
+ */
138
+ export function resolveProbeLines({ probes, scope = 'thin', runCommand = defaultRunCommand } = {}) {
139
+ const lines = []
140
+ const provenance = []
141
+ for (const p of probes || []) {
142
+ if (p.scope !== 'all' && p.scope !== scope) continue
143
+ for (const l of p.lines) lines.push(`${p.id}|${l}`)
144
+ if (p.command) {
145
+ const r = runCommand(p.command)
146
+ if (!r.ok) { provenance.push(`探针 ${p.id} ${r.warning || '命令失败'}——命令行源跳过`); continue }
147
+ for (const l of r.lines) lines.push(`${p.id}|${l}`)
148
+ for (const pv of r.provenance || []) provenance.push(`探针 ${p.id}:${pv}`)
149
+ }
150
+ }
151
+ return { lines, provenance }
152
+ }
153
+
154
+ /** 装配便捷面(调用点单行接线):装载→scope 过滤→行合并,warnings 汇含装载告警与帽留痕。 */
155
+ export function assembleProbeLinesForScope(specBase, scope) {
156
+ const { probes, warnings } = loadReviewProbes(specBase)
157
+ const { lines, provenance } = resolveProbeLines({ probes, scope })
158
+ return { lines, warnings: [...warnings, ...provenance] }
159
+ }
160
+
161
+ export default { parseReviewProbes, loadReviewProbes, resolveProbeLines, assembleProbeLinesForScope, capCommandOutput, PROBE_LIMITS }
@@ -353,22 +353,42 @@ export async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, p
353
353
  if (orphan.result === 'cleaned' || orphan.result === 'force-cleaned' || orphan.result === 'partial') {
354
354
  console.log(`🧹 归档清理孤儿 worktree 残留(meta 已注销): ${archiveChangeName}${orphan.residual?.length ? '(残留: ' + orphan.residual.join('; ') + ')' : ''}`)
355
355
  }
356
- return
357
- }
358
- const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
359
- if (check.hasChanges) {
360
- console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
361
- console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
362
- return
363
- }
364
- const cleanResult = wm.cleanup(archiveChangeName)
365
- if (cleanResult.residual?.length > 0) {
366
- console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
367
- console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
356
+ } else {
357
+ const check = meta.mode !== 'in-place-fallback' ? wm.hasUnappliedChanges(archiveChangeName) : { hasChanges: false }
358
+ if (check.hasChanges) {
359
+ console.warn(`⚠️ 归档时 worktree 仍有 ${check.changedFiles.length} 个未 apply 变更,保留 worktree`)
360
+ console.warn(` 确认不需要后手动清理: sillyspec worktree cleanup ${archiveChangeName} --force`)
361
+ } else {
362
+ const cleanResult = wm.cleanup(archiveChangeName)
363
+ if (cleanResult.residual?.length > 0) {
364
+ console.warn(`⚠️ 归档 worktree 清理残留: ${cleanResult.residual.join('; ')}`)
365
+ console.warn(` 手动处理: sillyspec worktree cleanup ${archiveChangeName} --force`)
366
+ }
367
+ }
368
368
  }
369
369
  } catch (e) {
370
370
  console.warn(`⚠️ 归档 worktree 清理失败(不阻断归档): ${e.message}`)
371
371
  }
372
+ // ── 跨仓兜底(坑 cross-wt-ghost-metadata 缺口②,fire-equipment 2026-10-10 实证):此前
373
+ // 归档链只清主仓,跨仓 worktree 清理只挂在 apply/execute reset/手工 cleanup 四个时机,
374
+ // 归档作为最后一道收口不补刀——apply 侧任何一次 partial,跨仓注册/目录就永久滞留。
375
+ // 此处对齐主仓语义收口:force=false(有未 apply 交付的跨仓仓保留并警告),幂等,fail-open
376
+ // 不阻断归档。目录不在的幽灵注册由 cleanupCrossWorktrees 双清复核兜(缺口①),doctor
377
+ // --prune-cross-worktrees 是终网(缺口③)。──
378
+ try {
379
+ const { cleanupCrossWorktrees } = await import('../worktree-cross.js')
380
+ const cross = cleanupCrossWorktrees({ cwd, changeName: archiveChangeName, specBase, force: false })
381
+ for (const r of cross.results) {
382
+ if (r.result === 'cleaned') {
383
+ console.log(`🧹 归档兜底清理跨仓 worktree(${r.repoKey}): ${(r.details || []).join('; ')}`)
384
+ } else if (r.result === 'partial') {
385
+ console.warn(`⚠️ 归档跨仓 worktree 清理 partial(${r.repoKey}): ${(r.details || []).join('; ')}`)
386
+ console.warn(` 处理: sillyspec worktree cleanup ${archiveChangeName} --force(有交付需 apply)或 sillyspec doctor --prune-cross-worktrees(悬空注册)`)
387
+ }
388
+ }
389
+ } catch (e) {
390
+ console.warn(`⚠️ 归档跨仓 worktree 兜底清理失败(不阻断归档): ${e.message}`)
391
+ }
372
392
  }
373
393
 
374
394
  /**
@@ -620,7 +640,7 @@ export function renderStageSourceSideMovesMessage(r) {
620
640
  * @param {{ cwd: string, specBase: string, destDir: string, destName: string }} opts
621
641
  * @returns {{ docsAdded: string[]|null, fallbackDocs: boolean }} docsAdded=null 表示走了目录级回退
622
642
  */
623
- export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName }) {
643
+ export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName, extraDocs = null }) {
624
644
  // changes 侧:本变更归档目录精确收窄(quick 轻量归档 closeSingleQuickLinkedChange 同款)
625
645
  safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
626
646
 
@@ -643,6 +663,19 @@ export function archiveNarrowedGitAdd({ cwd, specBase, destDir, destName }) {
643
663
  console.warn(`⚠️ knowledge 侧 add 失败(fail-soft,不阻断归档链):${e && e.message ? e.message : e}`)
644
664
  }
645
665
 
666
+ // extraDocs(2026-10-12-thin-module-gate-and-feed 二档):thin 变更索引回写文件精确入暂存
667
+ // (文件级 pathspec;与下方 module-impact 精确集/目录级回退对本文件集是幂等重复 add——
668
+ // 本通道不依赖回退路径存活,回退面未来收紧时回写暂存不受影响)。
669
+ if (Array.isArray(extraDocs) && extraDocs.length > 0) {
670
+ try {
671
+ const posixDocs = [...new Set(extraDocs.map((p) => String(p).replace(/\\/g, '/')))]
672
+ for (const batch of chunkPaths(posixDocs)) safeGit(cwd, ['add', '--', ...batch])
673
+ console.log(` 📖 模块变更索引回写入暂存:${posixDocs.length} 个文件`)
674
+ } catch (e) {
675
+ console.warn(`⚠️ 回写文件入暂存失败(fail-soft,不阻断归档链):${e && e.message ? e.message : e}`)
676
+ }
677
+ }
678
+
646
679
  // docs 侧:module-impact「更新结果」done 行 → 精确文件集。
647
680
  // 三态(对齐修复④要求):
648
681
  // - module-impact.md 无「## 更新结果」段 / 文件缺失 / 读取异常 → docPaths=null → 回退目录级
@@ -880,7 +913,7 @@ export function resolveArchiveCommitPathspecs({ changeName, stagedNameStatus, he
880
913
  return { pathspecs, dropped }
881
914
  }
882
915
 
883
- export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, destDir, skipApply = false, skipPlanCheck = false, deliverableFiles = null, docsIncrement = null } = {}) {
916
+ export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, destDir, skipApply = false, skipPlanCheck = false, deliverableFiles = null, docsIncrement = null, extraDocs = null } = {}) {
884
917
  const archiveDir = dirname(destDir) // 抽取补:原闭包变量(changes/archive),从 destDir 推导等价
885
918
  // ── 模块文档认领 advisory(资产三小件③,2026-09-23)──
886
919
  // flow done/burst 收口不产生模块文档增量(旧流程挂在 execute/verify 步骤)——交付面 ×
@@ -1013,7 +1046,7 @@ export async function runArchiveChain({ pm, cwd, specBase, changeName, srcDir, d
1013
1046
  // porcelain 实证)。basename(destDir) 与调用方 destDir 恒一致(flow.js 与 run archive 同构)。
1014
1047
  const destName = basename(destDir)
1015
1048
  try {
1016
- archiveNarrowedGitAdd({ cwd, specBase, destDir, destName })
1049
+ archiveNarrowedGitAdd({ cwd, specBase, destDir, destName, extraDocs })
1017
1050
  } catch (e) {
1018
1051
  console.warn(`⚠️ 归档窄化 git add 异常(不阻断归档,交付面靠下方探测块兜底/agent git status 核对): ${(e && e.message) || e}`)
1019
1052
  }
package/src/run/gates.js CHANGED
@@ -377,9 +377,16 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
377
377
  console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
378
378
  for (const { repoKey, meta: cm } of crossFailed) {
379
379
  console.error(` 跨仓 repo ${repoKey} 依赖未就绪(depsStatus=${cm.depsStatus || 'unknown'})——workdir: ${cm.worktreePath}`)
380
- if (cm.depsError) console.error(` 上次供给错误:${cm.depsError}`)
380
+ // 报错一次给全(2026-10-12-cli-friction-signal,dogfood 实证:首条阻断只有标题无明细、
381
+ // agent 二跑定位 + 最终两次手改内部 meta.json 才过门)——三要素 + 逃生口 + 防手改警告。
382
+ if (cm.depsError) {
383
+ const tail = String(cm.depsError)
384
+ console.error(` 上次供给错误(尾部 ≤200 字符):${tail.length > 200 ? '…' + tail.slice(-200) : tail}`)
385
+ }
386
+ console.error(` 状态文件:${join(cm.worktreePath || '', 'meta.json')}(勿手改——doctor 以实态校正,假状态会被下次校验打回)`)
381
387
  }
382
388
  console.error(' 修复:进上述跨仓 worktree 手动安装依赖(用该仓的包管理器),或重跑 sillyspec run execute 让 CLI 重试供给后 --done。')
389
+ console.error(' 正规逃生口:sillyspec worktree doctor --fix(按实态重供并回写 meta.json)。')
383
390
  if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
384
391
  process.exit(1)
385
392
  }
@@ -455,7 +462,15 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
455
462
  console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
456
463
  console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
457
464
  console.error(' 或在 worktree 内手动安装依赖后重试。')
458
- if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
465
+ if (meta?.depsError) {
466
+ const tail = String(meta.depsError)
467
+ console.error(` 上次供给错误(尾部 ≤200 字符):${tail.length > 200 ? '…' + tail.slice(-200) : tail}`)
468
+ }
469
+ // 状态文件路径 + 防手改(2026-10-12-cli-friction-signal):dogfood 实证 agent 被迫手改
470
+ // meta.json 假状态过门——路径透明 + 明示正规校正通道,堵死「改状态绕门」的路。
471
+ try {
472
+ if (wm && changeName) console.error(` 状态文件:${join(wm.getWorktreePath(changeName), 'meta.json')}(勿手改——doctor 以实态校正)`)
473
+ } catch { /* worktree 路径解析失败省略该行 */ }
459
474
  }
460
475
  if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
461
476
  process.exit(1)
package/src/run/shared.js CHANGED
@@ -445,6 +445,20 @@ export function resolveUninitCwdGate(command, { dir, specDir, platformFlags } =
445
445
  }
446
446
  }
447
447
  const gitRoot = safeGit(resolvedDir, ['rev-parse', '--show-toplevel']).value || null
448
+ // 跨仓标记指路(2026-10-12-cli-friction-signal):该仓被 register-repo 登记过(标记在 .git/ 内,
449
+ // registerRepoInLocalYaml 写入)→ block 态附 crossRepo 事实,index.js 门文案据此精确指回工作区
450
+ // (dogfood:agent 在跨仓子仓跑 CLI 三连跑错目录靠人工发现)。标记损坏/缺失按无标记零变化。
451
+ if (gitRoot) {
452
+ try {
453
+ const markerPath = join(gitRoot, '.git', 'sillyspec-crossrepo.json')
454
+ if (existsSync(markerPath)) {
455
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'))
456
+ if (m && typeof m.workspace === 'string' && m.workspace) {
457
+ return { verdict: 'block', gitRoot, crossRepo: { workspace: m.workspace, key: typeof m.key === 'string' ? m.key : null } }
458
+ }
459
+ }
460
+ } catch { /* 标记损坏按无标记 */ }
461
+ }
448
462
  return { verdict: 'block', gitRoot }
449
463
  }
450
464
 
@@ -168,21 +168,27 @@ export function detectBatchCheckCadence(events) {
168
168
  /**
169
169
  * 单拍勾选门决策(2026-09-29-batch-tick-gate,纯函数):检出单拍跳(batchTick 非 null)
170
170
  * 时的收口动作裁决。镜像豁免分支退役(2026-10-07-thin-tasks-v3——任务面统一为 agent
171
- * 工作分解,无镜像面)。四态(决策顺序即豁免优先序):
171
+ * 工作分解,无镜像面)。五态(决策顺序即豁免优先序):
172
172
  * 'silent' 无单拍跳;
173
173
  * 'bypass' --allow-batch-tick 显式旁路——留痕放行(同意门先例);
174
- * 'advisory' 机器代勾可解释整跳(autopilotTicked >= 跳幅——governance-autopilot 代勾是
175
- * 单拍多格机械写,非 agent 纪律面;代勾数不可解释最大跳时落到 reject——防「留
176
- * 一格给代勾补、自身大跳蹭豁免」的对抗绕过,二轮评审 P3-E);
177
- * 'reject' 非旁路、代勾解释不了整跳——agent 一把勾,拒收。
178
- * @param {{batchTick:object|null, nonMirrorCount?:number|null, allowBatchTick?:boolean, autopilotTicked?:number}} args
174
+ * 'advisory' 三路:哨兵面未知(fail-open 防误拒);哨兵证据完备(status=complete——
175
+ * 2026-10-13-batchtick-evidence-domain-hint:同形态 git 时点检查「一把勾模式」
176
+ * 因 token 证据齐放行,事件流门对齐该语义——证据是提交哈希锚定的客观面,
177
+ * 损害只剩 per-task 时间戳信号面;证据缺失形态不走此态,维持 reject);机器代勾
178
+ * 可解释整跳(autopilotTicked >= 跳幅——governance-autopilot 代勾是单拍多格机械写,
179
+ * 非 agent 纪律面;代勾数不可解释最大跳时落到 reject——防「留一格给代勾补、自身
180
+ * 大跳蹭豁免」的对抗绕过,二轮评审 P3-E);
181
+ * 'reject' 非旁路、证据不完备、代勾解释不了整跳——agent 一把勾,拒收。
182
+ * @param {{batchTick:object|null, nonMirrorCount?:number|null, allowBatchTick?:boolean, autopilotTicked?:number, evidenceComplete?:boolean}} args
179
183
  * nonMirrorCount 保留为哨兵面未知信号(null → fail-open advisory);镜像-only 语义已退役。
184
+ * evidenceComplete 缺省 false(additive——既有调用方零感知)。
180
185
  * @returns {{action:'silent'|'advisory'|'bypass'|'reject', reason:string}}
181
186
  */
182
- export function resolveBatchTickAction({ batchTick, nonMirrorCount = null, allowBatchTick = false, autopilotTicked = 0 } = {}) {
187
+ export function resolveBatchTickAction({ batchTick, nonMirrorCount = null, allowBatchTick = false, autopilotTicked = 0, evidenceComplete = false } = {}) {
183
188
  if (!batchTick) return { action: 'silent', reason: 'no-batch-tick' };
184
189
  if (allowBatchTick === true) return { action: 'bypass', reason: 'flag' };
185
190
  if (nonMirrorCount === null) return { action: 'advisory', reason: 'sentinel-unknown' };
191
+ if (evidenceComplete === true) return { action: 'advisory', reason: 'evidence-complete' };
186
192
  if (autopilotTicked >= batchTick.to - batchTick.from) return { action: 'advisory', reason: 'autopilot-ticked' };
187
193
  return { action: 'reject', reason: 'agent-batch-tick' };
188
194
  }
@@ -982,7 +982,7 @@ function validateAcceptanceMatrix(cwd, changeName, context = {}) {
982
982
  errors.push(
983
983
  `探针 7 验收×测试覆盖矩阵有 ${matrix.missingEvidence} 行证据缺失` +
984
984
  `(covered/covered-service/partial 证据须含测试锚点:\`.test.\` 文件名 / file:line / 反引号包裹的测试名;non-testable 证据须写一句理由):${list}。` +
985
- `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由。`
985
+ `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由(运行验证移交场景适用:构建回执+移交去向一句话)。`
986
986
  )
987
987
  }
988
988
 
@@ -1300,7 +1300,7 @@ export function judgeApiCoverageMatrix(args) {
1300
1300
  : `声明 ${N} 端点而 covered/covered-service 端点行仅 ${coveredCount}(解析零行降级,无法逐条列端点——按声明行拆出每端点行填判定)`
1301
1301
  errors.push(
1302
1302
  `接口验证覆盖矩阵覆盖不足:有效分母 ${validDenominator}(N=${N} − non-testable ${nonTestableCount}),covered+covered-service 分子 ${coveredCount}——缺覆盖端点:${list}。` +
1303
- `修复:补验证用例/冒烟步骤后把端点行改 covered 并填五形态锚点;端点行为由 service 层测试锁定的改 covered-service 并填测试锚点(\`.test.\` / file:line / 反引号);不适用端点改 non-testable 并写一句理由;确未覆盖的走「## 移交项(结构化)」承载并保持 partial/uncovered(FR-04)。`
1303
+ `修复:补验证用例/冒烟步骤后把端点行改 covered 并填五形态锚点;端点行为由 service 层测试锁定的改 covered-service 并填测试锚点(\`.test.\` / file:line / 反引号);不适用端点改 non-testable 并写一句理由(含运行验证移交场景:构建级验证已过、运行冒烟移交部署后执行——理由写构建回执+移交去向,该行不计入分母,勿硬凑 covered-service);确未覆盖的走「## 移交项(结构化)」承载并保持 partial/uncovered(FR-04)。`
1304
1304
  )
1305
1305
  }
1306
1306
  // covered-service 承接 advisory(D-001/R-01 第一版,只进 warnings 不进 errors):端点级与
@@ -15,12 +15,14 @@
15
15
 
16
16
  import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
17
17
  import { writeAtomicSync } from './fs-atomic.js'
18
- import { join } from 'path'
18
+ import { join, dirname } from 'path'
19
19
  import { createHash } from 'crypto'
20
20
  import jsYaml from 'js-yaml'
21
21
  import { VALID_VERDICTS, REVIEW_SCHEMA_VERSION } from './task-review.js'
22
22
  import { detectSpecDirTypo } from './spec-dir-typo.js'
23
23
  import { resolveRuntimeRoot, resolveSpecDir } from './run/shared.js'
24
+ import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
25
+ import { assembleProbeLinesForScope } from './review-probes.js'
24
26
 
25
27
  // 文档型 stage review 的合法 reviewType
26
28
  const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
@@ -136,6 +138,9 @@ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier,
136
138
  '## review.json 产物契约(CLI Stage Review Gate 将硬校验,以下为精确 schema —— 事前给的 == 事后查的)',
137
139
  '',
138
140
  '> 路径:`{SPEC_ROOT}/.runtime/stage-reviews/' + stageLbl + '-' + runIdLbl + '/review.json`(本次 reviewType=' + reviewType + ',主审查文档=' + mainDocRel + ')',
141
+ ...(changeDir
142
+ ? ['> **本次完整绝对路径(直接复制给审查子代理,勿手拼 runId/漏阶段前缀——坑 stage-review-path-prefix 2026-10-10 实证:marker 只有裸 runId,子代理按 runId 直写漏「<stage>-」前缀即错位)**: `' + join(dirname(dirname(changeDir)), '.runtime', 'stage-reviews', stageLbl + '-' + runIdLbl, 'review.json') + '`']
143
+ : []),
139
144
  '',
140
145
  '### 必填字段(validateStageReviewSchema 硬校验,缺任一即 schema 失败阻断)',
141
146
  '- `schemaVersion`: ' + REVIEW_SCHEMA_VERSION,
@@ -738,6 +743,51 @@ export function validateStageReviewWithAutoRefresh(opts) {
738
743
  return { result: recheck, autoRefreshed: recheck.ok, refreshedPath: reviewPath, previousErrors: result.errors, refreshOrdinal, hashDrift: `${oldHashShort}→${newHashShort}` }
739
744
  }
740
745
 
746
+ /**
747
+ * 厚档评审员任务书(agent 通道,2026-10-12-thick-taskbook-cli):可原样粘贴给干净上下文子代理
748
+ * 的完整派发 prompt——检查单生产权收归 CLI(dogfood 实证 fire-equipment 会话:主代理手写 9 份
749
+ * 派发 prompt,6 份带 leading 措辞——「本变更的修复:…」预设结论、「构建已跑过你无需重跑」
750
+ * 外包验证;三个生产缺陷全漏检的根因是检查单由主代理手写)。与 thin 面 renderReviewerTaskbook
751
+ * (flow-review.js)同哲学;平台通道任务书在 review-dispatch.js buildReviewerTaskBook(不动)。
752
+ * 契约复用 renderReviewJsonContract 单源渲染——禁止第二套手写 schema 摘要(防契约漂移;
753
+ * 其通道优先序段面向派发方,评审员读到无害)。
754
+ * 放本模块而非 review-dispatch:契约渲染器与校验器同模块,且 review-dispatch 静态 import
755
+ * 本模块(反向成环)。诚实边界:任务书仍由主代理复制转发——CLI 单源降 leading 概率,
756
+ * 不防恶意篡改(与 thin 同界)。
757
+ * @param {{ stage: string, changeDir: string, changeName?: string, reviewRunId?: string,
758
+ * reviewPath: string, probeLines?: string[]|null }} opts
759
+ * @returns {string}
760
+ */
761
+ export function renderStageReviewerTaskbook({ stage, changeDir, changeName, reviewRunId, reviewPath, probeLines = null }) {
762
+ const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
763
+ const reviewType = STAGE_REVIEW_TYPE[stage] || 'design'
764
+ const items = (REVIEW_CHECKLISTS[stage] || []).map((t, i) => ` ${i + 1}. ${t}`)
765
+ const probeSection = Array.isArray(probeLines) && probeLines.length > 0
766
+ ? ['', '【项目评审探针(local.yaml review_probes 注入——CLI 单源生产,项目特有面必查)】',
767
+ ...probeLines.map((l) => ` - ${l}`),
768
+ ' (逐条作答:pass 给证据锚点 / gap 给缺件 / fail 给违诺实证——与检查单同口径,未答=未审)']
769
+ : []
770
+ return [
771
+ `⚖️ 独立评审任务书 — ${stage} 阶段(agent 通道派发,原样复制给子代理——检查单生产权在 CLI,勿自行改写增删)`,
772
+ `══════════════════════════════════════`,
773
+ `【角色】你是独立评审员(干净上下文,未参与实现)——拿承诺对文档与代码,不信自述,逐条给证据锚点。`,
774
+ `【评审对象】${join(changeDir || '', mainDoc)}(reviewType=${reviewType}${changeName ? `,change=${changeName}` : ''})`,
775
+ ``,
776
+ `【检查单(逐条 pass / gap / fail + 证据锚点)】`,
777
+ ...items,
778
+ ...probeSection,
779
+ ``,
780
+ `【请求预算】硬帽 12 次——超帽即止;材料不足以作答的项如实标 cannot_verify 并列缺件,勿猜。`,
781
+ `【纪律】只读——禁止修改任何文件、禁止 git 写操作。`,
782
+ `【产物】把 review.json 写到:${reviewPath}`,
783
+ ` docHash 可占位 "TODO"——写完由主会话跑 sillyspec register-stage-review --change ${changeName || '<名>'} --stage ${stage} --refresh-hash 代填(保留 verdict/checklist)。`,
784
+ ``,
785
+ renderReviewJsonContract({ stage, changeDir, reviewRunId, tier: 'independent' }),
786
+ ``,
787
+ `【完成后】告知主会话重跑:sillyspec run ${stage} --done --change ${changeName || '<名>'}(断点续,已完成子步幂等跳过)`,
788
+ ].join('\n')
789
+ }
790
+
741
791
  /**
742
792
  * 打印 stage review 校验结果
743
793
  * @param {{ ok: boolean, errors: string[], warnings: string[] }} result
@@ -789,6 +839,25 @@ export function printStageReviewResult(result, context = {}) {
789
839
  if (result.errors.some(e => String(e).includes('docHash')) && changeName) {
790
840
  console.error(` docHash 失配修复:sillyspec register-stage-review --change ${changeName} --stage ${stage} --refresh-hash(主文档改版多阶段联动用 --all)`)
791
841
  }
842
+ // 厚档评审任务书(2026-10-12-thick-taskbook-cli):缺 review.json = 待派发形态——打印可原样
843
+ // 粘贴的完整任务书(thin 面同款时机:评审需要而未产)。条件性:仅缺件形态打印,docHash 失配/
844
+ // schema 错等其他失败不打印(防重复撞门噪音)。探针装配 fail-soft:specBase 由 runtimeRoot
845
+ // 父目录推导(平台模式 runtimeRoot 在外时 miss → 零注入),打印失败不影响 gate 原错误输出。
846
+ if (reviewRunId && runtimeRoot && changeName
847
+ && result.errors.some(e => String(e).includes('缺少') && String(e).includes('review.json'))) {
848
+ try {
849
+ const _probeAsm = assembleProbeLinesForScope(dirname(runtimeRoot), 'thick')
850
+ for (const _w of _probeAsm.warnings) console.error(` ⚠️ review_probes:${_w}`)
851
+ console.error(`\n 📋 评审员任务书(原样复制给干净上下文子代理——检查单生产权在 CLI,勿手写改写):`)
852
+ console.error(renderStageReviewerTaskbook({
853
+ stage, reviewRunId,
854
+ changeDir: join(dirname(runtimeRoot), 'changes', changeName),
855
+ changeName,
856
+ reviewPath: join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`, 'review.json'),
857
+ probeLines: _probeAsm.lines.length > 0 ? _probeAsm.lines : null,
858
+ }).split('\n').map((l) => ` ${l}`).join('\n'))
859
+ } catch { /* 任务书打印 best-effort:失败不影响 gate 原有输出与退出码 */ }
860
+ }
792
861
  }
793
862
 
794
863
  if (result.warnings.length > 0) {
@@ -596,6 +596,28 @@ async function tryImportSubcommandImpl(importPath, moduleName) {
596
596
  * @param {object} opts - { specDir }
597
597
  */
598
598
  export async function cmdKnowledge(args, dir, opts = {}) {
599
+ // --help/-h 全局拦截(坑:探索型调用 knowledge refresh --help 被当默认参数直接执行
600
+ // ——cmdRefresh 无参即全量重写 generated/,副作用写 34 文件。帮助请求在任何子命令
601
+ // 前消化,危险面子命令不再被误触发)。
602
+ if (args.includes('--help') || args.includes('-h')) {
603
+ const lines = [
604
+ '用法: sillyspec knowledge <子命令> [参数]',
605
+ '',
606
+ '子命令:',
607
+ ' search "<关键词>" 检索知识库(命中自动注入 prompt 的同源面)',
608
+ ' inspect <文件> 查看单个知识文件条目结构',
609
+ ' validate 校验知识库完整性(INDEX 锚点/注册/幽灵条目——纯读)',
610
+ ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',
611
+ ' stats 覆盖率/死条目统计(纯读)',
612
+ ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
613
+ ' propose / classify / inbox / graph / stats',
614
+ '',
615
+ '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
616
+ ]
617
+ for (const l of lines) console.log(l)
618
+ output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats'] })
619
+ return
620
+ }
599
621
  const subCommand = args[0] || ''
600
622
 
601
623
  switch (subCommand) {
@@ -17,7 +17,7 @@ const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).r
17
17
  import { join as pJoin, basename } from 'path'
18
18
  import { tmpdir } from 'os'
19
19
  import jsYaml from 'js-yaml'
20
- import { parseFileChangeList, pathMatches } from '../change-list.js'
20
+ import { parseFileChangeList, pathMatches, REPO_SECTION_HEADER_RE } from '../change-list.js'
21
21
  import { getRule } from '../stage-contract-spec.js'
22
22
  import { TASKCARD_PLACEHOLDERS } from '../taskcard-placeholders.js'
23
23
  import { validateScriptCommands } from './cmd-existence.js'
@@ -942,13 +942,10 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null, rep
942
942
  // design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
943
943
 
944
944
  /**
945
- * design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
946
- * 段头为 h2/h3,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀、(…)备注后缀
947
- * (`(跨仓,X1-X4)` 等,全/半角括号均可)与尾随空白/冒号——后缀不容忍时(用户实证
948
- * 2026-08-29)`### sillyspec 仓变更(跨仓,X1-X4)` 整段判 main、跨仓文件全量误报未覆盖。
949
- * 不命中(非仓变更段头)→ null。
945
+ * 「<repo> 仓变更」段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
946
+ * 正则已上移 change-list.js 导出(单一源,2026-10-13-h2-repo-heading-parse——thin 收口
947
+ * 对账解析器同步消费该段头形态),此处 import 使用;容忍语义见 change-list.js 处注释。
950
948
  */
951
- const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
952
949
 
953
950
  /**
954
951
  * 疑似段头宽匹配:标题行含「<token> 仓变更」字样(token 与仓变更间有空白)。
@@ -1566,6 +1563,44 @@ export function validateTaskcardsCli({ changeDir, projectRoot = null }) {
1566
1563
  errors.push(`${id}: target_files 形态非法——${bad.map(b => `${b.raw}(${b.invalid})`).join(';')}`)
1567
1564
  }
1568
1565
  }
1566
+ // provides/expects_from 结构校验(坑 taskcard-validate-vs-postcheck-gap,2026-10-10
1567
+ // 实证:散文行 `- contract Foo fields [a, b]` 是合法 YAML 字符串数组,--validate 过、
1568
+ // 契约解析器静默过滤(无 contract 键即丢)→ plan --done 才报「provider 未声明」。
1569
+ // 在填卡自检轮暴露:字符串元素/缺 contract/fields·needs 非数组 = 形态错,给出映射式模板。
1570
+ const id2 = (content.match(/^id:\s*(.+)/m)?.[1] || '').trim() || f
1571
+ const fm = fmParse.fm || {}
1572
+ const _tpl = '映射式:- contract: <名> / fields: [a, b](expects_from 同构,needs 替 fields)'
1573
+ if (Array.isArray(fm.provides)) {
1574
+ for (const pe of fm.provides) {
1575
+ const bad = typeof pe === 'string'
1576
+ || !pe || typeof pe !== 'object'
1577
+ || typeof pe.contract !== 'string' || !pe.contract.trim()
1578
+ || (pe.fields !== undefined && !Array.isArray(pe.fields))
1579
+ if (bad) {
1580
+ errors.push(`${id2}: provides 条目形态非法(${typeof pe === 'string' ? `散文行「${pe.slice(0, 40)}」` : '缺 contract 键或 fields 非数组'})——${_tpl}`)
1581
+ break
1582
+ }
1583
+ }
1584
+ }
1585
+ if (fm.expects_from && typeof fm.expects_from === 'object' && !Array.isArray(fm.expects_from)) {
1586
+ for (const [prov, contracts] of Object.entries(fm.expects_from)) {
1587
+ if (!Array.isArray(contracts)) {
1588
+ errors.push(`${id2}: expects_from.${prov} 应为契约数组(映射式:- contract: <名>
1589
+ needs: [a, b])`)
1590
+ continue
1591
+ }
1592
+ for (const ce of contracts) {
1593
+ const bad = typeof ce === 'string'
1594
+ || !ce || typeof ce !== 'object'
1595
+ || typeof ce.contract !== 'string' || !ce.contract.trim()
1596
+ || (ce.needs !== undefined && !Array.isArray(ce.needs))
1597
+ if (bad) {
1598
+ errors.push(`${id2}: expects_from.${prov} 条目形态非法(${typeof ce === 'string' ? `散文行「${ce.slice(0, 40)}」` : '缺 contract 键或 needs 非数组'})——${_tpl}`)
1599
+ break
1600
+ }
1601
+ }
1602
+ }
1603
+ }
1569
1604
  }
1570
1605
  }
1571
1606
  } catch {