sillyspec 3.33.0 → 3.33.2

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 (72) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +18 -10
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +3 -3
  4. package/README.md +2 -2
  5. package/assets/command-cards/flow.md +9 -5
  6. package/package.json +1 -1
  7. package/src/archive-delta.js +4 -3
  8. package/src/change-delete.js +6 -5
  9. package/src/contract-matrix.js +2 -1
  10. package/src/cross-placement.js +5 -1
  11. package/src/cross-repo-reconcile.js +4 -1
  12. package/src/db.js +8 -2
  13. package/src/decision-distill.js +22 -8
  14. package/src/decision-values.js +34 -0
  15. package/src/decisions-io.js +5 -2
  16. package/src/deleted-ledger.js +2 -1
  17. package/src/doctor-diagnostics.js +8 -3
  18. package/src/flow-draft.js +17 -10
  19. package/src/flow-parity.js +93 -5
  20. package/src/flow-review-pack.js +183 -0
  21. package/src/flow-review.js +45 -3
  22. package/src/flow.js +365 -109
  23. package/src/friction-ledger.js +2 -1
  24. package/src/friction-tally.js +8 -4
  25. package/src/handoff.js +2 -1
  26. package/src/hooks/worktree-guard.js +5 -5
  27. package/src/index.js +62 -21
  28. package/src/init.js +7 -30
  29. package/src/knowledge-hits.js +2 -1
  30. package/src/knowledge-vector.js +2 -1
  31. package/src/progress/consistency-doctor.js +5 -2
  32. package/src/progress/stage-machine.js +6 -2
  33. package/src/progress.js +19 -8
  34. package/src/quicklog.js +194 -9
  35. package/src/review-dispatch.js +4 -3
  36. package/src/review-tier.js +2 -1
  37. package/src/route-hindsight.js +4 -3
  38. package/src/run/bg-sync.js +10 -3
  39. package/src/run/command.js +14 -12
  40. package/src/run/complete-handlers.js +40 -37
  41. package/src/run/complete.js +12 -8
  42. package/src/run/gate-snapshot-ledger.js +2 -1
  43. package/src/run/gate-snapshot.js +102 -23
  44. package/src/run/gates.js +32 -8
  45. package/src/run/prompt.js +6 -5
  46. package/src/run/scan-profile.js +2 -1
  47. package/src/run/shared.js +109 -5
  48. package/src/run/stage.js +5 -4
  49. package/src/run/test-ledger.js +2 -1
  50. package/src/run/verify-quality-scan.js +11 -5
  51. package/src/runtime-paths.js +189 -0
  52. package/src/scan-refresh.js +4 -3
  53. package/src/scope-audit.js +13 -7
  54. package/src/sentinel-assertions.js +37 -0
  55. package/src/spec-sync.js +38 -22
  56. package/src/stage-contract.js +24 -2
  57. package/src/stage-review.js +3 -2
  58. package/src/stage-templates.js +3 -2
  59. package/src/stages/brainstorm.js +6 -5
  60. package/src/sync-noise.js +3 -2
  61. package/src/sync.js +71 -15
  62. package/src/task-done.js +2 -1
  63. package/src/task-review.js +7 -5
  64. package/src/task-tick.js +45 -1
  65. package/src/validate-artifacts.js +2 -1
  66. package/src/verify-draft.js +2 -1
  67. package/src/verify-postcheck.js +11 -10
  68. package/src/verify-probes.js +157 -52
  69. package/src/watcher.js +15 -9
  70. package/src/worktree-apply.js +2 -1
  71. package/src/write-audit.js +2 -1
  72. package/templates/agents-instruction.md +3 -3
package/src/run/shared.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
12
  */
13
13
  import { basename, join, resolve, dirname, sep } from 'node:path'
14
+ import { runtimeTempPath } from '../runtime-paths.js'
14
15
  import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs'
15
16
  import { fileURLToPath } from 'node:url'
16
17
  import os from 'node:os'
