sillyspec 3.28.3 → 3.28.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 (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
@@ -11,6 +11,7 @@
11
11
  * 本模块被 command.js 静态 import,顶部静态拉 @inquirer/prompts 会进**每条** run 命令
12
12
  * 启动路径(实测冷加载 100-150ms),而 checkbox 仅"≥2 活跃变更 + TTY"分支用到
13
13
  */
14
+ import { join } from 'node:path'
14
15
  import { parsePorcelainPath, safeGit } from './shared.js'
15
16
 
16
17
  /**
@@ -247,19 +248,43 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
247
248
  }
248
249
 
249
250
  const { runVerifyTestCheck, runVerifyLintCheck } = await import('../verify-postcheck.js')
250
- const test = runVerifyTestCheck({ cwd, specBase, changeName })
251
- const lint = runVerifyLintCheck({ cwd, specBase })
252
- const failed = []
253
- if (test.status === 'failed') failed.push('test')
254
- if (lint.status === 'failed') failed.push('lint')
255
- return {
256
- action: failed.length > 0 ? 'fail' : 'pass',
257
- failed,
258
- reason: failed.length > 0
259
- ? `实测失败:${failed.join(' + ')}(命令与输出尾部见上;修复后重跑 --done 不丢进度)`
260
- : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)`,
261
- test,
262
- lint,
251
+
252
+ // ── 隔离快照执行(2026-09-10 驾驭小结第六批②,用户实证「lint 实测对账在主仓跑被并行
253
+ // 会话脏文件拦门」):HEAD 干净快照 + 本会话文件 overlay 后在快照内跑 test+lint——并行
254
+ // 会话的未提交改动不再污染本会话门禁。快照基建失败 → 回退主仓现行为(零回归)。
255
+ // 门禁结果归属:快照内失败 = HEAD+本会话文件的真实失败(拦得对);主仓 fallback 失败
256
+ // 语义同旧版。两条路径的命令/超时/known_failures 语义同源(runVerify*Check 只换 cwd)。
257
+ let gateCwd = cwd
258
+ let gateSpecBase = specBase
259
+ let snapshot = null
260
+ if (files.length > 0 && !process.env.SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF) {
261
+ try {
262
+ const { createGateSnapshot } = await import('./gate-snapshot.js')
263
+ snapshot = createGateSnapshot({ cwd, files })
264
+ if (snapshot) {
265
+ gateCwd = snapshot.snapshotRoot
266
+ gateSpecBase = join(snapshot.snapshotRoot, '.sillyspec')
267
+ console.log(`🧪 门禁隔离快照:HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
268
+ }
269
+ } catch { /* 快照链路异常 → 主仓现行为 */ }
270
+ }
271
+ try {
272
+ const test = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
273
+ const lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
274
+ const failed = []
275
+ if (test.status === 'failed') failed.push('test')
276
+ if (lint.status === 'failed') failed.push('lint')
277
+ return {
278
+ action: failed.length > 0 ? 'fail' : 'pass',
279
+ failed,
280
+ reason: failed.length > 0
281
+ ? `实测失败:${failed.join(' + ')}(命令与输出尾部见上;修复后重跑 --done 不丢进度)`
282
+ : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
283
+ test,
284
+ lint,
285
+ }
286
+ } finally {
287
+ if (snapshot) { try { snapshot.cleanup() } catch { /* 清理失败不连坐门禁结论 */ } }
263
288
  }
264
289
  }
265
290
 
