sillyspec 3.28.10 → 3.28.11

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 (45) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/package.json +1 -1
  6. package/src/change-risk-profile.js +51 -3
  7. package/src/config-schema.js +21 -0
  8. package/src/design-facts.js +46 -9
  9. package/src/docs-check.js +14 -10
  10. package/src/docs-debt.js +7 -2
  11. package/src/docs-gate.js +42 -15
  12. package/src/endpoint-extractor.js +50 -2
  13. package/src/foreign-declared.js +25 -16
  14. package/src/hooks/worktree-guard.js +75 -2
  15. package/src/index.js +129 -1
  16. package/src/machine-interface.js +26 -0
  17. package/src/probe7-anchor-check.js +20 -2
  18. package/src/progress/stage-machine.js +34 -5
  19. package/src/progress.js +1 -1
  20. package/src/quicklog.js +231 -1
  21. package/src/run/complete-handlers.js +172 -1
  22. package/src/run/complete.js +73 -3
  23. package/src/run/gate-snapshot.js +262 -0
  24. package/src/run/gates.js +41 -7
  25. package/src/run/prompt.js +79 -1
  26. package/src/run/shared.js +10 -0
  27. package/src/stage-contract-spec.js +14 -0
  28. package/src/stage-contract.js +314 -8
  29. package/src/stage-review-checklist.js +3 -1
  30. package/src/stage-review.js +137 -2
  31. package/src/stages/archive.js +9 -5
  32. package/src/stages/brainstorm.js +7 -1
  33. package/src/stages/execute.js +62 -8
  34. package/src/stages/plan-postcheck.js +42 -5
  35. package/src/stages/plan.js +8 -1
  36. package/src/stages/quick.js +5 -5
  37. package/src/stages/verify.js +3 -2
  38. package/src/sync.js +69 -0
  39. package/src/task-review.js +9 -3
  40. package/src/verify-facts-schema.js +78 -9
  41. package/src/verify-postcheck.js +260 -26
  42. package/src/verify-probes.js +780 -44
  43. package/src/worktree-apply.js +175 -3
  44. package/src/write-audit.js +55 -0
  45. package/.claude/full.json +0 -1
@@ -9,6 +9,9 @@
9
9
  * srcDir 缺失时走 findAlreadyArchivedDir 幂等自愈(issue archive-stage-physical-tracking-desync);
10
10
  * archiveChangeDirectory + findAlreadyArchivedDir 已 export 供 test 直接 import
11
11
  * - sanitizeProjectName / validateParsedProjects:项目名清洗 + 列表校验纯函数(handleScanProjectListStep 专用)
12
+ * - assertWaveTasksComplete:execute「Wave N 执行」步骤 --done 前的完成度门(task-08 / FR-12——
13
+ * 本 Wave 任一 task 的 tasks.md checkbox 未勾 → exit 1;completeStep 在 status='completed' 赋值前
14
+ * 调用,异常 fail-open),实现收拢文件尾(同知识闭环段先例)
12
15
  *
13
16
  * 安全锚:run.js 始终 barrel。3 handler 由 run.js import 回来;sanitizeProjectName + validateParsedProjects
14
17
  * 被 test 直接 import(run-sanitize-project-name / run-scan-project-parse),run.js barrel re-export 契约保留。
@@ -46,7 +49,9 @@ import { resolveSessionIdentity } from '../progress.js'
46
49
  // ql-20260915-001 修复④:chunkPaths(argv 分批)供归档窄化 add / minePaths 精确补暂存用。
47
50
  // 无环:worktree-apply 静态闭包(worktree/task-review/quicklog 等)不引本文件;既有
48
51
  // withMainRepoLock 走动态 import 是归档链防环的历史形态,chunkPaths 纯函数无此约束。
49
- import { chunkPaths } from '../worktree-apply.js'
52
+ // checkDbScriptDeclarationGate(task-04 / FR-05)同排静态引入:门函数自身纯文件集 ×
53
+ // 文本声明对账(verify-probes 文法经 worktree-apply 顶层动态绑定),同样无环约束。
54
+ import { chunkPaths, checkDbScriptDeclarationGate } from '../worktree-apply.js'
50
55
 