@@ -644,7 +645,7 @@ export function warnSelfRefPointerOnce(cwd, specRoot, message) {
644
645
  try {
645
646
  const dir = join(cwd, '.sillyspec', '.runtime')
646
647
  mkdirSync(dir, { recursive: true })
647
- const markerPath = join(dir, SELFREF_WARN_MARKER)
648
+ const markerPath = runtimeTempPath(dir, SELFREF_WARN_MARKER)
648
649
  const now = Date.now()
649
650
  try {
650
651
  const m = JSON.parse(readFileSync(markerPath, 'utf8'))
@@ -943,7 +944,9 @@ async function raceWithAbort(op, timeoutMs = resolveSyncTotalTimeoutMs()) {
943
944
  const controller = new AbortController()
944
945
  let timer
945
946
  try {
946
- await Promise.race([
947
+ // 透传 op 结果(2026-10-17 终态强校验消费):历史调用方(triggerSync 等)忽略返回值
948
+ // 不受影响;超时分支 resolve(null)——消费方以 null 判「预算熔断」。
949
+ return await Promise.race([
947
950
  op({ signal: controller.signal }),
948
951
  new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(null) }, timeoutMs) }),
949
952
  ])
@@ -1039,6 +1042,86 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
1039
1042
  }
1040
1043
  }
1041
1044
 
1045
+ /**
1046
+ * 终态强校验同步(2026-10-17-archive-half-state-location-stuck-active)。
1047
+ *
1048
+ * 背景(坑档案 docs/sillyspec/archive-half-state-location-stuck-active.md,生产实证):
1049
+ * triggerSync 全链 best-effort + 后台化,重试是事件驱动(「下一条命令」)——而归档/删除
1050
+ * 是变更生命周期最后一个动作,之后大概率没有下一条命令。平台慢窗口内 DB 通道
1051
+ * (status=archived)成功而文件树通道(location)失败 → 半态:平台「进行中」列表
1052
+ * 幽灵滞留已归档变更,无人再碰则无限期。
1053
+ *
1054
+ * 与 triggerSync 的三点差异:
1055
+ * 1. **终态判定驱动**:仅当 DB 该变更 status ∈ (archived, deleted) 才走强校验;
1056
+ * 非终态(普通阶段完成的 completion 同步等)原样委托 triggerSync(后台化不变,
1057
+ * 零行为变化)。归档收尾允许阻塞换一致性——终态动作没有「下次再推」的假设。
1058
+ * 2. **内联有界**:绕过后台化(结果不再藏进 bg 日志文件),sync() 直跑 + raceWithAbort
1059
+ * 预算 = max(env SILLYSPEC_SYNC_TIMEOUT_MS 可调值, 45s 后台轮下限同款);opts.timeoutMs
1060
+ * 显式传入时直接生效(测试注入短窗)。
1061
+ * 3. **失败显性化**:sync() 返回 treeOk!==true(syncSpecTree ok 契约,fail-closed——
1062
+ * 无 ok 字段视同未确认)→ 🚨 告警 + 自救命令 + process.exitCode = 1(对齐
1063
+ * _submitApproval 的「终态网络失败必须可见」先例)。未决冲突(result.conflict)
1064
+ * 不在此列——冲突面已有自己的 resolve 处置横幅,重复告警是噪音。
1065
+ *
1066
+ * @returns {{confirmed: boolean, delegated?: boolean, skipped?: string, result?: object}}
1067
+ */
1068
+ export async function triggerSyncTerminalStrict(cwd, changeName, platformOpts = {}, opts = {}) {
1069
+ // quick 会话无实体变更目录(无归档/删除终态)——沿用既有 best-effort 语义
1070
+ if (changeName && QUICK_SID_RE.test(changeName)) {
1071
+ await triggerSync(cwd, changeName, platformOpts, opts)
1072
+ return { confirmed: false, delegated: true }
1073
+ }
1074
+ const syncMod = await import('../sync.js')
1075
+ // 未连接平台:静默 no-op(本地合法状态;强校验只在平台模式下有意义)
1076
+ if (!syncMod.peekPlatformConnected(cwd, platformOpts)) {
1077
+ return { confirmed: false, skipped: 'not-connected' }
1078
+ }
1079
+ // 终态判定(fail-open:DB 读不到/解析失败 → 非终态委托既有语义,不因校验层故障阻断主链)
1080
+ // opts.changeStatus / opts.syncImpl:测试注入缝(对齐 bakeArchiveTimeline writeImpl 惯例)
1081
+ let dbStatus = opts.changeStatus ?? null
1082
+ if (dbStatus === null) {
1083
+ try {
1084
+ const { resolvePlatformSpecDir, ProgressManager } = await import('../progress.js')
1085
+ const specDir = resolvePlatformSpecDir(cwd)
1086
+ if (specDir && existsSync(join(specDir, '.runtime', 'sillyspec.db'))) {
1087
+ const pm = new ProgressManager({ specDir })
1088
+ const row = pm._ensureDB(cwd).getDb().prepare(
1089
+ 'SELECT status FROM changes WHERE name = ?'
1090
+ ).get(changeName)
1091
+ dbStatus = row ? row.status : null
1092
+ }
1093
+ } catch { /* 判定失败按非终态处理 */ }
1094
+ }
1095
+ if (dbStatus !== 'archived' && dbStatus !== 'deleted') {
1096
+ await triggerSync(cwd, changeName, platformOpts, opts)
1097
+ return { confirmed: false, delegated: true }
1098
+ }
1099
+ // 终态:内联有界同步(绕过后台化——结果必须当场可判)
1100
+ const timeoutMs = opts.timeoutMs ?? Math.max(resolveSyncTotalTimeoutMs(), 45_000)
1101
+ let result = null
1102
+ try {
1103
+ // raceWithAbort 向 op 传入 { signal } 形状对象(非裸 AbortSignal,与既有 triggerSync
1104
+ // 内联用法 `sync(name, cwd, sig)` 同一形状)——默认路径原样透传,禁止二次包装
1105
+ //(dogfood 实证:包装成 { signal: sig } 会把套娃对象传进 AbortSignal.any → TypeError,
1106
+ // 强校验当场以 🚨 告警兜住——安全网本身工作正常,此处修被网住的 bug)。
1107
+ const runSync = opts.syncImpl || ((syncOpts) => syncMod.sync(changeName, cwd, syncOpts))
1108
+ result = await raceWithAbort(runSync, timeoutMs)
1109
+ } catch (e) {
1110
+ result = { synced: 0, errors: [(e && e.message) || String(e)] }
1111
+ }
1112
+ if (result === null) result = { synced: 0, errors: [`同步总预算 ${timeoutMs}ms 熔断`] }
1113
+ // 未决冲突:已有 resolve 处置横幅(syncSpecTree 冲突分支自带),不重复告警
1114
+ if (result.conflict === true) return { confirmed: false, skipped: 'conflict', result }
1115
+ const confirmed = result.treeOk === true
1116
+ if (!confirmed) {
1117
+ const detail = ((result.errors && result.errors[0]) || '文件树通道未确认收敛(见上方 [spec-sync]/[sync] 行)').slice(0, 200)
1118
+ console.error(`🚨 [终态同步] 本地${dbStatus === 'deleted' ? '删除' : '归档'}已完成,但平台文件树推送未确认:${detail}`)
1119
+ console.error(` 平台变更中心可能滞留「进行中」半态。自救(幂等):sillyspec platform sync --change ${changeName}`)
1120
+ if (opts.exitOnUnconfirmed !== false) process.exitCode = 1
1121
+ }
1122
+ return { confirmed, result }
1123
+ }
1124
+
1042
1125
  /**
1043
1126
  * 触发步骤开始上报(X3 / design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull task-13)。
1044
1127
  *
@@ -1087,7 +1170,8 @@ const AUTO_PULL_THROTTLE_MS = 10_000
1087
1170
  const AUTO_PULL_TIMEOUT_MS = 2_000
1088
1171
 
1089
1172
  function _autoPullThrottlePath(cwd) {
1090
- return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
1173
+ // 2026-10-18 布局分层:节流戳是可再生产物归 temp/;路径助手自带 mkdir-p
1174
+ return runtimeTempPath(join(cwd, '.sillyspec', '.runtime'), 'auto-pull-throttle.json')
1091
1175
  }
1092
1176
 
1093
1177
  function _autoPullRecently(cwd) {
@@ -1546,15 +1630,35 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1546
1630
  // gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
1547
1631
  // 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
1548
1632
  // 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
1633
+ // 探测必须走 --numstat:--name-only 不吃 --ignore-cr-at-eol(git 实测怪癖——git ≤2.43 仍把
1634
+ // 行尾-only 文件列进名单,Debian 12 git 2.39.5 / Ubuntu 24.04 git 2.43.0 实测;git ≥2.54 才
1635
+ // 听话),走 --name-only 会让本过滤在老 git 上静默失效、假 M 坑复活。同
1636
+ // verify-postcheck reconcileActualFiles 的 eolOnlyFiles 探测同口径(该处已按此结论实装)。
1549
1637
  // 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
1550
1638
  // ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
1551
1639
  // 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
1552
1640
  // 返回 {value,error}),失败显式落 null 透传不过滤。
1641
+ // -z 记录文法(od 实测,git 2.39.5 与 2.54 同形):普通条目 `<A>\t<D>\t<path>\0`;
1642
+ // rename `<A>\t<D>\t\0<old>\0<new>\0`(计数后路径字段为空,旧/新名各占一个 NUL 字段)——
1643
+ // 按位置取新名。非 -z 的 `old => new` 文本形态与「文件名里字面含 ` => `」不可区分(实测
1644
+ // `sub/a => b.js` 真实内容变更会被误判 rename 而剔出 changedFiles——审计放宽),-z 消歧。
1645
+ // -z 同时不做引号/C 转义,路径原样出(与 porcelain 侧键更一致)。
1646
+ const numstatZPaths = (out) => {
1647
+ const toks = String(out).split('\0')
1648
+ const paths = []
1649
+ for (let i = 0; i < toks.length; i++) {
1650
+ const m = toks[i].match(/^(?:\d+|-)\t(?:\d+|-)\t(.*)$/s)
1651
+ if (!m) continue
1652
+ if (m[1] === '') { paths.push(String(toks[i + 2] || '')); i += 2 }
1653
+ else paths.push(m[1])
1654
+ }
1655
+ return paths.filter(Boolean)
1656
+ }
1553
1657
  const eolRawList = (() => {
1554
1658
  try {
1555
- const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1659
+ const res = safeGit(cwd, ['diff', '--numstat', '-z', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1556
1660
  if (!res || res.error) return null
1557
- return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
1661
+ return numstatZPaths(String(res.value || ''))
1558
1662
  } catch { return null }
1559
1663
  })()
1560
1664
  const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
package/src/run/stage.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * - 'child_process'(execSync)裸模块名不变
18
18
  */
19
19
  import { join, dirname } from 'node:path'
20
+ import { runtimeStatePath } from '../runtime-paths.js'
20
21
  import { existsSync, readdirSync, readFileSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown, predictProtectedQuickFiles, mergeQuickBoundaryFiles, readStageBurst, STAGE_BURST_STAGES, readStageWall, STAGE_WALL_STAGES } from './shared.js'
@@ -55,7 +56,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
55
56
  const curSession = process.env.SILLYSPEC_SESSION_ID || ''
56
57
  const wallStageData = progress.stages && progress.stages[stageName]
57
58
  const firstEntry = !wallStageData || !Array.isArray(wallStageData.steps) || wallStageData.steps.length === 0
58
- const wallLedgerPath = join(specBase, '.runtime', `stage-session-ledger-${changeName}.json`)
59
+ const wallLedgerPath = runtimeStatePath(join(specBase, '.runtime'), `stage-session-ledger-${changeName}.json`)
59
60
  if (curSession && firstEntry && existsSync(wallLedgerPath)) {
60
61
  const wallLedger = JSON.parse(readFileSync(wallLedgerPath, 'utf8'))
61
62
  const { isStageWallBlocked } = await import('./shared.js')
@@ -151,7 +152,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
151
152
  const { generateExecuteRunId, isValidExecuteRunId, claimExecuteRunId } = await import('../task-review.js')
152
153
  const execSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
153
154
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
154
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
155
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
155
156
  mkdirSync(runtimeRoot, { recursive: true })
156
157
  // 优先读取已有的变更专属标记文件(agent 可写内容,格式校验防注入/穿越,非法视为缺失重生成)
157
158
  try {
@@ -325,7 +326,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
325
326
  startedAt: new Date().toISOString(),
326
327
  forceRescan: quickOpts?.isForceRescan || false,
327
328
  }
328
- const guardFile = join(specBase, '.runtime', 'scan-guard.json')
329
+ const guardFile = runtimeStatePath(join(specBase, '.runtime'), 'scan-guard.json')
329
330
  mkdirSync(dirname(guardFile), { recursive: true })
330
331
  // 原子写:hook 进程(worktree-guard 的 readScanGuard)并发读,裸 truncate→write 的
331
332
  // 半截窗口会被 JSON.parse 吞成 null → 覆盖保护静默失效(fail-open)
@@ -417,7 +418,7 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
417
418
  // 进程内直调 runCommand 的入口(测试/编程调用)与 --change 指向不存在会话的形态。
418
419
  console.error(`❌ quick 通道已退役,无法启动 quick 会话(若带 --change 则该会话不存在或已清理)。`)
419
420
  console.error(` 新工作请走轻量变更(--input 引号内换行合法,可照抄):`)
420
- console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
421
+ console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文概括(首行即变更标题,硬区间 10-30 必须、建议 ~20 字);随后动机与背景')
421
422
  console.error('')
422
423
  console.error(' 成功标准:')
423
424
  console.error(' - <可验证标准>"')
@@ -36,6 +36,7 @@
36
36
  * (meta.json 并发读先例同款约束)。
37
37
  */
38
38
  import { createHash } from 'crypto'
39
+ import { runtimeStatePath } from '../runtime-paths.js'
39
40
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
40
41
  import { join, relative, isAbsolute } from 'path'
41
42
  import { gitQuiet } from '../git-helper.js'
@@ -209,7 +210,7 @@ function ledgerKeyFor({ runtimeRoot, changeName, projectRoot, testRoot, specBase
209
210
 
210
211
  /** 账本路径(per-change 单文件) */
211
212
  export function testLedgerPath(runtimeRoot, changeName) {
212
- return join(runtimeRoot, `test-ledger-${changeName}.json`)
213
+ return runtimeStatePath(runtimeRoot, `test-ledger-${changeName}.json`)
213
214
  }
214
215
 
215
216
  /**
@@ -27,6 +27,7 @@
27
27
  * 刻意区分)。指纹已含 commands 段原文——配置 smoke 前后指纹自动变化,代码未变不重跑。
28
28
  */
29
29
  import { createHash } from 'node:crypto'
30
+ import { runtimeStatePath } from '../runtime-paths.js'
30
31
  import { execSync, spawnSync } from 'node:child_process'
31
32
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
32
33
  import { join, dirname } from 'node:path'
@@ -43,7 +44,7 @@ const RECORD_SCHEMA_VERSION = 1
43
44
 
44
45
  export function qualityScanRecordPath(specBase, changeName) {
45
46
  if (!changeName) return null
46
- return join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`)
47
+ return runtimeStatePath(join(specBase, '.runtime'), `verify-quality-scan-${changeName}.json`)
47
48
  }
48
49
 
49
50
  /**
@@ -365,7 +366,7 @@ export async function renderVerifyTestAttribution({ cwd, changeName, specBase, r
365
366
  // 第二判据(坑 derived-artifact-stale-baseline / verify-attr2-foreign-wip-misdirection):
366
367
  // 完整语义见 gates.js filterStaleBaselineOverlap 注释——两道过滤(必须 dirty + 剔除他者声明集)。
367
368
  try {
368
- const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
369
+ const pathspecFile = runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`)
369
370
  if (changeName && existsSync(pathspecFile)) {
370
371
  const ownFiles = new Set(readFileSync(pathspecFile, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((f) => f.replace(/\\/g, '/')))
371
372
  if (ownFiles.size > 0) {
@@ -655,14 +656,19 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
655
656
  } else {
656
657
  try {
657
658
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
658
- snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
659
+ const snapshotFailRef = {} // FR-04:结构化失败原因(pnpm 布局拒建/文件集三分成因/junction/基建异常)
660
+ snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts, failRef: snapshotFailRef })
659
661
  if (snap) {
660
662
  console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
661
663
  } else {
662
- // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账
664
+ // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账;
665
+ // FR-04(2026-10-16-fix-downstream-tool-defects):reason 消费 failRef 结构化原因,不再笼统
663
666
  try {
664
667
  const { reportGateSnapshotFallback } = await import('./gate-snapshot.js')
665
- if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'noAI 质量扫描步', reason: 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' })
668
+ const fbReason = snapshotFailRef && snapshotFailRef.reason
669
+ ? `createVerifyGateSnapshot 返回 null:${snapshotFailRef.reason}`
670
+ : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
671
+ if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'noAI 质量扫描步', reason: fbReason })
666
672
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
667
673
  }
668
674
  } catch (e) {
@@ -0,0 +1,189 @@
1
+ /**
2
+ * runtime-paths.js — .runtime 布局分层(state/ 与 temp/)与旧布局一次性迁移。
3
+ *
4
+ * 背景(2026-10-18 用户反馈):.runtime 根目录堆积 1649 个条目——22 类按 change 命名
5
+ * 等 24 类散文件不在归档回收名单、日志/db 备份与核心
6
+ * 状态混放。分野沿用 runtime-hygiene.js 的定界思路,按「删除是否安全」切两层:
7
+ * - **state/**:按 change 命名的派生状态(watcher 事件流、draft ledger、冲突标记…)。
8
+ * 活跃变更在读,归档/删除后无读者——由 pruneArchivedChangeRuntime 精确回收。
9
+ * - **temp/**:可随时整体删除的产物(watcher.log / spec-sync-bg.log / audit.log /
10
+ * db 备份 .bak|.corrupt-*|.pre-import-* / 锁残留 *.tmp)。无不可再生的状态语义。
11
+ *
12
+ * 家族表是唯一清单源(写入点路径助手 + 归档回收精确名 + 迁移前缀匹配三方共源,
13
+ * 防三处清单漂移)。契约:
14
+ * - 迁移幂等:目标已存在则跳过(不覆盖——混版本窗口防旧进程迟到文件盖掉新文件);
15
+ * 逐条 try/catch fail-open,绝不抛(卫生动作不阻断主流程,与 runtime-hygiene 同族)。
16
+ * - 迁移只动已知家族与已知临时产物,未知文件一律不动(用户/工具自置文件无归属语义)。
17
+ * - 文件名一概保持原样,仅目录前缀变化——写入点改动=一行 join 换助手,读写面零重命名。
18
+ */
19
+ import { existsSync, mkdirSync, readdirSync, renameSync } from 'fs'
20
+ import { join } from 'path'
21
+
22
+ export const RUNTIME_STATE_DIRNAME = 'state'
23
+ export const RUNTIME_TEMP_DIRNAME = 'temp'
24
+
25
+ /**
26
+ * state/ 家族表:prefix 用于迁移匹配与回收名生成;ext 是文件名后缀;
27
+ * stages 特例仅 current-stage-review-run-id(无扩展名且内嵌 stage 枚举
28
+ * brainstorm|plan|execute——stage-review.js stageReviewMarkerPath 的构造形态)。
29
+ */
30
+ const STATE_FAMILIES = [
31
+ { prefix: 'sync-docs-lastpush-', ext: '.json' },
32
+ { prefix: 'draft-ledger-', ext: '.json' },
33
+ { prefix: 'route-hindsight-baseline-', ext: '.json' },
34
+ { prefix: 'watcher-events-', ext: '.jsonl' },
35
+ { prefix: 'watcher-stage-timing-', ext: '.json' },
36
+ { prefix: 'watcher-last-snapshot-', ext: '.json' },
37
+ { prefix: 'test-rerun-', ext: '.json' },
38
+ { prefix: 'test-ledger-', ext: '.json' },
39
+ { prefix: 'ceremony-tier-', ext: '.json' },
40
+ { prefix: 'stage-session-ledger-', ext: '.json' },
41
+ { prefix: 'wave-session-ledger-', ext: '.json' },
42
+ { prefix: 'verify-draft-', ext: '.json' },
43
+ { prefix: 'verify-quality-scan-', ext: '.json' },
44
+ { prefix: 'verify-result-hwm-', ext: '.json' },
45
+ { prefix: 'review-dispatch-', ext: '.json' },
46
+ { prefix: 'review-dispatch-shadow-', ext: '.json' },
47
+ { prefix: 'execute-cleanup-', ext: '.json' },
48
+ { prefix: 'friction-tally-', ext: '.json' },
49
+ { prefix: 'prompt-inject-', ext: '.json' },
50
+ { prefix: 'sync-conflict-', ext: '.json' },
51
+ { prefix: 'spec-sync-conflict-', ext: '.json' },
52
+ { prefix: 'apply-pathspec-', ext: '.txt' },
53
+ { prefix: 'scope-audit-', ext: '.json' },
54
+ { prefix: 'scan-refresh-', ext: '.json' },
55
+ { prefix: 'current-execute-run-id-', ext: '' },
56
+ { prefix: 'current-stage-review-run-id-', ext: '', stages: ['brainstorm', 'plan', 'execute'] },
57
+ ]
58
+
59
+ /**
60
+ * 全局面单例(无 change 归属但有读者)→ state/(2026-10-18 二轮收敛:根目录只留
61
+ * db/锁/会话身份/global/spec-version/user-inputs 六件核心)。迁移精确名匹配。
62
+ */
63
+ export const RUNTIME_STATE_SINGLETONS = [
64
+ 'friction-ledger.json',
65
+ 'deleted-changes.jsonl',
66
+ 'active-gate-snapshots.json',
67
+ 'scan-guard.json',
68
+ 'verify-lint-tally.json',
69
+ 'decision-hits.json',
70
+ 'knowledge-hits.jsonl',
71
+ 'knowledge-hit-report.json',
72
+ 'vector-capability.json',
73
+ 'spec-sync-base.json',
74
+ 'spec-sync-last-success.json',
75
+ 'spec-sync-fail-streak.json',
76
+ 'spec-sync-follow-reported.json',
77
+ 'sync-noise-self-heal.json',
78
+ 'last-delta.json',
79
+ ]
80
+
81
+ /** 可再生全局单例产物(删后可重建、无不可再生状态)→ temp/。 */
82
+ export const RUNTIME_TEMP_SINGLETONS = [
83
+ 'doctor-diagnosis.json',
84
+ 'flow-telemetry.jsonl',
85
+ 'write-audit.jsonl',
86
+ 'anon-session-warn.json',
87
+ 'selfref-pointer-warn.json',
88
+ 'auto-pull-throttle.json',
89
+ ]
90
+
91
+ /** temp/ 侧已知产物:精确名 + 带时间戳/pid 尾的正则(corrupt/pre-import/lock-tmp)。 */
92
+ const TEMP_EXACT = new Set(['watcher.log', 'spec-sync-bg.log', 'audit.log', 'sillyspec.db.bak'])
93
+ const TEMP_PATTERNS = [
94
+ /^sillyspec\.db\.corrupt-/,
95
+ /^sillyspec\.db\.pre-import-/,
96
+ /^watcher\.lock\.\d+\.tmp$/,
97
+ ]
98
+
99
+ /**
100
+ * state/ 下某变更的全家族精确文件名清单(归档/删除回收用;禁 glob 的精确名形态)。
101
+ * @param {string} changeName
102
+ * @returns {string[]}
103
+ */
104
+ export function runtimeStateFileNames(changeName) {
105
+ const names = []
106
+ for (const f of STATE_FAMILIES) {
107
+ if (f.stages) {
108
+ for (const s of f.stages) names.push(`${f.prefix}${s}-${changeName}`)
109
+ } else {
110
+ names.push(`${f.prefix}${changeName}${f.ext}`)
111
+ }
112
+ }
113
+ return names
114
+ }
115
+
116
+ /**
117
+ * 路径助手自带目录保证(mkdir -p 语义,幂等 fail-open):库级直调(测试/embedded)不经
118
+ * CLI 入口迁移,裸 writeFileSync/appendFileSync 落 state|temp 会 ENOENT——写点分散逐个补
119
+ * mkdir 是打地鼠(2026-10-18 实证四起),在唯一路径构造点收口。读检查顺带建空目录无害
120
+ * (init 预建面与入口迁移本就常态建这两层)。
121
+ */
122
+ function _pathWithDir(runtimeRoot, dirname_, filename) {
123
+ const p = join(runtimeRoot, dirname_, filename)
124
+ try { mkdirSync(join(runtimeRoot, dirname_), { recursive: true }) } catch { /* fail-open:写入侧自抛真实原因 */ }
125
+ return p
126
+ }
127
+
128
+ /** @param {string} runtimeRoot @param {string} filename @returns {string} */
129
+ export function runtimeStatePath(runtimeRoot, filename) {
130
+ return _pathWithDir(runtimeRoot, RUNTIME_STATE_DIRNAME, filename)
131
+ }
132
+
133
+ /** @param {string} runtimeRoot @param {string} filename @returns {string} */
134
+ export function runtimeTempPath(runtimeRoot, filename) {
135
+ return _pathWithDir(runtimeRoot, RUNTIME_TEMP_DIRNAME, filename)
136
+ }
137
+
138
+ function _isTempArtifact(name) {
139
+ return TEMP_EXACT.has(name) || TEMP_PATTERNS.some((re) => re.test(name))
140
+ }
141
+
142
+ /**
143
+ * 旧布局一次性迁移:把 .runtime 根目录下已知家族文件移动到 state/ 或 temp/。
144
+ * 幂等(目标存在跳过)、fail-open(单条失败/目录缺失零副作用)、未知文件不动。
145
+ * @param {string} runtimeRoot .runtime 绝对路径
146
+ * @returns {{ stateMoved: number, tempMoved: number, skipped: number }}
147
+ */
148
+ export function migrateRuntimeLayout(runtimeRoot) {
149
+ const out = { stateMoved: 0, tempMoved: 0, skipped: 0 }
150
+ let entries
151
+ try {
152
+ entries = readdirSync(runtimeRoot, { withFileTypes: true })
153
+ } catch {
154
+ return out // 目录缺失(该仓库从未跑过流程)= 无可迁
155
+ }
156
+ const prefixes = STATE_FAMILIES.map((f) => f.prefix)
157
+ const stateSingletons = new Set(RUNTIME_STATE_SINGLETONS)
158
+ const tempSingletons = new Set(RUNTIME_TEMP_SINGLETONS)
159
+ const moves = [] // { name, dest }
160
+ for (const e of entries) {
161
+ if (!e.isFile()) continue
162
+ const name = e.name
163
+ if (prefixes.some((p) => name.startsWith(p)) || stateSingletons.has(name)) moves.push({ name, dest: RUNTIME_STATE_DIRNAME })
164
+ else if (_isTempArtifact(name) || tempSingletons.has(name)) moves.push({ name, dest: RUNTIME_TEMP_DIRNAME })
165
+ // 未知归属:不动(用户/工具自置文件无生命周期语义,迁移不猜)
166
+ }
167
+ // 无条件预建两层目录(幂等廉价):CLI 入口路径保证 state/ 在一切写入前就位——
168
+ // 新仓首跑无文件可搬,靠这里兜住「迁移零动作、写入点裸写 ENOENT」的窗口
169
+ for (const d of [RUNTIME_STATE_DIRNAME, RUNTIME_TEMP_DIRNAME]) {
170
+ try { mkdirSync(join(runtimeRoot, d), { recursive: true }) } catch { /* fail-open */ }
171
+ }
172
+ const madeDirs = new Set()
173
+ for (const { name, dest } of moves) {
174
+ const dst = join(runtimeRoot, dest, name)
175
+ try {
176
+ if (existsSync(dst)) { out.skipped++; continue } // 不覆盖:防旧进程迟到文件盖新文件
177
+ if (!madeDirs.has(dest)) {
178
+ mkdirSync(join(runtimeRoot, dest), { recursive: true }) // 并发 mkdir recursive 不抛
179
+ madeDirs.add(dest)
180
+ }
181
+ renameSync(join(runtimeRoot, name), dst) // 同卷原子;跨进程竞争失败即跳过下次再收
182
+ if (dest === RUNTIME_STATE_DIRNAME) out.stateMoved++
183
+ else out.tempMoved++
184
+ } catch {
185
+ out.skipped++
186
+ }
187
+ }
188
+ return out
189
+ }
@@ -25,6 +25,7 @@
25
25
  * 无引用论断检不出。refresh 只声称「核对至 HEAD 的检出项已处理」,不声称「一致」。
26
26
  */
27
27
  import { existsSync, readFileSync, readdirSync, mkdirSync } from 'node:fs'
28
+ import { runtimeStatePath } from './runtime-paths.js'
28
29
  import { join } from 'node:path'
29
30
  import { createHash } from 'node:crypto'
30
31
  import { safeGit } from './git-helper.js'
@@ -227,7 +228,7 @@ export function computeRefreshPlan({ projectRoot, specBase, projectName, force =
227
228
  try {
228
229
  const guardDir = join(specBase, '.runtime')
229
230
  mkdirSync(guardDir, { recursive: true })
230
- writeAtomicSync(join(guardDir, 'scan-guard.json'), JSON.stringify({
231
+ writeAtomicSync(runtimeStatePath(guardDir, 'scan-guard.json'), JSON.stringify({
231
232
  name_zh: '增量刷新守卫',
232
233
  mode: 'scan-refresh',
233
234
  refreshDocs,
@@ -381,7 +382,7 @@ export async function finalizeRefresh(opts = {}) {
381
382
  const scanDir = join(specBase, 'docs', projectName, 'scan')
382
383
  // ①拍 guard 会话态必须存在(握手文件=工单受影响集与内容哈希的唯一来源)
383
384
  let guard = null
384
- const guardPath = join(specBase, '.runtime', 'scan-guard.json')
385
+ const guardPath = runtimeStatePath(join(specBase, '.runtime'), 'scan-guard.json')
385
386
  try { guard = JSON.parse(readFileSync(guardPath, 'utf8')) } catch { /* 缺失/坏 JSON → 下方拒绝 */ }
386
387
  if (!guard || guard.mode !== 'scan-refresh') {
387
388
  console.error('scan refresh --done:未找到增量刷新会话态(.runtime/scan-guard.json 无 mode=scan-refresh)')
@@ -453,7 +454,7 @@ export async function finalizeRefresh(opts = {}) {
453
454
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
454
455
  mkdirSync(runtimeRoot, { recursive: true })
455
456
  const ts = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14)
456
- auditPath = join(runtimeRoot, `scan-refresh-${ts}.json`)
457
+ auditPath = runtimeStatePath(runtimeRoot, `scan-refresh-${ts}.json`)
457
458
  writeAtomicSync(auditPath, JSON.stringify({
458
459
  changeType: 'scan-refresh',
459
460
  head: headShort,
@@ -32,6 +32,7 @@
32
32
  * degraded 仓退 v1 ⊘ 形态不炸主仓表;预执行形态不调内核(跨仓行保持清单形态)。
33
33
  */
34
34
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
35
+ import { runtimeStatePath } from './runtime-paths.js'
35
36
  import { createHash } from 'node:crypto'
36
37
  import { join, dirname } from 'node:path'
37
38
  import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, collectOtherQuickSessionDeclarations, ancestorSpecDirs, isQuickMetadata } from './run/shared.js'
@@ -40,6 +41,7 @@ import { loadSpanRiskPatternsAllProjects } from './span-risk-surface.js'
40
41
  import { safeGit } from './git-helper.js'
41
42
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
42
43
  import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
44
+ import { isToolGovernancePath } from './flow-parity.js'
43
45
  import { parseRepoRegistry } from './stages/plan-postcheck.js'
44
46
  // 跨仓 per-repo 采集共享内核(2026-09-20 scope-audit-cross-repo task-02 消费 task-01 产物)。
45
47
  // 无环确认:cross-repo-reconcile 的静态依赖链(git-helper/plan-postcheck/worktree-apply/
@@ -263,7 +265,7 @@ function readScopeSnapshot(changeDir, runtimeRoot, changeName) {
263
265
  } catch { /* 缺失/损坏 → 下一源 */ }
264
266
  }
265
267
  try {
266
- const snap = JSON.parse(readFileSync(join(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
268
+ const snap = JSON.parse(readFileSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`), 'utf8'))
267
269
  if (snap && typeof snap === 'object' && Array.isArray(snap.rows)) return snap
268
270
  } catch { /* 缺失/损坏 → null */ }
269
271
  return null
@@ -859,10 +861,10 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
859
861
  })() : false
860
862
  const hasSnapshotFile = hasMergedScopeAudit
861
863
  || (changeDirInfo
862
- ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`))
863
- : existsSync(join(runtimeRoot, `scope-audit-${changeName}.json`)))
864
- const hasApplyPathspec = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
865
- const hasCleanupReceipt = existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
864
+ ? existsSync(join(changeDirInfo.dir, 'scope-audit.json')) || existsSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`))
865
+ : existsSync(runtimeStatePath(runtimeRoot, `scope-audit-${changeName}.json`)))
866
+ const hasApplyPathspec = existsSync(runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`))
867
+ const hasCleanupReceipt = existsSync(runtimeStatePath(runtimeRoot, `execute-cleanup-${changeName}.json`))
866
868
  let hasExecuteRunStamp = false
867
869
  if (!hasWorktreeMeta && !hasBranch && !hasAuditTag && !hasSnapshotFile && !hasApplyPathspec && !hasCleanupReceipt) {
868
870
  try {
@@ -1016,7 +1018,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1016
1018
  let narrowNote = null
1017
1019
  if (freshActual && collectPatch) {
1018
1020
  try {
1019
- const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
1021
+ const pathspecFile = runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`)
1020
1022
  const owned = readFileSync(pathspecFile, 'utf8').split('\n').map(l => toPosix(l.trim())).filter(Boolean)
1021
1023
  if (owned.length > 0) {
1022
1024
  const ownedSet = new Set(owned)
@@ -1372,7 +1374,11 @@ export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName,
1372
1374
  }
1373
1375
  // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1374
1376
  // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1375
- const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1377
+ // 工具治理模板同源剔除(2026-10-19-cross-repo-face-tool-filter):跨仓面(rows/totals/
1378
+ // patch)与主仓冻结面同口径,不让 .claude/.codex 等注入面刷屏跨仓交付。
1379
+ const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))]
1380
+ .filter((f) => !isToolGovernancePath(f))
1381
+ .map(toPosix).sort()
1376
1382
  const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1377
1383
  const repoMatched = new Set()
1378
1384
  for (const f of repoFiles) {
@@ -207,6 +207,43 @@ export function detectTickBurst(tickLog, opts = {}) {
207
207
  };
208
208
  }
209
209
 
210
+ /**
211
+ * 勾选时点判定·换源版(2026-10-17-tick-timing-source,纯函数):task-ticks.jsonl 侧车的逐格
212
+ * 墙钟时戳 → 勾选是「边干边勾」还是「末尾集中」。旧判据(tasks.md git 首次提交 == 收口最后
213
+ * 提交)已退役——git 历史形态被官方允许的 reset --soft 压扁无声摧毁(tick-timing-squash-
214
+ * blindspot 坑:边干边勾的渐进提交被并行会话压扁后警告照出,结构性误报;且 tasks.md 不进
215
+ * 交付 pathspec 的纪律下「untracked 直至归档」恒复现)。墙钟时戳是侧车文件事实,压扁/
216
+ * rebase 均不可改写——与「审计真相锚 change.patch sha256、不依赖 git 形态」口径对齐。
217
+ * 判定序(豁免优先):
218
+ * 'no-ticks' 侧车无记录(agent 全程直接 Edit)——不判(调用方给一行 task tick 教学);
219
+ * 'single' 仅 1 格——单格无节奏可言;
220
+ * 'spread' 时戳跨度 > clusterWindowMs(缺省 120s)——天然分散,边干边勾确认
221
+ * (超窗即铁证,无需存活锚);
222
+ * 'no-anchor' lifespanMs 非有限或 ≤0(baseline 缺失)——fail-open 不判(防误报);
223
+ * 'concentrated' 跨度 ≤ clusterWindowMs 且 lifespanMs ≥ ratio×max(跨度,1ms)(缺省 10)
224
+ * ——格子全挤在收口前短窗且变更活得够久,末尾集中勾选形态;
225
+ * 其余(挤短窗但存活比不足)→ 'spread'——快变更快勾是合法形态,不告。
226
+ * 输入 ticks 为 [{ts, task}](parseTickLog 产物或同形);行序不保证,内部按 ts 排序。
227
+ * 坏行(ts 非有限/≤0)静默剔除。watcher 采样事件刻意不作判据(部分观测会把分散勾选采样
228
+ * 成尾窗聚集,与采样合并跳系列坑同源——缺观测宁可教学不判)。
229
+ * @param {{ticks?:Array<{ts:number,task:string}>, lifespanMs?:number, clusterWindowMs?:number, ratio?:number}} args
230
+ * @returns {{status:'spread'|'concentrated'|'single'|'no-ticks'|'no-anchor', count:number, spanMs:number}}
231
+ */
232
+ export function detectTickTiming({ ticks, lifespanMs, clusterWindowMs = 120_000, ratio = 10 } = {}) {
233
+ const list = (Array.isArray(ticks) ? ticks : [])
234
+ .map((r) => Number(r && r.ts))
235
+ .filter((ts) => Number.isFinite(ts) && ts > 0)
236
+ .sort((a, b) => a - b);
237
+ if (list.length === 0) return { status: 'no-ticks', count: 0, spanMs: 0 };
238
+ if (list.length === 1) return { status: 'single', count: 1, spanMs: 0 };
239
+ const spanMs = list[list.length - 1] - list[0];
240
+ if (spanMs > clusterWindowMs) return { status: 'spread', count: list.length, spanMs };
241
+ const life = Number(lifespanMs);
242
+ if (!Number.isFinite(life) || life <= 0) return { status: 'no-anchor', count: list.length, spanMs };
243
+ if (life >= ratio * Math.max(spanMs, 1)) return { status: 'concentrated', count: list.length, spanMs };
244
+ return { status: 'spread', count: list.length, spanMs };
245
+ }
246
+
210
247
  /**
211
248
  * 单拍勾选门决策(2026-09-29-batch-tick-gate,纯函数):检出单拍跳(batchTick 非 null)
212
249
  * 时的收口动作裁决。镜像豁免分支退役(2026-10-07-thin-tasks-v3——任务面统一为 agent