sillyspec 3.28.4 → 3.28.6

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 (52) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/sa-rename-probe/src/new.js +4 -0
  5. package/src/agent-session-log.js +4 -3
  6. package/src/archive-delta.js +2 -2
  7. package/src/change-list.js +11 -1
  8. package/src/commit-suggest.js +52 -1
  9. package/src/config-cat.js +6 -1
  10. package/src/config-schema.js +13 -0
  11. package/src/decision-distill.js +53 -7
  12. package/src/decisions-io.js +96 -0
  13. package/src/design-facts.js +3 -0
  14. package/src/dispatch/probe.js +31 -8
  15. package/src/docs-check.js +77 -8
  16. package/src/friction-tally.js +221 -0
  17. package/src/index.js +430 -18
  18. package/src/mcp-server.js +144 -0
  19. package/src/module-impact.js +8 -1
  20. package/src/modules.js +46 -34
  21. package/src/progress/change-registry.js +20 -0
  22. package/src/progress.js +16 -1
  23. package/src/quicklog.js +60 -6
  24. package/src/review-dispatch.js +377 -0
  25. package/src/run/archive-distill.js +48 -0
  26. package/src/run/command.js +35 -5
  27. package/src/run/complete-handlers.js +136 -3
  28. package/src/run/complete.js +170 -2
  29. package/src/run/gate-snapshot.js +159 -0
  30. package/src/run/gates.js +166 -69
  31. package/src/run/prompt.js +54 -9
  32. package/src/run/quick-audit.js +78 -16
  33. package/src/run/shared.js +138 -7
  34. package/src/run/stage.js +31 -2
  35. package/src/run/verify-quality-scan.js +383 -0
  36. package/src/scope-audit.js +941 -0
  37. package/src/sillyhub-mcp/client.js +58 -3
  38. package/src/stage-review-checklist.js +44 -0
  39. package/src/stage-review.js +122 -4
  40. package/src/stages/archive.js +57 -164
  41. package/src/stages/brainstorm.js +8 -12
  42. package/src/stages/execute.js +13 -10
  43. package/src/stages/plan.js +3 -10
  44. package/src/stages/verify.js +14 -33
  45. package/src/sync.js +51 -10
  46. package/src/task-review.js +108 -16
  47. package/src/taskcard.js +46 -3
  48. package/src/validate-artifacts.js +163 -0
  49. package/src/verify-postcheck.js +90 -10
  50. package/src/verify-probes.js +134 -12
  51. package/src/worktree-apply.js +119 -14
  52. 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
  /**
@@ -32,9 +33,38 @@ export function printQuickAuditReview(review) {
32
33
  console.error(` sillyspec run quick --done --allow-delete --change <id> --output "..."`)
33
34
  console.error(` (--force-baseline / --allow-new 不能解锁删除;不确认请 git restore 撤回删除后再 --done,或走完整流程 execute 由 review 把关)`)
34
35
  } else {
35
- console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
36
- console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
37
- console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
36
+ // 分流点名(2026-09-11 用户实证:allowedFiles 推断漏了三个实改文件,被与「危险文件」
37
+ // 混在一起推 --force-baseline——语义过宽且吓人)。按 reason 类别给精确最小解锁:
38
+ // 超出 allowedFiles(非危险)→ 追加 --files 并入边界即可;危险/baseline → --force-baseline;
39
+ // 新增 → --allow-new。未命中的 flag 不再出现在咒语里。
40
+ const prot = review.reasons.filter((r) => r.startsWith('危险文件变更') || r.startsWith('覆盖 baseline'))
41
+ .map((r) => r.split(': ')[1]).filter(Boolean)
42
+ const news = review.reasons.filter((r) => r.startsWith('新增文件'))
43
+ .map((r) => r.split(': ')[1]).filter(Boolean)
44
+ const undecl = review.reasons.filter((r) => r.startsWith('超出 allowedFiles'))
45
+ .map((r) => r.split(': ')[1]).filter(Boolean)
46
+ const flags = []
47
+ if (undecl.length > 0) {
48
+ flags.push('--files <原声明文件>,' + undecl.join(','))
49
+ console.error(` 📋 边界归属未声明(非危险变更):${undecl.join(', ')}——本会话实际改动但未在 --files 边界内(自动推断漏掉),追加声明即可:`)
50
+ console.error(` sillyspec run quick --files ${undecl.join(',')} --change <id>(恢复会话追加边界,追加不替换)`)
51
+ }
52
+ if (news.length > 0) {
53
+ flags.push('--allow-new')
54
+ console.error(` 📋 新增文件待放行:${news.join(', ')}——确认收进本会话带 --allow-new。`)
55
+ }
56
+ if (prot.length > 0) {
57
+ flags.push('--force-baseline')
58
+ console.error(` ⛔ 受保护/危险文件变更:${prot.join(', ')}——放行开关唯 --force-baseline(--files 只声明归属不改变危险判定)。`)
59
+ }
60
+ if (flags.length > 0) {
61
+ console.error(` 解锁命令(按上方分类携带最小 flag 集):`)
62
+ console.error(` sillyspec run quick --done ${flags.join(' ')} --change <id> --output "..."`)
63
+ } else {
64
+ console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
65
+ console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
66
+ console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
67
+ }
38
68
  // 两套开关明示(坑 files-flag-not-unlock-protected,2026-08-22 实证:模块文档被判危险
39
69
  // 文件,追加 --files 边界不解锁仍被拦——「追加边界」与「解锁拦截」是两套开关,交互上易误解
40
70
  // 前者能解决后者)。命中危险文件 reason 时点名:--files 只改归属口径(哪些文件计入本
@@ -108,6 +138,14 @@ export function printQuickAuditReview(review) {
108
138
  }
109
139
  if (review.docsCheckHint && review.docsCheckHint.invalid > 0) {
110
140
  console.warn(`\n📎 文档引用失效(docs check):本次改动的文档含 ${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处失效 file:line 引用。`)
141
+ // 逐条指名(2026-09-11 用户实证:只报计数不指名哪处,四轮复现才定位——门禁输出
142
+ // 直接带 文件:行号,另跑命令是复现不是定位);封顶 10 与生产端对齐,超出提示全量口径
143
+ for (const i of (review.docsCheckHint.invalidRefs || [])) {
144
+ console.warn(` ❌ [${i.doc}:${i.docLine}] ${i.ref} → ${i.reason}`)
145
+ }
146
+ if (review.docsCheckHint.invalidTruncated) {
147
+ console.warn(` … 共 ${review.docsCheckHint.invalid} 处(上方封顶 10,全量跑 sillyspec docs check)`)
148
+ }
111
149
  console.warn(` 行号漂移 → 更新到当前源码;文件删改名 → 更新引用路径。跑 sillyspec docs check 可看完整清单。`)
112
150
  console.warn(` 引用格式:\`src/foo.js:42\`(或 42-48)+ 同行反引号代码符号(如 \`runDocsCheck\`)——符号可让 --suggest 给出候选行号。`)
113
151
  }
@@ -247,19 +285,43 @@ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], d
247
285
  }
248
286
 
249
287
  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,
288
+
289
+ // ── 隔离快照执行(2026-09-10 驾驭小结第六批②,用户实证「lint 实测对账在主仓跑被并行
290
+ // 会话脏文件拦门」):HEAD 干净快照 + 本会话文件 overlay 后在快照内跑 test+lint——并行
291
+ // 会话的未提交改动不再污染本会话门禁。快照基建失败 → 回退主仓现行为(零回归)。
292
+ // 门禁结果归属:快照内失败 = HEAD+本会话文件的真实失败(拦得对);主仓 fallback 失败
293
+ // 语义同旧版。两条路径的命令/超时/known_failures 语义同源(runVerify*Check 只换 cwd)。
294
+ let gateCwd = cwd
295
+ let gateSpecBase = specBase
296
+ let snapshot = null
297
+ if (files.length > 0 && !process.env.SILLYSPEC_QUICK_GATE_SNAPSHOT_OFF) {
298
+ try {
299
+ const { createGateSnapshot } = await import('./gate-snapshot.js')
300
+ snapshot = createGateSnapshot({ cwd, files })
301
+ if (snapshot) {
302
+ gateCwd = snapshot.snapshotRoot
303
+ gateSpecBase = join(snapshot.snapshotRoot, '.sillyspec')
304
+ console.log(`🧪 门禁隔离快照:HEAD + 本会话 ${snapshot.overlaid} 个文件(并行会话脏文件不参与判定)`)
305
+ }
306
+ } catch { /* 快照链路异常 → 主仓现行为 */ }
307
+ }
308
+ try {
309
+ const test = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
310
+ const lint = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
311
+ const failed = []
312
+ if (test.status === 'failed') failed.push('test')
313
+ if (lint.status === 'failed') failed.push('lint')
314
+ return {
315
+ action: failed.length > 0 ? 'fail' : 'pass',
316
+ failed,
317
+ reason: failed.length > 0
318
+ ? `实测失败:${failed.join(' + ')}(命令与输出尾部见上;修复后重跑 --done 不丢进度)`
319
+ : `触及 src/test 共 ${codeFiles.length} 个文件,test+lint 实测通过(或未配置命令自动跳过)${snapshot ? '(隔离快照口径:并行会话脏文件不计入)' : ''}`,
320
+ test,
321
+ lint,
322
+ }
323
+ } finally {
324
+ if (snapshot) { try { snapshot.cleanup() } catch { /* 清理失败不连坐门禁结论 */ } }
263
325
  }
