dsh-card-dispatch 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +249 -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 +56 -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 +964 -0
- package/src/criteria-llm.js +79 -0
- package/src/criteria.js +103 -0
- package/src/gates.js +123 -0
- package/src/human-channel.js +794 -0
- package/src/import.js +193 -0
- package/src/model-call.js +89 -0
- package/src/models.js +249 -0
- package/src/parse-llm.js +96 -0
- package/src/prompts-llm.js +104 -0
- package/src/selfcheck.js +117 -0
- package/src/split.js +397 -0
- package/src/verdict.js +133 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts-llm.js:让模型按需求给每张卡写「提示词开头」。
|
|
3
|
+
*
|
|
4
|
+
* 结构不由模型决定(拆几张、哪几条判据一组仍由 split.js 的规则算),模型只写
|
|
5
|
+
* 每张卡开头那段「目标 / 范围 / 明确不要做什么」。这样账单可预期,
|
|
6
|
+
* 而且「完成要交证据」这类硬要求仍由我们确定性地拼在后面,不会被模型写丢。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { callModel, extractJsonObject, MODEL_TIMEOUT_MS } from './model-call.js'
|
|
10
|
+
|
|
11
|
+
/** 一段开头最少多少字(太短就是没写)。 */
|
|
12
|
+
const MIN_INTRO = 12
|
|
13
|
+
|
|
14
|
+
/** 一段开头最多多少字(长了会挤掉判据与交付要求)。 */
|
|
15
|
+
const MAX_INTRO = 900
|
|
16
|
+
|
|
17
|
+
export const PROMPTS_SYSTEM = [
|
|
18
|
+
'你给任务卡写「提示词」的开头段:执行者会在一张新会话里读这段话干活,它看不到任何其它上下文。',
|
|
19
|
+
'只输出一个 JSON 对象,不要解释:{"cards":[{"key":"root","intro":"..."}]}',
|
|
20
|
+
'',
|
|
21
|
+
'每段 intro 写 3 到 6 行,依次说清:',
|
|
22
|
+
'- 目标:这一步要交付什么;',
|
|
23
|
+
'- 范围:只碰哪些文件 / 模块 / 场景;',
|
|
24
|
+
'- 不要做什么:最容易顺手做错的那一两件事。',
|
|
25
|
+
'',
|
|
26
|
+
'规则:',
|
|
27
|
+
'- key 必须原样使用给你的那一个,不要自己新增卡、不要合并卡。',
|
|
28
|
+
'- 每段都要自包含:不写「同上」「参见前面」,不引用别张卡才能看懂的话。',
|
|
29
|
+
'- 保留需求里出现的具体数字、型号、接口名;不要补需求没提的功能。',
|
|
30
|
+
'- 只写开头段,不要重复判据列表、不要写交付证据的要求(那些会由系统拼在后面)。',
|
|
31
|
+
'- 用需求本来使用的语言写。',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 把「需求原文 + 这张卡负责什么」整理成给模型的一次提问。
|
|
36
|
+
*
|
|
37
|
+
* @param {object} input
|
|
38
|
+
* @param {string} input.requirement 需求原文(标题 + 描述)
|
|
39
|
+
* @param {Array<{key:string,title:string,criteriaIds?:Array<string>,scope?:string}>} input.cards
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function buildBriefing({ requirement = '', cards = [] }) {
|
|
43
|
+
const cardLines = cards.map((card) => {
|
|
44
|
+
const ids = (card.criteriaIds ?? []).join('、')
|
|
45
|
+
return [
|
|
46
|
+
`- key: ${card.key}`,
|
|
47
|
+
` 卡片标题:${card.title}`,
|
|
48
|
+
ids === '' ? ' 负责判据:无(这是协调 / 验卡)' : ` 负责判据:${ids}`,
|
|
49
|
+
card.scope === undefined ? '' : ` 说的范围:${card.scope}`,
|
|
50
|
+
].filter((line) => line !== '').join('\n')
|
|
51
|
+
})
|
|
52
|
+
return [`需求原文:\n${requirement.trim()}`, '要写开头段的卡:', cardLines.join('\n')].join('\n\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 解析模型回复:只要 key 认得、字数够的段落。
|
|
57
|
+
*
|
|
58
|
+
* @param {string} reply
|
|
59
|
+
* @param {Array<string>} [allowedKeys]
|
|
60
|
+
* @returns {Record<string,string>}
|
|
61
|
+
*/
|
|
62
|
+
export function parsePromptsReply(reply, allowedKeys) {
|
|
63
|
+
const payload = extractJsonObject(reply)
|
|
64
|
+
const raw = Array.isArray(payload?.cards) ? payload.cards : []
|
|
65
|
+
const allowed = Array.isArray(allowedKeys) && allowedKeys.length > 0 ? new Set(allowedKeys) : undefined
|
|
66
|
+
const out = {}
|
|
67
|
+
for (const item of raw) {
|
|
68
|
+
const key = String(item?.key ?? '').trim()
|
|
69
|
+
if (key === '') continue
|
|
70
|
+
if (allowed !== undefined && !allowed.has(key)) continue
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) continue
|
|
72
|
+
const intro = String(item?.intro ?? '').replace(/\r/g, '').trim().slice(0, MAX_INTRO)
|
|
73
|
+
if (intro.length < MIN_INTRO) continue
|
|
74
|
+
out[key] = intro
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 给这些卡写开头段。
|
|
81
|
+
*
|
|
82
|
+
* @param {object} input
|
|
83
|
+
* @param {any} input.llm
|
|
84
|
+
* @param {{provider:string, model:string}} input.route
|
|
85
|
+
* @param {string} input.requirement
|
|
86
|
+
* @param {Array<{key:string,title:string,criteriaIds?:Array<string>,scope?:string}>} input.cards
|
|
87
|
+
* @param {number} [input.timeoutMs]
|
|
88
|
+
* @returns {Promise<{ok:boolean, intros:Record<string,string>, error:string|null}>}
|
|
89
|
+
*/
|
|
90
|
+
export async function writeCardIntros({ llm, route, requirement = '', cards = [], timeoutMs = MODEL_TIMEOUT_MS } = {}) {
|
|
91
|
+
if (cards.length === 0) return { ok: false, intros: {}, error: '没有要写提示词的卡' }
|
|
92
|
+
const reply = await callModel({
|
|
93
|
+
llm,
|
|
94
|
+
route,
|
|
95
|
+
system: PROMPTS_SYSTEM,
|
|
96
|
+
text: buildBriefing({ requirement, cards }),
|
|
97
|
+
timeoutMs,
|
|
98
|
+
})
|
|
99
|
+
if (!reply.ok) return { ok: false, intros: {}, error: reply.error }
|
|
100
|
+
const intros = parsePromptsReply(reply.reply, cards.map((card) => card.key))
|
|
101
|
+
const written = Object.keys(intros).length
|
|
102
|
+
if (written === 0) return { ok: false, intros: {}, error: '模型没写出可用的提示词(回复不是约定的 JSON 或太短)' }
|
|
103
|
+
return { ok: true, intros, error: written < cards.length ? `有 ${cards.length - written} 张卡的提示词没写成,按模板补` : null }
|
|
104
|
+
}
|
package/src/selfcheck.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selfcheck.js:开机体检,纯函数。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要体检而不是「自动纠正」:插件改不了部署设置(委派深度、权限预设、计费),
|
|
5
|
+
* 偷偷改配置只会让人以为组织图能跑、实际跑到一半才炸。所以这里的产出是**结论 + 一句
|
|
6
|
+
* 该怎么办**,由面板和启动日志展示。
|
|
7
|
+
*
|
|
8
|
+
* 判据只有一条:**fatal 必须为零**。warn 是「能跑但会难受」,info 是「知道了就行」。
|
|
9
|
+
*
|
|
10
|
+
* facts 全部由调用方注入(不在这里读盘/读宿主),这样每条分支都能被单测锁住。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} Facts
|
|
15
|
+
* @property {number|null} [subagentDepthMax] 部署允许的最大子智能体递归深度;未知传 null
|
|
16
|
+
* @property {boolean|null} [boardReachable] 任务看板 HTTP 是否可达;未知传 null
|
|
17
|
+
* @property {boolean|null} [boardTeamRun] 看板是否支持 teamRun 卡片(父卡起 Lead)
|
|
18
|
+
* @property {number|null} [workspaceCount] 已知工作区数量
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const FINDINGS = Object.freeze({
|
|
22
|
+
'ORG-DEPTH': {
|
|
23
|
+
severity: 'fatal',
|
|
24
|
+
title: '委派深度不足,主管带不了人',
|
|
25
|
+
hint: '设置 → 子智能体 → 最大递归深度 改成 2(0 = 禁用、1 = 只有主 Agent 能派生)',
|
|
26
|
+
},
|
|
27
|
+
'ORG-DEPTH-UNKNOWN': {
|
|
28
|
+
severity: 'warn',
|
|
29
|
+
title: '委派深度未知',
|
|
30
|
+
hint: '到 设置 → 子智能体 确认「最大递归深度」是否为 2;插件读不到这个部署设置,只能提示',
|
|
31
|
+
},
|
|
32
|
+
'BOARD-UNREACHABLE': {
|
|
33
|
+
severity: 'warn',
|
|
34
|
+
title: '执行层(任务看板)不可达',
|
|
35
|
+
hint: '插件只产出计划与卡片;看板不可达时卡片发不出去,确认 @linxin666/dsh-client-ui-task-board 已启用',
|
|
36
|
+
},
|
|
37
|
+
'BOARD-UNKNOWN': {
|
|
38
|
+
severity: 'info',
|
|
39
|
+
title: '执行层可达性未检测',
|
|
40
|
+
hint: '在面板点一次「体检」即可拿到结果(会发一个只读请求,不花钱)',
|
|
41
|
+
},
|
|
42
|
+
'TEAMRUN-UNSUPPORTED': {
|
|
43
|
+
severity: 'warn',
|
|
44
|
+
title: '看板不支持 teamRun',
|
|
45
|
+
hint: '关闭 execution.teamRun,退化为普通级联;否则父卡不会起 Lead,主管层不存在',
|
|
46
|
+
},
|
|
47
|
+
'NO-WORKSPACE': {
|
|
48
|
+
severity: 'warn',
|
|
49
|
+
title: '没有指定工作区',
|
|
50
|
+
hint: '卡片不钉工作区时会回退到「最近改动的那个」,对自动跑卡是隐患;在面板里显式选一个',
|
|
51
|
+
},
|
|
52
|
+
'PLAN-GATE-OFF': {
|
|
53
|
+
severity: 'warn',
|
|
54
|
+
title: '计划闸关着但总开关开着',
|
|
55
|
+
hint: '还没批准的计划也会直接开跑 = 直接花钱;建议保持 gates.requirePlanApproval = true',
|
|
56
|
+
},
|
|
57
|
+
'EVIDENCE-OFF': {
|
|
58
|
+
severity: 'warn',
|
|
59
|
+
title: '交付门槛关着',
|
|
60
|
+
hint: '没有 commit / 报告也算完成,自动跑卡时无法复核;建议保持 gates.requireEvidence = true',
|
|
61
|
+
},
|
|
62
|
+
DISABLED: {
|
|
63
|
+
severity: 'info',
|
|
64
|
+
title: '总开关关闭',
|
|
65
|
+
hint: '当前只做拆分与填卡,不会开任何会话、不产生费用',
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 跑一遍体检。
|
|
71
|
+
*
|
|
72
|
+
* @param {object} input
|
|
73
|
+
* @param {object} input.config 归一化配置(决定「应该是什么样」)
|
|
74
|
+
* @param {Facts} [input.facts] 外部注入的事实(决定「实际是什么样」)
|
|
75
|
+
* @returns {{ok:boolean, findings:Array<object>}}
|
|
76
|
+
*/
|
|
77
|
+
export function runSelfCheck({ config, facts = {} } = {}) {
|
|
78
|
+
const findings = []
|
|
79
|
+
const add = (id, detail) => {
|
|
80
|
+
const base = FINDINGS[id]
|
|
81
|
+
if (!base) return
|
|
82
|
+
findings.push({ id, severity: base.severity, title: base.title, detail, hint: base.hint })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const requiredDepth = config.org?.depthRequired ?? 2
|
|
86
|
+
const depth = facts.subagentDepthMax
|
|
87
|
+
if (depth === null || depth === undefined) {
|
|
88
|
+
add('ORG-DEPTH-UNKNOWN', `插件读不到部署的子智能体深度,按需要 ${requiredDepth} 层处理`)
|
|
89
|
+
} else if (Number(depth) < requiredDepth) {
|
|
90
|
+
add('ORG-DEPTH', `当前最大递归深度 ${depth},低于所需的 ${requiredDepth}:成员(主管)无法再派生部门人员`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const board = facts.boardReachable
|
|
94
|
+
if (board === false) add('BOARD-UNREACHABLE', '调用 /api/task-board 失败')
|
|
95
|
+
else if (board === null || board === undefined) add('BOARD-UNKNOWN', '尚未探测')
|
|
96
|
+
|
|
97
|
+
if (facts.boardTeamRun === false && config.execution?.teamRun === true) {
|
|
98
|
+
add('TEAMRUN-UNSUPPORTED', '已要求 teamRun 卡片,但看板侧没有这个能力')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (facts.workspaceCount === 0) {
|
|
102
|
+
add('NO-WORKSPACE', '工作区列表为空')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (config.enabled === true && config.gates?.requirePlanApproval !== true) {
|
|
106
|
+
add('PLAN-GATE-OFF', 'enabled=true 且 requirePlanApproval=false')
|
|
107
|
+
}
|
|
108
|
+
if (config.enabled === true && config.gates?.requireEvidence !== true) {
|
|
109
|
+
add('EVIDENCE-OFF', 'enabled=true 且 requireEvidence=false')
|
|
110
|
+
}
|
|
111
|
+
if (config.enabled !== true) {
|
|
112
|
+
add('DISABLED', 'enabled=false')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ok = findings.every((item) => item.severity !== 'fatal')
|
|
116
|
+
return { ok, findings }
|
|
117
|
+
}
|
package/src/split.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
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 { randomUUID } from 'node:crypto'
|
|
29
|
+
|
|
30
|
+
import { verdictPath } from './verdict.js'
|
|
31
|
+
|
|
32
|
+
/** 单卡 prompt 里判据条目的前缀(子卡只列自己那组)。 */
|
|
33
|
+
const CRITERIA_HEADING = '验收判据(完成后逐条自证):'
|
|
34
|
+
|
|
35
|
+
/** 交付要求:三类模型都会照做,不依赖具体仓库结构。 */
|
|
36
|
+
const DELIVERY_RULES = [
|
|
37
|
+
'先读相关代码再动手,改动尽量小而且能用。',
|
|
38
|
+
'每条验收判据都要给出可复核的验证(测试、命令或截图路径),不要只说「已完成」。',
|
|
39
|
+
'只做本卡范围内的改动;发现范围外的问题,写进总结而不是顺手改。',
|
|
40
|
+
'最后用三五行总结:改了什么、怎么验证的、还有什么没做。不要贴大段代码。',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** 证据要求:只有开了交付门槛才写进 prompt(关掉 = 省钱,也放弃了可复核性)。 */
|
|
44
|
+
const EVIDENCE_RULES = [
|
|
45
|
+
'把交付报告写到 `out/<本卡 id>/report.md`(相对工作区根目录)。',
|
|
46
|
+
'报告里逐条写:判据 id、期望、实际、证据(命令输出 / 测试名 / 文件路径)。',
|
|
47
|
+
'带上本次改动的 commit sha;没有 commit 就写 `none` 并说明为什么。',
|
|
48
|
+
'没有报告或报告缺条目,这张卡就不算完成——验收卡会据此打回。',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 判据归一化。
|
|
53
|
+
*
|
|
54
|
+
* 人只会写正文(「能算加减法」),但看板/台账的判据 id 有格式要求,
|
|
55
|
+
* 所以没写编号就按出现顺序补 `C-01`、`C-02`…;写了编号就沿用并接着往下编。
|
|
56
|
+
*
|
|
57
|
+
* @param {Array<string|{id?:string,text:string}>} criteria
|
|
58
|
+
* @returns {Array<{id:string, text:string}>}
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeCriteria(criteria = []) {
|
|
61
|
+
let sequence = 0
|
|
62
|
+
const out = []
|
|
63
|
+
for (const raw of criteria) {
|
|
64
|
+
const isObject = raw !== null && typeof raw === 'object'
|
|
65
|
+
const text = String((isObject ? raw.text : raw) ?? '').trim()
|
|
66
|
+
if (text === '') continue
|
|
67
|
+
const rawId = isObject ? String(raw.id ?? '').trim() : ''
|
|
68
|
+
let id = /^C-\d{2,}$/.test(rawId) ? rawId : ''
|
|
69
|
+
let body = text
|
|
70
|
+
if (id !== '') {
|
|
71
|
+
sequence = Number.parseInt(id.slice(2), 10) || sequence
|
|
72
|
+
} else {
|
|
73
|
+
// 字符串形态里可能自带编号(「C-05 自定义编号」),解析出来并让后续编号接着走。
|
|
74
|
+
// 页面那边会先解析一遍,但 /split 是可直接 curl 的,服务端必须自己扛得住。
|
|
75
|
+
const explicit = /^(C-\d{2,})[\s::、.\-—]*(.*)$/.exec(text)
|
|
76
|
+
if (explicit) {
|
|
77
|
+
id = explicit[1]
|
|
78
|
+
body = explicit[2].trim()
|
|
79
|
+
sequence = Number.parseInt(id.slice(2), 10) || sequence
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (body === '') continue
|
|
83
|
+
if (id === '') {
|
|
84
|
+
sequence += 1
|
|
85
|
+
id = `C-${sequence < 10 ? `0${sequence}` : sequence}`
|
|
86
|
+
}
|
|
87
|
+
out.push({ id, text: body })
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clip(text, max) {
|
|
93
|
+
const value = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
94
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function criteriaLines(criteria) {
|
|
98
|
+
return criteria.map((item) => `- ${item.id} ${item.text}`).join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 拼一张卡的 prompt。做成纯函数是为了让「投给看板的东西」可审、可测。
|
|
103
|
+
*
|
|
104
|
+
* `intro` 是模型按需求写的那段「目标 / 范围 / 不要做什么」,插在最前面;
|
|
105
|
+
* 判据、交付要求、证据要求仍然由这里确定性拼上——模型可以改措辞,
|
|
106
|
+
* 但不能把「完成要交证据」这条契约写丢。
|
|
107
|
+
*
|
|
108
|
+
* @param {object} input
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function buildPrompt({ title, description = '', criteria = [], scopeNote = '', cardId = null, intro = '' }) {
|
|
112
|
+
const parts = []
|
|
113
|
+
const written = String(intro ?? '').trim()
|
|
114
|
+
if (written !== '') {
|
|
115
|
+
parts.push(written)
|
|
116
|
+
} else {
|
|
117
|
+
parts.push(`目标:${title}`)
|
|
118
|
+
if (description.trim() !== '') parts.push(`背景:${description.trim()}`)
|
|
119
|
+
if (scopeNote.trim() !== '') parts.push(`范围:${scopeNote.trim()}`)
|
|
120
|
+
}
|
|
121
|
+
if (criteria.length > 0) parts.push(`${CRITERIA_HEADING}\n${criteriaLines(criteria)}`)
|
|
122
|
+
parts.push(`要求:\n${DELIVERY_RULES.map((rule, index) => `${index + 1}. ${rule}`).join('\n')}`)
|
|
123
|
+
if (cardId !== null) {
|
|
124
|
+
// 报告路径必须写死成这张卡自己的 id:执行会话读不到本插件的上下文,
|
|
125
|
+
// 只写「写到 out/」它会自己编一个目录,验收卡就找不到。
|
|
126
|
+
parts.push(
|
|
127
|
+
`交付证据:\n${EVIDENCE_RULES.map((rule, index) => `${index + 1}. ${rule.replace('<本卡 id>', cardId)}`).join('\n')}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return parts.join('\n\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 验收卡的 prompt。
|
|
135
|
+
*
|
|
136
|
+
* 这张卡的存在理由是基线 §3.3:**验证必须独立,不能和开发是同一张卡**。
|
|
137
|
+
* 它的产出只有两种(PASS / REJECT),REJECT 必须带 `criteriaId + expected + actual + evidencePath`——
|
|
138
|
+
* 没有定位的「不行」等于没验收,开发只能瞎改。
|
|
139
|
+
*
|
|
140
|
+
* @param {object} input
|
|
141
|
+
* @param {string} input.title
|
|
142
|
+
* @param {Array<{id:string,text:string}>} input.criteria
|
|
143
|
+
* @param {Array<{id:string,title:string}>} input.targets 待验收的卡
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function buildVerifyPrompt({ title, criteria = [], targets = [], rootId = '' }) {
|
|
147
|
+
const targetLines = targets
|
|
148
|
+
.map((target) => `- ${target.title}(卡 ${target.id})→ 报告 out/${target.id}/report.md`)
|
|
149
|
+
.join('\n')
|
|
150
|
+
return [
|
|
151
|
+
`目标:独立验收「${title}」。只核对证据,不改实现。`,
|
|
152
|
+
'职责:',
|
|
153
|
+
[
|
|
154
|
+
'1. 逐张读下面列出的报告;报告不存在 = 那张卡没交付,直接 REJECT。',
|
|
155
|
+
'2. 对每条判据核对「期望 / 实际 / 证据」是否对得上;证据必须是能复核的东西(命令输出、测试名、文件路径、commit sha)。',
|
|
156
|
+
'3. 结论只能是 PASS 或 REJECT:',
|
|
157
|
+
' - PASS:逐条给出判据 id 与证据位置;',
|
|
158
|
+
' - REJECT:逐条写 {criteriaId, expected, actual, evidencePath},缺一项都不算数。',
|
|
159
|
+
'4. 同一批最多打回两次;第三次停下来把问题交给人,不要再让开发继续改(对应基线 R3)。',
|
|
160
|
+
].join('\n'),
|
|
161
|
+
[
|
|
162
|
+
'结论落盘(必须做,否则这次验收等于没发生):',
|
|
163
|
+
`把结论写成 \`${verdictPath(rootId)}\`,形状:`,
|
|
164
|
+
'{"verdict":"PASS|REJECT","summary":"一句话","items":[{"criteriaId":"C-01","expected":"…","actual":"…","evidencePath":"…"}]}',
|
|
165
|
+
'REJECT 时 items 不能为空,四项都要有。插件与人只认这个文件,不认会话里的口头结论。',
|
|
166
|
+
].join('\n'),
|
|
167
|
+
'禁止:自己动手改代码或「顺手修一下」——验证与开发合并就失去意义(基线 §3.3);禁止凭「看起来没问题」放行。',
|
|
168
|
+
`待验收的卡:\n${targetLines}`,
|
|
169
|
+
`${CRITERIA_HEADING}\n${criteriaLines(criteria)}`,
|
|
170
|
+
].join('\n\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 判据分组:把 n 条判据尽量均匀地分成若干组,组数不超过组内上限。
|
|
175
|
+
*
|
|
176
|
+
* @param {Array} criteria
|
|
177
|
+
* @param {number} maxGroups
|
|
178
|
+
* @returns {Array<Array>}
|
|
179
|
+
*/
|
|
180
|
+
function groupCriteria(criteria, maxGroups) {
|
|
181
|
+
const groups = Math.max(1, Math.min(maxGroups, criteria.length))
|
|
182
|
+
const out = []
|
|
183
|
+
let cursor = 0
|
|
184
|
+
for (let index = 0; index < groups; index += 1) {
|
|
185
|
+
const remaining = criteria.length - cursor
|
|
186
|
+
const size = Math.ceil(remaining / (groups - index))
|
|
187
|
+
out.push(criteria.slice(cursor, cursor + size))
|
|
188
|
+
cursor += size
|
|
189
|
+
}
|
|
190
|
+
return out.filter((group) => group.length > 0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 只算结构,不生成信封:用来告诉模型「这次会拆成哪几张卡」。
|
|
195
|
+
*
|
|
196
|
+
* 和 splitRequirement 共用同一套分组与命名,所以模型按这些 key/title 写的提示词,
|
|
197
|
+
* 一定能落到对应的卡上(id 是随机的,key 是稳定的)。
|
|
198
|
+
*
|
|
199
|
+
* @param {object} input
|
|
200
|
+
* @returns {Array<{key:string, title:string, criteriaIds:Array<string>}>}
|
|
201
|
+
*/
|
|
202
|
+
export function outlineCards({
|
|
203
|
+
title,
|
|
204
|
+
description = '',
|
|
205
|
+
criteria = [],
|
|
206
|
+
parallel = true,
|
|
207
|
+
requireEvidence = false,
|
|
208
|
+
} = {}) {
|
|
209
|
+
const cleanTitle = clip(String(title ?? ''), 120)
|
|
210
|
+
const normalized = normalizeCriteria(criteria)
|
|
211
|
+
const shouldSplit = parallel && normalized.length >= 2
|
|
212
|
+
const out = [{
|
|
213
|
+
key: 'root',
|
|
214
|
+
title: cleanTitle,
|
|
215
|
+
criteriaIds: shouldSplit ? [] : normalized.map((item) => item.id),
|
|
216
|
+
}]
|
|
217
|
+
if (shouldSplit) {
|
|
218
|
+
const groups = groupCriteria(normalized, 4)
|
|
219
|
+
groups.forEach((group, index) => {
|
|
220
|
+
out.push({
|
|
221
|
+
key: `part-${index + 1}`,
|
|
222
|
+
title: `${cleanTitle}(${index + 1}/${groups.length})${clip(group[0].text, 28)}`,
|
|
223
|
+
criteriaIds: group.map((item) => item.id),
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
if (requireEvidence === true && normalized.length > 0) {
|
|
228
|
+
out.push({ key: 'verify', title: `${clip(cleanTitle, 60)}(验收)`, criteriaIds: [] })
|
|
229
|
+
}
|
|
230
|
+
void description
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 看板 action 信封:`{ requestId, action }`,形状由看板 `parseActionEnvelope` 校验。 */
|
|
235
|
+
function envelope(action, requestId) {
|
|
236
|
+
return { requestId: requestId ?? `card-dispatch-${randomUUID()}`, action }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 把一个需求拆成「父卡 + 可选子卡」,并生成可直接投递给看板的信封。
|
|
241
|
+
*
|
|
242
|
+
* @param {object} input
|
|
243
|
+
* @param {string} input.title 需求标题
|
|
244
|
+
* @param {string} [input.description] 需求描述
|
|
245
|
+
* @param {Array} [input.criteria] 判据(字符串或 {id,text})
|
|
246
|
+
* @param {boolean} [input.parallel] 是否按判据拆成并行子卡
|
|
247
|
+
* @param {string|null} [input.workspaceId] 钉住的工作区
|
|
248
|
+
* @param {string|null} [input.agentPreset] 钉住的 agent 预设(看板字段名是 mode)
|
|
249
|
+
* @param {string|null} [input.permission] 权限预设
|
|
250
|
+
* @returns {{rootId:string, cards:Array, envelopes:Array, run:object|null, warnings:Array<string>}}
|
|
251
|
+
*/
|
|
252
|
+
export function splitRequirement({
|
|
253
|
+
title,
|
|
254
|
+
description = '',
|
|
255
|
+
criteria = [],
|
|
256
|
+
parallel = true,
|
|
257
|
+
workspaceId = null,
|
|
258
|
+
agentPreset = null,
|
|
259
|
+
permission = 'workspace-write',
|
|
260
|
+
requireEvidence = false,
|
|
261
|
+
teamRun = false,
|
|
262
|
+
/** 模型按需求写好的开头段,按卡片 key(root / part-1 / verify)索引。 */
|
|
263
|
+
intros = {},
|
|
264
|
+
} = {}) {
|
|
265
|
+
const cleanTitle = clip(String(title ?? ''), 120)
|
|
266
|
+
if (cleanTitle === '') throw new Error('需求标题不能为空')
|
|
267
|
+
|
|
268
|
+
const normalized = normalizeCriteria(criteria)
|
|
269
|
+
const warnings = []
|
|
270
|
+
const rootId = randomUUID()
|
|
271
|
+
|
|
272
|
+
// 卡片公共字段:null 表示「不指定」,绝不能写进去(看板按 exactKeys 校验,多余字段直接 400)
|
|
273
|
+
const shared = {
|
|
274
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
275
|
+
...(agentPreset ? { mode: agentPreset } : {}),
|
|
276
|
+
...(permission ? { permission } : {}),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const evidenceOn = requireEvidence === true
|
|
280
|
+
/**
|
|
281
|
+
* 拆不拆子卡,取决于跑法:
|
|
282
|
+
* - 开了智能体团队:父卡是 Lead,子卡由它在**同一个**会话里派成 teammate,拆得越多越划算;
|
|
283
|
+
* - 没开:每张子卡都是**一条独立会话**,拆 4 条判据就是 4 次开会话,所以只建一张卡。
|
|
284
|
+
* 这条规则不放进开关表里让人二次确认:会话条数由跑法决定,不该由两个开关的组合决定。
|
|
285
|
+
*/
|
|
286
|
+
const shouldSplit = teamRun === true && parallel && normalized.length >= 2
|
|
287
|
+
|
|
288
|
+
const rootInput = {
|
|
289
|
+
title: cleanTitle,
|
|
290
|
+
description: description.trim(),
|
|
291
|
+
prompt: buildPrompt({
|
|
292
|
+
title: cleanTitle,
|
|
293
|
+
description,
|
|
294
|
+
criteria: normalized,
|
|
295
|
+
intro: intros.root ?? '',
|
|
296
|
+
// 不拆时父卡就是干活的那张,要写报告;拆了之后父卡是协调者(teamRun 的 Lead),
|
|
297
|
+
// 它不产出报告——让验收卡去要它的报告只会误伤。
|
|
298
|
+
cardId: evidenceOn && !shouldSplit ? rootId : null,
|
|
299
|
+
}),
|
|
300
|
+
...shared,
|
|
301
|
+
// 智能体团队只在父卡上开:看板见到根卡 teamRun=true 就只起一个 Lead 会话,
|
|
302
|
+
// 由它把每张子卡派成 teammate。子卡不能再写 teamRun,否则会被当成另一个 Lead。
|
|
303
|
+
...(teamRun ? { teamRun: true } : {}),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (teamRun && shouldSplit) {
|
|
307
|
+
// 根卡在 teamRun 模式下就是 Lead:顺序只能写在 prompt 里——看板没有依赖字段,
|
|
308
|
+
// 靠平台排不出「先开发后验收」,只能让 Lead 自己守这个顺序。
|
|
309
|
+
rootInput.prompt +=
|
|
310
|
+
'\n\n流程:先派发子卡完成开发 → 等子卡交付 → 再让验收卡执行 → 被打回最多两次,第三次停下来交给人。'
|
|
311
|
+
} else if (teamRun) {
|
|
312
|
+
rootInput.prompt += '\n\n执行方式:这一轮由你自己开团队完成(Lead 起 teammate),父卡不产出报告。'
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cards = [{ id: rootId, key: 'root', title: cleanTitle, input: rootInput }]
|
|
316
|
+
const envelopes = [envelope({ kind: 'create', id: rootId, input: rootInput })]
|
|
317
|
+
|
|
318
|
+
if (normalized.length === 0) {
|
|
319
|
+
warnings.push('这个需求没有验收判据,看板里的执行者不知道做到什么算完;建议补几条。')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (shouldSplit) {
|
|
323
|
+
const groups = groupCriteria(normalized, 4)
|
|
324
|
+
if (groups.length < normalized.length) {
|
|
325
|
+
// 一条判据一张卡只在判据不多时才有意义,否则一天开八张卡全是计费
|
|
326
|
+
}
|
|
327
|
+
groups.forEach((group, index) => {
|
|
328
|
+
const id = randomUUID()
|
|
329
|
+
const groupTitle = `${cleanTitle}(${index + 1}/${groups.length})${clip(group[0].text, 28)}`
|
|
330
|
+
const input = {
|
|
331
|
+
title: groupTitle,
|
|
332
|
+
description: `需求「${cleanTitle}」的第 ${index + 1} 部分,判据:${group.map((item) => item.id).join('、')}`,
|
|
333
|
+
prompt: buildPrompt({
|
|
334
|
+
title: groupTitle,
|
|
335
|
+
description,
|
|
336
|
+
criteria: group,
|
|
337
|
+
scopeNote: `这是需求「${cleanTitle}」拆分后的第 ${index + 1}/${groups.length} 部分,只做这几条判据;其余部分由同级的其它卡负责,不要重复实现。`,
|
|
338
|
+
cardId: evidenceOn ? id : null,
|
|
339
|
+
intro: intros[`part-${index + 1}`] ?? '',
|
|
340
|
+
}),
|
|
341
|
+
parentId: rootId,
|
|
342
|
+
...shared,
|
|
343
|
+
}
|
|
344
|
+
cards.push({ id, key: `part-${index + 1}`, title: groupTitle, input })
|
|
345
|
+
envelopes.push(envelope({ kind: 'create', id, input }))
|
|
346
|
+
})
|
|
347
|
+
warnings.push(
|
|
348
|
+
`拆成 ${cards.length} 张卡:父卡 1 张 + 子卡 ${groups.length} 张。子卡在看板里是并行的,所以每张都自带验收判据。`,
|
|
349
|
+
)
|
|
350
|
+
} else if (normalized.length >= 2 && teamRun !== true) {
|
|
351
|
+
warnings.push('没开智能体团队:不按判据拆子卡(每张子卡会各开一条会话),全部判据写进一张卡。')
|
|
352
|
+
} else if (normalized.length >= 2) {
|
|
353
|
+
warnings.push('没勾选并行拆分,全部判据都写进同一张卡,由一次执行完成。')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 验收卡:只在开了交付门槛、且确实有判据可验时生成。
|
|
357
|
+
// 它是**另一张卡**而不是父卡的一部分,因为基线 §3.3 要求验证独立于开发;
|
|
358
|
+
// 同一张卡自己说「我做完了」不算验收。
|
|
359
|
+
// 和有没有开智能体团队无关:门槛是「必须有人独立核对」的意思,不能因为省一条会话就悄悄失效。
|
|
360
|
+
// 代价是没开团队时它就是第 2 条会话,所以要提前把条数说清楚(见下)。
|
|
361
|
+
let verifyId = null
|
|
362
|
+
if (evidenceOn && normalized.length > 0) {
|
|
363
|
+
verifyId = randomUUID()
|
|
364
|
+
// 拆过的需求逐张列子卡;单卡需求时待验收的就是父卡本身。
|
|
365
|
+
const targets = shouldSplit
|
|
366
|
+
? cards.filter((card) => card.key !== 'root').map((card) => ({ id: card.id, title: card.title }))
|
|
367
|
+
: cards.map((card) => ({ id: card.id, title: card.title }))
|
|
368
|
+
const verifyTitle = `${clip(cleanTitle, 60)}(验收)`
|
|
369
|
+
const input = {
|
|
370
|
+
title: verifyTitle,
|
|
371
|
+
description: `独立验收「${cleanTitle}」:只核对证据,不改实现。`,
|
|
372
|
+
prompt: buildVerifyPrompt({ title: cleanTitle, criteria: normalized, targets, rootId }),
|
|
373
|
+
parentId: rootId,
|
|
374
|
+
...shared,
|
|
375
|
+
}
|
|
376
|
+
cards.push({ id: verifyId, key: 'verify', title: verifyTitle, input })
|
|
377
|
+
envelopes.push(envelope({ kind: 'create', id: verifyId, input }))
|
|
378
|
+
warnings.push('已附一张验收卡:它只读证据、只给 PASS/REJECT,和开发不是同一张卡(基线 §3.3)。')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 会话条数:团队模式下所有卡都在**同一个** Lead 会话里跑(teammate 是这个会话里的下级),
|
|
382
|
+
// 所以卡多不等于会话多。这句话得说出来——否则人会以为 6 张卡就是 6 次计费。
|
|
383
|
+
if (teamRun === true && cards.length > 1) {
|
|
384
|
+
warnings.push(`开智能体团队:这 ${cards.length} 张卡在同一个 Lead 会话里跑,只算 1 条会话。`)
|
|
385
|
+
} else if (teamRun !== true && cards.length > 1) {
|
|
386
|
+
warnings.push(`没开智能体团队:各卡各开一条会话,这次一共 ${cards.length} 条。`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
rootId,
|
|
391
|
+
verifyId,
|
|
392
|
+
cards,
|
|
393
|
+
envelopes,
|
|
394
|
+
run: envelope({ kind: 'run', taskId: rootId }),
|
|
395
|
+
warnings,
|
|
396
|
+
}
|
|
397
|
+
}
|