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/run/gates.js CHANGED
@@ -28,6 +28,7 @@
28
28
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
29
29
  */
30
30
  import { basename, join, relative, dirname } from 'node:path'
31
+ import { timedAsync } from '../timing.js'
31
32
  import { runtimeStatePath } from '../runtime-paths.js'
32
33
  import { createHash } from 'node:crypto'
33
34
  import { execFileSync } from 'node:child_process'
@@ -1227,6 +1228,29 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1227
1228
  printVerifyTestCheck(testCheck)
1228
1229
  _obsJournal('green-cache', true, null)
1229
1230
  } else {
1231
+ // ── 失败签名复用(2026-10-21-verify-reuse-large-repo FR-03):真跑前先咨询上次失败──
1232
+ // 同签名(代码指纹×豁免面×快照口径全等)+ 30min 窗口内 → 不重跑,合成 failed 照常阻断
1233
+ // (dsh 0ff6f1c8 取证:文档门修复循环里每轮 --done 都整轮重测同签名失败 10~13.5 分钟)。
1234
+ // fail-closed:任一判据不可得/失配/TTL 过期 → 照旧真跑。RERUN 逃生阀与 noAI 闸同阀对称。
1235
+ let _failedReuse = null
1236
+ try {
1237
+ const { consultFailedScanReuse, synthesizeReusedFailedCheck } = await import('./verify-quality-scan.js')
1238
+ const consult = consultFailedScanReuse({
1239
+ specBase, cwd, changeName,
1240
+ plannedSnapshot: Boolean(verifyGateSnap),
1241
+ forceRerun: Boolean(process.env.SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN),
1242
+ })
1243
+ if (consult.reuse) _failedReuse = synthesizeReusedFailedCheck(consult.record)
1244
+ else if (consult.reason && consult.reason !== 'no-failed-record') {
1245
+ _obsJournal('quality-scan-failed', false, consult.reason)
1246
+ }
1247
+ } catch { /* 咨询异常 → 真跑(复用是优化不是正确性依赖) */ }
1248
+ if (_failedReuse) {
1249
+ testCheck = _failedReuse
1250
+ console.log(`\n♻️ Verify 测试对账:失败签名复用——上次实测 failed 且签名未变,免重跑(阻断照常,出路见下)`)
1251
+ printVerifyTestCheck(testCheck)
1252
+ _obsJournal('quality-scan-failed', true, null)
1253
+ } else {
1230
1254
  // 测试实测是同步 execSync,长套件可跑 2~10min 且中途无输出——先预告避免 agent 误判卡死
1231
1255
  console.log(`\n⏳ Verify 测试对账:CLI 亲自执行 local.yaml 的 commands.test(同步,耗时可能较长,请等待…)`)
1232
1256
  // restrictFiles 接线(2026-09-26-verify-gate-restrictfiles,评审 P2 留痕缺口的清偿):
@@ -1287,6 +1311,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1287
1311
  }
1288
1312
  }
1289
1313
  } catch { /* auto-bind fail-soft */ }
1314
+ } // 失败签名复用分支到此为止——下方记账层对 failed 复用结果是 no-op(passed 守卫)
1290
1315
  // P2 记账(fail-closed 层②:只有通过结果落账本——失败永不缓存,修复后重跑才能记)
