sillyspec 3.33.3 → 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 (47) 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/docs-check.js +8 -4
  8. package/src/doctor-diagnostics.js +89 -6
  9. package/src/fr-index.js +7 -3
  10. package/src/git-helper.js +82 -0
  11. package/src/handoff.js +4 -0
  12. package/src/hooks/worktree-guard.js +12 -3
  13. package/src/hot-cache.js +60 -0
  14. package/src/index.js +99 -7
  15. package/src/knowledge-graph.js +82 -2
  16. package/src/knowledge-match.js +18 -2
  17. package/src/knowledge-quicklog.js +17 -2
  18. package/src/knowledge-vector.js +5 -3
  19. package/src/machine-interface.js +34 -13
  20. package/src/progress/change-registry.js +15 -0
  21. package/src/progress/shared.js +4 -0
  22. package/src/progress/stage-machine.js +15 -2
  23. package/src/progress.js +5 -0
  24. package/src/redomain-unmapped.js +204 -0
  25. package/src/redomain.js +5 -3
  26. package/src/review-dispatch.js +109 -47
  27. package/src/run/bg-sync.js +4 -0
  28. package/src/run/code-face-key.js +4 -1
  29. package/src/run/command.js +20 -5
  30. package/src/run/complete.js +185 -14
  31. package/src/run/gates.js +30 -1
  32. package/src/run/prompt.js +9 -1
  33. package/src/run/shared.js +18 -1
  34. package/src/run/stage.js +22 -19
  35. package/src/run/verify-quality-scan.js +51 -0
  36. package/src/runtime-paths.js +3 -0
  37. package/src/sillyhub-mcp/client.js +11 -6
  38. package/src/spawn-settle.js +47 -0
  39. package/src/spec-sync.js +89 -3
  40. package/src/stages/knowledge.js +25 -3
  41. package/src/timing.js +55 -0
  42. package/src/verify-postcheck.js +73 -4
  43. package/src/watcher.js +4 -0
  44. package/src/worktree-apply.js +67 -4
  45. package/src/worktree.js +80 -11
  46. package/src/yaml-scalar-unquote.js +43 -0
  47. package/templates/agents-instruction.md +1 -1
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 = []
@@ -292,6 +292,16 @@ function asciiWordHit(sh, text) {
292
292
  return new RegExp(`(^|[^a-z0-9-])${sh.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}([^a-z0-9-]|$)`, 'i').test(text)
293
293
  }
294
294
 