264
326
  }
265
327
 
package/src/run/shared.js CHANGED
@@ -70,6 +70,28 @@ export function assertSafeChangeName(name, label = '变更名') {
70
70
  }
71
71
  }
72
72
 
73
+ // ── 变更名日期前缀门禁(brainstorm step6 规则 CLI 化)──
74
+ // 规则出处:stages/brainstorm.js step6「变更名格式 YYYY-MM-DD-<简短描述>」此前只是 prompt 约束,
75
+ // agent 自拟/重命名丢日期前缀时 CLI 照单全收静默物化(2026-09-11 实证:friction-signal-hint)。
76
+ // 语义:只拦「新建/改名」——调用方须先确认名字未物化(无 DB 行、无 changes/ 目录含归档),
77
+ // 历史 DB/目录已存在的旧变更不追诉(归档里 auto-flow-optimization 等 10 个无前缀名照常可读)。
78
+ // 豁免:quick-<8hex> 会话 key 与 default 兜底 key 是系统生成名,非 agent 自拟描述名。
79
+ // 日期只校验形状(月 01-12 日 01-31),不校验「当天」——跨天续跑/次日重命名不应被拦。
80
+ const DATED_CHANGE_NAME_RE = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])-[a-zA-Z0-9][\w.\-]*$/
81
+ export function assertDatedChangeName(name, label = '变更名') {
82
+ if (name == null) return
83
+ const s = String(name)
84
+ if (s === 'default' || /^quick-[0-9a-f]{8}$/.test(s)) return
85
+ if (DATED_CHANGE_NAME_RE.test(s)) return
86
+ const now = new Date()
87
+ const pad = n => String(n).padStart(2, '0')
88
+ const example = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-<简短描述>`
89
+ throw new Error(
90
+ `${label}「${s}」缺少日期前缀或格式非法——新变更名必须形如 YYYY-MM-DD-<简短描述>(例如 ${example})。` +
91
+ `此为 brainstorm step6 既定规则,现由 CLI 强制:格式不合规不予执行。已存在的旧名变更不受影响`
92
+ )
93
+ }
94
+
73
95
  import { buildExecuteSteps } from '../stages/execute.js'
74
96
  import { buildPlanSteps } from '../stages/plan.js'
75
97
  import { stageRegistry } from '../stages/index.js'
@@ -466,6 +488,28 @@ export function detectEmptyShellQuickSessions(platformOpts, localSpecBase, curre
466
488
  */
467
489
  export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
468
490
 
491
+ // ── 自指指针警告降频(2026-09-10 驾驭小结第五批①,「自指平台指针警告频出」)──
492
+ // daemon junction 形态下(specRoot 经 junction 指回本地 .sillyspec),每条 CLI 命令的
493
+ // 恢复链/写入链都会命中自指检测并 warn——同一事实每进程重复刷屏。跨进程 10min 窗口
494
+ // marker(与 sync-noise 的失败噪音闸同款机制):窗口内静默,窗口外首报完整展示并续窗。
495
+ const SELFREF_WARN_WINDOW_MS = 10 * 60 * 1000
496
+ const SELFREF_WARN_MARKER = 'selfref-pointer-warn.json'
497
+
498
+ export function warnSelfRefPointerOnce(cwd, specRoot, message) {
499
+ try {
500
+ const dir = join(cwd, '.sillyspec', '.runtime')
501
+ mkdirSync(dir, { recursive: true })
502
+ const markerPath = join(dir, SELFREF_WARN_MARKER)
503
+ const now = Date.now()
504
+ try {
505
+ const m = JSON.parse(readFileSync(markerPath, 'utf8'))
506
+ if (m && Number.isFinite(m.at) && now - m.at < SELFREF_WARN_WINDOW_MS) return
507
+ } catch { /* 无 marker / 损坏 → 首报 */ }
508
+ writeFileSync(markerPath, JSON.stringify({ at: now, specRoot: specRoot || null }) + '\n')
509
+ } catch { /* marker 落盘失败 → 照常 warn(降频是优化不是语义) */ }
510
+ console.warn(message)
511
+ }
512
+
469
513
  export function writePlatformPointer(cwd, platformOpts, extra = {}) {
470
514
  if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
471
515
  // 自指写入拦截(FR-4,变更 2026-08-23-repo-native-spec-backfill):specRoot 经 realpath
@@ -473,8 +517,10 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
473
517
  // 单点收口同时覆盖 runCommand 与 cmdInit(writeInitPlatformPointer) 两调用方——旧模板
474
518
  // scan flag / init 平台模式显式传自指 specRoot 也不再重新投毒。runtimeRoot-only 组合
475
519
  // 无 specRoot 可判(isSelfReferentialSpecRoot 对 null 恒 false),原语义不变。
520
+ // 警告经 warnSelfRefPointerOnce 跨进程窗口降频(junction 形态下每命令命中的重复噪音)。
476
521
  if (isSelfReferentialSpecRoot(cwd, platformOpts.specRoot)) {
477
- console.warn(`⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}`)
522
+ warnSelfRefPointerOnce(cwd, platformOpts.specRoot,
523
+ `⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}(10 分钟内不重复提示)`)
478
524
  return false
479
525
  }
480
526
  // temp 残留写侧守卫(2026-09-08 temp 投毒治理,防患于未然):specRoot 在系统 temp 且
@@ -703,9 +749,10 @@ export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
703
749
 
704
750
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
705
751
  // 平台模式不再整体跳过(2026-08-26 用户决策):上行回传(进度 + 四件套 + spec 树)
706
- // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(daemon 注入通道,
707
- // sync.js _getPlatform,链路 D 同款);未注入 env 且 local.yaml 无 platform 段时
708
- // sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走平台自有链路(见各自门禁)。
752
+ // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(预留通道,daemon 未
753
+ // 实现注入——需外部显式 export,sync.js _getPlatform,链路 D 同款)或 local.yaml
754
+ // platform 段;两者皆缺时 sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走
755
+ // 平台自有链路(见各自门禁)。
709
756
  try {
710
757
  // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
711
758
  // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
@@ -1031,9 +1078,48 @@ export function predictProtectedQuickFiles(files, { linkedChanges = [], forceBas
1031
1078
  })
1032
1079
  }
1033
1080
 
1081
+ /**
1082
+ * 软归属匹配(2026-09-10 用户反馈:--file-notes 括注只落声明文件,同模块测试文件漏声明时审计行
1083
+ * 有提示但文件行不自动补齐,连续多批需手工核对):窗口内未声明的**测试文件**按 basename stem 对上
1084
+ * 声明文件 stem → 软归属候选。stem 相等或「声明 stem + 分隔符(- _ .)」前缀(src/change-list.js ↔
1085
+ * test/change-list-operation.test.mjs 两种布局均命中)。测试文件判定:tests/__tests__/specs? 目录内
1086
+ * 任意文件,或 basename 带 .test./_test./-test./.spec. 标记(JS/TS/Go/Python 主流命名,前缀式
1087
+ * test_foo.py 仅在测试目录内才认——防 src/test-utils.js 这类助手误判)。
1088
+ * 只出候选,不动 attributedFiles——硬归属口径仍是「声明即归属」(2026-08-18 误归属修复),软归属
1089
+ * 错认可由审计行 🔍 追溯后手工剔除。
1090
+ * @param {string[]} declaredFiles 声明边界(allowedFiles)
1091
+ * @param {string[]} undeclaredFiles 窗口内未声明脏文件
1092
+ * @returns {string[]} undeclaredFiles 的软归属子集(正斜杠归一、保序去重)
1093
+ */
1094
+ export function matchSameModuleTestFiles(declaredFiles, undeclaredFiles) {
1095
+ const TEST_DIR_RE = /(^|\/)(tests?|__tests__|specs?)\//i
1096
+ const TEST_BASENAME_RE = /[._-](test|spec)\.[^.]+$/i
1097
+ const stemOf = (p) => {
1098
+ const base = basename(String(p).replace(/\\/g, '/'))
1099
+ const dot = base.lastIndexOf('.')
1100
+ let stem = dot > 0 ? base.slice(0, dot) : base
1101
+ stem = stem.replace(/[._-](test|spec)$/i, '').replace(/^(test|spec)[._-]/i, '')
1102
+ return stem.toLowerCase()
1103
+ }
1104
+ const anchors = (Array.isArray(declaredFiles) ? declaredFiles : [])
1105
+ .map(stemOf).filter((s) => s !== '')
1106
+ if (anchors.length === 0) return []
1107
+ const out = []
1108
+ for (const f of (Array.isArray(undeclaredFiles) ? undeclaredFiles : [])) {
1109
+ const posix = String(f).replace(/\\/g, '/')
1110
+ const base = posix.slice(posix.lastIndexOf('/') + 1)
1111
+ if (!base) continue
1112
+ if (!TEST_DIR_RE.test(posix) && !TEST_BASENAME_RE.test(base)) continue
1113
+ const stem = stemOf(posix)
1114
+ const hit = anchors.some((a) => stem === a || stem.startsWith(a + '-') || stem.startsWith(a + '_') || stem.startsWith(a + '.'))
1115
+ if (hit && !out.includes(posix)) out.push(posix)
1116
+ }
1117
+ return out
1118
+ }
1119
+
1034
1120
  /**
1035
1121
  * quick 完成审计:对比 baseline 与实际变更。
1036
- * @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[]}> }}
1122
+ * @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[]}> }}
1037
1123
  */
1038
1124
  /**
1039
1125
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -1143,7 +1229,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1143
1229
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
1144
1230
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
1145
1231
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
1146
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], foreignSessionDeclared: [] }
1232
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [], softTestFiles: [], foreignSessionDeclared: [] }
1147
1233
 
1148
1234
  try {
1149
1235
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -1363,7 +1449,13 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1363
1449
  const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged, crossRepoRoots: quickCfg.crossRepoRoots })
1364
1450
  if (!dc.ok) {
1365
1451
  result.reasons.push(`本次改动文档含 ${dc.invalid.length} 处失效 file:line 引用(sillyspec docs check 可复现)`)
1366
- result.docsCheckHint = { invalid: dc.invalid.length, total: dc.total }
1452
+ // invalidRefs 明细(2026-09-11 用户实证:只报计数 N/M 不指名哪处,四轮才定位到一处
1453
+ // 预存债——门禁输出直接带 文件:行号,另跑 docs check 复现是逃生不是定位):封顶 10 条
1454
+ result.docsCheckHint = {
1455
+ invalid: dc.invalid.length, total: dc.total,
1456
+ invalidRefs: dc.invalid.slice(0, 10).map(i => ({ doc: i.doc, docLine: i.docLine, ref: i.ref, reason: String(i.reason || '').slice(0, 120) })),
1457
+ invalidTruncated: dc.invalid.length > 10,
1458
+ }
1367
1459
  if (result.status === 'safe') result.status = 'warning'
1368
1460
  }
1369
1461
  } catch { /* docs-check 不可用(老版本包等)→ 静默跳过,不阻断 quick */ }
@@ -1450,9 +1542,13 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1450
1542
  for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
1451
1543
  result.attributedFiles = own
1452
1544
  result.undeclaredFiles = result.changedFiles.filter(f => !declaredNorm.has(normalizeGitPath(f)))
1545
+ // 软归属(2026-09-10 用户反馈):未声明测试文件 stem 对上声明 stem → softTestFiles(收尾
1546
+ // 补入文件行带「软归属」括注 + 审计行单列 🔍,与 ⚖️ 真未知区分,错认可追溯剔除)。
1547
+ result.softTestFiles = matchSameModuleTestFiles(allowedFiles, result.undeclaredFiles)
1453
1548
  } else {
1454
1549
  result.attributedFiles = result.changedFiles
1455
1550
  result.undeclaredFiles = []
1551
+ result.softTestFiles = []
1456
1552
  }
1457
1553
 
1458
1554
  // --confirm 模式:展示 diff 并等待确认
@@ -1762,3 +1858,38 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1762
1858
  return def ? def.steps : null
1763
1859
  }
1764
1860
 
1861
+
1862
+ /**
1863
+ * 平台模式产物双写镜像(坑 platform-init-artifact-daemon-dir-only,2026-09-10 驾驭小结
1864
+ * 第三批②,用户两次实锤):agent 在平台接管项目(pointer 存在)本地跑 design-init /
1865
+ * verify-probes --init 时,spec 根解析为 hub/daemon specs 同步目录——产物只落镜像根,
1866
+ * 主仓 changeDir 要等 spec-sync 回程才可见(同步失败即失联)。双写:镜像根照写(平台
1867
+ * 权威数据面不动),同时把内容镜像到 cwd 本地主仓 changes/<change>/<file>——本地即时
1868
+ * 可见可核对,不再单点依赖 spec-sync 兜底。
1869
+ * 跳过条件(任一):非平台模式(platformBase 空)/ 显式 --spec-dir(daemon 上下文——
1870
+ * cwd 不是主仓,镜像会写错地方)/ 本地主仓 changeDir 不存在(不凭空建目录)/ 镜像与
1871
+ * 本地同路径(本地模式正常态)。
1872
+ * @param {{ cwd: string, changeName: string, file: string, content: string,
1873
+ * platformBase: string|null, specDirExplicit?: boolean|string }} opts
1874
+ * @returns {string|null} 镜像落盘路径(跳过返回 null)
1875
+ */
1876
+ export function mirrorPlatformArtifactToMainRepo({ cwd, changeName, file, content, platformBase, specDirExplicit = false, ensureParentDir = false }) {
1877
+ if (!platformBase || specDirExplicit) return null
1878
+ const localPath = join(cwd, '.sillyspec', 'changes', changeName, file)
1879
+ const mirrorPath = join(platformBase, 'changes', changeName, file)
1880
+ if (resolve(localPath) === resolve(mirrorPath)) return null
1881
+ if (!existsSync(dirname(localPath))) {
1882
+ // ensureParentDir(taskcard 等子目录产物用,2026-09-10 驾驭小结第六批①):本地 changeDir
1883
+ // 在而子目录(tasks/)未建时建子目录——守卫仍在 changeDir 层(不凭空建变更目录)。
1884
+ if (!ensureParentDir || !existsSync(dirname(dirname(localPath)))) return null
1885
+ try { mkdirSync(dirname(localPath), { recursive: true }) } catch { return null }
1886
+ }
1887
+ try {
1888
+ writeFileSync(localPath, content)
1889
+ console.log(`📎 平台模式产物已双写镜像主仓: ${localPath}(不再单点依赖 spec-sync 回程)`)
1890
+ return localPath
1891
+ } catch (e) {
1892
+ console.warn(`⚠️ 平台产物镜像主仓失败(不阻断,仍以 ${mirrorPath} 为权威): ${e && e.message ? e.message : e}`)
1893
+ return null
1894
+ }
1895
+ }
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};` +
@@ -473,6 +476,20 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
473
476
  if (allowNew) parts.push('允许新增文件')
