sillyspec 3.33.2 → 3.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +4 -0
  2. package/AGENTS.md +1 -1
  3. package/package.json +1 -1
  4. package/src/config-known-failures.js +258 -0
  5. package/src/decision-distill.js +26 -5
  6. package/src/decision-redomain.js +5 -4
  7. package/src/design-facts.js +1 -1
  8. package/src/docs-check.js +8 -4
  9. package/src/doctor-diagnostics.js +89 -6
  10. package/src/flow-review-pack.js +35 -3
  11. package/src/flow-review.js +31 -8
  12. package/src/flow.js +6 -2
  13. package/src/fr-index.js +7 -3
  14. package/src/git-helper.js +82 -0
  15. package/src/handoff.js +4 -0
  16. package/src/hooks/worktree-guard.js +12 -3
  17. package/src/hot-cache.js +60 -0
  18. package/src/index.js +99 -7
  19. package/src/knowledge-graph.js +82 -2
  20. package/src/knowledge-match.js +18 -2
  21. package/src/knowledge-quicklog.js +17 -2
  22. package/src/knowledge-vector.js +5 -3
  23. package/src/machine-interface.js +34 -13
  24. package/src/progress/change-registry.js +15 -0
  25. package/src/progress/shared.js +4 -0
  26. package/src/progress/stage-machine.js +15 -2
  27. package/src/progress.js +5 -0
  28. package/src/redomain-unmapped.js +204 -0
  29. package/src/redomain.js +5 -3
  30. package/src/review-dispatch.js +109 -47
  31. package/src/run/bg-sync.js +4 -0
  32. package/src/run/code-face-key.js +4 -1
  33. package/src/run/command.js +20 -5
  34. package/src/run/complete.js +185 -14
  35. package/src/run/gates.js +31 -2
  36. package/src/run/prompt.js +9 -1
  37. package/src/run/shared.js +18 -1
  38. package/src/run/stage.js +22 -19
  39. package/src/run/verify-quality-scan.js +51 -0
  40. package/src/runtime-paths.js +3 -0
  41. package/src/sillyhub-mcp/client.js +11 -6
  42. package/src/spawn-settle.js +47 -0
  43. package/src/spec-sync.js +89 -3
  44. package/src/stages/brainstorm.js +1 -0
  45. package/src/stages/knowledge.js +25 -3
  46. package/src/timing.js +55 -0
  47. package/src/ui-visual.js +3 -0
  48. package/src/verify-postcheck.js +73 -4
  49. package/src/watcher.js +4 -0
  50. package/src/worktree-apply.js +67 -4
  51. package/src/worktree.js +80 -11
  52. package/src/yaml-scalar-unquote.js +43 -0
  53. package/templates/agents-instruction.md +1 -1
@@ -313,6 +313,12 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
313
313
  ` (逐条作答:pass 给证据锚点 / gap 给缺件 / fail 给违诺实证——与上方检查单同口径,未答=未审)`,
314
314
  ] : []),
315
315
  ``,
316
+ `【复跑口径(与 AGENTS 自验口径同源——2026-10-21-review-rerun-scope)】`,
317
+ ` - 评审实测按「变更测试面」复跑:新测试 + 直接受影响既有文件;多文件合并一次 node --test 跑(逐文件跑每次付 ~10s 启动税)。`,
318
+ ` - 全量套件禁止:ledger 收口实测已跑变更面、CI test job 与 pre-push 门(lint+docs gate)已覆其余面——评审内重复跑全量是三重保险费(2026-10-20 会话画像实证:单次评审内两轮全量 8.4min)。`,
319
+ ` - 材料包含 ledger 测试回执时以回执为基线抽验复核,勿整体重跑。`,
320
+ ` - 长输出落 \`.sillyspec/.runtime/logs/\` 日志回看尾部(tail -50),勿整段读入上下文。`,
321
+ ``,
316
322
  `【纪律】只读——禁止修改任何文件、禁止 git 操作;唯一产物是 review.json。`,
317
323
  // 代行惯例(坑 flow-done-sentinel-token-split ②,2026-10-08 实证):宿主无已注册子代理
318
324
  // 通道时无法照做「起子代理」——主会话按检查单代行自审可接受,reviewer 字段如实标注
