sillyspec 3.26.2 → 3.26.3

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.
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) {
@@ -1,10 +1,12 @@
1
- import { existsSync, readFileSync, readdirSync } from 'fs'
1
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
2
2
  import path from 'path'
3
3
  import { buildContractMatrix, buildConsumerInjection, buildContractFieldInjection } from '../contract-matrix.js'
4
4
  import { getRule } from '../stage-contract-spec.js'
5
5
  import { renderDispatchInstruction } from '../dispatch/strategy.js'
6
6
  import { isPathASupported } from '../dispatch/backends/sillyhub-mcp.js'
7
7
  import { readMcpConfig } from '../sillyhub-mcp/config.js'
8
+ import { gitQuiet } from '../git-helper.js'
9
+ import { parseRepo } from './plan-postcheck.js'
8
10
 
9
11
  /**
10
12
  * 校验 plan.md 是否满足 execute 执行契约
@@ -460,10 +462,92 @@ export function getDispatchMode() {
460
462
  if (!hasConfig) return 'local'
461
463
  return isPathASupported() ? 'sillyhub' : 'local-fallback'
462
464
  }
465
+
466
+ /**
467
+ * 解析 task 卡的 repo 归属(W3 task-08,D-012)。
468
+ * 读 changeDir/tasks/task-<num>.md 的 frontmatter `repo:` 字段;缺省/无卡/'main' → 'main'。
469
+ * 单仓 change(无 repo: 字段)恒返 'main',零回归。
470
+ *
471
+ * @param {{ index?: number, name?: string }} task - wave.tasks 元素
472
+ * @param {string|null} changeDir - change 目录绝对路径
473
+ * @returns {string} repoKey,'main' 或 parseRepo 返回值
474
+ */
475
+ function resolveTaskRepo(task, changeDir) {
476
+ if (!changeDir || !task || task.index == null) return 'main'
477
+ const num = String(task.index).padStart(2, '0')
478
+ const taskFile = path.join(changeDir, 'tasks', `task-${num}.md`)
479
+ if (!existsSync(taskFile)) return 'main'
480
+ try {
481
+ const content = readFileSync(taskFile, 'utf8')
482
+ const repo = parseRepo(content)
483
+ return repo || 'main'
484
+ } catch {
485
+ return 'main'
486
+ }
487
+ }
488
+
489
+ /**
490
+ * 把 base_commit 锡点写入 task 卡 frontmatter(W3 task-08,D-010)。
491
+ * 派发跨仓 task 前 CLI 实时 git rev-parse HEAD 落盘——锁定子代理起改的 base 锚点,
492
+ * 防止同 Wave 多 task 改同一跨仓仓时 HEAD 推进致 diff 范围漂移(design §5.3 约束① / R-01)。
493
+ *
494
+ * 策略:幂等写——frontmatter 有 base_commit 行则就地替换,无则紧跟 repo 行后插入(
495
+ * 无 frontmatter 时整段创建)。仅改 frontmatter 不动正文。已存在相同值不重复写。
496
+ *
497
+ * @param {string} taskFilePath - task-NN.md 绝对路径
498
+ * @param {string} baseCommit - 跨仓仓 HEAD sha
499
+ * @returns {boolean} 是否实际写入(false = 文件不存在 / 写失败 / 值未变)
500
+ */
501
+ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
502
+ if (!taskFilePath || !baseCommit) return false
503
+ if (!existsSync(taskFilePath)) return false
504
+ let content
505
+ try {
506
+ content = readFileSync(taskFilePath, 'utf8')
507
+ } catch {
508
+ return false
509
+ }
510
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
511
+ if (!fmMatch) return false
512
+ const fm = fmMatch[1]
513
+ const baseLineRe = /^base_commit:\s*.*$/m
514
+ let newFm
515
+ if (baseLineRe.test(fm)) {
516
+ const existing = (fm.match(baseLineRe) || [''])[0]
517
+ if (existing === `base_commit: ${baseCommit}`) return false // 值未变,幂等跳过
518
+ newFm = fm.replace(baseLineRe, `base_commit: ${baseCommit}`)
519
+ } else {
520
+ // 紧跟 repo 行后插入(与 D-010 协议同源顺序 repo→base_commit→head_commit)
521
+ const repoLineRe = /^repo:\s*.*$/m
522
+ const anchor = `base_commit: ${baseCommit}\n`
523
+ if (repoLineRe.test(fm)) {
524
+ newFm = fm.replace(repoLineRe, m => m + '\n' + anchor.trimEnd())
525
+ } else {
526
+ // 无 repo 行:插到 frontmatter 首部(frontmatter 首字段)
527
+ newFm = anchor.trimEnd() + '\n' + fm
528
+ }
529
+ }
530
+ const newContent = content.replace(/^---\n[\s\S]*?\n---/, `---\n${newFm}\n---`)
531
+ if (newContent === content) return false
532
+ try {
533
+ writeFileSync(taskFilePath, newContent)
534
+ return true
535
+ } catch {
536
+ return false
537
+ }
538
+ }
539
+
463
540
  /**
464
541
  * 为 Wave 生成 prompt(强制子代理执行)
542
+ *
543
+ * W3 task-08(D-012 per-task workdir):签名加可选 `ctx`(MultiRepoContext)参数。
544
+ * - 无 ctx(单仓 change / 旧调用方)→ 退化为单 worktreePath 单值 worktreeSection(零回归)
545
+ * - 有 ctx → 按 task 逐个解析 repo 归属,构造 per-task workdir 多值表,跨仓 task 派发前
546
+ * 落 base_commit 锡点(D-010),注入跨仓 task commit 指引 + head_commit 落盘指引。
547
+ * 同 Wave 允许主仓+跨仓 task 混合(各独立 Task 调用各传 workdir,不强制同 Wave 同 repo)。
465
548
  */
