dsh-agent-board 1.7.5 → 1.7.6

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/core.mjs CHANGED
@@ -715,6 +715,9 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
715
715
  // 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
716
716
  // 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
717
717
  if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
718
+ // git 纪律红线(n-muyaaoo7cqnd 实证事故:并行 Worker 跑 git checkout HEAD -- package.json
719
+ // 把另一 Worker 的在途编辑冲掉):禁止还原命令,只改自己写的文件,要干净基线走 escalate 上报。
720
+ p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。'
718
721
  p += '\n\n完成契约(双模,工具优先):\n1. 完成时:优先调用 board_report 工具(kind=complete, taskId=' + t.id + ',summary=开发描述/changes=改动清单/selfTest=自测情况/diffStat=变更概要);工具不可用则按分段格式输出(## 开发描述 / ## 改动清单 / ## 自测情况 / ## diff 概要)。\n diffStat 要求:若本次改动发生在 git 仓库内,运行 git diff --stat(含 git status --short),把输出贴进 diffStat(≤1500 字符);关键逻辑变更可附 ≤20 行核心片段。非代码任务/无 git 仓库可省略。\n **board_report 调用成功即任务终点:立即结束输出,不要再修改/验证任何文件**。上报后任务即刻进入验收,你继续改动会让代码在验收口径之外漂移、且阻塞 Verifier 派发(实测有 Worker 上报后又自测 16 分钟);上报后发现新问题的,写进 selfTest 备注交由 Verifier/主窗口裁决。\n2. 歧义/信息不足/需用户决策时:优先调用 board_report(kind=escalate, taskId=' + t.id + ', question=疑问);工具不可用则输出以 [ESCALATE] 开头的说明。不要猜测。上报歧义后直接结束本轮——裁决后会有新 Worker 带着裁决答案接手。\n3. 进展汇报(较大任务):按里程碑推进,每完成一个可验证的里程碑调用一次 board_report(kind="progress", taskId=' + t.id + ', question=一行进展摘要,≤200 字符)。只在有实际产物/结论时报;禁止定时汇报或表演式汇报。'
719
722
  return p
720
723
  }
@@ -728,6 +731,25 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
728
731
  // 双模:工具通道走 board_verdict 的 userTest 参数;文本通道追加「## 自测指南」段
729
732
  //(段体格式 parseUserTest 解析,落账 t.verification.userTest)。
730
733
  export var USER_GUIDE_CONTRACT = '\n\n自测指南(给用户看的验收指引,随结论一并提交):\n在结论分段之后追加一段「## 自测指南」,严格按四字段格式:\ngist: <一句人话:这次改了什么,用户能感知到什么>\ntier: ui | metric | internal(三选一,诚实分级:ui=界面可操作验证;metric=看指标/数据变化验证;internal=纯内部改动,无用户可感知面)\nsteps:\n1. <用户操作第一步>\n2. <用户操作第二步>\nexpect: <预期看到什么>\n诚实护栏:steps 只写你亲自验过、或能从 diff 直接推导的步骤,不许编造没验过的操作;UI 特性给具体路径(哪个区哪个按钮);tier=internal 时 steps 可留空,expect 写「验证靠测试套件」。\n工具通道:调用 board_verdict 时把四字段放进 userTest 参数({gist: 一句人话, steps: [步骤,...], expect: 预期, tier: ui|metric|internal})。'
