sillyspec 3.33.3 → 3.33.5

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 (55) hide show
  1. package/.claude/skills/sillyspec-config/SKILL.md +48 -0
  2. package/.claude/skills/sillyspec-flow/SKILL.md +4 -0
  3. package/AGENTS.md +1 -1
  4. package/package.json +1 -1
  5. package/src/config-known-failures.js +258 -0
  6. package/src/config-schema.js +28 -0
  7. package/src/config-validate.js +180 -0
  8. package/src/decision-distill.js +26 -5
  9. package/src/decision-redomain.js +5 -4
  10. package/src/docs-check.js +8 -4
  11. package/src/doctor-diagnostics.js +89 -6
  12. package/src/flow-parity.js +22 -2
  13. package/src/flow-preflight.js +67 -0
  14. package/src/flow.js +252 -55
  15. package/src/fr-index.js +7 -3
  16. package/src/git-helper.js +82 -0
  17. package/src/handoff.js +4 -0
  18. package/src/hooks/worktree-guard.js +12 -3
  19. package/src/hot-cache.js +60 -0
  20. package/src/index.js +111 -7
  21. package/src/knowledge-graph.js +82 -2
  22. package/src/knowledge-match.js +18 -2
  23. package/src/knowledge-quicklog.js +17 -2
  24. package/src/knowledge-vector.js +5 -3
  25. package/src/machine-interface.js +34 -13
  26. package/src/progress/change-registry.js +15 -0
  27. package/src/progress/shared.js +4 -0
  28. package/src/progress/stage-machine.js +15 -2
  29. package/src/progress.js +5 -0
  30. package/src/redomain-unmapped.js +204 -0
  31. package/src/redomain.js +5 -3
  32. package/src/review-dispatch.js +208 -47
  33. package/src/run/bg-sync.js +4 -0
  34. package/src/run/code-face-key.js +4 -1
  35. package/src/run/command.js +20 -5
  36. package/src/run/complete-handlers.js +38 -4
  37. package/src/run/complete.js +185 -14
  38. package/src/run/gates.js +30 -1
  39. package/src/run/prompt.js +9 -1
  40. package/src/run/quick-audit.js +4 -2
  41. package/src/run/shared.js +18 -1
  42. package/src/run/stage.js +22 -19
  43. package/src/run/verify-quality-scan.js +51 -0
  44. package/src/runtime-paths.js +3 -0
  45. package/src/sillyhub-mcp/client.js +11 -6
  46. package/src/spawn-settle.js +47 -0
  47. package/src/spec-sync.js +89 -3
  48. package/src/stages/knowledge.js +25 -3
  49. package/src/timing.js +55 -0
  50. package/src/verify-postcheck.js +153 -11
  51. package/src/watcher.js +4 -0
  52. package/src/worktree-apply.js +67 -4
  53. package/src/worktree.js +80 -11
  54. package/src/yaml-scalar-unquote.js +43 -0
  55. package/templates/agents-instruction.md +1 -1
@@ -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,10 @@ 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…> 删豁免键(未命中静默忽略)
5398
+ sillyspec config validate [--json] 逐键校验 local.yaml(错误 exit 1;缺文件=零配置可用 exit 0)
5325
5399
 
5326
5400
  说明:
5327
5401
  - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
@@ -5363,11 +5437,41 @@ SillySpec config — local.yaml 配置键速查
5363
5437
  if (!content.endsWith('\n')) process.stdout.write('\n')
5364
5438
  break;
5365
5439
  }
