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/.claude/skills/sillyspec-execute/SKILL.md +38 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +48 -0
- package/package.json +1 -1
- package/src/index.js +55 -3
- package/src/machine-interface.js +11 -4
- 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 +6 -0
- package/src/run/shared.js +170 -2
- package/src/stage-review.js +1 -0
- package/src/stages/execute.js +185 -7
- package/src/stages/plan-postcheck.js +266 -11
- package/src/sync.js +24 -1
- 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
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 这些都是 noAI 步骤,不需要 LLM 参与。
|
|
11
11
|
*/
|
|
12
|
-
import { existsSync, readFileSync as _readFileSync, readdirSync } from 'fs'
|
|
12
|
+
import { existsSync, readFileSync as _readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs'
|
|
13
13
|
// 归一化行尾为 LF:Windows 下 python/编辑器文本模式写 .md 会产生 CRLF,致本模块多处
|
|
14
14
|
// frontmatter/字段正则(`^---\n`、`allowed_paths:\s*\n…`、`^goal:` 等)失配,报「缺 frontmatter
|
|
15
15
|
// /缺字段」假错误(见缺陷 windows-python-crlf-taskcard)。读取时统一转 LF,一处覆盖全部正则。
|
|
16
16
|
const readFileSync = (filePath, encoding) => _readFileSync(filePath, encoding).replace(/\r\n/g, '\n')
|
|
17
17
|
import { join as pJoin } from 'path'
|
|
18
|
+
import { tmpdir } from 'os'
|
|
18
19
|
import jsYaml from 'js-yaml'
|
|
19
20
|
import { parseFileChangeList, pathMatches } from '../change-list.js'
|
|
20
21
|
import { getRule } from '../stage-contract-spec.js'
|
|
@@ -80,6 +81,111 @@ export function parseAllowedPaths(content) {
|
|
|
80
81
|
return []
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* 从 task-NN.md frontmatter 解析标量字段(repo / base_commit / head_commit)。
|
|
86
|
+
* 复用 parseTaskContracts 的 frontmatter 提取 + js-yaml load 模式(标量可选字段,正则对引号/空值脆弱)。
|
|
87
|
+
*
|
|
88
|
+
* design §7.2 跨仓 task 卡 frontmatter 协议:
|
|
89
|
+
* repo: sillyspec # 可选,缺省='main'(调用方视 null 为 main)
|
|
90
|
+
* base_commit: <sha> # 可选,CLI 锡点写入(派发前落盘 base)
|
|
91
|
+
* head_commit: <sha> # 可选,CLI 锡点写入(回收 review 前落盘 head)
|
|
92
|
+
*
|
|
93
|
+
* 缺省 / 空值 / 无 frontmatter → null(向后兼容旧 task 卡)。
|
|
94
|
+
* @param {string} content - task 文件内容
|
|
95
|
+
* @param {string} field - frontmatter 字段名
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
function parseFrontmatterScalar(content, field) {
|
|
99
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
100
|
+
if (!fmMatch) return null
|
|
101
|
+
let fm
|
|
102
|
+
try {
|
|
103
|
+
fm = jsYaml.load(fmMatch[1]) || {}
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
const v = fm[field]
|
|
108
|
+
if (v == null) return null
|
|
109
|
+
const s = String(v).trim()
|
|
110
|
+
return s === '' ? null : s
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 解析 task-NN.md frontmatter 的 repo 字段(跨仓 task 声明)。
|
|
115
|
+
* 缺省=null(=main,调用方按主仓处理)。design §7.2 / D-001。
|
|
116
|
+
* @param {string} content
|
|
117
|
+
* @returns {string|null}
|
|
118
|
+
*/
|
|
119
|
+
export function parseRepo(content) {
|
|
120
|
+
return parseFrontmatterScalar(content, 'repo')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 解析 task-NN.md frontmatter 的 base_commit 字段(CLI 派发跨仓 task 前落盘的 base 锡点)。
|
|
125
|
+
* 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
|
|
126
|
+
* @param {string} content
|
|
127
|
+
* @returns {string|null}
|
|
128
|
+
*/
|
|
129
|
+
export function parseBaseCommit(content) {
|
|
130
|
+
return parseFrontmatterScalar(content, 'base_commit')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解析 task-NN.md frontmatter 的 head_commit 字段(CLI 回收 review 前落盘的 head 锡点)。
|
|
135
|
+
* 缺省=null(未锡点 / 单仓 task)。design §7.2 / D-010。
|
|
136
|
+
* @param {string} content
|
|
137
|
+
* @returns {string|null}
|
|
138
|
+
*/
|
|
139
|
+
export function parseHeadCommit(content) {
|
|
140
|
+
return parseFrontmatterScalar(content, 'head_commit')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 从 local.yaml 文本解析 repos: 段(跨仓 workspace 注册表)。
|
|
145
|
+
* 与 parseLocalYamlModules 同风格(轻量行扫描,不引 yaml 依赖),结构 Map<repoKey, absolutePath>。
|
|
146
|
+
*
|
|
147
|
+
* design §7.3 local.yaml repos schema:
|
|
148
|
+
* repos:
|
|
149
|
+
* sillyspec: C:/Users/qinyi/IdeaProjects/sillyspec
|
|
150
|
+
* # main 不用注册(隐式 = cwd / specRoot 父目录)
|
|
151
|
+
*
|
|
152
|
+
* 无 repos 段 / 空段 / 入参空 → 空 Map(单仓 change 不读,向后兼容)。
|
|
153
|
+
*
|
|
154
|
+
* **职责边界**:本函数只 parse local.yaml 文本,不读 local.yaml 文件(文件读取入口归
|
|
155
|
+
* execute 启动 / task-09)。本函数不校验 declaredRepos ⊆ registry(D-007 fail-closed
|
|
156
|
+
* 校验在 MultiRepoContext 构造时做)。
|
|
157
|
+
* @param {string} yamlText
|
|
158
|
+
* @returns {Map<string, string>} repoKey → absolutePath
|
|
159
|
+
*/
|
|
160
|
+
export function parseRepoRegistry(yamlText) {
|
|
161
|
+
const reg = new Map()
|
|
162
|
+
if (!yamlText) return reg
|
|
163
|
+
const lines = yamlText.split('\n')
|
|
164
|
+
let startIdx = -1
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
|
|
167
|
+
}
|
|
168
|
+
if (startIdx === -1) return reg
|
|
169
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
170
|
+
const line = lines[i]
|
|
171
|
+
// 遇到新的顶层 key(行首非空格且非注释)→ repos 段结束
|
|
172
|
+
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t') && !line.startsWith('#') && line.trim() !== '') break
|
|
173
|
+
// 条目格式: <key>: <value>(key 限 [A-Za-z0-9_.\-],与 parseLocalYamlModules 对齐)
|
|
174
|
+
const entry = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
|
|
175
|
+
if (!entry) continue
|
|
176
|
+
const value = (entry[2] || '').trim()
|
|
177
|
+
if (value === '' || value.startsWith('#')) continue
|
|
178
|
+
// 去可选行内注释(`value # comment`)—— 注意路径不含 ` #`,注释前必带空格
|
|
179
|
+
const cleaned = value.replace(/\s+#.*$/, '').trim()
|
|
180
|
+
if (cleaned === '') continue
|
|
181
|
+
// 去首尾可选引号(YAML 字符串引用),保留路径内反斜杠原样
|
|
182
|
+
const path = cleaned.replace(/^['"]|['"]$/g, '')
|
|
183
|
+
if (path === '') continue
|
|
184
|
+
reg.set(entry[1], path)
|
|
185
|
+
}
|
|
186
|
+
return reg
|
|
187
|
+
}
|
|
188
|
+
|
|
83
189
|
/**
|
|
84
190
|
* 检查 task-NN.md 是否包含验收标准章节
|
|
85
191
|
* @param {string} content
|
|
@@ -253,6 +359,11 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
253
359
|
// task 卡片基础字段文案从 manifest 同源(plan.task-card-structure);${id} 由下方 `${taskId} (${file})` 替换。
|
|
254
360
|
const bsRule = getRule('plan.task-card-structure')
|
|
255
361
|
const taskInfo = new Map()
|
|
362
|
+
// pathOwners 按 (repo, path) 二元组聚合(design §5.3 约束③ / D-008):键 = `${repo}|${path}`,
|
|
363
|
+
// repo 缺省='main'(parseRepo 返 null → main)。跨仓 task 与主仓 task 同名物理路径分属不同 repo
|
|
364
|
+
// → 二元组键不同 → 不误判同 Wave 冲突(如主仓 src/task-review.js vs sillyspec src/task-review.js)。
|
|
365
|
+
// ⚠️ 键分隔符 `|` 假设 repo 名与 path 均不含 `|`(repo 名约束 [A-Za-z0-9_.\-]、path 含 / \,极低概率含 |)。
|
|
366
|
+
// value 结构 { repo, path, owners }:冲突文案对外仍显示纯 path(用户体验),内部按二元组判冲突。
|
|
256
367
|
const pathOwners = new Map()
|
|
257
368
|
|
|
258
369
|
for (const file of taskFiles) {
|
|
@@ -268,6 +379,8 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
268
379
|
const allowedPaths = parseAllowedPaths(content)
|
|
269
380
|
const hasAcceptance = hasAcceptanceCriteria(content)
|
|
270
381
|
const hasTdd = hasTddOrVerify(content)
|
|
382
|
+
// repo 缺省='main'(parseRepo 返 null → 单仓 task,与既有行为零回归)
|
|
383
|
+
const repo = parseRepo(content) || 'main'
|
|
271
384
|
|
|
272
385
|
taskInfo.set(taskId, { dependsOn, allowedPaths, hasAcceptance, hasTdd, file })
|
|
273
386
|
|
|
@@ -282,17 +395,19 @@ export function validateBlueprintConsistency(changeDir) {
|
|
|
282
395
|
}
|
|
283
396
|
|
|
284
397
|
for (const p of allowedPaths) {
|
|
285
|
-
|
|
286
|
-
pathOwners.
|
|
398
|
+
const key = `${repo}|${p}`
|
|
399
|
+
if (!pathOwners.has(key)) pathOwners.set(key, { repo, path: p, owners: [] })
|
|
400
|
+
pathOwners.get(key).owners.push(taskId)
|
|
287
401
|
}
|
|
288
402
|
}
|
|
289
403
|
|
|
290
|
-
// 路径冲突(Wave 感知):同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
|
|
404
|
+
// 路径冲突(Wave 感知):同 repo 同 Wave 内 >1 task 共享 allowed_path → execute 强制并行(execute.js:603)
|
|
291
405
|
// 子代理会互相覆盖该文件 → error。跨 Wave 同文件 → 串行执行安全 → warning。
|
|
406
|
+
// 按 (repo, path) 二元组判冲突(约束③):跨仓 task 与主仓 task 同名路径不误判(不同 repo → 不同键)。
|
|
292
407
|
// Wave 口径 = plan.md 显式 `## Wave N`(parseTaskWavesFromPlan,与 execute 同源,非 topoSort 建议值);
|
|
293
|
-
// plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同文件即冲突。
|
|
408
|
+
// plan.md 无显式 Wave → execute 全并行(隐式单 Wave,execute.js:402-408)→ 同 repo 同文件即冲突。
|
|
294
409
|
const waveOfTask = parseTaskWavesFromPlan(pJoin(changeDir, 'plan.md'))
|
|
295
|
-
for (const [p, owners] of pathOwners) {
|
|
410
|
+
for (const [, { repo, path: p, owners }] of pathOwners) {
|
|
296
411
|
if (owners.length < 2) continue
|
|
297
412
|
if (waveOfTask === null) {
|
|
298
413
|
errors.push(
|
|
@@ -515,6 +630,120 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
|
|
|
515
630
|
// 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
|
|
516
631
|
// design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
|
|
517
632
|
|
|
633
|
+
/**
|
|
634
|
+
* design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
|
|
635
|
+
* 段头为 h2,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀与尾随空白/冒号。
|
|
636
|
+
* 不命中(非仓变更段头)→ null。
|
|
637
|
+
*/
|
|
638
|
+
const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更\s*[::]?\s*$/
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* design §6 文件清单章节标题(与 change-list.js FILE_LIST_SECTION_RE 同源,避免 import 私有常量)。
|
|
642
|
+
* 容忍可选编号前缀(`## 6. 文件变更清单`),与 change-list 解析口径一致。
|
|
643
|
+
*/
|
|
644
|
+
const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/im
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 按 repo 解析 design §6 文件清单(约束③ / D-014)。
|
|
648
|
+
*
|
|
649
|
+
* 支持 design §6 按仓分段:`## <repo> 仓变更` 段头下的路径归该 repo;无段头时整章节归 'main'
|
|
650
|
+
* (向后兼容单仓 design,与原 parseFileChangeList 行为等价)。段内路径解析复用 parseFileChangeList
|
|
651
|
+
* (表格列定位 / exclude 子段 / 占位符过滤 / CRLF 容错全部继承,零漂移)——方法是:把每段内容包成
|
|
652
|
+
* 临时 design 文件(前置标准 `## 文件变更清单` 标题),写 OS tmp 调 parseFileChangeList,立即清理。
|
|
653
|
+
*
|
|
654
|
+
* 为什么不复用单次 parseFileChangeList:change-list.js 的通用 parser 把段头 `## <repo> 仓变更`
|
|
655
|
+
* 当成「下一个 ## 章节」截断主章节(line 173 `^##\s`),跨仓段路径会丢——这正是 task-03 要修的。
|
|
656
|
+
*
|
|
657
|
+
* 返回值结构(与 pathOwners 二元组对齐):
|
|
658
|
+
* Map<repo, Set<path>> —— 每个 repo 的 design 清单路径集
|
|
659
|
+
* 外加 _hasSegmentHeader 标记(调用方据此决定是否走分段对账路径)
|
|
660
|
+
*
|
|
661
|
+
* @param {string} designPath - design.md 绝对路径
|
|
662
|
+
* @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[] }}
|
|
663
|
+
*/
|
|
664
|
+
function parseDesignCoverageByRepo(designPath) {
|
|
665
|
+
const byRepo = new Map()
|
|
666
|
+
const allFiles = []
|
|
667
|
+
if (!designPath || !existsSync(designPath)) {
|
|
668
|
+
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
669
|
+
}
|
|
670
|
+
const content = readFileSync(designPath, 'utf8')
|
|
671
|
+
|
|
672
|
+
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
673
|
+
if (!sectionMatch) {
|
|
674
|
+
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 主章节起点(match index)→ 扫描到下一个非段头的 `## ` 标题或文件末尾。
|
|
678
|
+
// 段头 `## <repo> 仓变更` 是 h2 但属本章节的子分段,不应触发章节结束。
|
|
679
|
+
const lines = content.slice(sectionMatch.index).split('\n')
|
|
680
|
+
// 先扫一遍:本章节内是否含至少一个段头(决定 hasSegmentHeader / 走分段路径 vs 退化路径)
|
|
681
|
+
let hasSegmentHeader = false
|
|
682
|
+
const sectionLines = []
|
|
683
|
+
for (let i = 0; i < lines.length; i++) {
|
|
684
|
+
const line = lines[i]
|
|
685
|
+
if (i > 0 && /^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) {
|
|
686
|
+
// 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束
|
|
687
|
+
break
|
|
688
|
+
}
|
|
689
|
+
if (REPO_SECTION_HEADER_RE.test(line)) hasSegmentHeader = true
|
|
690
|
+
sectionLines.push(line)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!hasSegmentHeader) {
|
|
694
|
+
// 无段头 → 整章节归 main(等价原 parseFileChangeList 行为)
|
|
695
|
+
const main = new Set(parseFileChangeList(designPath))
|
|
696
|
+
if (main.size > 0) byRepo.set('main', main)
|
|
697
|
+
for (const p of main) allFiles.push(p)
|
|
698
|
+
return { byRepo, hasSegmentHeader: false, allFiles }
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 有段头 → 按 repo 切片,每段构造临时 design 调 parseFileChangeList
|
|
702
|
+
// 当前 repo:段头之前的内容(主章节标题到第一个段头之间)归 'main'
|
|
703
|
+
let currentRepo = 'main'
|
|
704
|
+
const segments = new Map() // repo -> 行数组(含表格/列表,不含段头本身)
|
|
705
|
+
let beforeFirstHeader = [] // 段头之前的 main 段内容
|
|
706
|
+
let seenHeader = false
|
|
707
|
+
for (const line of sectionLines.slice(1)) { // 跳过主章节标题行(sectionLines[0])
|
|
708
|
+
const hdr = line.match(REPO_SECTION_HEADER_RE)
|
|
709
|
+
if (hdr) {
|
|
710
|
+
currentRepo = hdr[1]
|
|
711
|
+
seenHeader = true
|
|
712
|
+
if (!segments.has(currentRepo)) segments.set(currentRepo, [])
|
|
713
|
+
continue
|
|
714
|
+
}
|
|
715
|
+
if (!seenHeader) {
|
|
716
|
+
beforeFirstHeader.push(line)
|
|
717
|
+
} else {
|
|
718
|
+
segments.get(currentRepo).push(line)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 段头之前的 main 段(若有内容)也要对账
|
|
723
|
+
if (beforeFirstHeader.filter(l => l.trim()).length > 0) {
|
|
724
|
+
segments.set('main', beforeFirstHeader)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const tmpDir = mkdtempSync(pJoin(tmpdir(), 'design-cov-seg-'))
|
|
728
|
+
try {
|
|
729
|
+
for (const [repo, segLines] of segments) {
|
|
730
|
+
// 把段内容包成完整 design md(前置主章节标题,让 parseFileChangeList 识别章节起点)
|
|
731
|
+
const virtualDesign = ['## 文件变更清单', '', ...segLines, ''].join('\n')
|
|
732
|
+
const tmpPath = pJoin(tmpDir, `${repo}.md`)
|
|
733
|
+
writeFileSync(tmpPath, virtualDesign)
|
|
734
|
+
const segFiles = [...parseFileChangeList(tmpPath)]
|
|
735
|
+
if (segFiles.length === 0) continue
|
|
736
|
+
const set = byRepo.get(repo) || new Set()
|
|
737
|
+
for (const p of segFiles) { set.add(p); allFiles.push(p) }
|
|
738
|
+
byRepo.set(repo, set)
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { byRepo, hasSegmentHeader: true, allFiles }
|
|
745
|
+
}
|
|
746
|
+
|
|
518
747
|
/**
|
|
519
748
|
* design.md 文件变更清单 → tasks allowed_paths 覆盖对账
|
|
520
749
|
*
|
|
@@ -525,6 +754,10 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
|
|
|
525
754
|
* 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
|
|
526
755
|
* 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
|
|
527
756
|
*
|
|
757
|
+
* 跨仓分段对账(约束③ / D-014):design §6 支持 `## <repo> 仓变更` 段头按仓分段,
|
|
758
|
+
* task allowed_paths 按 (repo, path) 二元组归属——跨仓 task 的 allowed_paths 只覆盖
|
|
759
|
+
* 对应 repo 段的 design 清单,不与主仓段交叉误判。无段头时整章节归 main(零回归)。
|
|
760
|
+
*
|
|
528
761
|
* fail-open 边界(不阻断):design.md 不存在、无 task 卡片(none 级别)、
|
|
529
762
|
* task 卡片均无 allowed_paths(由 validatePlanFeasibility 把关)。
|
|
530
763
|
* 阻断边界:有 task 卡片但 design 缺清单章节(light/full 已生成 task → design 必有清单)、
|
|
@@ -552,7 +785,8 @@ export function validateDesignFileCoverage(changeDir) {
|
|
|
552
785
|
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
553
786
|
}
|
|
554
787
|
|
|
555
|
-
|
|
788
|
+
// 按仓分段解析 design §6(约束③):段头 `## <repo> 仓变更` → 段内路径归该 repo;无段头 → 全 main
|
|
789
|
+
const { byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles } = parseDesignCoverageByRepo(designPath)
|
|
556
790
|
// 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
|
|
557
791
|
const dcRule = getRule('plan.design-file-coverage')
|
|
558
792
|
if (designFiles.length === 0) {
|
|
@@ -561,16 +795,37 @@ export function validateDesignFileCoverage(changeDir) {
|
|
|
561
795
|
errors.push(dcRule.data.messageMissingList)
|
|
562
796
|
return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
|
|
563
797
|
}
|
|
564
|
-
|
|
798
|
+
|
|
799
|
+
// task allowed_paths 也按 (repo, path) 二元组收集(与 pathOwners 同口径,约束③):
|
|
800
|
+
// 跨仓 task 的 allowed_paths 只覆盖对应 repo 的 design 段,不与主仓段交叉误判。
|
|
801
|
+
const taskAllowedByRepo = new Map() // repo -> string[]
|
|
802
|
+
let totalAllowed = 0
|
|
565
803
|
for (const file of taskFiles) {
|
|
566
804
|
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|
|
567
|
-
|
|
805
|
+
const repo = parseRepo(content) || 'main'
|
|
806
|
+
const allowed = parseAllowedPaths(content)
|
|
807
|
+
if (allowed.length === 0) continue
|
|
808
|
+
totalAllowed += allowed.length
|
|
809
|
+
const arr = taskAllowedByRepo.get(repo) || []
|
|
810
|
+
arr.push(...allowed)
|
|
811
|
+
taskAllowedByRepo.set(repo, arr)
|
|
568
812
|
}
|
|
569
|
-
if (
|
|
813
|
+
if (totalAllowed === 0) {
|
|
570
814
|
return { ok: true, errors, warnings, designFiles, uncovered: [] }
|
|
571
815
|
}
|
|
572
816
|
|
|
573
|
-
|
|
817
|
+
// 对账:design 的每条 (repo, path) 必须被同 repo 的 task allowed_paths 覆盖。
|
|
818
|
+
// 无分段(单仓)时 byRepo 只含 main,taskAllowedByRepo 也只含 main → 退化原扁平对账(零回归)。
|
|
819
|
+
const uncovered = []
|
|
820
|
+
for (const [repo, paths] of designByRepo) {
|
|
821
|
+
const allowed = taskAllowedByRepo.get(repo) || []
|
|
822
|
+
for (const df of paths) {
|
|
823
|
+
// 同 repo 无 task 声明 → 整段无覆盖;或有 task 但无 pathMatches → 未覆盖
|
|
824
|
+
if (!allowed.some(ap => pathMatches(df, ap))) {
|
|
825
|
+
uncovered.push(hasSegmentHeader ? `[${repo}] ${df}` : df)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
574
829
|
if (uncovered.length > 0) {
|
|
575
830
|
errors.push(
|
|
576
831
|
dcRule.data.messageUncovered
|
package/src/sync.js
CHANGED
|
@@ -268,6 +268,29 @@ export class SyncManager {
|
|
|
268
268
|
}
|
|
269
269
|
console.log(`[sync] 平台连接成功: ${url}`);
|
|
270
270
|
|
|
271
|
+
// 换发 workspace-scoped 同步 token(task-09 / D-005@v1):用传入 user 级 token
|
|
272
|
+
// (shk_live_ / JWT)作 Bearer + 本地 root_path(connect 的 cwd,与平台
|
|
273
|
+
// Workspace.root_path 绑定值等值匹配)调 resolve-by-root-path。成功取 shpsync_
|
|
274
|
+
// 覆盖 token 写入 platform 段;404(root_path 未绑)/403(无 WORKSPACE_WRITE)/断网
|
|
275
|
+
// 均降级沿用原 token 继续(best-effort 不阻断 connect)。
|
|
276
|
+
let effectiveToken = token;
|
|
277
|
+
const resolveUrl = `${normalizedUrl}/api/workspaces/resolve-by-root-path`;
|
|
278
|
+
const resolved = await fetchJson(resolveUrl, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({ root_path: this.cwd }),
|
|
282
|
+
});
|
|
283
|
+
if (resolved && resolved.token && resolved.token.startsWith('shpsync_')) {
|
|
284
|
+
effectiveToken = resolved.token;
|
|
285
|
+
console.log(
|
|
286
|
+
`[sync] 已换发 workspace-scoped 同步 token (workspace: ${resolved.workspace_id})`
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
console.warn(
|
|
290
|
+
'[sync] 换发 workspace-scoped token 失败(404/403/断网),沿用原 token 继续'
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
271
294
|
// 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
|
|
272
295
|
const resolvedUser = resolvePlatformUser(this.cwd, user);
|
|
273
296
|
|
|
@@ -275,7 +298,7 @@ export class SyncManager {
|
|
|
275
298
|
// (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
|
|
276
299
|
const platformEntries = [
|
|
277
300
|
` url: ${normalizedUrl}`,
|
|
278
|
-
` token: ${
|
|
301
|
+
` token: ${effectiveToken}`,
|
|
279
302
|
` last_connected: ${new Date().toISOString()}`,
|
|
280
303
|
];
|
|
281
304
|
if (resolvedUser) {
|
package/src/task-review.js
CHANGED
|
@@ -12,13 +12,38 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSy
|
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
13
|
import { execFileSync } from 'child_process'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
|
-
import { parseAllowedPaths } from './stages/plan-postcheck.js'
|
|
15
|
+
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
17
17
|
import { WorktreeManager } from './worktree.js'
|
|
18
18
|
import { resolveRuntimeRoot } from './run/shared.js'
|
|
19
19
|
|
|
20
20
|
// ── review.json schema version ──
|
|
21
|
+
//
|
|
22
|
+
// design §7.4 / R-07:task-review 的 review.json schemaVersion 1→2(新增可选 repo 字段),
|
|
23
|
+
// 旧 v1(无 repo)向后兼容视 'main',不阻断既有 change archive。
|
|
24
|
+
//
|
|
25
|
+
// 关于常量与"1→2":REVIEW_SCHEMA_VERSION 常量被 stage-review.js(文档型阶段评审)共享——
|
|
26
|
+
// stage-review.test.mjs:358 + register.test.mjs:40 断言其值 === 1,且 stage-review.js 不在本次
|
|
27
|
+
// 变更的 allowed_paths 内。两个 schema(task code-review / stage doc-review)逻辑独立,但历史共享
|
|
28
|
+
// 同一常量。本期保持 REVIEW_SCHEMA_VERSION=1 作 stage-review 写侧默认值(不破坏 stage-review),
|
|
29
|
+
// 同时用 REVIEW_SCHEMA_VERSIONS_ACCEPTED=[1,2] 表达"读侧接受两版"——validateReviewSchema 兼容 v1/v2,
|
|
30
|
+
// 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
|
|
31
|
+
// 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
|
|
21
32
|
export const REVIEW_SCHEMA_VERSION = 1
|
|
33
|
+
export const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
|
|
34
|
+
// repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
|
|
35
|
+
export const DEFAULT_REPO_KEY = 'main'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 review.repo / task 卡 repo 字段为有效 repoKey。
|
|
39
|
+
* 缺省 / 非字符串 / 空串 → 'main'(向后兼容旧 v1 review + 无 repo 声明的单仓 task)。
|
|
40
|
+
* @param {any} repo
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRepoKey(repo) {
|
|
44
|
+
if (typeof repo !== 'string' || repo.trim() === '') return DEFAULT_REPO_KEY
|
|
45
|
+
return repo.trim()
|
|
46
|
+
}
|
|
22
47
|
|
|
23
48
|
// ── 合法 verdict 枚举 ──
|
|
24
49
|
export const VALID_VERDICTS = ['pass', 'fail', 'cannot_verify']
|
|
@@ -156,6 +181,12 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
156
181
|
|
|
157
182
|
/**
|
|
158
183
|
* 校验单个 review.json 文件
|
|
184
|
+
*
|
|
185
|
+
* schema 版本(design §7.4 / R-07):接受 v1(无 repo,单仓向后兼容)+ v2(新增可选 repo 字段,
|
|
186
|
+
* 跨仓 task 标记 repo: <key>)。repo 缺省 / 非字符串 → 视 'main'(normalizeRepoKey),不阻断。
|
|
187
|
+
* repo 的真实存在性(local.yaml repos 注册)由 MultiRepoContext 约束② fail-closed 兜底,schema 层
|
|
188
|
+
* 只做"非字符串时不阻断"的宽松类型校验(review.json 是 agent 手写产物,宽松避免误杀)。
|
|
189
|
+
*
|
|
159
190
|
* @param {object} review - 解析后的 JSON 对象
|
|
160
191
|
* @returns {{ ok: boolean, errors: string[] }}
|
|
161
192
|
*/
|
|
@@ -166,14 +197,20 @@ export function validateReviewSchema(review) {
|
|
|
166
197
|
return { ok: false, errors }
|
|
167
198
|
}
|
|
168
199
|
|
|
169
|
-
if (review.schemaVersion
|
|
170
|
-
errors.push(`schemaVersion 应为 ${
|
|
200
|
+
if (!REVIEW_SCHEMA_VERSIONS_ACCEPTED.includes(review.schemaVersion)) {
|
|
201
|
+
errors.push(`schemaVersion 应为 ${REVIEW_SCHEMA_VERSIONS_ACCEPTED.join(' 或 ')},实际为 ${review.schemaVersion}`)
|
|
171
202
|
}
|
|
172
203
|
|
|
173
204
|
if (!review.task || typeof review.task !== 'string') {
|
|
174
205
|
errors.push('缺少 task 字段(应为 "task-XX" 格式)')
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
// repo(可选,v2 新增):类型宽松校验。非字符串 / 空串 → 视 'main',不阻断 schema。
|
|
209
|
+
// 跨仓 repo 的注册存在性由 MultiRepoContext 构造时 fail-closed 校验(约束②)。
|
|
210
|
+
if (review.repo !== undefined && typeof review.repo !== 'string') {
|
|
211
|
+
errors.push(`repo 字段(若提供)应为字符串,实际类型为 ${typeof review.repo}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
if (!VALID_VERDICTS.includes(review.specVerdict)) {
|
|
178
215
|
errors.push(`specVerdict 无效:${review.specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
179
216
|
}
|
|
@@ -306,6 +343,11 @@ export function validateCheckedTaskReviews({ planContent, runtimeRoot, executeRu
|
|
|
306
343
|
* @param {string} opts.runtimeRoot - .sillyspec/.runtime 的绝对路径
|
|
307
344
|
* @param {string} opts.executeRunId - execute run id(如 'exec-2026-06-23-131400')
|
|
308
345
|
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
346
|
+
* @param {string} [opts.changeDir] - change 目录(读 task-NN.md frontmatter 判 low_risk)
|
|
347
|
+
* @param {string} [opts.gitDir] - 主仓 git 工作目录(单仓 review 真实性校验 cwd;无 ctx 时用此)
|
|
348
|
+
* @param {object} [opts.ctx] - MultiRepoContext 实例(D-013:缺省/null → 走 opts.gitDir 单仓零回归;
|
|
349
|
+
* 有 ctx 时按 review.repo(缺省 'main')从 ctx.resolve(repo).gitDir 取每条 review 的校验 cwd,
|
|
350
|
+
* 跨仓 gitDir=跨仓仓根,使 base..head diff 在正确仓跑——design §6 task-review A1 行)
|
|
309
351
|
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
310
352
|
*/
|
|
311
353
|
/**
|
|
@@ -327,7 +369,7 @@ function isTaskLowRisk(changeDir, taskId) {
|
|
|
327
369
|
}
|
|
328
370
|
|
|
329
371
|
export function validateTaskReviews(opts) {
|
|
330
|
-
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null } = opts
|
|
372
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
|
|
331
373
|
|
|
332
374
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
333
375
|
|
|
@@ -382,8 +424,23 @@ export function validateTaskReviews(opts) {
|
|
|
382
424
|
}
|
|
383
425
|
|
|
384
426
|
// ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
|
|
385
|
-
|
|
386
|
-
|
|
427
|
+
// D-013 + design §6 A1:有 ctx 时按 review.repo(缺省 'main')切 gitDir——跨仓 review 的
|
|
428
|
+
// base/head 是跨仓仓 commit,必须在跨仓仓根跑 rev-parse/diff,否则判伪造误杀。无 ctx 时退回
|
|
429
|
+
// opts.gitDir(单仓零回归)。reviewGitDir 为 null 则跳过证据校验(与原逻辑一致)。
|
|
430
|
+
let reviewGitDir = gitDir
|
|
431
|
+
if (ctx) {
|
|
432
|
+
const repoKey = normalizeRepoKey(review.repo)
|
|
433
|
+
const entry = ctx.resolve(repoKey)
|
|
434
|
+
if (entry && entry.gitDir) {
|
|
435
|
+
reviewGitDir = entry.gitDir
|
|
436
|
+
} else if (repoKey !== 'main') {
|
|
437
|
+
// 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
|
|
438
|
+
// 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
|
|
439
|
+
warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验`)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (reviewGitDir) {
|
|
443
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
387
444
|
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
388
445
|
if (!evidence.ok) {
|
|
389
446
|
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
@@ -681,10 +738,10 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
|
681
738
|
* fail-open:任何异常 → 返回统计,仅 console.warn,不阻断 execute 完成(草稿是兜底,
|
|
682
739
|
* 缺了也只是退回原状——gate 报缺 review.json,agent 手补,不比修复前差)。
|
|
683
740
|
*
|
|
684
|
-
* @param {{ changeName: string, cwd: string, platformOpts?: object }} opts
|
|
741
|
+
* @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
|
|
685
742
|
* @returns {Promise<{ generated: number, skipped: number, unattributed: string[], reason?: string, executeRunId?: string }>}
|
|
686
743
|
*/
|
|
687
|
-
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {} }) {
|
|
744
|
+
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {}, ctx = null }) {
|
|
688
745
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
689
746
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
690
747
|
const changeDir = join(specBase, 'changes', changeName)
|
|
@@ -709,11 +766,15 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
709
766
|
|
|
710
767
|
// base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
|
|
711
768
|
const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
712
|
-
|
|
769
|
+
// 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
|
|
770
|
+
// 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
|
|
771
|
+
if (!ctx && (!diffFiles || diffFiles.length === 0)) {
|
|
713
772
|
return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
|
|
714
773
|
}
|
|
715
774
|
|
|
716
775
|
// base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
|
|
776
|
+
// 有 ctx 时主仓 base/head 仅供主仓 task 用;ctx 主仓 entry 也可提供 base(meta.baseHash 同源)。
|
|
777
|
+
// 主仓 base/head 解析失败 + 无 ctx → 提前返回(原逻辑);有 ctx → 仅记 null,主仓 task 后续跳过(不阻断跨仓 task)。
|
|
717
778
|
let base = null
|
|
718
779
|
let head = null
|
|
719
780
|
let reviewGitDir = cwd
|
|
@@ -726,12 +787,19 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
726
787
|
}
|
|
727
788
|
} catch {}
|
|
728
789
|
if (!base) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
|
|
790
|
+
if (!ctx) {
|
|
791
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: '无 worktree meta.baseHash(无法定 base/head)' }
|
|
792
|
+
}
|
|
793
|
+
// 有 ctx:主仓 base 缺失(主仓无 worktree / 单纯跨仓 change)→ 主仓 task 后续跳过,跨仓 task 照常
|
|
794
|
+
} else {
|
|
795
|
+
try {
|
|
796
|
+
head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
|
|
797
|
+
} catch {
|
|
798
|
+
if (!ctx) {
|
|
799
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: 'git rev-parse HEAD 失败(' + reviewGitDir + ')' }
|
|
800
|
+
}
|
|
801
|
+
// 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
|
|
802
|
+
}
|
|
735
803
|
}
|
|
736
804
|
|
|
737
805
|
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
@@ -753,34 +821,76 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
753
821
|
|
|
754
822
|
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
755
823
|
const allowedPaths = parseAllowedPaths(content)
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
824
|
+
|
|
825
|
+
// ── 多仓分支(design §6 A2 / D-006 / D-010):跨仓 task 用 task 卡双锡点 + 跨仓仓 diff ──
|
|
826
|
+
const taskRepo = normalizeRepoKey(parseRepo(content))
|
|
827
|
+
const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
|
|
828
|
+
let taskBase = base
|
|
829
|
+
let taskHead = head
|
|
830
|
+
let taskChangedFiles
|
|
831
|
+
let draftRepo
|
|
832
|
+
|
|
833
|
+
if (crossEntry) {
|
|
834
|
+
// 跨仓 task:base/head 必须读 task 卡锡点(跨仓仓无 meta.json)。缺任一 → 跳过留给 agent。
|
|
835
|
+
const baseCommit = parseBaseCommit(content)
|
|
836
|
+
const headCommit = parseHeadCommit(content)
|
|
837
|
+
if (!baseCommit || !headCommit) {
|
|
838
|
+
skipped++
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
taskBase = baseCommit
|
|
842
|
+
taskHead = headCommit
|
|
843
|
+
draftRepo = taskRepo
|
|
844
|
+
// 跨仓 diff 在跨仓仓根跑(base..head 锡点锚定,非瞬时 HEAD——D-010 head 精度)
|
|
845
|
+
let crossDiffFiles = []
|
|
846
|
+
try {
|
|
847
|
+
const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
|
|
848
|
+
crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
|
|
849
|
+
} catch {
|
|
850
|
+
// git diff 失败(锡点非真实 commit 等)→ 不生成伪造草稿,留给 agent 手补
|
|
851
|
+
skipped++
|
|
852
|
+
continue
|
|
853
|
+
}
|
|
854
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
855
|
+
? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
856
|
+
: crossDiffFiles
|
|
857
|
+
} else {
|
|
858
|
+
// 主仓 task(taskRepo='main' 或无 ctx 跨仓解析失败 → 退单仓口径)
|
|
859
|
+
// diffFiles 可能为 null(git 不可用)——有 ctx 时主仓 git 异常不阻断跨仓 task,主仓 task 自然跳过。
|
|
860
|
+
const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
861
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
862
|
+
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
863
|
+
: []
|
|
864
|
+
}
|
|
759
865
|
|
|
760
866
|
// 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
|
|
761
|
-
if (
|
|
867
|
+
if (taskChangedFiles.length === 0) {
|
|
762
868
|
skipped++
|
|
763
869
|
continue
|
|
764
870
|
}
|
|
765
|
-
|
|
871
|
+
// unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
|
|
872
|
+
if (!crossEntry) {
|
|
873
|
+
taskChangedFiles.forEach(f => attributed.add(f))
|
|
874
|
+
}
|
|
766
875
|
|
|
767
876
|
const draft = {
|
|
768
877
|
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
769
878
|
task: taskId,
|
|
770
|
-
base,
|
|
771
|
-
head,
|
|
772
|
-
changedFiles,
|
|
879
|
+
base: taskBase,
|
|
880
|
+
head: taskHead,
|
|
881
|
+
changedFiles: taskChangedFiles,
|
|
773
882
|
specVerdict: 'cannot_verify',
|
|
774
883
|
qualityVerdict: 'cannot_verify',
|
|
775
884
|
requiredEvidence: ['auto-generated draft: 待 agent 对照 ' + taskId + ' brief + diff 复核后升级为 pass/fail'],
|
|
776
|
-
reviewerNotes: 'auto-generated draft from git diff ' +
|
|
885
|
+
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
|
|
777
886
|
}
|
|
887
|
+
if (draftRepo) draft.repo = draftRepo
|
|
778
888
|
mkdirSync(reviewDir, { recursive: true })
|
|
779
889
|
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
780
890
|
generated++
|
|
781
891
|
}
|
|
782
892
|
|
|
783
|
-
const unattributed = diffFiles.filter(f => !attributed.has(f))
|
|
893
|
+
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
784
894
|
return { generated, skipped, unattributed, executeRunId }
|
|
785
895
|
}
|
|
786
896
|
|