734
+
735
+ // ===== Verifier 验收员加餐(task-muy3gm03):固定 persona + 工具收窄 =====
736
+ // 背景:Verifier 此前只有任务层 prompt 契约,没有「人设」——遇到 Worker 汇报与实证冲突时
737
+ // 容易顺着汇报走。宿主 spawn 面实证(dsh-subagent types.d.ts:136-191 + spawn-in-process
738
+ // lib/index.js:23-29 capabilities 全 true):一次性 start() 支持 persona(scoped
739
+ // deployment:persona-prefix 影子段,只覆盖该子代理的部署人设)与 toolFilter(scoped
740
+ // tools.restrict(),工具从 prompt 消失且拒绝执行)。continuable 面(ContinuableCreateRequest
741
+ // 只有 sessionId/parent/signal)不支持这两个参数——但 verifier 恒走一次性路径
742
+ //(useContinuable 只对 worker 开),挂一次性面即覆盖全部 verifier spawn。
743
+ // ⚠️ persona 走宿主严格 {{…}} 插值(同 deployment persona 模板语义)——本文不得出现连写 {。
744
+ // ⚠️ toolFilter 名字走「响亮未知名校验」:deny 了不存在的工具会直接抛错,故只砍
745
+ // 宿主标配的 web_fetch/web_search,且 dispatch 侧有「剥外挂参数重试」兜底。
746
+ export var VERIFIER_PERSONA = '你是任务看板的独立验收员(Verifier),不是 Worker 的队友。\n1. 独立判断:不轻信 Worker 的汇报与自测描述——「Worker 说已验证」不构成证据,一切以你亲自复跑/核对到的为准。\n2. 实证为准:有硬性验收脚本必须独立复跑并核对真实输出;脚本失败或无法复现成功一律 REJECTED。\n3. 诚实分级:自测指南按真实可验证面分级(ui/metric/internal),不为好看拔高档次,没验过的步骤不写。\n4. 双读者:结论同时写给主窗口(逐条核对证据,含行号)和用户(自测指南人话)——两者都要诚实、可复核。'
747
+ // toolFilter 收窄评估结论(保守口径):只砍明确无关的联网检索(web_fetch/web_search——
748
+ // 验收以本地实证为准,不需联网);其余一律保留:read/grep/glob/pwsh 是核对主力,
749
+ // write/edit 供文件纪律允许的临时/测试文件,board_verdict 是落账通道,note_* 供查历史教训,
750
+ // browser_* 保留(tier=ui 验收可能要真实开页面核对),task_*/看板管理工具子代理本无权限
751
+ //(rpc 层 root 闸门)无需再砍。拿不准的宁可不收窄。
752
+ export var VERIFIER_TOOL_FILTER = { deny: ['web_fetch', 'web_search'] }
731
753
  export function buildVerifierPrompt(t, pack, userGuide) {
732
754
  var notes = histNotes(t)
733
755
  var msgs = buildMessages(t)
@@ -742,6 +764,10 @@ export function buildVerifierPrompt(t, pack, userGuide) {
742
764
  // 跑偏归因条款(调研遵循·host 三件套 ②):驳回理由注明「立单缺调研」——归因计入驳回热点统计,
743
765
  // 供主窗口分诊「立单缺料 vs Worker 执行问题」,缺料占高了就该把建卡调研门禁拧紧。
744
766
  p += '\n驳回归因:若 Worker 的产出明显因缺少调研上下文而跑偏/绕路,驳回时请在驳回理由里注明「立单缺调研」(归因会计入驳回热点统计)。'
767
+ // 文件纪律(task-muy3gm03,用户口径「验收时可以写必要的测试文件,不要动工程代码」):
768
+ // 写死进 prompt 的硬纪律 + 事后兜底威慑(touches 审计:Worker 交付的 diff 概要与任务声明的
769
+ // touches 即改动范围基线,越界即驳回——这层审计对 verifier 自己同样生效)。
770
+ p += '\n文件纪律:验收时可以写必要的临时/测试文件(放 _scratch 或测试目录,验收结束后自行清理干净);禁止改动工程代码与文档(README 等)。事后有 touches 审计兜底:Worker 交付的 diff 概要与任务声明的 touches 是改动范围基线,发现越界改动(含你自己留下的改动痕迹)一律驳回。'
745
771
  p += '\n\n结论契约(双模,工具优先):\n1. 优先调用 board_verdict 工具(taskId=' + t.id + ', verdict=approved/rejected, summary=测试概要, checks=逐条核对证据含行号)。\n2. 工具不可用则首行 APPROVED: <结论> 或 REJECTED: <结论>,然后 ## 测试概要 / ## 核对项 分段。'
746
772
  // 自测指南段(verifyUserGuide 开关门禁,缺省开):整段追加在 prompt 末尾;关掉 = 逐字无此段(省 token),
747
773
  // 落账侧(settleVerifier/board_verdict)同开关门禁——关时即使输出带了段也不挂 userTest 字段。
@@ -897,6 +923,80 @@ export function createTaskWarnings(t) {
897
923
  return out
898
924
  }
899
925
 
926
+ // ===== 任务起草 lint(proposal n-muwlpbspl3w2):四规则软警告,存 task.lintWarnings[],不阻塞 =====
927
+ // 与上方「调研门禁」(createTaskWarnings,瞬态 warning 字段)正交:那是「缺调研材料」的即时软提示;
928
+ // 这是「起草质量」的持久诊断(粒度过粗/双仓库歧义/缺验收/标题缺动词/描述空),随卡落盘、
929
+ // get-tasks 透出、详情页 ⚠️ 行展示。纯函数:双仓库歧义读盘(io 注入),本体零 IO 可单测。
930
+ // 四规则(各一触发 + 各一不触发,单测锁定):
931
+ // ① touches 含整树 glob(src/** 级)→ 粒度过粗,几乎锁整仓
932
+ // ② touches 相对路径在 ownerCwd 多个直接子目录下同名命中 → 路径双仓库歧义
933
+ // ③ pipeline=full 但无 acceptance 验收脚本 → 建议带验收命令
934
+ // ④ 标题缺动作动词 / 描述为空 → 建议补指令式标题 / 补目标约束
935
+ export var TREE_GLOB_LINT = 'touches 粒度过粗,几乎锁整仓'
936
+ export var AMBIGUOUS_PATH_LINT = '路径双仓库歧义,建议加仓库前缀'
937
+ export var FULL_NO_ACCEPTANCE_LINT = 'full 管线建议带验收命令'
938
+ export var TITLE_NO_VERB_LINT = '标题缺动作动词,建议改为「动词+宾语」指令式'
939
+
940
+ // 标题动作动词白名单(中文指令式任务标题高频动词,命中任一即算「有动词」)。
941
+ // 只做软提示:宁可漏检不可误报(漏了最多不亮这条),因此不做「非动词开头即判缺动词」的严格反例。
942
+ var TITLE_VERBS = /添加|新增|修复|实现|重构|优化|支持|调整|更新|删除|迁移|拆分|整理|调研|设计|接入|改造|移除|升级|编写|撰写|起草|补充|完善|收敛|扩展|统一|替换|清理|测试|验证|审查|复核|审计|沉淀|回写|生成|构建|发布|上线|评审|分析|汇总|统计|梳理|排查|定位|解决|处理|切换|对齐|收口|接线|透出|承接|重建|适配|放宽|收紧|拦截|放行|归一|聚合|装配|注入|投递|挂载|卸载|裁剪|截断|折叠|去重|合并|回滚|撤销|归档|恢复|继承|出清|释放|覆盖|包裹|转发|代理|缓存|落盘|读盘|心跳|轮询|结算|推进|回填|重命名|改名|重写|重排|重试|重投|重派|复查|复跑|补全|补齐|补写|补录|追加|跟进|追踪|溯源|归因|归类|导出|导入|展示|渲染|刷新|跳转|筛选|排序/
943
+
944
+ // touches 相对路径的「仓库内目录前缀」:剥掉 glob 尾段,得到跨仓库判重锚点。
945
+ // 'packages/dsh-agent-board/**' → 'packages/dsh-agent-board';'src/x.mjs' → 'src';
946
+ // 'a/b/c.mjs' → 'a/b';裸文件名(无 '/')→ ''(无目录前缀,不参与双仓库判重)。
947
+ export function touchDirPrefix(p) {
948
+ var v = normTouch(p)
949
+ if (!v || v === '**') return ''
950
+ var i = v.lastIndexOf('/')
951
+ return i < 0 ? '' : v.slice(0, i)
952
+ }
953
+
954
+ // 双仓库歧义:某条 touches 相对路径的目录前缀,在 root 的 ≥2 个直接子目录下都存在 → 命中同名仓库副本。
955
+ // 返回命中的相对路径原文(首个)或 null;io = { root, subdirs[], dirExists(path) } 由调用方注入(读盘)。
956
+ // 绝对路径/盘符(C:)/裸文件名不参与判重(裸文件名没有目录锚点,判不了)。
957
+ export function ambiguousTouch(touches, io) {
958
+ if (!io || typeof io.dirExists !== 'function') return null
959
+ var list = Array.isArray(touches) ? touches : []
960
+ var subs = Array.isArray(io.subdirs) ? io.subdirs : []
961
+ if (!subs.length) return null
962
+ var root = String(io.root || '')
963
+ for (var i = 0; i < list.length; i++) {
964
+ var p = list[i]
965
+ if (typeof p !== 'string' || !p.trim()) continue
966
+ var v = normTouch(p)
967
+ if (!v || path.isAbsolute(v) || /^[a-zA-Z]:/.test(v)) continue
968
+ var dir = touchDirPrefix(v)
969
+ if (!dir) continue
970
+ var hits = 0
971
+ for (var j = 0; j < subs.length && hits < 2; j++) {
972
+ var cand = path.join(root, subs[j], dir)
973
+ var ok = false
974
+ try { ok = !!io.dirExists(cand) } catch (_) { ok = false }
975
+ if (ok) hits++
976
+ }
977
+ if (hits >= 2) return p
978
+ }
979
+ return null
980
+ }
981
+
982
+ // 起草质量 lint 主入口:返回 string[](task.lintWarnings 字段内容)。io 缺省时规则②退化为不触发(零 IO 安全)。
983
+ export function draftLint(t, io) {
984
+ var out = []
985
+ if (!t || typeof t !== 'object') return out
986
+ var touches = Array.isArray(t.touches) ? t.touches : []
987
+ // ① 整树 glob(多条只报一次)
988
+ for (var i = 0; i < touches.length; i++) { if (isTreeGlob(touches[i])) { out.push(TREE_GLOB_LINT); break } }
989
+ // ② 双仓库歧义(附命中路径,便于定位该加哪个仓库前缀)
990
+ var amb = ambiguousTouch(touches, io)
991
+ if (amb) out.push(AMBIGUOUS_PATH_LINT + '(' + amb + ')')
992
+ // ③ full 无 acceptance(pipeline 空串按默认 full 口径)
993
+ if ((t.pipeline || 'full') === 'full' && !String(t.acceptance || '').trim()) out.push(FULL_NO_ACCEPTANCE_LINT)
994
+ // ④ 标题缺动词 / 描述为空(描述为空复用调研门禁 EMPTY_DESC_WARNING 文案,单一事实源)
995
+ if (!TITLE_VERBS.test(String(t.title || ''))) out.push(TITLE_NO_VERB_LINT)
996
+ if (!String(t.description || '').trim()) out.push(EMPTY_DESC_WARNING)
997
+ return out
998
+ }
999
+
900
1000
  // ===== touches → suggestedContextFiles 自动桥接(调研遵循·host 三件套 ①)=====
901
1001
  // 让遵守成为最省力路径:建卡触发「无调研上下文」warning 时,直接把 touches 里的具体文件
902
1002
  // 提炼成可一键采纳的 contextFiles 建议(响应挂 suggestedContextFiles 字段)。
package/lib/dispatch.mjs CHANGED
@@ -7,7 +7,7 @@ import { statSync } from 'node:fs'
7
7
  import * as core from './core.mjs'
8
8
  import { readRunUsage, findRunLog, readLogBytes, readLogFrames } from './usage.mjs'
9
9
  import { splitRuleOf, pushRejectLesson } from './policy.mjs'
10
- const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection } = core
10
+ const { ah, cfg, claimApply, resolveApply, verifyApply, parseSections, outputText, pickDispatch, isOrphan, buildWorkerPrompt, buildVerifierPrompt, buildContextPackSection, parseContextFileEntry, parentKickOnDispatch, LESSON_RECALL_HINT, buildHookPrompt, applyHookSettle, hookOn, hookSetState, gsb, pushRejection, VERIFIER_PERSONA, VERIFIER_TOOL_FILTER } = core
11
11
 
12
12
  export function createDispatch(ctx, state, deps) {
13
13
  // 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
@@ -203,6 +203,15 @@ export function createDispatch(ctx, state, deps) {
203
203
  promptText = buildHookPrompt(t, role === 'hook-pre' ? 'pre' : 'post', kids)
204
204
  } else promptText = role === 'worker' ? buildWorkerPrompt(t, pack, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, pack, cfg(dsnap).verifyUserGuide)
205
205
  var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: promptText }], parent: parent, signal: makeSignal() }
