dsh-agent-board 1.8.3 → 1.9.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/lib/policy.mjs CHANGED
@@ -2,17 +2,17 @@
2
2
  // 纯函数、零状态、零 IO:任务粒度治理(软闸门文案与判定)+ 学习飞轮候选教训生成。
3
3
  // index.mjs 薄壳 re-export 粒度治理导出(对外契约不变);dispatch/rpc 模块按需 import。
4
4
  import * as core from './core.mjs'
5
- const { cfg, lessonText, pushLesson } = core
5
+ const { cfg, lessonText, pushLesson, ah, classifyPipeline } = core
6
6
 
7
7
  // ===== 任务粒度治理(全软方案:只引导/提示,绝不阻断创建与派发)=====
8
8
  // 背景:agent 容易把史诗级大任务整坨塞进看板(长程 + 跑偏风险 + 返工成本高)。
9
9
  // 落地三件套(都不带硬闸门):
10
10
  // 1) task_create 工具描述里的粒度契约 TASK_SIZE_CONTRACT;
11
- // 2) Team 模式系统提示词第 6 条 TEAM_SPLIT_RULE(epic 父卡 + 子任务 + checkParentAuto 收尾);
11
+ // 2) Team 模式系统提示词第 6~8 条拆分 doctrine(流程 + 决策树 + 上下文装配);
12
12
  // 3) create-task RPC / task_create 工具返回体命中时附加 suggestSplit 一行建议(不落盘、不改状态)。
13
13
  // 板级总开关 epicSplit(缺省 true,UI 在入池配置弹层「功能」小节):**只关引导,不禁机制**。
14
14
  // false 时的两个生效点=上面第 2、3 条(本文件 splitRuleOf / withSplitHint 统一收口):
15
- // · splitRuleOf(false) → '':Team 提示词第 6 条整条不注入;
15
+ // · splitRuleOf(false) → '':Team 提示词第 6~8 条整条不注入;
16
16
  // · withSplitHint(out, t, false) → 原样返回:不再附 suggestSplit 软提示。
17
17
  // 机制面一律不看这个开关:显式传 parentId 建子卡、检查/收口父子流转、hooks 状态机照常工作——
18
18
  // 关的是「主动劝你拆」,不是「不许你拆」。
@@ -22,11 +22,21 @@ export var TASK_SIZE_CONTRACT = '建议粒度:单任务 10~30 分钟可独立
22
22
  export var SUGGEST_SPLIT_TEXT = '任务看起来偏大(建议单任务 10~30 分钟):考虑拆分子任务(parentId)或收窄边界'
23
23
  // Team 模式提示词第 6 条:大任务的 epic 拆分流程(父卡 pipeline=direct 不派发,子任务全 resolved 后父卡由 checkParentAuto 自动转 verifying)
24
24
  export var TEAM_SPLIT_RULE = '6. 大任务必须拆分:预计超过 30 分钟的任务,先建一张 epic 父卡(pipeline 传 direct,不派发),再拆成若干 10~30 分钟的子任务(task_create 传 parentId=父卡 id,有先后顺序的用 dependsOn 串联)。子任务全部完成后父卡会自动标记完成(checkParentAuto)。'
