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
@@ -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),仅用于展示/迭代顺序
@@ -9,6 +9,9 @@ import { writeAtomicSync } from '../fs-atomic.js';
9
9
  import { timeAgo } from '../datetime.js';
10
10
  import { runValidators } from '../stage-contract.js';
11
11
  import { QUICK_SID_RE } from '../run/shared.js';
12
+ // thin 出身投影读 flow-state(2026-10-21-overview-thin-stage):叶子模块,与 progress.js
13
+ // 2026-10-13-thin-sync-stage 同款 import 先例,零新依赖链。
14
+ import { readFlowState } from '../flow-state.js';
12
15
  import { VALID_STAGES, STAGE_LABELS, STAGE_ORDER, MAIN_FLOW_ORDER, SPEC_DIR_NAME, CURRENT_VERSION, STALL_WARN_DAYS, emptyStage } from './shared.js';
13
16
 
14
17
  // quick 会话行(quick-<8hex>)按设计无实体 changes/ 目录(progress.js initChange 跳过建目录),
@@ -314,12 +317,22 @@ export class StageMachine {
314
317
  for (const cn of changes) {
315
318
  const data = this.pm.read(cwd, cn);
316
319
  const dirMissing = _isGhostChange(changesRoot, cn);
320
+ // thin 出身投影(2026-10-21-overview-thin-stage,对齐 2026-10-13-thin-sync-stage
321
+ // 平台上行同款覆写):本地行出生恒 'brainstorm'(thin 不走阶段机),总览按
322
+ // flow-state tier 覆写展示值——否则面板把轻量变更渲染成 brainstorm 主管线。仅覆写
323
+ // envelope 载荷,DB 行不动;listChanges 恒回 active 行(归档不进循环,sync 侧的
324
+ // status 守卫在此天然满足);flow-state 缺失/损坏 readFlowState→null、tier=thick
325
+ // 一律按行值(fail-open 同 sync 口径)。
326
+ let currentStage = data?.currentStage || null;
327
+ if (currentStage && readFlowState(this.pm._changePath(cwd, cn))?.tier === 'thin') {
328
+ currentStage = 'thin';
329
+ }
317
330
  const entry = {
318
331
  name: cn,
319
332
  readable: !!data,
320
333
  ghost: dirMissing,
321
- current_stage: data?.currentStage || null,
322
- stage_label: data ? (STAGE_LABELS[data.currentStage] || data.currentStage || null) : null,
334
+ current_stage: currentStage,
335
+ stage_label: currentStage ? (STAGE_LABELS[currentStage] || currentStage) : null,
323
336
  last_active: this.pm._dumpIso(data?.lastActive) || null,
324
337
  };
325
338
  if (data) {
package/src/progress.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
14
14
  import { RUNTIME_TEMP_DIRNAME, runtimeTempPath } from './runtime-paths.js'
15
+ import { timedSync } from './timing.js';
15
16
  import { join, basename, dirname, resolve, sep } from 'path';
16
17
  import { tmpdir, hostname } from 'os';
17
18
  import { writeAtomicSync } from './fs-atomic.js';
@@ -959,6 +960,10 @@ export class ProgressManager {
959
960
  }
960
961
 
961
962
  _write(cwd, data, changeName = null) {
963
+ return timedSync('state-write', () => this._writeImpl(cwd, data, changeName))
964
+ }
965
+
966
+ _writeImpl(cwd, data, changeName = null) {
962
967
  const cn = changeName || data.currentChange;
963
968
  if (!cn) {
964
969
  console.warn('⚠️ _write: 无变更名,跳过写入');
@@ -0,0 +1,204 @@
1
+ /**
2
+ * redomain-unmapped.js — unmapped 停车场存量批量清偿驱动(FR-04,2026-10-21-knowledge-tier-denoise)
3
+ *
4
+ * 决策/FR 条目落 unmapped 是域路由失败的兜底——源头匹配器已修(2026-10-15-module-path-glob-match),
5
+ * 存量需机器通道清偿(decision-redomain.js 头注:迁移原语已在,缺批量推断与编排)。本模块即编排:
6
+ * - decisions 条目:文件锚(「文件:」字段行)逐文件 matchedModuleIds 投票 → 目标真模块域
7
+ * - fr 条目:来源变更覆盖面(frCoverageFiles 三源:归档 design 交付表 ∪ change-patch ∪ git)→ 同款投票
8
+ * - 投票零命中/无锚 → 留 unmapped 并在报告列明残量原因(宁漏勿错,不裸删)
9
+ * 迁移原语复用 redomainDecisionEntries / redomainFrEntries:段字节整体搬运、迁域不换号、
10
+ * 目标域同 id 同变更冲突跳过、幂等;INDEX 路由行随原语内 sync 维护(FR-02 泛词已退役)。
11
+ * 只动 knowledge 活库,不回写归档件(D-unmapped-152 死路:归档是冻结审计面)。
12
+ */
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { splitKnowledgeSections, discoverModuleIndex } from './decision-distill.js'
16
+ import { matchedModuleIds, frCoverageFiles, FR_SECTION_RE } from './fr-index.js'
17
+ import { anchorFilePaths } from './knowledge-match.js'
18
+ import { redomainDecisionEntries, sectionMeta } from './decision-redomain.js'
19
+ import { redomainFrEntries } from './redomain.js'
20
+
21
+ const DECISION_FILE_LINE_RE = /^文件[::]\s*(.+)$/
22
+ const DECISION_ANCHOR_LINE_RE = /^锚点[::]\s*(.*)$/
23
+ const FR_HEADER_RE = /^## (FR-[\w.-]+-\d+|FR-\d+)\s*(.*)$/
24
+
25
+ /** 决策段 → 路径数组:双源——「文件:」行集(显式字段)∪「锚点:」行 anchorFilePaths 路径
26
+ * 提取(本仓实证 443 条全走锚点源:distill 的 文件: 字段极少发射,锚点是标准兜底
27
+ * 命中源——与 knowledge-match/knowledge-graph 的 anchors 边同判法)。 */
28
+ function filesFromDecisionSection(lines) {
29
+ const out = []
30
+ for (const l of lines) {
31
+ const line = String(l || '')
32
+ const m = line.match(DECISION_FILE_LINE_RE)
33
+ if (m) out.push(...String(m[1]).split(/[,,、\s]+/).map((s) => s.replace(/\\/g, '/')).filter(Boolean))
34
+ const a = line.match(DECISION_ANCHOR_LINE_RE)
35
+ if (a) out.push(...anchorFilePaths(a[1]))
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** FR 段「变更:」行(source change 迁移过滤键同款判法)。 */
41
+ function changeFromSection(lines) {
42
+ for (const l of lines) {
43
+ const m = String(l || '').match(/^变更[::]\s*(\S+)/)
44
+ if (m) return m[1]
45
+ }
46
+ return null
47
+ }
48
+
49
+ /** 文件集 → 目标域投票:逐文件 matchedModuleIds(修复后的 glob 匹配器,多命中并计),
50
+ * 最高票为域;平票字典序(确定性)。零命中 → null(留停车场)。 */
51
+ function voteDomain(files, moduleIndex) {
52
+ const tally = new Map()
53
+ for (const f of files) {
54
+ for (const mid of matchedModuleIds(moduleIndex, f)) tally.set(mid, (tally.get(mid) || 0) + 1)
55
+ }
56
+ if (tally.size === 0) return null
57
+ return [...tally.entries()].sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1))[0][0]
58
+ }
59
+
60
+ function planDecisionsFace(knowledgeRoot, archiveRoot, moduleIndex) {
61
+ const fromPath = join(knowledgeRoot, 'decisions', 'unmapped.md')
62
+ if (!existsSync(fromPath)) return { absent: true, groups: [], leftover: [] }
63
+ const parsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
64
+ const groups = new Map()
65
+ const leftover = []
66
+ for (const s of parsed.sections) {
67
+ const meta = sectionMeta(s)
68
+ if (!meta) continue
69
+ // 三源:文件: 行 ∪ 锚点路径(显式锚优先)→ 都不中再走来源变更覆盖面(fr 面同款——
70
+ // 本仓实证 417/443 锚点「未记录」,436/443 有「变更:」行,覆盖面是主力推断源)
71
+ const files = filesFromDecisionSection(s.lines)
72
+ const change = changeFromSection(s.lines)
73
+ let domain = files.length > 0 ? voteDomain(files, moduleIndex) : null
74
+ if (!domain && change) {
75
+ const cov = [...frCoverageFiles({ archiveRoot, changeName: change })]
76
+ if (cov.length > 0) domain = voteDomain(cov, moduleIndex)
77
+ }
78
+ if (!domain) {
79
+ const reason = files.length === 0
80
+ ? (change ? '锚点无路径且来源覆盖面不命中任何模块' : '无文件锚/锚点路径且无来源变更')
81
+ : '锚不命中任何模块'
82
+ leftover.push({ id: meta.id, title: meta.title, reason })
83
+ continue
84
+ }
85
+ if (!groups.has(domain)) groups.set(domain, [])
86
+ groups.get(domain).push(meta.id)
87
+ }
88
+ return {
89
+ groups: [...groups.entries()].map(([domain, ids]) => ({ domain, ids })),
90
+ leftover,
91
+ total: parsed.sections.filter((s) => sectionMeta(s)).length,
92
+ }
93
+ }
94
+
95
+ function planFrFace(knowledgeRoot, archiveRoot, moduleIndex) {
96
+ const fromPath = join(knowledgeRoot, 'fr', 'unmapped.md')
97
+ if (!existsSync(fromPath)) return { absent: true, groups: [], leftover: [] }
98
+ const parsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'), { sectionRegex: FR_SECTION_RE, buildId: (n) => n })
99
+ const groups = new Map()
100
+ const leftover = []
101
+ for (const s of parsed.sections) {
102
+ const h = FR_HEADER_RE.exec(s.lines[0] || '')
103
+ if (!h) continue
104
+ const id = h[1]
105
+ const title = (h[2] || '').trim()
106
+ const change = changeFromSection(s.lines)
107
+ const files = change ? [...frCoverageFiles({ archiveRoot, changeName: change })] : []
108
+ const domain = files.length > 0 ? voteDomain(files, moduleIndex) : null
109
+ if (!domain) {
110
+ leftover.push({ id, title, reason: !change ? '无来源变更' : '来源变更覆盖面不命中任何模块' })
111
+ continue
112
+ }
113
+ if (!groups.has(domain)) groups.set(domain, [])
114
+ groups.get(domain).push(id)
115
+ }
116
+ return {
117
+ groups: [...groups.entries()].map(([domain, ids]) => ({ domain, ids })),
118
+ leftover,
119
+ total: parsed.sections.filter((s) => FR_HEADER_RE.exec(s.lines[0] || '')).length,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 干跑计划:各面分组(目标域 → 条目 id 集)与残量(id/标题/不可推断原因)。
125
+ * @param {{knowledgeRoot: string, archiveRoot: string}} opts
126
+ * @returns {{decisions: object, fr: object}} 各面 {absent?, groups, leftover, total?}
127
+ */
128
+ export function planUnmappedRedomain({ knowledgeRoot, archiveRoot }) {
129
+ const moduleIndex = discoverModuleIndex(knowledgeRoot) || { modules: {} }
130
+ return {
131
+ decisions: planDecisionsFace(knowledgeRoot, archiveRoot, moduleIndex),
132
+ fr: planFrFace(knowledgeRoot, archiveRoot, moduleIndex),
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 执行清偿(先内部重算计划——apply 自带干跑等价性;幂等:重跑同批 0 迁移)。
138
+ * 单组失败不拦其余组(逐组收集结果与错误如实上报)。
139
+ * @param {{knowledgeRoot: string, archiveRoot: string, faces?: string[]}} opts faces 缺省双面
140
+ * @returns {{results: object, plan: object}} results 各面 {moved, movedByDomain, skippedConflicts, errors}
141
+ */
142
+ export function applyUnmappedRedomain({ knowledgeRoot, archiveRoot, faces = ['decisions', 'fr'] }) {
143
+ const plan = planUnmappedRedomain({ knowledgeRoot, archiveRoot })
144
+ const empty = { moved: 0, movedByDomain: {}, skippedConflicts: [], errors: [] }
145
+ const results = {}
146
+ const run = (groups, migrate) => {
147
+ const acc = { moved: 0, movedByDomain: {}, skippedConflicts: [], errors: [] }
148
+ for (const g of groups) {
149
+ try {
150
+ const res = migrate(g)
151
+ acc.moved += res.moved.length
152
+ acc.movedByDomain[g.domain] = res.moved.length
153
+ if (Array.isArray(res.skippedConflicts)) acc.skippedConflicts.push(...res.skippedConflicts.map((id) => `${g.domain}:${id}`))
154
+ } catch (e) {
155
+ acc.errors.push({ domain: g.domain, error: e && e.message ? e.message : String(e) })
156
+ }
157
+ }
158
+ return acc
159
+ }
160
+ results.decisions = faces.includes('decisions')
161
+ ? run(plan.decisions.groups,
162
+ (g) => redomainDecisionEntries({ knowledgeRoot, from: 'unmapped', to: g.domain, anchors: g.ids }))
163
+ : { ...empty }
164
+ results.fr = faces.includes('fr')
165
+ ? run(plan.fr.groups,
166
+ (g) => redomainFrEntries({ knowledgeRoot, from: 'unmapped', to: g.domain, anchors: g.ids }))
167
+ : { ...empty }
168
+ return { results, plan }
169
+ }
170
+
171
+ /**
172
+ * CLI 子命令(stages/knowledge.js 经 tryImportSubcommandImpl 装载):
173
+ * sillyspec knowledge redomain-unmapped [--face decisions|fr] [--apply]
174
+ * 缺省 dry-run 只出报告(JSON 出口与 search/graph 同族);--apply 幂等落盘。
175
+ */
176
+ export async function cmdRedomainUnmapped(dir, args, opts = {}) {
177
+ const specBase = opts.specDir || join(dir, '.sillyspec')
178
+ const knowledgeRoot = join(specBase, 'knowledge')
179
+ const archiveRoot = join(specBase, 'changes', 'archive')
180
+ const face = args.includes('--face')
181
+ ? (args[args.indexOf('--face') + 1] === 'decisions' ? 'decisions' : args[args.indexOf('--face') + 1] === 'fr' ? 'fr' : 'all')
182
+ : 'all'
183
+ const apply = args.includes('--apply')
184
+ const emit = (ok, data, error) => console.log(JSON.stringify({ ok, ...data, ...(error ? { error } : {}) }, null, 2))
185
+
186
+ if (!existsSync(join(knowledgeRoot, 'INDEX.md'))) {
187
+ emit(false, {}, { code: 'knowledge_dir_missing', path: knowledgeRoot })
188
+ return
189
+ }
190
+ if (apply) {
191
+ const faces = face === 'all' ? ['decisions', 'fr'] : [face]
192
+ const { results, plan } = applyUnmappedRedomain({ knowledgeRoot, archiveRoot, faces })
193
+ const leftoverFace = (name) => (faces.includes(name) ? plan[name].leftover : [])
194
+ emit(true, {
195
+ mode: 'apply', face,
196
+ decisions: results.decisions,
197
+ fr: results.fr,
198
+ leftover: { decisions: leftoverFace('decisions'), fr: leftoverFace('fr') },
199
+ })
200
+ return
201
+ }
202
+ const plan = planUnmappedRedomain({ knowledgeRoot, archiveRoot })
203
+ emit(true, { mode: 'dry-run', face, decisions: plan.decisions, fr: plan.fr })
204
+ }
package/src/redomain.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
16
16
  import { join } from 'path'
17
- import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
17
+ import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines, FR_ROUTE_STRIP_TOKENS } from './decision-distill.js'
18
18
  import { FR_SECTION_RE } from './fr-index.js'
19
19
  import { writeAtomicSync } from './fs-atomic.js'
20
20
 
@@ -141,9 +141,11 @@ export function redomainFrEntries({ knowledgeRoot, from, to, anchors = null, byC
141
141
  syncIndexRoutingLines(knowledgeRoot, {
142
142
  section: 'FR 需求索引',
143
143
  subdir: 'fr',
144
+ // 泛词退役(FR-02,2026-10-21-knowledge-tier-denoise):与 fr-index.js 蒸馏侧同款模板
145
+ stripTokens: FR_ROUTE_STRIP_TOKENS,
144
146
  makeLine: (d) => d.startsWith('auto-')
145
- ? `- ${d}|${d.slice(5)}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`
146
- : `- ${d}|FR|需求|承接 → [fr/${d}.md](fr/${d}.md)`,
147
+ ? `- ${d}|${d.slice(5)} → [fr/${d}.md](fr/${d}.md)`
148
+ : `- ${d} → [fr/${d}.md](fr/${d}.md)`,
147
149
  })
148
150
  } catch { /* INDEX 缺席/同步失败不拦迁移(主手术已成) */ }
149
151
  const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''