206
+ // ===== Verifier 验收员加餐(task-muy3gm03):persona + toolFilter 只挂 verifier =====
207
+ // ① persona = 固定验收员人设(scoped deployment:persona-prefix 影子段,只覆盖该子代理);
208
+ // ② toolFilter = 保守收窄(deny web_fetch/web_search:验收以本地实证为准不需联网,其余工具
209
+ // 一律保留——评估口径见 core.mjs VERIFIER_TOOL_FILTER 头注释)。
210
+ // 两条 spawn 路径覆盖口径:continuable 面(ContinuableCreateRequest 仅 sessionId/parent/signal)
211
+ // 不支持 persona/toolFilter——但 verifier 恒走一次性 start()(useContinuable 只对 worker 开),
212
+ // 故挂在这里即覆盖全部 verifier spawn(含手动「派发验收」与自动派发两条入口,共用 spawnOneShot)。
213
+ // worker 请求不挂任何加餐(spawn 参数逐字 parity,单测锁定)。
214
+ if (role === 'verifier') { req.persona = VERIFIER_PERSONA; req.toolFilter = VERIFIER_TOOL_FILTER }
206
215
  if (modelOverride) {
207
216
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
208
217
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -230,9 +239,12 @@ export function createDispatch(ctx, state, deps) {
230
239
  run = await subagents.start(providerName, req)
231
240
  }
232
241
  } catch (e) {
233
- if (modelOverride) {
234
- console.error('[task-board] model override failed, fallback to parent model:', String(e))
235
- delete req.agentOptions
242
+ // 外挂参数回退(既有模型覆盖通道,task-muy3gm03 起并入 verifier 加餐 persona/toolFilter):
243
+ // provider 不支持某能力 / toolFilter 命中未知名时服务面 fail-loud 拒收(绝不静默忽略)——
244
+ // 剥光外挂参数用裸请求再试一次(文件纪律条款在 prompt 里,加餐剥掉纪律不丢),仍失败才放弃本轮。
245
+ if (modelOverride || req.persona !== undefined || req.toolFilter !== undefined) {
246
+ console.error('[task-board] spawn extras failed (model/persona/toolFilter), fallback to bare request:', String(e))
247
+ delete req.agentOptions; delete req.persona; delete req.toolFilter
236
248
  try {
237
249
  if (useContinuable) {
238
250
  var cs2 = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
package/lib/rpc.mjs CHANGED
@@ -8,12 +8,12 @@
8
8
  import * as core from './core.mjs'
9
9
  import path from 'node:path'
10
10
  import fsNode from 'node:fs'
11
- import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint } from './usage.mjs'
11
+ import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary, readMainWindowUsage, buildScoreboard, modelPerfHint, qualityChangeHints } from './usage.mjs'
12
12
  import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
13
13
  import { makeMsg } from './notify.mjs'
14
14
  import { computeHealthHints, computeRuntimeHealthHints } 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, archiveApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest } = core
16
+ const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, archiveApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
17
17
 
18
18
  function defineTool(options) {
19
19
  var userExecute = options.execute
@@ -93,6 +93,29 @@ export function createRpc(ctx, state, deps) {
93
93
  var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
94
94
  return function (p) { try { return fsNode.existsSync(path.isAbsolute(p) ? p : (cwd ? path.join(cwd, p) : p)) } catch (_) { return false } }
95
95
  }
96
+ // 起草 lint 的 IO 注入(双仓库歧义读盘):root=看板工作区(ownerCwd 优先,回退 sessionCwd),
97
+ // subdirs=root 下直接子目录名(readdirSync + isDirectory 过滤),dirExists=判目录存在。
98
+ // 读不到/异常一律按空处理(lint 是软提示,宁缺勿误报,绝不能因 IO 抛异常阻断建卡)。
99
+ function draftLintIo(sid, ownerCwd) {
100
+ var cwd = typeof sessionCwd === 'function' ? sessionCwd(sid) : ''
101
+ var root = String(ownerCwd || cwd || '')
102
+ var subdirs = []
103
+ if (root) {
104
+ try {
105
+ subdirs = fsNode.readdirSync(root, { withFileTypes: true })
106
+ .filter(function (e) { return e && e.isDirectory() })
107
+ .map(function (e) { return e.name })
108
+ } catch (_) { subdirs = [] }
109
+ }
110
+ return {
111
+ root: root,
112
+ subdirs: subdirs,
113
+ dirExists: function (p) {
114
+ if (!p) return false
115
+ try { return fsNode.existsSync(p) && fsNode.statSync(p).isDirectory() } catch (_) { return false }
116
+ }
117
+ }
118
+ }
96
119
  // 预检有缺失才 pushSysNote 汇总给主窗口;全部有材料不打扰(响应体同样不挂字段)。锁外调用,不拖长写锁。
97
120
  function afterPublishPrecheck(sid, taskId, result) {
98
121
  var pre = result && result.ok && result.epicPrecheck
@@ -128,8 +151,8 @@ export function createRpc(ctx, state, deps) {
128
151
  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 && 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) }) } }))
