sillyspec 3.32.6 → 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/src/index.js CHANGED
@@ -85,6 +85,9 @@ SillySpec CLI — 规范驱动开发工具包
85
85
  cleanup <change> [--force] 清理 worktree
86
86
  doctor [--fix] [--stale-hours N] 健康检查 + 修复
87
87
  --cleanup-ghosts [--confirm] 归档幽灵 active 记录(db 有行无目录 / 空壳目录 0 文件超 7 天,SS-2/2b)
88
+ --prune-cross-worktrees [--confirm] 清跨仓仓幽灵 worktree 注册(目录已删注册残留——dry-run 预览,--confirm 执行 prune;locked 条目只提示)
89
+
90
+ sillyspec patch-reanchor --change <名> 归档收口留痕锚点回写主仓(worktree 收口后 apply 重放致 baseline/head 悬空的修复;幂等,doctor 检出时指路至此)
88
91
 
89
92
  sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
90
93
  sillyspec local register-repo <key> <path> 注册跨仓仓到 local.yaml repos: 段(外科手术式写入,其余内容保留)
@@ -410,6 +413,12 @@ async function main() {
410
413
  console.error(' 非 init/scan 类命令需要已初始化的项目根。');
411
414
  console.error(' 修复:① cd 回项目根(含 .sillyspec 的目录)再跑;② 新项目先 sillyspec init(棕地项目 sillyspec scan);③ 或 --spec-dir <路径> 显式指定 .sillyspec 位置。');
412
415
  console.error(' 查询/诊断类命令(status/doctor/progress/next/knowledge 等)不受此拦。');
416
+ // 跨仓精确指路(2026-10-12-cli-friction-signal):register-repo 登记过的子仓跑 CLI——
417
+ // 通用三出路之上叠加登记事实与工作区路径(dogfood:三连跑错目录靠人工发现)。无标记零变化。
418
+ if (uninitGate.crossRepo) {
419
+ console.error(` 🎯 此仓已登记为 SillySpec 工作区的跨仓${uninitGate.crossRepo.key ? `「${uninitGate.crossRepo.key}」` : ''}——工作区根:${uninitGate.crossRepo.workspace}`);
420
+ console.error(` 请回工作区根运行本命令,或 --dir "${uninitGate.crossRepo.workspace}" 显式指定。(标记:${uninitGate.gitRoot}/.git/sillyspec-crossrepo.json)`);
421
+ }
413
422
  process.exit(2);
414
423
  }