51
56
  /**
52
57
  * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
@@ -785,6 +790,32 @@ export async function handleArchiveConfirmStep({ stageName, steps, currentIdx, c
785
790
  console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
786
791
  return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
787
792
  }
793
+ // ── db/*.sql 声明门·归档前置(2026-09-17-pass-cap-semantics task-04 / FR-05 / D-007/D-012
794
+ // 兜底第二门)── --confirm 已过、目录移动前:apply-manifest.json 的 files 过 worktree-apply
795
+ // 同款门(对账 verify-result.md 声明,含 db-script handover 互斥)。缺失/互斥 → 复用 !
796
+ // confirm 早退形态(status 回 pending + pm._write + early return),变更目录不动、输出修复
797
+ // 指引;apply-manifest.json 缺失 → 门空转不阻断(无 apply 面,主门在 verify 侧事实③);
798
+ // manifest 读取/解析异常 → fail-open warn 放行(门异常不锁死归档)。
799
+ const dbGateManifestPath = join(specBase, 'changes', changeName, 'apply-manifest.json')
800
+ if (existsSync(dbGateManifestPath)) {
801
+ try {
802
+ const dbGateManifest = JSON.parse(readFileSync(dbGateManifestPath, 'utf8'))
803
+ const dbGateFiles = (dbGateManifest && Array.isArray(dbGateManifest.files) ? dbGateManifest.files : [])
804
+ .map((e) => (e && typeof e === 'object' ? e.path : e)).filter(Boolean)
805
+ const dbGate = checkDbScriptDeclarationGate({ projectRoot: cwd, specBase, changeName, files: dbGateFiles })
806
+ if (!dbGate.ok) {
807
+ steps[currentIdx].status = 'pending'
808
+ steps[currentIdx].completedAt = null
809
+ if (outputText) steps[currentIdx].output = null
810
+ pm._write(cwd, progress, changeName)
811
+ console.error(`⛔ db 脚本执行声明对账未过(归档阻断,变更目录未移动):\n${dbGate.error}`)
812
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
813
+ }
814
+ if (dbGate.warning) console.warn(`⚠️ ${dbGate.warning}`)
815
+ } catch (e) {
816
+ console.warn(`⚠️ db 声明门异常降级放行(apply-manifest.json 读取/解析失败,fail-open): ${(e && e.message) || e}`)
817
+ }
818
+ }
788
819
  // ── 归档前 delta.md 自动生成(P3d task-02,fail-soft 零阻断)──
789
820
  // 目录移走前在 changes/<name>/ 落一份 Before/Delta/After 三段式快照(四源采集容缺,
790
821
  // buildDeltaReport 见 ../archive-delta.js),随归档包留存。失败只 console.error 留痕、
@@ -2481,3 +2512,143 @@ export function extractQuickCauseField(outputText) {
2481
2512
  return m[1].replace(/\s+/g, ' ').trim()
2482
2513
  }
2483
2514
 
2515
+ // ── Wave 步骤完成度门(2026-09-17-pass-cap-semantics task-08 / FR-12 / D-013@v1)──
2516
+ // 动机锚:2026-09-17 本变更执行中 Wave 2 越位实证——review write 退出码被 shell 管道吞掉后
2517
+ // --done 落在下一 Wave,「Wave 2 执行」步骤被静默标 completed;既有防护(--step 意图断言
2518
+ // 可选、并发 60s 横幅仅 warn)均非阻断。本门 fail-closed 补执行期缺口,放文件尾(函数声明
2519
+ // 提升 + B1 源码文本级断言窗口约束,同知识闭环段先例——门内含 process.exit,收拢在此避免
2520
+ // 推挪 handleQuickStageCompletion 附近 detectConcurrentChanges ±[200,600] 的断言锚)。
2521
+
2522
+ /**
2523
+ * 解析 plan.md 第 seq 个显式 Wave 段的任务 ID 列表。
2524
+ *
2525
+ * 同源口径孪生(不造第二套解析):src/stages/execute.js 的 parseWavesFromPlan 未导出,且
2526
+ * execute.js 不在本任务 allowed_paths,无法直接 import——正则与段边界守卫逐字对齐:
2527
+ * - Wave 标题 /^#+\s*Wave\s*(\d+)/i(空格可选、编号后缀任意——解析侧宁可多收不可静默丢)
2528
+ * - 引用行 /^[-*]\s+task-(\d+)\s*$/i → 归一 task-NN(padStart(2,'0'))
2529
+ * - 任何非 Wave 标题行(/^#{1,6}\s+/)退出当前段(「## 自检」等后续段的行不收)
2530
+ * - 段序 = 出现顺序(buildExecuteSteps 的步骤名 `Wave ${i+1} 执行` 取 waves 数组位次,
2531
+ * 非段内编号——显式段存在时两者通常一致,乱序编号时位次与 prompt 实际下发任务一致)
2532
+ * 差异(有意):不做 parseWavesFromPlan 的隐式 Wave 合成——门只核对显式段,plan 无显式段
2533
+ * 一律 null 交由调用方 warn 放行(D-003@v1 隐式串行语义无段可核对,不误伤)。
2534
+ * 漂移风险由 test/wave-task-complete-gate.test.mjs 断言锁定。
2535
+ *
2536
+ * @param {string} planContent plan.md 全文
2537
+ * @param {number} seq 1-based 显式段位次(步骤名编号同源)
2538
+ * @returns {string[]|null} 该段 task ID 列表;null = 显式段不足 seq 个(隐式/light/兜底形态)
2539
+ */
2540
+ function parseExplicitWaveTaskIds(planContent, seq) {
2541
+ const WAVE_HEADING_RE = /^#+\s*Wave\s*(\d+)/i // ← execute.js parseWavesFromPlan 同源正则
2542
+ const waveRefRe = /^[-*]\s+task-(\d+)\s*$/i // ← execute.js parseWavesFromPlan 同源正则
2543
+ const sections = [] // [{ index, ids }] 按出现顺序(不合成隐式 Wave)
2544
+ let current = null
2545
+ for (const rawLine of String(planContent || '').split('\n')) {
2546
+ const line = rawLine.replace(/\r$/, '')
2547
+ const waveMatch = line.match(WAVE_HEADING_RE)
2548
+ if (waveMatch) {
2549
+ current = { index: parseInt(waveMatch[1], 10), ids: [] }
2550
+ sections.push(current)
2551
+ continue
2552
+ }
2553
+ if (/^#{1,6}\s+/.test(line)) { current = null; continue }
2554
+ const refMatch = line.match(waveRefRe)
2555
+ if (refMatch && current) current.ids.push(`task-${refMatch[1].padStart(2, '0')}`)
2556
+ }
2557
+ const target = sections[seq - 1]
2558
+ return target ? target.ids : null
2559
+ }
2560
+
2561
+ /**
2562
+ * Wave 步骤完成度门(task-08 / FR-12 / D-013@v1):execute 阶段名为「Wave N 执行」的步骤
2563
+ * --done 时 fail-closed 核对本 Wave 全部 task 的 tasks.md checkbox 已勾(勾选唯一真源是
2564
+ * CLI:review write 落盘即勾 + autoCheckPlanFromReviews 兜底)。任一未勾 → console.error
2565
+ * 列未勾清单与两条出路 + process.exit(1),步骤保持待完成(接线点在 completeStep 的
2566
+ * status='completed' 赋值之前,exit 时 DB 不落假完成态)。
2567
+ *
2568
+ * 生效面与放行面:
2569
+ * - 仅 steps[currentIdx].name 匹配 /^Wave (\d+) 执行$/ 时生效,其他步骤名直接 return(零行为)
2570
+ * - ① autoCheckPlanFromReviews 幂等先行(review pass 自动勾选——先补勾再核对,「review 已
2571
+ * pass 但 checkbox 未回填」形态不误拦;该函数自身全路径 try/catch 从不抛)
2572
+ * - plan.md 无第 N 个显式 Wave 段(隐式 Wave/light 计划)→ warn 放行(D-003@v1 不误伤)
2573
+ * - plan.md/tasks.md 缺失或读取失败 → warn 放行(fail-open,文档瞬态不锁死流程)
2574
+ * - 门自身意外异常不在本函数内吞——上抛给接线层(complete.js)fail-open warn 放行;
2575
+ * 正常判定路径(读到未勾)必须硬拦,process.exit 不受 try/catch 影响
2576
+ *
2577
+ * @param {{ steps: Array<{name:string}>, currentIdx: number, changeName: string, cwd: string, specBase?: string, platformOpts?: object }} opts
2578
+ * changeDir 锚定与 autoCheckPlanFromReviews 写入侧同口径(driftAnchor > specRoot > specBase >
2579
+ * cwd/.sillyspec)——tasks.md 是 autoCheck 写的,门必须读同一份,防双真源分裂。
2580
+ */
2581
+ export async function assertWaveTasksComplete({ steps, currentIdx, changeName, cwd, specBase, platformOpts = {} }) {
2582
+ const stepName = String(steps?.[currentIdx]?.name || '')
2583
+ const waveMatch = /^Wave (\d+) 执行$/.exec(stepName)
2584
+ if (!waveMatch) return // 非「Wave N 执行」步骤:零行为(其他 execute 步骤 / 其他阶段不门)
2585
+ const waveSeq = parseInt(waveMatch[1], 10)
2586
+ if (!changeName || !cwd) {
2587
+ console.warn(`⚠️ [wave-complete-gate] 缺 changeName/cwd,跳过「${stepName}」完成度核对(fail-open)`)
2588
+ return
2589
+ }
2590
+
2591
+ // ① autoCheck 幂等先行(D-013):兜底 catch 仅防动态 import 自身失败——warn 后继续核对
2592
+ // (checkbox 才是门的判定依据;勾选补偿失败留给两条出路①重跑 --done 再试,不锁死)。
2593
+ // autoCheckPlanFromReviews 定义于 ./complete.js(grep 实证 :1172 export;任务卡原文写
2594
+ // ../task-review.js 系定位笔误,该文件仅有引用注释无定义);动态 import 防顶层环
2595
+ // (complete.js 静态 import 本文件,运行时调用时 complete.js 已在模块缓存,无死锁)。
2596
+ try {
2597
+ const { autoCheckPlanFromReviews } = await import('./complete.js')
2598
+ await autoCheckPlanFromReviews({ stageName: 'execute', changeName, cwd, platformOpts })
2599
+ } catch (e) {
2600
+ console.warn(`⚠️ [wave-complete-gate] autoCheck 先行勾选失败,按 tasks.md 现状继续核对: ${(e && e.message) || e}`)
2601
+ }
2602
+
2603
+ const gateSpecBase = platformOpts?.specDriftAnchor || platformOpts?.specRoot || specBase || join(cwd, '.sillyspec')
2604
+ const changeDir = join(gateSpecBase, 'changes', changeName)
2605
+ const planPath = join(changeDir, 'plan.md')
2606
+ const tasksPath = join(changeDir, 'tasks.md')
2607
+
2608
+ let planContent, tasksContent
2609
+ try {
2610
+ if (!existsSync(planPath) || !existsSync(tasksPath)) {
2611
+ console.warn(`⚠️ [wave-complete-gate] plan.md/tasks.md 缺失(${changeDir}),跳过「${stepName}」完成度核对(fail-open 放行)`)
2612
+ return
2613
+ }
2614
+ planContent = readFileSync(planPath, 'utf8')
2615
+ tasksContent = readFileSync(tasksPath, 'utf8')
2616
+ } catch (e) {
2617
+ console.warn(`⚠️ [wave-complete-gate] plan.md/tasks.md 读取失败(${(e && e.message) || e}),跳过核对(fail-open 放行)`)
2618
+ return
2619
+ }
2620
+
2621
+ // ② 本 Wave 任务 ID 列表(parseExplicitWaveTaskIds:execute.js parseWavesFromPlan 同源孪生)
2622
+ const waveTaskIds = parseExplicitWaveTaskIds(planContent, waveSeq)
2623
+ if (waveTaskIds === null) {
2624
+ console.warn(`⚠️ [wave-complete-gate] plan.md 无第 ${waveSeq} 个显式 Wave 段(隐式 Wave/light 计划无显式段头),跳过完成度核对(fail-open 放行——D-003@v1 隐式串行语义不误伤)`)
2625
+ return
2626
+ }
2627
+ if (waveTaskIds.length === 0) {
2628
+ // 段在但无引用行:execute 启动时 validatePlanForExecute 已拦(空 Wave 段诊断),不重复拦
2629
+ return
2630
+ }
2631
+
2632
+ // ③ tasks.md checkbox 逐 ID 核对(parseTaskRegistry 是 execute.js 导出的同源解析,复用不另造)
2633
+ const { parseTaskRegistry } = await import('../stages/execute.js')
2634
+ const registry = parseTaskRegistry(tasksContent)
2635
+ const regById = new Map(registry.map(t => [t.id, t]))
2636
+ const unchecked = waveTaskIds.filter(id => regById.get(id)?.done !== true)
2637
+
2638
+ if (unchecked.length > 0) {
2639
+ // ④ fail-closed 硬拦:沿用仓内 console.error + process.exit(1) 既有形态(错误信息
2640
+ // process.exit 前同步打印完整——Windows 下 UV_HANDLE_CLOSING 退出码坑不复现)
2641
+ console.error(`❌ Wave ${waveSeq} 完成度门未过:本 Wave ${waveTaskIds.length} 个 task 中 ${unchecked.length} 个的 tasks.md checkbox 未勾——本次 --done 未完成,步骤「${stepName}」保持待完成。`)
2642
+ console.error(' 未勾任务:')
2643
+ for (const id of unchecked) {
2644
+ const reg = regById.get(id)
2645
+ console.error(` - ${id}${reg && reg.name ? `(${reg.name})` : '(tasks.md 注册表无此行——plan 悬空引用)'}`)
2646
+ }
2647
+ console.error(' 两条出路:')
2648
+ console.error(` ① 任务确未完成:补实现与 review write(sillyspec review write --change ${changeName} --task <task-NN> --spec pass --quality pass --force --changed-files <文件>)后重跑 --done——review pass 落盘即自动勾选`)
2649
+ console.error(` ② 确属误推进(步骤被越位标完成):sillyspec run execute --reopen --from-step ${currentIdx + 1}${changeName ? ` --change ${changeName}` : ''} 退回本步重走完成`)
2650
+ process.exit(1)
2651
+ }
2652
+ console.log(`🛡️ 「${stepName}」完成度门通过:${waveTaskIds.length} 个 task checkbox 全勾(${waveTaskIds.join('、')})`)
2653
+ }
2654
+
@@ -16,7 +16,7 @@
16
16
  * - stages/plan-postcheck.js: executePlanPostcheck(noAI 硬门)
17
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
18
18
  */
19
- import { join } from 'node:path'
19
+ import { join, isAbsolute, resolve } from 'node:path'
20
20
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
21
  import { createHash } from 'node:crypto'
22
22
  import { writeAtomicSync } from '../fs-atomic.js'
@@ -43,10 +43,10 @@ async function getStageStepsAutoAware(stageName, cwd, progress, specDir) {
43
43
  import { executeScanPreflight, executeScanPostcheck, computeScanProfile, executeScanDetectProjects, executeScanResumeCheck, executeScanFinalize } from './scan-profile.js'
44
44
  import { executeProgressConfirm } from './progress-confirm.js'
45
45
  import { AUXILIARY_STAGES } from '../constants.js'
46
- import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
46
+ import { executePlanPostcheck as runPlanPostcheckLib, parseRepoRegistry } from '../stages/plan-postcheck.js'
47
47
  import { outputStep, collectStageWaitHistory } from './prompt.js'
48
48
  import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
49
- import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
49
+ import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact, assertWaveTasksComplete } from './complete-handlers.js'
50
50
  import { formatExecuteSummary } from '../worktree-apply.js'
51
51
  import { validateDecisionModuleRefs } from '../design-facts.js'
52
52
  import { isEndToEndTaskText } from '../change-risk-profile.js'
@@ -488,6 +488,19 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
488
488
  process.exit(1)
489
489
  }
