dsh-agent-board 1.7.5 → 1.7.7

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
@@ -571,6 +571,28 @@ export function pushRejection(t, summary, checks, at, by) {
571
571
  return true
572
572
  }
573
573
 
574
+ // ===== 迟到 verdict 落账(验收时序三洞 ③,用户 n-mv0ebretgv1o)=====
575
+ // 事故:主窗口在 Verifier 活跃期 task_verify 抢批(早 0.4s)+ 归档,Verifier 的独立结论与自测
576
+ // 指南被静默吞掉、无回执。修法:verifier 结论(工具通道 board_verdict / 文本通道 settleVerifier)
577
+ // 落到已 resolved/archived/cancelled 卡时不再静默丢弃——落 t.lateVerdict 留痕。
578
+ // 纯函数(零 IO):写 t.lateVerdict;userTest 若开关开且解析得出 → 挂 t.lateVerdict.userTest,
579
+ // 并补挂 t.verification.userTest(原 verification 缺 userTest 时;主窗口 task_verify approved 不写
580
+ // t.verification,此时用迟到结论的 verdict/summary/checks 兜底建 verification,避免详情页渲染空验收块)。
581
+ // 返回 { lateRejected, wasApproved }:调用方据此决定要不要发 err 级通知 + healthHint
582
+ // (I/O 留在调用方,core 保持零依赖)。wasApproved 必须先算——下方可能覆写 t.verification。
583
+ export function lateVerdictApply(t, verdict, summary, checks, userTest, at, by, verifyUserGuideOn) {
584
+ var approved = verdict === 'approved'
585
+ var wasApproved = t.status === 'resolved' || (t.status === 'archived' && !!(t.verifiedAt || t.verifiedBy || (t.verification && t.verification.verdict === 'approved')))
586
+ t.lateVerdict = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system', note: '迟到结论:卡在落定后收到' }
587
+ var ut = verifyUserGuideOn !== false ? normalizeUserTest(userTest) : null
588
+ if (ut) {
589
+ t.lateVerdict.userTest = ut
590
+ if (!t.verification) t.verification = { verdict: verdict, summary: summary || '', checks: checks || '', at: at || new Date().toISOString(), by: by || 'system' }
591
+ if (!t.verification.userTest) t.verification.userTest = ut
592
+ }
593
+ return { lateRejected: !approved, wasApproved: wasApproved }
594
+ }
595
+
574
596
  // ===== 一次性子代理 prompt 构建(上下文由主窗口 agent 写入 description/instructions,系统只追加生命周期记录)=====
575
597
  // 学习飞轮 v1 软召回引导(feedbackEnabled 开时才拼进 prompt):环境里若有笔记/记忆类工具,先查历史教训再动手。
576
598
  // 只是"提示先搜"——看板不代查、不调用任何记忆工具、也无从知道有没有这类工具(零耦合)。
@@ -715,6 +737,9 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
715
737
  // 学习飞轮 v1 软召回(feedbackEnabled 关闭时不出现):只提示"先查历史教训",看板绝不代查——
716
738
  // 环境里有没有笔记/记忆类工具、教训库长什么样,都是 Worker 自己判断的事(零耦合)。
717
739
  if (feedbackEnabled !== false) p += '\n\n' + LESSON_RECALL_HINT
740
+ // git 纪律红线(n-muyaaoo7cqnd 实证事故:并行 Worker 跑 git checkout HEAD -- package.json
741
+ // 把另一 Worker 的在途编辑冲掉):禁止还原命令,只改自己写的文件,要干净基线走 escalate 上报。
742
+ p += '\n\ngit 纪律红线:禁止 git checkout / git restore / git reset --hard / git clean 等还原命令(会冲掉并行 Worker 与你自己的未提交在途编辑);你只需管自己写的文件,别人改了别的文件与你无关;确实需要干净基线时用 board_report(kind=escalate)上报,由主窗口裁决。'
718
743
  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
744
  return p
720
745
  }
@@ -728,6 +753,25 @@ export function buildWorkerPrompt(t, pack, feedbackEnabled) {
728
753
  // 双模:工具通道走 board_verdict 的 userTest 参数;文本通道追加「## 自测指南」段
729
754
  //(段体格式 parseUserTest 解析,落账 t.verification.userTest)。
730
755
  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})。'
