dsh-agent-board 1.6.0 → 1.7.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/rpc.mjs CHANGED
@@ -11,7 +11,8 @@ import fsNode from 'node:fs'
11
11
  import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary } from './usage.mjs'
12
12
  import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
13
13
  import { makeMsg } from './notify.mjs'
14
- const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, PRIO_RANK, touchesConflict, holdsFiles, boardHome } = core
14
+ import { computeHealthHints } from './health.mjs'
15
+ const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote } = core
15
16
 
16
17
  function defineTool(options) {
17
18
  var userExecute = options.execute
@@ -40,6 +41,7 @@ export function createRpc(ctx, state, deps) {
40
41
  var rt = deps.rt, mutateLocked = deps.mutateLocked
41
42
  var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
42
43
  var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
44
+ var pushSysNote = deps.pushSysNote, sessionCwd = deps.sessionCwd // 调研门禁③:epic 发布预检的投递通道与路径解析根
43
45
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
44
46
  var teamModeCache = state.teamModeCache
45
47
  var feedbackCache = state.feedbackCache
@@ -47,6 +49,20 @@ export function createRpc(ctx, state, deps) {
47
49
  var handlers = state.handlers
48
50
  function handle(method, fn) { handlers[method] = fn }
49
51
 
52
+ // ===== 调研门禁③配套:epic 发布预检 =====
53
+ // 路径存在性判定:相对路径按「该会话工作区」解析(与 readContextPack 同根,不靠进程 cwd);
54
+ // existsSync 同步轻量,只在 publish 时跑一遍(每子任务 ≤20 条路径,不读全文)。
55
+ function existsInSession(sid) {
56
+ var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
57
+ return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
58
+ }
59
+ // 预检有缺失才 pushSysNote 汇总给主窗口;全部有材料不打扰(响应体同样不挂字段)。锁外调用,不拖长写锁。
60
+ function afterPublishPrecheck(sid, taskId, result) {
61
+ var pre = result && result.ok && result.epicPrecheck
62
+ if (!pre || !pre.missing || !pre.missing.length) return
63
+ if (typeof pushSysNote === 'function') pushSysNote(sid, epicPrecheckNote(pre), taskId)
64
+ }
65
+
50
66
  // ===== Tools =====
51
67
  ctx.tools.register(defineTool({ name: 'task_list', description: '列出当前会话任务。', parameters: { type: 'object', properties: { status: { type: 'string', enum: ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'cancelled'] }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tag: { type: 'string' }, parentId: { type: 'string' }, includeArchived: { type: 'boolean' }, limit: { type: 'number' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var a = d.tasks; var ts = a; if (!args.includeArchived) ts = ts.filter(function (x) { return x.status !== 'archived' }); if (args.status) ts = ts.filter(function (x) { return x.status === args.status }); if (args.priority) ts = ts.filter(function (x) { return x.priority === args.priority }); if (args.tag) ts = ts.filter(function (x) { return (x.tags || []).indexOf(args.tag) >= 0 }); if (args.parentId === 'null') ts = ts.filter(function (x) { return !isb(x) }); else if (args.parentId) ts = ts.filter(function (x) { return x.parentId === args.parentId }); var po = PRIO_RANK; ts.sort(function (a, b) { var dd = (po[b.priority] || 0) - (po[a.priority] || 0); return dd !== 0 ? dd : (a.createdAt || '').localeCompare(b.createdAt || '') }); var lim = Math.min(args.limit || 20, 100); var res = ts.slice(0, lim).map(function (x) { var e = Object.assign({}, x); if (isb(x)) { var p = gpt(x, a); if (p) e.parentSummary = { id: p.id, title: p.title, status: p.status } }; var ch = gsb(x.id, a); if (ch.length) { e.subtaskCount = ch.length; e.subtaskResolved = ch.filter(function (y) { return y.status === 'resolved' }).length }; return e }); var out = { tasks: res, total: ts.length, session: sid, actor: getActorId(), boardMode: d.boardMode || 'auto', teamMode: !!d.teamMode, poolStatus: d.poolStatus }; if (d.teamMode) out.teamHint = 'Team 模式已开启:实质性改动请优先 task_create 提交看板由池执行;调研/读取/讨论可直接进行;Worker 歧义会上报等你裁决。'; return out } }))
52
68
  ctx.tools.register(defineTool({ name: 'task_context', description: '获取任务完整上下文。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, expandFiles: { type: 'boolean' }, includeParent: { type: 'boolean' }, includeSubtasks: { type: 'boolean' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var d = await rt(sid); var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found: ' + args.taskId }; return { ok: true, context: { task: t, inheritedContext: t.context || {} } } } }))
@@ -55,8 +71,8 @@ export function createRpc(ctx, state, deps) {
55
71
  ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; if (t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
56
72
  ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; return verifyApply(d, t, actor, args.verdict, args.comment) }) } }))