package/src/run/shared.js CHANGED
@@ -338,6 +338,8 @@ export function resolveQuickSessionsDir(platformOpts, localSpecBase) {
338
338
  // GHOST_EMPTY_DIR_STALE_MS 同口径(7 天)——正常并发会话间隔在分钟/小时级,7 天未收尾的
339
339
  // 会话按僵尸处理,防崩溃残留的 guard 永久「认领」文件侵蚀危险门。
340
340
  const FOREIGN_SESSION_STALE_MS = 7 * 24 * 60 * 60 * 1000
341
+ // 空壳会话点名宽限期:零步骤完成但启动未满此时长的不列(进行中会话的正常起步态)
342
+ const EMPTY_SHELL_GRACE_MS = 10 * 60 * 1000
341
343
 
342
344
  /**
343
345
  * 枚举其他 active quick 会话的显式文件声明(坑 foreign-session-declared-false-block)。
@@ -424,6 +426,9 @@ export function detectEmptyShellQuickSessions(platformOpts, localSpecBase, curre
424
426
  if (!guard || !guard.startedAt) continue
425
427
  const startedAtMs = Date.parse(guard.startedAt)
426
428
  if (!Number.isFinite(startedAtMs) || nowMs - startedAtMs > FOREIGN_SESSION_STALE_MS) continue
429
+ // 宽限期(2026-09-09 §7-4,会话实证:2 分钟前刚启动的进行中会话被点名疑似空壳):
430
+ // 零步骤完成对刚起步的会话是正常态,不满 10 分钟不列(宁可漏报不误报的同款取向)。
431
+ if (nowMs - startedAtMs < EMPTY_SHELL_GRACE_MS) continue
427
432
  try {
428
433
  const p = pm.read(cwd, sessionName)
429
434
  if (!p || !p.stages) continue
@@ -461,6 +466,28 @@ export function detectEmptyShellQuickSessions(platformOpts, localSpecBase, curre
461
466
  */
462
467
  export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
463
468
 
469
+ // ── 自指指针警告降频(2026-09-10 驾驭小结第五批①,「自指平台指针警告频出」)──
470
+ // daemon junction 形态下(specRoot 经 junction 指回本地 .sillyspec),每条 CLI 命令的
471
+ // 恢复链/写入链都会命中自指检测并 warn——同一事实每进程重复刷屏。跨进程 10min 窗口
472
+ // marker(与 sync-noise 的失败噪音闸同款机制):窗口内静默,窗口外首报完整展示并续窗。
473
+ const SELFREF_WARN_WINDOW_MS = 10 * 60 * 1000
474
+ const SELFREF_WARN_MARKER = 'selfref-pointer-warn.json'
475
+
476
+ export function warnSelfRefPointerOnce(cwd, specRoot, message) {
477
+ try {
478
+ const dir = join(cwd, '.sillyspec', '.runtime')
479
+ mkdirSync(dir, { recursive: true })
480
+ const markerPath = join(dir, SELFREF_WARN_MARKER)
481
+ const now = Date.now()
482
+ try {
483
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'))
484
+ if (m && Number.isFinite(m.at) && now - m.at < SELFREF_WARN_WINDOW_MS) return
485
+ } catch { /* 无 marker / 损坏 → 首报 */ }
486
+ writeFileSync(markerPath, JSON.stringify({ at: now, specRoot: specRoot || null }) + '\n')
487
+ } catch { /* marker 落盘失败 → 照常 warn(降频是优化不是语义) */ }
488
+ console.warn(message)
489
+ }
490
+
464
491
  export function writePlatformPointer(cwd, platformOpts, extra = {}) {
465
492
  if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
466
493
  // 自指写入拦截(FR-4,变更 2026-08-23-repo-native-spec-backfill):specRoot 经 realpath
@@ -468,8 +495,20 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
468
495
  // 单点收口同时覆盖 runCommand 与 cmdInit(writeInitPlatformPointer) 两调用方——旧模板
469
496
  // scan flag / init 平台模式显式传自指 specRoot 也不再重新投毒。runtimeRoot-only 组合
470
497
  // 无 specRoot 可判(isSelfReferentialSpecRoot 对 null 恒 false),原语义不变。
498
+ // 警告经 warnSelfRefPointerOnce 跨进程窗口降频(junction 形态下每命令命中的重复噪音)。
471
499
  if (isSelfReferentialSpecRoot(cwd, platformOpts.specRoot)) {
472
- console.warn(`⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}`)
500
+ warnSelfRefPointerOnce(cwd, platformOpts.specRoot,
501
+ `⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}(10 分钟内不重复提示)`)
502
+ return false
503
+ }
504
+ // temp 残留写侧守卫(2026-09-08 temp 投毒治理,防患于未然):specRoot 在系统 temp 且
505
+ // 项目 cwd 不在 temp → 拒绝三写。真实平台 spec 根永不在 temp;此类组合只可能来自
506
+ // 测试/联调以临时 specRoot 在真实项目根跑平台模式命令——指针/声明一旦落盘,指针被
507
+ // 清理后残留声明会让全命令 fail-closed(读侧 isTempResidueSpecRoot 降级兜底已存在
508
+ // 的污染,此处从源头断新增)。cwd 同在 temp(套件隔离正常形态)不拦,平台模式
509
+ // 测试不受影响。
510
+ if (isTempResidueSpecRoot(cwd, platformOpts.specRoot)) {
511
+ console.warn(`⚠️ 拒绝写入平台指针:specRoot 指向系统 temp 目录(${platformOpts.specRoot})而项目不在 temp 下——测试/联调残留特征,已跳过三写并按本地模式运行`)
473
512
  return false
474
513
  }
475
514
  // HUB-05:合并保留既有生命周期字段。scan 完成时指针被写入 status/completedAt/scanStatus
@@ -569,6 +608,38 @@ export function isSelfReferentialSpecRoot(cwd, specRoot) {
569
608
  }
570
609
  }
571
610
 
611
+ /**
612
+ * temp 残留 specRoot 判定(temp 投毒治理,2026-09-08:multi-agent-platform 工作台
613
+ * 「总览不可用」排障实证——Git Bash mktemp 临时 specRoot 的平台模式命令在真实项目根
614
+ * 写下指针+声明,指针随后被 cleanup/STALE 清理,声明按设计存活(唯一删除路径 =
615
+ * platform disconnect)→ 全命令 fail-closed,且 disconnect 会连带清 local.yaml
616
+ * platform 段误伤真实平台连接)。
617
+ *
618
+ * 判定:specRoot 位于系统 temp 目录 **且** cwd 不在 temp 下 → true。两个条件缺一不可:
619
+ * 真实平台的 spec 根永不在 temp(daemon 用 ~/.sillyhub/daemon/specs/{ws},服务器用
620
+ * /data/spec-workspaces);测试/联调的 temp specRoot 只允许配 temp 项目 cwd(套件隔离
621
+ * 正常形态,见 test/run-tests.mjs childEnv 与 platform-managed-declaration 夹具——
622
+ * cwd 与 spec 同在 temp 下时本函数返回 false,既有 fail-closed 测试语义不变)。
623
+ *
624
+ * 与 isSelfReferentialSpecRoot 并列为声明的两类「可证伪残留」:自指回环(repo-native
625
+ * junction 投毒)已有降级先例(run/command.js:429),本函数补 temp 残留这一类。
626
+ * 口径对齐 progress.js 指针路径的 temp 警告(resolve() 前缀比较,不 realpath——temp
627
+ * 判定是残留启发式,无需穿透 junction 的强一致)。
628
+ *
629
+ * @param {string} cwd - 项目根(与 isSelfReferentialSpecRoot 同基准)
630
+ * @param {string|null} [specRoot] - 平台 specRoot(可 null/undefined/空串)
631
+ * @returns {boolean} 是否 temp 残留(写侧应拒绝三写,读侧应降级本地并清理声明)
632
+ */
633
+ export function isTempResidueSpecRoot(cwd, specRoot) {
634
+ if (!specRoot || typeof specRoot !== 'string') return false
635
+ const tmp = resolve(os.tmpdir())
636
+ const sr = resolve(specRoot)
637
+ if (sr !== tmp && !sr.startsWith(tmp + sep)) return false
638
+ const c = resolve(cwd)
639
+ if (c === tmp || c.startsWith(tmp + sep)) return false
640
+ return true
641
+ }
642
+
572
643
  /**
573
644
  * 平台模式判定单一数据源(自指免疫,变更 2026-08-23-repo-native-spec-backfill)。
574
645
  *
@@ -656,9 +727,10 @@ export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
656
727
 
657
728
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
658
729
  // 平台模式不再整体跳过(2026-08-26 用户决策):上行回传(进度 + 四件套 + spec 树)
659
- // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(daemon 注入通道,
660
- // sync.js _getPlatform,链路 D 同款);未注入 env 且 local.yaml 无 platform 段时
661
- // sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走平台自有链路(见各自门禁)。
730
+ // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(预留通道,daemon 未
731
+ // 实现注入——需外部显式 export,sync.js _getPlatform,链路 D 同款)或 local.yaml
732
+ // platform 段;两者皆缺时 sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走
733
+ // 平台自有链路(见各自门禁)。
662
734
  try {
663
735
  // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
664
736
  // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
@@ -774,7 +846,8 @@ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {})
774
846
  if (!sm._getPlatform()) return
775
847
  if (_autoPullRecently(cwd)) return
776
848
  _stampAutoPull(cwd)
777
- await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs ?? AUTO_PULL_TIMEOUT_MS)
849
+ // autoPull:噪音闸标识(sync.js pull 的失败 warn 走跨进程静默窗口,见 sync-noise.js)
850
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal, autoPull: true }), opts.timeoutMs ?? AUTO_PULL_TIMEOUT_MS)
778
851
  } catch (e) {
779
852
  // pull 失败静默跳过(Best Effort,失败不影响正确性)
780
853
  console.warn('⚠️ 拉取失败:', e.message)
@@ -813,9 +886,10 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
813
886
  }
814
887
  if (!cn) return
815
888
  // 已确认连接 + 单活跃变更,调 pull(自动注入 2s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
816
- // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
889
+ // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)。
890
+ // autoPull:噪音闸标识(sync.js pull 的失败 warn 走跨进程静默窗口,见 sync-noise.js)
817
891
  try {
818
- await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }), AUTO_PULL_TIMEOUT_MS)
892
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal, autoPull: true }), AUTO_PULL_TIMEOUT_MS)
819
893
  } catch (e) {
820
894
  console.warn('⚠️ 拉取失败:', e.message)
821
895
  }
@@ -884,8 +958,9 @@ export function parsePorcelainPath(line) {
884
958
  }
885
959
 
886
960
  /**
887
- * quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计 与 QUICKLOG「文件:」行回填
888
- * 单源)。回填文件行时复用:review.changedFiles 含这些元数据时过滤掉,只留真实业务文件。
961
+ * quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计豁免的单一真相源)。
962
+ * 注意:QUICKLOG「文件:」行回填**不再直接复用**本谓词——记录面与审计豁免面已分叉
963
+ * (模块卡 + changelog sidecar 审计照旧放行、但要进文件行),见 isQuicklogFileLineNoise。
889
964
  *
890
965
  * 归类口径:
891
966
  * - quicklog/.runtime/modules/_module-map/knowledge-uncategorized 等 quick 自身产物 → 元数据
@@ -894,7 +969,7 @@ export function parsePorcelainPath(line) {
894
969
  *
895
970
  * 「并发工作 vs 偷建变更」的意图软判定留给 sillyhub,确定性校验只做路径归类。
896
971
  *
897
- * @param {string} p 文件路径(容错 \\ / 混用)
972
+ * @param {string} p 文件路径(容错 \ / 混用)
898
973
  * @param {string[]} [linkedChanges] 关联变更名列表(影响 changes/ 归类)
899
974
  * @returns {boolean} true=quick 元数据,应从业务文件列表过滤掉
900
975
  */
@@ -915,6 +990,24 @@ export function isQuickMetadata(p, linkedChanges = []) {
915
990
  return false
916
991
  }
917
992
 
993
+ /**
994
+ * QUICKLOG「文件:」行回填过滤——isQuickMetadata 的**记录面**变体(2026-09-08 用户反馈①:
995
+ * 审计按 .sillyspec/ 放行规则整体豁免没问题,但模块卡 + changelog sidecar 恰是收尾必改项,
996
+ * 被同一谓词从文件行滤掉后每回都得手工补录)。审计豁免面(isQuickMetadata)与记录面在此
997
+ * 分叉:quick 自身元数据(quicklog/.runtime/知识库未归类)照旧不入文件行,但本 quick 实际
998
+ * 改动的模块卡(`modules/<id>.md`)与 sidecar(`modules/<id>.changelog.md`,同被上方
999
+ * `[^/]+\.md$` 覆盖)保留——它们是真实收尾工作的一部分。`_module-map.yaml` 仍滤:CLI
1000
+ * (module-resolve)维护的索引,非 agent 手工改动。
1001
+ * @param {string} p 文件路径(容错 \ / 混用)
1002
+ * @param {string[]} [linkedChanges] 关联变更名列表(透传 isQuickMetadata)
1003
+ * @returns {boolean} true=元数据噪音,应从「文件:」行过滤掉
1004
+ */
1005
+ export function isQuicklogFileLineNoise(p, linkedChanges = []) {
1006
+ const file = String(p).replace(/\\/g, '/')
1007
+ if (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file)) return false
1008
+ return isQuickMetadata(p, linkedChanges)
1009
+ }
1010
+
918
1011
  // 危险文件清单(auditQuickCompletion 危险门与 predictProtectedQuickFiles 预告共用单一真相源)。