25
- // Team 提示词第 6 条的注入出口(含前导换行,调用方直接字符串相加):
26
- // epicSplit 缺省/true → '\n' + TEAM_SPLIT_RULE(与开关落地前逐字相同,零变化);
27
- // epicSplit === false → ''(条款整条不出现,编号 1~5 连续不受影响)。
25
+ // Team 提示词第 7 条:命中任一信号即拆,按可独立验收的子卡与文件族切边界。
26
+ export var TEAM_DECISION_TREE = '7. 拆解决策树(命中任一信号就该拆):预计超 30 分钟;涉及 3+ 个文件/模块族;需求描述含「然后/接着/再/最后」等多阶段词;交付物超 2 类。拆法:按「每张子卡可独立验收」(自带可运行的 acceptance)切边界;按文件族/touches 切分还能天然避开文件锁互踩。'
27
+ // Team 提示词第 8 条:子代理零记忆,每张子卡都要独立带齐公共背景与调研包。
28
+ export var TEAM_CONTEXT_PACKING = '8. 拆解后的上下文装配:每张子卡独立带齐调研包(description 写目标约束 + contextNotes 写结论思路 + contextFiles 带行号)——子代理零记忆,epic 父卡上的背景不会自动传给子卡,公共背景要逐卡复制进 contextNotes。执行中 Worker 发现任务比预估大时会 escalate 上报,此时拆分重派,别让 Worker 硬跑整坨。'
29
+ // Team 提示词第 9 条:需求卡 → 拆解官 → 确认发布的工作流引导(task-mvneoy1u,epic task-mvnem59t 子卡 3/6)。
30
+ // task-mvoutja4 补漏款:命中拆分树信号的需求即使已有设计稿也走拆解官——设计稿进需求卡 contextNotes 当
31
+ // 拆解官参考方案起点(验证/修正而非从零重来),焊死「我已经设计好了」整卡直派的绕过缝隙。
32
+ // ⚠️ 本条**不受 epicSplit 门禁**:它是「确认流怎么用」的流程引导,不是「主动劝你拆」——
33
+ // 拆解官机制本身也不看 epicSplit(task_decompose 工具常驻)。dispatch 注入段在 splitRuleOf(...) 之后恒拼接本条。
34
+ export var TEAM_DECOMPOSE_FLOW = '9. 需求卡工作流:用户用自然语言下达的大需求,先建成 draft 需求卡(保留需求原文),再用 task_decompose 派拆解官出实现侧子卡提案;提案以草稿子卡落位后,向用户呈现一览(标题/档位/模型/touches),用户确认或调档(task_update 改 size/model)后统一 publish。拆解官提案不是终稿——你的呈现与用户的那一眼确认是语义质量的最后防线。命中拆分树信号的需求,即使主窗口已在讨论中形成设计稿,也一律走拆解官——设计稿写进需求卡 contextNotes 作为参考方案;拆解官读它当起点,职责是验证/修正设计而非从零重来。禁止以『我已经设计好了』为由整卡直派。'
35
+ // Team 提示词第 6~8 条拆分 doctrine 的注入出口(每条含前导换行,调用方直接字符串相加):
36
+ // epicSplit 缺省/true → 第 6 条拆分流程 + 第 7 条决策树 + 第 8 条上下文装配;
37
+ // epicSplit === false → ''(第 6~8 条整条不出现,编号 1~5 连续不受影响)。
28
38
  // 纯函数:只看传入布尔,不读配置、不碰 IO——dispatch 侧从缓存取 cfg(d).epicSplit 后传进来。
29
- export function splitRuleOf(epicSplit) { return epicSplit === false ? '' : '\n' + TEAM_SPLIT_RULE }
39
+ export function splitRuleOf(epicSplit) { return epicSplit === false ? '' : '\n'+TEAM_SPLIT_RULE+'\n'+TEAM_DECISION_TREE+'\n'+TEAM_CONTEXT_PACKING }
30
40
  // 史诗特征词:命中即视为"整坨塞进来"的典型信号(与 TASK_SIZE_CONTRACT 配套;无 /g,可安全复用)
31
41
  var EPIC_WORDS = /全量|整体|系统级|全面|重构|所有模块|整个/
32
42
  // description 长度阈值(字符):超过它说明描述密度远超"10~30 分钟单任务"应有体量
@@ -63,3 +73,163 @@ export function pushArbitrationLesson(d, t, question, answer, at) {
63
73
  if (!cfg(d).feedbackEnabled) return false
64
74
  return pushLesson(t, lessonText('任务「' + String(t.title || '') + '」(' + t.id + ') 的歧义裁决', [['疑问', question], ['裁决结论', answer]], lessonSource(t)), at, 'system')
65
75
  }