474
477
  if (allowDelete) parts.push('允许删除文件')
475
478
  console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
479
+ // 归属点名(2026-09-11 用户实证:--done 审计的 allowedFiles 自动推断可漏实改文件,
480
+ // 被拦后与危险文件混推 --force-baseline。启动未显式 --files 时,把工作区 src/test 前缀
481
+ // 脏文件点名让用户当场确认归属——预声明即精确边界,免去收尾被拦一轮)。
482
+ if (allowedFiles.length === 0 && Array.isArray(baselineFiles)) {
483
+ const declCandidates = baselineFiles
484
+ .map((p) => String(p).split('\\').join('/'))
485
+ .filter((p) => p.startsWith('src/') || p.startsWith('test/'))
486
+ if (declCandidates.length > 0) {
487
+ console.log(`👀 工作区已有 ${declCandidates.length} 个 src/test 脏文件(本会话如会改到其中部分,建议现在声明边界;并行会话的他者改动勿声明):`)
488
+ for (const p of declCandidates.slice(0, 8)) console.log(` - ${p}`)
489
+ if (declCandidates.length > 8) console.log(` … 共 ${declCandidates.length} 个`)
490
+ console.log(` 声明:sillyspec run quick --files <a.js,b.js> --change ${changeName}(恢复会话追加边界)`)
491
+ }
492
+ }
476
493
  console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
