sillyspec 3.28.4 → 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 (49) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/src/agent-session-log.js +4 -3
  5. package/src/archive-delta.js +2 -2
  6. package/src/change-list.js +11 -1
  7. package/src/commit-suggest.js +52 -1
  8. package/src/config-cat.js +6 -1
  9. package/src/decision-distill.js +45 -5
  10. package/src/decisions-io.js +96 -0
  11. package/src/design-facts.js +3 -0
  12. package/src/dispatch/probe.js +31 -8
  13. package/src/docs-check.js +68 -3
  14. package/src/index.js +426 -17
  15. package/src/mcp-server.js +144 -0
  16. package/src/module-impact.js +8 -1
  17. package/src/modules.js +46 -34
  18. package/src/progress/change-registry.js +18 -0
  19. package/src/progress.js +13 -1
  20. package/src/quicklog.js +60 -6
  21. package/src/review-dispatch.js +377 -0
  22. package/src/run/archive-distill.js +48 -0
  23. package/src/run/command.js +19 -5
  24. package/src/run/complete-handlers.js +63 -3
  25. package/src/run/complete.js +139 -2
  26. package/src/run/gate-snapshot.js +88 -0
  27. package/src/run/gates.js +115 -55
  28. package/src/run/prompt.js +54 -9
  29. package/src/run/quick-audit.js +38 -13
  30. package/src/run/shared.js +109 -6
  31. package/src/run/stage.js +17 -2
  32. package/src/run/verify-quality-scan.js +336 -0
  33. package/src/scope-audit.js +735 -0
  34. package/src/sillyhub-mcp/client.js +58 -3
  35. package/src/stage-review-checklist.js +44 -0
  36. package/src/stage-review.js +122 -4
  37. package/src/stages/archive.js +57 -164
  38. package/src/stages/brainstorm.js +5 -9
  39. package/src/stages/execute.js +13 -10
  40. package/src/stages/plan.js +3 -10
  41. package/src/stages/verify.js +14 -33
  42. package/src/sync.js +51 -10
  43. package/src/task-review.js +108 -16
  44. package/src/taskcard.js +46 -3
  45. package/src/validate-artifacts.js +163 -0
  46. package/src/verify-postcheck.js +90 -10
  47. package/src/verify-probes.js +134 -12
  48. package/src/worktree-apply.js +119 -14
  49. package/templates/agents-instruction.md +1 -0
package/src/run/prompt.js CHANGED
@@ -803,13 +803,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
803
803
  }
804
804
  } catch {}
805
805
  if (!runId) {
806
- const { generateExecuteRunId } = await import('../task-review.js')
807
- runId = generateExecuteRunId()
806
+ const { generateExecuteRunId, claimExecuteRunId } = await import('../task-review.js')
807
+ runId = generateExecuteRunId(changeName)
808
808
  // 落盘(与启动站点一致),保证 agent 收到的 ID == gate/checkbox 读取的 ID
809
809
  // D-001#1 fallback 写入点:mkdir execute-runs/<runId>/tasks 先于 marker(不变量:marker 在则目录在)。
810
810
  // 渲染路径异常不能炸 prompt 输出 → catch 内 console.error 留痕 + 保留降级(ID 注入继续,
811
811
  // 目录由下游 review 写入 / 其他写入点补齐)。
812
812
  try {
813
+ // 排他认领(坑 exec-run-id-same-second-collision):并行会话同秒碰撞在认领处消解
814
+ runId = claimExecuteRunId(runtimeRoot, runId)
813
815
  mkdirSync(join(runtimeRoot, 'execute-runs', runId, 'tasks'), { recursive: true })
814
816
  writeAtomicSync(runIdFile, runId + '\n')
815
817
  } catch (e) {
@@ -825,7 +827,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
825
827
  if (['brainstorm', 'plan', 'execute'].includes(stageName) && promptText.includes('{REVIEW_TIER}')) {
826
828
  try {
827
829
  const { classifyReviewTier } = await import('../review-tier.js')
828
- const { generateStageReviewRunId, renderReviewJsonContract, stageReviewMarkerPath } = await import('../stage-review.js')
830
+ const { generateStageReviewRunId, renderReviewJsonContract, stageReviewMarkerPath, readReviewChannelPriority } = await import('../stage-review.js')
829
831
  const tierSpecBase = resolvePromptSpecBase(platformOpts, cwd)
830
832
  const tierChangeDir = changeName ? join(tierSpecBase, 'changes', changeName) : null
831
833
  const designPath = tierChangeDir ? join(tierChangeDir, 'design.md') : null
@@ -859,8 +861,12 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
859
861
  console.log(` 📁 Stage Review 写入目录(直接复制给 review 子代理,勿手拼 runId):${reviewDir}/`)
860
862
  } catch {}
861
863
  }
862
- // review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源
863
- const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier })
864
+ // review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源。
865
+ // 通道优先序显式传入(readReviewChannelPriority 吃精确 cwd——平台模式 specRoot 异位时
866
+ // process.cwd 兜底会读错 local.yaml,2026-09-10 通道配置批次);tier=independent 时契约
867
+ // 头部渲染「审查执行通道」段(按用户配置序,见 local.yaml review_dispatch.channel_priority)。
868
+ const reviewChannelPriority = readReviewChannelPriority(cwd)
869
+ const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier, channelPriority: reviewChannelPriority })
864
870
  promptText = promptText