1291
1316
  if (!traceHasActiveRows) {
1292
1317
  try {
@@ -1916,7 +1941,11 @@ function readDesignOwnFiles(specBase, changeName) {
1916
1941
  * null = 全部通过,调用方继续自管收尾(如下一步提示);
1917
1942
  * 非 null = gate/handler 失败已 rollback,调用方直接 return。
1918
1943
  */
1919
- export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx = null }) {
1944
+ export async function completeStageGates(args) {
1945
+ return timedAsync('gates', () => completeStageGatesImpl(args))
1946
+ }
1947
+
1948
+ async function completeStageGatesImpl({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText, ctx = null }) {
1920
1949
  // 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
1921
1950
  // auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
1922
1951
  // (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
package/src/run/prompt.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * - loadModuleContextIndex/buildModuleContextInjection 内 require('fs'/'path') 改顶部静态 import
20
20
  */
21
21
  import { basename, dirname, join } from 'node:path'
22
+ import { timedAsync } from '../timing.js'
22
23
  import { runtimeStatePath } from '../runtime-paths.js'
23
24
  import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync, rmSync } from 'node:fs'
24
25
  import { createHash } from 'node:crypto'
@@ -921,6 +922,10 @@ async function recordPromptInjectLedger({ runtimeRoot, changeName, stageName, fi
921
922
  }
922
923
 
923
924
  export async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null, waitHistory = null, autoMeta = null) {
925
+ return timedAsync('render', () => outputStepImpl(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts, prevStepAnswer, waitHistory, autoMeta))
926
+ }
927
+
928
+ async function outputStepImpl(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null, waitHistory = null, autoMeta = null) {
924
929
  const step = steps[stepIndex]
925
930
  const total = steps.length
926
931
  // ── 越界防御 ──
@@ -941,7 +946,10 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
941
946
  // 网络阻塞;未连接平台静默 / quick 会话降级跳过 / 8s 熔断 / 失败只 warn,契约全在
942
947
  // triggerStepStartSync 内(shared.js docstring 即本接线点的说明),Node 事件循环保证在飞
943
948
  // POST 落地后进程才退出。
944
- triggerStepStartSync(cwd, changeName, platformOpts)
949
+ // 例外(2026-10-21-protocol-tax task-06 / FR-06):status 是只读查看阶段,不推——消费仓
950
+ // 实测该推送(进程退出等在飞 POST)占 status 命令耗时的大头(10-20s 量级,平台慢时更甚);
951
+ // 查看态无步骤起点语义,平台停滞信号的新鲜度交给写命令。
952
+ if (stageName !== 'status') triggerStepStartSync(cwd, changeName, platformOpts)
945
953
 
946
954
  // ── 阶段产物模板落盘(R16 减负批次 2026-09-24):模板出上下文——prompt 只留指针,模板
947
955
  // 幂等落盘 <specBase>/.runtime/templates/ 供 agent 写产物前 Read 一次。best-effort:
package/src/run/shared.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
12
  */
13
13
  import { basename, join, resolve, dirname, sep } from 'node:path'
14
+ import { timedAsync } from '../timing.js'
14
15
  import { runtimeTempPath } from '../runtime-paths.js'
15
16
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs'
16
17
  import { fileURLToPath } from 'node:url'
@@ -981,6 +982,10 @@ function warnPlatformNotConnectedDaily(cwd, platformOpts = {}) {
981
982
  }
982
983
 
983
984
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
985
+ return timedAsync('sync', () => triggerSyncImpl(cwd, changeName, platformOpts, opts))
986
+ }
987
+
988
+ async function triggerSyncImpl(cwd, changeName, platformOpts = {}, opts = {}) {
984
989
  // ── 后台异步化(2026-09-20 用户反馈:--done 被网络尾巴拖住分钟级)──
985
990
  // 默认把同步整体挪进 detached 后台子进程:17 个调用点全部 fire-and-forget,但进程
986
991
  // 退出要等事件循环排空,在飞 fetch 拖住命令返回。回落 inline 的三条件:
@@ -1066,9 +1071,13 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
1066
1071
  * @returns {{confirmed: boolean, delegated?: boolean, skipped?: string, result?: object}}
1067
1072
  */
1068
1073
  export async function triggerSyncTerminalStrict(cwd, changeName, platformOpts = {}, opts = {}) {
1074
+ return timedAsync('sync', () => triggerSyncTerminalStrictImpl(cwd, changeName, platformOpts, opts))
1075
+ }
1076
+
1077
+ async function triggerSyncTerminalStrictImpl(cwd, changeName, platformOpts = {}, opts = {}) {
1069
1078
  // quick 会话无实体变更目录(无归档/删除终态)——沿用既有 best-effort 语义
1070
1079
  if (changeName && QUICK_SID_RE.test(changeName)) {
1071
- await triggerSync(cwd, changeName, platformOpts, opts)
1080
+ await triggerSyncImpl(cwd, changeName, platformOpts, opts)
1072
1081
  return { confirmed: false, delegated: true }
1073
1082
  }
1074
1083
  const syncMod = await import('../sync.js')
@@ -1139,6 +1148,10 @@ export async function triggerSyncTerminalStrict(cwd, changeName, platformOpts =
1139
1148
  * 每任务上报指引)覆盖。
1140
1149
  */
1141
1150
  export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
1151
+ return timedAsync('sync', () => triggerStepStartSyncImpl(cwd, changeName, platformOpts, opts))
1152
+ }
1153
+
1154
+ async function triggerStepStartSyncImpl(cwd, changeName, platformOpts = {}, opts = {}) {
1142
1155
  try {
1143
1156
  // 无 changeName(渲染层个别路径/测试 fixture)直接静默——sync(null) 只会打 warn 噪音
1144
1157
  if (!changeName) return
@@ -1226,6 +1239,10 @@ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {})
1226
1239
  * 供 index.js 在 stage 命令(run/--done/archive)与 approve 决策点注入一行调用。
1227
1240
  */
1228
1241
  export async function triggerPullActiveChange(cwd, platformOpts = {}) {
1242
+ return timedAsync('sync', () => triggerPullActiveChangeImpl(cwd, platformOpts))
1243
+ }
1244
+
1245
+ async function triggerPullActiveChangeImpl(cwd, platformOpts = {}) {
1229
1246
  // 平台模式走自己的链路,跳过;自指回环(repo-native junction)按本地模式处理
1230
1247
  if (isPlatformMode(platformOpts, cwd)) return
1231
1248
  // 先检查是否连接平台:未连接直接 return,避免 _ensureDB 在无 local.yaml 的 cwd 创建空 DB 污染
package/src/run/stage.js CHANGED
@@ -100,29 +100,32 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
100
100
  const effectiveChange = changeName
101
101
  const { WorktreeManager } = await import('../worktree.js')
102
102
  const wm = new WorktreeManager({ cwd })
103
- const existingMeta = wm.getMeta(effectiveChange)
104
- if (existingMeta) {
105
- console.log(`🔗 worktree 已存在: ${existingMeta.worktreePath} (${existingMeta.mode})`)
103
+ // ensure(2026-10-21-brainstorm-proto-worktree):meta 已登记即复用——头脑风暴原型期
104
+ // 早建的 worktree / native 复用形态与 execute 首建走同一幂等入口,原 getMeta 短路语义收敛于此。
105
+ let wtResult
106
+ try {
107
+ wtResult = wm.ensure(effectiveChange, { adoptBranch: !!(quickOpts && quickOpts.adoptBranch) })
108
+ } catch (e) {
109
+ console.error(`❌ worktree 创建失败: ${e.message}`)
110
+ // 修复建议不再无条件推荐 git branch -D(坑 worktree-user-branch-conflict,2026-08-24:
111
+ // 同名分支可能是用户有意创建的——分支冲突时 create 报错已带三选一决策菜单,删不删
112
+ // 交由菜单/人工判断;doctor 的分支删除也有 review 锚点 + 无库保守双保护)。
113
+ console.error(` 修复建议:`)
114
+ console.error(` 1. 同名分支冲突/收编:按上方报错内三选一菜单处置(遗留分支确认作废后才删;用户指定分支用 --adopt-branch 收编)`)
115
+ console.error(` 2. worktree 状态异常:sillyspec worktree doctor --fix(分支删除有 review 锚点保护,不会误删在用分支)`)
116
+ console.error(` 3. 目录残留:确认无未提交改动后清理 .sillyspec/.runtime/worktrees/${effectiveChange}/ 再重试`)
117
+ process.exit(1)
118
+ }
119
+ if (wtResult.existing) {
120
+ console.log(`🔗 worktree 已存在: ${wtResult.worktreePath} (${wtResult.mode})`)
106
121
  } else {
107
- try {
108
- const result = wm.create(effectiveChange, { adoptBranch: !!(quickOpts && quickOpts.adoptBranch) })
109
- console.log(`🔗 worktree 已创建: ${result.worktreePath} (分支: ${result.branch}, 模式: ${result.mode}${result.adoptedBranch ? ', 收编既有分支' : ''})`)
110
- } catch (e) {
111
- console.error(`❌ worktree 创建失败: ${e.message}`)
112
- // 修复建议不再无条件推荐 git branch -D(坑 worktree-user-branch-conflict,2026-08-24:
113
- // 同名分支可能是用户有意创建的——分支冲突时 create 报错已带三选一决策菜单,删不删
114
- // 交由菜单/人工判断;doctor 的分支删除也有 review 锚点 + 无库保守双保护)。
115
- console.error(` 修复建议:`)
116
- console.error(` 1. 同名分支冲突/收编:按上方报错内三选一菜单处置(遗留分支确认作废后才删;用户指定分支用 --adopt-branch 收编)`)
117
- console.error(` 2. worktree 状态异常:sillyspec worktree doctor --fix(分支删除有 review 锚点保护,不会误删在用分支)`)
118
- console.error(` 3. 目录残留:确认无未提交改动后清理 .sillyspec/.runtime/worktrees/${effectiveChange}/ 再重试`)
119
- process.exit(1)
120
- }
122
+ console.log(`🔗 worktree 已创建: ${wtResult.worktreePath} (分支: ${wtResult.branch}, 模式: ${wtResult.mode}${wtResult.adoptedBranch ? ', 收编既有分支' : ''})`)
121
123
  }
122
124
  // 入口 deps 自检(D-002):已存在 worktree(create short-circuit 不供给)时重供给
123
- if (existingMeta) {
125
+ //(existing 标志置 undefined:JSON.stringify 丢弃,不随 deps 写回污染 meta.json)
126
+ if (wtResult.existing) {
124
127
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
125
- await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, existingMeta)
128
+ await ensureDepsFreshness(cwd, effectiveChange, execSpecBase, { ...wtResult, existing: undefined })
126
129
  }
127
130
 
128
131
  // ── 跨仓仓 worktree 隔离(坑 cross-repo-no-worktree-isolation,2026-08-27 用户实证)──
@@ -317,6 +317,57 @@ export function shouldReuseLastPassedScan({ lastRecord, currentPassedKey, planne
317
317
  return { reuse: true, reason: null }
318
318
  }
319
319
 
320
+ /**
321
+ * 收口门/gate verify 的失败签名复用咨询(2026-10-21-verify-reuse-large-repo FR-03)。
322
+ * 包装既有三件(loadLastQualityScanRecord + computeQualityScanDedupKey +
323
+ * shouldReuseLastFailedScan),供 gates.js 实测 else 分支与 machine-interface verify-test
324
+ * facet 在真跑前调用:上次 failed 且失败签名未变 → 不重跑,消费侧合成 failed 结果照常阻断
325
+ * (dsh 0ff6f1c8 取证:同签名失败在收口门前被完整重跑,每轮 10~13.5 分钟)。
326
+ *
327
+ * 与 noAI 步内 shouldReuseLastFailedScan 的两点刻意差异:
328
+ * 1. TTL(缺省 30min,对齐 green-cache 窗口)——dedupKey 不含测试面,agent 手动补测试
329
+ * 绑定后面漂移(记录面 X,本轮应跑面 X+Δ)在 noAI 步由「步骤不推进」自兜底,收口门
330
+ * 复用会把旧面失败压到新面收口上,窗口须有界;
331
+ * 2. RERUN 逃生阀由消费侧透传 forceRerun(与 noAI 失败签名闸同阀对称——环境确已修好等
332
+ * 场景不因复用层锁死)。
333
+ * fail-closed:任一判据不可得/失配 → { reuse: false, reason },消费侧照旧真跑。
334
+ */
335
+ export function consultFailedScanReuse({ specBase, cwd, changeName, plannedSnapshot, maxAgeMs = 30 * 60 * 1000, forceRerun = false, now = Date.now() }) {
336
+ const lastRecord = loadLastQualityScanRecord({ specBase, changeName })
337
+ const verdict = shouldReuseLastFailedScan({
338
+ lastRecord,
339
+ currentDedupKey: computeQualityScanDedupKey({ cwd, specBase }),
340
+ plannedSnapshot,
341
+ forceRerun,
342
+ })
343
+ if (!verdict.reuse) return { reuse: false, reason: verdict.reason, record: null }
344
+ if (maxAgeMs != null) {
345
+ const ranAtMs = Date.parse(lastRecord.ranAt || '')
346
+ if (!Number.isFinite(ranAtMs) || now - ranAtMs > maxAgeMs) {
347
+ return { reuse: false, reason: `ttl-expired(上次实测 ${lastRecord.ranAt || '未知时间'} 超 ${Math.round(maxAgeMs / 60000)}min 窗口——面漂移防护,照旧真跑)`, record: null }
348
+ }
349
+ }
350
+ return { reuse: true, reason: null, record: lastRecord }
351
+ }
352
+
353
+ /** consultFailedScanReuse 命中后的合成 testCheck(消费侧共用——披露统一、字段齐全) */
354
+ export function synthesizeReusedFailedCheck(record) {
355
+ const t = (record && record.testResult) || {}
356
+ return {
357
+ status: 'failed',
358
+ reason: `♻️ 复用上次失败实测(失败签名未变:代码指纹/豁免面/快照口径全等${record && record.ranAt ? `,实测于 ${record.ranAt}` : ''},免重跑)——${(t.reason || '测试失败').split('\n')[0]}。出路:① 修代码 ② 补 known_failures 豁免(均即失配自动重测)③ 强制重跑:SILLYSPEC_VERIFY_QUALITY_SCAN_RERUN=1(旁路本复用层直接重跑;=force 连 noAI 步失败签名闸一并旁路)`,
359
+ command: `${t.command || 'commands.test'} (failed-scan-reuse)`,
360
+ exitCode: typeof t.exitCode === 'number' ? t.exitCode : 1,
361
+ durationMs: 0,
362
+ outputTail: t.outputTail || null,
363
+ resultPath: t.resultPath || null,
364
+ strategy: t.strategy || null,
365
+ failureRemaining: t.failureRemaining || t.failure_remaining || null,
366
+ failureExempted: t.failureExempted || t.failure_exempted || null,
367
+ reusedFromRecord: true,
368
+ }
369
+ }
370
+
320
371
  /**
321
372
  * 伪影分诊编排(ql-20260920-010 修复一 a):测试失败 → classify 分类 → 命中且本次是
322
373
  * 快照口径 → 主树口径单点复跑对照。复跑过 = 伪影坐实:返回主树结果 + artifactTriage
@@ -74,6 +74,9 @@ export const RUNTIME_STATE_SINGLETONS = [
74
74
  'spec-sync-last-success.json',
75
75
  'spec-sync-fail-streak.json',
76
76
  'spec-sync-follow-reported.json',
77
+ // 2026-10-21-cli-manifest-since-cache:spec-manifest 增量游标缓存(有读者、
78
+ // 不可再生前同步全量代价高——state/ 语义)
79
+ 'spec-manifest-cache.json',
77
80
  'sync-noise-self-heal.json',
78
81
  'last-delta.json',
79
82
  ]
@@ -610,18 +610,21 @@ export class SillyHubMcpClient {
610
610
  * artifacts 原样透传不裁剪:平台实返形态([{kind, content_ref, id}…] 或演进形态)整组
611
611
  * 透传——kind 匹配 / JSON 提取等语义解析归消费方(review-dispatch 的
612
612
  * extractReviewFromArtifacts),本封装保持传输层纯度(task-02 constraints)。
613
+ * answer 原文透传(2026-10-21-review-dispatch-answer-salvage / dsh 会话实证):worker 可能把
614
+ * review JSON 放回答正文而非 artifacts——取 tool 返回的 answer 字段(缺省兜底 result 字段)
615
+ * 字符串原文一并透传,非字符串/双缺 → null;解析仍归消费方,传输层只搬运。
613
616
  * @param {object} p
614
617
  * @param {string} p.missionId
615
618
  * @param {string} p.workerId
616
- * @returns {Promise<{workerId: string|null, status: string, artifacts: Array}>}
617
- * 正常 → {workerId, status, artifacts} 原样解析(status 缺省 'unknown',artifacts 缺省 []);
618
- * 未配置 / 调用失败(null) / tool isError → { workerId: null, status: 'unavailable', artifacts: [] }
619
+ * @returns {Promise<{workerId: string|null, status: string, artifacts: Array, answer: string|null}>}
620
+ * 正常 → {workerId, status, artifacts, answer} 原样解析(status 缺省 'unknown',artifacts 缺省 [],answer 缺省 null);
621
+ * 未配置 / 调用失败(null) / tool isError → { workerId: null, status: 'unavailable', artifacts: [], answer: null }
619
622
  * (与 dispatchWorker/getDaemonStatus 同族 best-effort,绝不向上抛)
620
623
  */
621
624
  async getWorkerResult({ missionId, workerId } = {}) {
622
- if (!this._configured) return { workerId: null, status: 'unavailable', artifacts: [] };
625
+ if (!this._configured) return { workerId: null, status: 'unavailable', artifacts: [], answer: null };
623
626
  const result = await this._callTool('get_worker_result', { mission_id: missionId, worker_id: workerId });
624
- if (result === null || result.isError) return { workerId: null, status: 'unavailable', artifacts: [] };
627
+ if (result === null || result.isError) return { workerId: null, status: 'unavailable', artifacts: [], answer: null };
625
628
  const value = this._parseToolReturnValue(result);
626
629
  // workerId 兜底链与 dispatchWorker 同款(2026-09-10 平台实返 {id,…} 形态实证)
627
630
  const wid = value && typeof value === 'object'
@@ -631,7 +634,9 @@ export class SillyHubMcpClient {
631
634
  ? (value.status ?? 'unknown')
632
635
  : 'unknown';
633
636
  const artifacts = value && Array.isArray(value.artifacts) ? value.artifacts : [];
634
- return { workerId: wid, status, artifacts };
637
+ const rawAnswer = value && typeof value === 'object' ? (value.answer ?? value.result) : null;
638
+ const answer = typeof rawAnswer === 'string' ? rawAnswer : null;
639
+ return { workerId: wid, status, artifacts, answer };
635
640
  }
636
641
 
637
642
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * spawn-settle —— detached 子进程孵化稳定化(2026-10-21-p0-p1-hardening FR-08)
3
+ *
4
+ * 背景(dsh z1/z2 各实证一次):Windows 下父进程 spawn detached 子进程(watcher/bg-sync)后
5
+ * 立即退出,可触发 Node libuv 退出断言崩溃(`Assertion failed: !(handle->flags &
6
+ * UV_HANDLE_CLOSING) src\win\async.c`)——把本已成功的 CLI 调用污染成 exit 1。根因在 Node
7
+ * 运行时(父进程退出与子进程 async 句柄建立竞态),应用层无法根除;本 util 提供有界缓解:
8
+ * spawn 后等子进程 'spawn' 事件(孵化完成、句柄建立完毕)或 'error' 再放行,上限 timeoutMs
9
+ * 兜底——把「父进程在句柄建立中途退出」的竞态窗口压掉。best-effort:任何异常路径直接返回,
10
+ * 绝不影响命令功能。
11
+ */
12
+ import { EventEmitter } from 'node:events';
13
+
14
+ /**
15
+ * 等待 detached 子进程孵化稳定。
16
+ * @param {import('node:child_process').ChildProcess} child
17
+ * @param {number} [timeoutMs=150] 兜底上限('spawn'/'error' 均未发出时按此时长放行)
18
+ * @returns {Promise<void>} 事件到达 / 兜底超时 / child 已退出 / child 非法形态——均 resolve(永不 reject)
19
+ */
20
+ export async function awaitSpawnSettled(child, timeoutMs = 150) {
21
+ if (!child || typeof child.once !== 'function') return;
22
+ // 已退出(同步失败形态:exitCode/signalCode 已定)——无句柄在途,立即返回
23
+ if (child.exitCode !== null && child.exitCode !== undefined) return;
24
+ if (child.signalCode) return;
25
+ await new Promise((resolve) => {
26
+ let settled = false;
27
+ const fin = () => {
28
+ if (settled) return;
29
+ settled = true;
30
+ try { child.off('spawn', fin); child.off('error', fin); } catch { /* 已解绑/非 EventEmitter——best-effort */ }
31
+ resolve();
32
+ };
33
+ child.once('spawn', fin);
34
+ child.once('error', fin);
35
+ // 上限兜底:两事件都不发(异常宿主/极端调度)也不让调用方久等
36
+ const t = setTimeout(fin, timeoutMs);
37
+ t.unref?.();
38
+ });
39
+ }
40
+
41
+ /** 测试辅助:构造最小 child 形态(EventEmitter + exitCode/signalCode 字段)。 */
42
+ export function fakeChildForTest({ exitCode = null, signalCode = null } = {}) {
43
+ const c = new EventEmitter();
44
+ c.exitCode = exitCode;
45
+ c.signalCode = signalCode;
46
+ return c;
47
+ }
package/src/spec-sync.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createHash } from 'crypto';
19
+ import { timedAsync } from './timing.js';
19
20
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
20
21
  import { join, relative, sep } from 'path';
21
22
  import { bindSyncNoiseRoot, syncConnectionWarn, isConnectionClassStatus, noteSyncSuccess } from './sync-noise.js';
@@ -433,6 +434,48 @@ function writeBaseSnapshot(specRoot, localFiles) {
433
434
  } catch { /* 快照失败只损失下次的内容级防回推,不影响本次同步结果 */ }
434
435
  }
435
436
 
437
+ // ── spec-manifest 增量游标缓存(2026-10-21-cli-manifest-since-cache)──
438
+ // 平台 spec-manifest 支持 ?since=<spec_version> 增量返回(multi-agent-platform
439
+ // 2026-10-21-spec-manifest-incremental)。缓存 = 上次消费的整份清单 + 游标 +
440
+ // 连续增量计数:稳态拉取只传变化行(生产实测全量 2.2MB/13145 行、5.5s@~400KBps
441
+ // 链路——单请求吃掉 CLI 同步 8s 总预算七成;增量后 <10KB/亚秒)。
442
+ // 形态 {spec_version:int, files:{path:{hash,version,exists}}, inc_count:int};
443
+ // .runtime 在上传排除面,缓存不进同步树。fail-soft:读写任何失败都退化为
444
+ // 全量拉取(与无缓存同路径,行为不劣于现状)。
445
+ const MANIFEST_CACHE = 'spec-manifest-cache.json';
446
+
447
+ // 定期全量对账阈值:连续增量 N 轮后强制不带 since 全量(缓存与服务器脱钩的
448
+ // 幻影行防线,design 风险节)。env SILLYSPEC_SPEC_SYNC_RECONCILE_EVERY 可调。
449
+ function resolveReconcileEvery() {
450
+ const raw = Number(process.env.SILLYSPEC_SPEC_SYNC_RECONCILE_EVERY);
451
+ if (Number.isInteger(raw) && raw >= 1 && raw <= 10000) return raw;
452
+ return 50;
453
+ }
454
+
455
+ function readManifestCache(specRoot) {
456
+ try {
457
+ const c = JSON.parse(readFileSync(runtimeStatePath(join(specRoot, '.runtime'), MANIFEST_CACHE), 'utf8'));
458
+ if (
459
+ c && Number.isInteger(c.spec_version) && c.files && typeof c.files === 'object'
460
+ && Number.isInteger(c.inc_count)
461
+ ) return c;
462
+ return null;
463
+ } catch {
464
+ return null;
465
+ }
466
+ }
467
+
468
+ function writeManifestCache(specRoot, cache) {
469
+ try {
470
+ mkdirSync(join(specRoot, '.runtime', 'state'), { recursive: true });
471
+ writeAtomicSync(runtimeStatePath(join(specRoot, '.runtime'), MANIFEST_CACHE), JSON.stringify(cache));
472
+ } catch { /* 缓存写失败只损失增量优化,下轮全量(fail-soft) */ }
473
+ }
474
+
475
+ function clearManifestCache(specRoot) {
476
+ try { unlinkSync(runtimeStatePath(join(specRoot, '.runtime'), MANIFEST_CACHE)); } catch { /* 不存在即无事 */ }
477
+ }
478
+
436
479
  /**
437
480
  * 丢弃「跟随服务器」型 update op(坑 quicksync-conflict-granularity ①)。
438
481
  *
@@ -682,6 +725,10 @@ function attributeChangeLabel(paths) {
682
725
  * (syncSpecTreeOnly / resolve forcePush 等)不消费该字段,向后兼容。
683
726
  */
684
727
  export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
728
+ return timedAsync('sync', () => syncSpecTreeImpl(specRoot, platform, changeName, opts))
729
+ }
730
+
731
+ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
685
732
  if (!platform || !platform.url || !platform.token) {
686
733
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
687
734
  return { synced: 0, ok: true };
@@ -698,10 +745,15 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
698
745
  const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
699
746
  const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
700
747
 
701
- // 1. GET 服务器清单
748
+ // 1. GET 服务器清单(2026-10-21-cli-manifest-since-cache:缓存感知——有可用
749
+ // 缓存带 ?since=<游标> 拉增量行合并;无缓存/对账轮/SILLYSPEC_SPEC_SYNC_FULL=1
750
+ // 走全量并落缓存;旧平台响应无 spec_version → 作废缓存回退现状行为;服务端
751
+ // spec_version 倒退(重建)→ 一次性全量对账)
702
752
  let serverManifest = {};
753
+ const manifestCache = process.env.SILLYSPEC_SPEC_SYNC_FULL ? null : readManifestCache(specRoot);
754
+ const cacheUsable = manifestCache && manifestCache.inc_count < resolveReconcileEvery();
703
755
  try {
704
- const res = await fetch(manifestUrl, {
756
+ const res = await fetch(cacheUsable ? `${manifestUrl}?since=${manifestCache.spec_version}` : manifestUrl, {
705
757
  headers: { Authorization: `Bearer ${platform.token}` },
706
758
  signal: withSignal(10000),
707
759
  });
@@ -724,7 +776,41 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
724
776
  return { synced: 0, ok: false };
725
777
  }
726
778
  const body = await res.json().catch(() => ({}));
727
- serverManifest = body.files || {};
779
+ if (cacheUsable && typeof body.spec_version === 'number' && body.spec_version >= manifestCache.spec_version) {
780
+ // 增量合并:响应行(含 exists=false 墓碑行)逐行覆盖进缓存——与拉全量等价
781
+ const merged = { ...manifestCache.files, ...(body.files || {}) };
782
+ writeManifestCache(specRoot, { spec_version: body.spec_version, files: merged, inc_count: manifestCache.inc_count + 1 });
783
+ serverManifest = merged;
784
+ } else if (cacheUsable && typeof body.spec_version === 'number') {
785
+ // 服务端 spec_version 倒退(重建/回滚)——缓存脱离服务端纪元,一次性全量对账。
786
+ // 对账失败按拉取失败 fail-closed(ok:false):空锚会触发全量 add 风暴,禁用。
787
+ debugLog(`[spec-sync] spec_version 倒退(${manifestCache.spec_version} → ${body.spec_version},服务端重建?)——回退全量清单对账`);
788
+ const full = await fetch(manifestUrl, {
789
+ headers: { Authorization: `Bearer ${platform.token}` },
790
+ signal: withSignal(10000),
791
+ });
792
+ if (!full.ok) {
793
+ const failCount = bumpFailStreak(specRoot, logLabel, `倒退对账全量 HTTP ${full.status}`);
794
+ console.warn(`[spec-sync] 倒退对账全量拉取失败 HTTP ${full.status}(文件树本次未同步,下次自动重试): ${logLabel}`);
795
+ if (failCount >= FAIL_STREAK_ESCALATE_AT) {
796
+ console.warn(`🚨 [spec-sync] 已连续 ${failCount} 次同步失败——平台文件树可能持续落后(变更中心或仍显示「进行中」/旧内容): ${logLabel}`);
797
+ }
798
+ return { synced: 0, ok: false };
799
+ }
800
+ const fullBody = await full.json().catch(() => ({}));
801
+ serverManifest = fullBody.files || {};
802
+ if (typeof fullBody.spec_version === 'number') writeManifestCache(specRoot, { spec_version: fullBody.spec_version, files: serverManifest, inc_count: 0 });
803
+ else clearManifestCache(specRoot);
804
+ } else if (typeof body.spec_version === 'number') {
805
+ // 全量响应(首拉/对账轮/强制全量):整份落缓存
806
+ writeManifestCache(specRoot, { spec_version: body.spec_version, files: body.files || {}, inc_count: 0 });
807
+ serverManifest = body.files || {};
808
+ } else {
809
+ // 旧平台(响应无 spec_version 字段):忽略 since 参数恒回全量——作废缓存,
810
+ // 未部署窗口内行为与现状完全一致
811
+ serverManifest = body.files || {};
812
+ clearManifestCache(specRoot);
813
+ }
728
814
  } catch (err) {
729
815
  const failCount = bumpFailStreak(specRoot, logLabel, describeSyncError(err));
730
816
  const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
@@ -118,8 +118,19 @@ export async function cmdSearch(dir, args, opts = {}) {
118
118
  vectorHint = vectorSwitchHint(dir)
119
119
  } catch { /* 提示 fail-open */ }
120
120
 
121
+ // 停车场过滤披露(FR-01,2026-10-21-knowledge-tier-denoise):查询命中过 unmapped 停车场
122
+ // 文件但被检索核心滤除且最终零结果时,披露被滤文件——去噪不丢可见性,人工按需直查。
123
+ const parkingFiltered = !result.matched && Array.isArray(result.json && result.json.parkingFiltered)
124
+ ? result.json.parkingFiltered : []
125
+
121
126
  if (!result.matched) {
122
- output(true, { query, matches: [], ...(vectorHint ? { hint: vectorHint } : {}) })
127
+ output(true, {
128
+ query, matches: [],
129
+ ...(vectorHint ? { hint: vectorHint } : {}),
130
+ ...(parkingFiltered.length > 0
131
+ ? { parkingFiltered, parkingNote: `停车场文件已过滤(${parkingFiltered.join('、')})——按需直查文件` }
132
+ : {}),
133
+ })
123
134
  return
124
135
  }
125
136
 
@@ -620,11 +631,12 @@ export async function cmdKnowledge(args, dir, opts = {}) {
620
631
  ' refresh 重扫 scan 文档重写 generated/ 区(**写盘**——先 validate 再用)',
621
632
  ' propose / classify / inbox / graph / stats / renumber',
622
633
  ' renumber [--dry-run|--apply] 存量决策号迁移为全局号(dry-run 先行;apply 写盘幂等)',
634
+ ' redomain-unmapped [--face decisions|fr] [--apply] unmapped 停车场存量批量清偿(文件锚/覆盖面投票目标域;dry-run 先行)',
623
635
  '',
624
636
  '写盘面子命令(refresh/propose/classify)先跑同名只读命令确认现状;探索用法先看本帮助。',
625
637
  ]
626
638
  for (const l of lines) console.log(l)
627
- output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats'] })
639
+ output(true, { help: true, subcommands: ['search', 'inspect', 'validate', 'digest', 'refresh', 'propose', 'classify', 'inbox', 'graph', 'stats', 'redomain-unmapped'] })
628
640
  return
629
641
  }
630
642
  const subCommand = args[0] || ''
@@ -640,6 +652,16 @@ export async function cmdKnowledge(args, dir, opts = {}) {
640
652
  return cmdRefresh(dir, args.slice(1), opts)
641
653
  case 'propose':
642
654
  return cmdPropose(dir, args.slice(1), opts)
655
+ case 'redomain-unmapped': {
656
+ // unmapped 停车场存量批量清偿(FR-04,2026-10-21-knowledge-tier-denoise):decisions 按文件锚
657
+ // / fr 按来源变更覆盖面投票目标域;缺省 dry-run 报告先行,--apply 幂等迁移(不裸删、冲突跳过)。
658
+ const mod = await tryImportSubcommandImpl('../redomain-unmapped.js', 'redomain-unmapped.js')
659
+ if (!mod || typeof mod.cmdRedomainUnmapped !== 'function') {
660
+ output(false, {}, { code: 'not_implemented', subcommand: 'redomain-unmapped' })
661
+ return
662
+ }
663
+ return mod.cmdRedomainUnmapped(dir, args.slice(1), opts)
664
+ }
643
665
  case 'graph': {
644
666
  // 知识图谱查询面(2026-10-08-knowledge-graph):neighbors/path/impact/orphans/dangling,
645
667
  // --edges 边型筛选;全 JSON 出口(与 search/validate 同族)。
@@ -748,7 +770,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
748
770
  output(false, {}, {
749
771
  code: 'unknown_subcommand',
750
772
  subCommand: subCommand,
751
- available: ['search', 'inspect', 'validate', 'refresh', 'propose', 'classify', 'inbox', 'stats', 'digest', 'graph', 'renumber'], // dump 随 graph 路由面(2026-10-09-graph-dump-layout)
773
+ available: ['search', 'inspect', 'validate', 'refresh', 'propose', 'classify', 'inbox', 'stats', 'digest', 'graph', 'renumber', 'redomain-unmapped'], // dump 随 graph 路由面(2026-10-09-graph-dump-layout)
752
774
  })
753
775
  }
754
776
  }
package/src/timing.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 分段计时记录器(2026-10-21-protocol-tax FR-01)。
3
+ *
4
+ * 背景:会话考古(2026-10-20-session-page-dsh-alignment z1/z2)发现 CLI 单命令耗时无从归因
5
+ * ——verify --done 挂 300s 靠会话死亡才暴露,status 6.3s 靠 CPU profile 考古才定位到平台同步。
6
+ * 本模块给命令提供四段计时(sync / gates / render / state-write),env DEBUG_SILLYSPEC_TIMING=1
7
+ * 时在进程退出前输出 `[timing] sync=…ms gates=…ms render=…ms state-write=…ms`。
8
+ *
9
+ * 契约(FR-01):
10
+ * - 未设 env:timedSync/timedAsync 原样透传、退出钩子不输出——命令输出与现状逐字节等价。
11
+ * - 设 env:未触达段输出 `n/a` 不省略段名;同名段多次进入累加;走 stderr(不污染 --json
12
+ * 的 stdout envelope)。
13
+ * - 观测不改变行为:fn 抛错照抛,耗时只记到抛错前;模块注册的 exit 钩子只写一行 stderr。
14
+ * - 覆盖面:exit 钩子挂 process 'exit' 事件(含 process.exit 硬退出路径),无需各命令埋发射点。
15
+ */
16
+ const SEGMENTS = ['sync', 'gates', 'render', 'state-write']
17
+ const enabled = process.env.DEBUG_SILLYSPEC_TIMING === '1'
18
+ const acc = new Map()
19
+
20
+ function record(segment, ms) {
21
+ acc.set(segment, (acc.get(segment) || 0) + ms)
22
+ }
23
+
24
+ export function timedSync(segment, fn) {
25
+ if (!enabled) return fn()
26
+ const t0 = performance.now()
27
+ try {
28
+ return fn()
29
+ } finally {
30
+ record(segment, performance.now() - t0)
31
+ }
32
+ }
33
+
34
+ export async function timedAsync(segment, fn) {
35
+ if (!enabled) return fn()
36
+ const t0 = performance.now()
37
+ try {
38
+ return await fn()
39
+ } finally {
40
+ record(segment, performance.now() - t0)
41
+ }
42
+ }
43
+
44
+ function emitTimingIfEnabled(stream = process.stderr) {
45
+ if (!enabled) return
46
+ const parts = SEGMENTS.map((s) => {
47
+ const ms = acc.get(s)
48
+ return `${s}=${ms === undefined ? 'n/a' : `${Math.round(ms)}ms`}`
49
+ })
50
+ stream.write(`[timing] ${parts.join(' ')}\n`)
51
+ }
52
+
53
+ if (enabled) {
54
+ process.on('exit', () => emitTimingIfEnabled())
55
+ }