dsh-card-dispatch 0.0.0-stage → 0.1.1
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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +276 -2
- package/cordis.patch.yml +25 -0
- package/icon.svg +21 -0
- package/index.js +88 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -4
- package/src/catalog-source.js +122 -0
- package/src/client/index.js +155 -0
- package/src/config-store.js +49 -0
- package/src/config.js +229 -0
- package/src/console.html +1056 -0
- package/src/criteria-llm.js +79 -0
- package/src/criteria.js +102 -0
- package/src/gates.js +134 -0
- package/src/human-channel.js +900 -0
- package/src/import.js +193 -0
- package/src/model-call.js +106 -0
- package/src/models.js +249 -0
- package/src/parse-llm.js +96 -0
- package/src/prompts-llm.js +104 -0
- package/src/rework-store.js +69 -0
- package/src/selfcheck.js +117 -0
- package/src/split.js +549 -0
- package/src/verdict.js +133 -0
package/src/split.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 需求 → 看板卡片(纯函数,无 IO、无宿主依赖)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么只做这一件事
|
|
5
|
+
*
|
|
6
|
+
* 真机上插件自己开会话一路踩坑(`sessionController` 是网关客户端,缺 peer 就抛
|
|
7
|
+
* `throwIfAborted`/`truncatedAt`),而**任务看板插件已经把这套做通了**:它有 Host
|
|
8
|
+
* 权威台账、按卡片钉住工作区/预设/权限、自己开会话、跑完结算。所以本插件退到上游,
|
|
9
|
+
* 只负责两件事:
|
|
10
|
+
*
|
|
11
|
+
* 1. 把人写的需求 / 判据**拆**成若干张卡片;
|
|
12
|
+
* 2. 生成**看板的 HTTP action 信封**(`POST /api/task-board/action`),由页面直接投递。
|
|
13
|
+
*
|
|
14
|
+
* 执行、会话、计费、结算全部归看板。这里不碰会话、不碰网关、不碰调度。
|
|
15
|
+
*
|
|
16
|
+
* ## 拆分策略(规则式,可解释、可测)
|
|
17
|
+
*
|
|
18
|
+
* - 判据只有 1 条(或未要求并行)→ 只建**一张父卡**,prompt 里带全部判据;
|
|
19
|
+
* - 判据 ≥2 条且要求并行 → 父卡 + 每个判据组一张**子卡**(看板 `maxSubtaskDepth`
|
|
20
|
+
* 默认 1 层,所以不再往下拆);
|
|
21
|
+
* - 每张卡的 prompt 都是**自包含**的:目标、范围、验收判据、交付要求。因为看板会在
|
|
22
|
+
* 一个新会话里跑这张卡,它读不到这里的任何上下文。
|
|
23
|
+
*
|
|
24
|
+
* 不做的:LLM 语义拆分。规则式拆分的好处是「拆成几张、每张干什么」当场可见,
|
|
25
|
+
* 不会出现「模型自己编了五个任务」这种不可解释的账单。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
|
|
30
|
+
import { verdictPath } from './verdict.js'
|
|
31
|
+
|
|
32
|
+
/** 单卡 prompt 里判据条目的前缀(子卡只列自己那组)。 */
|
|
33
|
+
const CRITERIA_HEADING = '验收判据(完成后逐条自证):'
|
|
34
|
+
|
|
35
|
+
/** 判据条数上限:再多人也看不过来,prompt 也会被撑爆。 */
|
|
36
|
+
export const MAX_CRITERIA = 30
|
|
37
|
+
|
|
38
|
+
/** 单条判据字数上限:一条判据写成一段话,验收就没法逐条核对。 */
|
|
39
|
+
export const MAX_CRITERIA_TEXT = 500
|
|
40
|
+
|
|
41
|
+
/** 同一批卡的 id 幂等窗口:10 分钟内用同样输入再点一次,写的是同一批卡。 */
|
|
42
|
+
const CREATE_IDEMPOTENCY_MS = 10 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
/** 运行信封的幂等窗口:短一些,避免「想重跑」被当成重复点击。 */
|
|
45
|
+
const RUN_IDEMPOTENCY_MS = 5 * 60 * 1000
|
|
46
|
+
|
|
47
|
+
/** 验收卡等报告的上限:开发卡迟迟不结束才有意义,正常交付时报告一出现就开始验收。 */
|
|
48
|
+
export const VERIFY_WAIT_MINUTES = 120
|
|
49
|
+
|
|
50
|
+
/** 等待期间的轮询间隔:太短烧上下文,太长验收反应慢。 */
|
|
51
|
+
export const VERIFY_POLL_SECONDS = 60
|
|
52
|
+
|
|
53
|
+
/** 交付要求:三类模型都会照做,不依赖具体仓库结构。 */
|
|
54
|
+
const DELIVERY_RULES = [
|
|
55
|
+
'先读相关代码再动手,改动尽量小而且能用。',
|
|
56
|
+
'每条验收判据都要给出可复核的验证(测试、命令或截图路径),不要只说「已完成」。',
|
|
57
|
+
'只做本卡范围内的改动;发现范围外的问题,写进总结而不是顺手改。',
|
|
58
|
+
'最后用三五行总结:改了什么、怎么验证的、还有什么没做。不要贴大段代码。',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** 证据要求:只有开了交付门槛才写进 prompt(关掉 = 省钱,也放弃了可复核性)。 */
|
|
62
|
+
const EVIDENCE_RULES = [
|
|
63
|
+
'开工前先删掉这张卡上一轮的 `out/<本卡 id>/report.md`;报告只在最后一步写——复验时验收卡才不会把上一轮的旧报告当成本轮证据。',
|
|
64
|
+
'把交付报告写到 `out/<本卡 id>/report.md`(相对工作区根目录)。',
|
|
65
|
+
'报告里逐条写:判据 id、期望、实际、证据(命令输出 / 测试名 / 文件路径)。',
|
|
66
|
+
'带上本次改动的 commit sha;没有 commit 就写 `none` 并说明为什么。',
|
|
67
|
+
'没有报告或报告缺条目,这张卡就不算完成——验收卡会据此打回。',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function criteriaId(sequence) {
|
|
71
|
+
return `C-${sequence < 10 ? `0${sequence}` : sequence}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 判据归一化(带警告)。
|
|
76
|
+
*
|
|
77
|
+
* 人只会写正文(「能算加减法」),但看板/台账的判据 id 有格式要求,
|
|
78
|
+
* 所以没写编号就按出现顺序补 `C-01`、`C-02`…;写了编号就沿用并接着往下编。
|
|
79
|
+
*
|
|
80
|
+
* 三道收敛(`/split` 可被直接 curl,服务端必须自己扛住):
|
|
81
|
+
* - 超过 `MAX_CRITERIA` 条只收前面的;
|
|
82
|
+
* - 单条超过 `MAX_CRITERIA_TEXT` 字截断;
|
|
83
|
+
* - 编号重复时重新编号,否则验收按 `criteriaId` 归卡会歧义。
|
|
84
|
+
*
|
|
85
|
+
* @param {Array<string|{id?:string,text:string}>} criteria
|
|
86
|
+
* @returns {{criteria:Array<{id:string,text:string}>, warnings:Array<string>}}
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeCriteriaDetailed(criteria = []) {
|
|
89
|
+
let sequence = 0
|
|
90
|
+
const used = new Set()
|
|
91
|
+
const out = []
|
|
92
|
+
const warnings = []
|
|
93
|
+
let clipped = 0
|
|
94
|
+
let dropped = 0
|
|
95
|
+
let renumbered = 0
|
|
96
|
+
|
|
97
|
+
const nextId = () => {
|
|
98
|
+
sequence += 1
|
|
99
|
+
while (used.has(criteriaId(sequence))) sequence += 1
|
|
100
|
+
return criteriaId(sequence)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const raw of criteria) {
|
|
104
|
+
if (out.length >= MAX_CRITERIA) {
|
|
105
|
+
dropped += 1
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const isObject = raw !== null && typeof raw === 'object'
|
|
109
|
+
let text = String((isObject ? raw.text : raw) ?? '').trim()
|
|
110
|
+
if (text === '') continue
|
|
111
|
+
const rawId = isObject ? String(raw.id ?? '').trim() : ''
|
|
112
|
+
let id = /^C-\d{2,}$/.test(rawId) ? rawId : ''
|
|
113
|
+
if (id !== '') {
|
|
114
|
+
sequence = Math.max(sequence, Number.parseInt(id.slice(2), 10) || 0)
|
|
115
|
+
} else {
|
|
116
|
+
// 字符串形态里可能自带编号(「C-05 自定义编号」),解析出来并让后续编号接着走。
|
|
117
|
+
// 页面那边会先解析一遍,但 /split 是可直接 curl 的,服务端必须自己扛得住。
|
|
118
|
+
const explicit = /^(C-\d{2,})[\s::、.\-—]*(.*)$/.exec(text)
|
|
119
|
+
if (explicit) {
|
|
120
|
+
id = explicit[1]
|
|
121
|
+
text = explicit[2].trim()
|
|
122
|
+
sequence = Math.max(sequence, Number.parseInt(id.slice(2), 10) || 0)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (text === '') continue
|
|
126
|
+
if (id === '') {
|
|
127
|
+
id = nextId()
|
|
128
|
+
} else if (used.has(id)) {
|
|
129
|
+
id = nextId()
|
|
130
|
+
renumbered += 1
|
|
131
|
+
}
|
|
132
|
+
used.add(id)
|
|
133
|
+
if (text.length > MAX_CRITERIA_TEXT) {
|
|
134
|
+
text = `${text.slice(0, MAX_CRITERIA_TEXT - 1)}…`
|
|
135
|
+
clipped += 1
|
|
136
|
+
}
|
|
137
|
+
out.push({ id, text })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (dropped > 0) warnings.push(`判据超过 ${MAX_CRITERIA} 条,后面的 ${dropped} 条没有收进来`)
|
|
141
|
+
if (clipped > 0) warnings.push(`有 ${clipped} 条判据超过 ${MAX_CRITERIA_TEXT} 字,已截断`)
|
|
142
|
+
if (renumbered > 0) warnings.push(`有 ${renumbered} 条判据编号重复,已重新编号`)
|
|
143
|
+
return { criteria: out, warnings }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 判据归一化(只要结果,不要警告)。
|
|
148
|
+
*
|
|
149
|
+
* @param {Array<string|{id?:string,text:string}>} criteria
|
|
150
|
+
* @returns {Array<{id:string, text:string}>}
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeCriteria(criteria = []) {
|
|
153
|
+
return normalizeCriteriaDetailed(criteria).criteria
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clip(text, max) {
|
|
157
|
+
const value = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
158
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function criteriaLines(criteria) {
|
|
162
|
+
return criteria.map((item) => `- ${item.id} ${item.text}`).join('\n')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 拼一张卡的 prompt。做成纯函数是为了让「投给看板的东西」可审、可测。
|
|
167
|
+
*
|
|
168
|
+
* `intro` 是模型按需求写的那段「目标 / 范围 / 不要做什么」,插在最前面;
|
|
169
|
+
* 判据、交付要求、证据要求仍然由这里确定性拼上——模型可以改措辞,
|
|
170
|
+
* 但不能把「完成要交证据」这条契约写丢。
|
|
171
|
+
*
|
|
172
|
+
* @param {object} input
|
|
173
|
+
* @returns {string}
|
|
174
|
+
*/
|
|
175
|
+
export function buildPrompt({ title, description = '', criteria = [], scopeNote = '', cardId = null, intro = '' }) {
|
|
176
|
+
const parts = []
|
|
177
|
+
const written = String(intro ?? '').trim()
|
|
178
|
+
if (written !== '') {
|
|
179
|
+
parts.push(written)
|
|
180
|
+
} else {
|
|
181
|
+
parts.push(`目标:${title}`)
|
|
182
|
+
if (description.trim() !== '') parts.push(`背景:${description.trim()}`)
|
|
183
|
+
if (scopeNote.trim() !== '') parts.push(`范围:${scopeNote.trim()}`)
|
|
184
|
+
}
|
|
185
|
+
if (criteria.length > 0) parts.push(`${CRITERIA_HEADING}\n${criteriaLines(criteria)}`)
|
|
186
|
+
parts.push(`要求:\n${DELIVERY_RULES.map((rule, index) => `${index + 1}. ${rule}`).join('\n')}`)
|
|
187
|
+
if (cardId !== null) {
|
|
188
|
+
// 报告路径必须写死成这张卡自己的 id:执行会话读不到本插件的上下文,
|
|
189
|
+
// 只写「写到 out/」它会自己编一个目录,验收卡就找不到。
|
|
190
|
+
parts.push(
|
|
191
|
+
`交付证据:\n${EVIDENCE_RULES.map((rule, index) => `${index + 1}. ${rule.replace('<本卡 id>', cardId)}`).join('\n')}`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
return parts.join('\n\n')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 验收卡的 prompt。
|
|
199
|
+
*
|
|
200
|
+
* 这张卡的存在理由是基线 §3.3:**验证必须独立,不能和开发是同一张卡**。
|
|
201
|
+
* 它的产出只有两种(PASS / REJECT),REJECT 必须带 `criteriaId + expected + actual + evidencePath`——
|
|
202
|
+
* 没有定位的「不行」等于没验收,开发只能瞎改。
|
|
203
|
+
*
|
|
204
|
+
* @param {object} input
|
|
205
|
+
* @param {string} input.title
|
|
206
|
+
* @param {Array<{id:string,text:string}>} input.criteria
|
|
207
|
+
* @param {Array<{id:string,title:string}>} input.targets 待验收的卡
|
|
208
|
+
* @param {number} [input.maxReworkRounds] 打回上限(与配置同一来源,不能写死)
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
211
|
+
export function buildVerifyPrompt({ title, criteria = [], targets = [], rootId = '', maxReworkRounds = 2 }) {
|
|
212
|
+
const limit = Math.max(0, Math.trunc(Number(maxReworkRounds) || 0))
|
|
213
|
+
const targetLines = targets
|
|
214
|
+
.map((target) => `- ${target.title}(卡 ${target.id})→ 报告 out/${target.id}/report.md`)
|
|
215
|
+
.join('\n')
|
|
216
|
+
return [
|
|
217
|
+
`目标:独立验收「${title}」。只核对证据,不改实现。`,
|
|
218
|
+
'等待交付(你很可能和开发卡同时启动,报告还没写完):',
|
|
219
|
+
[
|
|
220
|
+
`1. 开始后先等 30 秒再读第一遍报告:开发卡可能刚启动,正按自己的要求删掉上一轮的旧报告。`,
|
|
221
|
+
`2. 先看下面列出的报告是否都在;都在就进入验收。`,
|
|
222
|
+
`3. 报告必须比它所在卡最近一次执行的 startedAt 新(有任务看板工具就用 task_board_get 看 executions):更旧的是上一轮报告,继续等,不要当本轮证据。`,
|
|
223
|
+
`4. 有缺失或报告陈旧时不要立刻 REJECT:每 ${VERIFY_POLL_SECONDS} 秒看一次(例如 PowerShell:Start-Sleep -Seconds ${VERIFY_POLL_SECONDS})。`,
|
|
224
|
+
'5. 某张卡已经结束(executions 出现 endedAt)但仍缺本轮报告 → 立即 REJECT,summary 写「卡 <id> 已结束但报告缺失」。',
|
|
225
|
+
`6. 最多等 ${VERIFY_WAIT_MINUTES} 分钟;超时仍缺报告 → REJECT,summary 写「等待报告超时」。`,
|
|
226
|
+
'7. 报告缺失时的 REJECT 也要给出 {criteriaId, expected, actual, evidencePath}:expected 写「报告存在」,actual 写「缺失」,evidencePath 指向本应存在的 report.md。',
|
|
227
|
+
].join('\n'),
|
|
228
|
+
'职责:',
|
|
229
|
+
[
|
|
230
|
+
'1. 逐张读上面等到的报告。',
|
|
231
|
+
'2. 对每条判据核对「期望 / 实际 / 证据」是否对得上;证据必须是能复核的东西(命令输出、测试名、文件路径、commit sha)。',
|
|
232
|
+
'3. 结论只能是 PASS 或 REJECT:',
|
|
233
|
+
' - PASS:逐条给出判据 id 与证据位置;',
|
|
234
|
+
' - REJECT:逐条写 {criteriaId, expected, actual, evidencePath},缺一项都不算数。',
|
|
235
|
+
`4. 同一批最多打回 ${limit} 次;第 ${limit + 1} 次停下来把问题交给人,不要再让开发继续改(对应基线 R3)。`,
|
|
236
|
+
].join('\n'),
|
|
237
|
+
[
|
|
238
|
+
'结论落盘(必须做,否则这次验收等于没发生):',
|
|
239
|
+
`把结论写成 \`${verdictPath(rootId)}\`,形状:`,
|
|
240
|
+
'{"verdict":"PASS|REJECT","summary":"一句话","items":[{"criteriaId":"C-01","expected":"…","actual":"…","evidencePath":"…"}]}',
|
|
241
|
+
'REJECT 时 items 不能为空,四项都要有。插件与人只认这个文件,不认会话里的口头结论。',
|
|
242
|
+
].join('\n'),
|
|
243
|
+
'禁止:自己动手改代码或「顺手修一下」——验证与开发合并就失去意义(基线 §3.3);禁止凭「看起来没问题」放行。',
|
|
244
|
+
`待验收的卡:\n${targetLines}`,
|
|
245
|
+
`${CRITERIA_HEADING}\n${criteriaLines(criteria)}`,
|
|
246
|
+
].join('\n\n')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 判据分组:把 n 条判据尽量均匀地分成若干组,组数不超过组内上限。
|
|
251
|
+
*
|
|
252
|
+
* @param {Array} criteria
|
|
253
|
+
* @param {number} maxGroups
|
|
254
|
+
* @returns {Array<Array>}
|
|
255
|
+
*/
|
|
256
|
+
function groupCriteria(criteria, maxGroups) {
|
|
257
|
+
const groups = Math.max(1, Math.min(maxGroups, criteria.length))
|
|
258
|
+
const out = []
|
|
259
|
+
let cursor = 0
|
|
260
|
+
for (let index = 0; index < groups; index += 1) {
|
|
261
|
+
const remaining = criteria.length - cursor
|
|
262
|
+
const size = Math.ceil(remaining / (groups - index))
|
|
263
|
+
out.push(criteria.slice(cursor, cursor + size))
|
|
264
|
+
cursor += size
|
|
265
|
+
}
|
|
266
|
+
return out.filter((group) => group.length > 0)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 只算结构,不生成信封:用来告诉模型「这次会拆成哪几张卡」。
|
|
271
|
+
*
|
|
272
|
+
* 和 splitRequirement 共用同一套分组与命名,所以模型按这些 key/title 写的提示词,
|
|
273
|
+
* 一定能落到对应的卡上(id 是随机的,key 是稳定的)。
|
|
274
|
+
*
|
|
275
|
+
* @param {object} input
|
|
276
|
+
* @returns {Array<{key:string, title:string, criteriaIds:Array<string>}>}
|
|
277
|
+
*/
|
|
278
|
+
export function outlineCards({
|
|
279
|
+
title,
|
|
280
|
+
description = '',
|
|
281
|
+
criteria = [],
|
|
282
|
+
parallel = true,
|
|
283
|
+
requireEvidence = false,
|
|
284
|
+
} = {}) {
|
|
285
|
+
const cleanTitle = clip(String(title ?? ''), 120)
|
|
286
|
+
const normalized = normalizeCriteria(criteria)
|
|
287
|
+
const shouldSplit = parallel && normalized.length >= 2
|
|
288
|
+
const out = [{
|
|
289
|
+
key: 'root',
|
|
290
|
+
title: cleanTitle,
|
|
291
|
+
criteriaIds: shouldSplit ? [] : normalized.map((item) => item.id),
|
|
292
|
+
}]
|
|
293
|
+
if (shouldSplit) {
|
|
294
|
+
const groups = groupCriteria(normalized, 4)
|
|
295
|
+
groups.forEach((group, index) => {
|
|
296
|
+
out.push({
|
|
297
|
+
key: `part-${index + 1}`,
|
|
298
|
+
title: `${cleanTitle}(${index + 1}/${groups.length})${clip(group[0].text, 28)}`,
|
|
299
|
+
criteriaIds: group.map((item) => item.id),
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
if (requireEvidence === true && normalized.length > 0) {
|
|
304
|
+
out.push({ key: 'verify', title: `${clip(cleanTitle, 60)}(验收)`, criteriaIds: [] })
|
|
305
|
+
}
|
|
306
|
+
void description
|
|
307
|
+
return out
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 32 位十六进制 → 稳定的 UUID 形状 id。
|
|
312
|
+
*
|
|
313
|
+
* 为什么不用 randomUUID:卡片 id 稳定,同一批重试时看板能按 requestId 幂等重放,
|
|
314
|
+
* 不会「失败一次就多一棵重复的树」。看板只要求 id 是非空字符串,UUID 形状只是好认。
|
|
315
|
+
*
|
|
316
|
+
* @param {string} seed
|
|
317
|
+
* @returns {string}
|
|
318
|
+
*/
|
|
319
|
+
function stableId(seed) {
|
|
320
|
+
const chars = createHash('sha256').update(String(seed)).digest('hex').slice(0, 32).split('')
|
|
321
|
+
chars[12] = '4'
|
|
322
|
+
chars[16] = '89ab'[Number.parseInt(chars[16], 16) % 4]
|
|
323
|
+
const value = chars.join('')
|
|
324
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20, 32)}`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 看板 action 信封:`{ requestId, action }`,形状由看板 `parseActionEnvelope` 校验。 */
|
|
328
|
+
function envelope(action, requestId) {
|
|
329
|
+
if (typeof requestId !== 'string' || requestId === '') throw new Error('信封必须有 requestId')
|
|
330
|
+
return { requestId, action }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 把一个需求拆成「父卡 + 可选子卡」,并生成可直接投递给看板的信封。
|
|
335
|
+
*
|
|
336
|
+
* 幂等:同一份输入(标题 / 描述 / 判据 / 跑法 / 提示词,10 分钟窗口内)得到同一批
|
|
337
|
+
* 卡片 id 与 requestId。页面重试、用户连点两次,看板都会按 requestId 重放而不是再建一份。
|
|
338
|
+
*
|
|
339
|
+
* @param {object} input
|
|
340
|
+
* @param {string} input.title 需求标题
|
|
341
|
+
* @param {string} [input.description] 需求描述
|
|
342
|
+
* @param {Array} [input.criteria] 判据(字符串或 {id,text})
|
|
343
|
+
* @param {boolean} [input.parallel] 是否按判据拆成并行子卡
|
|
344
|
+
* @param {string|null} [input.workspaceId] 钉住的工作区
|
|
345
|
+
* @param {string|null} [input.agentPreset] 钉住的 agent 预设(看板字段名是 mode)
|
|
346
|
+
* @param {string|null} [input.permission] 权限预设
|
|
347
|
+
* @param {boolean} [input.requireEvidence] 交付门槛
|
|
348
|
+
* @param {boolean} [input.teamRun] 用 teamRun 卡片
|
|
349
|
+
* @param {object} [input.intros] 模型写的开头段
|
|
350
|
+
* @param {number} [input.maxReworkRounds] 打回上限(写进验收 prompt,不能写死)
|
|
351
|
+
* @param {number} [input.now] 幂等窗口用的时间(测试可注入)
|
|
352
|
+
* @returns {{rootId:string, verifyId:string|null, cards:Array, envelopes:Array, run:object|null, warnings:Array<string>}}
|
|
353
|
+
*/
|
|
354
|
+
export function splitRequirement({
|
|
355
|
+
title,
|
|
356
|
+
description = '',
|
|
357
|
+
criteria = [],
|
|
358
|
+
parallel = true,
|
|
359
|
+
workspaceId = null,
|
|
360
|
+
agentPreset = null,
|
|
361
|
+
permission = 'workspace-write',
|
|
362
|
+
requireEvidence = false,
|
|
363
|
+
teamRun = false,
|
|
364
|
+
/** 模型按需求写好的开头段,按卡片 key(root / part-1 / verify)索引。 */
|
|
365
|
+
intros = {},
|
|
366
|
+
maxReworkRounds = 2,
|
|
367
|
+
now = Date.now(),
|
|
368
|
+
} = {}) {
|
|
369
|
+
const cleanTitle = clip(String(title ?? ''), 120)
|
|
370
|
+
if (cleanTitle === '') throw new Error('需求标题不能为空')
|
|
371
|
+
|
|
372
|
+
const { criteria: normalized, warnings: criteriaWarnings } = normalizeCriteriaDetailed(criteria)
|
|
373
|
+
const warnings = [...criteriaWarnings]
|
|
374
|
+
const limit = Math.max(0, Math.trunc(Number(maxReworkRounds) || 0))
|
|
375
|
+
const evidenceOn = requireEvidence === true
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 拆不拆子卡,取决于跑法:
|
|
379
|
+
* - 开了智能体团队:父卡是 Lead,子卡由它在**同一个**会话里派成 teammate,拆得越多越划算;
|
|
380
|
+
* - 没开:每张子卡都是**一条独立会话**,拆 4 条判据就是 4 次开会话,所以只建一张卡。
|
|
381
|
+
* 这条规则不放进开关表里让人二次确认:会话条数由跑法决定,不该由两个开关的组合决定。
|
|
382
|
+
*/
|
|
383
|
+
const shouldSplit = teamRun === true && parallel && normalized.length >= 2
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 父卡用不用 Lead 会话(teamRun):
|
|
387
|
+
* - 勾了智能体团队:用,父卡当 Lead 派子卡;
|
|
388
|
+
* - 没勾但开了交付门槛:也用——只有「父卡 + 验收卡」两张,没必要各开一条会话;
|
|
389
|
+
* 父卡当 Lead 自己干活,验收卡是同一会话里的 teammate。
|
|
390
|
+
* 所以「智能体团队」仍然只决定拆不拆多张开发卡,不决定验收卡跑在哪。
|
|
391
|
+
*/
|
|
392
|
+
const runAsTeam = teamRun === true || (evidenceOn && normalized.length > 0)
|
|
393
|
+
|
|
394
|
+
// 幂等种子:同一份输入 + 同一个 10 分钟窗口 = 同一批卡。
|
|
395
|
+
// 提示词也算进种子:重新让模型写过提示词就是新的一批,否则 requestId 会撞上不同 action。
|
|
396
|
+
const batchKey = createHash('sha256')
|
|
397
|
+
.update(JSON.stringify({
|
|
398
|
+
title: cleanTitle,
|
|
399
|
+
description: description.trim(),
|
|
400
|
+
criteria: normalized.map((item) => [item.id, item.text]),
|
|
401
|
+
parallel: parallel === true,
|
|
402
|
+
teamRun: teamRun === true,
|
|
403
|
+
runAsTeam: runAsTeam === true,
|
|
404
|
+
requireEvidence: evidenceOn,
|
|
405
|
+
workspaceId: workspaceId ?? null,
|
|
406
|
+
agentPreset: agentPreset ?? null,
|
|
407
|
+
permission: permission ?? null,
|
|
408
|
+
introsKey: createHash('sha256').update(JSON.stringify(intros ?? {})).digest('hex').slice(0, 16),
|
|
409
|
+
bucket: Math.floor(now / CREATE_IDEMPOTENCY_MS),
|
|
410
|
+
}))
|
|
411
|
+
.digest('hex')
|
|
412
|
+
const requestBase = `card-dispatch-${batchKey.slice(0, 20)}`
|
|
413
|
+
const rootId = stableId(`${batchKey}:root`)
|
|
414
|
+
let createIndex = 0
|
|
415
|
+
const createEnvelope = (id, input) =>
|
|
416
|
+
envelope({ kind: 'create', id, input }, `${requestBase}-create-${createIndex++}`)
|
|
417
|
+
|
|
418
|
+
// 卡片公共字段:null 表示「不指定」,绝不能写进去(看板按 exactKeys 校验,多余字段直接 400)
|
|
419
|
+
const shared = {
|
|
420
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
421
|
+
...(agentPreset ? { mode: agentPreset } : {}),
|
|
422
|
+
...(permission ? { permission } : {}),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const verifyWillExist = evidenceOn && normalized.length > 0
|
|
426
|
+
const rootInput = {
|
|
427
|
+
title: cleanTitle,
|
|
428
|
+
description: description.trim(),
|
|
429
|
+
prompt: buildPrompt({
|
|
430
|
+
title: cleanTitle,
|
|
431
|
+
description,
|
|
432
|
+
criteria: normalized,
|
|
433
|
+
intro: intros.root ?? '',
|
|
434
|
+
// 不拆时父卡就是干活的那张,要写报告;拆了之后父卡是协调者(teamRun 的 Lead),
|
|
435
|
+
// 它不产出报告——让验收卡去要它的报告只会误伤。
|
|
436
|
+
cardId: evidenceOn && !shouldSplit ? rootId : null,
|
|
437
|
+
}),
|
|
438
|
+
...shared,
|
|
439
|
+
// 智能体团队只在父卡上开:看板见到根卡 teamRun=true 就只起一个 Lead 会话,
|
|
440
|
+
// 由它把每张子卡派成 teammate。子卡不能再写 teamRun,否则会被当成另一个 Lead。
|
|
441
|
+
// 交付门槛开着时即使没勾团队也走 Lead 会话:父卡 + 验收卡两张没必要开两条会话。
|
|
442
|
+
...(runAsTeam ? { teamRun: true } : {}),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (runAsTeam && shouldSplit) {
|
|
446
|
+
// 根卡在 teamRun 模式下就是 Lead:顺序只能写在 prompt 里——看板没有依赖字段,
|
|
447
|
+
// 靠平台排不出「先开发后验收」,只能让 Lead 自己守这个顺序。
|
|
448
|
+
// 验收卡已经和子卡一起启动并在等报告,所以这里说「等结论」,不再让它去「执行验收卡」,
|
|
449
|
+
// 否则同一张验收卡会被跑两次。
|
|
450
|
+
// 没生成验收卡时不能再提验收卡,否则执行者会去找一张不存在的卡。
|
|
451
|
+
rootInput.prompt += verifyWillExist
|
|
452
|
+
? `\n\n流程:先派发子卡完成开发 → 等子卡交付 → 验收卡已在会话里等报告,报告齐全后会自动给出 PASS/REJECT;等它给结论再收尾。被打回最多 ${limit} 次,第 ${limit + 1} 次停下来交给人。`
|
|
453
|
+
: '\n\n流程:先派发子卡完成开发 → 等子卡交付 → 汇总结果。本次没有验收卡。'
|
|
454
|
+
} else if (runAsTeam) {
|
|
455
|
+
// 没拆子卡时根卡**就是干活的那张**(上面可能已经让它写报告),
|
|
456
|
+
// 这里不能再写「父卡不产出报告」——两句会互相打架,验收必打回。
|
|
457
|
+
rootInput.prompt += evidenceOn
|
|
458
|
+
? '\n\n执行方式:这一轮由你自己(Lead)完成,不派子卡;按上面的交付要求产出报告。验收卡已经在同一会话里等这份报告,写完等它给出结论再收尾。'
|
|
459
|
+
: '\n\n执行方式:这一轮由你自己(Lead)完成,不派子卡。'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const cards = [{ id: rootId, key: 'root', title: cleanTitle, input: rootInput }]
|
|
463
|
+
const envelopes = [createEnvelope(rootId, rootInput)]
|
|
464
|
+
|
|
465
|
+
if (normalized.length === 0) {
|
|
466
|
+
warnings.push('这个需求没有验收判据,看板里的执行者不知道做到什么算完;建议补几条。')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (shouldSplit) {
|
|
470
|
+
const groups = groupCriteria(normalized, 4)
|
|
471
|
+
groups.forEach((group, index) => {
|
|
472
|
+
const id = stableId(`${batchKey}:part-${index + 1}`)
|
|
473
|
+
const groupTitle = `${cleanTitle}(${index + 1}/${groups.length})${clip(group[0].text, 28)}`
|
|
474
|
+
const input = {
|
|
475
|
+
title: groupTitle,
|
|
476
|
+
description: `需求「${cleanTitle}」的第 ${index + 1} 部分,判据:${group.map((item) => item.id).join('、')}`,
|
|
477
|
+
prompt: buildPrompt({
|
|
478
|
+
title: groupTitle,
|
|
479
|
+
description,
|
|
480
|
+
criteria: group,
|
|
481
|
+
scopeNote: `这是需求「${cleanTitle}」拆分后的第 ${index + 1}/${groups.length} 部分,只做这几条判据;其余部分由同级的其它卡负责,不要重复实现。`,
|
|
482
|
+
cardId: evidenceOn ? id : null,
|
|
483
|
+
intro: intros[`part-${index + 1}`] ?? '',
|
|
484
|
+
}),
|
|
485
|
+
parentId: rootId,
|
|
486
|
+
...shared,
|
|
487
|
+
}
|
|
488
|
+
cards.push({ id, key: `part-${index + 1}`, title: groupTitle, input })
|
|
489
|
+
envelopes.push(createEnvelope(id, input))
|
|
490
|
+
})
|
|
491
|
+
warnings.push(
|
|
492
|
+
`拆成 ${cards.length} 张卡:父卡 1 张 + 子卡 ${groups.length} 张。子卡在看板里是并行的,所以每张都自带验收判据。`,
|
|
493
|
+
)
|
|
494
|
+
} else if (normalized.length >= 2 && teamRun !== true) {
|
|
495
|
+
warnings.push('没开智能体团队:不按判据拆子卡(每张子卡会各开一条会话),全部判据写进一张卡。')
|
|
496
|
+
} else if (normalized.length >= 2) {
|
|
497
|
+
warnings.push('没勾选并行拆分,全部判据都写进同一张卡,由一次执行完成。')
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 验收卡:只在开了交付门槛、且确实有判据可验时生成。
|
|
501
|
+
// 它是**另一张卡**而不是父卡的一部分,因为基线 §3.3 要求验证独立于开发;
|
|
502
|
+
// 同一张卡自己说「我做完了」不算验收。
|
|
503
|
+
// 和有没有开智能体团队无关:门槛是「必须有人独立核对」的意思,不能因为省一条会话就悄悄失效。
|
|
504
|
+
let verifyId = null
|
|
505
|
+
let verifyTitle = ''
|
|
506
|
+
if (evidenceOn && normalized.length > 0) {
|
|
507
|
+
verifyId = stableId(`${batchKey}:verify`)
|
|
508
|
+
// 拆过的需求逐张列子卡;单卡需求时待验收的就是父卡本身。
|
|
509
|
+
const targets = shouldSplit
|
|
510
|
+
? cards.filter((card) => card.key !== 'root').map((card) => ({ id: card.id, title: card.title }))
|
|
511
|
+
: cards.map((card) => ({ id: card.id, title: card.title }))
|
|
512
|
+
verifyTitle = `${clip(cleanTitle, 60)}(验收)`
|
|
513
|
+
const input = {
|
|
514
|
+
title: verifyTitle,
|
|
515
|
+
description: `独立验收「${cleanTitle}」:只核对证据,不改实现。`,
|
|
516
|
+
prompt: buildVerifyPrompt({ title: cleanTitle, criteria: normalized, targets, rootId, maxReworkRounds: limit }),
|
|
517
|
+
parentId: rootId,
|
|
518
|
+
...shared,
|
|
519
|
+
}
|
|
520
|
+
cards.push({ id: verifyId, key: 'verify', title: verifyTitle, input })
|
|
521
|
+
envelopes.push(createEnvelope(verifyId, input))
|
|
522
|
+
warnings.push('已附一张验收卡:它只读证据、只给 PASS/REJECT,和开发不是同一张卡(基线 §3.3)。')
|
|
523
|
+
// 验收卡是父卡的子卡,运行父卡时**同一次运行**就会把它带起来,不需要人再点一次。
|
|
524
|
+
// 代价是它可能比开发卡先启动,所以 prompt 里要求它等报告(见 buildVerifyPrompt)。
|
|
525
|
+
warnings.push('验收卡会随父卡一起启动并在报告齐全后自动验收,不需要手动运行;它可能比开发卡先启动,属于正常现象。')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 会话条数:Lead 模式下所有卡都在**同一个** Lead 会话里跑(teammate 是这个会话里的下级),
|
|
529
|
+
// 所以卡多不等于会话多。这句话得说出来——否则人会以为 6 张卡就是 6 次计费。
|
|
530
|
+
if (runAsTeam && cards.length > 1) {
|
|
531
|
+
warnings.push(teamRun === true
|
|
532
|
+
? `开智能体团队:这 ${cards.length} 张卡在同一个 Lead 会话里跑,只算 1 条会话。`
|
|
533
|
+
: `交付门槛:父卡 + 验收卡在同一个 Lead 会话里跑(验收卡是 teammate),只算 1 条会话;开发由父卡自己完成。`)
|
|
534
|
+
} else if (!runAsTeam && cards.length > 1) {
|
|
535
|
+
warnings.push(`没开智能体团队:各卡各开一条会话,这次一共 ${cards.length} 条;验收卡会等报告出现后自动验收。`)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
rootId,
|
|
540
|
+
verifyId,
|
|
541
|
+
cards,
|
|
542
|
+
envelopes,
|
|
543
|
+
run: envelope(
|
|
544
|
+
{ kind: 'run', taskId: rootId },
|
|
545
|
+
`${requestBase}-run-${Math.floor(now / RUN_IDEMPOTENCY_MS)}`,
|
|
546
|
+
),
|
|
547
|
+
warnings,
|
|
548
|
+
}
|
|
549
|
+
}
|
package/src/verdict.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verdict.js:验收结论的**文件契约**。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么是文件,不是看板状态
|
|
5
|
+
*
|
|
6
|
+
* 「打回之后谁重跑」原本看起来只能靠读看板状态,但两条路都不通:
|
|
7
|
+
*
|
|
8
|
+
* 1. 插件读不到看板:`GET /api/task-board/state` 走的是看板自己的信任栅栏,
|
|
9
|
+
* 插件进程没有那张 cookie,实测 403;
|
|
10
|
+
* 2. 就算读到了也没用:`ExecutionRecord.result` 只记「这次执行成没成」,
|
|
11
|
+
* 不记「验收判 PASS 还是 REJECT」——验收结论只活在会话记录里。
|
|
12
|
+
*
|
|
13
|
+
* 所以把结论**写进文件**:验收卡产出 `out/<根卡 id>/verdict.json`。
|
|
14
|
+
* 文件的好处是三方都能读:人、看板里的下一次执行、以及插件(人点一下时)。
|
|
15
|
+
* 这也顺带满足基线 P3——会话状态不可重建即不存在,落盘的才存在。
|
|
16
|
+
*
|
|
17
|
+
* 这个模块只做解析与判定,不读盘、不联网,所以能逐条单测。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** verdict.json 里 REJECT 的每一项必须带齐这四个字段。 */
|
|
21
|
+
const ITEM_KEYS = ['criteriaId', 'expected', 'actual', 'evidencePath']
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析 verdict.json。
|
|
25
|
+
*
|
|
26
|
+
* 故意严格:REJECT 不给定位信息 = 开发只能瞎改,所以「缺字段」本身就是错误,
|
|
27
|
+
* 而不是一条可以忽略的警告。
|
|
28
|
+
*
|
|
29
|
+
* @param {string|object} raw 文件内容或已解析对象
|
|
30
|
+
* @returns {{ok:boolean, verdict:string|null, items:Array, problems:Array<string>, summary:string, error:string|null}}
|
|
31
|
+
*/
|
|
32
|
+
export function parseVerdict(raw) {
|
|
33
|
+
const empty = { ok: false, verdict: null, items: [], problems: [], summary: '', error: null }
|
|
34
|
+
let data = raw
|
|
35
|
+
if (typeof raw === 'string') {
|
|
36
|
+
const text = raw.trim()
|
|
37
|
+
if (text === '') return { ...empty, error: 'verdict.json 是空文件' }
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(text)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { ...empty, error: `verdict.json 不是合法 JSON:${error.message}` }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
45
|
+
return { ...empty, error: 'verdict.json 的顶层必须是对象' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const verdict = String(data.verdict ?? '').toUpperCase()
|
|
49
|
+
if (verdict !== 'PASS' && verdict !== 'REJECT') {
|
|
50
|
+
return { ...empty, error: `verdict 只能是 PASS 或 REJECT,收到 ${JSON.stringify(data.verdict)}` }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const items = Array.isArray(data.items) ? data.items : []
|
|
54
|
+
const problems = []
|
|
55
|
+
if (verdict === 'REJECT') {
|
|
56
|
+
if (items.length === 0) {
|
|
57
|
+
problems.push('REJECT 必须逐条列出 items:没有定位的「不行」等于没验收')
|
|
58
|
+
}
|
|
59
|
+
items.forEach((item, index) => {
|
|
60
|
+
const missing = ITEM_KEYS.filter((key) => String(item?.[key] ?? '').trim() === '')
|
|
61
|
+
if (missing.length > 0) problems.push(`items[${index}] 缺 ${missing.join('、')}`)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
ok: problems.length === 0,
|
|
67
|
+
verdict,
|
|
68
|
+
items,
|
|
69
|
+
problems,
|
|
70
|
+
summary: String(data.summary ?? '').trim(),
|
|
71
|
+
error: null,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 拿到 verdict 之后,下一步该干什么。
|
|
77
|
+
*
|
|
78
|
+
* 三种结局,正好对应基线的三条线:
|
|
79
|
+
* - `pass`:验收通过 → 父卡可以收尾;
|
|
80
|
+
* - `rework`:打回,且还没到上限 → 重跑开发卡(**这才是真正花钱的那一步**);
|
|
81
|
+
* - `escalate`:到上限 → 停下来交给人(基线 R3),不再让开发瞎改;
|
|
82
|
+
* - `invalid`:verdict 本身不合格 → 让验收卡重做,不要拿它当依据。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} input
|
|
85
|
+
* @param {object} input.parsed parseVerdict 的结果
|
|
86
|
+
* @param {object} input.config 归一化配置(用 gates.maxReworkRounds)
|
|
87
|
+
* @param {number} [input.rejectedCount] 这一批已经打回过的次数
|
|
88
|
+
* @returns {{action:'pass'|'rework'|'escalate'|'invalid', rejectedCount:number, message:string, items:Array}}
|
|
89
|
+
*/
|
|
90
|
+
export function decideNext({ parsed, config, rejectedCount = 0 } = {}) {
|
|
91
|
+
const previous = Math.max(0, Math.trunc(Number(rejectedCount) || 0))
|
|
92
|
+
if (parsed === undefined || parsed === null || parsed.error !== null || parsed.verdict === null) {
|
|
93
|
+
return {
|
|
94
|
+
action: 'invalid',
|
|
95
|
+
rejectedCount: previous,
|
|
96
|
+
message: `验收结论不可用:${parsed?.error ?? '没有读到结论'};让验收卡重做一次,别拿它当依据`,
|
|
97
|
+
items: [],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!parsed.ok) {
|
|
101
|
+
return {
|
|
102
|
+
action: 'invalid',
|
|
103
|
+
rejectedCount: previous,
|
|
104
|
+
message: `验收结论不合格:${parsed.problems.join(';')}`,
|
|
105
|
+
items: parsed.items,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (parsed.verdict === 'PASS') {
|
|
109
|
+
return { action: 'pass', rejectedCount: previous, message: '验收通过,这批可以收尾', items: [] }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const limit = config.gates.maxReworkRounds
|
|
113
|
+
const next = previous + 1
|
|
114
|
+
if (previous >= limit) {
|
|
115
|
+
return {
|
|
116
|
+
action: 'escalate',
|
|
117
|
+
rejectedCount: previous,
|
|
118
|
+
message: `这将是第 ${next} 次打回,超过上限 ${limit}:停下来交给人(基线 R3),不要再让开发改`,
|
|
119
|
+
items: parsed.items,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
action: 'rework',
|
|
124
|
+
rejectedCount: next,
|
|
125
|
+
message: `第 ${next} 次打回(上限 ${limit}):把这几条带着证据退回开发`,
|
|
126
|
+
items: parsed.items,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 验收卡该把结论写到哪里(相对工作区根目录)。 */
|
|
131
|
+
export function verdictPath(rootId) {
|
|
132
|
+
return `out/${rootId}/verdict.json`
|
|
133
|
+
}
|