490
490
 
491
+ // ── Wave 步骤完成度门(2026-09-17-pass-cap-semantics task-08 / FR-12 / D-013@v1)──
492
+ // 动机锚:2026-09-17 本变更执行中 Wave 2 越位实证——review write 退出码被 shell 管道吞掉后
493
+ // --done 落在下一 Wave,步骤被静默标 completed;既有防护(--step 可选断言 / 60s 并发横幅)
494
+ // 均非阻断。本门 fail-closed:名为「Wave N 执行」的步骤 --done 时本 Wave 任一 task 的
495
+ // tasks.md checkbox 未勾 → exit 1(先 autoCheckPlanFromReviews 幂等补勾再核对,见
496
+ // complete-handlers.js assertWaveTasksComplete)。门自身异常 fail-open warn——门 bug 不
497
+ // 锁死完成路径,正常判定路径硬拦;exit 时 status 尚未赋 completed,DB 不落假完成态。
498
+ try {
499
+ await assertWaveTasksComplete({ steps, currentIdx, changeName, cwd, specBase, platformOpts })
500
+ } catch (e) {
501
+ console.warn(`⚠️ Wave 完成度门异常,fail-open 放行(不阻断本次完成): ${(e && e.message) || e}`)
502
+ }
503
+
491
504
  steps[currentIdx].status = 'completed'
