sillyspec 3.26.2 → 3.26.4

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * MultiRepoContext — 跨仓 task 运行时多仓执行上下文(W1 task-01)。
3
+ *
4
+ * 设计依据:design §7.1(接口权威定义)+ 决策 D-005/D-006/D-007/D-013。
5
+ *
6
+ * 收口 execute 链路 7 个单仓假设点(applyWorktree / validateTaskReviews /
7
+ * runVerifyTestCheck / generateTaskReviewDrafts 等)——把"主仓=唯一仓库"假设
8
+ * 抽成 Map<repoKey, RepoEntry>,单仓 change 退化为 {main:{...}} 单值 map 零回归
9
+ * (GOAL-2),跨仓 change 实时反映各仓 HEAD(约束①)+ 配置错误 fail-closed(约束②)。
10
+ *
11
+ * 关键约束:
12
+ * - 约束①(D-006):跨仓仓无 meta.json,base/head 不读 meta。resolveHead 实时
13
+ * `git -C <crossRepo> rev-parse HEAD`(不缓存,task 推进后过期);resolveBase
14
+ * 用 taskBaseCommit(task 卡 base_commit 锡点,跨仓必传)。
15
+ * - 约束②(D-007):declaredRepos 有 key 不在 repoRegistry → throw;跨仓仓
16
+ * `git -C <crossRepo> rev-parse HEAD` 失败 → throw。配置错误不降级,不沿用
17
+ * 主仓 verifyReviewGitEvidence 的 unavailable 降级(那是环境缺失容错;跨仓
18
+ * 不可用是用户 local.yaml 配错,性质不同,必须阻断)。
19
+ * - D-013:execute 启动时构造一次,进程级缓存贯穿 execute/apply/verify(本模块
20
+ * 不持有进程级缓存——ctx 实例本身就是进程级单例,由 execute 入口持有)。
21
+ *
22
+ * 不涉及生命周期契约(design §7.5):纯内存对象,不跨进程、不持久化、不入库。
23
+ */
24
+
25
+ import { git, gitQuiet } from '../git-helper.js';
26
+
27
+ /**
28
+ * @typedef {Object} RepoEntry
29
+ * @property {string} repoKey - repo 标识('main' 或 local.yaml repos: 的键名)
30
+ * @property {string} gitDir - git 工作目录(rev-parse/diff/apply 的 cwd)
31
+ * @property {string} worktreePath - 子代理工作目录(主仓=worktreePath 或 in-place-fallback 时=cwd;跨仓=跨仓仓根)
32
+ * @property {string} projectRoot - apply 目标根(主仓=主仓根;跨仓=跨仓仓根,但跨仓 apply=no-op 不用)
33
+ * @property {boolean} isMain - 是否主仓
34
+ * @property {function} resolveHead - () => string,实时 git rev-parse HEAD(跨仓不缓存)
35
+ * @property {function} resolveBase - (taskBaseCommit?) => string,base 锚点(主仓→meta.baseHash;跨仓→taskBaseCommit 必传)
36
+ */
37
+
38
+ export class MultiRepoContext {
39
+ /**
40
+ * @param {Object} opts
41
+ * @param {string} opts.cwd - 主仓 cwd
42
+ * @param {string} opts.changeName - 当前 change 名(读 worktreeManager.getMeta)
43
+ * @param {Object} [opts.platformOpts] - 平台选项(specRoot 等,预留,本模块不消费)
44
+ * @param {string[]} opts.declaredRepos - 所有 task 卡片声明的 repo: 值(含 'main')
45
+ * @param {Map<string,string>} opts.repoRegistry - local.yaml repos: 段解析结果 Map<key, path>
46
+ * @param {Object} opts.worktreeManager - WorktreeManager 实例(主仓 meta 读取;in-place-fallback 兜底 cwd)
47
+ * @throws {Error} 约束②: declaredRepos 中有 repoKey 不在 repoRegistry → 抛错列已注册 repo,阻断 execute
48
+ * 约束②: 跨仓仓 git rev-parse 失败(路径不存在/非 git 仓)→ 抛错阻断 execute
49
+ */
50
+ constructor({ cwd, changeName, platformOpts, declaredRepos, repoRegistry, worktreeManager }) {
51
+ if (!cwd) throw new Error('MultiRepoContext: cwd 必传');
52
+ if (!Array.isArray(declaredRepos)) throw new Error('MultiRepoContext: declaredRepos 必传为数组');
53
+ if (!(repoRegistry instanceof Map)) throw new Error('MultiRepoContext: repoRegistry 必传为 Map<key,path>');
54
+ if (!worktreeManager || typeof worktreeManager.getMeta !== 'function') {
55
+ throw new Error('MultiRepoContext: worktreeManager 必传(需 getMeta 方法)');
56
+ }
57
+
58
+ this.cwd = cwd;
59
+ this.changeName = changeName;
60
+ this.platformOpts = platformOpts || {};
61
+ this.repoRegistry = repoRegistry;
62
+ this.worktreeManager = worktreeManager;
63
+
64
+ // dedupe + 保留声明顺序(同一 repo 多 task 声明只建一项)
65
+ const seen = new Set();
66
+ const orderedKeys = [];
67
+ for (const key of declaredRepos) {
68
+ if (!seen.has(key)) {
69
+ seen.add(key);
70
+ orderedKeys.push(key);
71
+ }
72
+ }
73
+
74
+ // 约束② fail-closed 预检:declaredRepos ⊆ repoRegistry ∪ {'main'}
75
+ // main 隐式 = cwd,不要求注册;其余 key 必须在 repoRegistry 命中。
76
+ const unregistered = orderedKeys.filter(k => k !== 'main' && !repoRegistry.has(k));
77
+ if (unregistered.length > 0) {
78
+ const registered = ['main (隐式)', ...repoRegistry.keys()];
79
+ throw new Error(
80
+ `MultiRepoContext: 以下 repo 未在 local.yaml repos: 段注册:[${unregistered.join(', ')}]。` +
81
+ `当前已注册 repo:[${registered.join(', ')}]。` +
82
+ `请在 local.yaml repos: 段补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
83
+ );
84
+ }
85
+
86
+ const map = new Map();
87
+ for (const repoKey of orderedKeys) {
88
+ map.set(repoKey, repoKey === 'main'
89
+ ? this._buildMainEntry()
90
+ : this._buildCrossRepoEntry(repoKey, repoRegistry.get(repoKey)));
91
+ }
92
+ this.map = map;
93
+ }
94
+
95
+ /**
96
+ * 主仓 RepoEntry:isMain=true,读 worktreeManager.getMeta 的 baseHash + worktreePath。
97
+ * in-place-fallback 模式(meta.mode==='in-place-fallback' 或 meta 缺失)worktreePath 兜底为 cwd。
98
+ * @returns {RepoEntry}
99
+ * @private
100
+ */
101
+ _buildMainEntry() {
102
+ const meta = this.changeName ? this.worktreeManager.getMeta(this.changeName) : null;
103
+ const isInPlace = !meta || meta.mode === 'in-place-fallback';
104
+ const worktreePath = isInPlace ? this.cwd : (meta.worktreePath || this.cwd);
105
+ const baseHash = meta?.baseHash || null;
106
+ const gitDir = worktreePath; // 主仓 git 操作 cwd = worktree(或 in-place 的 cwd)
107
+ const projectRoot = this.cwd;
108
+
109
+ return {
110
+ repoKey: 'main',
111
+ gitDir,
112
+ worktreePath,
113
+ projectRoot,
114
+ isMain: true,
115
+ // 主仓 HEAD:从 worktree/cwd 实时取(不缓存)。主仓单仓不变式下也可用 meta.baseHash 作 base,
116
+ // 但 HEAD 反映 worktree 当前态(子代理可能 commit 推进),故实时取与跨仓一致语义。
117
+ resolveHead: () => git(gitDir, ['rev-parse', 'HEAD']),
118
+ // 主仓 base:meta.baseHash(单仓不变式,CLI 创建 worktree 时锡点)。
119
+ // 跨仓 base 不走这里(resolveBase 必传 taskBaseCommit,由 _buildCrossRepoEntry 闭包消费)。
120
+ // 主仓忽略 taskBaseCommit 参数——主仓 base 永远锚 meta.baseHash,与单仓行为一致(零回归)。
121
+ resolveBase: (_taskBaseCommit) => {
122
+ if (!baseHash) {
123
+ throw new Error(
124
+ 'MultiRepoContext: 主仓 meta.baseHash 缺失,无法解析 base。' +
125
+ '可能 worktree 未创建或 meta.json 损坏——请先 sillyspec worktree doctor 检查。'
126
+ );
127
+ }
128
+ return baseHash;
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 跨仓 RepoEntry:isMain=false,gitDir=projectRoot=worktreePath=跨仓仓根。
135
+ * 构造时 `git -C <crossRepo> rev-parse HEAD` 必须 succeed(约束② fail-closed),
136
+ * 路径不存在/非 git 仓 → throw 阻断 execute。
137
+ * @param {string} repoKey
138
+ * @param {string} crossRepoPath - repoRegistry 解析出的跨仓仓绝对路径
139
+ * @returns {RepoEntry}
140
+ * @private
141
+ */
142
+ _buildCrossRepoEntry(repoKey, crossRepoPath) {
143
+ if (!crossRepoPath) {
144
+ throw new Error(
145
+ `MultiRepoContext: repo "${repoKey}" 在 local.yaml repos: 段注册但路径为空。` +
146
+ `请补全路径(约束② fail-closed)。`
147
+ );
148
+ }
149
+ // 约束②:跨仓 git 必须可达。失败 = 路径不存在 / 非 git 仓 / git 异常,一律阻断。
150
+ // 不沿用主仓 verifyReviewGitEvidence unavailable 降级(design §9 兼容策略表)。
151
+ let headVerify;
152
+ try {
153
+ headVerify = git(crossRepoPath, ['rev-parse', 'HEAD']);
154
+ } catch (e) {
155
+ throw new Error(
156
+ `MultiRepoContext: 跨仓 repo "${repoKey}" git 不可达(路径:${crossRepoPath})。` +
157
+ `rev-parse HEAD 失败:${e.message}。` +
158
+ `请检查 local.yaml repos: 段路径是否正确、目录是否存在且为 git 仓(约束② fail-closed,配置错误不降级)。`
159
+ );
160
+ }
161
+ if (!headVerify) {
162
+ // git() 返回空串属异常态(无 commit 的空仓 rev-parse HEAD 会非零退出,已被 catch 兜底),
163
+ // 此处仅作防御性双保险,触发同样走 fail-closed。
164
+ throw new Error(
165
+ `MultiRepoContext: 跨仓 repo "${repoKey}"(${crossRepoPath})rev-parse HEAD 返回空——` +
166
+ `疑似空 git 仓(无 commit)。跨仓 task 需仓库已有至少一个 commit(约束② fail-closed)。`
167
+ );
168
+ }
169
+
170
+ return {
171
+ repoKey,
172
+ gitDir: crossRepoPath,
173
+ worktreePath: crossRepoPath,
174
+ projectRoot: crossRepoPath,
175
+ isMain: false,
176
+ // 约束①:跨仓 HEAD 实时取,不缓存(task 推进后过期,每次 resolveHead 反映最新 HEAD)。
177
+ resolveHead: () => git(crossRepoPath, ['rev-parse', 'HEAD']),
178
+ // 约束①:跨仓 base = task 卡 base_commit 锡点(必传)。跨仓仓无 meta.json,
179
+ // 不读 meta;taskBaseCommit 由 task-02 DeclaredRepos 之外的 task 卡 frontmatter 提供。
180
+ resolveBase: (taskBaseCommit) => {
181
+ if (!taskBaseCommit) {
182
+ throw new Error(
183
+ `MultiRepoContext: 跨仓 repo "${repoKey}" resolveBase 必传 taskBaseCommit(task 卡 base_commit 锡点)。` +
184
+ `跨仓仓无 meta.json,base 只能由 task 卡片锡点提供(约束①)。`
185
+ );
186
+ }
187
+ return taskBaseCommit;
188
+ },
189
+ };
190
+ }
191
+
192
+ /** @returns {RepoEntry|null} */
193
+ resolve(repoKey) {
194
+ return this.map.get(repoKey) || null;
195
+ }
196
+
197
+ /** @returns {Map<string, RepoEntry>} */
198
+ get repos() {
199
+ return this.map;
200
+ }
201
+
202
+ /** @returns {boolean} 是否含跨仓 task(map.size>1,execute prompt 分叉用) */
203
+ hasCrossRepo() {
204
+ return this.map.size > 1;
205
+ }
206
+ }
package/src/run/prompt.js CHANGED
@@ -23,6 +23,7 @@ import { stageRegistry } from '../stages/index.js'
23
23
  import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
24
24
  import { renderStageContract } from '../stage-contract-spec.js'
25
25
  import { parseModuleMapSimple } from '../modules.js'
26
+ import { REVIEW_SCHEMA_VERSION } from '../task-review.js'
26
27
 
27
28
  /**
28
29
  * 从 _module-map.yaml 读取模块上下文索引
@@ -218,6 +219,11 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
218
219
  promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
219
220
  promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
220
221
  promptText = promptText.replace(/<now-date>/g, nowDate)
222
+ // 替换 {REVIEW_SCHEMA_VERSION} 占位符(task review 示例模板用,值=CLI 当前 REVIEW_SCHEMA_VERSION 常量,
223
+ // 避免 agent 照抄 design 目标版本与 CLI 写侧常量漂移;与 stage review 契约 renderReviewJsonContract 动态注入同源)
224
+ if (promptText.includes('{REVIEW_SCHEMA_VERSION}')) {
225
+ promptText = promptText.replace(/\{REVIEW_SCHEMA_VERSION\}/g, String(REVIEW_SCHEMA_VERSION))
226
+ }
221
227
  // 替换 <change-name> 占位符
222
228
  if (changeName && promptText.includes('<change-name>')) {
223
229
  promptText = promptText.replace(/<change-name>/g, changeName)
package/src/run/shared.js CHANGED
@@ -70,6 +70,8 @@ export function assertSafeChangeName(name, label = '变更名') {
70
70
  import { buildExecuteSteps } from '../stages/execute.js'
71
71
  import { buildPlanSteps } from '../stages/plan.js'
72
72
  import { stageRegistry } from '../stages/index.js'
73
+ import { parseRepo, parseRepoRegistry } from '../stages/plan-postcheck.js'
74
+ import { MultiRepoContext } from './multi-repo-context.js'
73
75
 
74
76
  /**
75
77
  * 解析 prompt 中的 {{include: <name>}} 占位符:读包内 templates/prompts/<name>.md 注入。
@@ -689,6 +691,158 @@ export function formatWaitOptions(raw) {
689
691
  }
690
692
 
691
693
 
694
+ /**
695
+ * 进程级 MultiRepoContext 缓存(D-013 G2:execute 启动构造一次,贯穿 execute/apply/verify 不重建)。
696
+ *
697
+ * key = `${cwd} ${changeName}`(cwd 隔离防多仓并发串扰;changeName 隔离防同进程多 change 切换串扰)。
698
+ * 跨 CLI 命令(execute vs apply vs verify 各是独立进程)各自构造——design §7.2 G2 明确 apply/verify
699
+ * 「复用 execute 实例,**或**从 review.json.repo + local.yaml repos 反推」,进程级缓存指同一 CLI 命令内复用。
700
+ *
701
+ * 失效语义:ctx 是纯内存对象(task-01 不持久化),进程结束自动释放。无需手动清理。
702
+ * 缓存命中场景:execute 单次 --done 内 getStageSteps(派发)+ completeStageGates(gate)+ 阶段完成
703
+ * 收尾多次取同一 ctx;apply/verify 同理。fail-closed 抛错不缓存(下次重试重新构造)。
704
+ */
705
+ const _multiRepoCtxCache = new Map()
706
+
707
+ /**
708
+ * 读 local.yaml 原始文本(best-effort,不存在/读失败返 '')。
709
+ * 与 sync.js readLocalYamlRaw 同风格,但本模块不引 sync.js(避免循环依赖 + sync.js 非 export)。
710
+ * local.yaml 路径 = `<cwd>/.sillyspec/local.yaml`(与 sync.js/config-schema.js 一致)。
711
+ * @param {string} cwd
712
+ * @returns {string}
713
+ */
714
+ function readLocalYamlRaw(cwd) {
715
+ const p = join(cwd, '.sillyspec', 'local.yaml')
716
+ if (!existsSync(p)) return ''
717
+ try {
718
+ return readFileSync(p, 'utf8')
719
+ } catch {
720
+ return ''
721
+ }
722
+ }
723
+
724
+ /**
725
+ * 从 plan.md 内容聚合所有 task 卡片声明的 repo:(去重,含 'main' 隐式)。
726
+ *
727
+ * design §5.4 execute 启动段 + §7.2:扫 plan.md 所有 task 卡片 frontmatter,用 parseRepo 解析
728
+ * repo: 字段(缺省='main'),去重后返回数组。'main' 永远隐式在列(主仓 task 不写 repo: 也算主仓)。
729
+ *
730
+ * 聚合策略:plan.md 由 plan 阶段生成,task 卡片 frontmatter 形如 `---\nrepo: sillyspec\n...\n---`
731
+ * (与 task-NN.md 同源)。本函数全局匹配所有 frontmatter 块(`/^---\n([\s\S]*?)\n---/mg`),
732
+ * 每块重新拼成完整 frontmatter 喂 parseRepo(parseRepo 内部 `^---\n...\n---` 锚开头)。
733
+ *
734
+ * @param {string} planContent
735
+ * @returns {string[]} 去重后的 repo 数组(至少含 'main')
736
+ */
737
+ export function aggregateDeclaredRepos(planContent) {
738
+ const seen = new Set(['main']) // main 永远隐式
739
+ if (!planContent) return ['main']
740
+ // 全局匹配所有 frontmatter 块(m 标志让 ^ 匹配每行行首,但此处 ^--- 要求行首 ---,
741
+ // 配合 [\s\S]*? 非贪婪到下一个 ---)。每块重新拼完整 frontmatter 喂 parseRepo。
742
+ const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/gm
743
+ let match
744
+ while ((match = fmRegex.exec(planContent)) !== null) {
745
+ const fmBody = match[1]
746
+ // 重新拼成 parseRepo 期望的完整 frontmatter(---\n<body>\n---)
747
+ const repo = parseRepo(`---\n${fmBody}\n---`)
748
+ if (repo && !seen.has(repo)) seen.add(repo)
749
+ }
750
+ return Array.from(seen)
751
+ }
752
+
753
+ /**
754
+ * 构造或取进程级 MultiRepoContext 单例(D-013 G2)。
755
+ *
756
+ * design §5.4 execute 启动段 + §7.2 G2 + 决策 D-001/D-013:
757
+ * - 读 local.yaml repos: 段(parseRepoRegistry)→ repoRegistry Map(main 隐式不注册)
758
+ * - 扫 plan.md 所有 task 卡片 repo:(aggregateDeclaredRepos)→ declaredRepos(含 'main')
759
+ * - 构造 MultiRepoContext(约束② fail-closed:未注册 repo / 跨仓 git 不可用抛错阻断)
760
+ * - 按 `${cwd} ${changeName}` 缓存,同进程同 change 复用
761
+ *
762
+ * **单仓退化**(design §7.2 「main 隐式为 cwd 不用注册」):local.yaml 无 repos: 段 + 单仓 change
763
+ * (declaredRepos=['main'])→ repoRegistry=空 Map,MultiRepoContext 退化为 {main:{...}} 单值 map,
764
+ * hasCrossRepo()=false,所有 ctx 透传点缺省行为零回归(task-01/04/05/06/07/08 各模块 ctx=null 等价)。
765
+ *
766
+ * **跨仓 fail-closed**(约束② D-007):declaredRepos 含 local.yaml 未注册的 repo key,或跨仓仓
767
+ * git rev-parse 失败 → MultiRepoContext 构造抛错,本函数**不缓存**该错误,原样上抛阻断 execute 启动。
768
+ *
769
+ * 缓存语义:命中返回已构造实例;未命中且构造成功则缓存后返回;构造失败不缓存(下次重试重新构造)。
770
+ *
771
+ * @param {Object} opts
772
+ * @param {string} opts.cwd - 主仓 cwd
773
+ * @param {string} opts.changeName - 当前 change 名
774
+ * @param {Object} [opts.platformOpts] - 平台选项(specRoot 等,透传给 MultiRepoContext 预留)
775
+ * @param {boolean} [opts.noCache=false] - 跳过缓存(测试 / 显式重建场景)
776
+ * @returns {MultiRepoContext|null} changeName/cwd 缺失或无 plan.md 时返回 null(单仓退化,调用方按 ctx=null 处理)
777
+ * @throws {Error} MultiRepoContext 构造 fail-closed(约束②未注册/跨仓git不可用)
778
+ */
779
+ export async function getOrCreateMultiRepoContext({ cwd, changeName, platformOpts = {}, noCache = false } = {}) {
780
+ if (!cwd || !changeName) return null
781
+ const cacheKey = `${cwd} ${changeName}`
782
+ if (!noCache && _multiRepoCtxCache.has(cacheKey)) {
783
+ return _multiRepoCtxCache.get(cacheKey)
784
+ }
785
+
786
+ // 读 plan.md(execute 启动入口的 change 目录),无 plan.md → 无法聚合 declaredRepos → 退化 null
787
+ const specDir = platformOpts?.specRoot || resolveSpecDir(cwd)
788
+ const planFile = join(specDir, 'changes', changeName, 'plan.md')
789
+ let planContent = ''
790
+ if (existsSync(planFile)) {
791
+ try {
792
+ planContent = readFileSync(planFile, 'utf8')
793
+ } catch {
794
+ planContent = ''
795
+ }
796
+ }
797
+ if (!planContent) {
798
+ // 无 plan.md(plan 未完成 / brainstorm 阶段 / quick 等场景)→ 无 task 卡片可扫,
799
+ // 不构造 ctx(返 null 退化单仓)。跨仓 task 必须 plan.md 落地后才进 execute,此处安全。
800
+ return null
801
+ }
802
+
803
+ // 聚合 declaredRepos(扫 plan.md task 卡片 repo:)+ 读 local.yaml repos: 段
804
+ const declaredRepos = aggregateDeclaredRepos(planContent)
805
+ const repoRegistry = parseRepoRegistry(readLocalYamlRaw(cwd))
806
+
807
+ // 构造 WorktreeManager(主仓 meta 读取;与 getStageSteps 同源范式)
808
+ let worktreeManager
809
+ try {
810
+ const { WorktreeManager } = await import('../worktree.js')
811
+ worktreeManager = new WorktreeManager({ cwd })
812
+ } catch {
813
+ // WorktreeManager 不可用 → 退化为最小桩(in-place-fallback 兜底 cwd)
814
+ worktreeManager = { getMeta: () => null }
815
+ }
816
+
817
+ const ctx = new MultiRepoContext({
818
+ cwd,
819
+ changeName,
820
+ platformOpts,
821
+ declaredRepos,
822
+ repoRegistry,
823
+ worktreeManager,
824
+ })
825
+
826
+ if (!noCache) {
827
+ _multiRepoCtxCache.set(cacheKey, ctx)
828
+ }
829
+ return ctx
830
+ }
831
+
832
+ /**
833
+ * 清除进程级 MultiRepoContext 缓存(测试隔离用)。生产代码不需手动清。
834
+ * @param {string} [cwd]
835
+ * @param {string} [changeName]
836
+ */
837
+ export function _clearMultiRepoCtxCache(cwd, changeName) {
838
+ if (cwd && changeName) {
839
+ _multiRepoCtxCache.delete(`${cwd} ${changeName}`)
840
+ } else {
841
+ _multiRepoCtxCache.clear()
842
+ }
843
+ }
844
+
845
+
692
846
  /**
693
847
  * 获取阶段的步骤定义(execute 需要动态构建)
694
848
  */
@@ -697,14 +851,15 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
697
851
  const changeDir = resolveChangeDir(cwd, progress, specDir)
698
852
  let planFile = null
699
853
  let worktreePath = null
854
+ let changeName = null
700
855
  if (changeDir) {
701
856
  const p = join(changeDir, 'plan.md')
702
857
  if (existsSync(p)) planFile = p
858
+ changeName = basename(changeDir)
703
859
  // 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
704
860
  // 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
705
861
  // 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
706
862
  try {
707
- const changeName = basename(changeDir)
708
863
  const { WorktreeManager } = await import('../worktree.js')
709
864
  const wm = new WorktreeManager({ cwd })
710
865
  const meta = wm.getMeta(changeName)
@@ -715,7 +870,20 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
715
870
  // 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
716
871
  }
717
872
  }
718
- return buildExecuteSteps(planFile, { worktreePath })
873
+ // W3 task-09:execute 启动入口构造 MultiRepoContext 单例(D-013 G2),贯穿 execute/apply/verify。
874
+ // 单仓 change(无 repo: / 无 local.yaml repos 段)→ ctx=null 退化单仓零回归(task-08 buildExecuteSteps 缺省 null)。
875
+ // 跨仓 change 缺注册 / 跨仓 git 不可用 → getOrCreateMultiRepoContext 抛错阻断 execute 启动(约束②)。
876
+ let ctx = null
877
+ if (changeName) {
878
+ try {
879
+ ctx = await getOrCreateMultiRepoContext({ cwd, changeName, platformOpts: specDir ? { specRoot: specDir } : {} })
880
+ } catch (e) {
881
+ // fail-closed 阻断:execute 启动期 ctx 构造失败(未注册 repo / 跨仓 git 不可用),
882
+ // 抛错让上层 runCommand 把错误返回给 Agent(不降级单仓跑——跨仓 apply 走错仓=数据所有权事故)。
883
+ throw new Error(`execute 启动失败:跨仓 MultiRepoContext 构造失败(${e.message})`)
884
+ }
885
+ }
886
+ return buildExecuteSteps(planFile, { worktreePath, ctx })
719
887
  }
720
888
  if (stageName === 'plan') {
721
889
  const changeDir = resolveChangeDir(cwd, progress, specDir)
@@ -426,6 +426,7 @@ export function printStageReviewResult(result, context = {}) {
426
426
  console.error(` - ${err}`)
427
427
  }
428
428
  console.error(`\n 提示:tier=independent 要求独立审查子代理产出 review.json,补全后重新 --done`)
429
+ console.error(` 可用 sillyspec register-stage-review --change <名> --stage ${stage} [--from <已有review.json>] 一步生成 run 目录 + review.json 骨架(docHash 自动算)+ 写 marker + 自检,省掉手动建目录/写 marker`)
429
430
  }
430
431
 
431
432
  if (result.warnings.length > 0) {
@@ -329,6 +329,8 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
329
329
  - 长期术语只在 archive/scan 时再提升到 \`{SPEC_ROOT}/docs/<project>/glossary.md\`
330
330
  4. 格式自检(只查章节齐全;语义一致性/可行性/YAGNI 不在本步查,交给下一步 Design Grill 独立审查):
331
331
  - design.md 含全部必填章节(背景/设计目标/非目标/总体方案/文件变更清单/接口定义/风险登记)
332
+ - design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——step8 完成契约硬要求,审查前就补齐避免后续 docHash 漂移
333
+ - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——step8 完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
332
334
  - 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
333
335
  - 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
334
336
  5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)
@@ -476,10 +478,10 @@ created_at: <now-datetime>
476
478
 
477
479
  ### proposal.md 格式要求
478
480
  \`\`\`markdown
479
- ---
480
- author: <git-user>
481
- created_at: <now-datetime>
482
- ---
481
+ ---
482
+ author: <git-user>
483
+ created_at: <now-datetime>
484
+ ---
483
485
  # 提案书(Proposal)
484
486
 
485
487
  ## 动机
@@ -503,10 +505,10 @@ created_at: <now-datetime>
503
505
 
504
506
  ### requirements.md 格式要求
505
507
  \`\`\`markdown
506
- ---
507
- author: <git-user>
508
- created_at: <now-datetime>
509
- ---
508
+ ---
509
+ author: <git-user>
510
+ created_at: <now-datetime>
511
+ ---
510
512
  # 需求规格(Requirements)
511
513
 
512
514
  ## 角色
@@ -535,18 +537,18 @@ Then 期望结果
535
537
  | D-001@v1 | FR-01 | ... |
536
538
  \`\`\`
537
539
 
538
- ### tasks.md 格式要求(scale=large 时生成骨架,plan 阶段展开)
539
- \`\`\`markdown
540
- ---
541
- author: <git-user>
542
- created_at: <now-datetime>
543
- ---
544
- # 任务清单(Tasks)
545
-
546
- - [ ] task-01: <任务名>
547
- - [ ] task-02: <任务名>
548
- \`\`\`
549
-
540
+ ### tasks.md 格式要求(scale=large 时生成骨架,plan 阶段展开)
541
+ \`\`\`markdown
542
+ ---
543
+ author: <git-user>
544
+ created_at: <now-datetime>
545
+ ---
546
+ # 任务清单(Tasks)
547
+
548
+ - [ ] task-01: <任务名>
549
+ - [ ] task-02: <任务名>
550
+ \`\`\`
551
+
550
552
  ### decisions.md 格式要求(仅在有 Grill/重大决策时生成)
551
553
  \`\`\`markdown
552
554
  # 决策记录(Decisions)