5440
+ // config validate(2026-10-23-config-validate-skill):逐键校验(schema 单一源;YAML/
5441
+ // 类型/枚举/段内必填凭据闭合,未知键警告)——agent 写完配置的硬验证门(技能末步=本命令
5442
+ // exit 0 才算完成);缺文件=零配置可用 info exit 0(69 键仅凭据族真必填)。
5443
+ if (configSub === 'validate') {
5444
+ let specBase = null
5445
+ try { specBase = resolvePlatformSpecDir(dir, specDir) } catch { /* 同 cat:pointer 异常走祖先链 */ }
5446
+ const { cmdConfigValidate } = await import('./config-validate.js')
5447
+ const exitCode = cmdConfigValidate({ dir, specBase, json })
5448
+ process.exitCode = exitCode
5449
+ break;
5450
+ }
5451
+ // config known-failures(2026-10-21-p0-p1-hardening FR-07 / dsh z2 实证:known_failures
5452
+ // 无 CLI 管理面 → agent 手改 local.yaml,PS5.1 编码把 50KB 文件写成 mojibake 等用户
5453
+ // 2h13m)。行级增删查:保注释、原子写、坏 YAML 拒改——消灭手改场景。
5454
+ if (configSub === 'known-failures') {
5455
+ let specBase = null
5456
+ try { specBase = resolvePlatformSpecDir(dir, specDir) } catch { /* 同 cat:pointer 异常走祖先链 */ }
5457
+ const { resolveLocalYaml } = await import('./config-cat.js')
5458
+ const located = resolveLocalYaml(dir, { specBase })
5459
+ const { runKnownFailuresCommand } = await import('./config-known-failures.js')
5460
+ const verb = filteredArgs[2];
5461
+ const keys = filteredArgs.slice(3).filter((a) => a && !a.startsWith('--'));
5462
+ const exitCode = runKnownFailuresCommand({
5463
+ yamlPath: located.exists ? located.path : join(dir, '.sillyspec', 'local.yaml'),
5464
+ exists: located.exists,
5465
+ verb, keys, json,
5466
+ });
5467
+ process.exitCode = exitCode;
5468
+ break;
5469
+ }
5366
5470
  if (!isSchema) {
5367
- const sug = didYouMean(configSub, ['schema', 'cat']);
5471
+ const sug = didYouMean(configSub, ['schema', 'cat', 'validate', 'known-failures']);
5368
5472
  console.error(`❌ 未知子命令: config ${configSub}`);
5369
5473
  if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
5370
- console.error('用法: sillyspec config [schema|cat] [--json] 打印 local.yaml 配置键 / 读取实际内容');
5474
+ console.error('用法: sillyspec config [schema|cat|validate|known-failures] [--json] 打印配置键 / 读内容 / 校验 / 管理 known_failures 键');
5371
5475
  process.exit(2);
5372
5476
  }
5373
5477
  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
  }
@@ -352,9 +352,24 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
352
352
  // 绿缓存根:与 runtimeRoot 解析同源(drift 锚主仓口径);解析失败 null → 不缓存不命中
353
353
  let greenRoot = null;
354
354
  try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