129
152
  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 }) } }))
130
153
  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 t = d.tasks.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 }; return archiveApply(d, t, actor, 'archived') }) } }))
131
- 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: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, 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 } }))
132
- ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 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/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + 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 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, 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 }) } }))
154
+ 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: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
155
+ ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 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/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + 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 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, 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); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 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 }) } }))
133
156
 
134
157
  // ===== RPC =====
135
158
  // get-tasks 是纯读路径(rt 只读文件)——poolCycle 由 15s 心跳 + 写入后 kickCycle 驱动,
@@ -193,7 +216,10 @@ export function createRpc(ctx, state, deps) {
193
216
  // 合并后 runs≥10 且一次通过率≥90% 且有效均值较其他合格模型严格低 >30% → 黄条建议设默认 workerModel;
194
217
  // 无足够数据返回 null 不亮)。拼在尾部——运行时事故 > 架构信号 > 模型建议(建议性 hint 最不紧急)。
195
218
  var __mph = modelPerfHint(d.usageSummary.scoreboard)
196
- d.healthHints = __rh.hints.concat(computeHealthHints(d.tasks)).concat(__mph ? [__mph] : [])
219
+ // 质量异动告警(异常驱动审视②):scoreboard.trends 环比(一次通过率跌 / 超时率涨 / 驳回率涨),
220
+ // 拼在静态架构信号之后、荐模型建议之前——质量事故 > 架构建议 > 模型建议(最不紧急垫底)。
221
+ var __qch = qualityChangeHints(d.usageSummary.scoreboard)
222
+ d.healthHints = __rh.hints.concat(computeHealthHints(d.tasks)).concat(__qch).concat(__mph ? [__mph] : [])
197
223
  // 史诗父卡语义层:childStats 现算(零存储)——{ <parentId>: { total, settled, resolved, active, activeTitle } },
198
224
  // 父卡列位置/进度展示的数据源;total 含已归档子任务,settled=resolved|cancelled|archived(resolved 为
199
225
  // 兼容别名同值),因此归档子卡不会让进度分母缩水(task-muupgfot);只有无任何子任务的父卡才不出键
@@ -320,7 +346,7 @@ export function createRpc(ctx, state, deps) {
320
346
  return { ok: true, deleted: t.id }
321
347
  })