919
1012
  // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
920
1013
  // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
@@ -963,9 +1056,48 @@ export function predictProtectedQuickFiles(files, { linkedChanges = [], forceBas
963
1056
  })
964
1057
  }
965
1058
 
1059
+ /**
1060
+ * 软归属匹配(2026-09-10 用户反馈:--file-notes 括注只落声明文件,同模块测试文件漏声明时审计行
1061
+ * 有提示但文件行不自动补齐,连续多批需手工核对):窗口内未声明的**测试文件**按 basename stem 对上
1062
+ * 声明文件 stem → 软归属候选。stem 相等或「声明 stem + 分隔符(- _ .)」前缀(src/change-list.js ↔
1063
+ * test/change-list-operation.test.mjs 两种布局均命中)。测试文件判定:tests/__tests__/specs? 目录内
1064
+ * 任意文件,或 basename 带 .test./_test./-test./.spec. 标记(JS/TS/Go/Python 主流命名,前缀式
1065
+ * test_foo.py 仅在测试目录内才认——防 src/test-utils.js 这类助手误判)。
1066
+ * 只出候选,不动 attributedFiles——硬归属口径仍是「声明即归属」(2026-08-18 误归属修复),软归属
1067
+ * 错认可由审计行 🔍 追溯后手工剔除。
1068
+ * @param {string[]} declaredFiles 声明边界(allowedFiles)
1069
+ * @param {string[]} undeclaredFiles 窗口内未声明脏文件
1070
+ * @returns {string[]} undeclaredFiles 的软归属子集(正斜杠归一、保序去重)
1071
+ */
1072
+ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1073
+ const TEST_DIR_RE = /(^|\/)(tests?|__tests__|specs?)\//i
1074
+ const TEST_BASENAME_RE = /[._-](test|spec)\.[^.]+$/i
1075
+ const stemOf = (p) => {
1076
+ const base = basename(String(p).replace(/\\/g, '/'))
1077
+ const dot = base.lastIndexOf('.')
1078
+ let stem = dot > 0 ? base.slice(0, dot) : base
1079
+ stem = stem.replace(/[._-](test|spec)$/i, '').replace(/^(test|spec)[._-]/i, '')
1080
+ return stem.toLowerCase()
1081
+ }
1082
+ const anchors = (Array.isArray(declaredFiles) ? declaredFiles : [])
1083
+ .map(stemOf).filter((s) => s !== '')
1084
+ if (anchors.length === 0) return []
1085
+ const out = []
1086
+ for (const f of (Array.isArray(undeclaredFiles) ? undeclaredFiles : [])) {
1087
+ const posix = String(f).replace(/\\/g, '/')
1088
+ const base = posix.slice(posix.lastIndexOf('/') + 1)
1089
+ if (!base) continue
1090
+ if (!TEST_DIR_RE.test(posix) && !TEST_BASENAME_RE.test(base)) continue
1091
+ const stem = stemOf(posix)
1092
+ const hit = anchors.some((a) => stem === a || stem.startsWith(a + '-') || stem.startsWith(a + '_') || stem.startsWith(a + '.'))
1093
+ if (hit && !out.includes(posix)) out.push(posix)
1094
+ }
1095
+ return out
1096
+ }
1097
+
966
1098
  /**
967
1099
  * quick 完成审计:对比 baseline 与实际变更。
968
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}> }}
1100
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[], softTestFiles: string[], foreignSessionDeclared: Array<{file: string, sessions: string[]}> }}
969
1101
  */