@@ -341,8 +347,18 @@ export function renderReviewerTaskbook({ change, changeDir, head = null, probeLi
341
347
  return taskbook
342
348
  }
343
349
 
344
- /** 锚 sha 形态(7-40 位 hex)。 */
345
- const isAnchorSha = (v) => typeof v === 'string' && /^[0-9a-f]{7,40}$/i.test(v.trim())
350
+ /**
351
+ * 锚 sha 词元提取(2026-10-20-closeout-roundtrip-slim):字段含 7-40 位独立 hex 词元即通过,
352
+ * 返回归一化纯 sha(小写);无词元返回 null。评审代理常在 sha 后附实测注记(今晚两例被整串
353
+ * 校验拒收,各多一轮收口往返)——词元提取容忍注记形态。归一化只发生在内存对象/返回值,
354
+ * 不回写文件(校验器只读铁律)。误命中面(如连写分支名含 ≥7 位 hex 段)后果有限:
355
+ * reviewedAgainst 是溯源锚非门禁比对;风险评估见变更 design 风险1。
356
+ */
357
+ const extractAnchorSha = (v) => {
358
+ if (typeof v !== 'string') return null
359
+ const m = v.match(/(?<![0-9a-fA-F])[0-9a-fA-F]{7,40}(?![0-9a-fA-F])/)
360
+ return m ? m[0].toLowerCase() : null
361
+ }
346
362
 
347
363
  /**
348
364
  * 变更的跨仓冻结面仓键(无 change-patch.json / 无 repos / 不可读 → null=单仓形态)。
@@ -376,16 +392,22 @@ export function validateReviewJson(path) {
376
392
  if (!['PASS', 'FAIL'].includes(r.verdict)) errors.push('verdict 必须为 PASS|FAIL')
377
393
  if (r.reviewedAgainst !== undefined) {
378
394
  if (typeof r.reviewedAgainst === 'string') {
379
- if (!isAnchorSha(r.reviewedAgainst)) errors.push('reviewedAgainst 须为 7-40 位 hex sha(git rev-parse HEAD 输出,照抄任务书标注)')
395
+ const _anchor = extractAnchorSha(r.reviewedAgainst)
396
+ if (!_anchor) errors.push('reviewedAgainst 须为 7-40 位 hex sha(git rev-parse HEAD 输出,照抄任务书标注)')
397
+ else r.reviewedAgainst = _anchor
380
398
  } else if (r.reviewedAgainst && typeof r.reviewedAgainst === 'object' && !Array.isArray(r.reviewedAgainst)) {
381
- if (!isAnchorSha(r.reviewedAgainst.workspace)) errors.push('reviewedAgainst 对象形态须含 workspace: 7-40 位 hex sha(主仓评审对象锚)')
399
+ const _ws = extractAnchorSha(r.reviewedAgainst.workspace)
400
+ if (!_ws) errors.push('reviewedAgainst 对象形态须含 workspace: 7-40 位 hex sha(主仓评审对象锚)')
401
+ else r.reviewedAgainst.workspace = _ws
382
402
  if (r.reviewedAgainst.repos !== undefined) {
383
403
  const repos = r.reviewedAgainst.repos
384
404
  if (!repos || typeof repos !== 'object' || Array.isArray(repos)) {
385
405
  errors.push('reviewedAgainst.repos 须为对象(仓键 → sha;仓键取 local.yaml repos 注册表键)')
386
406
  } else {
387
407
  for (const [k, v] of Object.entries(repos)) {
388
- if (!isAnchorSha(v)) errors.push(`reviewedAgainst.repos.${k} 须为 7-40 位 hex sha(该仓 git rev-parse HEAD)`)
408
+ const _repo = extractAnchorSha(v)
409
+ if (!_repo) errors.push(`reviewedAgainst.repos.${k} 须为 7-40 位 hex sha(该仓 git rev-parse HEAD)`)
410
+ else repos[k] = _repo
389
411
  }
390
412
  }
391
413
  }
@@ -425,9 +447,10 @@ export function reviewAnchoredToHead(reviewPath, head) {
425
447
  try {
426
448
  const r = JSON.parse(readFileSync(reviewPath, 'utf8'))
427
449
  const raRaw = r?.reviewedAgainst
428
- const ra = typeof raRaw === 'string' ? raRaw.trim().toLowerCase()
429
- : (raRaw && typeof raRaw === 'object' && typeof raRaw.workspace === 'string' ? raRaw.workspace.trim().toLowerCase() : '')
430
- if (!/^[0-9a-f]{7,40}$/.test(ra)) return false
450
+ const raRawNorm = typeof raRaw === 'string' ? raRaw
451
+ : (raRaw && typeof raRaw === 'object' && typeof raRaw.workspace === 'string' ? raRaw.workspace : '')
452
+ const ra = extractAnchorSha(raRawNorm)
453
+ if (!ra) return false
431
454
  return h === ra || h.startsWith(ra) || ra.startsWith(h)
432
455
  } catch { return false }
433
456
  }
package/src/flow.js CHANGED
@@ -1921,6 +1921,7 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1921
1921
  console.error(` ① git commit 这些文件(信息尾缀带 (${change}) 或 (${change}),重跑本命令——冻结面自动并入`)
1922
1922
  console.error(` ② 全部归属本变更:flow done --change ${change} --freeze-dirty`)
1923
1923
  console.error(` ③ 接受审计缺口:flow done --change ${change} --accept-dirty-gap`)
1924
+ console.error(` 💡 快速通道:脏文件全属并行会话在途/运行残渣且不在本变更交付面时,直接重跑带 --accept-dirty-gap(显式 pathspec 提交面已隔离归属;属本变更的文件必须走①提交)`)
1924
1925
  reportMidFail('patch')
1925
1926
  process.exit(1)
1926
1927
  }
@@ -2395,8 +2396,11 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
2395
2396
  } catch { /* 归属面 fail-soft:装配失败零注入该子段 */ }
2396
2397
  let _packLines = null
2397
2398
  try {
2398
- const { assembleReviewPack } = await import('./flow-review-pack.js')
2399
- const _pack = assembleReviewPack({ changeDir, patchText, attribution: _attribution })
2399
+ const { assembleReviewPack, findLatestTestReceipt } = await import('./flow-review-pack.js')
2400
+ // ledger 测试回执(2026-10-21-review-rerun-scope):ledger 先于 review(SUBSTEPS 序),
2401
+ // verify-runs 最新本变更 test-result 作回执注入——评审从「自己重跑」变「回执基线抽验」。
2402
+ const _receipt = findLatestTestReceipt(join(specBase, '.runtime', 'verify-runs'), change)
2403
+ const _pack = assembleReviewPack({ changeDir, patchText, attribution: _attribution, testReceipt: _receipt })
2400
2404
  if (_pack && Array.isArray(_pack.lines) && _pack.lines.length > 0) _packLines = _pack.lines
2401
2405
  } catch { /* 材料包面 fail-soft:装配失败零注入 */ }
2402
2406
  console.log(renderReviewerTaskbook({ change, changeDir, head: taskbookHead, probeLines: _probeLines, packLines: _packLines }))
package/src/fr-index.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  joinKnowledgeFile,
30
30
  syncIndexRoutingLines,
31
31
  discoverModuleIndex,
32
+ FR_ROUTE_STRIP_TOKENS,
32
33
  } from './decision-distill.js';