322
348
  })
323
- 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 })
349
+ 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 } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
324
350
  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) } }) })
325
351
  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) } }) })
326
352
  // 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
@@ -662,7 +688,7 @@ export function createRpc(ctx, state, deps) {
662
688
  // 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
663
689
  // 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
664
690
  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 === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
665
- 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 }) })
691
+ 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); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 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 }) })
666
692
  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) } } })
667
693
  // ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
668
694
  // delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
@@ -689,7 +715,7 @@ export function createRpc(ctx, state, deps) {
689
715
  console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
690
716
  done++
691
717
  } else if (args.op === 'archive') {
692
- if (t.status !== 'resolved' && t.status !== 'cancelled') { skipped.push(id); return }
718
+ if (t.status !== 'resolved' && t.status !== 'cancelled') { skip(id, 'cannot archive'); return }
693
719
  var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
694
720
  archived.push(t)
695
721
  } else if (args.op === 'set-priority') {
@@ -705,7 +731,7 @@ export function createRpc(ctx, state, deps) {
705
731
  // 刻意不级联归档子卡:批量语义只动显式选中的 id(与单卡 archiveApply 的级联不同,保持老行为)。
706
732
  var parentsClosed = 0
707
733
  for (var ai = 0; ai < archived.length; ai++) { if (maybeAutoCloseParent(d, archived[ai])) parentsClosed++ }
708
- // reasons 只在 delete op 下有内容(其他 op 的跳过原因沿用"未命中门禁"的老行为),
734
+ // reasons 在 delete(deleteGate 门禁原因)与 archive(cannot archive)op 下有内容;
709
735
  // 老客户端只读 skipped.length,多一个可选字段无感。
710
736
  var out = { ok: true, done: done, skipped: skipped, reasons: reasons }
711
737
  if (parentsClosed) out.parentsClosed = parentsClosed
package/lib/usage.mjs CHANGED
@@ -767,7 +767,7 @@ function assembleScoreboard(recs, range) {
767
767
  var bmap = {} // bucketKey(model|role|size) -> 聚合器
768
768
  var bTasks = {} // bucketKey -> { taskId: true }(桶内任务级指标的分母来源)
769
769
  var meta = { tasks: recs.taskRecs.length, runsSettled: recs.runRecs.length, bucketed: 0, roleOther: 0, noModel: 0, noSize: 0, rejUnattributed: 0 }
770
- var tr = { firstPassByDay: {}, durationBuckets: { lt10m: 0, m10to30: 0, m30to60: 0, gt60m: 0 }, timeoutByDay: {}, resume: { runs: 0, completed: 0, rate: null } }
770
+ var tr = { firstPassByDay: {}, durationBuckets: { lt10m: 0, m10to30: 0, m30to60: 0, gt60m: 0 }, timeoutByDay: {}, rejectByDay: {}, resume: { runs: 0, completed: 0, rate: null } }
771
771
  // ===== 任务级趋势:一次通过率 byDay(resolved 任务按落定日)+ 卡时长分布四桶 =====
772
772
  var tById = {}
773
773
  for (var ti = 0; ti < recs.taskRecs.length; ti++) {
@@ -897,6 +897,9 @@ function assembleScoreboard(recs, range) {
897
897
  for (var ci = 0; ci < recs.rejRecs.length; ci++) {
898
898
  var rcj = recs.rejRecs[ci]
899
899
  if (hasRange && !dayInRange(rcj.mDay, range)) continue
900
+ // 驳回率 byDay(质量异动告警③ 的数据源,异常驱动审视②):按驳回消息自身本地日落点逐日计数——
901
+ // 与 rejectionClusters 同一 mDay 口径(direct 档 / 无 worker run 可归的驳回同样是样本,不因归因失败消失)。
902
+ if (rcj.mDay) { var rbd = tr.rejectByDay[rcj.mDay] || (tr.rejectByDay[rcj.mDay] = { rejects: 0 }); rbd.rejects++ }
900
903
  rejTexts.push(rcj.text)
901
904
  }
902
905
  return { buckets: out, trends: tr, meta: meta, rejectionClusters: clusterRejections(rejTexts) }
@@ -1048,3 +1051,76 @@ export function modelPerfHint(sb) {
1048
1051
  ',较其他合格模型低 ' + Math.round(minGap * 100) + '%),建议设为默认 workerModel',
1049
1052
  }
1050
1053
  }
1054
+
1055
+ // ===== 质量异动告警(异常驱动审视②,proposal n-muxyyvgonpoo):scoreboard.trends 数据驱动的环比告警 =====
1056
+ // 三条同构规则(阈值常量置顶):近 7 天 vs 再前 7 天,两侧样本各 ≥ QUALITY_MIN_SAMPLE 才判(防小样本误报)。
1057
+ // ① 一次通过率(firstPassByDay:firstPass/resolved)**跌** > QUALITY_CHANGE_PP 个百分点 → 建议抽查近期验收;
1058
+ // ② 超时率(timeoutByDay:timeout/runs)**涨** > QUALITY_CHANGE_PP 个百分点 → 建议排查派发/超时链路;
1059
+ // ③ 驳回率(rejectByDay:rejects / timeoutByDay 同日的 runs)**涨** > QUALITY_CHANGE_PP 个百分点 → 建议审查驳回原因。
1060
+ // 数据全部来自 scoreboard.trends(卡2 已建,本卡只做判定 + hint,不重建数据)。
1061
+ // 纯函数零 IO:日期只解析已有字符串做窗口切分;now 缺省 Date.now()(测试可注入钉死窗口,与卡2 零时钟纪律一致——
1062
+ // 环比的「近 7 天」必须相对某个「今天」,这是本函数的唯一时钟依赖,故显式参数化)。
1063
+ export var QUALITY_CHANGE_PP = 10 // 环比变化阈值(百分点):恰好 10pp 不亮、严格 >10pp 才亮
1064
+ export var QUALITY_MIN_SAMPLE = 5 // 两侧样本护栏:任一侧样本 <5 不判(防小样本噪声)
1065
+ export var QUALITY_WINDOW_DAYS = 7 // 环比窗口:近 7 天 vs 再前 7 天
1066
+
1067
+ // 近 count 天的本地日 key(旧→新,最后一个是今天):与 localDayKey 同一本地 getters 口径
1068
+ function qualityRecentDays(nowMs, count) {
1069
+ var out = []
1070
+ var d = new Date(nowMs)
1071
+ for (var i = count - 1; i >= 0; i--) out.push(localDayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - i)))
1072
+ return out
1073
+ }
1074
+ // 把 byDay 在指定日 key 集合上的指定数值字段累加;byDay 缺失/脏 → 全 0(绝不抛错)
1075
+ function qualitySumCells(byDay, keys, fields) {
1076
+ var out = {}
1077
+ for (var f = 0; f < fields.length; f++) out[fields[f]] = 0
1078
+ if (!byDay || typeof byDay !== 'object') return out
1079
+ for (var i = 0; i < keys.length; i++) {
1080
+ var c = byDay[keys[i]]
1081
+ if (!c || typeof c !== 'object') continue
1082
+ for (var f2 = 0; f2 < fields.length; f2++) out[fields[f2]] += numOr0(c[fields[f2]])
1083
+ }
1084
+ return out
1085
+ }
1086
+ // 环比百分点差(a - b,四舍五入到 0.1pp):恰好 10pp 不亮 / 10.1pp 亮——0.1pp 精度 + 严格 > 阈值,浮点安全
1087
+ function qualityPpDelta(a, b) { return Math.round((a - b) * 1000) / 10 }
1088
+
1089
+ export function qualityChangeHints(sb, now) {
1090
+ var tr = sb && sb.trends && typeof sb.trends === 'object' ? sb.trends : null
1091
+ if (!tr) return []
1092
+ var hints = []
1093
+ var nowMs = (typeof now === 'number' && isFinite(now)) ? now : Date.now()
1094
+ // 近 14 天窗口切两半:近 7 天(含今天)vs 再前 7 天
1095
+ var days = qualityRecentDays(nowMs, QUALITY_WINDOW_DAYS * 2)
1096
+ var near = days.slice(-QUALITY_WINDOW_DAYS)
1097
+ var prior = days.slice(0, QUALITY_WINDOW_DAYS)
1098
+ // --- ① 一次通过率跌 ---
1099
+ var fpN = qualitySumCells(tr.firstPassByDay, near, ['resolved', 'firstPass'])
1100
+ var fpP = qualitySumCells(tr.firstPassByDay, prior, ['resolved', 'firstPass'])
1101
+ if (fpN.resolved >= QUALITY_MIN_SAMPLE && fpP.resolved >= QUALITY_MIN_SAMPLE) {
1102
+ var fpNR = fpN.firstPass / fpN.resolved, fpPR = fpP.firstPass / fpP.resolved
1103
+ if (qualityPpDelta(fpPR, fpNR) > QUALITY_CHANGE_PP) {
1104
+ hints.push({ level: 'warn', text: '质量异动:一次通过率本周 ' + Math.round(fpNR * 100) + '%(上周 ' + Math.round(fpPR * 100) + '%),建议抽查近期验收' })
1105
+ }
1106
+ }
1107
+ // --- ② 超时率涨(分母 = 当日落定 runs)---
1108
+ var toN = qualitySumCells(tr.timeoutByDay, near, ['runs', 'timeout'])
1109
+ var toP = qualitySumCells(tr.timeoutByDay, prior, ['runs', 'timeout'])
1110
+ if (toN.runs >= QUALITY_MIN_SAMPLE && toP.runs >= QUALITY_MIN_SAMPLE) {
1111
+ var toNR = toN.timeout / toN.runs, toPR = toP.timeout / toP.runs
1112
+ if (qualityPpDelta(toNR, toPR) > QUALITY_CHANGE_PP) {
1113
+ hints.push({ level: 'warn', text: '质量异动:超时率本周 ' + Math.round(toNR * 100) + '%(上周 ' + Math.round(toPR * 100) + '%),建议排查派发/超时链路' })
1114
+ }
1115
+ }
1116
+ // --- ③ 驳回率涨(分子 = rejectByDay 的 rejects,分母 = timeoutByDay 同日的 runs)---
1117
+ var rjN = qualitySumCells(tr.rejectByDay, near, ['rejects'])
1118
+ var rjP = qualitySumCells(tr.rejectByDay, prior, ['rejects'])
1119
+ if (toN.runs >= QUALITY_MIN_SAMPLE && toP.runs >= QUALITY_MIN_SAMPLE) {
1120
+ var rjNR = rjN.rejects / toN.runs, rjPR = rjP.rejects / toP.runs
1121
+ if (qualityPpDelta(rjNR, rjPR) > QUALITY_CHANGE_PP) {
1122
+ hints.push({ level: 'warn', text: '质量异动:驳回率本周 ' + Math.round(rjNR * 100) + '%(上周 ' + Math.round(rjPR * 100) + '%),建议审查近期驳回原因' })
1123
+ }
1124
+ }
1125
+ return hints
1126
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agent-board",
3
3
  "description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
4
- "version": "1.7.5",
4
+ "version": "1.7.6",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "scripts": {