970
1102
  /**
971
1103
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -1075,7 +1207,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1075
1207
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
1076
1208
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
1077
1209
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
1078
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], foreignSessionDeclared: [] }
1210
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [] }
1079
1211
 
1080
1212
  try {
1081
1213
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -1382,9 +1514,13 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1382
1514
  for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
1383
1515
  result.attributedFiles = own
1384
1516
  result.undeclaredFiles = result.changedFiles.filter(f => !declaredNorm.has(normalizeGitPath(f)))
1517
+ // 软归属(2026-09-10 用户反馈):未声明测试文件 stem 对上声明 stem → softTestFiles(收尾
1518
+ // 补入文件行带「软归属」括注 + 审计行单列 🔍,与 ⚖️ 真未知区分,错认可追溯剔除)。
1519
+ result.softTestFiles = matchSameModuleTestFiles(allowedFiles, result.undeclaredFiles)
1385
1520
  } else {
1386
1521
  result.attributedFiles = result.changedFiles
1387
1522
  result.undeclaredFiles = []
1523
+ result.softTestFiles = []
1388
1524
  }
1389
1525
 
1390
1526
  // --confirm 模式:展示 diff 并等待确认
@@ -1694,3 +1830,38 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1694
1830
  return def ? def.steps : null
1695
1831
  }
1696
1832
 
1833
+
1834
+ /**
1835
+ * 平台模式产物双写镜像(坑 platform-init-artifact-daemon-dir-only,2026-09-10 驾驭小结
1836
+ * 第三批②,用户两次实锤):agent 在平台接管项目(pointer 存在)本地跑 design-init /
1837
+ * verify-probes --init 时,spec 根解析为 hub/daemon specs 同步目录——产物只落镜像根,
1838
+ * 主仓 changeDir 要等 spec-sync 回程才可见(同步失败即失联)。双写:镜像根照写(平台
1839
+ * 权威数据面不动),同时把内容镜像到 cwd 本地主仓 changes/<change>/<file>——本地即时
1840
+ * 可见可核对,不再单点依赖 spec-sync 兜底。
1841
+ * 跳过条件(任一):非平台模式(platformBase 空)/ 显式 --spec-dir(daemon 上下文——
1842
+ * cwd 不是主仓,镜像会写错地方)/ 本地主仓 changeDir 不存在(不凭空建目录)/ 镜像与
1843
+ * 本地同路径(本地模式正常态)。
1844
+ * @param {{ cwd: string, changeName: string, file: string, content: string,
1845
+ * platformBase: string|null, specDirExplicit?: boolean|string }} opts
1846
+ * @returns {string|null} 镜像落盘路径(跳过返回 null)
1847
+ */
1848
+ export function mirrorPlatformArtifactToMainRepo({ cwd, changeName, file, content, platformBase, specDirExplicit = false, ensureParentDir = false }) {
1849
+ if (!platformBase || specDirExplicit) return null
1850
+ const localPath = join(cwd, '.sillyspec', 'changes', changeName, file)
1851
+ const mirrorPath = join(platformBase, 'changes', changeName, file)
1852
+ if (resolve(localPath) === resolve(mirrorPath)) return null
1853
+ if (!existsSync(dirname(localPath))) {
1854
+ // ensureParentDir(taskcard 等子目录产物用,2026-09-10 驾驭小结第六批①):本地 changeDir
1855
+ // 在而子目录(tasks/)未建时建子目录——守卫仍在 changeDir 层(不凭空建变更目录)。
1856
+ if (!ensureParentDir || !existsSync(dirname(dirname(localPath)))) return null
1857
+ try { mkdirSync(dirname(localPath), { recursive: true }) } catch { return null }
1858
+ }
1859
+ try {
1860
+ writeFileSync(localPath, content)
1861
+ console.log(`📎 平台模式产物已双写镜像主仓: ${localPath}(不再单点依赖 spec-sync 回程)`)
1862
+ return localPath
1863
+ } catch (e) {
1864
+ console.warn(`⚠️ 平台产物镜像主仓失败(不阻断,仍以 ${mirrorPath} 为权威): ${e && e.message ? e.message : e}`)
1865
+ return null
1866
+ }
1867
+ }
package/src/run/stage.js CHANGED
@@ -119,7 +119,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
119
119
  // ── execute 阶段启动时固定 executeRunId(绑定变更名,避免跨变更复用) ──