415
424
  if (uninitGate.verdict === 'pass' && uninitGate.anchor && resolve(dir) !== uninitGate.anchor) {
@@ -923,6 +932,9 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
923
932
  const tTask = tTaskIdx >= 0 && filteredArgs[tTaskIdx + 1] && !String(filteredArgs[tTaskIdx + 1]).startsWith("--") ? filteredArgs[tTaskIdx + 1] : null;
924
933
  const tNoteIdx = filteredArgs.indexOf('--note');
925
934
  const tNote = tNoteIdx >= 0 && filteredArgs[tNoteIdx + 1] && !String(filteredArgs[tNoteIdx + 1]).startsWith("--") ? filteredArgs[tNoteIdx + 1] : null;
935
+ // --evidence(2026-10-12-tick-evidence-multi-anchor):tick 自愿证据补录(自由文本,@路径 读文件)
936
+ const tEvidenceIdx = filteredArgs.indexOf('--evidence');
937
+ const tEvidence = tEvidenceIdx >= 0 && filteredArgs[tEvidenceIdx + 1] && !String(filteredArgs[tEvidenceIdx + 1]).startsWith("--") ? filteredArgs[tEvidenceIdx + 1] : null;
926
938
  if (!tChange) { console.error('❌ 缺 --change <变更名>'); process.exit(2); }
927
939
  if (taskSub !== 'list') {
928
940
  if (!tTask || !/^task-\d+$/.test(tTask)) { console.error('❌ --task 格式应为 task-NN'); process.exit(2); }
@@ -960,7 +972,7 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
960
972
  // ── 轻量勾选动词(2026-10-03-voluntary-task-tick):翻格+进度回显+下一任务指针——
961
973
  // 纯反馈闭环(watcher task-done 事件自动上平台),自愿使用;幂等,无 review/verdict 仪式。──
962
974
  const { runTaskTick } = await import('./task-tick.js')
963
- await runTaskTick({ changeName: tChange, cwd: dir, taskId: tTask, specBase: tSpecBase })
975
+ await runTaskTick({ changeName: tChange, cwd: dir, taskId: tTask, specBase: tSpecBase, evidence: tEvidence })
964
976
  } else if (taskSub === 'done') {
965
977
  // ── task done 四合一(r5l-forensic-verdict 方案 1 / 评审护栏#3):13 任务 × 4 连收尾
966
978
  // 往返(review write+勾选 / finish / wt-commit)合并为单进程串行 + 合并输出——每次省
@@ -2528,6 +2540,27 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
2528
2540
  console.log(` docHash 已与当前 ${result.mainDoc} 一致,跳过重写(幂等 no-op,不动文件)`);
2529
2541
  } else {
2530
2542
  console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
2543
+ // 派发前任务书出口(2026-10-12-thick-taskbook-cli):新生成骨架 = 待派发形态——打印
2544
+ // 可原样粘贴的完整评审任务书(检查单生产权在 CLI,治主代理手写 leading prompt)。
2545
+ // 条件性:--from adopt(评审已产)与 --refresh-hash(刷新既有)不打印;--json 面不混文案。
2546
+ if (!rsrFrom && !wantRefresh) {
2547
+ try {
2548
+ const { renderStageReviewerTaskbook } = await import('./stage-review.js');
2549
+ const { assembleProbeLinesForScope } = await import('./review-probes.js');
2550
+ const rsrSpecBase = rsrPlatformOpts.specRoot || join(dir, '.sillyspec');
2551
+ const _probeAsm = assembleProbeLinesForScope(rsrSpecBase, 'thick');
2552
+ for (const _w of _probeAsm.warnings) console.warn(`⚠️ review_probes:${_w}`);
2553
+ console.log(`\n📋 评审员任务书(原样复制给干净上下文子代理——检查单生产权在 CLI,勿手写改写):`);
2554
+ console.log(renderStageReviewerTaskbook({
2555
+ stage: st,
2556
+ changeDir: join(rsrSpecBase, 'changes', rsrChange),
2557
+ changeName: rsrChange,
2558
+ reviewRunId: result.reviewRunId,
2559
+ reviewPath: result.reviewPath,
2560
+ probeLines: _probeAsm.lines.length > 0 ? _probeAsm.lines : null,
2561
+ }));
2562
+ } catch { /* 任务书打印 best-effort:失败不影响注册主流程 */ }
2563
+ }
2531
2564
  }
2532
2565
  }
2533
2566
  } catch (e) {
@@ -2911,6 +2944,7 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
2911
2944
  // 执行流:--cleanup-remnant / --cleanup-ghosts / --gc-unstamped-runs / --dump-db(结构化诊断之外的修复/取证动作)
2912
2945
  const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
2913
2946
  const cleanupGhosts = filteredArgs.includes('--cleanup-ghosts');
2947
+ const pruneCrossWts = filteredArgs.includes('--prune-cross-worktrees');
2914
2948
  const gcUnstampedRuns = filteredArgs.includes('--gc-unstamped-runs');
2915
2949
  const dumpDbFlag = filteredArgs.includes('--dump-db');
2916
2950
  const doctorConfirm = filteredArgs.includes('--confirm');
@@ -3017,6 +3051,31 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
3017
3051
  process.exitCode = r.errors.length > 0 ? 1 : 0;
3018
3052
  break;
3019
3053
  }