355
- const vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
356
- () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
357
- (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
355
+ // 失败签名复用(2026-10-21-verify-reuse-large-repo FR-03):真跑前咨询上次质量扫描失败——
356
+ // 同签名(代码指纹×豁免面)+ 快照口径一致(CLI 门主仓口径跑,plannedSnapshot=false)+
357
+ // 30min 窗口 → 不重跑,合成 failed(♻️ 披露 + 四出路)照常进 check not-ok 语义。
358
+ // gate verify 预检每轮整跑同签名失败 10~13.5 分钟的浪费由此消除(dsh 0ff6f1c8 取证)。
359
+ let vt = null;
360
+ try {
361
+ const { consultFailedScanReuse, synthesizeReusedFailedCheck } = await import('./run/verify-quality-scan.js');
362
+ const consult = consultFailedScanReuse({
363
+ specBase: specRoot, cwd, changeName, plannedSnapshot: false,
364
+ forceRerun: Boolean(process.env.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN),
365
+ });
366
+ if (consult.reuse) vt = synthesizeReusedFailedCheck(consult.record);
367
+ } catch { /* 咨询异常 → 照旧 green-cache/真跑 */ }
368
+ if (vt == null) {
369
+ vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
370
+ () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
371
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
372
+ }
358
373
  const vtWarnings = [];
359
374
  if (vt.cached) vtWarnings.push(vt.reason);
360
375
  if (vt.status === 'skipped') {
@@ -455,7 +470,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
455
470
  // 回合):①target_files reconcile 只读跑(reconcileTargetFiles 纯计算不落盘,--done 的写结果
456
471
  // 文件在其调用方);②stage review 在场探测(marker+review.json——缺则报预期路径,同 --done
457
472
  // Stage Review Gate 口径,只报缺不生成)。全只读零副作用;未传 full 零行为变化。
458
- if (full && (stage === 'verify' || stage === 'execute')) {
473
+ if (full && (stage === 'verify' || stage === 'execute' || stage === 'brainstorm' || stage === 'plan')) {
459
474
  // e1. reconcile(verify --done 的 target_files 对账门)
460
475
  if (stage === 'verify') {
461
476
  try {
@@ -484,10 +499,13 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
484
499
  });
485
500
  }
486
501
  }
487
- // e2. stage review 在场探测(execute --done 的 Stage Review Gate——verify 不在其列,
488
- // 坑 stage-review-verify-enum-gap 2026-10-11:gate 实际只拦 brainstorm/plan/execute,
489
- // register 枚举亦无 verify,verify 阶段不产 stage review 期望;verify 的真实门面是 e1 对账)
490
- if (stage === 'execute') try {
502
+ // e2. stage review 在场探测(Stage Review Gate 拦截面的 --full 预检——brainstorm/plan/execute
503
+ // 三阶段与 gates.js Stage Review Gate 实拦范围逐字对齐;verify 不在其列,坑
504
+ // stage-review-verify-enum-gap 2026-10-11:register 枚举亦无 verify,verify 阶段不产
505
+ // stage review 期望;verify 的真实门面是 e1 对账。dsh z1 实证(2026-10-21-p0-p1-hardening
506
+ // FR-04):此前 execute 单值使 brainstorm Step8 --done 被 Stage Review Gate「伏击」——
507
+ // gate 预检全绿而 --done 被拦,46 分钟+3 轮子代理评审才过)
508
+ if (stage === 'execute' || stage === 'brainstorm' || stage === 'plan') try {
491
509
  const { getLatestStageReviewRunId } = await import('./stage-review.js');
492
510
  const rrRoot = runtimeRoot || join(specRoot, '.runtime');
493
511
  const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
@@ -496,7 +514,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
496
514
  code: checkCode('full-stage-review') || 'full_stage_review_missing',
497
515
  ok: !!runId,
498
516
  errors: !runId
499
- ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——execute --done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
517
+ ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——${stage} --done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
500
518
  : [],
501
519
  data: { runId: runId || null },
502
520
  });
@@ -518,9 +536,12 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
518
536
  // register 指引;在场 → 静默 ok。独立 check id 不与 --full 的 full-stage-review 碰撞,
519
537
  // error 语义(--done 拦)仍归 --full 档与 --done 本体。
520
538
  // stage 期望面对齐(坑 stage-review-verify-enum-gap,2026-10-11 实证):Stage Review Gate
521
- // 实际只拦 brainstorm/plan/execute(gates.js),register-stage-review 枚举亦无 verify——
522
- // verify 阶段不产 stage review 提示(提示命令不可执行 + 指称的门禁不存在,双重误导)。
523
- if (!full && stage === 'execute') {
539
+ // 实际只拦 brainstorm/plan/execute(gates.js),register-stage-review 枚举亦无 verify——
540
+ // verify 阶段不产 stage review 提示(提示命令不可执行 + 指称的门禁不存在,双重误导)。
541
+ // 覆盖面扩全(2026-10-21-p0-p1-hardening FR-04 / dsh z1 实证):此前 execute 单值——brainstorm
542
+ // Step8 --done 被 Stage Review Gate 伏击(预检绿灯 vs 收口拦截口径不一致,损失 46 分钟)。
543
+ // 现与 gates.js 拦截面三阶段逐字对齐;文案按 stage 参数化。
544
+ if (!full && (stage === 'execute' || stage === 'brainstorm' || stage === 'plan')) {
524
545
  try {
525
546
  const { getLatestStageReviewRunId } = await import('./stage-review.js');
526
547
  const rrRoot = runtimeRoot || join(specRoot, '.runtime');
@@ -531,7 +552,7 @@ if (!full && stage === 'execute') {
531
552
  ok: true,
532
553
  informational: true,
533
554
  warnings: runId ? [] : [
534
- `缺 ${stage} 阶段的 stage review.json——execute --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
555
+ `缺 ${stage} 阶段的 stage review.json——${stage} --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
535
556
  ],
536
557
  data: { runId: runId || null },
537
558
  });
@@ -3,6 +3,7 @@
3
3
  // pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync } from 'fs';
5
5
  import { join } from 'path';
6
+ import { hostname } from 'os';
6
7
  import { appendDeletedLedger } from '../deleted-ledger.js';
7
8
 
8
9
  // 所有权活跃心跳窗缺省(2026-09-14-change-ownership-guards task-02 / FR-01):owner 非本会话
@@ -290,6 +291,7 @@ export class ChangeRegistry {
290
291
  * - opts.forced(--takeover) → takeover-forced 放行(无条件接管,调用方重写 owner 留痕)
291
292
  * - owner 为空(无行/NULL) → no-owner 放行(存量无主行,任何会话可接管)
292
293
  * - owner === selfSession → self 放行(本会话自有 change 零路径变化)
294
+ * - owner 本机匿名占位 + selfSession 显式 → takeover-anon-upgrade 放行(同主机显式身份自动升级,FR-05)
293
295
  * - owner 他人 且 now-last_active ≥ 窗口 → takeover-stale 放行(窗口外自动接管重写)
294
296
  * - owner 他人 且活跃窗内 → blocked-active-owner 拒绝(allowed:false,调用方打结构化错误)
295
297
  * last_active 缺失/不可解析按陈旧处理:无法证明新鲜即不拦——护栏拦「可证活跃」,不因时间戳
@@ -313,6 +315,19 @@ export class ChangeRegistry {
313
315
  if (opts.forced) return { allowed: true, action: 'takeover-forced', owner, lastActive, heartbeatMs };
314
316
  if (owner === null) return { allowed: true, action: 'no-owner', owner: null, lastActive, heartbeatMs };
315
317
  if (owner === opts.selfSession) return { allowed: true, action: 'self', owner, lastActive, heartbeatMs };
318
+ // 匿名占位所有权自动升级(2026-10-21-p0-p1-hardening FR-05 / dsh z1 实证:首调未设
319
+ // SESSION_ID → owner 落 anon@<host>,同机 agent 数分钟后补设显式身份,被
320
+ // blocked-active-owner 拦 15 分钟活跃窗,被迫 --takeover——自己锁自己)。anon@ 是机器级
321
+ // 降级标识(缺省只拦他机),同主机的显式身份(--session/env 三级解析,非 anon 降级)严格
322
+ // 更具体:直接放行升级(调用方 setChangeOwner 重写留痕),不必 --takeover。跨主机匿名
323
+ // (owner 的 @ 后缀 ≠ 本机 hostname)与任何显式 owner 持有不在此列,照旧新鲜度判定。
324
+ if (
325
+ typeof owner === 'string' && owner.startsWith('anon@')
326
+ && typeof opts.selfSession === 'string' && opts.selfSession && !opts.selfSession.startsWith('anon@')
327
+ && owner === `anon@${hostname()}`
328
+ ) {
329
+ return { allowed: true, action: 'takeover-anon-upgrade', owner, lastActive, heartbeatMs };
330
+ }
316
331
  // owner 他人:新鲜度决定拒绝 / 自动接管
317
332
  const lastMs = lastActive ? new Date(lastActive).getTime() : NaN;
318
333
  const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
@@ -16,6 +16,10 @@ export const STAGE_LABELS = {
16
16
  quick: '⚡ 快速任务',
17
17
  explore: '🧭 自由探索',
18
18
  archive: '📦 归档变更',
19
+ // thin 展示键(2026-10-21-overview-thin-stage):轻量变更出身投影专用(overview
20
+ // stage_label 查找),不进 VALID_STAGES/STAGE_ORDER——thin 不走阶段机,加进阶段
21
+ // 清单会建空阶段行扰动级联。◈ 对齐 SillyHub 前端旁路徽标字形(◈ thin)。
22
+ thin: '◈ 轻量变更',
19
23
  };
20
24
 
21
25
  // 完整阶段顺序(含 scan),仅用于展示/迭代顺序