sillyspec 3.27.7 → 3.27.11
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 +1 -1
- package/.npmrc-temp +1 -0
- package/package.json +1 -1
- package/src/commit-suggest.js +22 -0
- package/src/contract-matrix.js +59 -15
- package/src/decision-distill.js +33 -14
- package/src/docs-check.js +12 -2
- package/src/facade-hint.js +170 -0
- package/src/index.js +113 -15
- package/src/progress/stage-machine.js +3 -1
- package/src/run/command.js +81 -15
- package/src/run/complete-handlers.js +34 -0
- package/src/run/complete.js +10 -1
- package/src/run/gates.js +100 -8
- package/src/run/multi-repo-context.js +42 -4
- package/src/run/shared.js +50 -24
- package/src/run/stage.js +49 -3
- package/src/spec-sync.js +88 -1
- package/src/stage-contract.js +20 -7
- package/src/stages/execute.js +37 -16
- package/src/stages/plan.js +8 -0
- package/src/task-review.js +7 -0
- package/src/verify-postcheck.js +47 -6
- package/src/worktree-apply.js +254 -4
- package/src/worktree-cross.js +294 -0
- package/src/worktree-deps.js +156 -58
- package/templates/prompts/verify-probes.md +2 -1
package/src/stages/execute.js
CHANGED
|
@@ -1006,14 +1006,19 @@ ${indexLines}
|
|
|
1006
1006
|
return { taskNum, repo, workdir }
|
|
1007
1007
|
})
|
|
1008
1008
|
|
|
1009
|
-
// D-010 跨仓 task base
|
|
1010
|
-
// 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext
|
|
1009
|
+
// D-010 跨仓 task base 锡点:派发前落 task 卡 base_commit
|
|
1010
|
+
// 仅对跨仓 task(repo≠'main')落盘,主仓 task 锚 meta.baseHash(MultiRepoContext 已处理)。
|
|
1011
|
+
// worktree 模式(跨仓 worktree 隔离,坑 cross-repo-no-worktree-isolation)优先 entry.baseCommitHint
|
|
1012
|
+
//(= meta.baseHash 创建时快照)——worktree HEAD 会随子代理 commit 推进,rev-parse HEAD 当 base
|
|
1013
|
+
// 会把 Wave 内前序 task 的交付错算进后续 task 的 base;legacy 直写模式沿用跨仓根实时 HEAD。
|
|
1011
1014
|
const baseCommitCache = new Map() // 同仓多 task 只 spawn 一次
|
|
1012
1015
|
for (const item of perTaskWorkdirs) {
|
|
1013
1016
|
if (item.repo === 'main') continue
|
|
1014
1017
|
const entry = ctx.resolve(item.repo)
|
|
1015
1018
|
if (!entry) continue
|
|
1016
|
-
if (!baseCommitCache.has(entry.gitDir))
|
|
1019
|
+
if (!baseCommitCache.has(entry.gitDir)) {
|
|
1020
|
+
baseCommitCache.set(entry.gitDir, entry.baseCommitHint || gitQuiet(entry.gitDir, ['rev-parse', 'HEAD']))
|
|
1021
|
+
}
|
|
1017
1022
|
const baseCommit = baseCommitCache.get(entry.gitDir)
|
|
1018
1023
|
if (!baseCommit) continue // git 不可达已由 MultiRepoContext 构造时 fail-closed 拦截,此处置防御
|
|
1019
1024
|
const taskFile = changeDir
|
|
@@ -1023,28 +1028,43 @@ ${indexLines}
|
|
|
1023
1028
|
baseCommitWrites.push({ taskNum: item.taskNum, repo: item.repo, taskFile, written, baseCommit })
|
|
1024
1029
|
}
|
|
1025
1030
|
|
|
1026
|
-
// 跨仓 task commit 指引 + head_commit 落盘指引(D-010
|
|
1031
|
+
// 跨仓 task commit 指引 + head_commit 落盘指引(D-010 回收时机)。
|
|
1032
|
+
// worktree 模式(跨仓 worktree 隔离):子代理在跨仓 worktree 分支改+commit,apply 阶段 CLI
|
|
1033
|
+
// 统一 patch 回跨仓主工作区(与主仓同构);legacy 直写模式保留原文案(commit 直落跨仓主干)。
|
|
1034
|
+
const crossWorktreeItems = perTaskWorkdirs.filter(i => {
|
|
1035
|
+
const e = i.repo !== 'main' ? ctx.resolve(i.repo) : null
|
|
1036
|
+
return e && e.isWorktree
|
|
1037
|
+
})
|
|
1038
|
+
const crossLegacyItems = perTaskWorkdirs.filter(i => i.repo !== 'main' && !crossWorktreeItems.some(w => w.taskNum === i.taskNum))
|
|
1027
1039
|
const crossLines = perTaskWorkdirs
|
|
1028
1040
|
.filter(i => i.repo !== 'main')
|
|
1029
|
-
.map(i =>
|
|
1041
|
+
.map(i => {
|
|
1042
|
+
const e = ctx.resolve(i.repo)
|
|
1043
|
+
return `- task-${i.taskNum} → repo \`${i.repo}\`,workdir=\`${i.workdir}\`${e && e.isWorktree ? '(worktree 隔离)' : ''}`
|
|
1044
|
+
})
|
|
1030
1045
|
.join('\n')
|
|
1031
|
-
|
|
1032
|
-
### 跨仓 task 派发与双锡点
|
|
1033
|
-
|
|
1034
|
-
本 Wave 含跨仓 task(直接改跨仓仓主干,**不经主仓 worktree**):
|
|
1046
|
+
const crossWorktreeSection = crossWorktreeItems.length > 0 ? `
|
|
1035
1047
|
|
|
1036
|
-
|
|
1048
|
+
**跨仓 worktree task(repo 已建 worktree 隔离)——像主仓 task 一样工作:**
|
|
1049
|
+
- 子代理在 workdir(跨仓 worktree)内改+commit(git add + git commit 到 worktree 当前分支),**不要碰跨仓主工作副本**。
|
|
1050
|
+
- apply 阶段 CLI 统一把各跨仓 worktree 的交付 patch 回对应跨仓主工作区(勿手工 apply)。
|
|
1051
|
+
- base 已锚 worktree meta.baseHash(创建时快照),review 的 base/head 由 CLI 按 task 卡锡点解析。` : ''
|
|
1052
|
+
const crossLegacySection = crossLegacyItems.length > 0 ? `
|
|
1037
1053
|
|
|
1038
1054
|
**派发跨仓 task 前(base 锡点,CLI 已在 prompt 构造时落盘):**
|
|
1039
1055
|
- 跨仓 task 卡 frontmatter 的 \`base_commit\` 已由 CLI 实时 \`git -C <跨仓仓根> rev-parse HEAD\` 锁定(base 锡点,约束①)。子代理在此 HEAD 上改+commit,不受同 Wave 其他 task 推进 HEAD 影响。
|
|
1040
1056
|
|
|
1057
|
+
**跨仓 task 子代理 prompt 必须注入(legacy 直写模式):**
|
|
1058
|
+
> 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
|
|
1059
|
+
> task 卡 allowed_paths 是相对**该仓根**的路径(如 \`src/routes/x.js\`)——不带仓库名前缀(\`<repo>/src/...\` ❌)、不是绝对路径;在 workdir 下直接按相对路径定位文件。` : ''
|
|
1060
|
+
crossRepoCommitSection = `
|
|
1061
|
+
### 跨仓 task 派发与双锡点
|
|
1062
|
+
|
|
1063
|
+
${crossLines}${crossWorktreeSection}${crossLegacySection}
|
|
1064
|
+
|
|
1041
1065
|
**回收跨仓 task(head 锡点,CLI 自动落盘,勿手写):**
|
|
1042
|
-
- 子代理完成 commit 后,正常写 review.json(verdict/notes)并勾选 checkbox 即可。execute \`--done\` 时 CLI
|
|
1066
|
+
- 子代理完成 commit 后,正常写 review.json(verdict/notes)并勾选 checkbox 即可。execute \`--done\` 时 CLI 自动对跨仓仓(worktree 模式=该仓 worktree,legacy=仓根)\`git rev-parse HEAD\` 写入该 task 卡 \`head_commit:\`(幂等,已存在不覆盖——你若手写了精确锚点则以你的为准)。
|
|
1043
1067
|
- review.json 的 mechanics 字段(\`base\`/\`head\`/\`changedFiles\`)无需手算:\`base\` 取 task 卡 \`base_commit\`、\`head\` 取 task 卡 \`head_commit\`,写完跑 \`sillyspec backfill-reviews --change <变更名> --adopt\` 一键代填(verdict 保留)。
|
|
1044
|
-
|
|
1045
|
-
**跨仓 task 子代理 prompt 必须注入:**
|
|
1046
|
-
> 该 task 改的是 \`<repo>\` 仓,workdir=\`<跨仓仓根>\`。**直接在该仓主干工作区改+commit(git add + git commit 到该仓主干),不经主仓 worktree、不建分支。** commit 到该仓主干即落盘,apply 阶段对跨仓 task 为 no-op(design §5.4 G1)。
|
|
1047
|
-
> task 卡 allowed_paths 是相对**该仓根**的路径(如 \`src/routes/x.js\`)——不带仓库名前缀(\`<repo>/src/...\` ❌)、不是绝对路径;在 workdir 下直接按相对路径定位文件。
|
|
1048
1068
|
`
|
|
1049
1069
|
}
|
|
1050
1070
|
|
|
@@ -1245,10 +1265,11 @@ task-XX 对应:{SPEC_ROOT}/.runtime/execute-runs/{EXECUTE_RUN_ID}/tasks/task-X
|
|
|
1245
1265
|
|
|
1246
1266
|
### 完成后
|
|
1247
1267
|
1. 为每个后端 router task 生成 API 端点 artifact——**用 CLI 命令,勿手扫装饰器手写(易漏 endpoint)**:
|
|
1248
|
-
-
|
|
1268
|
+
- 聚合一次跑全:sillyspec endpoints extract --change <change> --all-tasks(逐 task 卡 allowed_paths 各自提取落盘,<change> = 当前变更名;与 verify 探针 5 聚合对账口径一致)
|
|
1249
1269
|
- CLI 静态扫描变更文件(FastAPI @router.* / Express router.* / Spring @*Mapping)生成
|
|
1250
1270
|
{SPEC_ROOT}/.runtime/contract-artifacts/<task-name>/endpoints.json
|
|
1251
1271
|
- 格式: { "task": "task-XX", "type": "backend_endpoints", "endpoints": [{ "method": "GET", "path": "/api/ppm/xxx" }] }(CLI 已按此格式写好,verify 探针 5 直接消费)
|
|
1272
|
+
- 只提单 task(--task task-NN)会让探针 5 用局部端点集对账放大假 missing——多 task 变更必用 --all-tasks
|
|
1252
1273
|
`
|
|
1253
1274
|
}
|
|
1254
1275
|
|
package/src/stages/plan.js
CHANGED
|
@@ -137,6 +137,12 @@ const stepGeneratePlan = {
|
|
|
137
137
|
4. **写回任务清单**:把展开后的任务清单写回 tasks.md(checkbox 行 \`- [ ] task-XX: 一句话任务名\`,可附 \`[model:xxx]\`/\`(depends_on: task-01,02)\` 行内标注)。**写回规则(D-002@v1)**:保留 frontmatter/中文标题/所有非 task-XX 行(quick 挂载的 ql-xxx 勾选行、注记等逐行保留),仅重写 task-XX checkbox 行集合——防摧毁 quick 挂载条目
|
|
138
138
|
5. 保存 plan.md(审查在下一步"审查计划"独立进行,不在本步自审——避免生成与自审同一次输出);frontmatter 保留 plan_level 字段不删。**plan.md Wave 段下任务一律纯 ID 引用行**(\`- task-XX\`,不重抄任务名——任务名唯一真相在 tasks.md,重抄即双写漂移)
|
|
139
139
|
|
|
140
|
+
> ⚠️ **Wave 段格式铁律(踩坑:旧先例 \`- [ ] task-XX: 描述\` 格式会导致 postcheck 告警):**
|
|
141
|
+
> ✅ 正确:\`- task-01\`(裸 ID 引用行,无 checkbox 无任务名)
|
|
142
|
+
> ❌ 错误:\`- [ ] task-01: 添加用户创建接口\`(checkbox + 任务名——这是 tasks.md 格式,不是 plan.md Wave 段格式)
|
|
143
|
+
> tasks.md 格式:\`- [ ] task-01: 一句话任务名\`(execute 从这里解析任务清单)
|
|
144
|
+
> plan.md Wave 段格式:\`- task-01\`(纯 ID 引用,execute 据此分组)
|
|
145
|
+
|
|
140
146
|
---
|
|
141
147
|
|
|
142
148
|
#### plan_level = none
|
|
@@ -446,6 +452,8 @@ related_tests: # 可选。当本 task 改动会导致
|
|
|
446
452
|
- **task id 从 1 连续**:task-01、task-02、task-03… 不能跳号或重号(postcheck 校验 id 连续性,gap 会拦)
|
|
447
453
|
这两条是跨 task 全局约束,子代理只写单卡看不到全局——你(主 agent)分派子代理前必须先在 plan.md 里确认 Wave 划分与编号正确。
|
|
448
454
|
|
|
455
|
+
⚠️ **TaskCard 必备字段(缺一 postcheck 直接阻断):** id、title(英文)、**title_zh(中文标题,必填)**、allowed_paths、goal、implementation、acceptance、verify、constraints。骨架已由 taskcard CLI 预生成(含 title_zh 占位),子代理只需 Edit 填值,不要删除任何必备字段。
|
|
456
|
+
|
|
449
457
|
## 任务清单
|
|
450
458
|
${taskList}
|
|
451
459
|
|
package/src/task-review.js
CHANGED
|
@@ -1107,11 +1107,18 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1107
1107
|
const reviewPath = join(reviewDir, 'review.json')
|
|
1108
1108
|
|
|
1109
1109
|
// 幂等:review.json 已存在(无论合法/解析错/schema 错)一律跳过,绝不覆盖人工/子代理 verdict
|
|
1110
|
+
// 双重守卫:readReview 判定 + existsSync 兜底(坑 task-review-draft-overwrite:已存在真实
|
|
1111
|
+
// review 仍被草稿覆盖的变体——readReview 路径/权限异常时 existsSync 作为最后防线)
|
|
1110
1112
|
const existing = readReview(reviewPath)
|
|
1111
1113
|
if (existing.ok || existing.parseError || existing.schemaError) {
|
|
1112
1114
|
skipped++
|
|
1113
1115
|
continue
|
|
1114
1116
|
}
|
|
1117
|
+
if (existsSync(reviewPath)) {
|
|
1118
|
+
// readReview 未捕获但文件存在(极端:权限/路径长度等)→ 宁跳不覆盖
|
|
1119
|
+
skipped++
|
|
1120
|
+
continue
|
|
1121
|
+
}
|
|
1115
1122
|
|
|
1116
1123
|
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
1117
1124
|
const allowedPaths = parseAllowedPaths(content)
|
package/src/verify-postcheck.js
CHANGED
|
@@ -579,10 +579,21 @@ export function extractKnownFailures(yamlText) {
|
|
|
579
579
|
if (inline) {
|
|
580
580
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
581
581
|
}
|
|
582
|
-
|
|
582
|
+
// 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次):块内注释行/空行
|
|
583
|
+
// 打断「连续列表项」的捕获链 → 只取到注释前的项,注释后的项静默丢失 → 清单残缺、
|
|
584
|
+
// verify 实测 remaining>0 假红。块定义放行注释行与空行(提取侧本就只认 `- ` 项行,
|
|
585
|
+
// 吸收进块的注释不影响结果)。
|
|
586
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
|
|
583
587
|
if (block) {
|
|
584
588
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
585
|
-
.map(s =>
|
|
589
|
+
.map(s => {
|
|
590
|
+
const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
|
|
591
|
+
const full = item.match(/^(['"])(.*)\1$/)
|
|
592
|
+
if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
|
|
593
|
+
const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
|
|
594
|
+
const q2 = stripped.match(/^(['"])(.*)\1$/)
|
|
595
|
+
return q2 ? q2[2] : stripped
|
|
596
|
+
})
|
|
586
597
|
.filter(Boolean)
|
|
587
598
|
}
|
|
588
599
|
return []
|
|
@@ -591,9 +602,25 @@ export function extractKnownFailures(yamlText) {
|
|
|
591
602
|
// 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
|
|
592
603
|
// 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
|
|
593
604
|
// "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
605
|
+
// ×(U+00D7) 是 vitest 失败行的实际前缀标记(✕✗✘ 是 jest/mocha 形态)。
|
|
606
|
+
const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|×|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
|
|
607
|
+
// 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、vitest "Test Files N failed"/"Tests N failed | M passed"
|
|
608
|
+
// (无冒号形态)、裸 "N failed/passed"、go "FAILED in Ns"、pnpm "ELIFECYCLE Test failed" 退出横幅等。
|
|
609
|
+
const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|^\s*(?:test\s+files|tests?)\s+\d|failed\s+in\s+\d|failed\s+tests\s+\d|elifecycle)/i
|
|
610
|
+
// 控制台捕获噪声行(非失败信号):vitest 把测试内 console 输出捕获为
|
|
611
|
+
// `stderr | <file> > <测试名>` 横幅 + 后续内容行——挂在通过用例上的噪声里常含
|
|
612
|
+
// error:/failed 字样(jsdom「Not implemented」警告、用例名本身带 failed 等),
|
|
613
|
+
// 与失败信号(× 行 / FAIL 横幅 / 汇总计数)无关,全部剔除。
|
|
614
|
+
const CONSOLE_CAPTURE_RE = /^\s*(?:stdout|stderr)\s*\|/
|
|
615
|
+
const ENV_NOISE_RE = /not\s+implemented\s*:/i
|
|
616
|
+
// 通过行(行首标记,剥 ANSI 后判定):vitest/jest/mocha 的 ✓√✔ 前缀行 + jest 的 PASS 文件行。
|
|
617
|
+
// 坑 verify-known-failures-pass-line-false-positive:FAILED/error:/exception 是子串匹配,
|
|
618
|
+
// 通过行用例名恰含这些字样(如「✓ … 超时后 syncStatus=failed」)会被误判失败行——
|
|
619
|
+
// 2710 用例套件 382 个"失败行"里 378 假阳性,known_failures 无法逐条枚举而实质失效。
|
|
620
|
+
// 框架输出里通过行恒以通过标记开头、失败行恒以失败标记开头,行首判定即足以分离两类。
|
|
621
|
+
const PASS_LINE_RE = /^\s*(?:[✓√✔]|PASS\b)/
|
|
622
|
+
// ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
|
|
623
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
|
|
597
624
|
|
|
598
625
|
/**
|
|
599
626
|
* 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
|
|
@@ -603,7 +630,15 @@ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)
|
|
|
603
630
|
*/
|
|
604
631
|
export function partitionFailures(output, knownFailures) {
|
|
605
632
|
const lines = String(output || '').split(/\r?\n/)
|
|
606
|
-
|
|
633
|
+
// 分类在剥 ANSI 后的行上做,返回保留原文(remaining 展示给 agent 时不变形)
|
|
634
|
+
const failureLines = lines.filter(l => {
|
|
635
|
+
if (PASS_LINE_RE.test(l)) return false
|
|
636
|
+
const bare = l.replace(ANSI_RE, '')
|
|
637
|
+
if (PASS_LINE_RE.test(bare)) return false
|
|
638
|
+
if (CONSOLE_CAPTURE_RE.test(bare)) return false
|
|
639
|
+
if (ENV_NOISE_RE.test(bare)) return false
|
|
640
|
+
return PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)
|
|
641
|
+
})
|
|
607
642
|
const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
|
|
608
643
|
const exempted = []
|
|
609
644
|
const remaining = []
|
|
@@ -1528,6 +1563,12 @@ export function printVerifyParityCheck(result) {
|
|
|
1528
1563
|
console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
|
|
1529
1564
|
}
|
|
1530
1565
|
if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
|
|
1566
|
+
// 全仓口径告警(坑 probe5-single-task-artifact-scope,2026-08-28 实证:diff/apply 清单都不可得
|
|
1567
|
+
// 时回退全仓,全仓调用 × 本变更局部端点 = 大量假 missing,agent 照单记 FAIL 误导归档判定)
|
|
1568
|
+
if (/\[scope: full-repo\]/.test(result.summary || '')) {
|
|
1569
|
+
console.warn(' ⚠️ 本次 scope 为 full-repo(diff 与 apply-pathspec 均不可得时的兜底)——missing 大概率是口径错配噪音而非真实 contract gap。')
|
|
1570
|
+
console.warn(' 先收窄复跑:sillyspec verify-probes --change <变更名>(change-diff/apply-pathspec 口径)再判定。')
|
|
1571
|
+
}
|
|
1531
1572
|
console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
|
|
1532
1573
|
}
|
|
1533
1574
|
|
package/src/worktree-apply.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync
|
|
|
16
16
|
import { join, resolve } from 'path';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
import { WorktreeManager } from './worktree.js';
|
|
19
|
+
import { getCrossWorktreeMeta, cleanupCrossWorktrees } from './worktree-cross.js';
|
|
19
20
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
20
21
|
import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
|
|
21
22
|
import { git, gitQuiet } from './git-helper.js';
|
|
@@ -440,6 +441,182 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
|
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 跨仓 worktree 真实 apply(坑 cross-repo-no-worktree-isolation,2026-08-27)。
|
|
446
|
+
*
|
|
447
|
+
* 对有 worktree meta 的跨仓仓(worktree-cross.js 创建)执行与主仓 A5 同构的 patch 回落:
|
|
448
|
+
* deliverables(diff baselineCommit||baseHash + untracked,filterDeliverableFiles 排除基础设施)
|
|
449
|
+
* → 清单校验(resolveApplyAllowSet 按 repo 切片 ∪ review 声明切片)
|
|
450
|
+
* → 跨仓主工作副本 dirty 重叠拦截
|
|
451
|
+
* → patch(tracked diff + untracked add/diff --cached/reset,Buffer 防二进制损坏)
|
|
452
|
+
* → git apply --3way 回跨仓主工作副本;成功后 cleanup 该跨仓 worktree(分支保留作锚)。
|
|
453
|
+
*
|
|
454
|
+
* 与主仓 apply 的顺序契约:本函数在主仓 patch 动作**之前**调用——任一跨仓失败即整体报错返回,
|
|
455
|
+
* 主仓工作区不动(不留半套状态)。legacy 直写模式的跨仓仓无 meta,不走本函数(原 no-op 校验)。
|
|
456
|
+
*
|
|
457
|
+
* @param {string} changeName
|
|
458
|
+
* @param {string} projectRoot 主仓根
|
|
459
|
+
* @param {object} ctx MultiRepoContext
|
|
460
|
+
* @param {{ checkOnly?: boolean }} opts
|
|
461
|
+
* @returns {{ repos: string[], applied: Array<{repoKey, changedFiles}>, errors: string[], warnings: string[], crossCleanups: string[] }}
|
|
462
|
+
*/
|
|
463
|
+
function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = false } = {}) {
|
|
464
|
+
const specBase = (ctx.platformOpts && ctx.platformOpts.specRoot) || join(projectRoot, '.sillyspec');
|
|
465
|
+
const out = { repos: [], applied: [], errors: [], warnings: [], crossCleanups: [] };
|
|
466
|
+
for (const [repoKey, entry] of ctx.repos) {
|
|
467
|
+
if (entry.isMain) continue;
|
|
468
|
+
const cm = getCrossWorktreeMeta(specBase, changeName, repoKey);
|
|
469
|
+
if (!cm || !cm.isCross || !cm.worktreePath || !existsSync(cm.worktreePath)) continue;
|
|
470
|
+
out.repos.push(repoKey);
|
|
471
|
+
const crossRoot = cm.crossRepoRoot || entry.projectRoot;
|
|
472
|
+
const deliverableBase = cm.baselineCommit || cm.baseHash;
|
|
473
|
+
|
|
474
|
+
// 交付集合(与主仓 step2 同口径:--name-status 捕 rename/delete + untracked,过滤基础设施文件)
|
|
475
|
+
let changedFiles = [];
|
|
476
|
+
const deletedFiles = [];
|
|
477
|
+
try {
|
|
478
|
+
const statusRaw = git(cm.worktreePath, ['diff', '--name-status', deliverableBase]);
|
|
479
|
+
const statusFiles = new Set();
|
|
480
|
+
if (statusRaw) {
|
|
481
|
+
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
482
|
+
const parts = line.split('\t');
|
|
483
|
+
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
484
|
+
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
485
|
+
if (parts[0].startsWith('D')) deletedFiles.push(parts[parts.length - 1]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const untrackedRaw = gitQuiet(cm.worktreePath, ['ls-files', '--others', '--exclude-standard']) || '';
|
|
489
|
+
changedFiles = filterDeliverableFiles([...new Set([...statusFiles, ...untrackedRaw.split('\n').filter(Boolean)])]);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
out.errors.push(`跨仓 ${repoKey}:获取变更文件列表失败(${cm.worktreePath}): ${e.message}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (changedFiles.length === 0) {
|
|
496
|
+
if (!checkOnly) {
|
|
497
|
+
const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
|
|
498
|
+
for (const r of cr.results) {
|
|
499
|
+
if (r.result === 'cleaned') out.crossCleanups.push(`${repoKey}(无变更,worktree 已清理)`);
|
|
500
|
+
else out.warnings.push(`跨仓 ${repoKey} 无变更但清理 partial:${(r.details || []).join('; ')}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
out.applied.push({ repoKey, changedFiles: [] });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 清单校验(per-repo 切片 + review 声明切片并入,与主仓 Gate1/3b 同语义)
|
|
508
|
+
const allowMap = resolveApplyAllowSet(projectRoot, changeName);
|
|
509
|
+
let allowSet = allowMap.get(repoKey) || new Set();
|
|
510
|
+
const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName).get(repoKey);
|
|
511
|
+
if (reviewDeclared) {
|
|
512
|
+
const merged = new Set([...allowSet]);
|
|
513
|
+
for (const f of reviewDeclared) {
|
|
514
|
+
if (![...merged].some(ap => pathMatches(f, ap))) merged.add(f);
|
|
515
|
+
}
|
|
516
|
+
allowSet = merged;
|
|
517
|
+
}
|
|
518
|
+
if (allowSet.size > 0) {
|
|
519
|
+
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
520
|
+
if (violations.length > 0) {
|
|
521
|
+
out.errors.push(
|
|
522
|
+
`跨仓 ${repoKey}:文件清单校验失败——以下变更文件不在 design.md 清单、也不在该仓 task review.json changedFiles 声明中:\n ${violations.join('\n ')}`
|
|
523
|
+
);
|
|
524
|
+
if (!checkOnly) continue;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (checkOnly) {
|
|
529
|
+
out.applied.push({ repoKey, changedFiles });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 跨仓主工作副本 dirty 重叠拦截(与主仓 step4.5 同语义:重叠 = git apply 无法安全应用)
|
|
534
|
+
try {
|
|
535
|
+
const dirtyFiles = [...new Set(
|
|
536
|
+
((gitQuiet(crossRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
537
|
+
.concat((gitQuiet(crossRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
538
|
+
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
539
|
+
const overlap = dirtyFiles.filter(f => changedFiles.includes(f));
|
|
540
|
+
if (overlap.length > 0) {
|
|
541
|
+
out.errors.push(
|
|
542
|
+
`跨仓 ${repoKey}(${crossRoot})主工作副本以下未提交文件与本次 apply 重叠,无法安全应用:\n ${overlap.join('\n ')}\n` +
|
|
543
|
+
`请先在跨仓仓提交或 stash 这些改动后重试 apply。`
|
|
544
|
+
);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
} catch (e) {
|
|
548
|
+
out.errors.push(`跨仓 ${repoKey}:dirty 重叠检测失败(${crossRoot}): ${e.message}`);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// patch 生成 + apply --3way(tracked/untracked 分流与 Buffer 聚合,与主仓 step7 同款)
|
|
553
|
+
const patchFiles = allowSet.size > 0
|
|
554
|
+
? resolvePatchFiles(changedFiles, allowSet, true)
|
|
555
|
+
: changedFiles;
|
|
556
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-cross-'));
|
|
557
|
+
const patchPath = join(tmpDir, 'apply.patch');
|
|
558
|
+
try {
|
|
559
|
+
const inTree = new Set(
|
|
560
|
+
(gitQuiet(cm.worktreePath, ['ls-tree', '-r', '--name-only', deliverableBase]) || '').split('\n').filter(Boolean)
|
|
561
|
+
);
|
|
562
|
+
const inIndexRaw = patchFiles.length > 0
|
|
563
|
+
? (gitQuiet(cm.worktreePath, ['ls-files', '--', ...patchFiles]) || '')
|
|
564
|
+
: '';
|
|
565
|
+
const inIndex = new Set(inIndexRaw.split('\n').filter(Boolean));
|
|
566
|
+
const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
|
|
567
|
+
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
568
|
+
const patchParts = [];
|
|
569
|
+
if (trackedFiles.length > 0) {
|
|
570
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...trackedFiles], { encoding: 'buffer', timeout: 60000 }));
|
|
571
|
+
}
|
|
572
|
+
if (untrackedPatchFiles.length > 0) {
|
|
573
|
+
git(cm.worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
574
|
+
try {
|
|
575
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...untrackedPatchFiles], { encoding: 'buffer', timeout: 60000 }));
|
|
576
|
+
} finally {
|
|
577
|
+
gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const patchContent = Buffer.concat(patchParts);
|
|
581
|
+
if (patchContent.length === 0) {
|
|
582
|
+
out.applied.push({ repoKey, changedFiles: [] });
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
writeFileSync(patchPath, patchContent);
|
|
586
|
+
try {
|
|
587
|
+
git(crossRoot, ['apply', '--3way', patchPath], { timeout: 30000 });
|
|
588
|
+
} catch (e) {
|
|
589
|
+
// 冲突回滚:tracked 文件还原 HEAD 版,--3way 新建的部分文件删除(与主仓 rollbackApply 同语义)
|
|
590
|
+
const rawStderr = e.message || '';
|
|
591
|
+
for (const f of patchFiles) {
|
|
592
|
+
try {
|
|
593
|
+
if (gitQuiet(crossRoot, ['cat-file', '-e', `HEAD:${f}`]) !== null) {
|
|
594
|
+
gitQuiet(crossRoot, ['checkout', 'HEAD', '--', f]);
|
|
595
|
+
} else {
|
|
596
|
+
try { rmSync(join(crossRoot, f), { force: true }) } catch { /* 新文件删除失败留残,rescue 提示 */ }
|
|
597
|
+
}
|
|
598
|
+
} catch { /* 回滚尽力而为 */ }
|
|
599
|
+
}
|
|
600
|
+
out.errors.push(
|
|
601
|
+
`跨仓 ${repoKey}:apply --3way 失败(已回滚跨仓主工作副本到 apply 前状态):${rawStderr}\n` +
|
|
602
|
+
`可选:进 ${crossRoot} 手工合并 worktree 分支 ${cm.branch}(git merge ${cm.branch}),完成后手动清理跨仓 worktree。`
|
|
603
|
+
);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
out.applied.push({ repoKey, changedFiles: patchFiles });
|
|
607
|
+
// 成功 → 清理该跨仓 worktree(分支保留作 review 锚点)
|
|
608
|
+
const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
|
|
609
|
+
for (const r of cr.results) {
|
|
610
|
+
if (r.result === 'cleaned') out.crossCleanups.push(repoKey);
|
|
611
|
+
else out.warnings.push(`跨仓 ${repoKey} apply 成功但清理 partial:${(r.details || []).join('; ')}${(r.residual || []).length ? ';残留: ' + r.residual.join('; ') : ''}`);
|
|
612
|
+
}
|
|
613
|
+
} finally {
|
|
614
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* 临时目录清理尽力而为 */ }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
443
620
|
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false } = {}) {
|
|
444
621
|
const projectRoot = cwd || process.cwd();
|
|
445
622
|
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
@@ -458,11 +635,26 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
458
635
|
skippedOverlapFiles: [],
|
|
459
636
|
};
|
|
460
637
|
|
|
461
|
-
// --- 0.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
638
|
+
// --- 0. 跨仓处理(task-05 / D-009 + 坑 cross-repo-no-worktree-isolation) ---
|
|
639
|
+
// 0a. worktree 模式的跨仓仓:真实 apply(patch 回跨仓主工作副本 + 成功后 cleanup worktree)。
|
|
640
|
+
// 在主仓任何动作之前跑——任一跨仓失败即整体返回,主仓工作区不动(不留半套状态)。
|
|
641
|
+
// 0b. legacy 直写模式的跨仓仓:no-op 校验(review.head 是跨仓真实 commit + 不 cleanup)。
|
|
642
|
+
// 单仓 ctx 缺省 / 无跨仓 entry → 两步都空转(零回归)。
|
|
465
643
|
if (ctx) {
|
|
644
|
+
const crossApplied = applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly });
|
|
645
|
+
if (crossApplied.repos.length > 0) {
|
|
646
|
+
result.crossRepoApplied = crossApplied.applied;
|
|
647
|
+
for (const w of crossApplied.warnings) result.warnings = (result.warnings || []).concat([w]);
|
|
648
|
+
if (crossApplied.crossCleanups.length > 0) {
|
|
649
|
+
result.warnings = (result.warnings || []).concat([
|
|
650
|
+
`跨仓 worktree 已 apply 并清理:${crossApplied.crossCleanups.join(', ')}(分支保留作 review 锚点)`
|
|
651
|
+
]);
|
|
652
|
+
}
|
|
653
|
+
if (crossApplied.errors.length > 0) {
|
|
654
|
+
result.errors.push(...crossApplied.errors);
|
|
655
|
+
return result; // 跨仓未落地 = 该仓 task 实际未完成,主仓 apply 不可继续
|
|
656
|
+
}
|
|
657
|
+
}
|
|
466
658
|
const cross = validateCrossRepoNoOp(ctx, projectRoot, changeName);
|
|
467
659
|
for (const e of cross.errors) result.errors.push(e);
|
|
468
660
|
if (cross.warnings.length > 0) result.warnings = (result.warnings || []).concat(cross.warnings);
|
|
@@ -1185,6 +1377,60 @@ function rollbackApply(projectRoot, trackedFiles, newFiles, rawStderr = '') {
|
|
|
1185
1377
|
* @param {object} p.result - applyWorktree 的 result(预对齐信息写入 result.warnings,可追溯,约束④)
|
|
1186
1378
|
* @returns {{ aligned: string[], commit: string|null }} aligned=本次对齐的文件(空数组=未对齐)
|
|
1187
1379
|
*/
|
|
1380
|
+
/**
|
|
1381
|
+
* merge 前自动 commit worktree 未提交交付物(坑 apply-merge-uncommitted-noop,2026-08-28 实证:
|
|
1382
|
+
* 子代理默认不 commit,分支 tip 只有 baseline checkpoint → git merge 空转「Already up to date」/
|
|
1383
|
+
* --no-ff 空合并,交付零落地,落地校验报错后只能手工 cherry-pick/cp 补救)。
|
|
1384
|
+
* 默认 patch 路径本就能吃未提交内容(git diff <base> 到工作区);只有 --merge/降级 merge 路径
|
|
1385
|
+
* 只看已提交——在此补上衔接。与 preAlignBaselineToMain 同款 pathspec commit(坑
|
|
1386
|
+
* git-commit-sweeps-prestaged:不扫入其他 staged 改动)。
|
|
1387
|
+
*
|
|
1388
|
+
* best-effort:commit 失败只 warning,交由下方落地校验兜底报错(不劣于原行为)。
|
|
1389
|
+
* @returns {{ committed: string[], skipped: boolean, shortHash: string|null }}
|
|
1390
|
+
*/
|
|
1391
|
+
function autoCommitWorktreeWip({ meta, result, changeName }) {
|
|
1392
|
+
const out = { committed: [], skipped: true, shortHash: null };
|
|
1393
|
+
const worktreePath = meta?.worktreePath;
|
|
1394
|
+
if (!worktreePath || !existsSync(worktreePath)) return out;
|
|
1395
|
+
if (meta.mode && meta.mode !== 'worktree') return out;
|
|
1396
|
+
|
|
1397
|
+
// 未提交交付物 = tracked-modified(vs HEAD)∪ untracked,过 filterDeliverableFiles(排 meta.json/.sillyspec)
|
|
1398
|
+
let wip = [];
|
|
1399
|
+
try {
|
|
1400
|
+
const modRaw = gitQuiet(worktreePath, ['diff', '--name-only', 'HEAD']);
|
|
1401
|
+
const untrackedRaw = gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard']);
|
|
1402
|
+
wip = filterDeliverableFiles([
|
|
1403
|
+
...new Set([...(modRaw || '').split('\n'), ...(untrackedRaw || '').split('\n')].filter(Boolean)),
|
|
1404
|
+
]);
|
|
1405
|
+
} catch { return out; }
|
|
1406
|
+
if (wip.length === 0) return out;
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
git(worktreePath, ['add', '-A', '--', ...wip]);
|
|
1410
|
+
try {
|
|
1411
|
+
git(worktreePath, ['commit', '--no-verify', '-m',
|
|
1412
|
+
`sillyspec: auto-commit worktree WIP (${wip.length} files, apply --merge)`, '--', ...wip]);
|
|
1413
|
+
} catch {
|
|
1414
|
+
// 仓级 git 身份缺失时 commit 拒绝——补 -c 身份重试一次(worktree 继承不到全局配置的形态)
|
|
1415
|
+
git(worktreePath, ['-c', 'user.name=sillyspec', '-c', 'user.email=sillyspec@apply.local',
|
|
1416
|
+
'commit', '--no-verify', '-m',
|
|
1417
|
+
`sillyspec: auto-commit worktree WIP (${wip.length} files, apply --merge)`, '--', ...wip]);
|
|
1418
|
+
}
|
|
1419
|
+
out.committed = wip;
|
|
1420
|
+
out.skipped = false;
|
|
1421
|
+
out.shortHash = gitQuiet(worktreePath, ['rev-parse', '--short', 'HEAD']);
|
|
1422
|
+
result.warnings = result.warnings || [];
|
|
1423
|
+
result.warnings.push(
|
|
1424
|
+
`worktree 有 ${wip.length} 个未提交交付文件(子代理未 commit),已在分支自动 commit` +
|
|
1425
|
+
`${out.shortHash ? `(${out.shortHash})` : ''} 再 merge:${wip.slice(0, 5).join(', ')}${wip.length > 5 ? ' 等' : ''}`
|
|
1426
|
+
);
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
result.warnings = result.warnings || [];
|
|
1429
|
+
result.warnings.push(`worktree WIP 自动 commit 失败(merge 可能空转,落地校验将报未落地):${(e.message || '').split('\n')[0]}`);
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1188
1434
|
function preAlignBaselineToMain({ meta, branch, projectRoot, result }) {
|
|
1189
1435
|
const pushWarning = (msg) => {
|
|
1190
1436
|
result.warnings = result.warnings || [];
|
|
@@ -1303,6 +1549,10 @@ export function applyByMerge(result, changeName, projectRoot, wm, opts = {}) {
|
|
|
1303
1549
|
// --- merge 前预对齐(D-002@v1 / task-02):baseline 并行旧文件对齐 main 版,消除 merge 冲突主因 ---
|
|
1304
1550
|
preAlignBaselineToMain({ meta, branch, projectRoot, result });
|
|
1305
1551
|
|
|
1552
|
+
// --- merge 前自动 commit 未提交交付物(坑 apply-merge-uncommitted-noop):预对齐在前——
|
|
1553
|
+
// 它跳过 dirty 文件(不覆盖 WIP)且只提交对齐集;WIP commit 在后,两者不混提交 ---
|
|
1554
|
+
autoCommitWorktreeWip({ meta, result, changeName });
|
|
1555
|
+
|
|
1306
1556
|
try {
|
|
1307
1557
|
git(projectRoot, ['merge', '--no-ff', branch], { timeout: 30000 });
|
|
1308
1558
|
} catch (e) {
|