295
+ /** 停车场文件(未归类 unmapped 域——decisions 与 fr 两面):检索核心零返回,文件本身
296
+ * 保留在盘上供人工直查(search 面以 parkingFiltered 披露指路)。2026-10-21-knowledge-tier-denoise
297
+ * FR-01,对齐 FR 注入面 2026-09-29-rot-retire-inject-cap 的 unmapped 过滤口径(停车场非行为域)。 */
298
+ const PARKING_FILES = new Set(['decisions/unmapped.md', 'fr/unmapped.md'])
299
+
300
+ /** file 是否停车场文件(decisions/fr unmapped;Windows 反斜杠归一后判等)。 */
301
+ export function isParkingFile(file) {
302
+ return PARKING_FILES.has(String(file || '').replace(/\\/g, '/'))
303
+ }
304
+
295
305
  /**
296
306
  * 零命中查询侧词片回退(2026-09-29-decision-route-vocab):路由行零命中时,只测「查询自己的
297
307
  * 词片」——逐 decisions 域文件数词片在条目文本(标题∪理由)的出现次数,落在「跨条目复现但
@@ -309,6 +319,7 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
309
319
  if (all.length === 0) return null
310
320
  const byFile = new Map()
311
321
  for (const h of all) {
322
+ if (isParkingFile(h.file)) continue // 停车场文件不参与词片回退(FR-01)
312
323
  let st = byFile.get(h.file)
313
324
  if (!st) { st = { entries: [], texts: [] }; byFile.set(h.file, st) }
314
325
  st.entries.push(h)
@@ -395,16 +406,21 @@ export function matchByRouting(indexDir, taskContext) {
395
406
  }
396
407
 
397
408
  const contextLower = taskContext.toLowerCase()
398
- const matched = allEntries.filter(entry => {
409
+ const matchedRaw = allEntries.filter(entry => {
399
410
  return entry.keywords.some(kw => keywordMatchesContext(kw, contextLower))
400
411
  })
412
+ // 停车场过滤(FR-01,2026-10-21-knowledge-tier-denoise):unmapped 路由行不进命中集;
413
+ // 只命中停车场行 → 按零命中处理(hybrid 链继续走向量/图/词片层),json.parkingFiltered
414
+ // 披露被滤文件供 search 面提示人工直查(可见性不因去噪丢失)。
415
+ const parkingFiltered = [...new Set(matchedRaw.filter((e) => isParkingFile(e.file)).map((e) => e.file))]
416
+ const matched = matchedRaw.filter((e) => !isParkingFile(e.file))
401
417
 
402
418
  if (matched.length === 0) {
403
419
  return {
404
420
  matched: false,
405
421
  entries: [],
406
422
  report: 'Status: no matches',
407
- json: { matched: false, entry_count: 0, entries: [] },
423
+ json: { matched: false, entry_count: 0, entries: [], ...(parkingFiltered.length > 0 ? { parkingFiltered } : {}) },
408
424
  decisionHits: []
409
425
  }
410
426
  }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
21
21
  import { join } from 'path'
22
+ import { readHotCache, writeHotCache, dirStamp } from './hot-cache.js'
22
23
 
23
24
  const QUICKLOG_ENTRY_RE = /^##\s+(ql-[\w-]+)\s*\|\s*([^|]+)\s*\|\s*(.+)$/
24
25
  // 文件注记行:`- path(中文括注)` / `- path(ascii)` / 裸 `- path`(allocateQuicklogEntry
@@ -95,10 +96,23 @@ function parseQuicklogFileEntries(specBase) {
95
96
  return entries
96
97
  }
97
98
 
99
+ /** 伪条目扫描缓存版本(解析契约变化时 bump 即全量失效)。 */
100
+ const FLOW_ARCHIVE_CACHE_VERSION = 1
101
+
98
102
  /** 源②:flow 归档变更伪条目——目录内 flow-state.yaml 在场即认(不校验 yaml 内容:伪条目
99
- * 只需「这曾是一个 flow 变更」的存在信号,字段全来自目录名与 proposal.md,缺件逐项降级)。 */
100
- function parseFlowArchiveEntries(specBase) {
103
+ * 只需「这曾是一个 flow 变更」的存在信号,字段全来自目录名与 proposal.md,缺件逐项降级)。
104
+ * 跨进程缓存(FR-03,2026-10-21-knowledge-tier-denoise):指纹=归档根子项名全集+根 mtimeMs
105
+ * (一次 readdir+一次 stat,廉价每次重算)——命中即免逐目录列举与逐变更 proposal.md 读。
106
+ * 归档件是冻结审计面(无就地编辑形态),指纹对增删/改名完备;坏缓存/失配 fail-open 全量重建。
107
+ * 导出供测试断言缓存三态(命中/失效/坏缓存)。 */
108
+ export function parseFlowArchiveEntries(specBase) {
101
109
  const archiveDir = join(specBase, 'changes', 'archive')
110
+ const fingerprint = dirStamp(archiveDir)
111
+ if (fingerprint === null) return []
112
+ const cachePath = join(specBase, '.runtime', 'cache', 'quicklog-archive-entries.json')
113
+ const cached = readHotCache(cachePath, { version: FLOW_ARCHIVE_CACHE_VERSION, fingerprint })
114
+ if (Array.isArray(cached)) return cached
115
+
102
116
  let dirs
103
117
  try {
104
118
  dirs = readdirSync(archiveDir, { withFileTypes: true })
@@ -125,6 +139,7 @@ function parseFlowArchiveEntries(specBase) {
125
139
  out.push({ qlId: d.name, date, title: title || d.name, solution: '', files: [], source: 'flow' })
126
140
  } catch { /* 单目录读取异常 → 跳过该目录(fail-open,不影响其余源) */ }
127
141
  }
142
+ writeHotCache(cachePath, { version: FLOW_ARCHIVE_CACHE_VERSION, fingerprint, payload: out })
128
143
  return out
129
144
  }
130
145
 
@@ -38,7 +38,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
38
38
  import { runtimeStatePath } from './runtime-paths.js'
39
39
  import { join, dirname } from 'path'
40
40
  import { readPlatformConfig } from './sync.js'
41
- import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries } from './knowledge-match.js'
41
+ import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries, isParkingFile } from './knowledge-match.js'
42
42
 
43
43
  const VECTOR_TIMEOUT_MS = 3000
44
44
  const ENDPOINT_PATH = '/api/spec/knowledge/vector-search'
@@ -181,12 +181,14 @@ function specPathToFile(specPath) {
181
181
  return String(specPath).replace(/^knowledge\//, '').replace(/\\/g, '/')
182
182
  }
183
183
 
184
- /** 平台候选 → matchKnowledge 同构结果(策略面全本地解析)。 */
185
- function buildResultFromPlatform(indexDir, pvResults) {
184
+ /** 平台候选 → matchKnowledge 同构结果(策略面全本地解析)。导出供测试(FR-01 停车场
185
+ * 候选丢弃断言;策略面全在本地解析的契约不变)。 */
186
+ export function buildResultFromPlatform(indexDir, pvResults) {
186
187
  const byFile = new Map()
187
188
  for (const r of pvResults) {
188
189
  const file = specPathToFile(r.spec_path)
189
190
  if (!file.startsWith('decisions/')) continue
191
+ if (isParkingFile(file)) continue // 停车场候选丢弃(FR-01)——平台向量语料含 unmapped,策略面本地滤
190
192
  if (!byFile.has(file)) byFile.set(file, [])
191
193
  byFile.get(file).push(r)
192
194
  }