756
+
757
+ // ===== Verifier 验收员加餐(task-muy3gm03):固定 persona + 工具收窄 =====
758
+ // 背景:Verifier 此前只有任务层 prompt 契约,没有「人设」——遇到 Worker 汇报与实证冲突时
759
+ // 容易顺着汇报走。宿主 spawn 面实证(dsh-subagent types.d.ts:136-191 + spawn-in-process
760
+ // lib/index.js:23-29 capabilities 全 true):一次性 start() 支持 persona(scoped
761
+ // deployment:persona-prefix 影子段,只覆盖该子代理的部署人设)与 toolFilter(scoped
762
+ // tools.restrict(),工具从 prompt 消失且拒绝执行)。continuable 面(ContinuableCreateRequest
763
+ // 只有 sessionId/parent/signal)不支持这两个参数——但 verifier 恒走一次性路径
764
+ //(useContinuable 只对 worker 开),挂一次性面即覆盖全部 verifier spawn。
765
+ // ⚠️ persona 走宿主严格 {{…}} 插值(同 deployment persona 模板语义)——本文不得出现连写 {。
766
+ // ⚠️ toolFilter 名字走「响亮未知名校验」:deny 了不存在的工具会直接抛错,故只砍
767
+ // 宿主标配的 web_fetch/web_search,且 dispatch 侧有「剥外挂参数重试」兜底。
768
+ export var VERIFIER_PERSONA = '你是任务看板的独立验收员(Verifier),不是 Worker 的队友。\n1. 独立判断:不轻信 Worker 的汇报与自测描述——「Worker 说已验证」不构成证据,一切以你亲自复跑/核对到的为准。\n2. 实证为准:有硬性验收脚本必须独立复跑并核对真实输出;脚本失败或无法复现成功一律 REJECTED。\n3. 诚实分级:自测指南按真实可验证面分级(ui/metric/internal),不为好看拔高档次,没验过的步骤不写。\n4. 双读者:结论同时写给主窗口(逐条核对证据,含行号)和用户(自测指南人话)——两者都要诚实、可复核。'
769
+ // toolFilter 收窄评估结论(保守口径):只砍明确无关的联网检索(web_fetch/web_search——
770
+ // 验收以本地实证为准,不需联网);其余一律保留:read/grep/glob/pwsh 是核对主力,
771
+ // write/edit 供文件纪律允许的临时/测试文件,board_verdict 是落账通道,note_* 供查历史教训,
772
+ // browser_* 保留(tier=ui 验收可能要真实开页面核对),task_*/看板管理工具子代理本无权限
773
+ //(rpc 层 root 闸门)无需再砍。拿不准的宁可不收窄。
774
+ export var VERIFIER_TOOL_FILTER = { deny: ['web_fetch', 'web_search'] }
731
775
  export function buildVerifierPrompt(t, pack, userGuide) {
732
776
  var notes = histNotes(t)
733
777
  var msgs = buildMessages(t)
@@ -742,6 +786,10 @@ export function buildVerifierPrompt(t, pack, userGuide) {
742
786
  // 跑偏归因条款(调研遵循·host 三件套 ②):驳回理由注明「立单缺调研」——归因计入驳回热点统计,
743
787
  // 供主窗口分诊「立单缺料 vs Worker 执行问题」,缺料占高了就该把建卡调研门禁拧紧。
744
788
  p += '\n驳回归因:若 Worker 的产出明显因缺少调研上下文而跑偏/绕路,驳回时请在驳回理由里注明「立单缺调研」(归因会计入驳回热点统计)。'
789
+ // 文件纪律(task-muy3gm03,用户口径「验收时可以写必要的测试文件,不要动工程代码」):
790
+ // 写死进 prompt 的硬纪律 + 事后兜底威慑(touches 审计:Worker 交付的 diff 概要与任务声明的
791
+ // touches 即改动范围基线,越界即驳回——这层审计对 verifier 自己同样生效)。
792
+ p += '\n文件纪律:验收时可以写必要的临时/测试文件(放 _scratch 或测试目录,验收结束后自行清理干净);禁止改动工程代码与文档(README 等)。事后有 touches 审计兜底:Worker 交付的 diff 概要与任务声明的 touches 是改动范围基线,发现越界改动(含你自己留下的改动痕迹)一律驳回。'
745
793
  p += '\n\n结论契约(双模,工具优先):\n1. 优先调用 board_verdict 工具(taskId=' + t.id + ', verdict=approved/rejected, summary=测试概要, checks=逐条核对证据含行号)。\n2. 工具不可用则首行 APPROVED: <结论> 或 REJECTED: <结论>,然后 ## 测试概要 / ## 核对项 分段。'
746
794
  // 自测指南段(verifyUserGuide 开关门禁,缺省开):整段追加在 prompt 末尾;关掉 = 逐字无此段(省 token),
747
795
  // 落账侧(settleVerifier/board_verdict)同开关门禁——关时即使输出带了段也不挂 userTest 字段。
@@ -897,6 +945,80 @@ export function createTaskWarnings(t) {
897
945
  return out
898
946
  }
899
947
 
948
+ // ===== 任务起草 lint(proposal n-muwlpbspl3w2):四规则软警告,存 task.lintWarnings[],不阻塞 =====
949
+ // 与上方「调研门禁」(createTaskWarnings,瞬态 warning 字段)正交:那是「缺调研材料」的即时软提示;
950
+ // 这是「起草质量」的持久诊断(粒度过粗/双仓库歧义/缺验收/标题缺动词/描述空),随卡落盘、
951
+ // get-tasks 透出、详情页 ⚠️ 行展示。纯函数:双仓库歧义读盘(io 注入),本体零 IO 可单测。
952
+ // 四规则(各一触发 + 各一不触发,单测锁定):
953
+ // ① touches 含整树 glob(src/** 级)→ 粒度过粗,几乎锁整仓
954
+ // ② touches 相对路径在 ownerCwd 多个直接子目录下同名命中 → 路径双仓库歧义
955
+ // ③ pipeline=full 但无 acceptance 验收脚本 → 建议带验收命令
956
+ // ④ 标题缺动作动词 / 描述为空 → 建议补指令式标题 / 补目标约束
957
+ export var TREE_GLOB_LINT = 'touches 粒度过粗,几乎锁整仓'
958
+ export var AMBIGUOUS_PATH_LINT = '路径双仓库歧义,建议加仓库前缀'
959
+ export var FULL_NO_ACCEPTANCE_LINT = 'full 管线建议带验收命令'
960
+ export var TITLE_NO_VERB_LINT = '标题缺动作动词,建议改为「动词+宾语」指令式'
961
+
962
+ // 标题动作动词白名单(中文指令式任务标题高频动词,命中任一即算「有动词」)。
963
+ // 只做软提示:宁可漏检不可误报(漏了最多不亮这条),因此不做「非动词开头即判缺动词」的严格反例。
964
+ var TITLE_VERBS = /添加|新增|修复|实现|重构|优化|支持|调整|更新|删除|迁移|拆分|整理|调研|设计|接入|改造|移除|升级|编写|撰写|起草|补充|完善|收敛|扩展|统一|替换|清理|测试|验证|审查|复核|审计|沉淀|回写|生成|构建|发布|上线|评审|分析|汇总|统计|梳理|排查|定位|解决|处理|切换|对齐|收口|接线|透出|承接|重建|适配|放宽|收紧|拦截|放行|归一|聚合|装配|注入|投递|挂载|卸载|裁剪|截断|折叠|去重|合并|回滚|撤销|归档|恢复|继承|出清|释放|覆盖|包裹|转发|代理|缓存|落盘|读盘|心跳|轮询|结算|推进|回填|重命名|改名|重写|重排|重试|重投|重派|复查|复跑|补全|补齐|补写|补录|追加|跟进|追踪|溯源|归因|归类|导出|导入|展示|渲染|刷新|跳转|筛选|排序/
965
+
966
+ // touches 相对路径的「仓库内目录前缀」:剥掉 glob 尾段,得到跨仓库判重锚点。
967
+ // 'packages/dsh-agent-board/**' → 'packages/dsh-agent-board';'src/x.mjs' → 'src';
968
+ // 'a/b/c.mjs' → 'a/b';裸文件名(无 '/')→ ''(无目录前缀,不参与双仓库判重)。
969
+ export function touchDirPrefix(p) {
970
+ var v = normTouch(p)
971
+ if (!v || v === '**') return ''
972
+ var i = v.lastIndexOf('/')
973
+ return i < 0 ? '' : v.slice(0, i)
974
+ }
975
+
976
+ // 双仓库歧义:某条 touches 相对路径的目录前缀,在 root 的 ≥2 个直接子目录下都存在 → 命中同名仓库副本。
977
+ // 返回命中的相对路径原文(首个)或 null;io = { root, subdirs[], dirExists(path) } 由调用方注入(读盘)。
978
+ // 绝对路径/盘符(C:)/裸文件名不参与判重(裸文件名没有目录锚点,判不了)。
979
+ export function ambiguousTouch(touches, io) {
980
+ if (!io || typeof io.dirExists !== 'function') return null
981
+ var list = Array.isArray(touches) ? touches : []
982
+ var subs = Array.isArray(io.subdirs) ? io.subdirs : []
983
+ if (!subs.length) return null
984
+ var root = String(io.root || '')
985
+ for (var i = 0; i < list.length; i++) {
986
+ var p = list[i]
987
+ if (typeof p !== 'string' || !p.trim()) continue
988
+ var v = normTouch(p)
989
+ if (!v || path.isAbsolute(v) || /^[a-zA-Z]:/.test(v)) continue
990
+ var dir = touchDirPrefix(v)
991
+ if (!dir) continue
992
+ var hits = 0
993
+ for (var j = 0; j < subs.length && hits < 2; j++) {
994
+ var cand = path.join(root, subs[j], dir)
995
+ var ok = false
996
+ try { ok = !!io.dirExists(cand) } catch (_) { ok = false }
997
+ if (ok) hits++
998
+ }
999
+ if (hits >= 2) return p
1000
+ }
1001
+ return null
1002
+ }
1003
+
1004
+ // 起草质量 lint 主入口:返回 string[](task.lintWarnings 字段内容)。io 缺省时规则②退化为不触发(零 IO 安全)。
1005
+ export function draftLint(t, io) {
1006
+ var out = []
1007
+ if (!t || typeof t !== 'object') return out
1008
+ var touches = Array.isArray(t.touches) ? t.touches : []
1009
+ // ① 整树 glob(多条只报一次)
1010
+ for (var i = 0; i < touches.length; i++) { if (isTreeGlob(touches[i])) { out.push(TREE_GLOB_LINT); break } }
1011
+ // ② 双仓库歧义(附命中路径,便于定位该加哪个仓库前缀)
1012
+ var amb = ambiguousTouch(touches, io)
1013
+ if (amb) out.push(AMBIGUOUS_PATH_LINT + '(' + amb + ')')
1014
+ // ③ full 无 acceptance(pipeline 空串按默认 full 口径)
1015
+ if ((t.pipeline || 'full') === 'full' && !String(t.acceptance || '').trim()) out.push(FULL_NO_ACCEPTANCE_LINT)
1016
+ // ④ 标题缺动词 / 描述为空(描述为空复用调研门禁 EMPTY_DESC_WARNING 文案,单一事实源)
1017
+ if (!TITLE_VERBS.test(String(t.title || ''))) out.push(TITLE_NO_VERB_LINT)
1018
+ if (!String(t.description || '').trim()) out.push(EMPTY_DESC_WARNING)
1019
+ return out
1020
+ }
1021
+
900
1022
  // ===== touches → suggestedContextFiles 自动桥接(调研遵循·host 三件套 ①)=====
901
1023
  // 让遵守成为最省力路径:建卡触发「无调研上下文」warning 时,直接把 touches 里的具体文件
902
1024
  // 提炼成可一键采纳的 contextFiles 建议(响应挂 suggestedContextFiles 字段)。
@@ -1004,7 +1126,10 @@ export function tasksHash(tasks) {
1004
1126
  Array.isArray(t.tags) ? t.tags.join(',') : '',
1005
1127
  t.claimedBy, t.claimedAt, t.resolvedAt, t.verifiedAt, t.archivedAt,
1006
1128
  t.lastError, t.lastProgress && t.lastProgress.text,
1007
- t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question
1129
+ t.frozen ? '1' : '', t.stuckSince, t.escalation && t.escalation.question,
1130
+ t.reviewedAt,
1131
+ t.reviewHint ? String(t.reviewHint.score) + ':' + (Array.isArray(t.reviewHint.reasons) ? t.reviewHint.reasons.join('|') : '') : '',
1132
+ t.lateVerdict ? (t.lateVerdict.verdict + ':' + t.lateVerdict.at) : ''
1008
1133
  ].join('\u0001'))
1009
1134
  }