492
505
  steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
493
506
  if (outputText) {
@@ -1009,7 +1022,33 @@ async function buildDraftContext(cwd, changeName) {
1009
1022
  * merge-base 补齐,与草稿归因同口径),再剔 meta.baselineFiles(R-07 防伪底线:merge-base..
1010
1023
  * wtHEAD 含 baseline checkpoint 提交,夹带文件=非本变更改动,不剔则「声明未做恰被夹带」的
1011
1024
  * task 误勾)。全空时仍不设 diffFileSet(退回逐 task 实测路径,原语义)。
1025
+ *
1026
+ * task-05 FR-08:另并入跨仓仓根双源(diff HEAD~1..HEAD ∪ status porcelain,见函数体
1027
+ * collectCrossRepoDiffRoots 注记)——跨仓 task 的 changedFiles 按其仓根相对路径命中。
1012
1028
  */
1029
+ /**
1030
+ * 跨仓仓根收集(task-05 / FR-08 diff 源层):读侧口径 = <cwd>/.sillyspec/local.yaml 的
1031
+ * parseRepoRegistry(与 design-facts.js 有段头分支 / execute MultiRepoContext 同款);相对
1032
+ * 注册路径按 cwd resolve,绝对路径原样,根不存在跳过。local.yaml 不可读/解析失败 fail-open
1033
+ * 返回 [](不炸勾选主流程、不放大勾选面)。
1034
+ */
1035
+ function collectCrossRepoDiffRoots(cwd) {
1036
+ const roots = []
1037
+ try {
1038
+ if (!cwd) return roots
1039
+ const localYamlPath = join(cwd, '.sillyspec', 'local.yaml')
1040
+ if (!existsSync(localYamlPath)) return roots
1041
+ const registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8'))
1042
+ if (!registry) return roots
1043
+ for (const raw of registry.values()) {
1044
+ if (!raw) continue
1045
+ const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
1046
+ if (root && existsSync(root)) roots.push(root)
1047
+ }
1048
+ } catch { /* registry 不可读 → 空表(fail-open) */ }
1049
+ return roots
1050
+ }
1051
+
1013
1052
  function prefetchDiffFileSet(ctx) {
1014
1053
  if (!ctx?.gitDir || !ctx.base || !ctx.head) return ctx
1015
1054
  try {
@@ -1030,6 +1069,37 @@ function prefetchDiffFileSet(ctx) {
1030
1069
  const n = String(b).replace(/\\/g, '/').trim()
1031
1070
  if (n) diffFileSet.delete(n)
1032
1071
  }
1072
+ // task-05 FR-08:跨仓仓根 diff 双源并入(adopt 通道跨仓 review 的 changedFiles 是其仓根
1073
+ // 相对路径,只算主仓源时 shouldAutoCheckTask 的 ctx.diffFileSet.has(f) 恒 miss → 跨仓
1074
+ // task 自动勾选断链)。双源 = git diff --name-only HEAD~1..HEAD ∪ git status --porcelain
1075
+ // --untracked-files=all(cross-repo-reconcile.js:94-106 双源口径只读复用,数组参数防
1076
+ // shell 拆词),产物正斜杠归一;置于剔 baseline 之后(跨仓仓不含主仓 baseline checkpoint
1077
+ // 提交,不被主仓夹带清单误剔)。git 失败/registry 不可读 fail-open 跳过该仓不炸主流程。
1078
+ try {
1079
+ for (const root of collectCrossRepoDiffRoots(ctx.cwd)) {
1080
+ let anySource = false
1081
+ const diffOut = gitQuiet(root, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: 30 * 1000 })
1082
+ if (diffOut !== null) {
1083
+ anySource = true
1084
+ for (const f of String(diffOut).split('\n')) {
1085
+ const n = String(f || '').trim().replace(/^\.\//, '').replace(/\\/g, '/')
1086
+ if (n) diffFileSet.add(n)
1087
+ }
1088
+ }
1089
+ const statusOut = gitQuiet(root, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30 * 1000, trim: false })
1090
+ if (statusOut !== null) {
1091
+ anySource = true
1092
+ for (const line of String(statusOut).split('\n')) {
1093
+ // porcelain 行 = XY<space>path(剥前 3 列);rename 取 -> 后新路径;剥引号、正斜杠归一
1094
+ let p = line.slice(3).trim()
1095
+ if (p.includes(' -> ')) p = p.slice(p.lastIndexOf(' -> ') + 4).trim()
1096
+ p = p.replace(/^"|"$/g, '').replace(/\\/g, '/')
1097
+ if (p) diffFileSet.add(p)
1098
+ }
1099
+ }
1100
+ if (!anySource) console.warn(`⚠️ 跨仓仓根 ${root} git 不可用/非仓库(diff 与 status 双失败),跳过该仓 diff 源(fail-open,不阻断勾选主流程)`)
1101
+ }
1102
+ } catch { /* 跨仓源异常 → 不炸主流程(fail-open) */ }
1033
1103
  if (diffFileSet.size === 0) return ctx
1034
1104
  return { ...ctx, diffFileSet }
1035
1105
  } catch {
@@ -111,6 +111,242 @@ function smokeImportPython(pythonBin, snapshotRoot, relFile) {
111
111
  return null
112
112
  }
113
113
 
114
+ /**
115
+ * local.yaml `gate_snapshot:` 段 `copy:` 列表的轻量行扫描(与 verify-postcheck
116
+ * extractLintCommand 同风格——单键定向取值,不引 js-yaml,本文件零新依赖)。
117
+ *
118
+ * 认块列表(缩进 `- <path>` 条目)与 inline flow(`copy: [a, b]`)两形态(对齐
119
+ * worktree.supplyFiles 等既有数组键的「块列表或 inline flow 均可解析」约定);空行/注释行
120
+ * 跳过不破段;离开 gate_snapshot 段(缩进回收到段头及以下 / 新顶层键)即止。容错:值剥
121
+ * 尾注(` #…`)与成对引号。
122
+ * @param {string} yamlText local.yaml 原文
123
+ * @returns {string[]} copy 条目原始值(未规整,交 applyGateSnapshotCopy 逐条规整)
124
+ */
125
+ function _stripYamlValue(v) {
126
+ let s = String(v).trim()
127
+ const hash = s.indexOf(' #')
128
+ if (hash >= 0) s = s.slice(0, hash).trim()
129
+ if ((s.startsWith('"') && s.endsWith('"') && s.length >= 2) || (s.startsWith("'") && s.endsWith("'") && s.length >= 2)) {
130
+ s = s.slice(1, -1).trim()
131
+ }
132
+ return s
133
+ }
134
+
135
+ function parseGateSnapshotCopy(yamlText) {
136
+ if (!yamlText) return []
137
+ const out = []
138
+ let inGate = false // 位于 gate_snapshot: 段内
139
+ let gateIndent = -1
140
+ let inCopy = false // 位于段内 copy: 列表内
141
+ let copyIndent = -1
142
+ for (const raw of String(yamlText).split(/\r?\n/)) {
143
+ const content = raw.trim()
144
+ if (!content || content.startsWith('#')) continue // 空行/注释行不破段
145
+ const indent = raw.match(/^[ \t]*/)[0].length
146
+ if (!inGate) {
147
+ if (/^gate_snapshot:\s*(?:#.*)?$/.test(content)) { inGate = true; gateIndent = indent; inCopy = false }
148
+ continue
149
+ }
150
+ if (indent <= gateIndent) { inGate = false; inCopy = false; continue } // 出段(新顶层键)
151
+ if (!inCopy) {
152
+ const flow = content.match(/^copy:\s*\[([^\]]*)\]/)
153
+ if (flow) {
154
+ for (const part of flow[1].split(',')) {
155
+ const v = _stripYamlValue(part)
156
+ if (v) out.push(v)
157
+ }
158
+ continue // inline flow 当行自洽,继续扫段内后续内容
159
+ }
160
+ if (/^copy:\s*(?:#.*)?$/.test(content)) { inCopy = true; copyIndent = indent }
161
+ continue // 段内其它子键忽略
162
+ }
163
+ if (indent < copyIndent) { inCopy = false; continue } // 缩进回收出列表
164
+ const item = content.match(/^-\s*(.+)$/)
165
+ if (!item) { inCopy = false; continue } // 同层非列表行 = 列表结束
166
+ const v = _stripYamlValue(item[1])
167
+ if (v) out.push(v)
168
+ }
169
+ return out
170
+ }
171
+
172
+ /**
173
+ * local.yaml `gate_snapshot:` 段 `commands:` 列表扫描(2026-09-17-feedback-hardening
174
+ * R1 / FR-01 / D-002@v2,坑 gate-snapshot-missing-generated-artifacts 二阶——copy 面只搬
175
+ * 「主仓现有态」,主仓侧生成物缺失(fresh clone 未跑 postinstall)或过期(build-id 类
176
+ * 构建期产物随版本变)时供给缺失/陈旧态,快照内全量 lint/test 必挂,用户 2026-09-17
177
+ * 负面①实证)。与 parseGateSnapshotCopy 同风格段内单键扫描(本文件零新依赖,不引
178
+ * js-yaml);认块列表(缩进 `- <cmd>` 条目)与 inline flow(`commands: [a, b]`)双形态
179
+ *(对齐 supplyFiles/copy 既有数组键约定);空行/注释行不破段;缩进回收到段头及以下/
180
+ * 新顶层键即出段——段外同名键(顶层 `commands:` 命令段 / 其它段内嵌 commands)不误收。
181
+ * @param {string} yamlText local.yaml 原文
182
+ * @returns {string[]} 命令串(未配置/空段/读失败 → [])
183
+ */
184
+ export function parseGateSnapshotCommands(yamlText) {
185
+ if (!yamlText) return []
186
+ const out = []
187
+ let inGate = false // 位于 gate_snapshot: 段内
188
+ let gateIndent = -1
189
+ let inCmds = false // 位于段内 commands: 列表内
190
+ let cmdsIndent = -1
191
+ for (const raw of String(yamlText).split(/\r?\n/)) {
192
+ const content = raw.trim()
193
+ if (!content || content.startsWith('#')) continue // 空行/注释行不破段
194
+ const indent = raw.match(/^[ \t]*/)[0].length
195
+ if (!inGate) {
196
+ if (/^gate_snapshot:\s*(?:#.*)?$/.test(content)) { inGate = true; gateIndent = indent; inCmds = false }
197
+ continue
198
+ }
199
+ if (indent <= gateIndent) { inGate = false; inCmds = false; continue } // 出段(新顶层键)
200
+ if (!inCmds) {
201
+ const flow = content.match(/^commands:\s*\[([^\]]*)\]/)
202
+ if (flow) {
203
+ for (const part of flow[1].split(',')) {
204
+ const v = _stripYamlValue(part)
205
+ if (v) out.push(v)
206
+ }
207
+ continue // inline flow 当行自洽,继续扫段内后续内容
208
+ }
209
+ if (/^commands:\s*(?:#.*)?$/.test(content)) { inCmds = true; cmdsIndent = indent }
210
+ continue // 段内其它子键(copy 等)忽略
211
+ }
212
+ if (indent < cmdsIndent) { inCmds = false; continue } // 缩进回收出列表
213
+ const item = content.match(/^-\s*(.+)$/)
214
+ if (!item) { inCmds = false; continue } // 同层非列表行 = 列表结束
215
+ const v = _stripYamlValue(item[1])
216
+ if (v) out.push(v)
217
+ }
218
+ return out
219
+ }
220
+
221
+ /**
222
+ * gate_snapshot.commands 执行段(D-002@v2):快照构建期在快照根逐条执行生成物命令,产出
223
+ * 「本仓应然态」。调用契约:cwd=**快照根**(环境目录 junction 已就位;命令产出真实文件
224
+ * → 后续 copy 面对已存在 dst 跳过,新鲜度优先)。每条 300s 超时帽;非零退出/超时只
225
+ * warn(含 cmd 与输出尾 ≤300 字)继续下一条——fail-open 与 copy 面同策略,不作废快照;
226
+ * 执行结果不落 meta(快照即用即弃,console 输出即审计面)。信任级与
227
+ * commands.install/test/lint 同:主仓 local.yaml 用户自持配置,不加命令白名单。
228
+ * 内部导出(createGateSnapshot 唯一运行时消费方;直测断言返回值)。
229
+ * @param {string} snapshotRoot 快照根(命令 cwd)
230
+ * @param {string[]} commands 命令串列表(parseGateSnapshotCommands 产物)
231
+ * @returns {{ ran: number, failed: Array<{cmd: string, reason: string}> }} ran=实际执行条数(含失败),failed=逐条失败记录
232
+ */
233
+ export function runGateSnapshotCommands(snapshotRoot, commands) {
234
+ if (!Array.isArray(commands) || commands.length === 0) return { ran: 0, failed: [] }
235
+ let ran = 0
236
+ const failed = []
237
+ for (const raw of commands) {
238
+ const cmd = String(raw).trim()
239
+ if (!cmd) continue
240
+ ran++
241
+ let r = null
242
+ try {
243
+ r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8' })
244
+ } catch (e) {
245
+ const reason = `spawn 异常:${e && e.message ? e.message : e}`
246
+ failed.push({ cmd, reason })
247
+ console.warn(`⚠️ gate_snapshot.commands 条目失败(${reason},不作废快照):${cmd}`)
248
+ continue
249
+ }
250
+ const timedOut = r && r.error && (r.error.code === 'ETIMEDOUT' || /ETIMEDOUT/i.test(String(r.error.message || '')))
251
+ if (r.error || r.status !== 0) {
252
+ const reason = timedOut
253
+ ? '超时(>300s 帽)'
254
+ : (r.error ? `spawn 失败:${r.error.message}` : `非零退出码 ${r.status}`)
255
+ failed.push({ cmd, reason })
256
+ const tail = String((r.stdout || '') + (r.stderr || '')).trim().slice(-300)
257
+ console.warn(`⚠️ gate_snapshot.commands 条目失败(${reason},不作废快照继续下一条):${cmd}${tail ? `|输出尾:${tail}` : ''}`)
258
+ continue
259
+ }
260
+ }
261
+ return { ran, failed }
262
+ }
263
+
264
+ /** 目录递归复制(copy 面 junction 不可用时的回退)。符号链接条目跳过(生成物目录内不追链)。 */
265
+ function copyDirRecursive(srcDir, dstDir) {
266
+ mkdirSync(dstDir, { recursive: true })
267
+ for (const e of readdirSync(srcDir, { withFileTypes: true })) {
268
+ const s = join(srcDir, e.name)
269
+ const d = join(dstDir, e.name)
270
+ if (e.isDirectory()) copyDirRecursive(s, d)
271
+ else if (e.isFile()) copyFileSync(s, d)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * gate_snapshot.copy 面(2026-09-16-friction5-hardening R4 / FR-04 / D-002@v1,坑
277
+ * gate-snapshot-missing-generated-artifacts——用户 2026-09-16 驾驭小结④实证只能
278
+ * SNAPSHOT_OFF 对照):HEAD 快照缺 gitignored 生成物(api-types/generated 类不进 HEAD
279
+ * 也不在会话文件集)→ 快照内 lint/test 环境性假败。按主仓 local.yaml 的 copy 清单把
280
+ * 生成物从主仓 junction 链接进快照(失败回退复制),消除环境性假败。
281
+ *
282
+ * 调用契约:cwd=**主仓**根(local.yaml 从主仓读——快照内 local.yaml 是稍后复制的复制件,
283
+ * copy 面必须先于复制生效);在 overlay 之后调用(dst 已存在 = overlay 已覆盖本变更最新态,
284
+ * 跳过不覆盖)。
285
+ *
286
+ * 逐条容错(fail-open,与本文件快照基建策略一致,任何失败不作废快照):路径规整
287
+ *(String、反斜杠→正斜杠、trim);含 '..' 或绝对路径形态(POSIX / 与 Windows 盘符)拒绝
288
+ * warn 跳过;主仓不存在 warn 跳过;junction 抛错回退递归 copy(目录)/ copyFileSync(文件);
289
+ * 再失败 warn 跳过。成功 ≥1 条 console.log 一行报备。未配置/空清单/读失败 → 全段空转
290
+ * 零输出零行为(存量 local.yaml 逐字节不变)。
291
+ *
292
+ * ⚠️ junction 是活链接:快照内再跑生成命令会写穿到主仓该目录(constraints 明示,
293
+ * config-schema desc 与 renderExample 注释同步警告)。
294
+ *
295
+ * 新增内部导出(symbol-impact:内部测试直测边界条目用;createGateSnapshot 唯一运行时消费方)。
296
+ * @param {string} cwd 主仓根
297
+ * @param {string} snapshotRoot 快照根
298
+ * @returns {number} 成功链接/复制的条目数
299
+ */
300
+ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
301
+ let entries = []
302
+ try {
303
+ const cfgPath = join(cwd, '.sillyspec', 'local.yaml')
304
+ if (!existsSync(cfgPath)) return 0
305
+ entries = parseGateSnapshotCopy(readFileSync(cfgPath, 'utf8'))
306
+ } catch { return 0 } // 读侧容错:解析/读失败与未配置同兜底(空转,不连坐快照)
307
+ if (!Array.isArray(entries) || entries.length === 0) return 0
308
+ let linked = 0
309
+ for (const raw of entries) {
310
+ const p = String(raw).replace(/\\/g, '/').trim()
311
+ if (!p) continue
312
+ if (p.includes('..') || p.startsWith('/') || /^[A-Za-z]:\//.test(p)) {
313
+ console.warn(`⚠️ gate_snapshot.copy 条目「${p}」含 .. 或绝对路径形态,拒绝(快照外写面)——跳过`)
314
+ continue
315
+ }
316
+ let srcStat = null
317
+ try { srcStat = statSync(join(cwd, p)) } catch { /* 主仓不存在,下方 warn 跳过 */ }
318
+ if (!srcStat) {
319
+ console.warn(`⚠️ gate_snapshot.copy 条目主仓不存在,跳过:${p}`)
320
+ continue
321
+ }
322
+ const src = join(cwd, p)
323
+ const dst = join(snapshotRoot, p)
324
+ if (existsSync(dst)) continue // overlay 已覆盖(本变更最新态优先)
325
+ try {
326
+ mkdirSync(dirname(dst), { recursive: true })
327
+ let viaLink = false
328
+ try {
329
+ symlinkSync(src, dst, 'junction')
330
+ // Windows 文件 junction 可「建成但不解析」(重解析点指向非目录,symlinkSync 不抛、
331
+ // existsSync 为假)——链接后验真,假成功回退复制(平台差异实测坑)
332
+ viaLink = existsSync(dst)
333
+ } catch { viaLink = false }
334
+ if (!viaLink) {
335
+ try { rmSync(dst, { recursive: true, force: true }) } catch { /* 假链接残留清不掉 → 下方复制报错走 warn */ }
336
+ if (srcStat.isDirectory()) copyDirRecursive(src, dst)
337
+ else copyFileSync(src, dst)
338
+ }
339
+ linked++
340
+ } catch (e) {
341
+ console.warn(`⚠️ gate_snapshot.copy 条目「${p}」junction/复制均失败:${e && e.message ? e.message : e}——跳过(不作废快照)`)
342
+ }
343
+ }
344
+ if (linked > 0) {
345
+ console.log(`🔬 门禁快照 copy 面:${linked} 个 gate_snapshot.copy 条目自主仓 junction/复制进快照(⚠️ junction 是活链接,快照内再跑生成命令会写穿到主仓该目录)`)
346
+ }
347
+ return linked
348
+ }
349
+
114
350
  /**
115
351
  * 创建隔离快照:HEAD worktree + 会话文件 overlay + node_modules junction + local.yaml。
116
352
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
@@ -191,6 +427,32 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
191
427
  return null
192
428
  }
193
429
 
430
+ // gate_snapshot.commands 命令面(D-002@v2,2026-09-17-feedback-hardening R1,用户
431
+ // 2026-09-17 负面①实证「全量快照必挂」):主仓侧生成物缺失(fresh clone 未跑
432
+ // postinstall)或过期时,copy 面只搬「主仓现有态」供给缺失/陈旧态——本段在环境链接
433
+ // 就位后、copy 面前于快照根逐条执行 postinstall 类生成命令,产出「本仓应然态」(命令
434
+ // 产出真实文件 → copy 面对已存在 dst 跳过,新鲜度优先)。配置恒从主仓 cwd 读原文
435
+ //(local.yaml 复制段在后面,快照内副本不采信——与 copy 键同源)。未配置 → 全段空转
436
+ // 零输出(存量快照逐字节不变);整段 try/catch fail-open:任何异常只 warn 不作废快照。
437
+ try {
438
+ const gsCfgPath = join(cwd, '.sillyspec', 'local.yaml')
439
+ const gsCommands = existsSync(gsCfgPath) ? parseGateSnapshotCommands(readFileSync(gsCfgPath, 'utf8')) : []
440
+ if (gsCommands.length > 0) {
441
+ console.log(`🔬 门禁快照 commands 面:${gsCommands.length} 条 gate_snapshot.commands 在快照内逐条执行(cwd=快照根,产出本仓应然态生成物)`)
442
+ console.warn(`⚠️ 环境目录是活链接:命令不得改写 node_modules/venv 等环境目录(写会穿透到主仓)——只放生成物命令(npm run gen:build-id 类)`)
443
+ runGateSnapshotCommands(snapshotRoot, gsCommands)
444
+ }
445
+ } catch (e) {
446
+ console.warn(`⚠️ gate_snapshot.commands 执行段异常(不作废快照):${e && e.message ? e.message : e}`)
447
+ }
448
+
449
+ // gate_snapshot.copy 面(R4 / D-002@v1,2026-09-16-friction5-hardening):gitignored
450
+ // 生成物从主仓 junction/复制进快照。位置=环境目录链接段之后(环境优先级最高)、
451
+ // local.yaml 复制段之前(从主仓 cwd 读原文——快照内 local.yaml 是稍后才复制的复制件,
452
+ // 且 copy 面必须先于复制生效);overlay 已在上面完成(dst 已存在即跳过,本变更最新态优先)。
453
+ // 未配置 gate_snapshot.copy → 全段空转零行为(见 applyGateSnapshotCopy jsdoc)。
454
+ applyGateSnapshotCopy(cwd, snapshotRoot)
455
+
194
456
  // local.yaml(gitignore 不进 HEAD;门禁命令配置来源)+ package-lock 保持 HEAD 版(npm test 不装新依赖)
195
457
  for (const cfg of [join('.sillyspec', 'local.yaml')]) {
196
458
  const src = join(cwd, cfg)