3054
+ if (pruneCrossWts) {
3055
+ // 坑 cross-wt-ghost-metadata 缺口③(fire-equipment 2026-10-10 实证):跨仓仓 .git/worktrees/
3056
+ // 幽灵注册(目录已删、注册残留占分支)对 cleanup/归档链隐身(meta.json 随目录同亡)——
3057
+ // 本动作从注册面反扫清理。默认 dry-run,--confirm 才 prune;locked 条目只提示 unlock
3058
+ // 不自动解除(用户显式保护意图)。
3059
+ const { pruneCrossWorktreeGhosts } = await import('./worktree-cross.js');
3060
+ const r = pruneCrossWorktreeGhosts({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
3061
+ if (json) {
3062
+ console.log(JSON.stringify(r, null, 2));
3063
+ } else {
3064
+ console.log(
3065
+ doctorConfirm
3066
+ ? `👻 跨仓幽灵 worktree 注册清理(repos: ${r.repos_scanned ?? '—'}):${(r.pruned || []).length} 已 prune / ${(r.skipped_locked || []).length} locked 跳过 / ${(r.remaining || []).length} 残留`
3067
+ : `👻 跨仓幽灵 worktree 注册扫描(repos: ${r.repos_scanned ?? '—'}):待清理 ${r.count} 个(dry-run)`,
3068
+ );
3069
+ for (const g of (doctorConfirm ? r.pruned : r.ghosts) || []) console.log(` ${doctorConfirm ? '✅' : '-'} ${g.repoKey}: ${g.worktreePath}`);
3070
+ for (const g of (r.skipped_locked || [])) console.log(` 🔒 ${g.repoKey}: ${g.worktreePath}(locked——先 git -C ${g.repoRoot} worktree unlock ${g.worktreePath} 后重跑)`);
3071
+ const lockedPaths = new Set((r.skipped_locked || []).map((s) => s.worktreePath));
3072
+ for (const g of (r.remaining || []).filter((x) => !lockedPaths.has(x.worktreePath))) console.log(` ⚠️ ${g.repoKey}: ${g.worktreePath}(prune 后复扫仍在)`);
3073
+ for (const e of (r.errors || [])) console.log(` ❌ ${e.repoKey || ''}${e.repoRoot ? `(${e.repoRoot})` : ''}: ${e.error}`);
3074
+ if (!doctorConfirm && r.count > 0) console.log(`\n加 --confirm 执行 prune(git 只清指向不存在路径的注册,不动活 worktree、不碰分支)。`);
3075
+ }
3076
+ process.exitCode = (r.errors && r.errors.length > 0) ? 1 : 0;
3077
+ break;
3078
+ }
3020
3079
  if (gcUnstampedRuns) {
3021
3080
  // ql-20260908-006-5f04:无戳 execute-runs 一次性清扫。默认 dry-run,--confirm 才删。
3022
3081
  // 不进 archive 热路径(pruneArchivedChangeRuntime 故意不猜无戳 run)。
@@ -3999,6 +4058,40 @@ SillySpec worktree — git worktree 隔离管理
3999
4058
  await cmdAgentLog(filteredArgs.slice(1), { json, cwd: dir, specDir });
4000
4059
  break;
4001
4060
  }
4061
+ case 'patch-reanchor': {
4062
+ // 收口留痕锚点回写(2026-10-12-close-trace-freeze-integrity FR-03):worktree 内收口的
4063
+ // change-patch.json 记 worktree 本地 SHA,apply 重放回主仓后悬空——按提交主题归属推导
4064
+ // 主仓锚点区间回写 + change.patch 重冻结(幂等;doctor 检出悬空时指路本命令)。
4065
+ const prChangeIdx = filteredArgs.indexOf('--change');
4066
+ const prChange = prChangeIdx >= 0 && filteredArgs[prChangeIdx + 1] && !String(filteredArgs[prChangeIdx + 1]).startsWith('--') ? filteredArgs[prChangeIdx + 1] : null;
4067
+ if (!prChange) {
4068
+ console.error('用法: sillyspec patch-reanchor --change <名> [--spec-dir <路径>] [--json]');
4069
+ process.exit(2);
4070
+ }
4071
+ const specRootPr = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
4072
+ const { locateChangeDir, reAnchorChangePatch } = await import('./patch-reanchor.js');
4073
+ const changeDirPr = locateChangeDir(specRootPr, prChange);
4074
+ if (!changeDirPr) {
4075
+ console.error(`❌ 变更目录未找到(活跃区与归档区均无):${prChange}`);
4076
+ process.exit(1);
4077
+ }
4078
+ const r = reAnchorChangePatch({ projectRoot: dir, changeDir: changeDirPr, changeName: prChange });
4079
+ if (!r.ok) {
4080
+ console.error(`❌ 锚点回写未执行:${r.reason}`);
4081
+ process.exit(1);
4082
+ }
4083
+ if (json) {
4084
+ console.log(JSON.stringify({ schema_version: 1, change: prChange, noop: r.noop === true, baseline: r.baseline || null, head: r.head || null, rewritten: r.rewritten || [] }, null, 2));
4085
+ } else if (r.noop) {
4086
+ console.log(`✅ 锚点已可解析,无需回写(幂等 no-op):${prChange}`);
4087
+ } else {
4088
+ console.log(`📦 锚点已回写主仓:${prChange}`);
4089
+ console.log(` baseline → ${r.baseline.slice(0, 10)}(原 worktree 锚留档 worktreeBaseline)`);
4090
+ console.log(` head → ${r.head.slice(0, 10)}(原 worktree 锚留档 worktreeHead)`);
4091
+ console.log(` change.patch 已按主仓区间重冻结,patchSha256 重锚(${(r.rewritten || []).join('、')})`);
4092
+ }
4093
+ break;
4094
+ }
4002
4095
  case 'handoff': {
4003
4096
  // 阶段跑者瘦会话交接块(P1-5 v1,2026-09-20 对撞实验驱动):打印下一阶段新会话
4004
4097
  // 启动块(保持 SILLYSPEC_SESSION_ID)+ 状态摘要。纯只读。CLI prompt 自足——
@@ -15,7 +15,7 @@
15
15
  * `sillyspec local detect` / `platform connect` 各自补齐)。落盘走 writeAtomicSync
16
16
  * (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
17
17
  */
18
- import { existsSync, readFileSync, mkdirSync } from 'fs'
18
+ import { existsSync, readFileSync, mkdirSync, statSync } from 'fs'
19
19
  import { dirname, join } from 'path'
20
20
  import { writeAtomicSync } from './fs-atomic.js'
21
21
  import { withFileLock } from './quicklog.js'
@@ -58,9 +58,27 @@ export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
58
58
  const posixPath = String(repoPath).replace(/\\/g, '/')
59
59
  const entryLine = ` ${key}: ${posixPath}`
60
60
 
61
+ // 跨仓标记(2026-10-12-cli-friction-signal):注册成功后 best-effort 向目标仓 .git/ 内写
62
+ // 指路标记——uninit 门据此把「在子仓跑 CLI」精确指回工作区(dogfood:三连跑错目录靠人工
63
+ // 发现)。.git/ 内零工作区污染;linked worktree 的 .git 是文件则跳过;失败仅告警不拦注册。
64
+ const tryWriteMarker = () => {
65
+ try {
66
+ const gitDir = join(String(repoPath), '.git')
67
+ if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return
68
+ writeAtomicSync(join(gitDir, 'sillyspec-crossrepo.json'), JSON.stringify({
69
+ workspace: dirname(dirname(yamlPath)).replace(/\\/g, '/'),
70
+ key,
71
+ registeredAt: new Date().toISOString(),
72
+ }, null, 2) + '\n')
73
+ } catch (e) {
74
+ console.warn(`⚠️ 跨仓标记写入失败(${e && e.message ? e.message : e})——注册仍成功,未初始化门指路退化为通用文案`)
75
+ }
76
+ }
77
+
61
78
  const write = (text) => {
62
79
  mkdirSync(dirname(yamlPath), { recursive: true })
63
80
  writeAtomicSync(yamlPath, text)
81
+ tryWriteMarker()
64
82
  }
65
83
 
66
84
  const lines = raw.split('\n')
@@ -93,6 +111,7 @@ export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
93
111
  // 编辑器写入),内存归一后比对相等直接跳过 → 磁盘永不治愈 + CLI 报 ✅ 假成功 → 解析侧
94
112
  // (修复前)空 Map → MultiRepoContext 报「未注册」→ 死循环。幂等路径也落盘一次治愈。
95
113
  if (hadCr) write(lines.join('\n'))
114
+ tryWriteMarker() // 幂等重注册也补标记(标记被删后可经重注册恢复——uninit 门指路依赖)
96
115
  return { fileCreated: false, sectionCreated: false, replaced: false } // 值未变,幂等跳过
97
116
  }
98
117
  lines[i] = entryLine
@@ -0,0 +1,131 @@
1
+ /**
2
+ * patch-reanchor.js — 收口留痕锚点回写(2026-10-12-close-trace-freeze-integrity FR-03)。
3
+ *
4
+ * 事故形态(multi-agent-platform 2026-10-12 ws-ref 五变更取证):thin 流程在会话专属
5
+ * worktree(独立克隆/事后清理)内收口,change-patch.json 冻结的 baseline/head 是 worktree
6
+ * 本地 SHA;worktree apply 重放回主仓时 SHA 全部改写且原对象被清理——归档锚点悬空,
7
+ * `git diff baseline..head` 不可解析,归档溯源死链。
8
+ *
9
+ * 回写约定:按「提交主题含变更名」归属(仓内提交规范 `(<变更名>)` 后缀族,与
10
+ * filterCommittedFace 的 commitAttribution 同一约定),`git log --all --grep` 取归属提交链:
11
+ * head=拓扑末笔、baseline=首笔^;区间一致性(首笔^ 必须是 head 祖先、区间 diff 与留档
12
+ * files[] 至少一文件重叠)不过即 fail-closed 不改写。原 worktree 锚降级为
13
+ * worktreeBaseline/worktreeHead 溯源字段,patchSha256 按重冻正文重锚。
14
+ *
15
+ * 依赖边界:本模块被 worktree-apply.js 静态引入(apply 尾声自动回写),而 scope-audit.js
16
+ * 已静态引入 worktree-apply(评审 G3「内核绝不 import scope-audit」)——故本模块禁止
17
+ * import scope-audit/worktree-apply(防环),diff 采集自实现(pathspec 分块)。
18
+ */
19
+ import { createHash } from 'node:crypto'
20
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import { execFileSync } from 'node:child_process'
23
+ import { writeAtomicSync } from './fs-atomic.js'
24
+
25
+ function git(cwd, args, opts = {}) {
26
+ try {
27
+ return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, windowsHide: true, ...opts }).trim()
28
+ } catch { return null }
29
+ }
30
+
31
+ const resolves = (cwd, sha) => Boolean(sha) && /^[0-9a-f]{40}$/i.test(String(sha)) && git(cwd, ['cat-file', '-e', `${sha}^{commit}`]) !== null
32
+
33
+ /** pathspec 分块(命令行长度上限防御,与 worktree-apply.chunkPaths 同上限语义,本地实现防环) */
34
+ function chunkPaths(paths, maxChars = 8000) {
35
+ const chunks = []
36
+ let cur = []
37
+ let len = 0
38
+ for (const p of paths) {
39
+ if (cur.length > 0 && len + p.length + 1 > maxChars) { chunks.push(cur); cur = []; len = 0 }
40
+ cur.push(p); len += p.length + 1
41
+ }
42
+ if (cur.length > 0) chunks.push(cur)
43
+ return chunks
44
+ }
45
+
46
+ /**
47
+ * 定位变更目录:活跃区 → 归档区精确名 → 归档区 `<日期>-<名>` 后缀匹配(归档重命名形态)。
48
+ * @returns {string|null}
49
+ */
50
+ export function locateChangeDir(specBase, changeName) {
51
+ const active = join(specBase, 'changes', changeName)
52
+ if (existsSync(join(active, 'change-patch.json')) || existsSync(join(active, 'design.md'))) return active
53
+ const archiveExact = join(specBase, 'changes', 'archive', changeName)
54
+ if (existsSync(join(archiveExact, 'change-patch.json')) || existsSync(join(archiveExact, 'design.md'))) return archiveExact
55
+ try {
56
+ const archiveRoot = join(specBase, 'changes', 'archive')
57
+ if (existsSync(archiveRoot)) {
58
+ const suffix = `-${changeName}`
59
+ const hit = readdirSync(archiveRoot).filter((d) => d.endsWith(suffix)).sort()
60
+ if (hit.length === 1) return join(archiveRoot, hit[0])
61
+ }
62
+ } catch { /* 归档扫描失败走 null */ }
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * 归档锚点回写核心(幂等)。
68
+ * @param projectRoot 主仓根(锚点可解析性判定与重冻区间的仓)
69
+ * @param changeDir 变更目录(活跃或归档侧)
70
+ * @param changeName 变更名(提交主题归属 grep 锚)
71
+ * @returns {{ ok: boolean, noop?: boolean, baseline?: string, head?: string, reason?: string, rewritten?: string[] }}
72
+ */
73
+ export function reAnchorChangePatch({ projectRoot, changeDir, changeName }) {
74
+ const cpPath = join(changeDir, 'change-patch.json')
75
+ if (!existsSync(cpPath)) return { ok: false, reason: 'change-patch.json 不在案(本变更无收口冻结件)' }
76
+ let meta
77
+ try { meta = JSON.parse(readFileSync(cpPath, 'utf8')) } catch (e) { return { ok: false, reason: `change-patch.json 不可解析:${e && e.message ? e.message.split('\n')[0] : e}` } }
78
+
79
+ const headOk = resolves(projectRoot, meta.head)
80
+ const baseOk = resolves(projectRoot, meta.baseline)
81
+ if (headOk && (baseOk || !meta.baseline)) return { ok: true, noop: true }
82
+
83
+ // 归属提交链:主题含变更名(提交规范后缀族);--all 覆盖主干/分支,date-order 保拓扑可读
84
+ const grepRaw = git(projectRoot, ['log', '--all', '--date-order', '--format=%H%x01%s', `--grep=${changeName}`])
85
+ if (!grepRaw) return { ok: false, reason: `主仓无可归属提交(git log --all --grep=${changeName} 空/失败)——锚点推导无据,fail-closed` }
86
+ const rows = grepRaw.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { const i = l.indexOf('\u0001'); return [l.slice(0, i), l.slice(i + 1)] })
87
+ if (rows.length === 0) return { ok: false, reason: `主仓无可归属提交——锚点推导无据,fail-closed` }
88
+ const oldest = rows[rows.length - 1][0]
89
+ const newest = rows[0][0]
90
+ const isAncestor = (a, b) => git(projectRoot, ['merge-base', '--is-ancestor', a, b]) !== null
91
+ if (!isAncestor(oldest, newest)) return { ok: false, reason: `归属提交链拓扑不一致(首笔 ${oldest.slice(0, 10)} 非末笔 ${newest.slice(0, 10)} 祖先)——疑似同名异源提交混入,fail-closed` }
92
+ const baseRaw = git(projectRoot, ['rev-parse', `${oldest}^`])
93
+ if (!baseRaw || !resolves(projectRoot, baseRaw)) return { ok: false, reason: `首笔归属提交无第一父(根提交形态)——baseline 不可推导,fail-closed` }
94
+ if (!isAncestor(baseRaw, newest)) return { ok: false, reason: `推导区间不一致(首笔^ 非 head 祖先)——fail-closed` }
95
+
96
+ // 区间与留档 files[] 至少一文件重叠(防错归属整链改写)
97
+ const rangeFiles = String(git(projectRoot, ['diff', '--name-only', '--no-color', baseRaw, newest]) || '')
98
+ .split('\n').map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean)
99
+ const recorded = (Array.isArray(meta.files) ? meta.files : []).map((f) => String(f).replace(/\\/g, '/'))
100
+ const overlap = rangeFiles.filter((f) => recorded.includes(f) || recorded.some((r) => r.startsWith(`${f}/`) || f.startsWith(`${r}/`)))
101
+ if (recorded.length > 0 && overlap.length === 0) {
102
+ return { ok: false, reason: `推导区间(${baseRaw.slice(0, 10)}..${newest.slice(0, 10)})与留档 files[] 零重叠——归属可疑,fail-closed` }
103
+ }
104
+
105
+ // 重冻正文:区间 diff 按 files[] pathspec 限面(重放后的主仓区间即归档真相)
106
+ const face = recorded.length > 0 ? recorded : rangeFiles
107
+ const parts = []
108
+ for (const chunk of chunkPaths(face)) {
109
+ const d = git(projectRoot, ['diff', '--no-color', baseRaw, newest, '--', ...chunk])
110
+ if (d) parts.push(d)
111
+ }
112
+ const patchBody = parts.length > 0 ? (parts.join('\n').endsWith('\n') ? parts.join('\n') : parts.join('\n') + '\n') : null
113
+ if (!patchBody) return { ok: false, reason: `主仓区间 diff 为空(${baseRaw.slice(0, 10)}..${newest.slice(0, 10)} × ${face.length} 文件)——重冻无据,fail-closed` }
114
+
115
+ const sha256 = createHash('sha256').update(patchBody.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
116
+ const rewritten = []
117
+ if (meta.baseline !== baseRaw) { meta.worktreeBaseline = meta.baseline ?? null; meta.baseline = baseRaw; rewritten.push('baseline') }
118
+ if (meta.head !== newest) { meta.worktreeHead = meta.head ?? null; meta.head = newest; rewritten.push('head') }
119
+ if (meta.scopeAudit && typeof meta.scopeAudit === 'object' && meta.scopeAudit.baseAnchor !== baseRaw) {
120
+ meta.scopeAudit = { ...meta.scopeAudit, baseAnchor: baseRaw }
121
+ rewritten.push('scopeAudit.baseAnchor')
122
+ }
123
+ meta.patchSha256 = sha256
124
+ meta.patchStatus = 'ok'
125
+ meta.reanchoredAt = new Date().toISOString()
126
+ meta.reanchorNote = `apply/回填后主仓重锚(原 worktree 锚悬空:${meta.worktreeBaseline ? String(meta.worktreeBaseline).slice(0, 10) : '—'}/${meta.worktreeHead ? String(meta.worktreeHead).slice(0, 10) : '—'};归属约定=提交主题含变更名)——2026-10-12-close-trace-freeze-integrity FR-03`
127
+ writeAtomicSync(cpPath, JSON.stringify(meta, null, 2) + '\n')
128
+ writeAtomicSync(join(changeDir, 'change.patch'), patchBody)
129
+ rewritten.push('change.patch', 'patchSha256')
130
+ return { ok: true, noop: false, baseline: baseRaw, head: newest, rewritten }
131
+ }
@@ -22,6 +22,7 @@ import { join, dirname } from 'node:path'
22
22
  import jsYaml from 'js-yaml'