57
73
  ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'only resolved/cancelled' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'archived'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) } }))
58
- ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) } }))
59
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务进行中持有文件锁,提交验收/完成后释放。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); return withSplitHint({ ok: true, task: t }, t) }) } }))
74
+ ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
75
+ ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时 host 会从磁盘读取最新内容,通过「上下文注入」通道传给 Worker/Verifier(呈现为独立注入区块,不占对话流),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示——调研材料会让 Worker 少花 10-15 分钟自行 grep。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务进行中持有文件锁,提交验收/完成后释放。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径),内容将在派发时经上下文注入传给子代理' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),经上下文注入传给子代理' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id: ' + args.id }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title, description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t); var __warns = createTaskWarnings(t); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
60
76
 
61
77
  // ===== RPC =====
62
78
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -71,6 +87,11 @@ export function createRpc(ctx, state, deps) {
71
87
  d.workMode = deriveWorkMode(d)
72
88
  // ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
73
89
  d.usageSummary = aggregateUsageSummary(d.tasks)
90
+ // 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
91
+ d.healthHints = computeHealthHints(d.tasks)
92
+ // 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, resolved, active, activeTitle } },
93
+ // 父卡列位置/进度展示的数据源;无非归档子任务的父卡不出键
94
+ d.childStats = aggregateChildStats(d.tasks)
74
95
  return d })
75
96
  // 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
76
97
  handle('preview-context', async function (args) {
@@ -187,7 +208,7 @@ export function createRpc(ctx, state, deps) {
187
208
  return { ok: true, deleted: t.id }
188
209
  })
189
210
  })
190
- handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) })
211
+ handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; delete t.retryCount; delete t.stuckSince; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
191
212
  handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
192
213
  handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
193
214
  // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
@@ -439,7 +460,7 @@ export function createRpc(ctx, state, deps) {
439
460
  return { ok: true, delivered: delivered }
440
461
  })
441
462
  handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } return { ok: true } }) })
442
- handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return withSplitHint({ ok: true, task: t }, t) }) })
463
+ handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t); var warns = createTaskWarnings(t); if (warns.length) out.warning = warns.join(';'); return out }) })
443
464
  handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
444
465
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
445
466
  // delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.6.0",
4
+ "version": "1.7.0",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {
8
8
  "build-client": "node scripts/build-client.cjs",
9
- "test": "node --test test/core.test.mjs",
9
+ "test": "node --test test/*.test.mjs",
10
10
  "pretest": "npm run build-client",
11
11
  "sync-readme": "node -e \"require('fs').copyFileSync('../../README.md','README.md')\"",
12
12
  "prepublishOnly": "npm run build-client && node --check index.mjs && node --check lib/client.js && node --check lib/core.mjs && npm test",
@@ -12,6 +12,7 @@ import * as core from '../lib/core.mjs'
12
12
  // 均由 index.mjs 薄壳 re-export(对外契约不变),这里仍从 index.mjs 导入直接断言
13
13
  import { suggestSplitOf, withSplitHint, SUGGEST_SPLIT_TEXT, TASK_SIZE_CONTRACT, TEAM_SPLIT_RULE } from '../index.mjs'
14
14
  import { aggregateUsageSummary, readRunUsage, findRunLog } from '../index.mjs'
15
+ import { createRpc } from '../lib/rpc.mjs'
15
16
  // host 端源码拼接(Phase 2 模块化后:薄壳 index.mjs + lib/*.mjs 领域模块),供源码级接线断言
16
17
  const HOST_SOURCES = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/notify.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
17
18
  function hostSrc() { return HOST_SOURCES.map(function (f) { return readFileSync(new URL(f, import.meta.url), 'utf8') }).join('\n') }
@@ -702,6 +703,170 @@ test('粒度治理接线:工具描述/Team 提示词/双出口返回体均已
702
703
  assert.match(src, /SPLIT_DESC_LIMIT = 500/) // 500 字符阈值在位
703
704
  })
704
705
 