865
871
  .split('{REVIEW_TIER}').join(tier.tier)
866
872
  .split('{REVIEW_TIER_REASON}').join(tier.reason)
@@ -1012,6 +1018,29 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1012
1018
  promptText = promptText.split('{ARCHIVE_IMPACT_AUDIT}').join(auditBlock)
1013
1019
  }
1014
1020
 
1021
+ // {SCOPE_AUDIT_TABLE}(2026-09-10-change-scope-audit task-05,FR-03):archive「确认归档」步的
1022
+ // 变更范围对账全表注入——computeChangeScopeAudit + renderScopeAuditTable 代算(计划×实际
1023
+ // 三态 + 行数,D-003 同源:只 import scope-audit.js 导出,不自研采集)。maxRows 60 截断防
1024
+ // prompt 膨胀(R-03,表尾自带「完整表跑 scope-audit」指引)。fail-soft:注入异常降级单行
1025
+ // 指引(回退手跑 scope-audit 命令),不阻断归档 prompt 输出(对齐 {ARCHIVE_IMPACT_AUDIT}
1026
+ // 注入先例);ok=false(quick 会话不存在 / 实际侧整体失败等)同样附手跑指引——归档零新增
1027
+ // 阻断面(D-006)。
1028
+ if (stageName === 'archive' && promptText.includes('{SCOPE_AUDIT_TABLE}')) {
1029
+ let scopeTable = ''
1030
+ try {
1031
+ const { computeChangeScopeAudit, renderScopeAuditTable } = await import('../scope-audit.js')
1032
+ const saSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1033
+ const saResult = await computeChangeScopeAudit({ cwd, specBase: saSpecBase, changeName, platformOpts })
1034
+ scopeTable = renderScopeAuditTable(saResult, { maxRows: 60 })
1035
+ if (saResult && saResult.ok === false) {
1036
+ scopeTable += '\n(对账降级——回退手跑 sillyspec scope-audit --change <name> 查看原因后再确认归档,注入失败不阻断归档)'
1037
+ }
1038
+ } catch (e) {
1039
+ scopeTable = '(变更范围对账表注入失败:' + (e && e.message ? e.message : e) + '——回退手跑 sillyspec scope-audit --change <name> 查看后再确认归档,注入失败不阻断归档)'
1040
+ }
1041
+ promptText = promptText.split('{SCOPE_AUDIT_TABLE}').join(scopeTable)
1042
+ }
1043
+
1015
1044
  // 注入模块上下文(brainstorm/plan/execute 阶段全步 + quick 首步「理解任务」——刀①:quick
1016
1045
  // step1 原让 agent cat module-map 再挑模块卡读,改为按任务描述匹配后注入,基于 Module Context Index)
1017
1046
  const quickFirstStep = stageName === 'quick' && step && step.name === '理解任务'
@@ -1087,7 +1116,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1087
1116
  console.log('- CLI 子命令以本 prompt 或上一条 --done 输出的字面为准;不确定时停下问用户,猜测命令会让状态机推进到错误位置。')
1088
1117
  console.log('- 本步骤产物落盘后立即执行 prompt 末尾的 --done:CLI 据此校验产出并推进状态机;不跑则进度永远停在本步。')
