sillyspec 3.26.6 → 3.26.8

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