1010
1135
  var s = parts.join('\u0002')
@@ -1012,3 +1137,80 @@ export function tasksHash(tasks) {
1012
1137
  for (var j = 0; j < s.length; j++) h = (((h << 5) + h) ^ s.charCodeAt(j)) >>> 0
1013
1138
  return h.toString(36)
1014
1139
  }
1140
+
1141
+ // ===== 风险队列「待你过目」:reviewHint 现算(异常驱动审视①,task-muzikj1y)=====
1142
+ // 纯函数零 IO 零落库:唯一会落库的新字段是 reviewedAt(rpc.mjs mark-reviewed 落),
1143
+ // reviewHint 每轮 get-tasks 现算(四硬信号恒计入 + 两软信号仅质量异动告警激活期间计入)。
1144
+ // 信号口径:
1145
+ // 硬① 被驳回过:rejectCount>0 或 verification.verdict='rejected' 或 messages 有 rejection 包 或 runs 含 rejected 结局
1146
+ // 硬② 上报过歧义:messages 有 arbitration 裁决包
1147
+ // 硬③ touches 碰核心文件:任一条 touches 的 basename ∈ {dispatch.mjs, rpc.mjs, core.mjs}
1148
+ // 硬④ full 无 acceptance:pipeline=full 且无硬性验收脚本
1149
+ // 软⑤ diff>300 行:deliverable.diff(git diff --stat)解析出的总增删行数 >300(仅告警态)
1150
+ // 软⑥ 续跑≥2 次:runs 中 resume:true 条目数 ≥2(仅告警态)
1151
+ export var REVIEW_CORE_FILES = ['dispatch.mjs', 'rpc.mjs', 'core.mjs']
1152
+ export var REVIEW_DIFF_LINES = 300
1153
+ export var REVIEW_RESUME_MIN = 2
1154
+ function reviewBaseName(p) { var s = String(p == null ? '' : p); var i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); return i >= 0 ? s.slice(i + 1) : s }
1155
+ function reviewTouchesCore(touches) {
1156
+ var list = Array.isArray(touches) ? touches : []
1157
+ for (var i = 0; i < list.length; i++) { if (REVIEW_CORE_FILES.indexOf(reviewBaseName(list[i])) >= 0) return true }
1158
+ return false
1159
+ }
1160
+ function reviewWasRejected(t) {
1161
+ if (!t) return false
1162
+ if ((t.rejectCount || 0) > 0) return true
1163
+ if (t.verification && t.verification.verdict === 'rejected') return true
1164
+ if (Array.isArray(t.messages)) { for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'rejection') return true } }
1165
+ if (Array.isArray(t.runs)) { for (var j = 0; j < t.runs.length; j++) { var r = t.runs[j]; if (r && r.outcome === 'rejected') return true } }
1166
+ return false
1167
+ }
1168
+ function reviewHasArbitration(t) {
1169
+ if (!t || !Array.isArray(t.messages)) return false
1170
+ for (var i = 0; i < t.messages.length; i++) { var m = t.messages[i]; if (m && m.kind === 'arbitration') return true }
1171
+ return false
1172
+ }
1173
+ // diffStat 解析:优先读汇总行「N files changed, X insertions(+), Y deletions(-)」的增删合计;
1174
+ // 无汇总行回退按每文件「 | N 」改动数累加(git diff --stat 每行路径 + 改动数)。解析不出返回 0(不计软信号)。
1175
+ function reviewDiffLines(t) {
1176
+ var text = t && t.deliverable && t.deliverable.diff ? String(t.deliverable.diff) : ''
1177
+ if (!text) return 0
1178
+ var mIns = text.match(/(\d+)\s+insertions?\(\+\)/)
1179
+ var mDel = text.match(/(\d+)\s+deletions?\(-\)/)
1180
+ var ins = mIns ? (parseInt(mIns[1], 10) || 0) : 0
1181
+ var del = mDel ? (parseInt(mDel[1], 10) || 0) : 0
1182
+ if (ins || del) return ins + del
1183
+ var total = 0
1184
+ var re = /\|\s*(\d+)\s/g
1185
+ var mm
1186
+ while ((mm = re.exec(text)) !== null) total += parseInt(mm[1], 10) || 0
1187
+ return total
1188
+ }
1189
+ function reviewResumeCount(t) {
1190
+ if (!t || !Array.isArray(t.runs)) return 0
1191
+ var n = 0
1192
+ for (var i = 0; i < t.runs.length; i++) { var r = t.runs[i]; if (r && r.resume === true) n++ }
1193
+ return n
1194
+ }
1195
+ // 返回 { [taskId]: { score, reasons[] } }(含 score=0 的空 reasons 条目,调用方按需透出)。
1196
+ // opts.qualityAlertActive = 质量异动告警黄条激活态(读 qualityChangeHints 输出非空),软信号仅此时计入。
1197
+ export function computeReviewHints(tasks, opts) {
1198
+ var list = Array.isArray(tasks) ? tasks : []
1199
+ var alertActive = !!(opts && opts.qualityAlertActive)
1200
+ var out = {}
1201
+ for (var i = 0; i < list.length; i++) {
1202
+ var t = list[i]
1203
+ if (!t || !t.id) continue
1204
+ var reasons = []
1205
+ if (reviewWasRejected(t)) reasons.push('曾被 Verifier 驳回(验收判 reject)')
1206
+ if (reviewHasArbitration(t)) reasons.push('执行中上报过歧义(待主窗口裁决)')
1207
+ if (reviewTouchesCore(t.touches)) reasons.push('touches 触碰核心文件(dispatch/rpc/core)')
1208
+ if ((t.pipeline || 'full') === 'full' && !(t.acceptance && String(t.acceptance).trim())) reasons.push('全流程任务缺硬性验收脚本(acceptance)')
1209
+ if (alertActive) {
1210
+ if (reviewDiffLines(t) > REVIEW_DIFF_LINES) reasons.push('改动规模大(diff 超 ' + REVIEW_DIFF_LINES + ' 行)')
1211
+ if (reviewResumeCount(t) >= REVIEW_RESUME_MIN) reasons.push('续跑 ≥' + REVIEW_RESUME_MIN + ' 次(冷复活重试偏多)')
1212
+ }
1213
+ out[t.id] = { score: reasons.length, reasons: reasons }
1214
+ }
1215
+ return out
1216
+ }
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, lateVerdictApply, 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):派发侧不再读盘——
@@ -22,6 +22,7 @@ export function createDispatch(ctx, state, deps) {
22
22
  // Team 提示词组装是同步函数,只能读缓存,不能读盘。
23
23
  var epicSplitOn = deps.epicSplitOn
24
24
  var pushSysNote = deps.pushSysNote, maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
25
+ var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
25
26
  // 派发即回执:spawn 成功后入 45s 聚合队列的「🚀 已派发」区(老 host 未注入 → 静默跳过)
26
27
  var notifyDispatched = deps.notifyDispatched
27
28
  // 共享状态别名(本体由 index.mjs apply 统一构建并逐模块注入)
@@ -203,6 +204,15 @@ export function createDispatch(ctx, state, deps) {
203
204
  promptText = buildHookPrompt(t, role === 'hook-pre' ? 'pre' : 'post', kids)
204
205
  } else promptText = role === 'worker' ? buildWorkerPrompt(t, pack, cfg(dsnap).feedbackEnabled) : buildVerifierPrompt(t, pack, cfg(dsnap).verifyUserGuide)
205
206
  var req = { label: role + ':' + t.id, prompt: [{ type: 'text', text: promptText }], parent: parent, signal: makeSignal() }
207
+ // ===== Verifier 验收员加餐(task-muy3gm03):persona + toolFilter 只挂 verifier =====
208
+ // ① persona = 固定验收员人设(scoped deployment:persona-prefix 影子段,只覆盖该子代理);
209
+ // ② toolFilter = 保守收窄(deny web_fetch/web_search:验收以本地实证为准不需联网,其余工具
210
+ // 一律保留——评估口径见 core.mjs VERIFIER_TOOL_FILTER 头注释)。
211
+ // 两条 spawn 路径覆盖口径:continuable 面(ContinuableCreateRequest 仅 sessionId/parent/signal)
212
+ // 不支持 persona/toolFilter——但 verifier 恒走一次性 start()(useContinuable 只对 worker 开),
213
+ // 故挂在这里即覆盖全部 verifier spawn(含手动「派发验收」与自动派发两条入口,共用 spawnOneShot)。
214
+ // worker 请求不挂任何加餐(spawn 参数逐字 parity,单测锁定)。
215
+ if (role === 'verifier') { req.persona = VERIFIER_PERSONA; req.toolFilter = VERIFIER_TOOL_FILTER }
206
216
  if (modelOverride) {
207
217
  // list-models 返回的 id 是 "provider/model" 复合格式(如 "cmss/zhanlu/glm-5.2"),
208
218
  // 但 AgentOptions 的 provider 和 model 是分开的——整串塞进 model 会报 UNKNOWN_MODEL
@@ -230,9 +240,12 @@ export function createDispatch(ctx, state, deps) {
230
240
  run = await subagents.start(providerName, req)
231
241
  }
232
242
  } catch (e) {
233
- if (modelOverride) {
234
- console.error('[task-board] model override failed, fallback to parent model:', String(e))
235
- delete req.agentOptions
243
+ // 外挂参数回退(既有模型覆盖通道,task-muy3gm03 起并入 verifier 加餐 persona/toolFilter):
244
+ // provider 不支持某能力 / toolFilter 命中未知名时服务面 fail-loud 拒收(绝不静默忽略)——
245
+ // 剥光外挂参数用裸请求再试一次(文件纪律条款在 prompt 里,加餐剥掉纪律不丢),仍失败才放弃本轮。
246
+ if (modelOverride || req.persona !== undefined || req.toolFilter !== undefined) {
247
+ console.error('[task-board] spawn extras failed (model/persona/toolFilter), fallback to bare request:', String(e))
248
+ delete req.agentOptions; delete req.persona; delete req.toolFilter
236
249
  try {
237
250
  if (useContinuable) {
238
251
  var cs2 = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
@@ -793,7 +806,22 @@ export function createDispatch(ctx, state, deps) {
793
806
  doneOn = cfg(d).notifyDone !== false
794
807
  var t = d.tasks.find(function (x) { return x.id === rec.taskId })
795
808
  if (!t) return null
796
- if (t.status !== 'verifying' || t.escalation) return { task: t, already: true } // 工具通道已处理
809
+ if (t.escalation) return { task: t, already: true }
810
+ if (t.status !== 'verifying') {
811
+ // 迟到落账(验收时序三洞 ③):verifier 结论落到已落定卡(主窗口抢批/归档早于本 run 结算)不再静默丢弃。
812
+ // 但「工具通道已处理」= board_verdict 已由本 run 自己落账(t.verification.by === rec.id)→ 保持 already。
813
+ if (t.status === 'resolved' || t.status === 'archived' || t.status === 'cancelled') {
814
+ if (t.verification && String(t.verification.by) === String(rec.id)) return { task: t, already: true }
815
+ var ltrim = (output || '').trim()
816
+ var lvm = ltrim.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
817
+ if (failed || !lvm) return { task: t, already: true } // 无有效结论,不落 late
818
+ var lapproved = lvm[1].toUpperCase() === 'APPROVED'
819
+ var lsecs = parseSections(ltrim)
820
+ var lr = lateVerdictApply(t, lapproved ? 'approved' : 'rejected', lsecs.verifySummary || ltrim.slice(0, 600), lsecs.checks || '', lsecs.userTest, new Date().toISOString(), String(rec.id), cfg(d).verifyUserGuide)
821
+ return { task: t, late: true, lateRejected: lr.lateRejected && lr.wasApproved }
822
+ }
823
+ return { task: t, already: true }
824
+ }
797
825
  var trimmed = (output || '').trim()
798
826
  var vm = trimmed.match(/^[ \t>*#\-\s]*(APPROVED|REJECTED)\b/im)
799
827
  if (failed || !vm) {
@@ -829,6 +857,14 @@ export function createDispatch(ctx, state, deps) {
829
857
  if (!result) return
830
858
  // already=true:工具通道(board_verdict)已推进状态并已发回执,settle 只负责 dispose,不再重复通知
831
859
  if (result.already) return
860
+ // 迟到驳回(验收时序三洞 ③):verdict=rejected 且卡已 approved → err 级通知 + healthHint
861
+ if (result.late) {
862
+ if (result.lateRejected) {
863
+ if (notifyLateReject) notifyLateReject(sid, result.task)
864
+ poolHealthFor(sid).lateRejectNote = { taskId: result.task.id, at: Date.now() }
865
+ }
866
+ return
867
+ }
832
868
  if (result.escalated) maybeNotify(sid, result.task)
833
869
  if (doneOn && result.task && result.task.status === 'resolved') notifyTaskDone(sid, result.task, 'resolved')
834
870
  if (doneOn && result.task && result.task.status === 'blocked') notifyTaskDone(sid, result.task, 'blocked')
package/lib/health.mjs CHANGED
@@ -194,6 +194,28 @@ export function computeRuntimeHealthHints(board, rt, opts) {
194
194
  var bornAt = typeof rt.bornAt === 'number' ? rt.bornAt : now
195
195
  var tasks = board.tasks
196
196
 
197
+ // --- ④ 看板数据文件腐坏/抢救可见化(task-mv0bl9vg ②③)---
198
+ // store.recoverCorrupt 隔离/抢救时写 rt.corruptNote({ at, salvage, kept, file }),这里现算透出
199
+ // err 级 hint(客户端红色档,区别于 warn/info)。数据安全红线信号:sticky 不消费、不 TTL——
200
+ // 只要板还处于腐坏态就持续亮(poolHealth 纯内存,host 重启自然清)。level='err' 是通道新档,
201
+ // 客户端按非 warn/info 渲染红色;老客户端缺省降级为 info 色,信号仍可见。
202
+ var cn = rt.corruptNote
203
+ if (cn && typeof cn === 'object') {
204
+ var cnText = cn.salvage
205
+ ? '🔴 看板数据文件腐坏,已自动抢救保留 ' + (cn.kept || 0) + ' 张卡(丢弃撕裂残片),原始文件留档 ' + (cn.file || '') + ',可联系恢复'
206
+ : '🔴 看板数据文件腐坏已隔离,历史在 ' + (cn.file || '') + ',可联系恢复'
207
+ out.hints.push({ level: 'err', text: cnText })
208
+ }
209
+
210
+ // --- ⑤ 迟到驳回可见化(验收时序三洞 ③,用户 n-mv0ebretgv1o)---
211
+ // 主窗口 force 抢批后 Verifier 迟到驳回:err 级 hint(sticky 不 TTL——host 重启才清),
212
+ // 与 corruptNote 同款「进行中的事故优先」语义。rt.lateRejectNote 由 board_verdict/settleVerifier
213
+ // 迟到落账路径写入(dispatch.mjs poolHealthFor / rpc.mjs state.poolHealth)。
214
+ var lr = rt.lateRejectNote
215
+ if (lr && typeof lr === 'object' && lr.taskId) {
216
+ out.hints.push({ level: 'err', text: '迟到驳回:' + String(lr.taskId) + ' 你批早了但 Verifier 验出红的' })
217
+ }
218
+
197
219
  if (alive) {
198
220
  // --- ① 派发循环心跳:有可派卡 + 池有空位 + 上次成功轮 >5min ---
199
221
  // poolLastOkAt 缺失时退到 bornAt(首次见到本板的时刻):host 重启后心跳全断也能在 5min 宽限后亮条,
package/lib/notify.mjs CHANGED
@@ -183,5 +183,27 @@ export function createNotify(ctx, state, deps) {
183
183
  } else deliver().catch(function (e) { console.error('[task-board] receipt flush failed:', String(e)) })
184
184
  }
185
185
 
186
- return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote }
186
+ // ===== 看板数据文件腐坏/抢救通知(task-mv0bl9vg ②):P0 数据安全,直投 owner,不走回执聚合 =====
187
+ // store.recoverCorrupt 隔离/抢救时调用(经 storeDeps.notifyBoardCorrupt 晚绑定注入)。
188
+ // 与歧义裁决通知同款:任何模式都投、不读回执开关、直接 followup——数据可能丢了必须立刻提醒,
189
+ // 不能等 45s 聚合窗口、更不能被「回执已关」吞掉(回执开关只闸派发/完成两条回执)。
190
+ function notifyBoardCorrupt(sid, text) {
191
+ var root = rootForSession(sid)
192
+ if (!root) return
193
+ try { root.followup(makeMsg('🚨 [任务看板] ' + text + '\n\n历史看板数据未丢,可从留档文件人工恢复;如需协助请告知。', 'notice', '看板数据文件腐坏')) } catch (e) { console.error('[task-board] board corrupt notify failed:', String(e)) }
194
+ }
195
+
196
+ // ===== 迟到驳回通知(验收时序三洞 ③,用户 n-mv0ebretgv1o):err 级,回执直投 owner,不走开关/聚合 =====
197
+ // 主窗口抢批(force approve)后 Verifier 迟到驳回——「你批早了但验收是红的」是 P1 信号,必须立刻直达
198
+ // owner:不进 45s 回执聚合、不被 notifyDone 开关吞掉(与 notifyBoardCorrupt 同一纪律)。
199
+ function notifyLateReject(sid, t) {
200
+ var root = rootForSession(sid)
201
+ if (!root) return
202
+ var lv = (t && t.lateVerdict) || {}
203
+ try {
204
+ root.followup(makeMsg('🚨 [任务看板] 迟到驳回:' + (t.id || '') + ' 你批早了但 Verifier 验出红的\n\n任务: ' + String(t.title || '') + ' (' + (t.id || '') + ')\n\nVerifier 结论(迟到): ' + String(lv.summary || '(无)') + '\n核对项: ' + String(lv.checks || '(无)') + '\n\n详情页已展示「迟到 Verifier 结论」块;如需据此返工,请把任务驳回重投或人工处置。', 'notice', '迟到驳回: ' + (t.id || '')))
205
+ } catch (e) { console.error('[task-board] late reject notify failed:', String(e)) }
206
+ }
207
+
208
+ return { maybeNotify: maybeNotify, notifyTaskDone: notifyTaskDone, notifyDispatched: notifyDispatched, pushSysNote: pushSysNote, notifyBoardCorrupt: notifyBoardCorrupt, notifyLateReject: notifyLateReject }
187
209
  }