706
+ // ===== create-task 空 description 软警告(E 卡)=====
707
+ // 轻量 RPC 直调 harness:mock ctx(tools/effect/webServer)+ deps 最小面,直接调 handlers['create-task']
708
+ // extra 可覆盖默认 deps(如 epic 预检用例注入 pushSysNote 捕获 / sessionCwd 解析根)
709
+ function mkRpcHandlers(board, extra) {
710
+ const state = { handlers: {}, teamModeCache: {}, feedbackCache: {} }
711
+ const ctx = { tools: { register() {} }, effect() {}, webServer: { register() { return () => {} } } }
712
+ const deps = Object.assign({
713
+ getActorId: () => 'tester', resolveRoot: (x) => x,
714
+ toolSessionId: () => 's1', rpcSessionId: () => 's1',
715
+ rootForSession: () => null, deriveWorkMode: () => 'solo', runsFor: () => [],
716
+ rt: async () => board, mutateLocked: (sid, fn) => fn(board),
717
+ maybeNotify: () => {}, notifyTaskDone: () => {},
718
+ spawnOneShot: () => {}, accumulateRunUsage: () => {}, readContextPack: async () => null,
719
+ pushSysNote: () => {}, sessionCwd: () => '',
720
+ }, extra || {})
721
+ createRpc(ctx, state, deps)
722
+ return state.handlers
723
+ }
724
+ const EMPTY_DESC_WARNING = '任务描述为空——Worker 只能凭标题猜需求,建议补一句目标/约束'
725
+
726
+ test('create-task RPC: description 空白(空串 / 纯空格)→ 创建成功且响应附 warning 软警告', async () => {
727
+ let board = mkBoard([])
728
+ let r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: '' })
729
+ assert.equal(r.ok, true); assert.equal(r.warning, EMPTY_DESC_WARNING)
730
+ assert.equal(board.tasks.length, 1) // 软警告不拦截创建
731
+ board = mkBoard([])
732
+ r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: ' ' }) // 纯空格同样视为空白(trim 判定)
733
+ assert.equal(r.ok, true); assert.equal(r.warning, EMPTY_DESC_WARNING)
734
+ assert.equal(board.tasks.length, 1)
735
+ })
736
+
737
+ test('create-task RPC: description 非空 → 响应无 warning 字段(返回体形态不变,老调用方无感)', async () => {
738
+ const board = mkBoard([])
739
+ const r = await mkRpcHandlers(board)['create-task']({ title: 'T', description: '把按钮文案从 A 改成 B,肉眼确认' })
740
+ assert.equal(r.ok, true)
741
+ assert.equal('warning' in r, false)
742
+ assert.equal(board.tasks.length, 1)
743
+ })
744
+
745
+ // ===== 调研门禁(task-mute6zpw):warning 族扩展 + epic 发布预检 + 读包失败落卡 =====
746
+ // 纯函数口径在 core.mjs(isTreeGlob/createTaskWarnings/epicPrecheck/epicPrecheckNote);
747
+ // 这里既测纯函数矩阵,也经 mkRpcHandlers 直调验证双通道接线(warning 合并、publish 预检投递)。
748
+
749
+ test('isTreeGlob: 仅「以 /** 结尾或恰为 **」算整树 glob', () => {
750
+ for (const p of ['**', 'src/**', './src/**', 'src\\**', 'a/b/**']) assert.equal(core.isTreeGlob(p), true, p)
751
+ for (const p of ['src/*', 'src/**/*.mjs', '*.mjs', 'src/x.mjs', '', '**x']) assert.equal(core.isTreeGlob(p), false, p)
752
+ })
753
+
754
+ test('createTaskWarnings: 触发/不触发矩阵(pipeline × touches × 调研上下文)', () => {
755
+ // ① description 空白(沿用 E 卡既有文案,逐字一致)
756
+ assert.deepEqual(core.createTaskWarnings(mkTask({ description: ' ' })), [EMPTY_DESC_WARNING])
757
+ // ② full/work + touches 非空 + contextFiles/contextNotes 皆空 → 无调研上下文警告
758
+ const noCtx = core.createTaskWarnings(mkTask({ description: 'd', pipeline: 'full', touches: ['lib/a.mjs'] }))
759
+ assert.equal(noCtx.length, 1); assert.match(noCtx[0], /未附调研上下文/)
760
+ const work = core.createTaskWarnings(mkTask({ description: 'd', pipeline: 'work', touches: ['lib/a.mjs'] }))
761
+ assert.equal(work.length, 1); assert.match(work[0], /未附调研上下文/)
762
+ // 有 contextFiles 或 contextNotes 任一 → ②不触发
763
+ assert.deepEqual(core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'], context: { files: ['lib/a.mjs'], notes: '' } })), [])
764
+ assert.deepEqual(core.createTaskWarnings(mkTask({ description: 'd', touches: ['lib/a.mjs'], context: { files: [], notes: '结论' } })), [])
765
+ // pipeline=direct 或 touches 为空 → ②③都不触发
766
+ assert.deepEqual(core.createTaskWarnings(mkTask({ description: 'd', pipeline: 'direct', touches: ['src/**'] })), [])
767
+ assert.deepEqual(core.createTaskWarnings(mkTask({ description: 'd', touches: [] })), [])
768
+ // ③ 整树 glob(与 ② 同现时的数组序:② 在前 ③ 在后)
769
+ const both = core.createTaskWarnings(mkTask({ description: 'd', touches: ['src/**'] }))
770
+ assert.equal(both.length, 2); assert.match(both[0], /未附调研上下文/); assert.match(both[1], /整树 glob/)
771
+ // 有调研上下文时只剩 ③
772
+ const globOnly = core.createTaskWarnings(mkTask({ description: 'd', touches: ['src/**'], context: { files: [], notes: 'n' } }))
773
+ assert.equal(globOnly.length, 1); assert.match(globOnly[0], /整树 glob/)
774
+ })
775
+
776
+ test('create-task RPC: touches 非空且无调研上下文 → warning 附「未附调研上下文」(软提示不拦截)', async () => {
777
+ const board = mkBoard([])
778
+ const r = await mkRpcHandlers(board)['create-task']({ title: '改 lib/x.mjs 的返回值', description: '明确描述', touches: ['lib/x.mjs'] })
779
+ assert.equal(r.ok, true); assert.match(r.warning, /未附调研上下文/); assert.equal(board.tasks.length, 1)
780
+ })
781
+
782
+ test('create-task RPC: 整树 glob → warning 附「整树 glob」;pipeline=direct 不触发 a/b', async () => {
783
+ let board = mkBoard([])
784
+ let r = await mkRpcHandlers(board)['create-task']({ title: '调整 src 目录的一批样式', description: 'd', touches: ['src/**'], contextNotes: '已调研' })
785
+ assert.equal(r.ok, true); assert.match(r.warning, /整树 glob/); assert.ok(!/未附调研上下文/.test(r.warning))
786
+ board = mkBoard([])
787
+ r = await mkRpcHandlers(board)['create-task']({ title: '改 src 下某个开关', description: 'd', pipeline: 'direct', touches: ['src/**'] })
788
+ assert.equal(r.ok, true); assert.equal('warning' in r, false)
789
+ })
790
+
791
+ test('create-task RPC: 空描述 + 无调研上下文 + 整树 glob → 三条合并为一条 warning(;分隔)', async () => {
792
+ const board = mkBoard([])
793
+ const r = await mkRpcHandlers(board)['create-task']({ title: '改 src 一批文件', description: '', touches: ['src/**'] })
794
+ assert.equal(r.ok, true)
795
+ assert.match(r.warning, /任务描述为空/); assert.match(r.warning, /未附调研上下文/); assert.match(r.warning, /整树 glob/)
796
+ assert.equal(r.warning.split(';').length, 3)
797
+ })
798
+
799
+ test('epicPrecheck: 字段有无 + 路径存在性(exists 注入),direct/归档子任务不参与', () => {
800
+ const kids = [
801
+ mkTask({ id: 'k1', title: '无材料', parentId: 'ep', context: { files: [], notes: '' } }),
802
+ mkTask({ id: 'k2', title: '有笔记', parentId: 'ep', context: { files: [], notes: '结论' } }),
803
+ mkTask({ id: 'k3', title: '路径全不存在', parentId: 'ep', context: { files: ['a.mjs', 'b.mjs:L3-L9'], notes: '' } }),
804
+ mkTask({ id: 'k4', title: '路径存在', parentId: 'ep', context: { files: ['c.mjs'], notes: '' } }),
805
+ mkTask({ id: 'k5', title: 'direct 跳过', parentId: 'ep', pipeline: 'direct', context: { files: [], notes: '' } }),
806
+ mkTask({ id: 'k6', title: '归档跳过', parentId: 'ep', status: 'archived', context: { files: [], notes: '' } }),
807
+ ]
808
+ const pre = core.epicPrecheck(kids, 'ep', (p) => p === 'c.mjs')
809
+ assert.equal(pre.total, 4) // direct/归档不计入 N
810
+ assert.deepEqual(pre.missing.map((m) => m.id), ['k1', 'k3'])
811
+ assert.match(pre.missing[0].reason, /无 contextFiles/)
812
+ assert.match(pre.missing[1].reason, /全部不存在/)
813
+ // 锚点 :L 段先剥掉再查存在性('b.mjs:L3-L9' 以 'b.mjs' 查 exists)
814
+ const seen = []
815
+ core.epicPrecheck([mkTask({ id: 'x', parentId: 'ep', context: { files: ['b.mjs:L3-L9'], notes: '' } })], 'ep', (p) => { seen.push(p); return true })
816
+ assert.deepEqual(seen, ['b.mjs'])
817
+ })
818
+
819
+ test('epicPrecheckNote: 全部有材料 → 空串(不打扰);有缺失 → N/M 汇总文案(超 5 条折叠)', () => {
820
+ assert.equal(core.epicPrecheckNote({ total: 3, missing: [] }), '')
821
+ assert.equal(core.epicPrecheckNote(null), '')
822
+ const note = core.epicPrecheckNote({ total: 3, missing: [{ id: 'k1', title: '无材料', reason: '无 contextFiles/contextNotes' }] })
823
+ assert.match(note, /epic 发布预检:3 个子任务中 1 个无调研注入/)
824
+ assert.match(note, /k1「无材料」/)
825
+ const many = { total: 7, missing: [1, 2, 3, 4, 5, 6].map((i) => ({ id: 'k' + i, title: 't' + i, reason: 'r' })) }
826
+ const note2 = core.epicPrecheckNote(many)
827
+ assert.match(note2, /7 个子任务中 6 个无调研注入/); assert.match(note2, /等/)
828
+ })
829
+
830
+ test('update-task RPC: 发布 epic(有子任务缺调研注入)→ pushSysNote 汇总 + 响应挂 epicPrecheck;全有材料不打扰', async () => {
831
+ const notes = []
832
+ const board = mkBoard([
833
+ mkTask({ id: 'ep', title: '史诗', status: 'draft' }),
834
+ mkTask({ id: 'c1', title: '子任务1', parentId: 'ep', status: 'draft', context: { files: [], notes: '' } }),
835
+ mkTask({ id: 'c2', title: '子任务2', parentId: 'ep', status: 'draft', context: { files: [], notes: 'n' } }),
836
+ ])
837
+ const handlers = mkRpcHandlers(board, { pushSysNote: (sid, text, taskId) => notes.push({ text, taskId }) })
838
+ const r = await handlers['update-task']({ taskId: 'ep', publish: true })
839
+ assert.equal(r.ok, true)
840
+ assert.equal(board.tasks[0].status, 'pending') // 照常发布,预检不阻断
841
+ assert.equal(notes.length, 1)
842
+ assert.match(notes[0].text, /epic 发布预检:2 个子任务中 1 个无调研注入/)
843
+ assert.match(notes[0].text, /c1「子任务1」/)
844
+ assert.equal(notes[0].taskId, 'ep')
845
+ assert.ok(r.epicPrecheck && r.epicPrecheck.missing.length === 1)
846
+ // 全部有材料 → 不投递、响应不挂字段(「不打扰」口径)
847
+ const notes2 = []
848
+ const board2 = mkBoard([
849
+ mkTask({ id: 'ep2', title: '史诗2', status: 'draft' }),
850
+ mkTask({ id: 'c3', title: '子任务3', parentId: 'ep2', status: 'draft', context: { files: [], notes: 'n' } }),
851
+ ])
852
+ const h2 = mkRpcHandlers(board2, { pushSysNote: (sid, text) => notes2.push(text) })
853
+ const r2 = await h2['update-task']({ taskId: 'ep2', publish: true })
854
+ assert.equal(r2.ok, true); assert.equal(notes2.length, 0); assert.equal('epicPrecheck' in r2, false)
855
+ // 非 draft 发布被拒(not a draft)→ 不预检不投递:ep2 已是 pending,重复 publish 应被拒
856
+ const r3 = await h2['update-task']({ taskId: 'ep2', publish: true })
857
+ assert.equal(r3.ok, false); assert.match(r3.error, /not a draft/); assert.equal(notes2.length, 0)
858
+ })
859
+
860
+ test('调研门禁接线:双通道 warning 调用 / epic 预检投递 / 读包失败落卡(源码级轻量断言)', () => {
861
+ const src = hostSrc()
862
+ assert.equal((src.match(/createTaskWarnings\(t\)/g) || []).length, 2) // task_create 工具 + create-task RPC
863
+ assert.match(src, /pushSysNote: notify\.pushSysNote, sessionCwd: session\.sessionCwd/) // index.mjs 接线
864
+ assert.match(src, /__pre = epicPrecheck\(d\.tasks, t\.id, existsFn\)/) // publish 预检调用
865
+ assert.match(src, /afterPublishPrecheck\(sid, args\.taskId, __res\)/) // 锁外 pushSysNote 投递
866
+ assert.match(src, /t2\.lastError = \('contextPack 读取失败: '/) // dispatch 读包失败落卡(mutateLocked 返回非空才写盘)
867
+ assert.match(src, /调研门禁:pipeline=full\/work 且 touches 非空/) // task_create 工具描述事前引导
868
+ })
869
+
705
870
  // ===== Token 消耗统计 =====
706
871
  test('aggregateUsageSummary: 空任务/无 usage 任务 → 全零 + 空 Top', () => {
707
872
  const s = aggregateUsageSummary([mkTask({ id: 'a' }), mkTask({ id: 'b', usage: null })])
@@ -864,3 +1029,151 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
864
1029
  assert.doesNotMatch(coreSrc, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
865
1030
  assert.doesNotMatch(cli, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
866
1031
  })
1032
+
1033
+ // ===== 架构自省 L1·UI:仪表盘「架构健康」提示区接线(源码级断言;纯函数信号本身见 health.test.mjs)=====
1034
+ test('架构健康接线:get-tasks 返回 healthHints + 仪表盘 HealthHints 区缺省兼容渲染', () => {
1035
+ const host = hostSrc()
1036
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1037
+ assert.match(host, /d\.healthHints = computeHealthHints\(d\.tasks\)/) // host 每次请求现算返回(零存储)
1038
+ assert.match(cli, /function HealthHints\(\)/) // 仪表盘提示区组件
1039
+ assert.match(cli, /React\.createElement\(HealthHints\)/) // 挂进 Dashboard(统计卡行下方、Token 消耗区上方)
1040
+ assert.match(cli, /Array\.isArray\(d\.healthHints\)/) // 缺省兼容:老 host 无字段/非数组 → 按空处理
1041
+ assert.match(cli, /if \(!hints\.length\) return null/) // 空数组 → 整块不渲染(零残留)
1042
+ assert.match(cli, /'架构健康'/) // 区标题
1043
+ assert.match(cli, /warn \? C\.warn : infoColor/) // warn=琥珀 / info=蓝灰 两级配色
1044
+ assert.match(cli, /dsw-alias-state-business-primary/) // info 蓝灰用 DSH 现有 business 信息色(色板无独立 info 档)
1045
+ })
1046
+
1047
+ // ===== 史诗父卡语义层:父卡自动流转 + childStats 聚合 =====
1048
+ test('parentKickOnDispatch: 子任务派发时 pending 父卡 → in-progress(记 ah),返回父卡', () => {
1049
+ const p = mkTask({ id: 'epic', status: 'pending' })
1050
+ const c = mkTask({ id: 'c1', parentId: 'epic' })
1051
+ const d = mkBoard([p, c])
1052
+ const r = core.parentKickOnDispatch(d, c)
1053
+ assert.equal(r, p)
1054
+ assert.equal(p.status, 'in-progress')
1055
+ const h = p.history[p.history.length - 1]
1056
+ assert.equal(h.from, 'pending'); assert.equal(h.to, 'in-progress')
1057
+ assert.match(h.note, /首个子任务派发,史诗进入推进态/)
1058
+ })
1059
+
1060
+ test('parentKickOnDispatch: 已在 in-progress 不重放 / verifying 不动', () => {
1061
+ const p = mkTask({ id: 'epic', status: 'in-progress' })
1062
+ const c1 = mkTask({ id: 'c1', parentId: 'epic' }); const c2 = mkTask({ id: 'c2', parentId: 'epic' })
1063
+ const d = mkBoard([p, c1, c2])
1064
+ assert.equal(core.parentKickOnDispatch(d, c1), null) // 已推进态 → 不重放
1065
+ assert.equal(p.history.length, 0)
1066
+ assert.equal(p.status, 'in-progress')
1067
+ // verifying 父卡(全子任务 resolved 后的验收态)同样不动
1068
+ const pv = mkTask({ id: 'epic2', status: 'verifying' })
1069
+ const cv = mkTask({ id: 'c3', parentId: 'epic2' })
1070
+ const d2 = mkBoard([pv, cv])
1071
+ assert.equal(core.parentKickOnDispatch(d2, cv), null)
1072
+ assert.equal(pv.status, 'verifying'); assert.equal(pv.history.length, 0)
1073
+ })
1074
+
1075
+ test('parentKickOnDispatch: draft 父卡不动(草稿是刻意的人工态)', () => {
1076
+ const p = mkTask({ id: 'epic', status: 'draft' })
1077
+ const c = mkTask({ id: 'c1', parentId: 'epic' })
1078
+ const d = mkBoard([p, c])
1079
+ assert.equal(core.parentKickOnDispatch(d, c), null)
1080
+ assert.equal(p.status, 'draft'); assert.equal(p.history.length, 0)
1081
+ })
1082
+
1083
+ test('parentKickOnDispatch: 无 parentId / 父卡不存在 → null,无副作用', () => {
1084
+ const solo = mkTask({ id: 'solo' }); const d = mkBoard([solo])
1085
+ assert.equal(core.parentKickOnDispatch(d, solo), null)
1086
+ const orphan = mkTask({ id: 'c1', parentId: 'ghost' }); const d2 = mkBoard([orphan])
1087
+ assert.equal(core.parentKickOnDispatch(d2, orphan), null)
1088
+ })
1089
+
1090
+ test('aggregateChildStats: 空板 / 无父子关系 → 空对象(不出键)', () => {
1091
+ assert.deepEqual(core.aggregateChildStats([]), {})
1092
+ assert.deepEqual(core.aggregateChildStats(null), {})
1093
+ assert.deepEqual(core.aggregateChildStats(undefined), {})
1094
+ const d = [mkTask({ id: 'a' }), mkTask({ id: 'b', status: 'in-progress' })]
1095
+ assert.deepEqual(core.aggregateChildStats(d), {})
1096
+ })
1097
+
1098
+ test('aggregateChildStats: 聚合口径 total/resolved/active/activeTitle', () => {
1099
+ const kids = [
1100
+ mkTask({ id: 'c1', parentId: 'epic', status: 'resolved' }),
1101
+ mkTask({ id: 'c2', parentId: 'epic', status: 'in-progress', title: '进行中甲' }),
1102
+ mkTask({ id: 'c3', parentId: 'epic', status: 'pending' }),
1103
+ mkTask({ id: 'c4', parentId: 'epic', status: 'in-progress', title: '进行中乙' }),
1104
+ mkTask({ id: 'c5', parentId: 'epic', status: 'verifying' }),
1105
+ mkTask({ id: 'c6', parentId: 'epic', status: 'blocked' }),
1106
+ ]
1107
+ const s = core.aggregateChildStats(kids).epic
1108
+ assert.equal(s.total, 6) // 非归档子任务全计
1109
+ assert.equal(s.resolved, 1) // 仅 resolved(verifying 不算完成)
1110
+ assert.equal(s.active, 2) // in-progress 数
1111
+ assert.equal(s.activeTitle, '进行中甲') // 第一个 in-progress 子任务标题(看板顺序)
1112
+ })
1113
+
1114
+ test('aggregateChildStats: 归档子任务不计入;全归档 → 不出键;多父卡各自成键', () => {
1115
+ const tasks = [
1116
+ // 父卡 p1:1 resolved + 1 archived(归档不计)+ 1 in-progress
1117
+ mkTask({ id: 'a1', parentId: 'p1', status: 'resolved' }),
1118
+ mkTask({ id: 'a2', parentId: 'p1', status: 'archived' }),
1119
+ mkTask({ id: 'a3', parentId: 'p1', status: 'in-progress', title: 'p1活跃' }),
1120
+ // 父卡 p2:全部 archived → 不出键
1121
+ mkTask({ id: 'b1', parentId: 'p2', status: 'archived' }),
1122
+ mkTask({ id: 'b2', parentId: 'p2', status: 'archived' }),
1123
+ // 父卡 p3:无活跃子任务 → activeTitle 空串
1124
+ mkTask({ id: 'd1', parentId: 'p3', status: 'pending' }),
1125
+ mkTask({ id: 'd2', parentId: 'p3', status: 'resolved' }),
1126
+ ]
1127
+ const stats = core.aggregateChildStats(tasks)
1128
+ assert.deepEqual(Object.keys(stats).sort(), ['p1', 'p3']) // p2 全归档不出键;父卡自身不在 tasks 也照算(按键聚合)
1129
+ assert.deepEqual(stats.p1, { total: 2, resolved: 1, active: 1, activeTitle: 'p1活跃' }) // archived 的 a2 不进 total
1130
+ assert.deepEqual(stats.p3, { total: 2, resolved: 1, active: 0, activeTitle: '' })
1131
+ })
1132
+
1133
+ test('史诗语义层接线:poolCycle 派发分支触发父卡流转 + get-tasks 返回 childStats(源码级断言)', () => {
1134
+ const host = hostSrc()
1135
+ // poolCycle 占位 claim 的 dispatch 分支:claimApply 后紧跟 parentKickOnDispatch
1136
+ assert.match(host, /claimApply\(d, t, 'spawn-pending', 'dispatch'\); if \(parentKickOnDispatch\(d, t\)\)/)
1137
+ // get-tasks 现算 childStats(零存储)
1138
+ assert.match(host, /d\.childStats = aggregateChildStats\(d\.tasks\)/)
1139
+ // 两模块都从 core 解构引入(接线不断);rpc.mjs 解构表尾部随调研门禁(task-mute6zpw)扩展三个纯函数
1140
+ assert.match(host, /parentKickOnDispatch, LESSON_RECALL_HINT \} = core/)
1141
+ assert.match(host, /boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote \} = core/)
1142
+ // 既有「全子任务 resolved → 父 verifying」逻辑不动(checkParentAuto 仍在 verifyApply 链路;core.mjs 不在 hostSrc 清单,单独读)
1143
+ const coreSrc = readFileSync(new URL('../lib/core.mjs', import.meta.url), 'utf8')
1144
+ assert.match(coreSrc, /if \(verdict === 'approved' && isb\(t\)\) \{ var p = checkParentAuto\(d, t\)/)
1145
+ })
1146
+
1147
+ // ===== 防再发:notify 逻辑单一来源(lib/notify.mjs)=====
1148
+ // dispatch.mjs 曾藏整套 notify 僵尸重复副本(本地 receiptBuf/escNotifyTimers + notifyTaskDone/flushReceipts 等:
1149
+ // 函数声明提升后被头部 var x = deps.x 赋值覆盖,是永不执行的死代码——改错地方不产生效果,挪赋值顺序即引爆)。
1150
+ // 此处断言六个函数的定义只存在于 notify.mjs,任何模块再长出副本即红。
1151
+ test('notify 单一来源:notifyTaskDone/flushReceipts/deliverEscalation 等函数定义只存在于 lib/notify.mjs', () => {
1152
+ const names = ['deliverEscalation', 'notifyMainWindow', 'maybeNotify', 'notifyTaskDone', 'pushSysNote', 'flushReceipts']
1153
+ const others = ['../index.mjs', '../lib/policy.mjs', '../lib/usage.mjs', '../lib/session.mjs', '../lib/store.mjs', '../lib/dispatch.mjs', '../lib/rpc.mjs']
1154
+ const notifySrc = readFileSync(new URL('../lib/notify.mjs', import.meta.url), 'utf8')
1155
+ for (const name of names) {
1156
+ const re = new RegExp('function ' + name + '\\s*\\(')
1157
+ assert.match(notifySrc, re, 'lib/notify.mjs 缺少 ' + name + ' 定义(权威实现应在此)')
1158
+ for (const f of others) {
1159
+ const src = readFileSync(new URL(f, import.meta.url), 'utf8')
1160
+ assert.ok(!re.test(src), f + ' 不得再定义 ' + name + '(notify 逻辑唯一权威在 lib/notify.mjs,副本是僵尸死代码)')
1161
+ }
1162
+ }
1163
+ })
1164
+
1165
+ // ===== 调研门禁·UI:无调研徽章 + 详情注入清单 + 创建表单 warning 展示(源码级断言)=====
1166
+ test('调研门禁 UI 接线:卡片「⚠️ 无调研」徽章 + 详情「调研注入」清单 + 创建表单 warning 黄行(源码级断言)', () => {
1167
+ const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
1168
+ assert.match(cli, /function noResearch\(t\)/) // 徽章判定函数(纯 client 侧现算)
1169
+ assert.match(cli, /pl !== 'full' && pl !== 'work'/) // 口径:仅 full/work 管线参与(direct 主窗口自处理)
1170
+ assert.match(cli, /'⚠️ 无调研'/) // 卡片标题行右侧徽章文案
1171
+ assert.match(cli, /本任务未附调研上下文,Worker 需自行定位/) // 徽章悬停解释(title)
1172
+ assert.match(cli, /var cx = t\.context \|\| \{\}/) // context 缺省兼容:老任务无 context 按空处理(预期亮徽章)
1173
+ assert.match(cli, /'📎 调研注入: '/) // 详情页「调研注入」区标题
1174
+ assert.match(cli, /无调研注入——Worker 需自行定位/) // 详情空态明示
1175
+ assert.match(cli, /个文件(' \+ files\.map\(function \(f\) \{ return actBase\(f\) \}\)/) // files 清单按 basename 展示
1176
+ assert.match(cli, /调研笔记 ' \+ notes\.length \+ ' 字/) // notes 字数统计
1177
+ assert.match(cli, /if \(r && r\.warning\)/) // 创建表单消费 create-task 响应的 warning 字段
1178
+ assert.match(cli, /'⚠️ ' \+ warn/) // warning 原文黄色行展示(C.warn)
1179
+ })