1089
1118
  console.log('- 变更目录改名用 `sillyspec change-rename <旧名> <新名>`:mv/rename 会漏改进度库引用,导致变更失联。')
1090
- console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
1119
+ console.log('- 变更产物文档一律用 CLI 骨架命令生成(fourpiece-init / design-init / taskcard / module-impact / verify-probes --init)——骨架已预填 author/created_at/generated_by 元数据,勿删勿手拼 frontmatter;仅从零手写的补充文档才需手填 author(git 用户名)与 created_at(精确到秒)')
1091
1120
  console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
1092
1121
  }
1093
1122
  // 平台模式 + 路径规则(安全关键,每步注入;step1+ 起带精简标题,不复述通用铁律)
@@ -1123,6 +1152,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1123
1152
  const mayNeedWait = WAIT_MARKER_RE.test(stepPrompt) || requiresWait || conditionalWait
1124
1153
 
1125
1154
  console.log(`\n### 完成后执行`)
1155
+ // P0-3(noai-ir-roadmap §3):gate 预检前置——gate 失败 → rollback → 重做一轮是最贵的循环,
1156
+ // 多数失败是机械的(占位符未替换/文件未建/行号失效)。gate 是只读聚合预检,--done 前跑一次
1157
+ // 把「重做一轮」变「本地预检」。主阶段且有 change 才提示(quick 边界审计在 --done 内联、
1158
+ // 辅助阶段无 gate 语义)。
1159
+ if (['brainstorm', 'plan', 'execute', 'verify', 'archive'].includes(stageName) && changeName) {
1160
+ console.log(`💡 预检(只读,省一轮 gate 失败重跑):sillyspec gate ${stageName} --change ${changeName} [--json]`)
1161
+ }
1126
1162
  // requiresConfirm 步骤(如 archive「确认归档」)的完成命令必须带 --confirm——坑
1127
1163
  // archive-batch-31-tool-notes ②:通用 --done 模板不带该 flag,agent 照抄执行撞
1128
1164
  // 「请添加 --confirm」确认门,误以为参数没带。
@@ -1146,15 +1182,24 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1146
1182
  console.log(`如果不需要用户决策,正常完成:`)
1147
1183
  }
1148
1184
  console.log(doneCommand + (autoMeta ? '' : ' --input "用户原始需求/反馈"'))
1185
+ // P0-2(noai-ir-roadmap §3):--output 可省略提示(auto 模式除外——auto driver 的
1186
+ // SS-META doneCommand 仍带 --output,照抄路径零变化;省略路径由 command.js 同源合成)。
1187
+ if (!autoMeta) {
1188
+ console.log(`(--output 可省略:省略时 CLI 按门禁与 diff 事实合成事实性摘要;方案取舍/用户反馈等语义说明才需要手写)`)
1189
+ }
1149
1190
 
1150
1191
  // ── SS-META 机器可读元数据块(change: 2026-09-08-auto-driver,D-002@v1,FR-01)──