120
120
  let currentExecuteRunId = null
121
121
  if (stageName === 'execute') {
122
- const { generateExecuteRunId, isValidExecuteRunId } = await import('../task-review.js')
122
+ const { generateExecuteRunId, isValidExecuteRunId, claimExecuteRunId } = await import('../task-review.js')
123
123
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
124
124
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
125
125
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
@@ -136,11 +136,14 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
136
136
  }
137
137
  } catch {}
138
138
  if (!currentExecuteRunId) {
139
- currentExecuteRunId = generateExecuteRunId()
139
+ currentExecuteRunId = generateExecuteRunId(changeName)
140
140
  // D-001#1 主写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在,
141
141
  // archive 完成度扫描/漂移兜底不再落到「有 marker 无目录」的空 run)。失败直接 throw——execute
142
142
  // 启动即失败优于事后 review 错配(调用方 runCommand 冒到 CLI 顶层 exit 1,给出修复指引)。
143
143
  try {
144
+ // 排他认领(坑 exec-run-id-same-second-collision,2026-09-10 驾驭小结①):并行会话同秒
145
+ // 生成同一 runId 时在认领处消解(碰撞换随机后缀),per-task review.json 不再跨变更互相覆盖
146
+ currentExecuteRunId = claimExecuteRunId(runtimeRoot, currentExecuteRunId)
144
147
  mkdirSync(join(runtimeRoot, 'execute-runs', currentExecuteRunId, 'tasks'), { recursive: true })
145
148
  } catch (e) {
146
149
  throw new Error(`execute run 目录创建失败(${join(runtimeRoot, 'execute-runs', currentExecuteRunId)}): ${e.message};` +
@@ -151,6 +154,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
151
154
  // marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
152
155
  const { stampExecuteRunChange } = await import('../task-review.js')
153
156
  stampExecuteRunChange(runtimeRoot, currentExecuteRunId, changeName)
157
+ // execute-runs 回收不在此处(ql-20260908-005/006 已定分野):变更归属类证据走归档时
158
+ // 精确回收(complete-handlers pruneArchivedChangeRuntime)+ doctor --gc-unstamped-runs
159
+ // 清存量;写入侧 keep-N 滚动是启发式,会误删活跃变更的旧 run,不进证据类目录。
154
160
  }
155
161
  }
156
162
 
@@ -400,6 +406,28 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
400
406
  // 判定与 --done 审计危险门同口径(predictProtectedQuickFiles)
401
407
  const protectedPreview = predictProtectedQuickFiles(allowedFiles, { linkedChanges, forceBaseline })
402
408
  if (protectedPreview.length > 0) warnProtectedQuickFiles(protectedPreview)
409
+ // 任务描述驱动预检(2026-09-09 刀批,轮次经济学 §3.4):未预声明 --files 且未带
410
+ // forceBaseline 时,用当前脏文件 × 任务描述 token 粗匹配猜可能触及的文件,命中
411
+ // 受保护面即起步点破——省「--done 撞危险门 → 带 --force-baseline 重跑」一整轮。
412
+ // 启发式 advisory:猜错零代价(误报忽略),猜中省一轮;精确判定仍归 --done 审计。
413
+ if (protectedPreview.length === 0 && !forceBaseline) {
414
+ try {
415
+ const desc = quickOpts?.taskDescription || ''
416
+ if (desc) {
417
+ const dirty = String(safeGit(cwd, ['status', '--porcelain', '--untracked-files=all']).value || '')
418
+ .split('\n').map(l => l.slice(3).trim().split(' -> ').pop() || '')
419
+ .map(pp => pp.replace(/^"|"$/g, '').replace(/\\/g, '/'))
420
+ .filter(pp => pp && !pp.startsWith('.sillyspec/'))
421
+ const tokens = desc.toLowerCase().split(/[\s,,。;;::/\\]+/).filter(t => t.length >= 3)
422
+ const guessed = dirty.filter(pp => tokens.some(t => pp.toLowerCase().includes(t) || t.includes(pp.split('/').pop().replace(/[.][a-z]+$/, '').toLowerCase())))
423
+ const guessedProtected = predictProtectedQuickFiles(guessed, { linkedChanges, forceBaseline })
424
+ if (guessedProtected.length > 0) {
425
+ console.warn(`⚠️ 任务描述可能触及受保护文件(启发式预检,精确判定在 --done):${guessedProtected.slice(0, 3).join('、')}${guessedProtected.length > 3 ? ' …' : ''}`)
426
+ console.warn(' 若确要改它们:重启 quick 带 --force-baseline 预声明,省 --done 被拦重跑一轮;误报忽略即可。')
427
+ }
428
+ }
429
+ } catch { /* 预检 fail-open */ }
430
+ }
403
431
  // CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
404
432
  const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
405
433
  // 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
@@ -434,6 +462,9 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
434
462
  forceBaseline,
435
463
  linkedChanges,
436
464
  quicklogId: qlId,
465
+ // 刀①(2026-09-08):启动 --input 的任务描述落 guard——prompt.js 渲染 step1 时据此做
466
+ // 模块上下文关键词匹配(changeName 是 quick-<hash> 无语义,step.prompt 全文噪音大)
467
+ taskDescription: quickDesc,
437
468
  startedAt: new Date().toISOString(),
438
469
  }
