sillyspec 3.26.10 → 3.26.13

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 (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
package/src/run/shared.js CHANGED
@@ -1,1215 +1,1348 @@
1
- /**
2
- * run.js 共享纯工具(W6 Step1 从 run.js 抽出)。
3
- *
4
- * 安全锚:run.js 始终是 barrel。这些函数搬至此模块后,run.js import 回来;
5
- * parsePorcelainPath + auditQuickCompletion 被 test 直接 import,run.js 必须 re-export。
6
- *
7
- * 路径修正(相对 src/run/):
8
- * - resolvePromptIncludes 的 templates/prompts 在仓库根 → __dirname 上两层
9
- * - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
10
- * - safeGit 已收口到 src/git-helper.js(单一公共 git 调用入口,数组形式不经 shell),
11
- * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
- */
13
- import { basename, join, resolve, dirname, sep } from 'node:path'
14
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
15
- import { fileURLToPath } from 'node:url'
16
- import os from 'node:os'
17
-
18
- // safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
19
- // re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
20
- import { safeGit } from '../git-helper.js'
21
- import { createHash } from 'node:crypto'
22
- export { safeGit }
23
-
24
- const __dirname = dirname(fileURLToPath(import.meta.url))
25
-
26
- // ── Wait State Constants ──(W6 Step3 从 run.js 搬入:prompt.js outputStep 与 run.js wait-detection 共用)
27
- // 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
28
- export const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
29
-
30
- // ── did-you-mean(命令级 + flag 级 typo 建议,零依赖)──
31
- // 历史:agent 打错命令名/flag 名只报「未知」+整屏列表,要自己捞正确拼写。
32
- export function levenshtein(a, b) {
33
- const m = String(a).length, n = String(b).length
34
- if (!m) return n; if (!n) return m
35
- let prev = Array.from({ length: n + 1 }, (_, i) => i)
36
- for (let i = 1; i <= m; i++) {
37
- const cur = [i]
38
- for (let j = 1; j <= n; j++) {
39
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
40
- }
41
- prev = cur
42
- }
43
- return prev[n]
44
- }
45
- export function didYouMean(input, candidates) {
46
- if (!input) return null
47
- let best = null, bestD = Infinity
48
- for (const c of candidates) {
49
- const d = levenshtein(String(input).toLowerCase(), String(c).toLowerCase())
50
- if (d < bestD) { bestD = d; best = c }
51
- }
52
- return (best !== null && bestD <= Math.max(1, Math.floor(String(input).length / 2))) ? best : null
53
- }
54
-
55
- // ── 变更名路径穿越消毒(F6)──
56
- // change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
57
- // 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
58
- // 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
59
- export function assertSafeChangeName(name, label = '变更名') {
60
- if (name == null) return
61
- const s = String(name)
62
- if (s === '') throw new Error(`${label}不能为空`)
63
- // 含路径分隔符 或 父目录穿越段 → 拒绝(Windows 下 \\ 也算)
64
- if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
65
- throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
66
- }
67
- if (!/^[\w.\-]+$/.test(s)) {
68
- throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
69
- }
70
- }
71
-
72
- import { buildExecuteSteps } from '../stages/execute.js'
73
- import { buildPlanSteps } from '../stages/plan.js'
74
- import { stageRegistry } from '../stages/index.js'
75
- import { parseRepo, parseRepoRegistry } from '../stages/plan-postcheck.js'
76
- import { MultiRepoContext } from './multi-repo-context.js'
77
-
78
- /**
79
- * 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
80
- * 把 stage step prompt 里 self-contained 的大块抽到外部模板,CLI 端组装时注入——
81
- * agent 收到的仍是自包含 prompt 字符串,无需自己 Read。单次替换(不递归);
82
- * 模板缺失则保留占位符并 warn,便于发现配置错误。
83
- */
84
- export function resolvePromptIncludes(text) {
85
- return text.replace(/\{\{include:\s*([\w.-]+)\s*\}\}/g, (match, name) => {
86
- // shared.js 在 src/run/,templates/prompts 在仓库根 → 上两层
87
- const tplPath = join(__dirname, '..', '..', 'templates', 'prompts', `${name}.md`)
88
- if (!existsSync(tplPath)) {
89
- console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
90
- return match
91
- }
92
- return readFileSync(tplPath, 'utf8')
93
- })
94
- }
95
-
96
- /**
97
- * 找 .sillyspec 祖先目录:用户指定 specDir 优先,否则从 cwd 向上找。
98
- * home 拒绝守卫:home 下的 .sillyspec 恒不命中——历史污染源(smoke 测试在 home 下临时目录
99
- * 跑 CLI,_ensureDB 读路径即建库,~/.sillyspec 长出一整套平行进度库后,任何 home 子目录
100
- * 跑命令都会向上撞它,污染自我延续)。撞 home 即停,回退 cwd/.sillyspec(子目录自建)。
101
- * @param {string} cwd
102
- * @param {object} [opts]
103
- * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
104
- * @returns {string} 规范目录的绝对路径
105
- */
106
- export function resolveSpecDir(cwd, opts = {}) {
107
- if (opts.specDir) return resolve(opts.specDir)
108
- const home = os.homedir()
109
- let dir = resolve(cwd)
110
- while (true) {
111
- // home 拒绝守卫:cwd 本身就在 home 下(含恰好在 home 跑命令)时,home 层不匹配 .sillyspec,
112
- // 继续向上只会离项目更远(home 的父目录不可能有真项目 .sillyspec),直接回退 cwd/.sillyspec。
113
- if (dir !== home) {
114
- const candidate = join(dir, '.sillyspec')
115
- if (existsSync(candidate)) return candidate
116
- }
117
- const parent = dirname(dir)
118
- if (parent === dir) break
119
- dir = parent
120
- }
121
- return join(resolve(cwd), '.sillyspec')
122
- }
123
-
124
- /**
125
- * 枚举 cwd 祖先链(含自身)上所有 .sillyspec 目录的绝对路径,上界 = git root。
126
- *
127
- * 返回顺序:从 cwd 最近开始向上到 git root(含两端命中项)。单实例项目任意子目录恒返回 [自身]。
128
- *
129
- * 上界 = git root(monorepo 天然边界)。必须有限上界:一路数到文件系统根会撞 home 等
130
- * 无关祖先的孤立 .sillyspec(home 下任何项目都被误报多实例)。非 git 仓库:不向上数,
131
- * 只看 cwd 自身(≤1,永不误报)。
132
- *
133
- * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
134
- * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
135
- */
136
- export function ancestorSpecDirs(cwd) {
137
- const resolved = resolve(cwd)
138
- // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
139
- // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
140
- // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
141
- // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
142
- const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
143
- let ceiling = topLevel ? resolve(topLevel) : null
144
- const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
145
- if (commonDir) {
146
- const absCommonDir = resolve(resolved, commonDir)
147
- if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
148
- const mainRoot = dirname(absCommonDir)
149
- // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
150
- if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
151
- }
152
- }
153
- const dirs = []
154
- let dir = resolved
155
- while (true) {
156
- if (existsSync(join(dir, '.sillyspec'))) dirs.push(join(dir, '.sillyspec'))
157
- if (!ceiling || resolve(dir) === resolve(ceiling)) break
158
- const parent = dirname(dir)
159
- if (parent === dir) break
160
- dir = parent
161
- }
162
- return dirs
163
- }
164
-
165
- /**
166
- * 统计 cwd 祖先链上有多少个 .sillyspec 目录(= ancestorSpecDirs(cwd).length)。
167
- *
168
- * ≥2 个 = monorepo 多实例(子项目独立 .sillyspec + 根 .sillyspec 并存),resolveSpecDir 命中的
169
- * 「最近」实例未必是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)→ 漂移风险。
170
- * 单实例项目在任意子目录工作恒返回 1,不误报。
171
- */
172
- export function countAncestorSpecDirs(cwd) {
173
- return ancestorSpecDirs(cwd).length
174
- }
175
-
176
- /**
177
- * 在 cwd 祖先链的各 specBase 中查找 quick 会话 guard.json(坑 quick-cwd-drift-splits-specdir)。
178
- *
179
- * sessionId(quick-<uuid8>)全局唯一,但 guard.json 按 specBase 分散落盘
180
- * (<specBase>/.runtime/quick-sessions/<sessionId>/guard.json)。cd 漂移到子项目后,当前 specBase
181
- * 下读不到本 session 的 guard,但祖先链别处的 specBase(创建 session 的根)仍有它 → 据此定位
182
- * session 真正归属,供 detectQuickSessionDrift 的 fail-fast 守卫判断。
183
- *
184
- * 遍历顺序 = ancestorSpecDirs(cwd 最近 → git root),返回第一个命中。
185
- *
186
- * @param {string} cwd
187
- * @param {string} sessionId - quick-<uuid8>
188
- * @returns {{ specBase: string, guard: object } | null}
189
- */
190
- export function locateQuickSessionGuard(cwd, sessionId) {
191
- if (!sessionId) return null
192
- for (const specBase of ancestorSpecDirs(cwd)) {
193
- const guardFile = join(specBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
194
- if (existsSync(guardFile)) {
195
- try {
196
- const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
197
- return { specBase, guard }
198
- } catch {
199
- // guard.json 损坏:当作不存在,继续找下一个(不阻断主流程)
200
- }
201
- }
202
- }
203
- return null
204
- }
205
-
206
- /**
207
- * 检测 quick 会话是否跨 specDir 漂移(坑 quick-cwd-drift-splits-specdir)。
208
- *
209
- * quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),故 quick
210
- * 漂移除 countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落
211
- * 子项目、根 session 停滞)。本函数补这个口:
212
- * - 当前 specBase 已有本 session guard → null(同一 specDir,无漂移)
213
- * - 当前 specBase 没有,但祖先链别处有同 sessionId guard → 判漂移,返回引导信息供调用方 fail-fast
214
- * - 别处也没有 → null(真·新会话首次启动,guard 尚未建,放行 —— 不误伤子项目主动启新 quick)
215
- *
216
- * 风格仿 validateChangeExists:null = 通过,对象 = 失败(含 message)。
217
- *
218
- * @param {string} cwd
219
- * @param {string} currentSpecBase - 当前命中的 specBase
220
- * @param {string} sessionId - quick-<uuid8>
221
- * @returns {{ realSpecBase: string, message: string } | null}
222
- */
223
- export function detectQuickSessionDrift(cwd, currentSpecBase, sessionId) {
224
- if (!sessionId || !currentSpecBase) return null
225
- // 当前 specBase 已有本 session guard → 同一 specDir,无漂移
226
- const currentGuard = join(currentSpecBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
227
- if (existsSync(currentGuard)) return null
228
- // 当前没有 → 看祖先链别处有没有同 sessionId 的 guard
229
- const located = locateQuickSessionGuard(cwd, sessionId)
230
- if (!located) return null // 别处也没有 = 真·新会话首次启动,放行
231
- return {
232
- realSpecBase: located.specBase,
233
- message: `quick 会话 "${sessionId}" 创建于 ${located.specBase},当前 cwd 解析到 ${currentSpecBase}(跨 specDir 漂移)。` +
234
- `继续会把 progress/artifact/QUICKLOG 写到错误的 spec,与原会话分裂。` +
235
- `排查:① cd 回 ${dirname(located.specBase)}(原 spec 所属项目根)再跑;② 或显式 --spec-dir ${located.specBase} 指定原 spec。`,
236
- }
237
- }
238
-
239
- /**
240
- * 检测 specBase 是否命中 worktree 内 checkout 出来的 .sillyspec 副本(坑 worktree-execute-spec-drift)。
241
- *
242
- * worktree 根 = <mainRepo>/.sillyspec/.runtime/worktrees/<changeName>/(worktree.js WORKTREES_REL)。
243
- * worktree 是主仓完整 checkout,若 .sillyspec/changes/ 被跟踪 → 副本里 change 目录真实存在,
244
- * validateChangeExists 被骗放行 → 进度/产出写进副本,与主仓 .sillyspec 分裂(副本随工作树清理丢失)。
245
- *
246
- * 判据:specBase 路径含 `.sillyspec/.runtime/worktrees/<seg>/.sillyspec`。主仓 specBase=<mainRepo>/.sillyspec
247
- * 无此段 → null,零误伤。返回 null=通过;对象=漂移(含 mainSpecBase/changeName/message),风格仿
248
- * validateChangeExists。覆盖 plan/execute/verify/archive(同 Set)——都要求 change 已存在、都会被副本骗过。
249
- * 平台模式/--spec-dir 已明确指定,由调用方跳过。
250
- */
251
- export function detectWorktreeSpecDrift(specBase) {
252
- if (!specBase) return null
253
- const seg = resolve(specBase).split(sep)
254
- // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
255
- // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
256
- // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
257
- if (seg[seg.length - 1] !== '.sillyspec') return null
258
- for (let i = 0; i + 3 < seg.length; i++) {
259
- if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
260
- const changeName = seg[i + 3]
261
- const mainSpec = seg.slice(0, i + 1).join(sep) // <mainRepo>/.sillyspec
262
- return {
263
- changeName,
264
- mainSpecBase: mainSpec,
265
- message: `当前 spec 命中 worktree 副本(${specBase})——这是 ${changeName} 隔离工作树内 checkout 出来的 .sillyspec,不是主仓 spec。\n` +
266
- ` 在此跑 plan/execute/verify/archive 会把进度与产出写到副本,与主仓 .sillyspec 分裂(副本随工作树清理而丢失)。\n` +
267
- ` 排查:① cd 回 ${dirname(mainSpec)}(主仓根)再跑;② 或 --spec-dir ${mainSpec} 显式指定主仓 spec。`,
268
- }
269
- }
270
- }
271
- return null
272
- }
273
-
274
- /**
275
- * 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
276
- *
277
- * 优先级:平台模式 runtimeRoot > drift 锚点 specDriftAnchor > 本地 specBase/.runtime。
278
- * specDriftAnchor 仅在此处消费,不参与平台 sentinel(specRoot||runtimeRoot)判定,
279
- * 避免误跳 triggerSync(:286)/ checkApproval(:313)/ 平台渲染分支(D-02)。
280
- *
281
- * 历史:drift 守卫(command.js)命中时只重写本地 specBase/specRoot/specDir/pm(progress 落主仓),
282
- * 但漏设 platformOpts 字段——下游 15 处 runtimeRoot 解析从 cwd(仍 worktree)重算 → execute-runs /
283
- * stage-reviews 落 worktree,被 cleanup 整目录删。本函数 + specDriftAnchor 锚点堵此源头。
284
- *
285
- * @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
286
- * @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
287
- * @returns {string} .runtime 根目录绝对路径
288
- */
289
- export function resolveRuntimeRoot(platformOpts, localSpecBase) {
290
- if (platformOpts?.runtimeRoot) return platformOpts.runtimeRoot
291
- if (platformOpts?.specDriftAnchor) return join(platformOpts.specDriftAnchor, '.runtime')
292
- return join(localSpecBase, '.runtime')
293
- }
294
-
295
- /**
296
- * quick 会话目录(.runtime/quick-sessions)的单一解析入口(multi-agent-review Q4)。
297
- *
298
- * 历史 bug:stage.js 写 guard 用 `specBase/.runtime/quick-sessions/`,收尾 handleQuickStageCompletion
299
- * 读/清理用 `resolveRuntimeRoot`(runtimeRoot 被设时返回 runtimeRoot)。两者在平台模式(runtimeRoot 与
300
- * specBase/.runtime 不同)下分裂——guard 写到 specBase/.runtime、收尾从 runtimeRoot 读不到 → guard=null
301
- * brownfield 分支整体跳过边界审计 + 兜底重分配 qlId + session 目录清错位置。
302
- *
303
- * 收敛到此函数后,写/读/清理三处共用同一解析(基于 resolveRuntimeRoot),任何平台/漂移模式都对齐。
304
- *
305
- * @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
306
- * @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
307
- * @returns {string} quick-sessions 目录绝对路径
308
- */
309
- export function resolveQuickSessionsDir(platformOpts, localSpecBase) {
310
- return join(resolveRuntimeRoot(platformOpts, localSpecBase), 'quick-sessions')
311
- }
312
-
313
- /**
314
- * 平台指针三写(单一数据源,runCommand scan 与 cmdInit 平台模式共用)。
315
- *
316
- * 写三处:
317
- * 1. <specRoot>/.runtime/platform-scan.json — 主持久化文件(后续 run/--done 恢复)
318
- * 2. <cwd>/.sillyspec-platform.json — 轻量恢复指针(供裸调——不带 --spec-root——找回 specDir)
319
- * 3. <cwd>/.sillyspec-platform-managed — 平台接管声明(无过期,独立于指针生命周期;
320
- * 指针被 cleanup/STALE 清理后它仍在,resolvePlatformSpecDir/runCommand 恢复链靠它
321
- * fail-closed 防静默落本地库。唯一删除路径 = platform disconnect)
322
- *
323
- * status 字段语义(POINTER_STATUS):
324
- * - init 落盘时传 status: 'active'(已接入未 scan,24h STALE 清理不作用于它)
325
- * - scan 完成时 complete-handlers 升级为 'scan_completed'
326
- * - 不传 status 则省略(与 runCommand 历史行为一致)
327
- *
328
- * 写失败静默(不阻断主流程),返回 true/false 供调用方决定是否提示。
329
- *
330
- * @param {string} cwd - 恢复指针落盘目录(= 调用方命令的 cwd,通常为项目根)
331
- * @param {object} platformOpts - { specRoot, runtimeRoot?, workspaceId?, scanRunId? }
332
- * @param {object} [extra] - 追加字段(如 { status: 'active' },只进指针/主文件不进声明)
333
- * @returns {boolean} 是否全部写入成功
334
- */
335
- export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
336
-
337
- export function writePlatformPointer(cwd, platformOpts, extra = {}) {
338
- if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
339
- const payload = {
340
- specRoot: platformOpts.specRoot || null,
341
- runtimeRoot: platformOpts.runtimeRoot || null,
342
- workspaceId: platformOpts.workspaceId || null,
343
- scanRunId: platformOpts.scanRunId || null,
344
- savedAt: new Date().toISOString(),
345
- ...extra,
346
- }
347
- // 声明文件字段集独立于指针 payload(D-E@v2 四字段:无 status/savedAt/scanRunId——
348
- // 声明语义是"项目归平台管"这一持久事实,非 scan 会话状态)
349
- const declaration = {
350
- managed: true,
351
- specRoot: platformOpts.specRoot || null,
352
- workspaceId: platformOpts.workspaceId || null,
353
- declaredAt: new Date().toISOString(),
354
- }
355
- try {
356
- mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
357
- writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
358
- writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify(payload, null, 2) + '\n')
359
- writeFileSync(join(cwd, PLATFORM_MANAGED_FILENAME), JSON.stringify(declaration, null, 2) + '\n')
360
- return true
361
- } catch {
362
- return false
363
- }
364
- }
365
-
366
- /**
367
- * 读平台接管声明(读侧宽容:任何异常情况返回 null = 视同无声明,不抛错)。
368
- * 供 resolvePlatformSpecDir / runCommand 恢复链 / doctor 诊断三处消费。
369
- *
370
- * @param {string} cwd - 项目根(声明查找目录)
371
- * @returns {{managed:true, specRoot, workspaceId, declaredAt}|null}
372
- * 文件不存在 / JSON 损坏 / managed 非 true → null
373
- */
374
- export function checkPlatformManaged(cwd) {
375
- const p = join(resolve(cwd), PLATFORM_MANAGED_FILENAME)
376
- if (!existsSync(p)) return null
377
- try {
378
- const decl = JSON.parse(readFileSync(p, 'utf8'))
379
- if (!decl || decl.managed !== true) return null
380
- return {
381
- managed: true,
382
- specRoot: decl.specRoot || null,
383
- workspaceId: decl.workspaceId || null,
384
- declaredAt: decl.declaredAt || null,
385
- }
386
- } catch {
387
- return null
388
- }
389
- }
390
-
391
- /**
392
- * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
393
- */
394
- export function resolveChangeDir(cwd, progress, specDir = null) {
395
- const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
396
- if (!existsSync(changesDir)) return null
397
-
398
- // 1. 优先用 currentChange
399
- if (progress.currentChange) {
400
- const target = join(changesDir, progress.currentChange)
401
- if (existsSync(target)) return target
402
- }
403
-
404
- // 2. fallback:唯一非 archive 目录
405
- const entries = readdirSync(changesDir, { withFileTypes: true })
406
- .filter(e => e.isDirectory() && e.name !== 'archive')
407
- if (entries.length === 1) return join(changesDir, entries[0].name)
408
-
409
- return null
410
- }
411
-
412
- /**
413
- * 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
414
- */
415
- // sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
416
- // fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
417
- // 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
418
- const SYNC_TOTAL_TIMEOUT_MS = 8_000
419
-
420
- export async function triggerSync(cwd, changeName, platformOpts = {}) {
421
- // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
422
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
423
- try {
424
- if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
425
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
426
- const syncMod = await import('../sync.js')
427
- let timer
428
- try {
429
- await Promise.race([
430
- syncMod.sync(changeName, cwd),
431
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
432
- ])
433
- } finally {
434
- clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
435
- }
436
- } catch (e) {
437
- // sync.js 不存在或同步失败,静默跳过
438
- console.warn('⚠️ 同步失败:', e.message)
439
- }
440
- }
441
-
442
- /**
443
- * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
444
- * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
445
- * 注入时机:CLI 启动(run/--done)+ 关键决策点(approve/archive 前)+ 手动 platform pull。
446
- * 不在每步 pull(避免高频写入与网络压力),仅低频边界点。
447
- * @param {string} cwd
448
- * @param {string} changeName - 当前活跃变更(多变更时传 null 跳过,避免误拉)
449
- * @param {object} [platformOpts] - 平台模式 opts(specRoot/runtimeRoot 存在则跳过,走平台自有链路)
450
- */
451
- export async function triggerPull(cwd, changeName, platformOpts = {}) {
452
- // 平台模式(SillyHub)走自己的链路,跳过
453
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
454
- try {
455
- const syncMod = await import('../sync.js')
456
- const sm = new syncMod.SyncManager(cwd)
457
- // 未连接平台静默跳过(本地独立用户合法状态,不噪音)
458
- if (!sm._getPlatform()) return
459
- let timer
460
- try {
461
- await Promise.race([
462
- sm.pull(changeName),
463
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
464
- ])
465
- } finally {
466
- clearTimeout(timer)
467
- }
468
- } catch (e) {
469
- // pull 失败静默跳过(Best Effort,失败不影响正确性)
470
- console.warn('⚠️ 拉取失败:', e.message)
471
- }
472
- }
473
-
474
- /**
475
- * triggerPull 的便捷封装:未显式传 changeName 时自动推导单活跃变更(task-10)。
476
- * 多活跃 / 无活跃变更时跳过(无法确定目标,避免误拉);其余语义同 triggerPull。
477
- * 供 index.js 在 stage 命令(run/--done/archive)与 approve 决策点注入一行调用。
478
- */
479
- export async function triggerPullActiveChange(cwd, platformOpts = {}) {
480
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
481
- // 先检查是否连接平台:未连接直接 return,避免 _ensureDB 在无 local.yaml 的 cwd 创建空 DB 污染
482
- let sm = null
483
- try {
484
- const syncMod = await import('../sync.js')
485
- sm = new syncMod.SyncManager(cwd)
486
- if (!sm._getPlatform()) return
487
- } catch {
488
- return
489
- }
490
- let cn = null
491
- try {
492
- const { ProgressManager } = await import('../progress.js')
493
- const pm = new ProgressManager({})
494
- const changes = pm.listChanges(cwd)
495
- if (changes.length === 1) cn = changes[0]
496
- } catch {
497
- // progress 不可达则跳过(Best Effort)
498
- }
499
- if (!cn) return
500
- // 已确认连接 + 单活跃变更,调 pull(复用 triggerPull 的 8s 熔断)
501
- try {
502
- let timer
503
- try {
504
- await Promise.race([
505
- sm.pull(cn),
506
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
507
- ])
508
- } finally {
509
- clearTimeout(timer)
510
- }
511
- } catch (e) {
512
- console.warn('⚠️ 拉取失败:', e.message)
513
- }
514
- }
515
-
516
- /**
517
- * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
518
- * 平台模式走自己的链路,跳过;否则 await import sync.js。
519
- * @returns {{ status: string, reason?: string } | null}
520
- */
521
- export async function checkApproval(cwd, changeName, platformOpts = {}) {
522
- // 平台模式不需要 CLI 内置审批检查
523
- if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
524
- try {
525
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
526
- const syncMod = await import('../sync.js')
527
- return await syncMod.checkApproval(changeName, cwd)
528
- } catch (e) {
529
- return null
530
- }
531
- }
532
-
533
- /**
534
- * 解析 git status --porcelain 单行 → 文件路径(去引号/处理 rename/归一化)。
535
- * 注意:line.slice(3) —— porcelain 行前 2 字符是状态码 + 1 空格,路径从 index 3 开始。
536
- */
537
- export function parsePorcelainPath(line) {
538
- if (!line) return ''
539
- let path = line.slice(3).trim()
540
- if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
541
- path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
542
- }
543
- const arrow = path.indexOf(' -> ')
544
- if (arrow !== -1) path = path.slice(arrow + 4)
545
- return path.replace(/\\/g, '/')
546
- }
547
-
548
- /**
549
- * quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计 与 QUICKLOG「文件:」行回填
550
- * 单源)。回填文件行时复用:review.changedFiles 含这些元数据时过滤掉,只留真实业务文件。
551
- *
552
- * 归类口径:
553
- * - quicklog/.runtime/modules/_module-map/knowledge-uncategorized 等 quick 自身产物 → 元数据
554
- * - .sillyspec/changes/:quick 自己没有该目录,其下文件要么是关联变更(reverse-sync),
555
- * 要么是并发他者会话的工作。非关联变更目录整体视为元数据放行;关联变更文件不算(属本 quick 真实改动)
556
- *
557
- * 「并发工作 vs 偷建变更」的意图软判定留给 sillyhub,确定性校验只做路径归类。
558
- *
559
- * @param {string} p 文件路径(容错 \\ / 混用)
560
- * @param {string[]} [linkedChanges] 关联变更名列表(影响 changes/ 归类)
561
- * @returns {boolean} true=quick 元数据,应从业务文件列表过滤掉
562
- */
563
- export function isQuickMetadata(p, linkedChanges = []) {
564
- const normalizeGitPath = (x) => String(x).replace(/\\/g, '/')
565
- const file = normalizeGitPath(p)
566
- if (file.startsWith('.sillyspec/quicklog/')
567
- || file.startsWith('.sillyspec/.runtime/')
568
- || file === '.sillyspec/knowledge/uncategorized.md'
569
- || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
570
- || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
571
- if (file.startsWith('.sillyspec/changes/')) {
572
- const linkedChangeNames = new Set((Array.isArray(linkedChanges) ? linkedChanges : []).map(c => normalizeGitPath(c)))
573
- const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
574
- // bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)或具体但非关联的变更 → 放行
575
- if (!m || !linkedChangeNames.has(m[1])) return true
576
- }
577
- return false
578
- }
579
-
580
- /**
581
- * quick 完成审计:对比 baseline 与实际变更。
582
- * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[] }}
583
- */
584
- /**
585
- * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
586
- * "涉及哪些模块卡"。只做归属(matchFilesToModules 纯函数零 git),不算 behind——对账是
587
- * execute Wave [docs-debt] 的职责(design D-001 轻量边界)。map 缺失/解析空/异常 → 空数组
588
- * 降级(hint 仍打,modules 省略),不报错不阻断。
589
- * 动态 import docs-debt.js/modules.js:prompt.js 已静态 import shared(本模块),静态互引成 ESM 环。
590
- */
591
- async function matchQuickModules(srcChanged, specBase, projectName) {
592
- if (!specBase || !projectName || srcChanged.length === 0) return []
593
- try {
594
- const { join } = await import('node:path')
595
- const { existsSync: ex, readFileSync: rf } = await import('node:fs')
596
- const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
597
- if (!ex(mapPath)) return []
598
- const { parseModuleMapSimple } = await import('../modules.js')
599
- const idx = parseModuleMapSimple(rf(mapPath, 'utf8'))
600
- if (!idx || Object.keys(idx).length === 0) return []
601
- const { matchFilesToModules } = await import('../docs-debt.js')
602
- const cardsDir = join(specBase, 'docs', projectName, 'modules')
603
- const { byModule } = matchFilesToModules(srcChanged, idx, { cardsDir })
604
- return [...byModule.entries()].map(([id, e]) => ({ id, doc: e.doc }))
605
- } catch {
606
- return []
607
- }
608
- }
609
-
610
- // ── task-03(2026-08-16-state-split-fixes): 活文档漂移提示辅助 ──
611
- // 背景:platform-interface-map.md 等活文档持续维护 src 主入口的 file:line 映射;并行会话改
612
- // src/ 后引用行号静默失效,docs gate 只能事后拦到别人的流程上。审计(auditQuickCompletion)
613
- // 当场检测「本次改动的 src 文件是否被活文档引用」并提示(advisory 不阻断,docs gate 语义不变)。
614
-
615
- /** 缺省活文档:src 主入口 ↔ 文档接口映射(file:line 锚点密度最高的持续维护文档)。 */
616
- export const DEFAULT_LIVING_DOC = 'docs/sillyspec/platform-interface-map.md'
617
-
618
- /**
619
- * 解析活文档集合:local.yaml `docs-check.living-docs` 列表【只追加不覆盖】缺省集合。
620
- * 与 docs-check.paths 的覆盖语义刻意不同——paths 是扫描范围(配了即整体替换缺省),
621
- * living-docs 是监控点登记(配了是加哨兵,不该把缺省监控点挤掉)。读配置走本模块现成的
622
- * readLocalYamlRaw + js-yaml 动态 import(readDocsCheckConfig 不回 living-docs 键;动态
623
- * import 与上方 docs-check 的隔离约定一致);坏 YAML / 非数组 / 读失败 → 仅缺省。
624
- * @param {string} cwd 仓库根(local.yaml 在 <cwd>/.sillyspec/local.yaml)
625
- * @returns {Promise<string[]>} 去重后的活文档相对路径(仓库根相对 POSIX)
626
- */
627
- export async function resolveLivingDocs(cwd) {
628
- let configured = []
629
- const raw = readLocalYamlRaw(cwd)
630
- if (raw) {
631
- try {
632
- const mod = await import('js-yaml')
633
- const yamlLoad = mod.load || mod.default?.load
634
- const doc = yamlLoad(raw)
635
- const dc = doc && typeof doc === 'object' ? doc['docs-check'] : null
636
- const ld = dc && typeof dc === 'object' ? dc['living-docs'] : null
637
- if (Array.isArray(ld)) configured = ld.filter((s) => typeof s === 'string' && s.trim() !== '')
638
- } catch { /* 坏 YAML → 忽略配置,仅缺省 */ }
639
- }
640
- return [...new Set([DEFAULT_LIVING_DOC, ...configured])]
641
- }
642
-
643
- /**
644
- * 纯函数:活文档引用的源码文件与本次改动的 src/ 文件求交。
645
- * 匹配口径对齐 docs-check resolveCandidates 的三形态(纯路径比对,不查盘):
646
- * ①仓库根相对:changed === ref(如 src/a.js)
647
- * ②src 内部相对:changed === 'src/' + ref(如 run/b.js → src/run/b.js)
648
- * ③裸名/中缀后缀:changed.endsWith('/' + ref)(如 b.js;前导斜杠防 aa.js 被引用 a.js 误吃)
649
- * changedFiles 口径 = git status porcelain 归一后的仓库根相对 POSIX 路径(parsePorcelainPath 产物)。
650
- * 同名多文件时后缀形态可能多报——与 docs-check 多候选宽容同向,advisory 仅提示可接受。
651
- * @param {string[]} srcChangedFiles changedFiles 中 src/ 前缀的文件
652
- * @param {Array<{file: string}>} refs collectDocRefs(md) 产物(只用 .file)
653
- * @returns {string[]} 命中的 srcChangedFiles 子集(去重保序)
654
- */
655
- export function matchLivingDocRefs(srcChangedFiles, refs) {
656
- const hit = new Set()
657
- for (const c of srcChangedFiles) {
658
- if (refs.some((r) => r && typeof r.file === 'string' &&
659
- (c === r.file || c === 'src/' + r.file || c.endsWith('/' + r.file)))) {
660
- hit.add(c)
661
- }
662
- }
663
- return [...hit]
664
- }
665
-
666
- export async function auditQuickCompletion(cwd, guard, options = {}) {
667
- const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false, allowDelete = false, specBase = null, projectName = null } = guard
668
- const { isConfirm } = options
669
- // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
670
- // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
671
- // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
672
- const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [] }
673
-
674
- try {
675
- // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
676
- // 外层 catch 吞成 warning(multi-agent-review Q3)。safeGit 返回 {value,error} 不抛;若仍有 error
677
- // 说明真读不到 git 状态,保守阻断(审计无锚点不能放行),不静默降级。
678
- // trim:false 必传:porcelain 首行前导空格是状态码一部分,trim 会削掉致 parsePorcelainPath 丢首字符。
679
- // timeout 15000 + retryOnTimeout:机器忙时 git 子进程启动慢,默认 5s 偏紧易瞬时超时(ETIMEDOUT),
680
- // 审计锚点失败即 blocked 中断 quick。加大到 15s 并对 ETIMEDOUT 重试一次(重试 30s),消化绝大多数
681
- // 瞬时抖动,免去用户手工重跑(实测反馈:审计 git 偶发 ETIMEDOUT,重试即过)。
682
- const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false, timeout: 15000, retryOnTimeout: true })
683
- if (statusResult.error) {
684
- result.reasons.push(`审计失败(git status): ${statusResult.error}`)
685
- result.status = 'blocked'
686
- return result
687
- }
688
- const gitStatus = statusResult.value || ''
689
- // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
690
- const currentEntries = gitStatus.split('\n').filter(Boolean)
691
-
692
- const normalizeGitPath = (p) => p.replace(/\\/g, '/')
693
- // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
694
- // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
695
- // → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
696
- // 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
697
- // 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
698
- const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
699
- const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
700
- const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
701
- const isBaselineFile = (p) => {
702
- const f = normalizeGitPath(p)
703
- return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
704
- }
705
- const DANGEROUS_PATTERNS = [
706
- 'package.json',
707
- 'package-lock.json',
708
- 'yarn.lock',
709
- 'pnpm-lock.yaml',
710
- '.eslintrc',
711
- 'tsconfig.json',
712
- // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
713
- // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
714
- // (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
715
- // 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
716
- // barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
717
- 'src/run.js',
718
- 'src/run/',
719
- 'src/progress.js',
720
- 'src/progress/',
721
- 'src/db.js',
722
- 'src/stage-contract.js',
723
- 'src/stage-contract-spec.js',
724
- 'src/worktree.js',
725
- 'src/worktree-apply.js',
726
- 'src/hooks/',
727
- ]
728
-
729
- for (const entry of currentEntries) {
730
- const status = entry.slice(0, 2).trim()
731
- const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
732
- if (!file) continue
733
-
734
- // 累计暂存计数:所有非 quick 元数据的未提交条目(含下方将被 baseline 跳过的前序残留)。
735
- // 放在 isBaselineFile 跳过之前,确保 baseline 内文件也计入「累计暂存」。
736
- if (!isQuickMetadata(file, guard.linkedChanges)) result.stagedTotal++
737
-
738
- // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
739
- if (isBaselineFile(file)) continue
740
-
741
- result.changedFiles.push(file)
742
- if (status === 'D' || status === ' D') result.deletedFiles.push(file)
743
- if (status === '??') result.newFiles.push(file)
744
-
745
- // 检查是否命中 baseline protected files
746
- if (baselineFiles.includes(file)) {
747
- result.baselineHit.push(file)
748
- }
749
-
750
- // 检查危险文件(除非 force-baseline)
751
- if (file.startsWith('.sillyspec/') && !isQuickMetadata(file, guard.linkedChanges) && !forceBaseline) {
752
- result.reasons.push(`危险文件变更: ${file}`)
753
- }
754
-
755
- if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
756
- result.reasons.push(`危险文件变更: ${file}`)
757
- }
758
- }
759
-
760
- // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
761
- if (!allowDelete) {
762
- for (const f of result.deletedFiles) {
763
- result.reasons.push(`删除文件: ${f}`)
764
- }
765
- }
766
-
767
- // 检查 baseline hit(除非 force-baseline)
768
- if (!forceBaseline) {
769
- for (const f of result.baselineHit) {
770
- result.reasons.push(`覆盖 baseline 文件: ${f}`)
771
- }
772
- }
773
-
774
- // 检查 new files(除非 allow-new)
775
- if (!allowNew) {
776
- for (const f of result.newFiles) {
777
- if (!isQuickMetadata(f, guard.linkedChanges)) {
778
- result.reasons.push(`新增文件(需 --allow-new): ${f}`)
779
- }
780
- }
781
- }
782
-
783
- // 检查 allowedFiles 范围
784
- if (allowedFiles.length > 0) {
785
- for (const f of result.changedFiles) {
786
- if (!allowedFiles.includes(f) && !isQuickMetadata(f, guard.linkedChanges)) {
787
- result.reasons.push(`超出 allowedFiles: ${f}`)
788
- }
789
- }
790
- }
791
-
792
- // 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning;
793
- // allow-delete 降级删除文件 → 非 blocked。reasons 文案本就受 flag 控制,但原判定直接看数组长度,
794
- // 致 flag 对 status 失效。)
795
- if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
796
- result.status = 'blocked'
797
- } else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
798
- result.status = 'warning'
799
- }
800
-
801
- // quicklog 存在性检查
802
- try {
803
- const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
804
- if (existsSync(quicklogDir)) {
805
- const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
806
- if (qlFiles.length === 0) {
807
- result.reasons.push('quicklog 目录为空(无任务记录)')
808
- if (result.status === 'safe') result.status = 'warning'
809
- }
810
- } else {
811
- result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
812
- if (result.status === 'safe') result.status = 'warning'
813
- }
814
- } catch {}
815
-
816
- // D-8 文档欠账显性化(advisory):本次改动含源码文件但不含任何文档文件 → 打标记 warn。
817
- // quick 是低摩擦通道(不强制文档同步),但欠账要显性化——72% 流量 86% 不动文档是系统性欠账
818
- // 主通道(doc-consistency-debt D-8)。不阻断不改 status,只 warn + 记 reasons 供 QUICKLOG 追溯。
819
- const isDocFile = (f) =>
820
- f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') ||
821
- f.startsWith('docs/') || f.startsWith('.sillyspec/docs/')
822
- const srcChanged = result.changedFiles.filter(f => !isDocFile(f) && !isQuickMetadata(f, guard.linkedChanges))
823
- const docChanged = result.changedFiles.filter(isDocFile)
824
- if (srcChanged.length > 0 && docChanged.length === 0) {
825
- result.reasons.push(`本次未同步模块文档(${srcChanged.length} 个源码文件改动,无文档文件)`)
826
- result.docSyncHint = { touchedSource: srcChanged.length, docFiles: [], modules: await matchQuickModules(srcChanged, specBase, projectName) }
827
- } else if (srcChanged.length > 0 && docChanged.length > 0) {
828
- result.docSyncHint = { touchedSource: srcChanged.length, docFiles: docChanged }
829
- }
830
-
831
- // docs check advisory(doc-consistency-debt D-6 后续):本次改动的 .md 文档跑 file:line
832
- // 引用校验,失效即 warn + reasons——检测能力落地后欠账只涨不跌的最后一公里。只查本次
833
- // changedFiles 的文档(归因到本次改动),不扫全仓历史欠账(那是存量问题,quick 不背锅)。
834
- // 排除删除文件(troubleshooting #9):删除的 .md 不在盘,runDocsCheck 只会报「文档不存在」
835
- // 假失效——删除语义归 --allow-delete 管,无引用校验可言(并行会话的删除尤其不该归属本会话)。
836
- // docs-check 是 IO 面(walkGlob/existsSync),动态 import 隔离;任何异常 fail-open 只跳过。
837
- const deletedSet = new Set(result.deletedFiles)
838
- const mdChanged = result.changedFiles.filter((f) => f.endsWith('.md') && !deletedSet.has(f) && !isQuickMetadata(f, guard.linkedChanges))
839
- if (mdChanged.length > 0) {
840
- try {
841
- const { runDocsCheck } = await import('../docs-check.js')
842
- const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged })
843
- if (!dc.ok) {
844
- result.reasons.push(`本次改动文档含 ${dc.invalid.length} 处失效 file:line 引用(sillyspec docs check 可复现)`)
845
- result.docsCheckHint = { invalid: dc.invalid.length, total: dc.total }
846
- if (result.status === 'safe') result.status = 'warning'
847
- }
848
- } catch { /* docs-check 不可用(老版本包等)→ 静默跳过,不阻断 quick */ }
849
- }
850
-
851
- // task-03: 活文档漂移提示(advisory,不阻断不改 status,docs gate 语义不变)。
852
- // 方向与上方 docsCheckHint 相反:那是「本次改的文档引用失效吗」,这是「本次改的 src 被活文档
853
- // 引用吗」——并行会话改 src/ 主入口后 platform-interface-map.md 的 file:line 行号静默失效,
854
- // gate 事后拦到别人流程上;审计当场命中交集即提示顺手跑 docs check。fail-open:活文档
855
- // 缺失/读失败/collectDocRefs 不可用 → 静默跳过(不误报不阻断)。
856
- const srcChangedFiles = result.changedFiles.filter((f) => f.startsWith('src/') && !deletedSet.has(f))
857
- if (srcChangedFiles.length > 0) {
858
- try {
859
- const { collectDocRefs } = await import('../docs-check.js')
860
- const livingDocs = await resolveLivingDocs(cwd)
861
- const files = []
862
- const docs = []
863
- for (const docRel of livingDocs) {
864
- let md
865
- try { md = readFileSync(join(cwd, docRel), 'utf8') } catch { continue } // 活文档缺失/读失败 → 跳过
866
- const hit = matchLivingDocRefs(srcChangedFiles, collectDocRefs(md))
867
- if (hit.length > 0) {
868
- files.push(...hit)
869
- docs.push(docRel)
870
- }
871
- }
872
- if (files.length > 0) {
873
- const uniqFiles = [...new Set(files)]
874
- result.docsCheckHint = { ...(result.docsCheckHint || {}), livingDocDrift: { files: uniqFiles, docs, total: srcChangedFiles.length } }
875
- result.reasons.push(`活文档引用漂移: 改动 ${uniqFiles.length} 个文件被活文档引用(${docs.join(', ')})`)
876
- }
877
- } catch { /* collectDocRefs 不可用 → 静默跳过 */ }
878
- }
879
-
880
- // task-02: 同文件并发检测——allowedFile 在 baseline(他者改过)且当前 hash ≠ step1 hash(我也改了)
881
- // → commit 整文件 pathspec 会夹带他者 hunk,warn 给分离指引(advisory,不阻断,D-002)
882
- // sameFileHits 提升到本层作用域:下方归属切分要把「baseline 声明文件且我也改了」并入
883
- // attributedFiles(这类文件先被 isBaselineFile 跳过不进 changedFiles,但确属本会话产物)。
884
- const sameFileHits = []
885
- if (allowedFiles.length > 0 && guard.allowedFilesHash) {
886
- for (const f of allowedFiles) {
887
- if (isBaselineFile(f) && guard.allowedFilesHash[f] !== undefined) {
888
- try {
889
- const cur = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')
890
- if (cur !== guard.allowedFilesHash[f]) sameFileHits.push(f)
891
- } catch {} // 文件读失败(删除等)跳过
892
- }
893
- }
894
- if (sameFileHits.length > 0) {
895
- result.reasons.push(`同文件并发: ${sameFileHits.length} 个 allowedFile 含他者+你的改动(${sameFileHits.join(', ')})`)
896
- console.warn(`\n⚠️ 同文件并发(${sameFileHits.length} 个 allowedFile 含他者改动+你的改动,commit 整文件会夹带他者 hunk):`)
897
- for (const f of sameFileHits) {
898
- console.warn(` - ${f}`)
899
- console.warn(` 分离:git add -p ${f}(交互选你的 hunk)或 git diff ${f} > mine.patch(编辑留你的)+ git apply --cached mine.patch`)
900
- }
901
- }
902
- }
903
-
904
- // 归属切分(2026-08-18 误归属修复,ql-20260818-003 实证):窗口 diff(changedFiles)在多 agent
905
- // 并发仓库分不清「本会话改」与「他者窗口内改」,他者文件会污染 QUICKLOG「文件:」行。声明即归属:
906
- // allowedFiles 非空时 attributedFiles = 窗口∩声明 ∪ sameFileHits(同文件并发 = 我也改了),
907
- // undeclaredFiles = 窗口−声明(他者或漏声明,供「审计:」行落盘追溯——不静默丢,防漏声明时
908
- // 真实改动被挤走无痕)。未声明会话无归属信息不猜,维持全量口径(attributed = changed)。
909
- const declaredNorm = new Set(allowedFiles.map(f => normalizeGitPath(f)))
910
- if (declaredNorm.size > 0) {
911
- const own = result.changedFiles.filter(f => declaredNorm.has(normalizeGitPath(f)))
912
- for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
913
- result.attributedFiles = own
914
- result.undeclaredFiles = result.changedFiles.filter(f => !declaredNorm.has(normalizeGitPath(f)))
915
- } else {
916
- result.attributedFiles = result.changedFiles
917
- result.undeclaredFiles = []
918
- }
919
-
920
- // --confirm 模式:展示 diff 并等待确认
921
- if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
922
- console.log(`\n📋 quick 变更概览:`)
923
- console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
924
- if (result.changedFiles.length > 0) {
925
- console.log(`\n 变更文件:`)
926
- for (const f of result.changedFiles) {
927
- const isBaseline = baselineFiles.includes(f)
928
- const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
929
- const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
930
- console.log(` ${marker} ${f}`)
931
- }
932
- }
933
- console.log(`\n 状态: ${result.status.toUpperCase()}`)
934
- if (result.reasons.length > 0) {
935
- console.log(` 原因:`)
936
- for (const r of result.reasons) {
937
- console.log(` - ${r}`)
938
- }
939
- }
940
- if (result.deletedFiles.length > 0) {
941
- if (guard.allowDelete) {
942
- console.log(`\n ℹ️ 已确认删除文件 ${result.deletedFiles.length} 个(--allow-delete 显式解锁):${result.deletedFiles.join(', ')}`)
943
- } else {
944
- console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——删除是破坏性操作默认 fail-closed,确认删除请带 --allow-delete 显式解锁:`)
945
- console.log(` sillyspec run quick --done --allow-delete --change <id> --output "..."`)
946
- console.log(` (--force-baseline / --allow-new 不能解锁删除;不确认请 git restore 撤回删除后再 --done,或走完整流程 execute 由 review 把关)`)
947
- }
948
- } else {
949
- console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
950
- console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
951
- console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
952
- console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
953
- }
954
- }
955
- } catch (e) {
956
- result.reasons.push(`审计失败: ${e.message}`)
957
- result.status = 'warning'
958
- }
959
-
960
- return result
961
- }
962
-
963
- // ── Step 处理辅助(W6 Step7a 从 run.js 搬入:getStageSteps/formatWaitOptions 被 run.js + complete.js 共用)──
964
- /**
965
- * 格式化 waitOptions 为人类可读字符串
966
- */
967
- export function formatWaitOptions(raw) {
968
- if (!raw) return null
969
- try {
970
- const parsed = JSON.parse(raw)
971
- if (Array.isArray(parsed)) return parsed.join(', ')
972
- return raw
973
- } catch {
974
- return raw
975
- }
976
- }
977
-
978
-
979
- /**
980
- * 进程级 MultiRepoContext 缓存(D-013 G2:execute 启动构造一次,贯穿 execute/apply/verify 不重建)。
981
- *
982
- * key = `${cwd} ${changeName}`(cwd 隔离防多仓并发串扰;changeName 隔离防同进程多 change 切换串扰)。
983
- * 跨 CLI 命令(execute vs apply vs verify 各是独立进程)各自构造——design §7.2 G2 明确 apply/verify
984
- * 「复用 execute 实例,**或**从 review.json.repo + local.yaml repos 反推」,进程级缓存指同一 CLI 命令内复用。
985
- *
986
- * 失效语义:ctx 是纯内存对象(task-01 不持久化),进程结束自动释放。无需手动清理。
987
- * 缓存命中场景:execute 单次 --done 内 getStageSteps(派发)+ completeStageGates(gate)+ 阶段完成
988
- * 收尾多次取同一 ctx;apply/verify 同理。fail-closed 抛错不缓存(下次重试重新构造)。
989
- */
990
- const _multiRepoCtxCache = new Map()
991
-
992
- /**
993
- * 读 local.yaml 原始文本(best-effort,不存在/读失败返 '')。
994
- * 与 sync.js readLocalYamlRaw 同风格,但本模块不引 sync.js(避免循环依赖 + sync.js 非 export)。
995
- * local.yaml 路径 = `<cwd>/.sillyspec/local.yaml`(与 sync.js/config-schema.js 一致)。
996
- * @param {string} cwd
997
- * @returns {string}
998
- */
999
- function readLocalYamlRaw(cwd) {
1000
- const p = join(cwd, '.sillyspec', 'local.yaml')
1001
- if (!existsSync(p)) return ''
1002
- try {
1003
- return readFileSync(p, 'utf8')
1004
- } catch {
1005
- return ''
1006
- }
1007
- }
1008
-
1009
- /**
1010
- * 从 plan.md 内容聚合所有 task 卡片声明的 repo:(去重,含 'main' 隐式)。
1011
- *
1012
- * design §5.4 execute 启动段 + §7.2:扫 plan.md 所有 task 卡片 frontmatter,用 parseRepo 解析
1013
- * repo: 字段(缺省='main'),去重后返回数组。'main' 永远隐式在列(主仓 task 不写 repo: 也算主仓)。
1014
- *
1015
- * 聚合策略:plan.md 由 plan 阶段生成,task 卡片 frontmatter 形如 `---\nrepo: sillyspec\n...\n---`
1016
- * (与 task-NN.md 同源)。本函数全局匹配所有 frontmatter 块(`/^---\n([\s\S]*?)\n---/mg`),
1017
- * 每块重新拼成完整 frontmatter 喂 parseRepo(parseRepo 内部 `^---\n...\n---` 锚开头)。
1018
- *
1019
- * @param {string} planContent
1020
- * @returns {string[]} 去重后的 repo 数组(至少含 'main')
1021
- */
1022
- export function aggregateDeclaredRepos(planContent) {
1023
- const seen = new Set(['main']) // main 永远隐式
1024
- if (!planContent) return ['main']
1025
- // 全局匹配所有 frontmatter 块(m 标志让 ^ 匹配每行行首,但此处 ^--- 要求行首 ---,
1026
- // 配合 [\s\S]*? 非贪婪到下一个 ---)。每块重新拼完整 frontmatter 喂 parseRepo。
1027
- const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/gm
1028
- let match
1029
- while ((match = fmRegex.exec(planContent)) !== null) {
1030
- const fmBody = match[1]
1031
- // 重新拼成 parseRepo 期望的完整 frontmatter(---\n<body>\n---)
1032
- const repo = parseRepo(`---\n${fmBody}\n---`)
1033
- if (repo && !seen.has(repo)) seen.add(repo)
1034
- }
1035
- return Array.from(seen)
1036
- }
1037
-
1038
- /**
1039
- * 坑7 补扫:读 tasks/task-NN.md 独立卡片,拼成可被 aggregateDeclaredRepos 的 frontmatter
1040
- * 正则识别的文本(每张卡内容原样拼接,卡内容自带的 ---\n…\n--- frontmatter 会被同一正则命中)。
1041
- *
1042
- * plan 阶段标准产出形态是 plan.md 内联卡片块 + tasks/ 独立卡片双写,但实际执行中 plan.md
1043
- * 可能只留 checkbox 行(卡片全在 tasks/)——此时 plan.md 聚合不到跨仓 repo,跨仓 review
1044
- * 误报伪造。本函数读 tasks/ 目录兜底,任一文件读失败跳过(fail-open,兜底源不引入新阻断)。
1045
- *
1046
- * @param {string} specDir - spec 根(change 目录的父级)
1047
- * @param {string} changeName
1048
- * @returns {string} 拼接文本(无 tasks/ 或全空 → '',plan.md 原行为零回归)
1049
- */
1050
- function collectTaskCardReposFallback(specDir, changeName) {
1051
- const tasksDir = join(specDir, 'changes', changeName, 'tasks')
1052
- if (!existsSync(tasksDir)) return ''
1053
- let merged = ''
1054
- for (const f of readdirSync(tasksDir)) {
1055
- if (!/^task-\d+\.md$/i.test(f)) continue
1056
- const p = join(tasksDir, f)
1057
- // CRLF 卡片:\r?\n 容差在 aggregateDeclaredRepos 正则里,这里只管拼接不归一
1058
- try {
1059
- merged += '\n' + readFileSync(p, 'utf8') + '\n'
1060
- } catch {
1061
- // 单文件读失败跳过——兜底源缺失不该阻断主聚合
1062
- }
1063
- }
1064
- return merged
1065
- }
1066
-
1067
- /**
1068
- * 构造或取进程级 MultiRepoContext 单例(D-013 G2)。
1069
- *
1070
- * design §5.4 execute 启动段 + §7.2 G2 + 决策 D-001/D-013:
1071
- * - 读 local.yaml repos: 段(parseRepoRegistry)→ repoRegistry Map(main 隐式不注册)
1072
- * - 扫 plan.md 所有 task 卡片 repo:(aggregateDeclaredRepos)→ declaredRepos(含 'main')
1073
- * - 构造 MultiRepoContext(约束② fail-closed:未注册 repo / 跨仓 git 不可用抛错阻断)
1074
- * - 按 `${cwd} ${changeName}` 缓存,同进程同 change 复用
1075
- *
1076
- * **单仓退化**(design §7.2 「main 隐式为 cwd 不用注册」):local.yaml 无 repos: 段 + 单仓 change
1077
- * (declaredRepos=['main'])→ repoRegistry=空 Map,MultiRepoContext 退化为 {main:{...}} 单值 map,
1078
- * hasCrossRepo()=false,所有 ctx 透传点缺省行为零回归(task-01/04/05/06/07/08 各模块 ctx=null 等价)。
1079
- *
1080
- * **跨仓 fail-closed**(约束② D-007):declaredRepos 含 local.yaml 未注册的 repo key,或跨仓仓
1081
- * git rev-parse 失败 → MultiRepoContext 构造抛错,本函数**不缓存**该错误,原样上抛阻断 execute 启动。
1082
- *
1083
- * 缓存语义:命中返回已构造实例;未命中且构造成功则缓存后返回;构造失败不缓存(下次重试重新构造)。
1084
- *
1085
- * @param {Object} opts
1086
- * @param {string} opts.cwd - 主仓 cwd
1087
- * @param {string} opts.changeName - 当前 change 名
1088
- * @param {Object} [opts.platformOpts] - 平台选项(specRoot 等,透传给 MultiRepoContext 预留)
1089
- * @param {boolean} [opts.noCache=false] - 跳过缓存(测试 / 显式重建场景)
1090
- * @returns {MultiRepoContext|null} changeName/cwd 缺失或无 plan.md 时返回 null(单仓退化,调用方按 ctx=null 处理)
1091
- * @throws {Error} MultiRepoContext 构造 fail-closed(约束②未注册/跨仓git不可用)
1092
- */
1093
- export async function getOrCreateMultiRepoContext({ cwd, changeName, platformOpts = {}, noCache = false } = {}) {
1094
- if (!cwd || !changeName) return null
1095
- const cacheKey = `${cwd} ${changeName}`
1096
- if (!noCache && _multiRepoCtxCache.has(cacheKey)) {
1097
- return _multiRepoCtxCache.get(cacheKey)
1098
- }
1099
-
1100
- // 读 plan.md(execute 启动入口的 change 目录),无 plan.md → 无法聚合 declaredRepos → 退化 null
1101
- const specDir = platformOpts?.specRoot || resolveSpecDir(cwd)
1102
- const planFile = join(specDir, 'changes', changeName, 'plan.md')
1103
- let planContent = ''
1104
- if (existsSync(planFile)) {
1105
- try {
1106
- planContent = readFileSync(planFile, 'utf8')
1107
- } catch {
1108
- planContent = ''
1109
- }
1110
- }
1111
- if (!planContent) {
1112
- // 无 plan.md(plan 未完成 / brainstorm 阶段 / quick 等场景)→ 无 task 卡片可扫,
1113
- // 不构造 ctx(返 null 退化单仓)。跨仓 task 必须 plan.md 落地后才进 execute,此处安全。
1114
- return null
1115
- }
1116
-
1117
- // 聚合 declaredRepos(扫 plan.md task 卡片 repo:)+ 读 local.yaml repos: 段
1118
- // 坑7 兼扫 tasks/task-NN.md:plan 阶段把卡片写到 tasks/、plan.md 只留 checkbox 行时,
1119
- // plan.md 内联 frontmatter 聚合不到跨仓 repo → ctx 不含跨仓 entry → 跨仓 review 退回主仓
1120
- // gitDir 校验,误报「base 不是真实 commit — 疑似伪造」(真实 commit 在跨仓仓可达)。
1121
- // 两源并入同一聚合:plan.md 内联块(原行为,零回归)+ tasks/ 独立卡片(坑7 补扫)。
1122
- const declaredRepos = aggregateDeclaredRepos(
1123
- planContent + collectTaskCardReposFallback(specDir, changeName)
1124
- )
1125
- const repoRegistry = parseRepoRegistry(readLocalYamlRaw(cwd))
1126
-
1127
- // 构造 WorktreeManager(主仓 meta 读取;与 getStageSteps 同源范式)
1128
- let worktreeManager
1129
- try {
1130
- const { WorktreeManager } = await import('../worktree.js')
1131
- worktreeManager = new WorktreeManager({ cwd })
1132
- } catch {
1133
- // WorktreeManager 不可用 → 退化为最小桩(in-place-fallback 兜底 cwd)
1134
- worktreeManager = { getMeta: () => null }
1135
- }
1136
-
1137
- const ctx = new MultiRepoContext({
1138
- cwd,
1139
- changeName,
1140
- platformOpts,
1141
- declaredRepos,
1142
- repoRegistry,
1143
- worktreeManager,
1144
- })
1145
-
1146
- if (!noCache) {
1147
- _multiRepoCtxCache.set(cacheKey, ctx)
1148
- }
1149
- return ctx
1150
- }
1151
-
1152
- /**
1153
- * 清除进程级 MultiRepoContext 缓存(测试隔离用)。生产代码不需手动清。
1154
- * @param {string} [cwd]
1155
- * @param {string} [changeName]
1156
- */
1157
- export function _clearMultiRepoCtxCache(cwd, changeName) {
1158
- if (cwd && changeName) {
1159
- _multiRepoCtxCache.delete(`${cwd} ${changeName}`)
1160
- } else {
1161
- _multiRepoCtxCache.clear()
1162
- }
1163
- }
1164
-
1165
-
1166
- /**
1167
- * 获取阶段的步骤定义(execute 需要动态构建)
1168
- */
1169
- export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1170
- if (stageName === 'execute') {
1171
- const changeDir = resolveChangeDir(cwd, progress, specDir)
1172
- let planFile = null
1173
- let worktreePath = null
1174
- let changeName = null
1175
- if (changeDir) {
1176
- const p = join(changeDir, 'plan.md')
1177
- if (existsSync(p)) planFile = p
1178
- changeName = basename(changeDir)
1179
- // 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
1180
- // 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
1181
- // 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
1182
- try {
1183
- const { WorktreeManager } = await import('../worktree.js')
1184
- const wm = new WorktreeManager({ cwd })
1185
- const meta = wm.getMeta(changeName)
1186
- if (meta?.worktreePath && existsSync(meta.worktreePath)) {
1187
- worktreePath = meta.worktreePath
1188
- }
1189
- } catch {
1190
- // 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
1191
- }
1192
- }
1193
- // W3 task-09:execute 启动入口构造 MultiRepoContext 单例(D-013 G2),贯穿 execute/apply/verify。
1194
- // 单仓 change(无 repo: / 无 local.yaml repos 段)→ ctx=null 退化单仓零回归(task-08 buildExecuteSteps 缺省 null)。
1195
- // 跨仓 change 缺注册 / 跨仓 git 不可用 → getOrCreateMultiRepoContext 抛错阻断 execute 启动(约束②)。
1196
- let ctx = null
1197
- if (changeName) {
1198
- try {
1199
- ctx = await getOrCreateMultiRepoContext({ cwd, changeName, platformOpts: specDir ? { specRoot: specDir } : {} })
1200
- } catch (e) {
1201
- // fail-closed 阻断:execute 启动期 ctx 构造失败(未注册 repo / 跨仓 git 不可用),
1202
- // 抛错让上层 runCommand 把错误返回给 Agent(不降级单仓跑——跨仓 apply 走错仓=数据所有权事故)。
1203
- throw new Error(`execute 启动失败:跨仓 MultiRepoContext 构造失败(${e.message})`)
1204
- }
1205
- }
1206
- return buildExecuteSteps(planFile, { worktreePath, ctx })
1207
- }
1208
- if (stageName === 'plan') {
1209
- const changeDir = resolveChangeDir(cwd, progress, specDir)
1210
- return buildPlanSteps(changeDir)
1211
- }
1212
- const def = stageRegistry[stageName]
1213
- return def ? def.steps : null
1214
- }
1215
-
1
+ /**
2
+ * run.js 共享纯工具(W6 Step1 从 run.js 抽出)。
3
+ *
4
+ * 安全锚:run.js 始终是 barrel。这些函数搬至此模块后,run.js import 回来;
5
+ * parsePorcelainPath + auditQuickCompletion 被 test 直接 import,run.js 必须 re-export。
6
+ *
7
+ * 路径修正(相对 src/run/):
8
+ * - resolvePromptIncludes 的 templates/prompts 在仓库根 → __dirname 上两层
9
+ * - triggerSync 的动态 import './sync.js' → '../sync.js'(src/sync.js)
10
+ * - safeGit 已收口到 src/git-helper.js(单一公共 git 调用入口,数组形式不经 shell),
11
+ * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
+ */
13
+ import { basename, join, resolve, dirname, sep } from 'node:path'
14
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+ import os from 'node:os'
17
+
18
+ // safeGit 收口至 src/git-helper.js:import 建本地词法绑定(本模块内部 L128/130/431 调用)+
19
+ // re-export 供 run/ 层现有调用方继续从 shared.js 引用(pure re-export 不建本地绑定,会致内部 ReferenceError)。
20
+ import { safeGit } from '../git-helper.js'
21
+ import { createHash } from 'node:crypto'
22
+ export { safeGit }
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url))
25
+
26
+ // ── Wait State Constants ──(W6 Step3 从 run.js 搬入:prompt.js outputStep 与 run.js wait-detection 共用)
27
+ // 正则匹配:只识别独立一行的标记,避免误伤文档正文引用
28
+ export const WAIT_MARKER_RE = /^\s*\[(WAIT_FOR_USER|NEEDS_CONFIRM|NEEDS_DECISION)\]\s*$/m
29
+
30
+ // ── did-you-mean(命令级 + flag 级 typo 建议,零依赖)──
31
+ // 历史:agent 打错命令名/flag 名只报「未知」+整屏列表,要自己捞正确拼写。
32
+ export function levenshtein(a, b) {
33
+ const m = String(a).length, n = String(b).length
34
+ if (!m) return n; if (!n) return m
35
+ let prev = Array.from({ length: n + 1 }, (_, i) => i)
36
+ for (let i = 1; i <= m; i++) {
37
+ const cur = [i]
38
+ for (let j = 1; j <= n; j++) {
39
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
40
+ }
41
+ prev = cur
42
+ }
43
+ return prev[n]
44
+ }
45
+ export function didYouMean(input, candidates) {
46
+ if (!input) return null
47
+ let best = null, bestD = Infinity
48
+ for (const c of candidates) {
49
+ const d = levenshtein(String(input).toLowerCase(), String(c).toLowerCase())
50
+ if (d < bestD) { bestD = d; best = c }
51
+ }
52
+ return (best !== null && bestD <= Math.max(1, Math.floor(String(input).length / 2))) ? best : null
53
+ }
54
+
55
+ // ── 变更名路径穿越消毒(F6)──
56
+ // change 名会被 join 进 .sillyspec/changes/<name>/ 当目录用。若含 ../ 或路径分隔符,
57
+ // 会逃出 changes/ 写到任意位置(如 --change ../../etc/x 把 proposal.md 写到 changes 同级之外)。
58
+ // 在 --change 解析边界统一消毒,比在每个 join 点守卫更集中。合法名:字母/数字/._-,禁分隔符与 ..
59
+ export function assertSafeChangeName(name, label = '变更名') {
60
+ if (name == null) return
61
+ const s = String(name)
62
+ if (s === '') throw new Error(`${label}不能为空`)
63
+ // 含路径分隔符 或 父目录穿越段 → 拒绝(Windows 下 \\ 也算)
64
+ if (/[\\/]/.test(s) || /(^|[/\\])\.\.(?=$|[/\\])/.test(s) || s === '..' || s.includes('..')) {
65
+ throw new Error(`${label}「${s}」含路径分隔符或 ..,禁止路径穿越(变更名只能是 .sillyspec/changes/ 下的一个目录名,不能逃出)`)
66
+ }
67
+ if (!/^[\w.\-]+$/.test(s)) {
68
+ throw new Error(`${label}「${s}」含非法字符(仅允许字母、数字、._-)`)
69
+ }
70
+ }
71
+
72
+ import { buildExecuteSteps } from '../stages/execute.js'
73
+ import { buildPlanSteps } from '../stages/plan.js'
74
+ import { stageRegistry } from '../stages/index.js'
75
+ import { parseRepo, parseRepoRegistry } from '../stages/plan-postcheck.js'
76
+ import { MultiRepoContext } from './multi-repo-context.js'
77
+
78
+ /**
79
+ * 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
80
+ * 把 stage step prompt 里 self-contained 的大块抽到外部模板,CLI 端组装时注入——
81
+ * agent 收到的仍是自包含 prompt 字符串,无需自己 Read。单次替换(不递归);
82
+ * 模板缺失则保留占位符并 warn,便于发现配置错误。
83
+ */
84
+ export function resolvePromptIncludes(text) {
85
+ return text.replace(/\{\{include:\s*([\w.-]+)\s*\}\}/g, (match, name) => {
86
+ // shared.js 在 src/run/,templates/prompts 在仓库根 → 上两层
87
+ const tplPath = join(__dirname, '..', '..', 'templates', 'prompts', `${name}.md`)
88
+ if (!existsSync(tplPath)) {
89
+ console.warn(`⚠️ prompt include 模板缺失: ${name} (期望: ${tplPath})`)
90
+ return match
91
+ }
92
+ return readFileSync(tplPath, 'utf8')
93
+ })
94
+ }
95
+
96
+ /**
97
+ * 找 .sillyspec 祖先目录:用户指定 specDir 优先,否则从 cwd 向上找。
98
+ * home 拒绝守卫:home 下的 .sillyspec 恒不命中——历史污染源(smoke 测试在 home 下临时目录
99
+ * 跑 CLI,_ensureDB 读路径即建库,~/.sillyspec 长出一整套平行进度库后,任何 home 子目录
100
+ * 跑命令都会向上撞它,污染自我延续)。撞 home 即停,回退 cwd/.sillyspec(子目录自建)。
101
+ * @param {string} cwd
102
+ * @param {object} [opts]
103
+ * @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
104
+ * @returns {string} 规范目录的绝对路径
105
+ */
106
+ export function resolveSpecDir(cwd, opts = {}) {
107
+ if (opts.specDir) return resolve(opts.specDir)
108
+ const home = os.homedir()
109
+ let dir = resolve(cwd)
110
+ while (true) {
111
+ // home 拒绝守卫:cwd 本身就在 home 下(含恰好在 home 跑命令)时,home 层不匹配 .sillyspec,
112
+ // 继续向上只会离项目更远(home 的父目录不可能有真项目 .sillyspec),直接回退 cwd/.sillyspec。
113
+ if (dir !== home) {
114
+ const candidate = join(dir, '.sillyspec')
115
+ if (existsSync(candidate)) return candidate
116
+ }
117
+ const parent = dirname(dir)
118
+ if (parent === dir) break
119
+ dir = parent
120
+ }
121
+ return join(resolve(cwd), '.sillyspec')
122
+ }
123
+
124
+ /**
125
+ * 枚举 cwd 祖先链(含自身)上所有 .sillyspec 目录的绝对路径,上界 = git root。
126
+ *
127
+ * 返回顺序:从 cwd 最近开始向上到 git root(含两端命中项)。单实例项目任意子目录恒返回 [自身]。
128
+ *
129
+ * 上界 = git root(monorepo 天然边界)。必须有限上界:一路数到文件系统根会撞 home 等
130
+ * 无关祖先的孤立 .sillyspec(home 下任何项目都被误报多实例)。非 git 仓库:不向上数,
131
+ * 只看 cwd 自身(≤1,永不误报)。
132
+ *
133
+ * 被 countAncestorSpecDirs(漂移提醒 warn)与 locateQuickSessionGuard(quick 漂移 fail-fast
134
+ * 守卫,坑 quick-cwd-drift-splits-specdir)共用 —— 单一真相源,避免两处各自重写祖先枚举漂移。
135
+ */
136
+ export function ancestorSpecDirs(cwd) {
137
+ const resolved = resolve(cwd)
138
+ // 上界 = git root。但 linked worktree 内 --show-toplevel 返回 worktree 根(非主仓根),
139
+ // 祖先链到不了主仓 .sillyspec → 漂移提醒/quick 守卫全哑(坑 worktree-execute-spec-drift)。
140
+ // 复刻 worktree.js _resolveMainRepoRoot:--git-common-dir 的 dirname 才是主仓根,取更靠上者作 ceiling。
141
+ // (git 可能返回相对路径,须 resolve(cwd, commonDir) 绝对化,否则相对 process.cwd 误解析。)
142
+ const topLevel = safeGit(resolved, ['rev-parse', '--show-toplevel']).value
143
+ let ceiling = topLevel ? resolve(topLevel) : null
144
+ const commonDir = safeGit(resolved, ['rev-parse', '--git-common-dir']).value
145
+ if (commonDir) {
146
+ const absCommonDir = resolve(resolved, commonDir)
147
+ if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
148
+ const mainRoot = dirname(absCommonDir)
149
+ // 主仓根是 worktree 根的祖先时取它(更靠上);monorepo/单仓两者相等不替换,行为不变
150
+ if (!ceiling || resolve(ceiling).startsWith(resolve(mainRoot) + sep)) ceiling = mainRoot
151
+ }
152
+ }
153
+ const dirs = []
154
+ let dir = resolved
155
+ while (true) {
156
+ if (existsSync(join(dir, '.sillyspec'))) dirs.push(join(dir, '.sillyspec'))
157
+ if (!ceiling || resolve(dir) === resolve(ceiling)) break
158
+ const parent = dirname(dir)
159
+ if (parent === dir) break
160
+ dir = parent
161
+ }
162
+ return dirs
163
+ }
164
+
165
+ /**
166
+ * 统计 cwd 祖先链上有多少个 .sillyspec 目录(= ancestorSpecDirs(cwd).length)。
167
+ *
168
+ * ≥2 个 = monorepo 多实例(子项目独立 .sillyspec + 根 .sillyspec 并存),resolveSpecDir 命中的
169
+ * 「最近」实例未必是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)→ 漂移风险。
170
+ * 单实例项目在任意子目录工作恒返回 1,不误报。
171
+ */
172
+ export function countAncestorSpecDirs(cwd) {
173
+ return ancestorSpecDirs(cwd).length
174
+ }
175
+
176
+ /**
177
+ * 在 cwd 祖先链的各 specBase 中查找 quick 会话 guard.json(坑 quick-cwd-drift-splits-specdir)。
178
+ *
179
+ * sessionId(quick-<uuid8>)全局唯一,但 guard.json 按 specBase 分散落盘
180
+ * (<specBase>/.runtime/quick-sessions/<sessionId>/guard.json)。cd 漂移到子项目后,当前 specBase
181
+ * 下读不到本 session 的 guard,但祖先链别处的 specBase(创建 session 的根)仍有它 → 据此定位
182
+ * session 真正归属,供 detectQuickSessionDrift 的 fail-fast 守卫判断。
183
+ *
184
+ * 遍历顺序 = ancestorSpecDirs(cwd 最近 → git root),返回第一个命中。
185
+ *
186
+ * @param {string} cwd
187
+ * @param {string} sessionId - quick-<uuid8>
188
+ * @returns {{ specBase: string, guard: object } | null}
189
+ */
190
+ export function locateQuickSessionGuard(cwd, sessionId) {
191
+ if (!sessionId) return null
192
+ for (const specBase of ancestorSpecDirs(cwd)) {
193
+ const guardFile = join(specBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
194
+ if (existsSync(guardFile)) {
195
+ try {
196
+ const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
197
+ return { specBase, guard }
198
+ } catch {
199
+ // guard.json 损坏:当作不存在,继续找下一个(不阻断主流程)
200
+ }
201
+ }
202
+ }
203
+ return null
204
+ }
205
+
206
+ /**
207
+ * 检测 quick 会话是否跨 specDir 漂移(坑 quick-cwd-drift-splits-specdir)。
208
+ *
209
+ * quick 被 validateChangeExists 的 sessionId 豁免(quick-<8hex> 不在 changes/ 下),故 quick
210
+ * 漂移除 countAncestorSpecDirs 的 warn 外无硬守卫 → 无声分裂(progress/artifact/QUICKLOG 落
211
+ * 子项目、根 session 停滞)。本函数补这个口:
212
+ * - 当前 specBase 已有本 session guard → null(同一 specDir,无漂移)
213
+ * - 当前 specBase 没有,但祖先链别处有同 sessionId guard → 判漂移,返回引导信息供调用方 fail-fast
214
+ * - 别处也没有 → null(真·新会话首次启动,guard 尚未建,放行 —— 不误伤子项目主动启新 quick)
215
+ *
216
+ * 风格仿 validateChangeExists:null = 通过,对象 = 失败(含 message)。
217
+ *
218
+ * @param {string} cwd
219
+ * @param {string} currentSpecBase - 当前命中的 specBase
220
+ * @param {string} sessionId - quick-<uuid8>
221
+ * @returns {{ realSpecBase: string, message: string } | null}
222
+ */
223
+ export function detectQuickSessionDrift(cwd, currentSpecBase, sessionId) {
224
+ if (!sessionId || !currentSpecBase) return null
225
+ // 当前 specBase 已有本 session guard → 同一 specDir,无漂移
226
+ const currentGuard = join(currentSpecBase, '.runtime', 'quick-sessions', sessionId, 'guard.json')
227
+ if (existsSync(currentGuard)) return null
228
+ // 当前没有 → 看祖先链别处有没有同 sessionId 的 guard
229
+ const located = locateQuickSessionGuard(cwd, sessionId)
230
+ if (!located) return null // 别处也没有 = 真·新会话首次启动,放行
231
+ return {
232
+ realSpecBase: located.specBase,
233
+ message: `quick 会话 "${sessionId}" 创建于 ${located.specBase},当前 cwd 解析到 ${currentSpecBase}(跨 specDir 漂移)。` +
234
+ `继续会把 progress/artifact/QUICKLOG 写到错误的 spec,与原会话分裂。` +
235
+ `排查:① cd 回 ${dirname(located.specBase)}(原 spec 所属项目根)再跑;② 或显式 --spec-dir ${located.specBase} 指定原 spec。`,
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 检测 specBase 是否命中 worktree 内 checkout 出来的 .sillyspec 副本(坑 worktree-execute-spec-drift)。
241
+ *
242
+ * worktree 根 = <mainRepo>/.sillyspec/.runtime/worktrees/<changeName>/(worktree.js WORKTREES_REL)。
243
+ * worktree 是主仓完整 checkout,若 .sillyspec/changes/ 被跟踪 → 副本里 change 目录真实存在,
244
+ * validateChangeExists 被骗放行 → 进度/产出写进副本,与主仓 .sillyspec 分裂(副本随工作树清理丢失)。
245
+ *
246
+ * 判据:specBase 路径含 `.sillyspec/.runtime/worktrees/<seg>/.sillyspec`。主仓 specBase=<mainRepo>/.sillyspec
247
+ * 无此段 → null,零误伤。返回 null=通过;对象=漂移(含 mainSpecBase/changeName/message),风格仿
248
+ * validateChangeExists。覆盖 plan/execute/verify/archive(同 Set)——都要求 change 已存在、都会被副本骗过。
249
+ * 平台模式/--spec-dir 已明确指定,由调用方跳过。
250
+ */
251
+ export function detectWorktreeSpecDrift(specBase) {
252
+ if (!specBase) return null
253
+ const seg = resolve(specBase).split(sep)
254
+ // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
255
+ // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
256
+ // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
257
+ if (seg[seg.length - 1] !== '.sillyspec') return null
258
+ for (let i = 0; i + 3 < seg.length; i++) {
259
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
260
+ const changeName = seg[i + 3]
261
+ const mainSpec = seg.slice(0, i + 1).join(sep) // <mainRepo>/.sillyspec
262
+ return {
263
+ changeName,
264
+ mainSpecBase: mainSpec,
265
+ message: `当前 spec 命中 worktree 副本(${specBase})——这是 ${changeName} 隔离工作树内 checkout 出来的 .sillyspec,不是主仓 spec。\n` +
266
+ ` 在此跑 plan/execute/verify/archive 会把进度与产出写到副本,与主仓 .sillyspec 分裂(副本随工作树清理而丢失)。\n` +
267
+ ` 排查:① cd 回 ${dirname(mainSpec)}(主仓根)再跑;② 或 --spec-dir ${mainSpec} 显式指定主仓 spec。`,
268
+ }
269
+ }
270
+ }
271
+ return null
272
+ }
273
+
274
+ /**
275
+ * 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
276
+ *
277
+ * 优先级:平台模式 runtimeRoot > drift 锚点 specDriftAnchor > 本地 specBase/.runtime。
278
+ * specDriftAnchor 仅在此处消费,不参与平台 sentinel(specRoot||runtimeRoot)判定,
279
+ * 避免误跳 triggerSync(:286)/ checkApproval(:313)/ 平台渲染分支(D-02)。
280
+ *
281
+ * 历史:drift 守卫(command.js)命中时只重写本地 specBase/specRoot/specDir/pm(progress 落主仓),
282
+ * 但漏设 platformOpts 字段——下游 15 处 runtimeRoot 解析从 cwd(仍 worktree)重算 → execute-runs /
283
+ * stage-reviews 落 worktree,被 cleanup 整目录删。本函数 + specDriftAnchor 锚点堵此源头。
284
+ *
285
+ * @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
286
+ * @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
287
+ * @returns {string} .runtime 根目录绝对路径
288
+ */
289
+ export function resolveRuntimeRoot(platformOpts, localSpecBase) {
290
+ if (platformOpts?.runtimeRoot) return platformOpts.runtimeRoot
291
+ if (platformOpts?.specDriftAnchor) return join(platformOpts.specDriftAnchor, '.runtime')
292
+ return join(localSpecBase, '.runtime')
293
+ }
294
+
295
+ /**
296
+ * quick 会话目录(.runtime/quick-sessions)的单一解析入口(multi-agent-review Q4)。
297
+ *
298
+ * 历史 bug:stage.js 写 guard 用 `specBase/.runtime/quick-sessions/`,收尾 handleQuickStageCompletion
299
+ * 读/清理用 `resolveRuntimeRoot`(runtimeRoot 被设时返回 runtimeRoot)。两者在平台模式(runtimeRoot 与
300
+ * specBase/.runtime 不同)下分裂——guard 写到 specBase/.runtime、收尾从 runtimeRoot 读不到 → guard=null
301
+ * brownfield 分支整体跳过边界审计 + 兜底重分配 qlId + session 目录清错位置。
302
+ *
303
+ * 收敛到此函数后,写/读/清理三处共用同一解析(基于 resolveRuntimeRoot),任何平台/漂移模式都对齐。
304
+ *
305
+ * @param {object} [platformOpts] - { runtimeRoot?, specDriftAnchor?, ... }(可 null/undefined)
306
+ * @param {string} localSpecBase - 本地 specBase(cwd/.sillyspec),兜底落点
307
+ * @returns {string} quick-sessions 目录绝对路径
308
+ */
309
+ export function resolveQuickSessionsDir(platformOpts, localSpecBase) {
310
+ return join(resolveRuntimeRoot(platformOpts, localSpecBase), 'quick-sessions')
311
+ }
312
+
313
+ /**
314
+ * 平台指针三写(单一数据源,runCommand scan 与 cmdInit 平台模式共用)。
315
+ *
316
+ * 写三处:
317
+ * 1. <specRoot>/.runtime/platform-scan.json — 主持久化文件(后续 run/--done 恢复)
318
+ * 2. <cwd>/.sillyspec-platform.json — 轻量恢复指针(供裸调——不带 --spec-root——找回 specDir)
319
+ * 3. <cwd>/.sillyspec-platform-managed — 平台接管声明(无过期,独立于指针生命周期;
320
+ * 指针被 cleanup/STALE 清理后它仍在,resolvePlatformSpecDir/runCommand 恢复链靠它
321
+ * fail-closed 防静默落本地库。唯一删除路径 = platform disconnect)
322
+ *
323
+ * status 字段语义(POINTER_STATUS):
324
+ * - init 落盘时传 status: 'active'(已接入未 scan,24h STALE 清理不作用于它)
325
+ * - scan 完成时 complete-handlers 升级为 'scan_completed'
326
+ * - 不传 status 则省略(与 runCommand 历史行为一致)
327
+ *
328
+ * 写失败静默(不阻断主流程),返回 true/false 供调用方决定是否提示。
329
+ *
330
+ * @param {string} cwd - 恢复指针落盘目录(= 调用方命令的 cwd,通常为项目根)
331
+ * @param {object} platformOpts - { specRoot, runtimeRoot?, workspaceId?, scanRunId? }
332
+ * @param {object} [extra] - 追加字段(如 { status: 'active' },只进指针/主文件不进声明)
333
+ * @returns {boolean} 是否全部写入成功
334
+ */
335
+ export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
336
+
337
+ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
338
+ if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
339
+ // HUB-05:合并保留既有生命周期字段。scan 完成时指针被写入 status/completedAt/scanStatus
340
+ //(complete-handlers),但下一次任何平台模式 run(含只读 --status)都会重写指针——
341
+ // 恢复链只回填 specRoot 等四字段,不保留 status → 指针永远回 active、isPointerStale
342
+ // 恒 false、`platform pointer --cleanup` 的 STALE 分支不可达。extra 显式传值仍可覆盖。
343
+ let preserved = {}
344
+ try {
345
+ const existing = JSON.parse(readFileSync(join(cwd, '.sillyspec-platform.json'), 'utf8'))
346
+ if (existing && typeof existing === 'object') {
347
+ for (const k of ['status', 'completedAt', 'scanStatus']) {
348
+ if (existing[k] !== undefined && extra[k] === undefined) preserved[k] = existing[k]
349
+ }
350
+ }
351
+ } catch { /* 无既有指针/损坏 → 全新写入 */ }
352
+ const payload = {
353
+ specRoot: platformOpts.specRoot || null,
354
+ runtimeRoot: platformOpts.runtimeRoot || null,
355
+ workspaceId: platformOpts.workspaceId || null,
356
+ scanRunId: platformOpts.scanRunId || null,
357
+ savedAt: new Date().toISOString(),
358
+ ...preserved,
359
+ ...extra,
360
+ }
361
+ // 声明文件字段集独立于指针 payload(D-E@v2 四字段:无 status/savedAt/scanRunId——
362
+ // 声明语义是"项目归平台管"这一持久事实,非 scan 会话状态)
363
+ const declaration = {
364
+ managed: true,
365
+ specRoot: platformOpts.specRoot || null,
366
+ workspaceId: platformOpts.workspaceId || null,
367
+ declaredAt: new Date().toISOString(),
368
+ }
369
+ try {
370
+ mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
371
+ writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
372
+ writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify(payload, null, 2) + '\n')
373
+ writeFileSync(join(cwd, PLATFORM_MANAGED_FILENAME), JSON.stringify(declaration, null, 2) + '\n')
374
+ return true
375
+ } catch {
376
+ return false
377
+ }
378
+ }
379
+
380
+ /**
381
+ * 读平台接管声明(读侧宽容:任何异常情况返回 null = 视同无声明,不抛错)。
382
+ * 供 resolvePlatformSpecDir / runCommand 恢复链 / doctor 诊断三处消费。
383
+ *
384
+ * @param {string} cwd - 项目根(声明查找目录)
385
+ * @returns {{managed:true, specRoot, workspaceId, declaredAt}|null}
386
+ * 文件不存在 / JSON 损坏 / managed 非 true → null
387
+ */
388
+ export function checkPlatformManaged(cwd) {
389
+ const p = join(resolve(cwd), PLATFORM_MANAGED_FILENAME)
390
+ if (!existsSync(p)) return null
391
+ try {
392
+ const decl = JSON.parse(readFileSync(p, 'utf8'))
393
+ if (!decl || decl.managed !== true) return null
394
+ return {
395
+ managed: true,
396
+ specRoot: decl.specRoot || null,
397
+ workspaceId: decl.workspaceId || null,
398
+ declaredAt: decl.declaredAt || null,
399
+ }
400
+ } catch {
401
+ return null
402
+ }
403
+ }
404
+
405
+ /**
406
+ * 统一查找变更目录(与 progress.js 的变更检测逻辑一致)。
407
+ */
408
+ export function resolveChangeDir(cwd, progress, specDir = null) {
409
+ const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
410
+ if (!existsSync(changesDir)) return null
411
+
412
+ // 1. 优先用 currentChange
413
+ if (progress.currentChange) {
414
+ const target = join(changesDir, progress.currentChange)
415
+ if (existsSync(target)) return target
416
+ }
417
+
418
+ // 2. fallback:唯一非 archive 目录
419
+ const entries = readdirSync(changesDir, { withFileTypes: true })
420
+ .filter(e => e.isDirectory() && e.name !== 'archive')
421
+ if (entries.length === 1) return join(changesDir, entries[0].name)
422
+
423
+ return null
424
+ }
425
+
426
+ /**
427
+ * 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
428
+ */
429
+ // sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
430
+ // fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
431
+ // 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
432
+ // HUB-09:熔断超时时必须 abort 底层在飞请求(opts.signal 一路传到 fetch)——否则熔断后请求
433
+ // 仍在跑,平台可能已接受推送而客户端当作超时放弃(spec 树推送无 base_ts 自愈兜底)。
434
+ const SYNC_TOTAL_TIMEOUT_MS = 8_000
435
+
436
+ /**
437
+ * 熔断 race + 确定性取消:超时触发 controller.abort() 并 resolve,在飞 fetch 收到
438
+ * AbortError 后自然失败(各调用方 catch 返回降级值,进程不挂起)。
439
+ * @param {() => Promise} op 接收 { signal } 的异步操作
440
+ * @param {number} timeoutMs 熔断时长(测试可缩短)
441
+ */
442
+ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
443
+ const controller = new AbortController()
444
+ let timer
445
+ try {
446
+ await Promise.race([
447
+ op({ signal: controller.signal }),
448
+ new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(null) }, timeoutMs) }),
449
+ ])
450
+ } finally {
451
+ clearTimeout(timer)
452
+ }
453
+ }
454
+
455
+ // quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
456
+ // initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
457
+ // 局部复制正则保持轻载——改形态时三处同改。
458
+ const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
459
+
460
+ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
461
+ // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
462
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
463
+ try {
464
+ // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
465
+ // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
466
+ // 模块文档的上行通道)以服务器清单为锚,与变更目录无关——降级只推 spec 树。
467
+ if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) {
468
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
469
+ const syncMod = await import('../sync.js')
470
+ await raceWithAbort((sig) => syncMod.syncSpecTreeOnly(changeName, cwd, sig), opts.timeoutMs)
471
+ return
472
+ }
473
+ // 目录缺失的非 quick 变更不再静默 return(archive-final-state-sync):归档把
474
+ // changes/<name>/ 移到 changes/archive/<name>/ 并注销 DB 行后,第 4/5 步收尾的
475
+ // triggerSync 仍会触发——DB 里的终态(steps 完成态 + status='archived')与文件树
476
+ // 移动必须推平台,否则平台进度永远停在移动前的最后一步。sync() 内部对目录缺失
477
+ // 本就是 warn-and-continue(serializeForSync 从 DB 读不依赖目录);真实拼错名时
478
+ // 无 DB 行,sync() 在发出任何 HTTP 前即 return,仅多一行 warn(未连接平台则完全静默)。
479
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
480
+ const syncMod = await import('../sync.js')
481
+ await raceWithAbort((sig) => syncMod.sync(changeName, cwd, sig), opts.timeoutMs)
482
+ } catch (e) {
483
+ // sync.js 不存在或同步失败,静默跳过
484
+ console.warn('⚠️ 同步失败:', e.message)
485
+ }
486
+ }
487
+
488
+ /**
489
+ * 自动 pull 节流(体检 PERF-02):triggerPull/triggerPullActiveChange 在每条 stage 命令
490
+ * 启动时注入,agent 命令往往是秒级连发——每次都发 HTTP GET(daemon 挂/慢时单命令最多
491
+ * 阻塞 8s)。跨进程 marker 节流:10s 内已有自动 pull 则跳过(手动 `platform pull` 不走
492
+ * 此门,语义不变)。错过窗口的平台更新由下一条 >10s 的命令或 push 409 自愈兜底。
493
+ */
494
+ const AUTO_PULL_THROTTLE_MS = 10_000
495
+
496
+ function _autoPullThrottlePath(cwd) {
497
+ return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
498
+ }
499
+
500
+ function _autoPullRecently(cwd) {
501
+ try {
502
+ const t = JSON.parse(readFileSync(_autoPullThrottlePath(cwd), 'utf8'))
503
+ return typeof t.at === 'number' && Date.now() - t.at < AUTO_PULL_THROTTLE_MS
504
+ } catch {
505
+ return false
506
+ }
507
+ }
508
+
509
+ function _stampAutoPull(cwd) {
510
+ try {
511
+ mkdirSync(dirname(_autoPullThrottlePath(cwd)), { recursive: true })
512
+ writeFileSync(_autoPullThrottlePath(cwd), JSON.stringify({ at: Date.now() }) + '\n', 'utf8')
513
+ } catch { /* best-effort:节流失效仅退化为逐次 pull */ }
514
+ }
515
+
516
+ /**
517
+ * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
518
+ * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
519
+ * 注入时机:stage 命令启动(顶层别名 + case 'run',ql-20260818-008 补齐后者)+ 关键决策点
520
+ * (approve 前)+ 手动 platform pull。不在每步 pull(避免高频写入与网络压力),仅低频边界点。
521
+ * 自动注入走 skipIfLocalDirty 保守守卫:本地有未同步改动时 pull 内部跳过 import,
522
+ * 防「本地领先」被平台旧快照覆盖(手动 platform pull 不受影响)。
523
+ * @param {string} cwd
524
+ * @param {string} changeName - 当前活跃变更(多变更时传 null 跳过,避免误拉)
525
+ * @param {object} [platformOpts] - 平台模式 opts(specRoot/runtimeRoot 存在则跳过,走平台自有链路)
526
+ */
527
+ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {}) {
528
+ // 平台模式(SillyHub)走自己的链路,跳过
529
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
530
+ try {
531
+ const syncMod = await import('../sync.js')
532
+ const sm = new syncMod.SyncManager(cwd)
533
+ // 未连接平台静默跳过(本地独立用户合法状态,不噪音)——节流 stamp 必须在此之后:
534
+ // 否则未连接的项目每次 run 都写 .sillyspec/.runtime/ 标记(凭空造目录)
535
+ if (!sm._getPlatform()) return
536
+ if (_autoPullRecently(cwd)) return
537
+ _stampAutoPull(cwd)
538
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs)
539
+ } catch (e) {
540
+ // pull 失败静默跳过(Best Effort,失败不影响正确性)
541
+ console.warn('⚠️ 拉取失败:', e.message)
542
+ }
543
+ }
544
+
545
+ /**
546
+ * triggerPull 的便捷封装:未显式传 changeName 时自动推导单活跃变更(task-10)。
547
+ * 多活跃 / 无活跃变更时跳过(无法确定目标,避免误拉);其余语义同 triggerPull。
548
+ * 供 index.js 在 stage 命令(run/--done/archive)与 approve 决策点注入一行调用。
549
+ */
550
+ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
551
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
552
+ // 先检查是否连接平台:未连接直接 return,避免 _ensureDB 在无 local.yaml 的 cwd 创建空 DB 污染
553
+ let sm = null
554
+ try {
555
+ const syncMod = await import('../sync.js')
556
+ sm = new syncMod.SyncManager(cwd)
557
+ if (!sm._getPlatform()) return
558
+ } catch {
559
+ return
560
+ }
561
+ // PERF-02 节流(连接确认后才 stamp——未连接项目不得凭空写 .sillyspec/.runtime/):
562
+ // 与 triggerPull 共用同一 marker(本函数与它在同一命令启动链上互斥出现)
563
+ if (_autoPullRecently(cwd)) return
564
+ _stampAutoPull(cwd)
565
+ let cn = null
566
+ try {
567
+ const { ProgressManager } = await import('../progress.js')
568
+ const pm = new ProgressManager({})
569
+ const changes = pm.listChanges(cwd)
570
+ if (changes.length === 1) cn = changes[0]
571
+ } catch {
572
+ // progress 不可达则跳过(Best Effort)
573
+ }
574
+ if (!cn) return
575
+ // 已确认连接 + 单活跃变更,调 pull(复用 8s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
576
+ // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
577
+ try {
578
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }))
579
+ } catch (e) {
580
+ console.warn('⚠️ 拉取失败:', e.message)
581
+ }
582
+ }
583
+
584
+ /**
585
+ * 审批状态 unknown(体检 HUB-07)的统一处置:醒目多行警告 + 落 .runtime/approval-unknown.log 留痕。
586
+ *
587
+ * 语义边界:unknown = 已连接平台但 404/断网/超时/非 JSON——**不是审批通过**。放行是
588
+ * fail-open 的既定取舍(网络故障不应硬阻断每一条 execute),但静默放行会让团队审批
589
+ * 形同虚设且无迹可查;至少要(a)让操作者当场看见(b)留下可审计记录。
590
+ * 留痕选 .runtime 日志而非 QUICKLOG:QUICKLOG 是 quick 会话的任务记录,run 流程的
591
+ * 审批事件写进去会污染 quick 语义与边界审计。
592
+ * @param {string} cwd
593
+ * @param {string} changeName
594
+ * @param {string} reason checkApproval 返回的 reason
595
+ */
596
+ export function warnApprovalUnknown(cwd, changeName, reason) {
597
+ console.warn('')
598
+ console.warn('⚠️⚠️⚠️══════════════════════════════════════════════')
599
+ console.warn(`⚠️ 审批状态未知:变更「${changeName}」无法核实审批(${reason || '请求失败'})`)
600
+ console.warn('⚠️ 本次 execute 按 fail-open 放行——这不是审批通过;平台恢复后请到平台复核审批记录')
601
+ console.warn('⚠️ 排查:平台服务是否可达(sillyspec platform status)/ 网络代理;确认无需审批可 --skip-approval 显式跳过')
602
+ console.warn('⚠️ 本次放行已记录:.sillyspec/.runtime/approval-unknown.log')
603
+ console.warn('⚠️⚠️══════════════════════════════════════════════')
604
+ try {
605
+ const logDir = join(cwd, '.sillyspec', '.runtime')
606
+ mkdirSync(logDir, { recursive: true })
607
+ appendFileSync(join(logDir, 'approval-unknown.log'),
608
+ `${new Date().toISOString()} | change=${changeName} | reason=${reason || 'unknown'}\n`, 'utf8')
609
+ } catch { /* 留痕失败不阻断流程(警告横幅已输出) */ }
610
+ }
611
+
612
+ /**
613
+ * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
614
+ * 平台模式走自己的链路,跳过;否则 await import sync.js。
615
+ * @returns {{ status: string, reason?: string } | null}
616
+ */export async function checkApproval(cwd, changeName, platformOpts = {}) {
617
+ // 平台模式不需要 CLI 内置审批检查
618
+ if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
619
+ try {
620
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
621
+ const syncMod = await import('../sync.js')
622
+ return await syncMod.checkApproval(changeName, cwd)
623
+ } catch (e) {
624
+ return null
625
+ }
626
+ }
627
+
628
+ /**
629
+ * 解析 git status --porcelain 单行 → 文件路径(去引号/处理 rename/归一化)。
630
+ * 注意:line.slice(3) —— porcelain 行前 2 字符是状态码 + 1 空格,路径从 index 3 开始。
631
+ */
632
+ export function parsePorcelainPath(line) {
633
+ if (!line) return ''
634
+ let path = line.slice(3).trim()
635
+ if (path.length >= 2 && path.startsWith('"') && path.endsWith('"')) {
636
+ path = path.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
637
+ }
638
+ const arrow = path.indexOf(' -> ')
639
+ if (arrow !== -1) path = path.slice(arrow + 4)
640
+ return path.replace(/\\/g, '/')
641
+ }
642
+
643
+ /**
644
+ * quick 自身写入的 .sillyspec/ 元数据判定(auditQuickCompletion 审计 与 QUICKLOG「文件:」行回填
645
+ * 单源)。回填文件行时复用:review.changedFiles 含这些元数据时过滤掉,只留真实业务文件。
646
+ *
647
+ * 归类口径:
648
+ * - quicklog/.runtime/modules/_module-map/knowledge-uncategorized 等 quick 自身产物 → 元数据
649
+ * - .sillyspec/changes/:quick 自己没有该目录,其下文件要么是关联变更(reverse-sync),
650
+ * 要么是并发他者会话的工作。非关联变更目录整体视为元数据放行;关联变更文件不算(属本 quick 真实改动)
651
+ *
652
+ * 「并发工作 vs 偷建变更」的意图软判定留给 sillyhub,确定性校验只做路径归类。
653
+ *
654
+ * @param {string} p 文件路径(容错 \\ / 混用)
655
+ * @param {string[]} [linkedChanges] 关联变更名列表(影响 changes/ 归类)
656
+ * @returns {boolean} true=quick 元数据,应从业务文件列表过滤掉
657
+ */
658
+ export function isQuickMetadata(p, linkedChanges = []) {
659
+ const normalizeGitPath = (x) => String(x).replace(/\\/g, '/')
660
+ const file = normalizeGitPath(p)
661
+ if (file.startsWith('.sillyspec/quicklog/')
662
+ || file.startsWith('.sillyspec/.runtime/')
663
+ || file === '.sillyspec/knowledge/uncategorized.md'
664
+ || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
665
+ || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
666
+ if (file.startsWith('.sillyspec/changes/')) {
667
+ const linkedChangeNames = new Set((Array.isArray(linkedChanges) ? linkedChanges : []).map(c => normalizeGitPath(c)))
668
+ const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
669
+ // bare 折叠 token(git 把全新未跟踪 changes/ 折叠成 `changes/`)或具体但非关联的变更 → 放行
670
+ if (!m || !linkedChangeNames.has(m[1])) return true
671
+ }
672
+ return false
673
+ }
674
+
675
+ /**
676
+ * quick 完成审计:对比 baseline 与实际变更。
677
+ * @returns {{ status: 'safe'|'warning'|'blocked', reasons: string[], changedFiles: string[], newFiles: string[], deletedFiles: string[], baselineHit: string[], stagedTotal: number, attributedFiles: string[], undeclaredFiles: string[] }}
678
+ */
679
+ /**
680
+ * D-8 O-1 模块归属(2026-08-15 docs-signals-o12):quick 欠账 hint 从"改了 N 文件"升级为
681
+ * "涉及哪些模块卡"。只做归属(matchFilesToModules 纯函数零 git),不算 behind——对账是
682
+ * execute Wave [docs-debt] 的职责(design D-001 轻量边界)。map 缺失/解析空/异常 → 空数组
683
+ * 降级(hint 仍打,modules 省略),不报错不阻断。
684
+ * 动态 import docs-debt.js/modules.js:prompt.js 已静态 import shared(本模块),静态互引成 ESM 环。
685
+ */
686
+ async function matchQuickModules(srcChanged, specBase, projectName) {
687
+ if (!specBase || !projectName || srcChanged.length === 0) return []
688
+ try {
689
+ const { join } = await import('node:path')
690
+ const { existsSync: ex, readFileSync: rf } = await import('node:fs')
691
+ const mapPath = join(specBase, 'docs', projectName, 'modules', '_module-map.yaml')
692
+ if (!ex(mapPath)) return []
693
+ const { parseModuleMapSimple } = await import('../modules.js')
694
+ const idx = parseModuleMapSimple(rf(mapPath, 'utf8'))
695
+ if (!idx || Object.keys(idx).length === 0) return []
696
+ const { matchFilesToModules } = await import('../docs-debt.js')
697
+ const cardsDir = join(specBase, 'docs', projectName, 'modules')
698
+ const { byModule } = matchFilesToModules(srcChanged, idx, { cardsDir })
699
+ return [...byModule.entries()].map(([id, e]) => ({ id, doc: e.doc }))
700
+ } catch {
701
+ return []
702
+ }
703
+ }
704
+
705
+ // ── task-03(2026-08-16-state-split-fixes): 活文档漂移提示辅助 ──
706
+ // 背景:platform-interface-map.md 等活文档持续维护 src 主入口的 file:line 映射;并行会话改
707
+ // src/ 后引用行号静默失效,docs gate 只能事后拦到别人的流程上。审计(auditQuickCompletion)
708
+ // 当场检测「本次改动的 src 文件是否被活文档引用」并提示(advisory 不阻断,docs gate 语义不变)。
709
+
710
+ /** 缺省活文档:src 主入口 ↔ 文档接口映射(file:line 锚点密度最高的持续维护文档)。 */
711
+ export const DEFAULT_LIVING_DOC = 'docs/sillyspec/platform-interface-map.md'
712
+
713
+ /**
714
+ * 解析活文档集合:local.yaml `docs-check.living-docs` 列表【只追加不覆盖】缺省集合。
715
+ * 与 docs-check.paths 的覆盖语义刻意不同——paths 是扫描范围(配了即整体替换缺省),
716
+ * living-docs 是监控点登记(配了是加哨兵,不该把缺省监控点挤掉)。读配置走本模块现成的
717
+ * readLocalYamlRaw + js-yaml 动态 import(readDocsCheckConfig 不回 living-docs 键;动态
718
+ * import 与上方 docs-check 的隔离约定一致);坏 YAML / 非数组 / 读失败 → 仅缺省。
719
+ * @param {string} cwd 仓库根(local.yaml 在 <cwd>/.sillyspec/local.yaml)
720
+ * @returns {Promise<string[]>} 去重后的活文档相对路径(仓库根相对 POSIX)
721
+ */
722
+ export async function resolveLivingDocs(cwd) {
723
+ let configured = []
724
+ const raw = readLocalYamlRaw(cwd)
725
+ if (raw) {
726
+ try {
727
+ const mod = await import('js-yaml')
728
+ const yamlLoad = mod.load || mod.default?.load
729
+ const doc = yamlLoad(raw)
730
+ const dc = doc && typeof doc === 'object' ? doc['docs-check'] : null
731
+ const ld = dc && typeof dc === 'object' ? dc['living-docs'] : null
732
+ if (Array.isArray(ld)) configured = ld.filter((s) => typeof s === 'string' && s.trim() !== '')
733
+ } catch { /* 坏 YAML → 忽略配置,仅缺省 */ }
734
+ }
735
+ return [...new Set([DEFAULT_LIVING_DOC, ...configured])]
736
+ }
737
+
738
+ /**
739
+ * 纯函数:活文档引用的源码文件与本次改动的 src/ 文件求交。
740
+ * 匹配口径对齐 docs-check resolveCandidates 的三形态(纯路径比对,不查盘):
741
+ * ①仓库根相对:changed === ref(如 src/a.js)
742
+ * ②src 内部相对:changed === 'src/' + ref(如 run/b.js → src/run/b.js)
743
+ * ③裸名/中缀后缀:changed.endsWith('/' + ref)(如 b.js;前导斜杠防 aa.js 被引用 a.js 误吃)
744
+ * changedFiles 口径 = git status porcelain 归一后的仓库根相对 POSIX 路径(parsePorcelainPath 产物)。
745
+ * 同名多文件时后缀形态可能多报——与 docs-check 多候选宽容同向,advisory 仅提示可接受。
746
+ * @param {string[]} srcChangedFiles changedFiles 中 src/ 前缀的文件
747
+ * @param {Array<{file: string}>} refs collectDocRefs(md) 产物(只用 .file)
748
+ * @returns {string[]} 命中的 srcChangedFiles 子集(去重保序)
749
+ */
750
+ export function matchLivingDocRefs(srcChangedFiles, refs) {
751
+ const hit = new Set()
752
+ for (const c of srcChangedFiles) {
753
+ if (refs.some((r) => r && typeof r.file === 'string' &&
754
+ (c === r.file || c === 'src/' + r.file || c.endsWith('/' + r.file)))) {
755
+ hit.add(c)
756
+ }
757
+ }
758
+ return [...hit]
759
+ }
760
+
761
+ /**
762
+ * 纯函数:docs check invalid 条目与本次改动 src 文件求交(2026-08-18 漂移提示精度对齐)。
763
+ * runDocsCheck 的 invalid 只带 ref 全串(`path:line(-end)`)——剥尾部行号段还原引用文件,
764
+ * 再按 matchLivingDocRefs 同款三形态匹配(仓库根相对 / src 内部相对 / 裸名后缀)。
765
+ * ref 空串(文档不存在条目)不匹配。
766
+ * @param {Array<{ref?: string, doc?: string, docLine?: number, reason?: string}>} invalidRefs runDocsCheck().invalid
767
+ * @param {string[]} srcChangedFiles changedFiles 中 src/ 前缀的文件
768
+ * @returns {Array<{changed: string, doc: string, docLine: number, ref: string, reason: string}>} 真失效且指向本次改动文件的引用
769
+ */
770
+ export function matchInvalidRefsToChanged(invalidRefs, srcChangedFiles) {
771
+ const out = []
772
+ for (const x of Array.isArray(invalidRefs) ? invalidRefs : []) {
773
+ if (!x || typeof x.ref !== 'string' || x.ref === '') continue
774
+ const refFile = x.ref.replace(/:\d+(-\d+)?$/, '')
775
+ const changed = srcChangedFiles.find((c) => c === refFile || c === 'src/' + refFile || c.endsWith('/' + refFile))
776
+ if (changed) out.push({ changed, doc: x.doc || '', docLine: x.docLine || 0, ref: x.ref, reason: x.reason || '' })
777
+ }
778
+ return out
779
+ }
780
+
781
+ export async function auditQuickCompletion(cwd, guard, options = {}) {
782
+ const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false, allowDelete = false, specBase = null, projectName = null } = guard
783
+ const { isConfirm } = options
784
+ // stagedTotal:当前所有非 quick 元数据的未提交条目(含前序 baseline 残留)。
785
+ // 与 changedFiles(扣 baseline 后的本轮新增)区分,供审计文案同时展示「本轮新增 vs 累计暂存」,
786
+ // 避免叠加 quick 会话时把前序会话未提交文件误读为「本会话只动了 N 个」。
787
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [], stagedTotal: 0, attributedFiles: [], undeclaredFiles: [] }
788
+
789
+ try {
790
+ // safeGit 带 -c safe.directory,避免 linked worktree/容器异 uid/挂载点下裸 `git status` 抛错被
791
+ // 外层 catch 吞成 warning(multi-agent-review Q3)。safeGit 返回 {value,error} 不抛;若仍有 error
792
+ // 说明真读不到 git 状态,保守阻断(审计无锚点不能放行),不静默降级。
793
+ // trim:false 必传:porcelain 首行前导空格是状态码一部分,trim 会削掉致 parsePorcelainPath 丢首字符。
794
+ // timeout 15000 + retryOnTimeout:机器忙时 git 子进程启动慢,默认 5s 偏紧易瞬时超时(ETIMEDOUT),
795
+ // 审计锚点失败即 blocked 中断 quick。加大到 15s 并对 ETIMEDOUT 重试一次(重试 30s),消化绝大多数
796
+ // 瞬时抖动,免去用户手工重跑(实测反馈:审计 git 偶发 ETIMEDOUT,重试即过)。
797
+ const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false, timeout: 15000, retryOnTimeout: true })
798
+ if (statusResult.error) {
799
+ result.reasons.push(`审计失败(git status): ${statusResult.error}`)
800
+ result.status = 'blocked'
801
+ return result
802
+ }
803
+ const gitStatus = statusResult.value || ''
804
+ // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
805
+ const currentEntries = gitStatus.split('\n').filter(Boolean)
806
+
807
+ const normalizeGitPath = (p) => p.replace(/\\/g, '/')
808
+ // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
809
+ // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
810
+ // → 永远 blocked(--force-baseline 也救不回来,因为 status 判定看 baselineHit 数组)。
811
+ // 前缀匹配:baseline 录入时未跟踪目录会被 git 折叠成 `dir/`(带尾斜杠)token,审计时若该
812
+ // 目录下文件被跟踪则展开成文件级 `dir/file`——精确匹配对不上,故尾斜杠 token 按目录前缀放行。
813
+ const normBaseline = (baselineFiles || []).map(f => normalizeGitPath(f))
814
+ const baselineExact = new Set(normBaseline.filter(f => !f.endsWith('/')))
815
+ const baselineDirs = normBaseline.filter(f => f.endsWith('/'))
816
+ const isBaselineFile = (p) => {
817
+ const f = normalizeGitPath(p)
818
+ return baselineExact.has(f) || baselineDirs.some(d => f.startsWith(d))
819
+ }
820
+ const DANGEROUS_PATTERNS = [
821
+ 'package.json',
822
+ 'package-lock.json',
823
+ 'yarn.lock',
824
+ 'pnpm-lock.yaml',
825
+ '.eslintrc',
826
+ 'tsconfig.json',
827
+ // SillySpec 核心流程代码。W6 重构后 src/run.js(23 行 barrel)/ src/progress.js(facade)
828
+ // 把真正逻辑下沉到 src/run/、src/progress/ 子目录——旧的精确文件名匹配
829
+ // (file === 'src/run.js')命中不到 src/run/command.js(. 与 / 不可混同),致危险文件门
830
+ // 静默失效。改用「目录前缀(带尾斜杠,startsWith 不会误伤 src/runtime-* 等同名前缀)+
831
+ // barrel/facade 本体精确名」双重覆盖。重构 src/ 模块时须同步本清单(multi-agent-review Q5)。
832
+ 'src/run.js',
833
+ 'src/run/',
834
+ 'src/progress.js',
835
+ 'src/progress/',
836
+ 'src/db.js',
837
+ 'src/stage-contract.js',
838
+ 'src/stage-contract-spec.js',
839
+ 'src/worktree.js',
840
+ 'src/worktree-apply.js',
841
+ 'src/hooks/',
842
+ ]
843
+
844
+ for (const entry of currentEntries) {
845
+ // porcelain 两字符状态码(X=暂存区 Y=工作区)不能先 trim:' D'.trim()==='D' 使第二分支恒假、
846
+ // 'DD'/'AD' 等 trim 后不等于 'D' 而漏检——--allow-delete 门对此类删除静默放行(体检 BUG-04)。
847
+ // 用原始两字符判定任一侧为 D 即删除。
848
+ const rawStatus = entry.slice(0, 2)
849
+ const status = rawStatus.trim()
850
+ const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
851
+ if (!file) continue
852
+
853
+ // 累计暂存计数:所有非 quick 元数据的未提交条目(含下方将被 baseline 跳过的前序残留)。
854
+ // 放在 isBaselineFile 跳过之前,确保 baseline 内文件也计入「累计暂存」。
855
+ if (!isQuickMetadata(file, guard.linkedChanges)) result.stagedTotal++
856
+
857
+ // 预存脏文件:step1 baseline 已记录,非本次 quick 产生,跳过审计(含折叠目录前缀匹配)
858
+ if (isBaselineFile(file)) continue
859
+
860
+ result.changedFiles.push(file)
861
+ if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
862
+ if (status === '??') result.newFiles.push(file)
863
+
864
+ // 检查是否命中 baseline protected files
865
+ if (baselineFiles.includes(file)) {
866
+ result.baselineHit.push(file)
867
+ }
868
+
869
+ // 检查危险文件(除非 force-baseline)
870
+ if (file.startsWith('.sillyspec/') && !isQuickMetadata(file, guard.linkedChanges) && !forceBaseline) {
871
+ result.reasons.push(`危险文件变更: ${file}`)
872
+ }
873
+
874
+ if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
875
+ result.reasons.push(`危险文件变更: ${file}`)
876
+ }
877
+ }
878
+
879
+ // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
880
+ if (!allowDelete) {
881
+ for (const f of result.deletedFiles) {
882
+ result.reasons.push(`删除文件: ${f}`)
883
+ }
884
+ }
885
+
886
+ // 检查 baseline hit(除非 force-baseline)
887
+ if (!forceBaseline) {
888
+ for (const f of result.baselineHit) {
889
+ result.reasons.push(`覆盖 baseline 文件: ${f}`)
890
+ }
891
+ }
892
+
893
+ // 检查 new files(除非 allow-new)
894
+ if (!allowNew) {
895
+ for (const f of result.newFiles) {
896
+ if (!isQuickMetadata(f, guard.linkedChanges)) {
897
+ result.reasons.push(`新增文件(需 --allow-new): ${f}`)
898
+ }
899
+ }
900
+ }
901
+
902
+ // 检查 allowedFiles 范围
903
+ if (allowedFiles.length > 0) {
904
+ for (const f of result.changedFiles) {
905
+ if (!allowedFiles.includes(f) && !isQuickMetadata(f, guard.linkedChanges)) {
906
+ result.reasons.push(`超出 allowedFiles: ${f}`)
907
+ }
908
+ }
909
+ }
910
+
911
+ // 判定结果(force-baseline 降级 baselineHit → 非 blocked;allow-new 降级新增文件 → 非 warning;
912
+ // allow-delete 降级删除文件 → 非 blocked。reasons 文案本就受 flag 控制,但原判定直接看数组长度,
913
+ // 致 flag 对 status 失效。)
914
+ if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
915
+ result.status = 'blocked'
916
+ } else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
917
+ result.status = 'warning'
918
+ }
919
+
920
+ // quicklog 存在性检查
921
+ try {
922
+ const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
923
+ if (existsSync(quicklogDir)) {
924
+ const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
925
+ if (qlFiles.length === 0) {
926
+ result.reasons.push('quicklog 目录为空(无任务记录)')
927
+ if (result.status === 'safe') result.status = 'warning'
928
+ }
929
+ } else {
930
+ result.reasons.push('quicklog 目录不存在(agent 未创建任务记录)')
931
+ if (result.status === 'safe') result.status = 'warning'
932
+ }
933
+ } catch {}
934
+
935
+ // D-8 文档欠账显性化(advisory):本次改动含源码文件但不含任何文档文件 → 打标记 warn。
936
+ // quick 是低摩擦通道(不强制文档同步),但欠账要显性化——72% 流量 86% 不动文档是系统性欠账
937
+ // 主通道(doc-consistency-debt D-8)。不阻断不改 status,只 warn + 记 reasons 供 QUICKLOG 追溯。
938
+ const isDocFile = (f) =>
939
+ f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') ||
940
+ f.startsWith('docs/') || f.startsWith('.sillyspec/docs/')
941
+ const srcChanged = result.changedFiles.filter(f => !isDocFile(f) && !isQuickMetadata(f, guard.linkedChanges))
942
+ const docChanged = result.changedFiles.filter(isDocFile)
943
+ if (srcChanged.length > 0 && docChanged.length === 0) {
944
+ result.reasons.push(`本次未同步模块文档(${srcChanged.length} 个源码文件改动,无文档文件)`)
945
+ result.docSyncHint = { touchedSource: srcChanged.length, docFiles: [], modules: await matchQuickModules(srcChanged, specBase, projectName) }
946
+ } else if (srcChanged.length > 0 && docChanged.length > 0) {
947
+ result.docSyncHint = { touchedSource: srcChanged.length, docFiles: docChanged }
948
+ }
949
+
950
+ // docs check advisory(doc-consistency-debt D-6 后续):本次改动的 .md 文档跑 file:line
951
+ // 引用校验,失效即 warn + reasons——检测能力落地后欠账只涨不跌的最后一公里。只查本次
952
+ // changedFiles 的文档(归因到本次改动),不扫全仓历史欠账(那是存量问题,quick 不背锅)。
953
+ // 排除删除文件(troubleshooting #9):删除的 .md 不在盘,runDocsCheck 只会报「文档不存在」
954
+ // 假失效——删除语义归 --allow-delete 管,无引用校验可言(并行会话的删除尤其不该归属本会话)。
955
+ // docs-check 是 IO 面(walkGlob/existsSync),动态 import 隔离;任何异常 fail-open 只跳过。
956
+ const deletedSet = new Set(result.deletedFiles)
957
+ const mdChanged = result.changedFiles.filter((f) => f.endsWith('.md') && !deletedSet.has(f) && !isQuickMetadata(f, guard.linkedChanges))
958
+ if (mdChanged.length > 0) {
959
+ try {
960
+ const { runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
961
+ // HUB-06:补传 crossRepoRoots(与 docs gate / index.js docs check 同口径)——
962
+ // 否则 repo:// 跨仓引用在这条链路恒跳过;无 local.yaml 配置时 undefined = 不启用
963
+ let quickCfg = {}
964
+ try { quickCfg = readDocsCheckConfig(cwd) || {} } catch { quickCfg = {} }
965
+ const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged, crossRepoRoots: quickCfg.crossRepoRoots })
966
+ if (!dc.ok) {
967
+ result.reasons.push(`本次改动文档含 ${dc.invalid.length} 处失效 file:line 引用(sillyspec docs check 可复现)`)
968
+ result.docsCheckHint = { invalid: dc.invalid.length, total: dc.total }
969
+ if (result.status === 'safe') result.status = 'warning'
970
+ }
971
+ } catch { /* docs-check 不可用(老版本包等)→ 静默跳过,不阻断 quick */ }
972
+ }
973
+
974
+ // task-03: 活文档漂移提示(advisory,不阻断不改 status,docs gate 语义不变)。
975
+ // 方向与上方 docsCheckHint 相反:那是「本次改的文档引用失效吗」,这是「本次改的 src 被活文档
976
+ // 引用吗」——并行会话改 src/ 主入口后 platform-interface-map.md 的 file:line 行号静默失效,
977
+ // gate 事后拦到别人流程上。fail-open:活文档缺失/读失败/校验机器不可用 → 静默跳过(不误报不阻断)。
978
+ // 2026-08-18 精度对齐:原路径级「被引用即提示」在行号锚未真断时误报(实测 advisory 报漂移、
979
+ // docs check 417/417 全过)。升级为复用 runDocsCheck 分层真校验(存在 + 行界 + 关键词窗口),
980
+ // 只报「真失效且指向本次改动文件」的引用;全过 → 零输出(与 docs check 结论同源)。
981
+ // matchLivingDocRefs 留作廉价预过滤:该文档不引用任何本次改动文件时跳过整档真校验(省 IO)。
982
+ const srcChangedFiles = result.changedFiles.filter((f) => f.startsWith('src/') && !deletedSet.has(f))
983
+ if (srcChangedFiles.length > 0) {
984
+ try {
985
+ const { collectDocRefs, runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
986
+ // HUB-06:活文档(platform-interface-map.md 等最可能写 repo:// 引用的文档)同样补传
987
+ // crossRepoRoots;配置读一次提到循环外(per-cwd 恒同)
988
+ let livingCfg = {}
989
+ try { livingCfg = readDocsCheckConfig(cwd) || {} } catch { livingCfg = {} }
990
+ const livingDocs = await resolveLivingDocs(cwd)
991
+ const files = []
992
+ const docs = []
993
+ const invalidAll = []
994
+ for (const docRel of livingDocs) {
995
+ let md
996
+ try { md = readFileSync(join(cwd, docRel), 'utf8') } catch { continue } // 活文档缺失/读失败 → 跳过
997
+ const hit = matchLivingDocRefs(srcChangedFiles, collectDocRefs(md))
998
+ if (hit.length === 0) continue
999
+ files.push(...hit)
1000
+ docs.push(docRel)
1001
+ const r = runDocsCheck({ projectRoot: cwd, docs: [docRel], keywordAssert: true, crossRepoRoots: livingCfg.crossRepoRoots })
1002
+ for (const x of r.invalid) invalidAll.push({ ...x, doc: docRel })
1003
+ }
1004
+ const invalid = matchInvalidRefsToChanged(invalidAll, srcChangedFiles)
1005
+ if (invalid.length > 0) {
1006
+ const uniqFiles = [...new Set(files)]
1007
+ result.docsCheckHint = { ...(result.docsCheckHint || {}), livingDocDrift: { files: uniqFiles, docs, total: srcChangedFiles.length, invalid } }
1008
+ result.reasons.push(`活文档引用真失效: ${invalid.length} 处指向本次改动文件的引用校验失败(${docs.join(', ')})`)
1009
+ }
1010
+ } catch { /* 校验机器不可用 → 静默跳过 */ }
1011
+ }
1012
+
1013
+ // task-02: 同文件并发检测——allowedFile 在 baseline(他者改过)且当前 hash ≠ step1 hash(我也改了)
1014
+ // → commit 整文件 pathspec 会夹带他者 hunk,warn 给分离指引(advisory,不阻断,D-002)
1015
+ // sameFileHits 提升到本层作用域:下方归属切分要把「baseline 声明文件且我也改了」并入
1016
+ // attributedFiles(这类文件先被 isBaselineFile 跳过不进 changedFiles,但确属本会话产物)。
1017
+ const sameFileHits = []
1018
+ if (allowedFiles.length > 0 && guard.allowedFilesHash) {
1019
+ for (const f of allowedFiles) {
1020
+ if (isBaselineFile(f) && guard.allowedFilesHash[f] !== undefined) {
1021
+ try {
1022
+ const cur = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex')
1023
+ if (cur !== guard.allowedFilesHash[f]) sameFileHits.push(f)
1024
+ } catch {} // 文件读失败(删除等)跳过
1025
+ }
1026
+ }
1027
+ if (sameFileHits.length > 0) {
1028
+ result.reasons.push(`同文件并发: ${sameFileHits.length} 个 allowedFile 含他者+你的改动(${sameFileHits.join(', ')})`)
1029
+ console.warn(`\n⚠️ 同文件并发(${sameFileHits.length} 个 allowedFile 含他者改动+你的改动,commit 整文件会夹带他者 hunk):`)
1030
+ for (const f of sameFileHits) {
1031
+ console.warn(` - ${f}`)
1032
+ console.warn(` 分离:git add -p ${f}(交互选你的 hunk)或 git diff ${f} > mine.patch(编辑留你的)+ git apply --cached mine.patch`)
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ // 归属切分(2026-08-18 误归属修复,ql-20260818-003 实证):窗口 diff(changedFiles)在多 agent
1038
+ // 并发仓库分不清「本会话改」与「他者窗口内改」,他者文件会污染 QUICKLOG「文件:」行。声明即归属:
1039
+ // allowedFiles 非空时 attributedFiles = 窗口∩声明 ∪ sameFileHits(同文件并发 = 我也改了),
1040
+ // undeclaredFiles = 窗口−声明(他者或漏声明,供「审计:」行落盘追溯——不静默丢,防漏声明时
1041
+ // 真实改动被挤走无痕)。未声明会话无归属信息不猜,维持全量口径(attributed = changed)。
1042
+ const declaredNorm = new Set(allowedFiles.map(f => normalizeGitPath(f)))
1043
+ if (declaredNorm.size > 0) {
1044
+ const own = result.changedFiles.filter(f => declaredNorm.has(normalizeGitPath(f)))
1045
+ for (const f of sameFileHits) if (!own.includes(f)) own.push(f)
1046
+ result.attributedFiles = own
1047
+ result.undeclaredFiles = result.changedFiles.filter(f => !declaredNorm.has(normalizeGitPath(f)))
1048
+ } else {
1049
+ result.attributedFiles = result.changedFiles
1050
+ result.undeclaredFiles = []
1051
+ }
1052
+
1053
+ // --confirm 模式:展示 diff 并等待确认
1054
+ if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
1055
+ console.log(`\n📋 quick 变更概览:`)
1056
+ console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
1057
+ if (result.changedFiles.length > 0) {
1058
+ console.log(`\n 变更文件:`)
1059
+ for (const f of result.changedFiles) {
1060
+ const isBaseline = baselineFiles.includes(f)
1061
+ const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
1062
+ const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
1063
+ console.log(` ${marker} ${f}`)
1064
+ }
1065
+ }
1066
+ console.log(`\n 状态: ${result.status.toUpperCase()}`)
1067
+ if (result.reasons.length > 0) {
1068
+ console.log(` 原因:`)
1069
+ for (const r of result.reasons) {
1070
+ console.log(` - ${r}`)
1071
+ }
1072
+ }
1073
+ if (result.deletedFiles.length > 0) {
1074
+ if (guard.allowDelete) {
1075
+ console.log(`\n ℹ️ 已确认删除文件 ${result.deletedFiles.length} 个(--allow-delete 显式解锁):${result.deletedFiles.join(', ')}`)
1076
+ } else {
1077
+ console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——删除是破坏性操作默认 fail-closed,确认删除请带 --allow-delete 显式解锁:`)
1078
+ console.log(` sillyspec run quick --done --allow-delete --change <id> --output "..."`)
1079
+ console.log(` (--force-baseline / --allow-new 不能解锁删除;不确认请 git restore 撤回删除后再 --done,或走完整流程 execute 由 review 把关)`)
1080
+ }
1081
+ } else {
1082
+ console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
1083
+ console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
1084
+ console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
1085
+ console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
1086
+ }
1087
+ }
1088
+ } catch (e) {
1089
+ result.reasons.push(`审计失败: ${e.message}`)
1090
+ result.status = 'warning'
1091
+ }
1092
+
1093
+ return result
1094
+ }
1095
+
1096
+ // ── Step 处理辅助(W6 Step7a 从 run.js 搬入:getStageSteps/formatWaitOptions 被 run.js + complete.js 共用)──
1097
+ /**
1098
+ * 格式化 waitOptions 为人类可读字符串
1099
+ */
1100
+ export function formatWaitOptions(raw) {
1101
+ if (!raw) return null
1102
+ try {
1103
+ const parsed = JSON.parse(raw)
1104
+ if (Array.isArray(parsed)) return parsed.join(', ')
1105
+ return raw
1106
+ } catch {
1107
+ return raw
1108
+ }
1109
+ }
1110
+
1111
+
1112
+ /**
1113
+ * 进程级 MultiRepoContext 缓存(D-013 G2:execute 启动构造一次,贯穿 execute/apply/verify 不重建)。
1114
+ *
1115
+ * key = `${cwd} ${changeName}`(cwd 隔离防多仓并发串扰;changeName 隔离防同进程多 change 切换串扰)。
1116
+ * 跨 CLI 命令(execute vs apply vs verify 各是独立进程)各自构造——design §7.2 G2 明确 apply/verify
1117
+ * 「复用 execute 实例,**或**从 review.json.repo + local.yaml repos 反推」,进程级缓存指同一 CLI 命令内复用。
1118
+ *
1119
+ * 失效语义:ctx 是纯内存对象(task-01 不持久化),进程结束自动释放。无需手动清理。
1120
+ * 缓存命中场景:execute 单次 --done 内 getStageSteps(派发)+ completeStageGates(gate)+ 阶段完成
1121
+ * 收尾多次取同一 ctx;apply/verify 同理。fail-closed 抛错不缓存(下次重试重新构造)。
1122
+ */
1123
+ const _multiRepoCtxCache = new Map()
1124
+
1125
+ /**
1126
+ * 读 local.yaml 原始文本(best-effort,不存在/读失败返 '')。
1127
+ * 与 sync.js readLocalYamlRaw 同风格,但本模块不引 sync.js(避免循环依赖 + sync.js 非 export)。
1128
+ * local.yaml 路径 = `<cwd>/.sillyspec/local.yaml`(与 sync.js/config-schema.js 一致)。
1129
+ * @param {string} cwd
1130
+ * @returns {string}
1131
+ */
1132
+ function readLocalYamlRaw(cwd) {
1133
+ const p = join(cwd, '.sillyspec', 'local.yaml')
1134
+ if (!existsSync(p)) return ''
1135
+ try {
1136
+ return readFileSync(p, 'utf8')
1137
+ } catch {
1138
+ return ''
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * 从 plan.md 内容聚合所有 task 卡片声明的 repo:(去重,含 'main' 隐式)。
1144
+ *
1145
+ * design §5.4 execute 启动段 + §7.2:扫 plan.md 所有 task 卡片 frontmatter,用 parseRepo 解析
1146
+ * repo: 字段(缺省='main'),去重后返回数组。'main' 永远隐式在列(主仓 task 不写 repo: 也算主仓)。
1147
+ *
1148
+ * 聚合策略:plan.md 由 plan 阶段生成,task 卡片 frontmatter 形如 `---\nrepo: sillyspec\n...\n---`
1149
+ * (与 task-NN.md 同源)。本函数全局匹配所有 frontmatter 块(`/^---\n([\s\S]*?)\n---/mg`),
1150
+ * 每块重新拼成完整 frontmatter 喂 parseRepo(parseRepo 内部 `^---\n...\n---` 锚开头)。
1151
+ *
1152
+ * @param {string} planContent
1153
+ * @returns {string[]} 去重后的 repo 数组(至少含 'main')
1154
+ */
1155
+ export function aggregateDeclaredRepos(planContent) {
1156
+ const seen = new Set(['main']) // main 永远隐式
1157
+ if (!planContent) return ['main']
1158
+ // 全局匹配所有 frontmatter 块(m 标志让 ^ 匹配每行行首,但此处 ^--- 要求行首 ---,
1159
+ // 配合 [\s\S]*? 非贪婪到下一个 ---)。每块重新拼完整 frontmatter 喂 parseRepo。
1160
+ const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/gm
1161
+ let match
1162
+ while ((match = fmRegex.exec(planContent)) !== null) {
1163
+ const fmBody = match[1]
1164
+ // 重新拼成 parseRepo 期望的完整 frontmatter(---\n<body>\n---)
1165
+ const repo = parseRepo(`---\n${fmBody}\n---`)
1166
+ if (repo && !seen.has(repo)) seen.add(repo)
1167
+ }
1168
+ return Array.from(seen)
1169
+ }
1170
+
1171
+ /**
1172
+ * 坑7 补扫:读 tasks/task-NN.md 独立卡片,拼成可被 aggregateDeclaredRepos 的 frontmatter
1173
+ * 正则识别的文本(每张卡内容原样拼接,卡内容自带的 ---\n…\n--- frontmatter 会被同一正则命中)。
1174
+ *
1175
+ * plan 阶段标准产出形态是 plan.md 内联卡片块 + tasks/ 独立卡片双写,但实际执行中 plan.md
1176
+ * 可能只留 checkbox 行(卡片全在 tasks/)——此时 plan.md 聚合不到跨仓 repo,跨仓 review
1177
+ * 误报伪造。本函数读 tasks/ 目录兜底,任一文件读失败跳过(fail-open,兜底源不引入新阻断)。
1178
+ *
1179
+ * @param {string} specDir - spec 根(change 目录的父级)
1180
+ * @param {string} changeName
1181
+ * @returns {string} 拼接文本(无 tasks/ 或全空 → '',plan.md 原行为零回归)
1182
+ */
1183
+ function collectTaskCardReposFallback(specDir, changeName) {
1184
+ const tasksDir = join(specDir, 'changes', changeName, 'tasks')
1185
+ if (!existsSync(tasksDir)) return ''
1186
+ let merged = ''
1187
+ for (const f of readdirSync(tasksDir)) {
1188
+ if (!/^task-\d+\.md$/i.test(f)) continue
1189
+ const p = join(tasksDir, f)
1190
+ // CRLF 卡片:\r?\n 容差在 aggregateDeclaredRepos 正则里,这里只管拼接不归一
1191
+ try {
1192
+ merged += '\n' + readFileSync(p, 'utf8') + '\n'
1193
+ } catch {
1194
+ // 单文件读失败跳过——兜底源缺失不该阻断主聚合
1195
+ }
1196
+ }
1197
+ return merged
1198
+ }
1199
+
1200
+ /**
1201
+ * 构造或取进程级 MultiRepoContext 单例(D-013 G2)。
1202
+ *
1203
+ * design §5.4 execute 启动段 + §7.2 G2 + 决策 D-001/D-013:
1204
+ * - 读 local.yaml repos: 段(parseRepoRegistry)→ repoRegistry Map(main 隐式不注册)
1205
+ * - 扫 plan.md 所有 task 卡片 repo:(aggregateDeclaredRepos)→ declaredRepos(含 'main')
1206
+ * - 构造 MultiRepoContext(约束② fail-closed:未注册 repo / 跨仓 git 不可用抛错阻断)
1207
+ * - 按 `${cwd} ${changeName}` 缓存,同进程同 change 复用
1208
+ *
1209
+ * **单仓退化**(design §7.2 「main 隐式为 cwd 不用注册」):local.yaml 无 repos: 段 + 单仓 change
1210
+ * (declaredRepos=['main'])→ repoRegistry=空 Map,MultiRepoContext 退化为 {main:{...}} 单值 map,
1211
+ * hasCrossRepo()=false,所有 ctx 透传点缺省行为零回归(task-01/04/05/06/07/08 各模块 ctx=null 等价)。
1212
+ *
1213
+ * **跨仓 fail-closed**(约束② D-007):declaredRepos 含 local.yaml 未注册的 repo key,或跨仓仓
1214
+ * git rev-parse 失败 → MultiRepoContext 构造抛错,本函数**不缓存**该错误,原样上抛阻断 execute 启动。
1215
+ *
1216
+ * 缓存语义:命中返回已构造实例;未命中且构造成功则缓存后返回;构造失败不缓存(下次重试重新构造)。
1217
+ *
1218
+ * @param {Object} opts
1219
+ * @param {string} opts.cwd - 主仓 cwd
1220
+ * @param {string} opts.changeName - 当前 change 名
1221
+ * @param {Object} [opts.platformOpts] - 平台选项(specRoot 等,透传给 MultiRepoContext 预留)
1222
+ * @param {boolean} [opts.noCache=false] - 跳过缓存(测试 / 显式重建场景)
1223
+ * @returns {MultiRepoContext|null} changeName/cwd 缺失或无 plan.md 时返回 null(单仓退化,调用方按 ctx=null 处理)
1224
+ * @throws {Error} MultiRepoContext 构造 fail-closed(约束②未注册/跨仓git不可用)
1225
+ */
1226
+ export async function getOrCreateMultiRepoContext({ cwd, changeName, platformOpts = {}, noCache = false } = {}) {
1227
+ if (!cwd || !changeName) return null
1228
+ const cacheKey = `${cwd} ${changeName}`
1229
+ if (!noCache && _multiRepoCtxCache.has(cacheKey)) {
1230
+ return _multiRepoCtxCache.get(cacheKey)
1231
+ }
1232
+
1233
+ // 读 plan.md(execute 启动入口的 change 目录),无 plan.md → 无法聚合 declaredRepos → 退化 null
1234
+ const specDir = platformOpts?.specRoot || resolveSpecDir(cwd)
1235
+ const planFile = join(specDir, 'changes', changeName, 'plan.md')
1236
+ let planContent = ''
1237
+ if (existsSync(planFile)) {
1238
+ try {
1239
+ planContent = readFileSync(planFile, 'utf8')
1240
+ } catch {
1241
+ planContent = ''
1242
+ }
1243
+ }
1244
+ if (!planContent) {
1245
+ // 无 plan.md(plan 未完成 / brainstorm 阶段 / quick 等场景)→ 无 task 卡片可扫,
1246
+ // 不构造 ctx(返 null 退化单仓)。跨仓 task 必须 plan.md 落地后才进 execute,此处安全。
1247
+ return null
1248
+ }
1249
+
1250
+ // 聚合 declaredRepos(扫 plan.md task 卡片 repo:)+ 读 local.yaml repos: 段
1251
+ // 坑7 兼扫 tasks/task-NN.md:plan 阶段把卡片写到 tasks/、plan.md 只留 checkbox 行时,
1252
+ // plan.md 内联 frontmatter 聚合不到跨仓 repo → ctx 不含跨仓 entry → 跨仓 review 退回主仓
1253
+ // gitDir 校验,误报「base 不是真实 commit — 疑似伪造」(真实 commit 在跨仓仓可达)。
1254
+ // 两源并入同一聚合:plan.md 内联块(原行为,零回归)+ tasks/ 独立卡片(坑7 补扫)。
1255
+ const declaredRepos = aggregateDeclaredRepos(
1256
+ planContent + collectTaskCardReposFallback(specDir, changeName)
1257
+ )
1258
+ const repoRegistry = parseRepoRegistry(readLocalYamlRaw(cwd))
1259
+
1260
+ // 构造 WorktreeManager(主仓 meta 读取;与 getStageSteps 同源范式)
1261
+ let worktreeManager
1262
+ try {
1263
+ const { WorktreeManager } = await import('../worktree.js')
1264
+ worktreeManager = new WorktreeManager({ cwd })
1265
+ } catch {
1266
+ // WorktreeManager 不可用 → 退化为最小桩(in-place-fallback 兜底 cwd)
1267
+ worktreeManager = { getMeta: () => null }
1268
+ }
1269
+
1270
+ const ctx = new MultiRepoContext({
1271
+ cwd,
1272
+ changeName,
1273
+ platformOpts,
1274
+ declaredRepos,
1275
+ repoRegistry,
1276
+ worktreeManager,
1277
+ })
1278
+
1279
+ if (!noCache) {
1280
+ _multiRepoCtxCache.set(cacheKey, ctx)
1281
+ }
1282
+ return ctx
1283
+ }
1284
+
1285
+ /**
1286
+ * 清除进程级 MultiRepoContext 缓存(测试隔离用)。生产代码不需手动清。
1287
+ * @param {string} [cwd]
1288
+ * @param {string} [changeName]
1289
+ */
1290
+ export function _clearMultiRepoCtxCache(cwd, changeName) {
1291
+ if (cwd && changeName) {
1292
+ _multiRepoCtxCache.delete(`${cwd} ${changeName}`)
1293
+ } else {
1294
+ _multiRepoCtxCache.clear()
1295
+ }
1296
+ }
1297
+
1298
+
1299
+ /**
1300
+ * 获取阶段的步骤定义(execute 需要动态构建)
1301
+ */
1302
+ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1303
+ if (stageName === 'execute') {
1304
+ const changeDir = resolveChangeDir(cwd, progress, specDir)
1305
+ let planFile = null
1306
+ let worktreePath = null
1307
+ let changeName = null
1308
+ if (changeDir) {
1309
+ const p = join(changeDir, 'plan.md')
1310
+ if (existsSync(p)) planFile = p
1311
+ changeName = basename(changeDir)
1312
+ // 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
1313
+ // 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
1314
+ // 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
1315
+ try {
1316
+ const { WorktreeManager } = await import('../worktree.js')
1317
+ const wm = new WorktreeManager({ cwd })
1318
+ const meta = wm.getMeta(changeName)
1319
+ if (meta?.worktreePath && existsSync(meta.worktreePath)) {
1320
+ worktreePath = meta.worktreePath
1321
+ }
1322
+ } catch {
1323
+ // 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
1324
+ }
1325
+ }
1326
+ // W3 task-09:execute 启动入口构造 MultiRepoContext 单例(D-013 G2),贯穿 execute/apply/verify。
1327
+ // 单仓 change(无 repo: / 无 local.yaml repos 段)→ ctx=null 退化单仓零回归(task-08 buildExecuteSteps 缺省 null)。
1328
+ // 跨仓 change 缺注册 / 跨仓 git 不可用 → getOrCreateMultiRepoContext 抛错阻断 execute 启动(约束②)。
1329
+ let ctx = null
1330
+ if (changeName) {
1331
+ try {
1332
+ ctx = await getOrCreateMultiRepoContext({ cwd, changeName, platformOpts: specDir ? { specRoot: specDir } : {} })
1333
+ } catch (e) {
1334
+ // fail-closed 阻断:execute 启动期 ctx 构造失败(未注册 repo / 跨仓 git 不可用),
1335
+ // 抛错让上层 runCommand 把错误返回给 Agent(不降级单仓跑——跨仓 apply 走错仓=数据所有权事故)。
1336
+ throw new Error(`execute 启动失败:跨仓 MultiRepoContext 构造失败(${e.message})`)
1337
+ }
1338
+ }
1339
+ return buildExecuteSteps(planFile, { worktreePath, ctx })
1340
+ }
1341
+ if (stageName === 'plan') {
1342
+ const changeDir = resolveChangeDir(cwd, progress, specDir)
1343
+ return buildPlanSteps(changeDir)
1344
+ }
1345
+ const def = stageRegistry[stageName]
1346
+ return def ? def.steps : null
1347
+ }
1348
+