76
+
77
+ // ===== 拆解官(task-mvnenrwk,epic task-mvnem59t 子卡 2/6):outputSchema 强箍提案 + host 物化 =====
78
+ // 核心设计(需求讨论定稿,勿改架构):拆解官=一次性 run,**不拿任何建卡工具**——提案由 host 读
79
+ // result.structured 后物化成 draft 子卡(权限口子=根本不需要开)。本层只放三样纯定义/纯函数:
80
+ // DECOMPOSE_SCHEMA outputSchema(仅 type/properties/required/items/enum 白名单字段)
81
+ // buildDecomposerPrompt(t, pack) 拆解官契约(写死纪律,与 buildWorkerPrompt/buildVerifierPrompt 同风格)
82
+ // applyDecomposeProposal(d, t, proposal, now) host 物化:提案 → draft 子卡(幂等清旧批)
83
+ // ⚠️ 宿主硬性规则(task-mvnenrwk 驳回实证,dsh-tools lib/types/json-schema.js:223-228):enum 必须伴
84
+ // type/oneOf——缺 type 的 enum 会在 startActivation 内被 assertObjectJsonSchema 同步抛 JsonSchemaError
85
+ // 拒收 → 命中「剥外挂参数裸重试」回退 → 运行期永远拿不到 result.structured → host 物化生产不可达。
86
+ // 回归锁:test/decompose.test.mjs ①b(宿主子集校验镜像 + 破形反证)。
87
+ export var DECOMPOSE_MAX_CHILDREN = 8
88
+ export var DECOMPOSE_SCHEMA = {
89
+ type: 'object',
90
+ required: ['verdict', 'children'],
91
+ properties: {
92
+ verdict: { type: 'string', enum: ['split', 'keep'] },
93
+ children: {
94
+ type: 'array',
95
+ items: {
96
+ type: 'object',
97
+ required: ['title', 'size', 'touches', 'acceptance', 'description'],
98
+ properties: {
99
+ title: { type: 'string' },
100
+ description: { type: 'string' },
101
+ size: { type: 'string', enum: ['light', 'standard', 'heavy'] },
102
+ modelHint: { type: 'string' },
103
+ touches: { type: 'array', items: { type: 'string' } },
104
+ acceptance: { type: 'string' },
105
+ dependsOnIdx: { type: 'array', items: { type: 'number' } },
106
+ },
107
+ },
108
+ },
109
+ rationale: { type: 'string' },
110
+ },
111
+ }
112
+ // 拆解官 prompt 契约(写死纪律):
113
+ // ① 第一步必须 read contextFiles 清单里的文件再动手拆(实现侧拆解,禁止需求侧转述);
114
+ // ② 每张子卡 10~30 分钟可独立验收、touches 精确到文件、acceptance 是可执行命令;
115
+ // ③ size 判档语义:light=单点小改/文档/配置,standard=常规模块改动(缺省),heavy=跨模块/高风险/需强推理;
116
+ // modelHint 仅在有特殊理由时填(如超长上下文);
117
+ // ④ children 上限 DECOMPOSE_MAX_CHILDREN 张;verdict=keep 表示源卡不用拆;
118
+ // ⑤ dependsOnIdx 只许引用本批**前排**子卡下标(0 起)——物化侧据此翻译成本批真实 id,按构造天然无环。
119
+ export function buildDecomposerPrompt(t, pack) {
120
+ var p = '你是任务看板的拆解官(一次性 run)。你的唯一交付物是**结构化拆解提案**(由 outputSchema 强箍:verdict/children/rationale),提案落定后本会话即销毁。\n\n'
121
+ p += '源卡:' + t.id + ' · ' + String(t.title || '') + '\n'
122
+ p += '状态:' + String(t.status || '') + '\n'
123
+ p += '描述:\n' + String(t.description || '(无)').slice(0, 8000) + '\n'
124
+ if (t.acceptance) p += '源卡验收脚本:' + String(t.acceptance) + '\n'
125
+ if (t.context && t.context.instructions) p += '指引:' + String(t.context.instructions).slice(0, 1000) + '\n'
126
+ if (Array.isArray(t.touches) && t.touches.length) p += '源卡 touches:' + t.touches.join('、') + '\n'
127
+ if (pack) p += '\n' + pack + '\n'
128
+ p += '\n拆解纪律(硬约束):\n'
129
+ p += '1. 第一步必须先用 read 工具读上面文件清单里的文件(按所给行号范围),从**实现侧**拆(按模块/文件边界切分);禁止不读代码就拍脑袋拆,禁止只做需求侧转述。\n'
130
+ p += '2. 每张子卡必须 10~30 分钟可独立完成并验收:touches 精确到文件(文件级排他锁,同批子卡 touches 不得互相重叠),acceptance 必须是可执行命令(如 "node --test test/xxx.test.mjs")。\n'
131
+ p += '3. size 判档:light=单点小改/文档/配置;standard=常规模块改动(缺省);heavy=跨模块/高风险/需强推理。modelHint 仅在有特殊理由时填(如某子卡需超长上下文模型),否则留空。\n'
132
+ p += '4. children 上限 ' + DECOMPOSE_MAX_CHILDREN + ' 张;有先后顺序的用 dependsOnIdx 标注(只许引用本批**前排**子卡的下标,0 起)。\n'
133
+ p += '5. 源卡本身不大、不必拆时:verdict=keep,并在 rationale 写清理由。\n'
134
+ p += '\n权限边界:你没有任何建卡工具——不要尝试创建/修改看板任务。提案由宿主读结构化结果后自动物化为 draft 子卡(源卡状态不变,由主窗口确认后逐张 publish)。\n'
135
+ return p
136
+ }
137
+ // 子卡 id 分配:parentId-N 派生(可读、可溯源);与存量卡撞号时追加 rN 后缀(重拆撞已发布子卡的场景)。
138
+ function decomposeChildId(d, parentId, n) {
139
+ var id = parentId + '-' + n
140
+ var bump = 0
141
+ while (d.tasks.some(function (x) { return x && x.id === id })) { bump++; id = parentId + '-' + n + 'r' + bump }
142
+ return id
143
+ }
144
+ // host 物化:拆解提案 → draft 子卡(在 mutateLocked 锁内调用的纯函数)。
145
+ // 纪律:
146
+ // · 校验先于清理——提案畸形/run 失败时**绝不清**旧批提案(失败不毁已有资产);
147
+ // · 幂等:提案有效才清旧批(proposalOf===源卡 id 且仍 draft 的提案子卡整批移除;已 publish/落定的绝不碰);
148
+ // · 子卡落位:status=draft、parentId=源卡、size(合法才落)、model=modelHint(空不落)、touches/acceptance/
149
+ // description 落位、dependsOnIdx → 本批真实 id(只收前排下标,天然无环)、上限 DECOMPOSE_MAX_CHILDREN 截断;
150
+ // · 源卡自身状态不变(仍 draft/pending,由用户处置),只追加 messages 提案摘要行 + history 审计行。
151
+ // 返回 { ok, verdict, children?:[{id,title}], error? }——非空返回值驱动 mutateLocked 写盘。
152
+ export function applyDecomposeProposal(d, t, proposal, now) {
153
+ if (!d || !Array.isArray(d.tasks) || !t) return { ok: false, error: '板/源卡缺失' }
154
+ if (!Array.isArray(t.messages)) t.messages = []
155
+ var stamp = now || new Date().toISOString()
156
+ var verdict = proposal && typeof proposal === 'object' ? proposal.verdict : ''
157
+ if (verdict !== 'split' && verdict !== 'keep') {
158
+ t.messages.push({ kind: 'decompose', text: '🧩 拆解提案无效(verdict 缺失/非法:' + String(verdict || '(空)') + ')——源卡未改动,可重试 task_decompose', at: stamp, by: 'decomposer' })
159
+ return { ok: false, error: '提案 verdict 缺失/非法' }
160
+ }
161
+ var kids = []
162
+ if (verdict === 'split') {
163
+ var raw = Array.isArray(proposal.children) ? proposal.children : []
164
+ for (var i = 0; i < raw.length && kids.length < DECOMPOSE_MAX_CHILDREN; i++) {
165
+ var c0 = raw[i]
166
+ if (!c0 || typeof c0 !== 'object') continue
167
+ if (!String(c0.title == null ? '' : c0.title).trim()) continue
168
+ kids.push(c0)
169
+ }
170
+ if (!kids.length) {
171
+ t.messages.push({ kind: 'decompose', text: '🧩 拆解提案无效(children 为空或无一条带 title)——源卡未改动,可重试 task_decompose', at: stamp, by: 'decomposer' })
172
+ return { ok: false, error: '提案 children 为空' }
173
+ }
174
+ }
175
+ // 幂等点:提案有效才清旧批——上次未发布(仍 draft)的提案子卡整批移除。
176
+ d.tasks = d.tasks.filter(function (x) { return !(x && x.proposalOf === t.id && x.status === 'draft') })
177
+ if (verdict === 'keep') {
178
+ var rationale = String(proposal.rationale == null ? '' : proposal.rationale).trim().slice(0, 1500)
179
+ t.messages.push({ kind: 'decompose', text: '🧩 拆解官判定无需拆分:' + (rationale || '(未给理由)'), at: stamp, by: 'decomposer' })
180
+ ah(t, t.status, t.status, 'system', '拆解官判定无需拆分(rationale 落源卡消息)')
181
+ return { ok: true, verdict: 'keep', children: [] }
182
+ }
183
+ // 物化:先分配整批 id(dependsOnIdx → 真实 id 翻译要求全员 id 已知),再逐张落 draft 子卡。
184
+ var ids = []
185
+ for (var k = 0; k < kids.length; k++) ids.push(decomposeChildId(d, t.id, k + 1))
186
+ var created = []
187
+ for (var k2 = 0; k2 < kids.length; k2++) {
188
+ var c = kids[k2]
189
+ var deps = []
190
+ var di = Array.isArray(c.dependsOnIdx) ? c.dependsOnIdx : []
191
+ for (var dj = 0; dj < di.length; dj++) {
192
+ var idx = di[dj]
193
+ if (typeof idx === 'number' && isFinite(idx) && idx >= 0 && idx < k2 && deps.indexOf(ids[idx]) < 0) deps.push(ids[idx])
194
+ }
195
+ var child = {
196
+ id: ids[k2],
197
+ title: String(c.title).trim().slice(0, 200),
198
+ description: String(c.description == null ? '' : c.description).slice(0, 8000),
199
+ status: 'draft',
200
+ priority: t.priority || 'medium',
201
+ tags: Array.isArray(t.tags) ? t.tags.slice() : [],
202
+ parentId: t.id,
203
+ subtaskStrategy: null,
204
+ assignMode: 'auto',
205
+ assignee: null,
206
+ context: { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' },
207
+ acceptance: String(c.acceptance == null ? '' : c.acceptance).slice(0, 2000),
208
+ dependsOn: deps,
209
+ touches: Array.isArray(c.touches) ? c.touches.filter(function (x) { return typeof x === 'string' && x.trim() }).slice(0, 20).map(function (x) { return x.trim() }) : [],
210
+ pipeline: '',
211
+ claimedBy: null, claimedAt: null, createdAt: stamp,
212
+ resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null,
213
+ messages: [],
214
+ history: [{ from: 'created', to: 'draft', timestamp: stamp, actor: 'decomposer', note: '拆解官提案物化(源卡 ' + t.id + ')' }],
215
+ proposalOf: t.id,
216
+ }
217
+ // 拆解官定的 touches 就是它的调研结论(task-mvnrtpxo,task-mvnnkv7x epic 发布预检「无调研包」误报实证):
218
+ // 物化时 context.files 自动 = touches(文件级路径,Worker 可按需 read 自取;touches 已过滤+截 20,与
219
+ // contextFiles 上限同口径),预检 epicPrecheck 不再对拆解子卡报 missing。context.notes 留空——
220
+ // child.description 本身已含实现侧结论,不重复复制。
221
+ child.context.files = child.touches.slice()
222
+ // 与 create-task 同口径:未显式给 pipeline 时按卡面分类(含 acceptance 的通常落 full)
223
+ child.pipeline = classifyPipeline(child); child.pipelineAuto = true
224
+ var sz = typeof c.size === 'string' ? c.size.trim() : ''
225
+ if (sz === 'light' || sz === 'standard' || sz === 'heavy') child.size = sz
226
+ var mh = typeof c.modelHint === 'string' ? c.modelHint.trim() : ''
227
+ if (mh) child.model = mh
228
+ d.tasks.push(child)
229
+ created.push(child)
230
+ }
231
+ var summary = created.map(function (x) { return x.id + '·' + x.title }).join('、').slice(0, 900)
232
+ t.messages.push({ kind: 'decompose', text: '🧩 拆解提案已就位(' + created.length + ' 张草稿子卡):' + summary + '——确认后逐张 publish(task_update publish=true)', at: stamp, by: 'decomposer' })
233
+ ah(t, t.status, t.status, 'system', '拆解官提案物化 ' + created.length + ' 张草稿子卡(draft,待主窗口确认发布)')
234
+ return { ok: true, verdict: 'split', children: created.map(function (x) { return { id: x.id, title: x.title } }) }
235
+ }