dsh-agent-board 1.7.0 → 1.7.2
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/README.md +21 -10
- package/index.mjs +10 -3
- package/lib/client/board-list.js +47 -5
- package/lib/client/dashboard.js +151 -8
- package/lib/client/kernel.js +61 -15
- package/lib/client/task-detail.js +161 -9
- package/lib/client.js +420 -37
- package/lib/core.mjs +402 -21
- package/lib/dispatch.mjs +164 -12
- package/lib/health.mjs +43 -11
- package/lib/notify.mjs +42 -12
- package/lib/policy.mjs +17 -1
- package/lib/rpc.mjs +71 -13
- package/lib/session.mjs +24 -3
- package/lib/store.mjs +2 -1
- package/lib/usage.mjs +84 -3
- package/package.json +1 -1
- package/test/core.test.mjs +1906 -38
- package/test/health.test.mjs +86 -4
package/lib/rpc.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary } from '
|
|
|
12
12
|
import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
|
|
13
13
|
import { makeMsg } from './notify.mjs'
|
|
14
14
|
import { computeHealthHints } from './health.mjs'
|
|
15
|
-
|
|
15
|
+
import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
|
|
16
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection } = core
|
|
16
17
|
|
|
17
18
|
function defineTool(options) {
|
|
18
19
|
var userExecute = options.execute
|
|
@@ -42,9 +43,26 @@ export function createRpc(ctx, state, deps) {
|
|
|
42
43
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
43
44
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
44
45
|
var pushSysNote = deps.pushSysNote, sessionCwd = deps.sessionCwd // 调研门禁③:epic 发布预检的投递通道与路径解析根
|
|
46
|
+
// 僵尸 epic 出清(反馈 n-mutma3mmwceq):archive 门禁的活性判定由 index.mjs 接线注入
|
|
47
|
+
// (runsFor(sid)[taskId] 存在且 !settled 即活跃)。未注入时按「有活跃 run」兜底——
|
|
48
|
+
// 保守保持旧门禁行为(in-progress 一律拒归档),绝不因缺依赖误放走在跑的任务。
|
|
49
|
+
var hasActiveRun = typeof deps.hasActiveRun === 'function' ? deps.hasActiveRun : function () { return true }
|
|
50
|
+
// archive 门禁(archive-task RPC 与 task_archive 工具同一口径,返回错误串或 null 放行):
|
|
51
|
+
// resolved / cancelled → 放行(既有行为)
|
|
52
|
+
// in-progress 且无活跃 run → 放行(parentKick 僵尸态出清:direct epic 被
|
|
53
|
+
// parentKickOnDispatch 推进到 in-progress 后子任务已归档、无 run、claimedBy=null,
|
|
54
|
+
// 此前 archive(非 resolved/cancelled)与 resolve(not claimed by you)双拒,
|
|
55
|
+
// 只能 resetToPending→delete 三段舞)
|
|
56
|
+
// in-progress 有活跃 run / 其余状态 → 拒绝
|
|
57
|
+
function archiveErr(sid, t) {
|
|
58
|
+
if (t.status === 'resolved' || t.status === 'cancelled') return null
|
|
59
|
+
if (t.status === 'in-progress' && !hasActiveRun(sid, t.id)) return null
|
|
60
|
+
return 'cannot archive'
|
|
61
|
+
}
|
|
45
62
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
46
63
|
var teamModeCache = state.teamModeCache
|
|
47
64
|
var feedbackCache = state.feedbackCache
|
|
65
|
+
var epicSplitCache = state.epicSplitCache
|
|
48
66
|
// RPC handlers 表必须在最前面初始化:后面的 handle(...) 调用依赖它(var 只提升声明不提升赋值)
|
|
49
67
|
var handlers = state.handlers
|
|
50
68
|
function handle(method, fn) { handlers[method] = fn }
|
|
@@ -63,35 +81,65 @@ export function createRpc(ctx, state, deps) {
|
|
|
63
81
|
if (typeof pushSysNote === 'function') pushSysNote(sid, epicPrecheckNote(pre), taskId)
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
// ===== hooks(史诗 hooks=agent run)只许主窗口设置 =====
|
|
85
|
+
// 工具任务链早已有「看板管理工具仅主窗口可用」门禁(resolveRoot(actor) === actor),这里单独再拦一道:
|
|
86
|
+
// hooks 是高权限入口(会 spawn 真的 agent run 去动代码/跑收口),RPC 通道同样只放行主窗口。
|
|
87
|
+
var HOOKS_MAIN_ONLY = 'hooks 仅主窗口可设(子代理无 hooks 权限)'
|
|
88
|
+
function hooksDenied(actor) { return resolveRoot(actor) !== actor }
|
|
89
|
+
// hooks 浅校验 + 与既有值浅合并;返回 { hooks } 或 { error }(口径唯一在 core.normalizeHooks)。
|
|
90
|
+
// 传 hooks=null 表示整体清除;传 { pre: null } 表示只撤 pre 点位;未提交的点位保留原值。
|
|
91
|
+
function applyHooks(target, incoming) {
|
|
92
|
+
var n = normalizeHooks(incoming)
|
|
93
|
+
if (n.error) return n
|
|
94
|
+
var merged = mergeHooks(target.hooks, n.hooks)
|
|
95
|
+
if (Object.keys(merged).length) target.hooks = merged
|
|
96
|
+
else delete target.hooks
|
|
97
|
+
return { ok: true }
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
// ===== Tools =====
|
|
67
101
|
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 } }))
|
|
68
102
|
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 || {} } } } }))
|
|
69
103
|
ctx.tools.register(defineTool({ name: 'task_preview_context', description: '派发前上下文预览:预演 task_create 的 contextFiles/contextNotes 将注入给子代理的实际内容(读盘后的最终形态),用于确认材料是否足够。不创建任务。返回 ok=false/empty=true 说明无可注入内容。', parameters: { type: 'object', properties: { contextFiles: { type: 'array', items: { type: 'string' }, description: '预演的文件路径列表' }, contextNotes: { type: 'string', description: '预演的调研笔记' } }, required: [] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []; var notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''; if (!files.length && !notes.trim()) return { ok: false, error: 'contextFiles/contextNotes 至少提供一个' }; try { var pack = await readContextPack(sid, { context: { files: files, notes: notes } }); return { ok: true, empty: !pack, pack: pack } } catch (e) { return { ok: false, error: String(e).slice(0, 200) } } } }))
|
|
70
104
|
ctx.tools.register(defineTool({ name: 'task_claim', description: '领取待办任务→in-progress。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { 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 t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, args.reason || 'claimed'); return { ok: true, task: t, context: { task: t, inheritedContext: t.context || {} } } }) } }))
|
|
71
105
|
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) }) } }))
|
|
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' };
|
|
73
|
-
ctx.tools.register(defineTool({ name: 'task_archive', description: '
|
|
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
|
|
106
|
+
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' }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
|
|
107
|
+
ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务(无活跃 run 的 in-progress 僵尸卡也可归档,用于 parentKick 僵尸态出清)。', 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' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; 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 }) } }))
|
|
108
|
+
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 状态有效)' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; 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') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
109
|
+
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 可选:调研结论/原始需求/思路等非文件类上下文,同样走上下文注入。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 contextFiles、结论思路放进 contextNotes——实测可让 Worker 省去 10~15 分钟从零 grep 定位的时间,且方向感天壤之别(无调研的 Worker 只能靠标题猜需求、极易跑偏)。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务持有文件锁(in-progress/verifying/resolved 都持有,锁持到归档才真释放——护住验收后-提交前的提交窗口期;cancelled 立即释放)。' + 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 可跳过草稿' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; 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 (args.hooks !== undefined) { var __hn = normalizeHooks(args.hooks); if (__hn.error) return { ok: false, error: __hn.error }; if (__hn.hooks && Object.keys(__hn.hooks).length) t.hooks = __hn.hooks } 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, cfg(d).epicSplit); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
|
|
76
110
|
|
|
77
111
|
// ===== RPC =====
|
|
78
112
|
// get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
|
|
79
113
|
// 客户端 3s 轮询不再触发池计算/写盘(之前每轮询一次就 poolCycle+写盘一次,切会话时多会话轮询挤在文件锁上)
|
|
114
|
+
// isRoot 现算不落盘:生成开始/结束瞬间 agents 树重建,roots() 存在瞬态窗口返回不含本 sid(假 false)。
|
|
115
|
+
// host 不改行为(保持"现算真相"),防抖在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次
|
|
116
|
+
// false(3s 心跳≈9s)才收抽屉;从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
|
|
80
117
|
handle('get-tasks', async function (args) { var sid = rpcSessionId(args); var d = await rt(sid); d.sessionId = sid; var __ag = ctx.agents; d.isRoot = true; if (__ag) { var __roots = __ag.roots(); var __rids = []; for (var __i = 0; __i < __roots.length; __i++) __rids.push(String(__roots[__i].id)); d.isRoot = __rids.indexOf(sid) >= 0 }
|
|
81
118
|
// poolStatus 防幽灵:只保留指向当前活跃任务的条目(重启后内存 runs 清空,文件快照可能残留)
|
|
82
119
|
if (d.poolStatus) { var __act = {}; (d.tasks || []).forEach(function (t) { if (t.status === 'in-progress' || t.status === 'verifying') __act[t.id] = true }); d.poolStatus.workers = (d.poolStatus.workers || []).filter(function (w) { return __act[w.taskId] }); d.poolStatus.verifiers = (d.poolStatus.verifiers || []).filter(function (v) { return __act[v.taskId] }) }
|
|
83
120
|
// 学习飞轮 v1:把 feedbackEnabled 显式放进返回体(normalizeBoard 已按老看板补默认 true),
|
|
84
121
|
// 客户端据此做能力检测——开关关闭时渲染层不生成候选、不显示「沉淀」按钮。
|
|
85
122
|
d.feedbackEnabled = cfg(d).feedbackEnabled
|
|
123
|
+
// 回执开关(设置区「通知」小节)同法显式透出:老 host 不返回时客户端按「缺字段=开」兜底,
|
|
124
|
+
// 这里给出确定布尔值,UI 勾选态不依赖客户端各自的兜底写法。
|
|
125
|
+
d.notifyDispatch = cfg(d).notifyDispatch
|
|
126
|
+
d.notifyDone = cfg(d).notifyDone
|
|
127
|
+
// 史诗拆分总开关(设置区「功能」小节)同法显式透出确定布尔值:老 host 不返回时客户端按「缺字段=开」兜底,
|
|
128
|
+
// UI 勾选态不依赖各自兜底写法。
|
|
129
|
+
d.epicSplit = cfg(d).epicSplit
|
|
86
130
|
// 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
|
|
87
131
|
d.workMode = deriveWorkMode(d)
|
|
88
132
|
// ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
|
|
89
133
|
d.usageSummary = aggregateUsageSummary(d.tasks)
|
|
90
134
|
// 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
|
|
91
135
|
d.healthHints = computeHealthHints(d.tasks)
|
|
92
|
-
// 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, resolved, active, activeTitle } },
|
|
93
|
-
//
|
|
136
|
+
// 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
|
|
137
|
+
// 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
|
|
138
|
+
// 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
|
|
94
139
|
d.childStats = aggregateChildStats(d.tasks)
|
|
140
|
+
// 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
|
|
141
|
+
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify——传输仍全量,省的是渲染
|
|
142
|
+
d.tasksHash = tasksHash(d.tasks)
|
|
95
143
|
return d })
|
|
96
144
|
// 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
|
|
97
145
|
handle('preview-context', async function (args) {
|
|
@@ -170,8 +218,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
170
218
|
})
|
|
171
219
|
handle('claim-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' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, 'manual claim via board'); return { ok: true, task: t } }) })
|
|
172
220
|
handle('resolve-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 (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
|
|
173
|
-
handle('verify-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 (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') pushRejectLesson(d, t, args.comment); return r }) })
|
|
174
|
-
handle('archive-task', async function (args) { var sid = rpcSessionId(args); 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' };
|
|
221
|
+
handle('verify-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 (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
|
|
222
|
+
handle('archive-task', async function (args) { var sid = rpcSessionId(args); 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' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'manual archive'); 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 }) })
|
|
175
223
|
// ===== 任务删除通道(真删,无 undo)=====
|
|
176
224
|
// 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
|
|
177
225
|
// 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
|
|
@@ -208,7 +256,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
208
256
|
return { ok: true, deleted: t.id }
|
|
209
257
|
})
|
|
210
258
|
})
|
|
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 })
|
|
259
|
+
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; 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') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
212
260
|
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) } }) })
|
|
213
261
|
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) } }) })
|
|
214
262
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -413,6 +461,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
413
461
|
if (!approved) { t.rejectCount = (t.rejectCount || 0) + 1; if (t.rejectCount >= 3) { t.status = 'blocked'; ah(t, 'in-progress', 'blocked', 'system', 'verifier 驳回 x' + t.rejectCount + ',待人工裁决') } }
|
|
414
462
|
// 学习飞轮 v1:工具通道驳回同样生成候选教训(与文本通道同一条判重口径,不会重复落)
|
|
415
463
|
if (!approved) pushRejectLesson(d, t, (args.summary || '') + (args.checks ? '\n' + args.checks : ''), t.verification.at)
|
|
464
|
+
// 驳回包全量带回(task-muvg15p5):checks(逐条核对证据)此前只进 history 且被截到 200 字——
|
|
465
|
+
// 现落一条 kind='rejection' 全量包,随 buildMessages 注入重派 Worker prompt(见 core.pushRejection)
|
|
466
|
+
if (!approved) pushRejection(t, args.summary, args.checks, t.verification.at, actor)
|
|
416
467
|
return { ok: true, task: t }
|
|
417
468
|
})
|
|
418
469
|
if (result && result.ok && result.task) {
|
|
@@ -420,7 +471,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
420
471
|
if (result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
|
|
421
472
|
}
|
|
422
473
|
if (result && result.ok && !approved && result.task) {
|
|
423
|
-
// v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker
|
|
474
|
+
// v74 一次性模型:原 Worker 已销毁,驳回任务回 pending 重派新 Worker(完整驳回包已落 t.messages,
|
|
475
|
+
// 经 buildMessages 全量随 prompt 注入;history 只是审计轨)
|
|
424
476
|
var bt = result.task
|
|
425
477
|
if ((bt.rejectCount || 0) < 3) { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === bt.id }); if (t2 && t2.status === 'in-progress') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null }; return t2 }, true) }
|
|
426
478
|
}
|
|
@@ -459,8 +511,14 @@ export function createRpc(ctx, state, deps) {
|
|
|
459
511
|
} catch (e) { console.error('[task-board] push-lesson followup failed:', String(e)) }
|
|
460
512
|
return { ok: true, delivered: delivered }
|
|
461
513
|
})
|
|
462
|
-
|
|
463
|
-
|
|
514
|
+
// 板级配置写入(设置区单入口):数值键钳制范围、字符串键 trim、布尔键原样存。
|
|
515
|
+
// 回执开关 notifyDispatch/notifyDone(UI「通知」小节):布尔原样落盘,缺省 true 由 normalizeBoard 补;
|
|
516
|
+
// 生效点在派发侧读 cfg(snap)(不回填缓存),下一次派发/结算周期即生效。
|
|
517
|
+
// 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
|
|
518
|
+
// 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
|
|
519
|
+
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
520
|
+
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 } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
521
|
+
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = getActorId(); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } 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 (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; 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, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
|
|
464
522
|
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) } } })
|
|
465
523
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
|
466
524
|
// delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
|
package/lib/session.mjs
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
// dsh-agent-board — 会话层(lib/session.mjs)
|
|
2
2
|
// createSession(ctx, state):agent 归属解析(resolveRoot 10s 缓存)/ 工具与 RPC 的会话 id 归一 /
|
|
3
|
-
// 已知会话心跳集合 / 工作模式三档派生 / feedback 开关读取 / 按会话找 root agent / 会话工作区 /
|
|
3
|
+
// 已知会话心跳集合 / 工作模式三档派生 / feedback 与 epicSplit 开关读取 / 按会话找 root agent / 会话工作区 /
|
|
4
4
|
// 通用超时包装 / 活跃 run 表与派发履历访问。共享状态全部经 state 显式注入(别名为本地 var)。
|
|
5
|
+
// 另出口纯函数 isFullSessionId(幻影板防线口径,rpc.mjs 直接 import 复用,与 policy.mjs 纯函数同例)。
|
|
6
|
+
|
|
7
|
+
// 完整会话 id 结构判定(防幻影板,task-muuf0o7a):真实 root 会话 id 形如
|
|
8
|
+
// session-cc24eb5c-702c-...(长度 ≥20 且至少两个 '-');裸短 id(如 cc24eb5c——某次调用
|
|
9
|
+
// 传了截断短 id)不具备完整结构,用它建出的 tasks-<短id>.json 是幻影板:poolCycle 对它
|
|
10
|
+
// pickDispatch 成功却永远匹配不到活 root(真实 id 带 session- 前缀与后缀),每 15s 心跳
|
|
11
|
+
// console.error 刷屏。import- 前缀板(import-sess_df0837fa-...)是合法的导入会话板,放行。
|
|
12
|
+
export function isFullSessionId(sid) {
|
|
13
|
+
if (!sid || typeof sid !== 'string') return false
|
|
14
|
+
if (sid.indexOf('import-') === 0) return true // import- 前缀板合法,结构判断放行
|
|
15
|
+
if (sid.length < 20) return false
|
|
16
|
+
var first = sid.indexOf('-')
|
|
17
|
+
return first > 0 && sid.indexOf('-', first + 1) > first
|
|
18
|
+
}
|
|
5
19
|
|
|
6
20
|
export function createSession(ctx, state) {
|
|
7
21
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
8
22
|
var knownSessions = state.knownSessions
|
|
9
23
|
var feedbackCache = state.feedbackCache
|
|
24
|
+
var epicSplitCache = state.epicSplitCache
|
|
10
25
|
var activeRuns = state.activeRuns
|
|
11
26
|
var dispatchedEver = state.dispatchedEver
|
|
12
27
|
|
|
@@ -32,11 +47,17 @@ export function createSession(ctx, state) {
|
|
|
32
47
|
function rpcSessionId(args) { var sid = (args && typeof args.sessionId === 'string' && args.sessionId.length > 0) ? args.sessionId : resolveRoot(getActorId()); touchSession(sid); return sid }
|
|
33
48
|
// 已知会话集合:心跳驱动这些会话的 poolCycle(摆脱对客户端轮询的依赖)
|
|
34
49
|
// 带 TTL 淘汰:>30 分钟无活跃的会话从心跳中移除,避免长期运行后空转 poolCycle
|
|
35
|
-
|
|
50
|
+
// 不完整短 id 不注册(幻影板防线 task-muuf0o7a):短 id 板永远匹配不到活 root,
|
|
51
|
+
// 进心跳集合只会让 poolCycle 每 15s 空转刷屏——在注册口直接拦住。
|
|
52
|
+
function touchSession(sid) { if (sid && typeof sid === 'string' && sid !== 'unknown' && isFullSessionId(sid)) knownSessions[sid] = Date.now() }
|
|
36
53
|
// teamMode 缓存:由 rt() 同步,供 systemPrompt 动态引导段读取(v65)
|
|
37
54
|
// feedbackEnabled 缓存(学习飞轮 v1):同样由 rt() 同步——systemPrompt 的组装是同步函数,
|
|
38
55
|
// 不能在里面读文件。与 teamModeCache 同源同生命周期(引导段本来就要 teamMode 命中才渲染)。
|
|
39
56
|
function feedbackOn(sid) { return feedbackCache[sid] !== false }
|
|
57
|
+
// 史诗拆分总开关缓存(epicSplit,缺省 true):与 feedbackCache 同源同生命周期——Team 提示词组装是同步
|
|
58
|
+
// 函数,只能读缓存;由 rt() 每次读盘同步、set-board-config 写入时当场更新。缺键=开(老看板字段由
|
|
59
|
+
// normalizeBoard 补 true,未落盘前也不会误判为关)。
|
|
60
|
+
function epicSplitOn(sid) { return epicSplitCache[sid] !== false }
|
|
40
61
|
// 工作模式三档收敛(UI 一维化):内部仍存 boardMode+teamMode 两个 flag(老数据/老 RPC 无损),
|
|
41
62
|
// workMode 是纯派生字段——由两 flag 算出,不落盘(normalizeBoard 无需改动)。
|
|
42
63
|
// 'team' → boardMode=auto + teamMode=true(主窗口当调度员:默认草稿 + 裁决歧义)
|
|
@@ -56,5 +77,5 @@ export function createSession(ctx, state) {
|
|
|
56
77
|
function runsFor(sid) { if (!activeRuns[sid]) activeRuns[sid] = {}; return activeRuns[sid] }
|
|
57
78
|
function isDispatched(sid, id) { return !!(id && dispatchedEver[sid] && dispatchedEver[sid][id]) }
|
|
58
79
|
|
|
59
|
-
return { getActorId: getActorId, resolveRoot: resolveRoot, toolSessionId: toolSessionId, rpcSessionId: rpcSessionId, touchSession: touchSession, rootForSession: rootForSession, sessionCwd: sessionCwd, deriveWorkMode: deriveWorkMode, feedbackOn: feedbackOn, withTimeout: withTimeout, runsFor: runsFor, isDispatched: isDispatched }
|
|
80
|
+
return { getActorId: getActorId, resolveRoot: resolveRoot, toolSessionId: toolSessionId, rpcSessionId: rpcSessionId, touchSession: touchSession, rootForSession: rootForSession, sessionCwd: sessionCwd, deriveWorkMode: deriveWorkMode, feedbackOn: feedbackOn, epicSplitOn: epicSplitOn, withTimeout: withTimeout, runsFor: runsFor, isDispatched: isDispatched, isFullSessionId: isFullSessionId }
|
|
60
81
|
}
|
package/lib/store.mjs
CHANGED
|
@@ -13,6 +13,7 @@ export function createStore(ctx, state, deps) {
|
|
|
13
13
|
// 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
|
|
14
14
|
var teamModeCache = state.teamModeCache
|
|
15
15
|
var feedbackCache = state.feedbackCache
|
|
16
|
+
var epicSplitCache = state.epicSplitCache
|
|
16
17
|
var fileLocks = state.fileLocks
|
|
17
18
|
var cyclePending = state.cyclePending
|
|
18
19
|
|
|
@@ -137,7 +138,7 @@ export function createStore(ctx, state, deps) {
|
|
|
137
138
|
try {
|
|
138
139
|
var d = JSON.parse(r)
|
|
139
140
|
if (vt(d) && d.ownerSession === sid) {
|
|
140
|
-
teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false
|
|
141
|
+
teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false; epicSplitCache[sid] = nd.epicSplit !== false
|
|
141
142
|
// ownerCwd 回填(只改内存,随下一次写盘落盘):老看板文件没有该字段,而继承判定靠它——
|
|
142
143
|
// 不写盘就永远不能匿名继承。这里不额外做 IO,避免把只读路径变成写路径。
|
|
143
144
|
if (!nd.ownerCwd) { var cw = sessionCwd(sid); if (cw) nd.ownerCwd = cw }
|
package/lib/usage.mjs
CHANGED
|
@@ -114,19 +114,100 @@ export function readRunUsage(runId, sessionsRoot) {
|
|
|
114
114
|
}
|
|
115
115
|
function numOr0(v) { var n = Number(v); return isFinite(n) && n > 0 ? n : 0 }
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// 本地日期 key(YYYY-MM-DD):与 dispatch.mjs 的日账记账同一口径。
|
|
118
|
+
// 用本地 getters 拼而不用 toISOString()——UTC 会把晚间消耗挪到次日,「今日消耗」直接错位。
|
|
119
|
+
function localDayKey(d) {
|
|
120
|
+
var x = d
|
|
121
|
+
function p2(n) { return (n < 10 ? '0' : '') + n }
|
|
122
|
+
return x.getFullYear() + '-' + p2(x.getMonth() + 1) + '-' + p2(x.getDate())
|
|
123
|
+
}
|
|
124
|
+
// ISO 串 → 本地日 key;缺失/解析失败返回 ''(宁可漏记一天,也不错记到别的日子)
|
|
125
|
+
function dayKeyOf(iso) {
|
|
126
|
+
if (!iso) return ''
|
|
127
|
+
var d = new Date(iso)
|
|
128
|
+
if (isNaN(d.getTime())) return ''
|
|
129
|
+
return localDayKey(d)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 有效消耗 = 输入 + 输出 + 缓存写(不含缓存读)。
|
|
133
|
+
// 口径背景:本板实测累计 total 中缓存读占 94%(9.7M/10.3M),大数字被缓存读撑高约 17 倍,
|
|
134
|
+
// 与「真实花掉多少」严重脱节;有效消耗才是可比的成本口径,缓存读保留为独立可查分量。
|
|
135
|
+
export function effectiveTokens(u) {
|
|
136
|
+
if (!u) return 0
|
|
137
|
+
return numOr0(u.input) + numOr0(u.output) + numOr0(u.cacheWrite)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 单卡「有效消耗」展示口径(task-muupnnq5):ROI 行与 Token 消耗区必须同源,否则同一张卡两处两个数。
|
|
141
|
+
// - 有结算分量(input/output/cacheWrite 任一为 >0 的数字)→ 有效消耗 = 输入+输出+缓存写(不含缓存读);
|
|
142
|
+
// - 无任何分量字段(老形态 / 只有 total 的存量卡)→ 兜底退化为 total,调用方可按 fallback 标注口径;
|
|
143
|
+
// - 两者皆无 → null(表示「没有可用的结算记录」,调用方跳过该样本而不是记 0,避免拉低均值)。
|
|
144
|
+
// 返回 { tok, fallback, total }:tok 为 null 时整卡不参与均值。
|
|
145
|
+
export function taskEffectiveTokens(u) {
|
|
146
|
+
if (!u) return { tok: null, fallback: false, total: null }
|
|
147
|
+
var hasPart = numOr0(u.input) > 0 || numOr0(u.output) > 0 || numOr0(u.cacheWrite) > 0
|
|
148
|
+
var tot = numOr0(u.total) > 0 ? numOr0(u.total) : null
|
|
149
|
+
if (hasPart) return { tok: effectiveTokens(u), fallback: false, total: tot }
|
|
150
|
+
if (tot !== null) return { tok: tot, fallback: true, total: tot }
|
|
151
|
+
return { tok: null, fallback: false, total: null }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 日账单元的归一化读取:新形态是 { t, e }(t=总量含缓存读,e=有效消耗不含缓存读),
|
|
155
|
+
// 老形态是裸 number(只有总量)→ e 返回 null 表示「不可知」——调用方按近似口径展示(标 ~),
|
|
156
|
+
// 绝不把总量冒充有效值(伪造一个有效数字比留 null 更坏)。
|
|
157
|
+
// 注意区分「不可知」与「真的是 0」:老 number 形态永远 e=null(哪怕 t=0);
|
|
158
|
+
// 新对象形态缺 e 字段视为没记过有效值 → 同样 null(宁可显示 ~ 近似,不假装是 0)。
|
|
159
|
+
function dayCell(v) {
|
|
160
|
+
if (v && typeof v === 'object') {
|
|
161
|
+
var hasT = v.t !== undefined && v.t !== null
|
|
162
|
+
var hasE = v.e !== undefined && v.e !== null
|
|
163
|
+
var t = Number(v.t); var e = Number(v.e)
|
|
164
|
+
return { t: (hasT && isFinite(t) && t > 0) ? t : 0, e: (hasE && isFinite(e)) ? e : null }
|
|
165
|
+
}
|
|
166
|
+
var n = Number(v)
|
|
167
|
+
return { t: isFinite(n) && n > 0 ? n : 0, e: null }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// board 级聚合(get-tasks 现算,不落盘额外表):总量 + 输入/输出/缓存读拆分 + 有效合计 + 按模型小计 + 任务 Top8 + 日账。
|
|
118
171
|
// 归档任务同样计入(它们确实消耗过 token);无 usage 的任务跳过。
|
|
119
172
|
export function aggregateUsageSummary(tasks) {
|
|
120
|
-
var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byModel: {}, topTasks: [] }
|
|
173
|
+
var s = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, effective: 0, byModel: {}, byDay: {}, topTasks: [] }
|
|
121
174
|
var list = Array.isArray(tasks) ? tasks : []
|
|
122
175
|
for (var i = 0; i < list.length; i++) {
|
|
123
176
|
var t = list[i]; var u = t && t.usage
|
|
124
177
|
if (!u) continue
|
|
125
178
|
s.total += u.total || 0; s.input += u.input || 0; s.output += u.output || 0
|
|
126
179
|
s.cacheRead += u.cacheRead || 0; s.cacheWrite += u.cacheWrite || 0
|
|
180
|
+
s.effective += effectiveTokens(u)
|
|
127
181
|
var ms = u.models || {}
|
|
128
182
|
for (var mk in ms) { if (Object.prototype.hasOwnProperty.call(ms, mk)) s.byModel[mk] = (s.byModel[mk] || 0) + (ms[mk] || 0) }
|
|
129
|
-
|
|
183
|
+
// Top8 带有效/缓存读拆分:仪表盘 Top 行 title 直接展示两个口径,点上就能核对虚高来源
|
|
184
|
+
if (u.total) s.topTasks.push({ id: t.id, title: t.title, total: u.total, effective: effectiveTokens(u), cacheRead: u.cacheRead || 0, runs: u.runs || 0 })
|
|
185
|
+
// 日账合并:任务自带 byDay(逐日精确)→ 逐 key 累加,并把日单元归一化成 {t,e}
|
|
186
|
+
// (老 number 形态 → {t:n, e:null},e 不可知就不猜;e 为 null 的日聚合后仍是 null)。
|
|
187
|
+
// 存量兜底:本功能上线前结算的老任务没有 byDay,把整笔 total 归到 updatedAt 的本地日——
|
|
188
|
+
// 近似口径(跨天老任务全部落在最后结算日),好过整段历史在「近 7 天」里凭空消失;
|
|
189
|
+
// 无 updatedAt / 解析失败则不归任何日(宁可漏不错)。兜底路径同样只有总量(e=null)。
|
|
190
|
+
var by = (u.byDay && typeof u.byDay === 'object') ? u.byDay : null
|
|
191
|
+
var hasBy = false
|
|
192
|
+
for (var bk in by) { if (Object.prototype.hasOwnProperty.call(by, bk)) { hasBy = true; break } }
|
|
193
|
+
if (hasBy) {
|
|
194
|
+
for (var dk in by) {
|
|
195
|
+
if (!Object.prototype.hasOwnProperty.call(by, dk)) continue
|
|
196
|
+
var c = dayCell(by[dk])
|
|
197
|
+
// 累加器初值 e 用 undefined(=还没合并过任何来源),与「已知不可知」(null)区分开——
|
|
198
|
+
// 若初值直接写 null,第一轮 cur.e===null 判定就把已知的有效值自我清空成 null(本实现踩过的坑)。
|
|
199
|
+
// 合并规则:任一来源 e 不可知 → 该日 e 不可知(不假装算得出);全部已知才累加。
|
|
200
|
+
var cur = s.byDay[dk] || { t: 0, e: undefined }
|
|
201
|
+
cur.t += c.t
|
|
202
|
+
if (c.e === null) cur.e = null
|
|
203
|
+
else if (cur.e !== null) cur.e = (cur.e || 0) + c.e
|
|
204
|
+
if (cur.e === undefined) cur.e = null
|
|
205
|
+
s.byDay[dk] = cur
|
|
206
|
+
}
|
|
207
|
+
} else if (u.total) {
|
|
208
|
+
var lk = dayKeyOf(u.updatedAt)
|
|
209
|
+
if (lk) { var lc = s.byDay[lk] || { t: 0, e: undefined }; lc.t += u.total; lc.e = null; s.byDay[lk] = lc }
|
|
210
|
+
}
|
|
130
211
|
}
|
|
131
212
|
s.topTasks.sort(function (a, b) { return b.total - a.total })
|
|
132
213
|
s.topTasks = s.topTasks.slice(0, 8)
|
package/package.json
CHANGED