33
34
 
34
35
  /** epoch:此前归档不检查/不索引(存量 93 份不回填——不伪造历史,D-007)。 */
@@ -594,10 +595,13 @@ export function indexRequirements({ changeDir, knowledgeRoot, headHash = '', del
594
595
  section: 'FR 需求索引',
595
596
  subdir: FR_DIR,
596
597
  // 伪域路由行追加裸段关键词(auto-backend 行同时吃 'backend' 命中——域名字面作为
597
- // 唯一路由键对 auto- 域过窄,2026-09-21 unmapped 大池路由失效的教训)
598
+ // 唯一路由键对 auto- 域过窄,2026-09-21 unmapped 大池路由失效的教训)。
599
+ // 泛词退役(FR-02,2026-10-21-knowledge-tier-denoise):FR|需求|承接 不再全体域共享
600
+ // (查询含泛词命中全部 fr 行=注入席位行序抽签病根);存量行 stripTokens 幂等剥离。
601
+ stripTokens: FR_ROUTE_STRIP_TOKENS,
598
602
  makeLine: (d) => d.startsWith('auto-')
599
- ? `- ${d}|${d.slice(5)}|FR|需求|承接 → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`
600
- : `- ${d}|FR|需求|承接 → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`,
603
+ ? `- ${d}|${d.slice(5)} → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`
604
+ : `- ${d} → [${FR_DIR}/${d}.md](${FR_DIR}/${d}.md)`,
601
605
  });
602
606
  }
603
607
 