466
549
  export function buildWavePrompt(wave, waveIndex, changeDir, worktreePath, options = {}) {
550
+ const ctx = options.ctx || null
467
551
  // ── Contract Matrix:检查是否有 provider/consumer 契约需要注入 ──
468
552
  let contractInjection = ''
469
553
  let prototypeInjection = ''
@@ -558,8 +642,97 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
558
642
  return s
559
643
  }).join('\n')
560
644
 
561
- const worktreeSection = (worktreePath)
562
- ? `
645
+ // ── per-task workdir 解析(W3 task-08,D-012)──
646
+ // 无 ctx(单仓 change)→ 全 task workdir=主仓 worktreePath,沿用旧单值逻辑(零回归)。
647
+ // 有 ctx → 按 task 卡 repo: 字段逐个解析:主仓 task workdir=主仓 worktreePath,跨仓 task
648
+ // workdir=ctx.resolve(repo).worktreePath(跨仓仓根)。同 Wave 允许主仓+跨仓混合。
649
+ const taskRepos = wave.tasks.map(t => ctx ? resolveTaskRepo(t, changeDir) : 'main')
650
+ const hasCrossRepoWave = ctx ? taskRepos.some(r => r !== 'main') : false
651
+
652
+ // per-task workdir 映射(用于多值 worktreeSection + 跨仓 commit 指引)
653
+ // 无 ctx 或无跨仓 task → 不构造(沿用旧单值 worktreePath 注入)
654
+ let perTaskWorkdirs = null
655
+ let crossRepoCommitSection = ''
656
+ let baseCommitWrites = [] // [{ taskNum, repo, taskFile, written }] 跨仓 task base 锡点落盘记录
657
+ if (ctx && hasCrossRepoWave) {
658
+ perTaskWorkdirs = wave.tasks.map((t, ti) => {
659
+ const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
660
+ const repo = taskRepos[ti]
661
+ const entry = ctx.resolve(repo)
662
+ const workdir = entry ? entry.worktreePath : (worktreePath || '')
663
+ return { taskNum, repo, workdir }
664
+ })
665
+
666
+ // D-010 跨仓 task base 锡点:派发前实时 git rev-parse HEAD 落 task 卡 base_commit
667
+ // 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)
668
+ for (const item of perTaskWorkdirs) {
669
+ if (item.repo === 'main') continue
670
+ const entry = ctx.resolve(item.repo)
671
+ if (!entry) continue
672
+ const baseCommit = gitQuiet(entry.gitDir, ['rev-parse', 'HEAD'])
673
+ if (!baseCommit) continue // git 不可达已由 MultiRepoContext 构造时 fail-closed 拦截,此处置防御
674
+ const taskFile = changeDir
675
+ ? path.join(changeDir, 'tasks', `task-${item.taskNum}.md`)
676
+ : null
677
+ const written = taskFile ? writeBaseCommitToTaskCard(taskFile, baseCommit) : false
678
+ baseCommitWrites.push({ taskNum: item.taskNum, repo: item.repo, taskFile, written, baseCommit })
679
+ }
680
+
681
+ // 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)
682
+ const crossLines = perTaskWorkdirs
683
+ .filter(i => i.repo !== 'main')
684
+ .map(i => `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\``)
685
+ .join('\n')
686
+ crossRepoCommitSection = `
687
+ ### 跨仓 task 派发与双锡点(必须严格遵守)
688
+
689
+ 本 Wave 含跨仓 task(直接改跨仓仓主干,**不经主仓 worktree**):
690
+
691
+ ${crossLines}
692
+
693
+ **派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
694
+ - 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
695
+
696
+ **回收跨仓 task review 前(head 锡点,你必须在子代理完成后落盘):**
697
+ - 跨仓 task 子代理完成 commit 后、写 review.json / 勾选 checkbox **之前**,你必须运行 \`git -C <跨仓仓根> rev-parse HEAD\` 把结果写入该 task 卡 frontmatter \`head_commit:\` 字段(与 \`base_commit:\` 同源 frontmatter 锡点写入)。
698
+ - review.json 的 \`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`(**非瞬时 HEAD**),锁 diff 范围不漂移。
699
+
700
+ **跨仓 task 子代理 prompt 必须注入:**
701
+ > 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
702
+ `
703
+ }
704
+
705
+ // worktreeSection:无 ctx / 无跨仓 task → 旧单值(零回归);有 ctx 含跨仓 task → per-task 多值表
706
+ let worktreeSection
707
+ if (ctx && hasCrossRepoWave && perTaskWorkdirs) {
708
+ const workdirLines = perTaskWorkdirs.map(i =>
709
+ ` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
710
+ ).join('\n')
711
+ worktreeSection = `
712
+ ### 工作目录(必须严格遵守,per-task)
713
+
714
+ 调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
715
+
716
+ **per-task workdir 表(每个子代理按其 task 对应的 workdir 启动):**
717
+
718
+ ${workdirLines}
719
+
720
+ 不传 workdir 或传错 workdir 会导致:主仓 task 写到跨仓仓 / 跨仓 task 写到主仓 worktree,破坏隔离与 apply 归属。
721
+
722
+ \`\`\`json
723
+ {
724
+ "subagent_type": "general",
725
+ "workdir": "<按上方 per-task 表选>",
726
+ "prompt": "在此编写任务描述..."
727
+ }
728
+ \`\`\`
729
+
730
+ ### 注意
731
+ 蓝图文件(tasks.md / design.md / proposal.md / requirements.md / tasks/task-XX.md)在主工作区 .sillyspec/changes/<change>/ 下,它们不在跨仓仓也不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree / 跨仓仓路径下。
732
+ `
733
+ } else {
734
+ worktreeSection = (worktreePath)
735
+ ? `
563
736
  ### 工作目录(必须严格遵守)
564
737
 
565
738
  调用 Task 工具启动子代理时,**workdir 参数是强制必传的**。
@@ -576,7 +749,8 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
576
749
  ### 注意
577
750
  蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
578
751
  `
579
- : ''
752
+ : ''
753
+ }
580
754
 