477
494
  // 空壳会话探测(坑 quick-duplicate-empty-shell,2026-09-03 用户实证:一次输出被吞 →
478
495
  // agent 误判失败重跑 → 重复空壳会话,此前只能手工 reset + 删骨架)。新会话起步时点名
@@ -577,6 +594,18 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
577
594
  // 主流程 step1「进度确认」noAI 化(2026-09-07):brainstorm/execute/verify——
578
595
  // 快照本就 CLI 注入、阶段路由已由 run 落定,省一轮「复述摘要→--done」往返
579
596
  executeProgressConfirm({ stageName, cwd, stageData, changeName, pm })
597
+ } else if (cliAction === 'verifyRunQualityScan') {
598
+ // P0-1(noai-ir-roadmap §3):verify「运行测试和质量扫描」noAI 化——test/lint 实测提前,
599
+ // 指纹落盘供 --done 复用免重跑(长套件不再跑两遍);失败 throw 不盖 completed,
600
+ // 输出 + 并行 WIP 归因提示透传给 agent(前置一/二)。
601
+ const { executeVerifyQualityScan } = await import('./verify-quality-scan.js')
602
+ await executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts })
603
+ } else if (cliAction === 'archiveDistill') {
604
+ // P0-4 安全变体(noai-ir-roadmap §3):archive「decision-distill 决策提炼」noAI 化——
605
+ // 提炼本体是 CLI 纯函数(旧 prompt 即「调函数转述输出」纯中继);needsWait 裁决收敛到
606
+ // 「确认归档 --confirm」。全路径不抛(裁决是确认步输入,非本步阻断条件)。
607
+ const { executeArchiveDistill } = await import('./archive-distill.js')
608
+ await executeArchiveDistill({ cwd, specBase, changeName })
580
609
  } else {
581
610
  throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
582
611
  }