package/src/git-helper.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { execFileSync } from 'node:child_process'
16
16
  import { existsSync, readdirSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
+ import { createHash } from 'node:crypto'
18
19
 
19
20
  /**
20
21
  * git C 风格引号路径解码:`\\` `\"` `\n` `\t` 与 `\NNN` 八进制**原始字节**。
@@ -146,6 +147,87 @@ export function git(cwd, args, opts = {}) {
146
147
  return trim ? value.trim() : value
147
148
  }
148
149
 
150
+ /**
151
+ * 批量对象存在性判定(2026-10-21-protocol-tax task-07 / FR-07)。
152
+ *
153
+ * 单次 `git cat-file --batch-check`(SHA 列表走 stdin)替代逐 SHA `cat-file -e`——
154
+ * Windows 单次 git spawn ~100-150ms,归档锚点校验在 681 归档仓实测串行 25s+,批量化后
155
+ * 一次 spawn 判完全部。探针形态 `<sha>^{commit}` 与旧逐查同构(tag/tree 对象不算 commit)。
156
+ *
157
+ * @param {string} cwd
158
+ * @param {string[]} shas 40-hex SHA 列表(大小写不敏感,返回键统一小写)
159
+ * @returns {Map<string, boolean>} sha → 是否为可解析 commit;整批失败返回空 Map
160
+ * (调用方查表 miss = false = 不可解析 = 悬空——与逐查失败同向 fail,不静默洗白)
161
+ */
162
+ export function batchCatFileExists(cwd, shas) {
163
+ const map = new Map()
164
+ if (!Array.isArray(shas) || shas.length === 0) return map
165
+ const probes = shas.map((s) => `${s}^{commit}`)
166
+ try {
167
+ const out = execFileSync('git', buildFullArgs(cwd, ['cat-file', '--batch-check']), {
168
+ encoding: 'utf8', timeout: 30000, maxBuffer: GIT_MAX_BUFFER,
169
+ input: probes.join('\n') + '\n',
170
+ env: gitChildEnv(), windowsHide: true,
171
+ })
172
+ for (const line of String(out).split('\n')) {
173
+ // 行形态:可解析 → git 回显**解析后裸 SHA**(不回显输入串)`<sha> commit <size>`;
174
+ // 不可解析 → 回显输入串 `<sha>^{commit} missing`。统一剥后缀取裸 SHA 作键。
175
+ const m = line.match(/^([0-9a-f]{40})(?:\^\{commit\})?\s+(\S+)/i)
176
+ if (m) map.set(m[1].toLowerCase(), m[2] === 'commit')
177
+ }
178
+ } catch { /* 整批失败:空表全 miss=悬空(同逐查失败语义) */ }
179
+ return map
180
+ }
181
+
182
+ /**
183
+ * 批量暂存区 blob 内容哈希(2026-10-21-protocol-tax task-07 / FR-07 配套)。
184
+ *
185
+ * 单次 `git cat-file --batch`(stdin 逐行 `:path`)替代逐文件 `git show :path`——apply
186
+ * 交付指纹漂移维度对最近 5 份 manifest × 全部文件逐个起进程,Windows spawn 税可观。
187
+ * 响应按输入顺序回流:成功 `<oid> blob <size>\n<content>\n`、缺失 `<ref> missing\n`;
188
+ * 内容与写侧同基(staged blob 即 LF 规范态),sha256 直算 content 字节。
189
+ *
190
+ * @param {string} cwd
191
+ * @param {string[]} paths 仓库相对路径列表
192
+ * @returns {Map<string, string|null>} path → staged 内容 sha256(null=未暂存/不可解析);
193
+ * 整批失败返回空 Map(调用方查表 miss=null=未暂存,与逐查 gitQuiet null 同语义)
194
+ */
195
+ export function batchStageBlobSha256(cwd, paths) {
196
+ const map = new Map()
197
+ if (!Array.isArray(paths) || paths.length === 0) return map
198
+ let out
199
+ try {
200
+ out = execFileSync('git', buildFullArgs(cwd, ['cat-file', '--batch']), {
201
+ encoding: 'buffer', timeout: 30000, maxBuffer: GIT_MAX_BUFFER,
202
+ input: paths.map((p) => `:${p}`).join('\n') + '\n',
203
+ env: gitChildEnv(), windowsHide: true,
204
+ })
205
+ } catch { return map }
206
+ const buf = Buffer.isBuffer(out) ? out : Buffer.from(String(out), 'binary')
207
+ let pos = 0
208
+ let idx = 0
209
+ while (pos < buf.length && idx < paths.length) {
210
+ const nl = buf.indexOf(0x0a, pos)
211
+ if (nl === -1) break
212
+ const header = buf.toString('utf8', pos, nl).trim()
213
+ pos = nl + 1
214
+ const parts = header.split(/\s+/)
215
+ if (parts[parts.length - 1] === 'missing') {
216
+ map.set(paths[idx], null)
217
+ idx++
218
+ continue
219
+ }
220
+ const size = Number.parseInt(parts[parts.length - 1], 10)
221
+ if (!Number.isFinite(size) || pos + size > buf.length) break // 形态意外:截断防错读
222
+ map.set(paths[idx], createHash('sha256').update(buf.subarray(pos, pos + size)).digest('hex'))
223
+ pos += size + 1 // 内容 + 尾随换行
224
+ idx++
225
+ }
226
+ // 尾部未覆盖的路径(流截断等)按未暂存处理——与逐查失败同向
227
+ for (; idx < paths.length; idx++) map.set(paths[idx], null)
228
+ return map
229
+ }
230
+
149
231
  /**
150
232
  * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
151
233
  * ENOBUFS(输出超 GIT_MAX_BUFFER 上限)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
package/src/handoff.js CHANGED
@@ -164,6 +164,10 @@ export async function buildHandoff({ cwd, specBase, changeName } = {}) {
164
164
  } else {
165
165
  lines.push('# (流程终态,无续跑命令)');
166
166
  }
167
+ // 所有权接缝预告(2026-10-21-p0-p1-hardening FR-05 / dsh z2 实证:新会话自拟新 SESSION_ID
168
+ // 而非沿用交接块导出的旧 ID 时,首个写操作必撞 15 分钟活跃窗被迫 --takeover):粘贴块内显式
169
+ // 给出出路,消「交接块没预告接管」的设计缺口。
170
+ lines.push('# ⚠️ 若换用新的 SILLYSPEC_SESSION_ID(不推荐,保持上方导出值即可):首个写操作(--done/--answer 等)会撞所有权活跃窗——等窗过期自动接管,或首写带 --takeover 显式接管留痕');
167
171
  lines.push('# ── 交接块结束 ──');
168
172
  lines.push('');
169
173
  lines.push(`📋 ${target}:当前 ${STAGE_LABELS[currentStage] || currentStage}${steps.length > 0 ? `(步骤 ${doneSteps}/${steps.length})` : ''}${stageCompleted ? ' ✅ 已完成' : ''}`);
@@ -67,11 +67,14 @@ const STAGE_HINTS = {
67
67
  ' - <可验证标准>"',
68
68
  ' 逻辑变更(skill sillyspec-brainstorm):sillyspec run brainstorm → plan → execute → verify → archive',
69
69
  ' 全自动模式(skill sillyspec-auto):sillyspec run auto "任务描述"',
70
+ '',
71
+ '若在头脑风暴阶段做真原型(变更 key 已出):sillyspec worktree create <变更名> 进隔离树写原型,',
72
+ '已在 worktree 内则复用当前目录不重复创建;文档照常主树(worktree 内运行 sillyspec 自动锚回主仓根)。',
70
73
  ],
71
74
  'brainstorm': [
72
- '当前在 brainstorm(需求分析)阶段,这个阶段只写文档,不写代码。',
73
- '完成 brainstorm 后,流程会自动推进到 plan → execute。',
74
- 'execute 阶段才允许写代码。',
75
+ '当前在 brainstorm(需求分析)阶段:spec 文档写主树;做真原型须先进隔离 worktree——变更 key 已出,运行 sillyspec worktree create <变更名>(已在 worktree 内则复用当前目录,不重复创建),原型代码写 worktree 内。',
76
+ '文档不受影响:worktree 内运行 sillyspec 会自动锚回主仓根,文档照常落主树。',
77
+ '完成 brainstorm 后,流程会自动推进到 plan → execute;execute 会复用已建的 worktree。',
75
78
  ],
76
79
  'plan': [
77
80
  '当前在 plan(计划制定)阶段,这个阶段只写计划文档和任务蓝图,不写代码。',
@@ -805,6 +808,12 @@ export function shouldBlockWrite(filePath, cwd) {
805
808
  // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
806
809
  if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
807
810
 
811
+ // 2.5 位置豁免(2026-10-21-brainstorm-proto-worktree):已注册 worktree(meta.json 登记)
812
+ // 是隔离区——路径落在其内即放行,不问阶段,与 Bash 侧 worktree-cwd 全放行对称。
813
+ // 头脑风暴期做真原型的协议出口:变更 key 已出 → sillyspec worktree create → 进树写原型。
814
+ // 未登记的 worktrees 存储区子目录不沾此豁免(下方阶段门照拦)。
815
+ if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
816
+
808
817
  if (!['execute', 'quick'].includes(stage)) {
809
818
  return {
810
819
  blocked: true,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * hot-cache.js — 跨进程热路径缓存小工具(2026-10-21-knowledge-tier-denoise FR-03)
3
+ *
4
+ * 服务对象:纯读派生面(quicklog flow 归档伪条目扫描、knowledge-graph 构建)——输入是
5
+ * 冻结/低频变更面(归档目录、知识库、模块图),派生结果跨进程可复用。落点
6
+ * .sillyspec/.runtime/cache/(gitignored 且被平台 spec 上传排除——与 vector-capability
7
+ * 同款纪律;writeAtomicSync 自建父目录)。
8
+ *
9
+ * 契约:
10
+ * - 读:SILLYSPEC_HOT_CACHE=off 逃生阀直 null;version/fingerprint 任一不匹配 → null;
11
+ * ttlMs>0 且 savedAt 超窗 → null;坏 JSON/缺文件 → null(fail-open,缓存非真相源)
12
+ * - 写:原子;写失败 fail-soft 返回 false(加速面失败不阻断本体)
13
+ * - fingerprint 须可 JSON 稳定序列化且键序/元素序确定(数组按序构造,dirStamp 已排序)
14
+ */
15
+ import { readFileSync, readdirSync, statSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { writeAtomicSync } from './fs-atomic.js'
18
+
19
+ /** 读缓存:命中返回 payload,任何不匹配/损坏/关闭 → null。 */
20
+ export function readHotCache(cachePath, { version, fingerprint, ttlMs = 0, now = Date.now() }) {
21
+ if (process.env.SILLYSPEC_HOT_CACHE === 'off') return null
22
+ try {
23
+ const obj = JSON.parse(readFileSync(cachePath, 'utf-8'))
24
+ if (!obj || obj.version !== version) return null
25
+ if (JSON.stringify(obj.fingerprint) !== JSON.stringify(fingerprint)) return null
26
+ if (ttlMs > 0 && !(typeof obj.savedAt === 'number' && now - obj.savedAt <= ttlMs)) return null
27
+ return obj.payload
28
+ } catch { return null }
29
+ }
30
+
31
+ /** 写缓存:原子落盘 {version, fingerprint, savedAt, payload};失败 fail-soft。 */
32
+ export function writeHotCache(cachePath, { version, fingerprint, payload, now = Date.now() }) {
33
+ try {
34
+ writeAtomicSync(cachePath, JSON.stringify({ version, fingerprint, savedAt: now, payload }))
35
+ return true
36
+ } catch { return false }
37
+ }
38
+
39
+ /** 目录级指纹:子项名全集 + 目录 mtimeMs——子项增删/改名必变;不感知深层内容变化
40
+ * (配冻结输入面用:归档目录、或配 TTL 硬上限)。目录缺席 → null。 */
41
+ export function dirStamp(dir) {
42
+ try {
43
+ return { names: readdirSync(dir).sort(), mtimeMs: statSync(dir).mtimeMs }
44
+ } catch { return null }
45
+ }
46
+
47
+ /** 文件级指纹:目录内全部文件(含 _module-map.yaml 等非 md)名 → mtimeMs:size,按名排序。
48
+ * 感知本目录文件的新增/删除/就地重写(模块卡归档重写、知识文件蒸馏都在此列);
49
+ * 不递归子目录(子目录增删由上层逐目录 fileDirStamp 覆盖)。目录缺席 → null。 */
50
+ export function fileDirStamp(dir) {
51
+ try {
52
+ const out = []
53
+ for (const f of readdirSync(dir).sort()) {
54
+ const st = statSync(join(dir, f))
55
+ if (st.isDirectory()) continue
56
+ out.push([f, `${st.mtimeMs}:${st.size}`])
57
+ }
58
+ return out
59
+ } catch { return null }
60
+ }
package/src/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 只负责两件事:init(安装命令模板)和 setup(安装 MCP 工具)。
7
7
  * 状态管理通过 sillyspec.db(SQLite)完成,使用 `sillyspec progress` 命令。
8
8
  */
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, unlinkSync } from 'fs';
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, writeSync, unlinkSync } from 'fs';
10
10
  import { runtimeStatePath } from './runtime-paths.js'