581
755
  // ── 派发后端段注入(task-07,D-006 / D-007 / D-008 接入)──
582
756
  // 同步判定(不发网络,零回归关键):local(无 MCP 配置)→ dispatchSection='' → 本 prompt 与改前
@@ -616,7 +790,7 @@ ${prototypes.map(p => `- \`${path.join(protoRelDir, p)}\``).join('\n')}
616
790
  3. 勾选 plan.md 中的 checkbox
617
791
  4. 记录改动文件和测试结果
618
792
 
619
- ${worktreeSection}${dispatchSection}
793
+ ${worktreeSection}${crossRepoCommitSection}${dispatchSection}
620
794
  ### 任务摘要(按需读取完整蓝图)
621
795
  为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
622
796
 
@@ -680,7 +854,7 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
680
854
 
681
855
  **review.json 必填字段:**
682
856
 
683
- { "name_zh": "任务评审", "schemaVersion": 1, "task": "task-XX", "base": "<git-base-commit>", "head": "<git-head-commit>",
857
+ { "name_zh": "任务评审", "schemaVersion": {REVIEW_SCHEMA_VERSION}, "task": "task-XX", "base": "<git-base-commit>", "head": "<git-head-commit>",
684
858
  "changedFiles": ["src/foo.js"], "specVerdict": "pass|fail|cannot_verify",
685
859
  "qualityVerdict": "pass|fail|cannot_verify", "reviewerNotes": "评审说明",
686
860
  "requiredEvidence": [] }
@@ -729,10 +903,14 @@ export function buildExecuteSteps(planFilePath = null, options = {}) {
729
903
  // 尝试获取 worktree 路径(可能由前缀步骤创建)
730
904
  const worktreePath = options.worktreePath || null
731
905
 
906
+ // W3 task-08:ctx 由 execute 启动入口(task-09)透传,进程级单例贯穿 execute/apply/verify
907
+ // (D-013)。缺省=null → buildWavePrompt 退化为单仓单 worktreePath(零回归)。
908
+ const ctx = options.ctx || null
909
+
732
910
  const waveSteps = waves.map((wave, i) => ({
733
911
  name: `Wave ${i + 1} 执行`,
734
912
  mode: 'implementation',
735
- prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch }),
913
+ prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath, { dispatchMode: options.dispatchMode, branch: options.branch, ctx }),
736
914
  outputHint: `Wave ${i + 1} 执行结果`,
737
915
  optional: false
738
916
  }))