439
470
  // 写入 .runtime/quick-sessions/<sessionId>/guard.json 供 worktree-guard hook 读取
@@ -538,10 +569,29 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
538
569
  await executeScanFinalize(cwd, platformOpts)
539
570
  } else if (cliAction === 'planPostcheck') {
540
571
  await executePlanPostcheck(cwd, platformOpts, progress)
572
+ } else if (cliAction === 'doctorRunDiagnostics') {
573
+ // 2026-09-09-doctor-noai FR-01:doctor 阶段折叠——noAI 步跑全量诊断
574
+ // (八维 + 三新 detector)+ renderDoctorSummary 渲染 + doctor-diagnosis.json 落盘
575
+ const { runDoctorDiagnostics, renderDoctorSummary, writeDoctorDiagnosis } = await import('../doctor-diagnostics.js')
576
+ const diag = await runDoctorDiagnostics({ cwd })
577
+ console.log(renderDoctorSummary(diag))
578
+ try { writeDoctorDiagnosis(diag, (platformOpts?.specRoot || join(cwd, '.sillyspec'))) } catch { /* 落盘 fail-soft */ }
541
579
  } else if (cliAction === 'progressConfirm') {
542
580
  // 主流程 step1「进度确认」noAI 化(2026-09-07):brainstorm/execute/verify——
543
581
  // 快照本就 CLI 注入、阶段路由已由 run 落定,省一轮「复述摘要→--done」往返
544
582
  executeProgressConfirm({ stageName, cwd, stageData, changeName, pm })
583
+ } else if (cliAction === 'verifyRunQualityScan') {
584
+ // P0-1(noai-ir-roadmap §3):verify「运行测试和质量扫描」noAI 化——test/lint 实测提前,
585
+ // 指纹落盘供 --done 复用免重跑(长套件不再跑两遍);失败 throw 不盖 completed,
586
+ // 输出 + 并行 WIP 归因提示透传给 agent(前置一/二)。
587
+ const { executeVerifyQualityScan } = await import('./verify-quality-scan.js')
588
+ await executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts })
589
+ } else if (cliAction === 'archiveDistill') {
590
+ // P0-4 安全变体(noai-ir-roadmap §3):archive「decision-distill 决策提炼」noAI 化——
591
+ // 提炼本体是 CLI 纯函数(旧 prompt 即「调函数转述输出」纯中继);needsWait 裁决收敛到
592
+ // 「确认归档 --confirm」。全路径不抛(裁决是确认步输入,非本步阻断条件)。
593
+ const { executeArchiveDistill } = await import('./archive-distill.js')
594
+ await executeArchiveDistill({ cwd, specBase, changeName })
545
595
  } else {
546
596
  throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
547
597
  }