11
11
  import { writeAtomicSync } from './fs-atomic.js';
12
12
  import { basename, dirname, extname, join, resolve, isAbsolute, sep, relative } from 'path';
@@ -237,6 +237,47 @@ function refuseRetiredQuickFreshStart(stageArgs) {
237
237
  process.exit(1)
238
238
  }
239
239
 
240
+ /**
241
+ * 全局输出收集器(2026-10-21-protocol-tax FR-03)。
242
+ *
243
+ * 收编会话考古(z1)发现的 agent 自发模式:44/48 次把 CLI 输出重定向进日志再 tail 尾部——
244
+ * 工具精心生成的规则大半没进上下文,且 shell 包装会掩蔽真实退出码。内建后:全文落
245
+ * --out-file 文件、stdout 只回提示行+尾 N 行(--tail,默认 40),退出码零拦截(失败信息
246
+ * 进尾行或全文件,不丢)。
247
+ *
248
+ * 实现要点:
249
+ * - 拦截点选 process.stdout.write/process.stderr.write(console.log/error 底层同路,
250
+ * JSON envelope 直写 process.stdout 的命令也被覆盖;stdio:'inherit' 的子进程直写 fd
251
+ * 不经此层——已知豁免面,退出码不受影响)。
252
+ * - exit 钩子同步落盘+尾行输出用 fs.writeSync 直写 fd(exit 期 pipe 异步写会丢)。
253
+ * - 观测面优先、绝不改变命令成败:写文件失败退化为全量打印;收集器任何异常不影响退出码。
254
+ */
255
+ function installOutFileRedirect(outFile, tailN) {
256
+ const entries = [];
257
+ const push = (chunk) => { entries.push(typeof chunk === 'string' ? chunk : chunk.toString()); return true; };
258
+ process.stdout.write = push;
259
+ process.stderr.write = push;
260
+ process.on('exit', () => {
261
+ const full = entries.join('');
262
+ let wrote = true;
263
+ try { writeFileSync(outFile, full); } catch { wrote = false; }
264
+ if (!wrote) {
265
+ try {
266
+ writeSync(2, `⚠️ --out-file 写入失败(${outFile})——改为全量打印:\n`);
267
+ writeSync(1, full);
268
+ } catch { /* exit 期 best-effort */ }
269
+ return;
270
+ }
271
+ const lines = full.split('\n');
272
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
273
+ const tail = lines.slice(-tailN);
274
+ try {
275
+ writeSync(1, `📄 全文已写 ${outFile}(${lines.length} 行,尾 ${tail.length} 行):\n`);
276
+ if (tail.length) writeSync(1, tail.join('\n') + '\n');
277
+ } catch { /* exit 期 best-effort */ }
278
+ });
279
+ }
280
+
240
281
  async function main() {
241
282
  const args = process.argv.slice(2);
242
283
 
@@ -269,6 +310,9 @@ async function main() {
269
310
  // 平台模式 flag(init 落平台指针用;非 init 命令时忽略——runCommand 自行解析 filteredArgs)
270
311
  let platformWorkspaceId = null;
271
312
  let platformRuntimeRoot = null;
313
+ // 全局输出收集器(2026-10-21-protocol-tax FR-03):--out-file <路径> / --tail N
314
+ let outFileRedirect = null;
315
+ let outFileTailLines = null;
272
316
  const filteredArgs = [];
273
317
 
274
318
  for (let i = 0; i < args.length; i++) {
@@ -305,6 +349,13 @@ async function main() {
305
349
  platformRuntimeRoot = resolve(args[i + 1]);
306
350
  filteredArgs.push(args[i], args[i + 1]);
307
351
  i++;
352
+ } else if (args[i] === '--out-file' && args[i + 1] && !args[i + 1].startsWith('--')) {
353
+ // 全局输出收集器(2026-10-21-protocol-tax FR-03):全文落文件、stdout 只回尾 N 行
354
+ outFileRedirect = resolve(args[i + 1]);
355
+ i++;
356
+ } else if (args[i] === '--tail' && args[i + 1] && /^\d+$/.test(args[i + 1]) && parseInt(args[i + 1], 10) > 0) {
357
+ outFileTailLines = parseInt(args[i + 1], 10);
358
+ i++;
308
359
  } else if (args[i] === '--interactive' || args[i] === '-i') {
309
360
  interactive = true;
310
361
  } else if (args[i] === '--allow-worktree-cwd') {
@@ -324,6 +375,13 @@ async function main() {
324
375
  process.exit(2);
325
376
  }
326
377
 
378
+ // --out-file/--tail 校验与安装(2026-10-21-protocol-tax FR-03):必须在任何命令输出之前装好
379
+ if (outFileTailLines !== null && outFileRedirect === null) {
380
+ console.error('❌ --tail 需与 --out-file 同用(控制 --out-file 模式下 stdout 尾部行数,默认 40)。');
381
+ process.exit(2);
382
+ }
383
+ if (outFileRedirect) installOutFileRedirect(outFileRedirect, outFileTailLines ?? 40);
384
+
327
385
  // ── 旧布局迁移(2026-10-18 runtime-relayout):per-change 散文件入 state/、可删产物入 temp/。
328
386
  // 每次入口跑一遍(幂等:已收敛后 readdir 一轮零写入);fail-open 绝不阻断命令。
329
387
  // init 除外——目标仓尚无 .sillyspec 可迁。resolveEffectiveDir 是函数声明(提升后可调)。
@@ -1683,12 +1741,18 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
1683
1741
  const rdStage = rdStageIdx >= 0 && args[rdStageIdx + 1] && !String(args[rdStageIdx + 1]).startsWith("--") ? args[rdStageIdx + 1] : null;
1684
1742
  const rdStatus = args.includes('--status');
1685
1743
  const rdKill = args.includes('--kill');
1744
+ const rdSalvage = args.includes('--salvage');
1686
1745
  const rdMode = rdKill ? 'kill' : rdStatus ? 'status' : 'create';
1687
- const rdUsage = '用法: sillyspec review-dispatch --change <名> --stage <brainstorm|plan|execute> [--status] [--kill]\n' +
1746
+ const rdUsage = '用法: sillyspec review-dispatch --change <名> --stage <brainstorm|plan|execute> [--status] [--kill] [--status --salvage]\n' +
1688
1747
  ' 缺省=派发(probe 前置→create_mission(external)→dispatch_worker(read_only),异步返回 missionId)\n' +
1689
1748
  ' --status=轮询状态+停滞提示;worker completed 自动回收落盘 review.json(reviewer.channel=platform)\n' +
1749
+ ' --status --salvage=终态原文兜底:worker-answer.md 落盘 + register-stage-review --from 回收指引(提取失败时用)\n' +
1690
1750
  ' --kill=清本地在途记录+平台处置指引(不自动 kill,处置权在人)';
1691
1751
  if (!rdChange) { console.error(rdUsage); process.exit(2); }
1752
+ if (rdSalvage && !rdStatus) {
1753
+ console.error(`❌ --salvage 仅与 --status 组合使用(completed 提取失败后的终态原文兜底回收)\n${rdUsage}`);
1754
+ process.exit(2);
1755
+ }
1692
1756
  assertSafeChangeName(rdChange, '--change 变更名');
1693
1757
  if (rdMode === 'create' && !['brainstorm', 'plan', 'execute'].includes(rdStage)) {
1694
1758
  console.error(`❌ create 形态必须 --stage ∈ brainstorm|plan|execute(实际:${rdStage || '缺'})\n${rdUsage}`);
@@ -1706,7 +1770,7 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
1706
1770
  const rdSpecBase = resolvePlatformSpecDir(dir, specDir);
1707
1771
  const r = await runReviewDispatch({
1708
1772
  mode: rdMode, cwd: dir, specBase: rdSpecBase, changeName: rdChange, stage: rdStage,
1709
- client: rdClient, budgetUsd: rdCfg.budget_usd, stallMs: rdCfg.stall_ms,
1773
+ client: rdClient, budgetUsd: rdCfg.budget_usd, stallMs: rdCfg.stall_ms, salvage: rdSalvage,
1710
1774
  });
1711
1775
  if (r.ok) {
1712
1776
  if (r.statusLine) console.log(`🛰️ ${r.statusLine}`);
@@ -1721,6 +1785,7 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
1721
1785
  } else {
1722
1786
  console.error(`❌ review-dispatch(${rdMode}): ${r.reason}`);
1723
1787
  if (r.statusLine) console.error(` ${r.statusLine}`);
1788
+ if (r.salvagePath) console.error(` 📄 worker 终态原文:${r.salvagePath}`);
1724
1789
  if (r.errors) for (const e of r.errors) console.error(` - ${e}`);
1725
1790
  if (r.guidance) console.error(` 下一步:\n${r.guidance}`);
1726
1791
  process.exitCode = 1;
@@ -3264,8 +3329,13 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
3264
3329
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
3265
3330
  // 下行 pull:stage 命令(run/--done,含 archive)启动时拉一次(task-10 / D-009 / FR-04)
3266
3331
  // 不在每步 pull,仅低频边界点;未连接平台静默跳过
3267
- const { triggerPullActiveChange } = await import('./run/shared.js')
3268
- await triggerPullActiveChange(effectiveDir)
3332
+ // 例外(2026-10-21-protocol-tax task-06 / FR-06):status 只读查看不 pull——消费仓实测
3333
+ // pull 占 status 6.3s 的大头(profile 71% idle 等网络);本地 DB 是查看态事实源
3334
+ //(progress show 同款语义——它从不 pull),新鲜度交给写命令与 daemon/bg-sync。
3335
+ if (command !== 'status') {
3336
+ const { triggerPullActiveChange } = await import('./run/shared.js')
3337
+ await triggerPullActiveChange(effectiveDir)
3338
+ }
3269
3339
  await runCommand(stageArgs, effectiveDir, specDir, { json })
3270
3340
  break
3271
3341
  }
@@ -5322,6 +5392,9 @@ SillySpec config — local.yaml 配置键速查
5322
5392
  git common-dir 主仓根;worktree 内 .sillyspec 是 checkout
5323
5393
  副本无此文件,cat 自动解析到主仓)
5324
5394
  sillyspec config cat --json 机读 JSON(dir/path/source/content;未找到退出 1)
5395
+ sillyspec config known-failures list 列 known_failures 豁免键
5396
+ sillyspec config known-failures add <key…> 增豁免键(行级手术保注释、原子写;坏 YAML 拒改)
5397
+ sillyspec config known-failures remove <key…> 删豁免键(未命中静默忽略)
5325
5398
 
5326
5399
  说明:
5327
5400
  - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
@@ -5363,11 +5436,30 @@ SillySpec config — local.yaml 配置键速查
5363
5436
  if (!content.endsWith('\n')) process.stdout.write('\n')
5364
5437
  break;
5365
5438
  }
5439
+ // config known-failures(2026-10-21-p0-p1-hardening FR-07 / dsh z2 实证:known_failures
5440
+ // 无 CLI 管理面 → agent 手改 local.yaml,PS5.1 编码把 50KB 文件写成 mojibake 等用户
5441
+ // 2h13m)。行级增删查:保注释、原子写、坏 YAML 拒改——消灭手改场景。
5442
+ if (configSub === 'known-failures') {
5443
+ let specBase = null
5444
+ try { specBase = resolvePlatformSpecDir(dir, specDir) } catch { /* 同 cat:pointer 异常走祖先链 */ }
5445
+ const { resolveLocalYaml } = await import('./config-cat.js')
5446
+ const located = resolveLocalYaml(dir, { specBase })
5447
+ const { runKnownFailuresCommand } = await import('./config-known-failures.js')
5448
+ const verb = filteredArgs[2];
5449
+ const keys = filteredArgs.slice(3).filter((a) => a && !a.startsWith('--'));
5450
+ const exitCode = runKnownFailuresCommand({
5451
+ yamlPath: located.exists ? located.path : join(dir, '.sillyspec', 'local.yaml'),
5452
+ exists: located.exists,
5453
+ verb, keys, json,
5454
+ });
5455
+ process.exitCode = exitCode;
5456
+ break;
5457
+ }
5366
5458
  if (!isSchema) {
5367
- const sug = didYouMean(configSub, ['schema', 'cat']);
5459
+ const sug = didYouMean(configSub, ['schema', 'cat', 'known-failures']);
5368
5460
  console.error(`❌ 未知子命令: config ${configSub}`);
5369
5461
  if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
5370
- console.error('用法: sillyspec config [schema|cat] [--json] 打印 local.yaml 配置键 / 读取实际内容');
5462
+ console.error('用法: sillyspec config [schema|cat|known-failures] [--json] 打印配置键 / 读内容 / 管理 known_failures 键');
5371
5463
  process.exit(2);
5372
5464
  }
5373
5465
  const { renderSchemaHuman, renderSchemaJson } = await import('./config-schema.js');
@@ -18,10 +18,11 @@
18
18
  * 模块域内解析(沿 fr-index renderFrLines / module-changelog 行形态的机械契约)。
19
19
  */
20
20
 
21
- import { existsSync, readFileSync, readdirSync } from 'fs'
21
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
22
22
  import { join } from 'path'
23
23
  import { parseKnowledgeIndex, parseDecisionEntries, anchorFilePaths } from './knowledge-match.js'
24
24
  import { modulePathMatches, parseModuleMapSimple } from './modules.js'
25
+ import { readHotCache, writeHotCache, dirStamp, fileDirStamp } from './hot-cache.js'
25
26
 
26
27
  // ── 本体常量(design.md 总体方案 §1 的代码化) ──
27
28
 
@@ -60,7 +61,7 @@ function extractFilePaths(text, cap = 15) {
60
61
 
61
62
  // ── 构建器 ──
62
63
 
63
- export function buildKnowledgeGraph(specRoot) {
64
+ function buildKnowledgeGraphFresh(specRoot) {
64
65
  const nodes = new Map()
65
66
  const edges = []
66
67
  const byEdgeType = new Map()
@@ -340,6 +341,85 @@ export function buildKnowledgeGraph(specRoot) {
340
341
  return { root: specRoot, nodes, edges, byEdgeType, stats }
341
342
  }
342
343
 
344
+ // ── 跨进程缓存(FR-03,2026-10-21-knowledge-tier-denoise)──
345
+ // 图召回是四层检索的第三层 advisory 保底(fail-soft);构建成本随归档线性(逐变更读
346
+ // design.md+change-patch.json),flow start/知识门/doctor 热路径上无缓存重付。D-003 墓碑
347
+ // 切割原则不变:md/yaml 仍是唯一真相源——缓存只存派生图、键在输入指纹上,不构成第二
348
+ // 真相源;任何失配全量重建。
349
+ // 陈旧边界(有意接受):指纹感知本目录文件级变化与目录增删,但 docs 树深层就地重写若
350
+ // 未触及被 stat 的文件面则漏——TTL 10min 硬上限兜底,advisory 面有界陈旧可接受。
351
+
352
+ const GRAPH_CACHE_VERSION = 1
353
+ const GRAPH_CACHE_TTL_MS = 10 * 60 * 1000
354
+
355
+ function statSignature(p) {
356
+ try { const s = statSync(p); return `${s.mtimeMs}:${s.size}` } catch { return null }
357
+ }
358
+
359
+ /** 输入面指纹:knowledge(INDEX+decisions+fr)+ docs 各项目(modules+scan 文件级)+
360
+ * 归档根(dirStamp)+ quicklog test-bindings。键序固定、数组排序——JSON 稳定比较。
361
+ * 各面缺席记 null(合法形态:无 docs/ 的小仓、无 fr 库)——缺席本身是指纹的一部分。 */
362
+ function knowledgeGraphFingerprint(specRoot) {
363
+ const knowledgeDir = join(specRoot, 'knowledge')
364
+ const docsDir = join(specRoot, 'docs')
365
+ let projects = []
366
+ try {
367
+ projects = readdirSync(docsDir, { withFileTypes: true })
368
+ .filter((d) => d.isDirectory())
369
+ .sort((a, b) => a.name.localeCompare(b.name))
370
+ .map((d) => [
371
+ d.name,
372
+ fileDirStamp(join(docsDir, d.name, 'modules')),
373
+ fileDirStamp(join(docsDir, d.name, 'scan')),
374
+ ])
375
+ } catch { /* docs/ 缺席 = 无项目面(合法形态)*/ }
376
+ return {
377
+ index: statSignature(join(knowledgeDir, 'INDEX.md')),
378
+ decisions: fileDirStamp(join(knowledgeDir, 'decisions')),
379
+ fr: fileDirStamp(join(knowledgeDir, 'fr')),
380
+ projects,
381
+ archive: dirStamp(join(specRoot, 'changes', 'archive')),
382
+ testBindings: statSignature(join(specRoot, 'quicklog', 'test-bindings.json')),
383
+ }
384
+ }
385
+
386
+ /** 缓存载荷 → 图对象(nodes Map 按 payload 序重建保迭代序一致;byEdgeType/stats 复算)。 */
387
+ function reviveGraph(payload, specRoot) {
388
+ const nodes = new Map(payload.nodes.map((n) => [String(n.id), n]))
389
+ const edges = payload.edges.map((e) => ({ s: String(e.s), t: String(e.t), type: e.type, note: String(e.note || '') }))
390
+ const byEdgeType = new Map()
391
+ for (const e of edges) {
392
+ if (!byEdgeType.has(e.type)) byEdgeType.set(e.type, [])
393
+ byEdgeType.get(e.type).push(e)
394
+ }
395
+ const stats = {
396
+ nodeCount: nodes.size,
397
+ edgeCount: edges.length,
398
+ byNodeType: [...nodes.values()].reduce((a, n) => (a[n.type] = (a[n.type] || 0) + 1, a), {}),
399
+ byEdgeType: edges.reduce((a, e) => (a[e.type] = (a[e.type] || 0) + 1, a), {}),
400
+ }
401
+ return { root: payload.root || specRoot, nodes, edges, byEdgeType, stats }
402
+ }
403
+
404
+ /** 构建入口(缓存包装,签名与返回结构不变):指纹+TTL+版本键三闸,命中零全量重扫。 */
405
+ export function buildKnowledgeGraph(specRoot) {
406
+ const fingerprint = knowledgeGraphFingerprint(specRoot)
407
+ const cachePath = join(specRoot, '.runtime', 'cache', 'knowledge-graph.json')
408
+ if (fingerprint) {
409
+ const cached = readHotCache(cachePath, { version: GRAPH_CACHE_VERSION, fingerprint, ttlMs: GRAPH_CACHE_TTL_MS })
410
+ if (cached && Array.isArray(cached.nodes) && Array.isArray(cached.edges)) return reviveGraph(cached, specRoot)
411
+ }
412
+ const graph = buildKnowledgeGraphFresh(specRoot)
413
+ if (fingerprint) {
414
+ writeHotCache(cachePath, {
415
+ version: GRAPH_CACHE_VERSION,
416
+ fingerprint,
417
+ payload: { root: graph.root, nodes: [...graph.nodes.values()], edges: graph.edges },
418
+ })
419
+ }
420
+ return graph
421
+ }
422
+
343
423
  /** changelog 三态行解析(表格行 ∪ 列表行 ∪ 标题态——backend 侧主形态;坏行跳过 fail-soft)。 */
344
424
  export function parseChangelogEntries(content) {
345
425
  const out = []