23
23
  import { writeAtomicSync } from './fs-atomic.js'
24
24
  import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
25
+ import { assembleProbeLinesForScope } from './review-probes.js'
25
26
  import { CEREMONY_TIERS } from './ceremony-tier.js'
26
27
  import { withFileLock } from './quicklog.js'
27
28
  import {
@@ -161,15 +162,20 @@ export function extractReviewFromArtifacts(artifacts) {
161
162
  * docblock 规则)②review.json 契约(与 gate 同源)③期望产出方式(read_only worker:
162
163
  * 结论经 artifacts 返回,**不写任何文件**——落盘归 CLI)④铁律段(禁改文件/禁 git commit)。
163
164
  */
164
- export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority }) {
165
+ export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority, probeLines = null }) {
165
166
  const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
166
167
  const items = (REVIEW_CHECKLISTS[stage] || []).map((t, i) => `${i + 1}. ${t}`).join('\n')
167
168
  const contract = renderReviewJsonContract({ stage, changeDir, reviewRunId, tier: 'independent', channelPriority })
169
+ // 项目评审探针(2026-10-12-review-probe-injection):local.yaml 数据驱动注入,缺省零变化。
170
+ const probeSection = Array.isArray(probeLines) && probeLines.length > 0
171
+ ? ['', '## 项目评审探针(local.yaml 注入——逐条 pass/gap/fail + 证据锚点,未答=未审)', ...probeLines.map((l) => `- ${l}`)]
172
+ : []
168
173
  const workerPrompt = [
169
174
  `你是独立审查者(platform 通道派发)。审查对象:${changeDir ? join(changeDir, mainDoc) : mainDoc}`,
170
175
  '',
171
176
  '## 审查清单(逐条给 pass/gap/fail + 证据锚点)',
172
177
  items,
178
+ ...probeSection,
173
179
  '',
174
180
  contract,
175
181
  '',
@@ -285,7 +291,7 @@ export async function runReviewDispatch(opts = {}) {
285
291
  } catch { /* marker 写失败不阻断派发(status 回收时再补) */ }
286
292
  }
287
293
  const changeDir = join(specBase, 'changes', changeName)
288
- const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: runId, channelPriority: readReviewChannelPriority(cwd) })
294
+ const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: runId, channelPriority: readReviewChannelPriority(cwd), probeLines: probeLinesForThick(specBase) })
289
295
  const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd })
290
296
  const missionId = mission && mission.missionId
291
297
  if (!missionId) return { ok: false, reason: 'create_mission 未返回 missionId', guidance: renderFallbackGuidance(cwd) }
@@ -694,7 +700,7 @@ export async function dispatchShadowReview(opts = {}) {
694
700
  // 影子 runId 独立生成(R-06 隔离通道②:绝不读写主线 marker / stageReviewMarkerPath)
695
701
  const shadowRunId = generateShadowReviewRunId()
696
702
  const changeDir = join(specBase, 'changes', changeName)
697
- const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: shadowRunId, channelPriority: readReviewChannelPriority(cwd) })
703
+ const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: shadowRunId, channelPriority: readReviewChannelPriority(cwd), probeLines: probeLinesForThick(specBase) })
698
704
  const workerPrompt = book.workerPrompt + '\n(影子审查:本结论将由 CLI 回收到 stage-reviews-shadow/ 独立命名空间做轻/重对照记账,不影响主线任何判定。)\n'
699
705
  const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd: budget })
700
706
  const missionId = mission && mission.missionId
@@ -840,6 +846,15 @@ export async function dispatchShadowReview(opts = {}) {
840
846
 
841
847
  // task-06 消费契约预留(配置读取在该 task 落地;此处常量为缺省值单一来源)
842
848
  const DEFAULT_REVIEW_DISPATCH_CONFIG = { budget_usd: 1.0, stall_ms: 900_000, timeout_ms: 0 }
849
+ /** 厚档任务书探针装配(2026-10-12-review-probe-injection):失败/未配置返回 null(零注入零阻断)。 */
850
+ function probeLinesForThick(specBase) {
851
+ try {
852
+ const { lines, warnings } = assembleProbeLinesForScope(specBase, 'thick')
853
+ for (const w of warnings) console.warn(`⚠️ review_probes:${w}`)
854
+ return lines.length > 0 ? lines : null
855
+ } catch { return null }
856
+ }
857
+
843
858
  export function readReviewDispatchConfig(cwd) {
844
859
  try {
845
860
  const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml')
@@ -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 }