sillyspec 3.26.1 → 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/.claude/skills/sillyspec-execute/SKILL.md +38 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +48 -0
- package/package.json +1 -1
- package/src/classify-change.js +41 -5
- package/src/config-schema.js +287 -0
- package/src/db.js +1 -1
- package/src/hooks/worktree-guard.js +3 -2
- package/src/index.js +96 -7
- package/src/init.js +13 -0
- package/src/machine-interface.js +11 -4
- package/src/run/command.js +4 -2
- package/src/run/complete.js +27 -4
- package/src/run/gates.js +26 -14
- package/src/run/multi-repo-context.js +206 -0
- package/src/run/prompt.js +15 -3
- package/src/run/quick-audit.js +12 -3
- package/src/run/shared.js +179 -6
- package/src/stage-review.js +1 -0
- package/src/stages/execute.js +185 -7
- package/src/stages/plan-postcheck.js +266 -11
- package/src/stages/plan.js +5 -0
- package/src/sync.js +125 -40
- package/src/task-review.js +135 -25
- package/src/verify-postcheck.js +242 -11
- package/src/worktree-apply.js +137 -9
- package/.sillyspec-platform-cleaned +0 -1
- package/.sillyspec-platform.json +0 -7
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 读取模块上下文索引
|
|
@@ -41,9 +42,9 @@ function loadModuleContextIndex(specBase, projectName) {
|
|
|
41
42
|
// 提示 v1/v2 格式混用风险(旧格式字段解析会静默错位)。正常生成的文件不触发。
|
|
42
43
|
const sv = content.match(/^schema_version:\s*(\d+)/m)
|
|
43
44
|
if (!sv) {
|
|
44
|
-
console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}
|
|
45
|
+
console.warn(`⚠️ _module-map.yaml 缺少 schema_version 声明(期望 2),模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
|
|
45
46
|
} else if (sv[1] !== '2') {
|
|
46
|
-
console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}
|
|
47
|
+
console.warn(`⚠️ _module-map.yaml schema_version=${sv[1]}(期望 2),可能为旧格式,模块解析可能错位:${mapPath}(跑 \`sillyspec modules rebuild\` 升级到 schema_version: 2 可消除此警告)`)
|
|
47
48
|
}
|
|
48
49
|
return parseModuleMapSimple(content)
|
|
49
50
|
} catch {
|
|
@@ -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)
|
|
@@ -452,7 +458,13 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
452
458
|
} catch {}
|
|
453
459
|
if (!reviewRunId) {
|
|
454
460
|
reviewRunId = generateStageReviewRunId()
|
|
455
|
-
try {
|
|
461
|
+
try {
|
|
462
|
+
mkdirSync(tierRuntimeRoot, { recursive: true })
|
|
463
|
+
writeFileSync(reviewRunIdMarker, reviewRunId + '\n')
|
|
464
|
+
// plan-c: echo 完整 review 目录路径,避免 agent 拿裸 runId 给子代理时漏连字符拼错路径
|
|
465
|
+
const reviewDir = `${tierRuntimeRoot}/stage-reviews/${stageName}-${reviewRunId}`
|
|
466
|
+
console.log(` 📁 Stage Review 写入目录(直接复制给 review 子代理,勿手拼 runId):${reviewDir}/`)
|
|
467
|
+
} catch {}
|
|
456
468
|
}
|
|
457
469
|
// review.json 产物契约(schema + 示例 + docHash 算法 + 重算提示)事前注入,与 validateStageReviewSchema 同源
|
|
458
470
|
const reviewContractMd = renderReviewJsonContract({ stage: stageName, changeDir: tierChangeDir, reviewRunId, tier: tier.tier })
|
package/src/run/quick-audit.js
CHANGED
|
@@ -24,9 +24,18 @@ export function printQuickAuditReview(review) {
|
|
|
24
24
|
console.error(`\n quick 已停止:请恢复/拆分这些变更,或重新运行 quick 并显式声明范围。`)
|
|
25
25
|
// 解锁咒语须在 BLOCKED 分支也给出 —— 原只在 confirm/review 分支打印,
|
|
26
26
|
// 普通 --done 走这里只看到「恢复/拆分」反方向指引,正确解法(带 flag 重跑)根本不出现。
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// 但删除是例外:deletedFiles.length>0 是裸判定(auditQuickCompletion 判定行不受任何 flag
|
|
28
|
+
// 门控),--force-baseline / --allow-new 都救不回。此时甩 flag 建议会误导调用者照加后继续
|
|
29
|
+
// 被拦——改提示走完整流程 execute(有意设计:删除是高危操作,须 review 把关)。
|
|
30
|
+
if (review.deletedFiles && review.deletedFiles.length > 0) {
|
|
31
|
+
console.error(`\n ⛔ 本次含删除文件 ${review.deletedFiles.length} 个:${review.deletedFiles.join(', ')}`)
|
|
32
|
+
console.error(` quick 不支持删除——--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
|
|
33
|
+
console.error(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
|
|
34
|
+
} else {
|
|
35
|
+
console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
36
|
+
console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
37
|
+
console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
38
|
+
}
|
|
30
39
|
} else if (review.status === 'warning') {
|
|
31
40
|
console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
|
|
32
41
|
for (const r of review.reasons) {
|
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 注入。
|
|
@@ -655,10 +657,15 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
655
657
|
console.log(` - ${r}`)
|
|
656
658
|
}
|
|
657
659
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
660
|
+
if (result.deletedFiles.length > 0) {
|
|
661
|
+
console.log(`\n ⛔ 本次含删除文件 ${result.deletedFiles.length} 个——quick 不支持删除,--force-baseline / --allow-new 均无法解锁(有意设计,高危操作须走 review 把关)。`)
|
|
662
|
+
console.log(` 请改走完整流程:sillyspec run execute(在 plan 的 task allowed_paths 内删除),或先 git restore 撤回删除后再 --done。`)
|
|
663
|
+
} else {
|
|
664
|
+
console.log(`\n 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
|
|
665
|
+
console.log(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
|
|
666
|
+
console.log(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件)`)
|
|
667
|
+
console.log(` 或在首个 sillyspec run quick 启动(step 1)时就声明这些 flag,持久化进 guard。`)
|
|
668
|
+
}
|
|
662
669
|
}
|
|
663
670
|
} catch (e) {
|
|
664
671
|
result.reasons.push(`审计失败: ${e.message}`)
|
|
@@ -684,6 +691,158 @@ export function formatWaitOptions(raw) {
|
|
|
684
691
|
}
|
|
685
692
|
|
|
686
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
|
+
|
|
687
846
|
/**
|
|
688
847
|
* 获取阶段的步骤定义(execute 需要动态构建)
|
|
689
848
|
*/
|
|
@@ -692,14 +851,15 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
|
692
851
|
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
693
852
|
let planFile = null
|
|
694
853
|
let worktreePath = null
|
|
854
|
+
let changeName = null
|
|
695
855
|
if (changeDir) {
|
|
696
856
|
const p = join(changeDir, 'plan.md')
|
|
697
857
|
if (existsSync(p)) planFile = p
|
|
858
|
+
changeName = basename(changeDir)
|
|
698
859
|
// 自动检测 worktree 路径,注入 Wave prompt 的 workdir 指令
|
|
699
860
|
// 修复:之前未传 worktreePath 给 buildExecuteSteps,导致 Wave prompt 缺失工作目录指令,
|
|
700
861
|
// 子代理可能把文件写到主工作区而非 worktree 内,破坏隔离。
|
|
701
862
|
try {
|
|
702
|
-
const changeName = basename(changeDir)
|
|
703
863
|
const { WorktreeManager } = await import('../worktree.js')
|
|
704
864
|
const wm = new WorktreeManager({ cwd })
|
|
705
865
|
const meta = wm.getMeta(changeName)
|
|
@@ -710,7 +870,20 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
|
710
870
|
// 无 worktree meta 不阻断——可能是首次启动或 in-place 模式
|
|
711
871
|
}
|
|
712
872
|
}
|
|
713
|
-
|
|
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 })
|
|
714
887
|
}
|
|
715
888
|
if (stageName === 'plan') {
|
|
716
889
|
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
package/src/stage-review.js
CHANGED
|
@@ -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) {
|
package/src/stages/execute.js
CHANGED
|
@@ -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
|
-
|
|
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":
|
|
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
|
}))
|