1151
- // 仅 auto 模式(autoMeta 非空)渲染:单行 HTML 注释内 JSON,agent 低噪可读、脚本一行正则
1152
- // 可提取(<!--SS-META:(.*)-->)。requiresUser 四源见上方纯函数;doneCommand 与正文同源。
1153
- if (autoMeta) {
1192
+ // 单行 HTML 注释内 JSON,agent 低噪可读、脚本一行正则可提取(<!--SS-META:(.*)-->)。
1193
+ // requiresUser 四源见上方纯函数;doneCommand 与正文同源。
1194
+ // P1-3(noai-ir-roadmap §4):SS-META 从 auto 专属毕业——常规模式 `run <stage> --meta`
1195
+ // 同样渲染(command.js 置 SILLYSPEC_RUN_META=1,CLI 短进程生命周期内有效,不跨调用泄漏);
1196
+ // meta 增补 change 字段(宿主脚本免再从正文 header 行解析变更名,向后兼容追加)。
1197
+ if (autoMeta || process.env.SILLYSPEC_RUN_META === '1') {
1154
1198
  const meta = {
1155
1199
  stage: stageName,
1156
1200
  stepIndex: stepIndex + 1,
1157
1201
  stepName: step.name,
1202
+ change: changeName || null,
1158
1203
  requiresUser: requiresWait || mayNeedWait || step.requiresConfirm === true,
1159
1204
  doneCommand,
1160
1205
  waitHint: (requiresWait || mayNeedWait)
@@ -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
@@ -466,6 +466,28 @@ export function detectEmptyShellQuickSessions(platformOpts, localSpecBase, curre
466
466
  */
467
467
  export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
468
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
+
469
491
  export function writePlatformPointer(cwd, platformOpts, extra = {}) {
470
492
  if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
471
493
  // 自指写入拦截(FR-4,变更 2026-08-23-repo-native-spec-backfill):specRoot 经 realpath
@@ -473,8 +495,10 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
473
495
  // 单点收口同时覆盖 runCommand 与 cmdInit(writeInitPlatformPointer) 两调用方——旧模板
474
496
  // scan flag / init 平台模式显式传自指 specRoot 也不再重新投毒。runtimeRoot-only 组合
475
497
  // 无 specRoot 可判(isSelfReferentialSpecRoot 对 null 恒 false),原语义不变。
498
+ // 警告经 warnSelfRefPointerOnce 跨进程窗口降频(junction 形态下每命令命中的重复噪音)。
476
499
  if (isSelfReferentialSpecRoot(cwd, platformOpts.specRoot)) {
477
- console.warn(`⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}`)
500
+ warnSelfRefPointerOnce(cwd, platformOpts.specRoot,
501
+ `⚠️ 检测到自指平台指针(repo-native junction 回环,specRoot 指回本地 .sillyspec),已跳过指针写入并按本地模式运行: ${platformOpts.specRoot}(10 分钟内不重复提示)`)
478
502
  return false
479
503
  }
480
504
  // temp 残留写侧守卫(2026-09-08 temp 投毒治理,防患于未然):specRoot 在系统 temp 且
@@ -703,9 +727,10 @@ export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
703
727
 
704
728
  export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
705
729
  // 平台模式不再整体跳过(2026-08-26 用户决策):上行回传(进度 + 四件套 + spec 树)
706
- // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(daemon 注入通道,
707
- // sync.js _getPlatform,链路 D 同款);未注入 env 且 local.yaml 无 platform 段时
708
- // sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走平台自有链路(见各自门禁)。
730
+ // 在平台模式同样执行,凭据经 env SILLYHUB_PLATFORM_URL/TOKEN(预留通道,daemon 未
731
+ // 实现注入——需外部显式 export,sync.js _getPlatform,链路 D 同款)或 local.yaml
732
+ // platform 段;两者皆缺时 sync() 内部静默跳过(合法状态)。下行 pull / 审批检查仍走
733
+ // 平台自有链路(见各自门禁)。
709
734
  try {
710
735
  // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
711
736
  // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
@@ -1031,9 +1056,48 @@ export function predictProtectedQuickFiles(files, { linkedChanges = [], forceBas
1031
1056
  })
1032
1057
  }
1033
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
+
1034
1098
  /**
1035
1099
  * 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[]}> }}
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[]}> }}
1037
1101
  */
1038
1102
  /**
1039
1103
  * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
@@ -1143,7 +1207,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1143
1207
  // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
1144
1208
  // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
1145
1209
  // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
1146
- 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: [] }
1147
1211
 
1148
1212
  try {
1149
1213
  // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
@@ -1450,9 +1514,13 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1450
1514
  for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
1451
1515
  result.attributedFiles = own
1452
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)
1453
1520
  } else {
1454
1521
  result.attributedFiles = result.changedFiles
1455
1522
  result.undeclaredFiles = []
1523
+ result.softTestFiles = []
1456
1524
  }
1457
1525
 
1458
1526
  // --confirm 模式:展示 diff 并等待确认
@@ -1762,3 +1830,38 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1762
1830
  return def ? def.steps : null
1763
1831
  }
1764
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};` +
@@ -577,6 +580,18 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
577
580
  // 主流程 step1「进度确认」noAI 化(2026-09-07):brainstorm/execute/verify——
578
581
  // 快照本就 CLI 注入、阶段路由已由 run 落定,省一轮「复述摘要→--done」往返
579
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 })
580
595
  } else {
581
596
  throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
582
597
  }