Files
workbuddy_skills/session-mechanism/assets/board.html
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

1859 lines
148 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多会话协作 · 实时架构看板</title>
<script>/* 🔴 在样式生效前就套用上次选的风格 —— ⛔ 别等 load,否则会先闪一下当前风格 */
(function(){try{var s=localStorage.getItem('dsh-board-style');
if(s&&s!=='base'){document.documentElement.setAttribute('data-style',s)}}catch(e){}})();
</script>
<style>
/* ══════════════════════════════════════════════════════════════════════════════
🔴 风格系统(用户 2026-09-30:「画架构图参考 archify 的样式,右上角加风格切换(保留当前风格)」)
· **切换只换 `<html data-style="…">`** —— 颜色/字体/网格全部走令牌,SVG 里没有硬编码颜色
⇒ 换风格**不需要重画图**。
· 三套:`base`(**保留的当前风格**)/`archify-dark`/`archify-light`
(后两套令牌逐字取自 archify 的 `assets/template.html`,MIT)。
· ⛔ 新写样式一律走令牌,别在组件里写死颜色 —— 否则换风格会花。
══════════════════════════════════════════════════════════════════════════════ */
/* ── 通用(与风格无关的尺寸/字体/网格令牌) ─────────────────────────────── */
:root{
--radius-8:8px; --radius-12:12px; --radius-16:16px; --radius-pill:999px;
--font-ui:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
--node-stroke-w:1;
--shadow-pop:0 10px 28px rgba(0,0,0,.18);
}
/* ── 风格 1 · base =**当前风格**(跟随系统深浅色) ─────────────────────── */
:root{ /* base · light */
--color-bg-page:#f6f8f5; --color-bg-surface:#ffffff; --color-bg-subtle:#eef2ee;
--color-text-primary:#12261a; --color-text-secondary:#4a5c50; --color-text-tertiary:#7a8a80;
--color-border-subtle:#e2e9e2; --color-border-default:#cdd8ce; --color-border-strong:#a9b8ab;
--color-accent:#256d37; --color-accent-subtle:#e6f1e8; --color-on-accent:#ffffff;
--color-success:#1f7a3d; --color-success-subtle:#e6f4ea;
--color-warning:#8a5a00; --color-warning-subtle:#fdf3e0;
--color-danger:#b3261e; --color-danger-subtle:#fdecea;
--color-focus-ring:#256d37;
--color-canvas-dot:transparent; --dot-size:22px;
}
@media (prefers-color-scheme: dark){
:root:not([data-style]), :root[data-style="base"]{ /* base · dark */
--color-bg-page:#0f1412; --color-bg-surface:#161d1a; --color-bg-subtle:#1d2622;
--color-text-primary:#e8f1ea; --color-text-secondary:#adbcb2; --color-text-tertiary:#7f8f85;
--color-border-subtle:#243029; --color-border-default:#2f3d36; --color-border-strong:#455349;
--color-accent:#7fd3a0; --color-accent-subtle:#14301f; --color-on-accent:#08200f;
--color-success:#6ede8a; --color-success-subtle:#12301c;
--color-warning:#ffcf7a; --color-warning-subtle:#33260f;
--color-danger:#ff8f85; --color-danger-subtle:#3a1613;
--color-focus-ring:#7fd3a0;
}}
/* ── 风格 2 · Archify 暗(令牌取自 archify 的 dark 主题) ────────────────── */
:root[data-style="archify-dark"]{
--color-bg-page:#020617; --color-bg-surface:#0f172a; --color-bg-subtle:#1e293b;
--color-text-primary:#ffffff; --color-text-secondary:#94a3b8; --color-text-tertiary:#64748b;
--color-border-subtle:#1e293b; --color-border-default:#334155; --color-border-strong:#475569;
--color-accent:#34d399; /* archify --arrow-emphasis */
--color-accent-subtle:rgba(52,211,153,0.14); --color-on-accent:#052e16;
--color-success:#34d399; --color-success-subtle:rgba(52,211,153,0.14); /* backend-stroke */
--color-warning:#fbbf24; --color-warning-subtle:rgba(251,191,36,0.14); /* cloud-stroke */
--color-danger:#fb7185; --color-danger-subtle:rgba(251,113,133,0.16); /* security-stroke*/
--color-focus-ring:#34d399;
--color-canvas-dot:rgba(148,163,184,0.16); --dot-size:22px; /* --canvas-dot */
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
/* ── 风格 3 · Archify 亮(令牌取自 archify 的 light 主题) ───────────────── */
:root[data-style="archify-light"]{
--color-bg-page:#f4f5f7; --color-bg-surface:#ffffff; --color-bg-subtle:#f1f5f9;
--color-text-primary:#111827; --color-text-secondary:#5b6474; --color-text-tertiary:#9aa3b2;
--color-border-subtle:#e5e7eb; --color-border-default:#cbd5e1; --color-border-strong:#94a3b8;
--color-accent:#059669;
--color-accent-subtle:rgba(5,150,105,0.10); --color-on-accent:#ffffff;
--color-success:#059669; --color-success-subtle:rgba(52,211,153,0.20);
--color-warning:#d97706; --color-warning-subtle:rgba(251,191,36,0.20);
--color-danger:#e11d48; --color-danger-subtle:rgba(251,113,133,0.15);
--color-focus-ring:#059669;
--color-canvas-dot:#d9dee5; --dot-size:22px;
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
*{box-sizing:border-box}
body{margin:0;background:var(--color-bg-page);color:var(--color-text-primary);
font-family:var(--font-ui);font-size:14px;line-height:1.6;font-weight:400}
a,button{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
header{padding:24px 32px 16px;border-bottom:1px solid var(--color-border-subtle);
display:flex;flex-wrap:wrap;gap:16px;align-items:baseline}
h1{margin:0;font-size:20px;font-weight:500;line-height:1.3}
.lede{color:var(--color-text-secondary);font-size:13px}
.spacer{flex:1}
.stamp{color:var(--color-text-tertiary);font-size:12px;font-variant-numeric:tabular-nums}
.hd-left{display:flex;flex-direction:column;gap:2px}
.hd-left .lede strong,.hd-left .lede b{font-weight:500;color:var(--color-text-secondary)}
.hd-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
.lag{font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.lag.is-old{color:var(--color-warning)}
.ro{font-size:11px;color:var(--color-text-tertiary)}
.warnbar{margin:0 0 24px;border:1px solid var(--color-warning);background:var(--color-warning-subtle);
color:var(--color-warning);border-radius:var(--radius-12);padding:12px 16px;font-size:13px}
/* 「本项目」身份卡 —— 🔴 看板顶部第一眼就要说清"这是哪个需求目标项目" */
.card.proj{border-color:var(--color-accent);background:var(--color-accent-subtle)}
.pill{display:inline-flex;align-items:center;padding:3px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:500;color:var(--color-on-accent);background:var(--color-accent)}
.crit{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.critLead{color:var(--color-text-secondary);font-size:12px}
.crit .chip{background:var(--color-bg-surface)}
/* 🔴 2026-10-05 随「任务类别」整行删除,`.topics` 规则也删掉了(用户:「这个没用就删除 看着烦」)。
⛔ 别把它当"少了个样式"找回来:版面上已经没有 `.topics` 这个容器了
(类别改由「协作架构」图 + `?` 提示承载)。 */
.accRow{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.accRow .critLead{color:var(--color-text-secondary)}
.projGoal{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-size:17px;line-height:1.4}
.projGoal b{font-weight:600}
.accBar{height:6px;border-radius:3px;background:var(--color-border-tertiary);overflow:hidden;
width:220px;flex:0 0 auto}
.accBar i{display:block;height:100%;background:var(--color-success);transition:width .3s}
.accBar.is-badn{background:var(--color-danger-subtle)}
/* 🔴 2026-10-02 作废(用户逐字:「**完成情况 已经通过的部分 改为绿色 数字也是** 6 /7 通过 也是绿色」):
原规则 `.accBar.is-badn i{background:var(--color-danger)}` 把**已通过的那一段填充**也染成危险色
—— 等于把"已经过了的"画成"坏的",读起来是"全都没过"。⇒ 改为**填充段恒绿**(=已过)。
"还没到的地方"仍由轨道底色表达(上一行 `.accBar.is-badn` 的 `--color-danger-subtle`);
"还差哪几条"仍由右侧 `未完:…` 逐条点名 ⇒ ⛔ 信息一条没少。
⛔ 原文留痕(不再生效):`.accBar.is-badn i{background:var(--color-danger)}` */
.accBar.is-badn i{background:var(--color-success)}
.accNum{font-variant-numeric:tabular-nums;font-size:14px}
.accNum.is-ok{color:var(--color-success)}
/* 🔴 2026-10-02 作废(同上一处,用户:「**数字也是** 6 /7 通过 也是绿色」):
原 `.accNum.is-bad{color:var(--color-danger)}` 在"还有没过项"时把数字染红 ⇒ 用户要的是**绿色**。
⛔ 原文留痕(不再生效):`.accNum.is-bad{color:var(--color-danger)}` */
.accNum.is-bad{color:var(--color-success)}
/* ⛔ 2026-10-01 删:`.projAttr`("目标 id / 主会话 / 工作区 / 归属判据 压成一列"那一行)——
用户当天后续定性:「**看板要精简的展示重点有效的信息,不是写说明书**」⇒ 那一列整体**不进版面**,
全部追加到卡片右上角 `?`(见 `renderProject()` 末尾)。页面从此只剩三行:目标 / 完成情况 / 任务类别。 */
/* 🔴 2026-10-01 多目标 tab(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」)
⚠️ 一个目标时也画(只是只有一格)—— 让"这是多目标看板"这件事**自己说得出来**,
⛔ 不要等有第二个目标了才让人发现原来支持。 */
.tabs{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch;margin:0 0 20px;
border-bottom:1px solid var(--color-border-subtle);padding-bottom:12px}
/* 🔴🔴 2026-10-04 用户定案:「**看板中的 tab 宽度改为一样宽**」
⛔ 原写法 `min-width:180px; max-width:420px`(**内容自适应**)⇒ 每格宽取决于标题字数
⇒ 「目标甲」窄、「检查会话协作是否运行正常 + 协作机制问题排查与修复」把整行撑到420
⇒ 参差不齐,扫读时找不到"现在在第几格"。
✅ 改成**等宽**:`flex:1 1 0` = 每格**等分**剩余宽度(`flex-basis:0` 是关键,
⛔ 用 `flex:1` 默认 `1 1 auto` 仍按内容分配 ⇒ 不会等宽);
`max-width:420px` 保留 ⇒ 目标很少时不会拉成整屏那么宽(配合 `justify-content:flex-start`)。
⚠️ 文字多的格靠 `.tt/.tsub` 已有的 `text-overflow:ellipsis` 截断(见下),
⛔ 不许改成 `white-space:normal` —— 那会让每格**高度**又不一样。 */
.tab{display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:0;
max-width:420px;text-align:left;
padding:9px 14px;border:1px solid var(--color-border-default);border-radius:var(--radius-12);
background:var(--color-bg-surface);color:var(--color-text-secondary);font:inherit;font-size:13px;
cursor:pointer;transition:border-color .15s,background .15s}
.tab:hover{border-color:var(--color-accent)}
.tab.is-on{border-color:var(--color-accent);background:var(--color-accent-subtle);
color:var(--color-text-primary)}
.tab .tt{display:flex;align-items:center;gap:6px;font-weight:500;color:var(--color-text-primary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tab .tsub{font-size:11px;color:var(--color-text-tertiary);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
/* 🔴 2026-10-03 20:31x **工作区名用大字体**(用户:「工作区名称 用大字体」)
⇒ 它是这一行的**主体**(读者第一眼要分清"哪个工作区"),⛔ 不该跟判据徽章一样是 11px 小字。 */
.tab .tsub .wsname{font-size:13px;font-weight:600;color:var(--color-text-secondary);margin-left:6px}
.tab .badge{flex:0 0 auto;padding:0 7px;border-radius:var(--radius-pill);font-size:11px;
border:1px solid var(--color-border-default);color:var(--color-text-tertiary)}
.tab .badge.is-ok{color:var(--color-success);border-color:var(--color-success);
background:var(--color-success-subtle)}
.tab .badge.is-bad{color:var(--color-danger);border-color:var(--color-danger);
background:var(--color-danger-subtle)}
.tab .badge.is-warn{color:var(--color-warning);border-color:var(--color-warning);
background:var(--color-warning-subtle)}
/* ⛔ 2026-10-01 删:`.tabHint`("只有一格/怎么加第二格"那段说明)—— 见 `renderTabs()` 里的说明。
tab 条**只放数据**;使用说明一律进各板块右上角 `?`。 */
/* 🔴🔴 2026-10-04 **超出 6 个的目标收进下拉**(用户定案:「最多显示 6 个目标,超出的用下拉菜单显示」)。
⚠️ `position:relative` 在 `.tabwrap` 上 ⇒ 菜单**相对它**定位(⛔ 别挂到 `.tabs` 上,
那会让菜单跟着 flex 换行跑到版面别处—— flex 容器里绝对定位的参照物是**最近的定位祖先**)。 */
.tabwrap{position:relative;display:flex;flex:0 0 auto}
/*「更多」是**操作件**不是数据格 ⇒ 固定宽度,⛔ 不跟着 `flex:1 1 0` 一起等分
(那样它会跟旁边 6 格一样宽,⛔ 而内容只有「更多」两个字 ⇒ 版面浪费)。 */
.tabmore-btn{flex:0 0 auto;min-width:132px;max-width:220px}
.tabmore-btn .tsub{display:flex;align-items:center;gap:5px}
.tabmenu{position:absolute;top:calc(100% + 6px);left:0;z-index:40;min-width:260px;max-width:420px;
max-height:320px;overflow:auto;padding:6px;display:flex;flex-direction:column;gap:4px;
background:var(--color-bg-surface);border:1px solid var(--color-border-default);
border-radius:var(--radius-12);box-shadow:var(--shadow-pop)}
.tabmenu[hidden]{display:none}
.tabmenu-item{display:flex;flex-direction:column;gap:2px;align-items:flex-start;width:100%;
text-align:left;padding:7px 10px;border:1px solid transparent;border-radius:var(--radius-8);
background:transparent;color:var(--color-text-primary);font:inherit;font-size:13px;cursor:pointer}
.tabmenu-item:hover{border-color:var(--color-accent);background:var(--color-accent-subtle)}
.tabmenu-item .tt{white-space:normal;font-weight:500}
.tabmenu-item .wsname{font-size:11px;font-weight:600;color:var(--color-text-tertiary)}
td .role{display:inline-block;padding:1px 8px;border-radius:var(--radius-pill);font-size:11px;
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);white-space:nowrap}
td .role.is-main{color:var(--color-accent);border-color:var(--color-accent);background:var(--color-accent-subtle)}
main{padding:24px 32px 48px;max-width:1440px;margin:0 auto}
section{margin:0 0 24px}
.card{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:20px}
.card > h2{margin:0 0 16px;font-size:14px;font-weight:500;color:var(--color-text-secondary);padding-right:30px}
.card > h2 .hint,.hero h2 .hint{font-weight:400;color:var(--color-text-tertiary);font-size:12px;margin-left:8px}
.hero{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:24px}
.hero h2{margin:0 0 16px;font-size:16px;font-weight:500;padding-right:30px}
.hero p{margin:0 0 16px;color:var(--color-text-tertiary);font-size:12px}
svg.arch{width:100%;height:auto;display:block}
/* 画布点阵(archify 的 --canvas-dot):base 风格为 transparent ⇒ 无点阵 */
#archWrap{background-image:radial-gradient(var(--color-canvas-dot) 1px, transparent 1px);
background-size:var(--dot-size) var(--dot-size);background-position:0 0;
border-radius:var(--radius-12)}
/* ── 右上角「风格切换」(用户 2026-09-30 要求;默认=当前风格) ───────────── */
.stylesw{display:inline-flex;border:1px solid var(--color-border-default);border-radius:var(--radius-pill);
overflow:hidden;background:var(--color-bg-surface)}
.stylesw button{border:0;background:transparent;color:var(--color-text-secondary);cursor:pointer;
padding:4px 12px;font-size:12px;line-height:1.6;font-family:var(--font-ui);white-space:nowrap}
.stylesw button + button{border-left:1px solid var(--color-border-subtle)}
.stylesw button:hover{background:var(--color-bg-subtle);color:var(--color-text-primary)}
.stylesw button.on{background:var(--color-accent);color:var(--color-on-accent);font-weight:500}
.stylesw button:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:-2px}
/* ── 🔴 各板块右上角「?」:小字描述收进这里,鼠标移上/键盘聚焦才显示 ─────────────
(用户 2026-09-30:「把各板块的小字描述放到各板块对应右上角 ?号图标中,鼠标移上去显示
(**只保留标题,主体,类别标签**这类信息)」)
⛔ 别再往版面里摊大段说明文字 —— 一律进 .qtip。 */
.card,.hero{position:relative}
.qh{position:absolute;top:12px;right:14px;z-index:6;width:20px;height:20px;padding:0;
border-radius:50%;border:1px solid var(--color-border-default);background:var(--color-bg-subtle);
color:var(--color-text-secondary);font-size:12px;font-weight:500;line-height:18px;text-align:center;
cursor:help;font-family:var(--font-ui);user-select:none}
.qh:hover,.qh:focus-visible{background:var(--color-accent);color:var(--color-on-accent);
border-color:var(--color-accent);outline:none}
.qh .qtip{position:absolute;top:26px;right:0;z-index:30;width:min(440px,78vw);padding:12px 14px;
border-radius:var(--radius-12);border:1px solid var(--color-border-default);
background:var(--color-bg-surface);box-shadow:var(--shadow-pop);
color:var(--color-text-secondary);font-size:12px;font-weight:400;line-height:1.7;text-align:left;
white-space:normal;cursor:default;opacity:0;visibility:hidden;transition:opacity .12s ease}
.qh:hover .qtip,.qh:focus-visible .qtip{opacity:1;visibility:visible}
@media (prefers-reduced-motion: reduce){.qh .qtip{transition:none}}
.qtip b{color:var(--color-text-primary);font-weight:500}
.qtip .kt{display:inline-block;padding:0 6px;border-radius:var(--radius-pill);
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);font-size:11px}
/* 「协作架构」板块右上角:延迟算式 + 保留的快照时刻(固定可见,⛔ 不收进 ?) */
.herometa{position:absolute;top:12px;right:52px;text-align:right;line-height:1.5;
font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.herometa > span{display:block;white-space:nowrap} /* ⚠️ 只作用于直接子元素:写 `.herometa span` 会把内层 #lag 也变成块 ⇒ "当前 3 秒"被拆两行 */
.herometa b{color:var(--color-text-primary);font-weight:500}
.herometa .sec{color:var(--color-text-secondary)}
.herometa.is-old .sec{color:var(--color-warning)}
@media(max-width:820px){.herometa{position:static;text-align:left;margin:0 0 12px}}
.cols-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}
.cols-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}
@media(max-width:1100px){.cols-3,.cols-2{grid-template-columns:1fr}}
.chip{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:400;border:1px solid var(--color-border-default);
color:var(--color-text-secondary);background:var(--color-bg-subtle)}
.chip.is-ok{color:var(--color-success);background:var(--color-success-subtle);border-color:var(--color-success)}
.chip.is-bad{color:var(--color-danger);background:var(--color-danger-subtle);border-color:var(--color-danger)}
.chip.is-warn{color:var(--color-warning);background:var(--color-warning-subtle);border-color:var(--color-warning)}
.rowchips{display:flex;flex-wrap:wrap;gap:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 8px;border-bottom:1px solid var(--color-border-subtle);text-align:left;vertical-align:top}
th{font-weight:500;color:var(--color-text-tertiary);font-size:12px}
tr:last-child td{border-bottom:0}
td.num{font-variant-numeric:tabular-nums;color:var(--color-text-tertiary);font-size:12px;white-space:nowrap}
td .ellip{display:block;max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 🔴 弱化说明(台账「任务类别」列里标旧值/项目卡里标类别来源):⛔ 只弱化,⛔ 不隐藏 */
.oldline{color:var(--color-text-tertiary);font-size:12px}
/* 🔴🔴 2026-10-04 21:3x 用户报障:「看板中 **未完成的描述 分段落展示一条一行**」。
原写法=`<span>未完:A(…)、B(…)、C(…)</span>`,用 `、` 全拼在**一行**里
⇒ 条数一多就挤成一大坨(实测 vibe-product 区 15 条未完项 ⇒ 整块糊住、根本读不出来)。
⇒ 改成 `<ul><li>` **一条一个 `<li>`**(每 li 里:`<code>` 判据名 + 说明)。
⚠️ 三条约束:
① **每 li 内部允许折行**(说明文字很长)⇒ 用 `flex` + `min-width:0` 让说明自己换行,
⛔ 不用 `white-space:nowrap`(那会横向溢出,撞 J5「零横向溢出」判据)。
② 🔴 **全展开,⛔ 不设 `max-height`/内滚动** —— 我第一版设了 `max-height:190px`,
结果**只能看到前 6 条、后面 9 条被裁掉**(实测 vibe-product 区 15 条)。
⇒ 这正是用户要"看成情况"的那块信息,**藏起来就等于没说**
(同族红线:读到了就要说)。整页本来就纵向滚动,跟着滚即可。
③ 判据名用**等宽体**(`code`)并**左对齐定宽**,让 15 行的说明文字**竖向对齐**、能扫读。 */
.accList{margin:6px 0 0;padding:0;list-style:none;flex:1 1 100%;min-width:0;
border-top:1px solid var(--color-border-tertiary)}
/* ⚠️ 「未完:」这个小标签单独占一行(它是 `.accList` 的**上一行**)⇒ 去掉下边距、
与列表首行贴住,视觉上是一段而不是两段。 */
.accLead{flex:1 1 100%;margin:0}
.accList li{display:flex;gap:10px;align-items:baseline;padding:5px 0 5px 2px;
border-bottom:1px solid var(--color-border-tertiary);font-size:12px;line-height:1.5}
.accList li:last-child{border-bottom:0}
.accList .accKey{flex:0 0 auto;min-width:64px;font-weight:600;
color:var(--color-text-secondary);font-family:ui-monospace,Consolas,monospace}
.accList .accWhy{flex:1 1 auto;min-width:0;color:var(--color-text-tertiary);
overflow-wrap:anywhere}
.warnTx{color:var(--color-warning);font-weight:600}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--color-text-secondary)}
.empty{padding:16px 0;color:var(--color-text-tertiary);font-size:13px}
.empty strong{display:block;color:var(--color-text-secondary);font-weight:500;margin-bottom:4px}
.err{border-color:var(--color-danger);background:var(--color-danger-subtle);color:var(--color-danger);
border-radius:var(--radius-12);padding:16px;font-size:13px}
.skel{height:14px;border-radius:var(--radius-8);background:var(--color-bg-subtle);margin:8px 0}
.legend{display:flex;flex-wrap:wrap;gap:16px;color:var(--color-text-tertiary);font-size:12px;margin-top:8px}
.legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;
vertical-align:-1px;border:1px solid var(--color-border-default)}
/* 🔴 2026-10-01(用户:「**这段乱七八糟的 也精简 分段落描述**」)——
架构图下面的说明原先是**一整行** `… · … · …`(八九条串起来,读起来是一堵墙)。
⇒ 改成**分块 + 一行一条**:块 = 一个小标题(「当前」「<唤醒会话名>」),
块内每条独立成行、行首一个小点。⛔ 别再退回 `join(' · ')` 那种串行。 */
.archNotes{margin-top:12px;display:flex;flex-direction:column;gap:10px}
.archNotes .anBlock{display:flex;flex-direction:column;gap:3px}
.archNotes .anLead{color:var(--color-text-secondary);font-size:12px;font-weight:500}
.archNotes .anItem{color:var(--color-text-tertiary);font-size:12px;line-height:1.65;
padding-left:14px;position:relative}
.archNotes .anItem::before{content:'·';position:absolute;left:3px}
.n-title{fill:var(--color-text-primary);font-size:16px;font-weight:500}
.n-title-sm{fill:var(--color-text-primary);font-size:13px;font-weight:500}
.n-sub{fill:var(--color-text-tertiary);font-size:11px}
.n-stat{fill:var(--color-accent);font-size:13px;font-weight:500}
.n-ok{fill:var(--color-success);font-size:12px;font-weight:500}
.n-idle{fill:var(--color-text-tertiary);font-size:12px}
.n-warn{fill:var(--color-warning);font-size:12px;font-weight:500}
.n-bad{fill:var(--color-danger);font-size:12px;font-weight:500}
.e-label{fill:var(--color-text-tertiary);font-size:12px}
.node-bg{fill:var(--color-bg-surface);stroke:var(--color-border-default)}
.node-on{fill:var(--color-success-subtle);stroke:var(--color-success)}
.node-warn{fill:var(--color-warning-subtle);stroke:var(--color-warning)}
.node-off{fill:var(--color-danger-subtle);stroke:var(--color-danger);stroke-dasharray:4 3}
.node-base{fill:var(--color-bg-subtle);stroke:var(--color-border-strong);stroke-dasharray:5 4}
.node-bg,.node-on,.node-warn,.node-off,.node-base,.trig-node{stroke-width:var(--node-stroke-w)}
/* 🔴 唤醒会话(**位置:主会话下面那一行(`RW`)的左格**)—— 用户 2026-10-01:先要「换个样式 和 协作会话区分开」,
随后**定性**「**唤醒脉冲会话,本质还是会话**」⇒ **形状改回会话同族的 `rect rx=14`**,
⛔ **不再用六边形** —— 形状已不是区分手段,**位置**才是(`RW` 行左格、⛔ 不进任务会话那一排)。
⇒ 只剩一条视觉标记:外圈一圈**虚线光晕** ⇒ 一眼看出「它靠**脉冲**被叫醒,不是一直在跑」。
⚠️ 状态色仍然照旧(`up` ⇒ 绿/否则黄)—— 位置管"它是谁",颜色管"它活着没"。 */
/* 🔴🔴 2026-10-01 加:**虚线分组大框** —— 用户原话:
「用一个**虚线大框**把 主会话 唤醒会话 和 跟进会话都框起来,
这个虚线大框 **连接 协作会话 虚线大框**就好」
⛔ **不填色**:它只负责"把几格圈在一起",填了色里面的格就被背景淹没了。
⚠️ 线比节点边框**细一点、虚一点**(1.6px / 8-7 虚线)—— 它是**分组**,⛔ 不是又一个节点。 */
.grp{fill:none;stroke:var(--color-border-strong);stroke-width:1.6;stroke-dasharray:8 7;stroke-linejoin:round}
.trig-halo{fill:none;stroke-width:1;stroke-dasharray:5 4;opacity:.6}
.trig-halo.h-on{stroke:var(--color-success)}
.trig-halo.h-warn{stroke:var(--color-warning)}
/* 状态徽章的三组图形(⛔ 用图形不用字符 —— 字符要靠字体,Linux/无 CJK 字体时会变成方框) */
.trig-dot{fill:var(--color-success)}
.trig-pause{fill:var(--color-warning)}
.trig-idle-dot{fill:none;stroke:var(--color-text-tertiary);stroke-width:1.4}
/* 🔴 2026-09-30 用户:「有些线都看不到」——根因是灰线用了 `--color-border-strong`(**太浅**,
白底对比度仅约 1.9:1)+ 1.5px ⇒ 基本看不见;而唤醒→主会话/WorkBuddy→Hook/--once/--tick/
④收结果**全是灰线** ⇒ 整张图看着像"线没连全"。改用 `--color-text-tertiary`(约 3.5:1),
并与 `.e-label` 同色(标签本就在描述这条线)⇒ 灰色语义保留,可见性恢复。
⚠️ 注释里⛔ 不写具体色值 —— 有静态用例守着"组件里零硬编码色"。 */
.edge{stroke:var(--color-accent);stroke-width:1.5;fill:none}
.edge-grey{stroke:var(--color-text-tertiary);stroke-width:1.5;fill:none}
@media (prefers-reduced-motion: no-preference){
.live{stroke-dasharray:6 5;animation:flow 1.6s linear infinite}
@keyframes flow{to{stroke-dashoffset:-22}}
}
</style>
</head>
<body>
<header>
<div class="hd-left">
<h1>多会话协作 · 实时架构看板</h1>
</div>
<span class="spacer"></span>
<div class="hd-right">
<!-- 🔴 右上角风格切换(你现在的风格 = 第一项,⛔ 不删) -->
<div class="stylesw" id="stylesw" role="group" aria-label="看板风格">
<button type="button" data-style="base" title="保留的原风格(跟随系统深浅色)">当前风格</button>
<button type="button" data-style="archify-dark" title="参考 archify 的暗色令牌">Archify 暗</button>
<button type="button" data-style="archify-light" title="参考 archify 的亮色令牌">Archify 亮</button>
</div>
</div>
</header>
<main>
<div class="err" id="err" hidden></div>
<div class="warnbar" id="warnbar" hidden></div>
<!-- 🔴 多目标 tab(用户 2026-10-01:「把协作实时看板改为 tab 支持多个目标执行协作状态展示」)
⛔ 一格 = 一个目标;下面「本项目 / 协作架构 / 需求台账 / 会话明细」四块跟着**当前 tab** 变,
⛔ 「前置 / 队列通知」是**工作区级**的,不随 tab 变(它们本来就不属于某一个目标)。
🗑️ 2026-10-03 **删掉第三张卡「最近上报 wakeups.jsonl」**(用户口径「没用了就删除」):
投递机制整套退役 ⇒ `wakeups.jsonl` **已不再被写**(实测文件根本不存在)⇒ 那张卡恒空,
留着就是一块永远显示「还没有上报记录」的**假面板**。 -->
<div class="tabs" id="tabs" hidden></div>
<section class="card proj" id="projCard">
<h2>本项目</h2>
<span class="qh" tabindex="0" role="button" aria-label="本项目板块说明">?<span class="qtip" id="projTip">
上面每一格 tab 是<b>一个需求目标</b>;这一块画的是<b>当前那格</b>的目标。<br>
跟着 tab 走的还有:<b>协作架构</b>、<b>需求台账</b>、<b>会话明细</b> —— 因为它们都按"任务类别"归属到某个目标。<br>
上面那一排「前置 / 队列通知」是<b>整个工作区</b>的,⛔ 不随 tab 变。<br>
<b>怎么多一格</b>:在收件箱的 <code>goals/</code> 目录里放一份与 <code>goal.json</code> 同字段的
<code>&lt;目标id&gt;.json</code>,刷新即多一格。只有一格时,就是本工作区只登记了一个目标。<br>
看板只读:后台隔一会儿生成一份快照,页面直接读缓存(间隔见「协作架构」那块的问题说明)。
</span></span>
<!-- 🔴🔴 2026-10-05 加(用户逐字:「看板里面应该明确指出 对应的 工作区 和 目标」):
下面第一行**固定是「目标」**,它先说**所属工作区**(真实目录名,放在 pill 里),
再说**目标全称** —— 值由 `renderProject()` 填(⛔ 不在这儿写死任何值)。 -->
<div id="proj"><div class="skel"></div></div>
</section>
<section class="hero">
<h2>协作架构<span class="hint" id="archHint">用户 · 会话 · 任务会话 · 程序 · WorkBuddy</span></h2>
<!-- 🔴 延迟与"保留的快照时刻"固定在板块右上角(用户 2026-09-30)—— ⛔ 别收进 ? 里 -->
<div class="herometa" id="herometa">
<span><b id="ts2">—</b></span>
<span class="sec">延迟 <span id="lag">—</span></span>
</div>
<span class="qh" tabindex="0" role="button" aria-label="任务架构板块说明">?<span class="qtip">
🔴 <b>2026-10-03 改版:会话只剩两类</b>(用户逐字:「让你把 架构图里面的 唤醒和跟进 以及
上报都删除」)—— <b>唤醒会话 / 跟进会话 / 队列上报机制整套退役</b>,图上<b>整行删除</b>
(⛔ 不是画成灰格占位)。⇒ 图形顺序=<b>用户 → 主会话 → 任务会话 → 检查程序</b>。<br>
<b>主会话</b>只管<b>判断 + 派活</b>,⛔ <b>只由用户触发</b>(图上指进主会话的线<b>只有上面那一条</b>)。<br>
<b>虚线大框</b>=<b>分组</b>(⛔ <b>不是节点</b>、⛔ 不是"新的一层"):上面框住<b>主会话</b>,
下面框住<b>任务会话</b>;两个大框之间<b>只有一条线 = ① 派活</b>(⛔ 不从主会话往每格画射线)。<br>
🔴 <b>① 派活</b>的特别之处:它是主会话建一条<b>自动化排期</b>、宿主到点开新会话 ——
这是<b>唯一能开新会话的通道</b>(钩子开不了会话)。<br>
主会话<b>下方那一排</b>是<b>任务会话</b> —— 一格一条,横着排、有几条画几条,
一条都没有时画个空框写明「暂无协作目标」。<br>
任务类别是<b>同一个工作区</b>里用<b>会话名称前缀</b>区分的(每个类别各有自己的主会话)。<br>
会话共<b>两类</b>:<b>主会话 · 任务会话</b>,靠<b>标题两级前缀</b>区分、⛔ 不看目录;<br>
某条会话<b>撞到上限</b>时由它自己建出的<b>接续</b>是<b>形态</b>、⛔ 不是第 3 类
(它<b>继承被接续那条的角色</b>)。<br>
<b>协作与投递按定案一直运行(常驻)</b>,宿主钩子只作<b>补充</b>;⛔ 不再用"自动任务当闹钟"。
⚠️ 唤醒时钟=<b>检查程序</b>(<b>--supervise</b>),⛔ 不再靠"唤醒会话"那一套。<br>
状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪一格停着不动,看框的颜色就知道。<br>
看板只读。后台每 <span id="iv">—</span> 秒生成一份快照,页面直接读缓存。
</span></span>
<div id="archWrap"><svg class="arch" id="arch" viewBox="0 20 1280 926" role="img"
aria-label="多会话协作架构(2026-10-03 改版:会话只剩两类)。两个虚线大框=两组(不是节点)。上组框住主会话(只管判断和派活,只由用户触发,指进它的线只有上面那一条);下组框住任务会话(有几条画几条,一条都没有时画一个空框写明暂无任务会话);两个大框之间只有一条线=① 派活。图上只画现役的两类会话与检查程序,不画已下线的东西。再下面是协作(全称检查程序)、WorkBuddy 在特定时机起来的 Hook进程、WorkBuddy""></svg></div>
<div class="legend">
<span><i style="background:var(--color-success-subtle);border-color:var(--color-success)"></i>有会话在跑</span>
<span><i style="background:var(--color-warning-subtle);border-color:var(--color-warning)"></i>有件没人在跑</span>
<span><i style="background:var(--color-bg-surface);border-color:var(--color-border-default)"></i>空闲 / 件已全完</span>
<span><i style="background:var(--color-danger-subtle);border-color:var(--color-danger)"></i>已停</span>
<span><i style="background:var(--color-bg-subtle);border-color:var(--color-border-strong)"></i>WorkBuddy</span>
</div>
<!-- 🔴 2026-10-01:原来这里另有一行「① 派活…④ 收结果…队列…」的静态小字 ——
它和下面 `#archNote` 一起读就是一堵墙。⇒ **并进 `#archNote` 的「连线」块**(一行一条),
⛔ 不再单独占一行版面。 -->
<div class="archNotes" id="archNote"></div>
</section>
<section class="cols-3">
<!-- 🔴 本板块的名称/标签/内容**全部由使用方提供**(board_ext)—— 技能侧不写死任何项目的东西 -->
<div class="card" id="frontCard"><h2>前置</h2><span class="qh" tabindex="0" role="button" aria-label="前置板块说明">?<span class="qtip" id="frontTip">
看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。<br>
组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。<br>
状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。
</span></span><div id="front"><div class="skel"></div></div></div>
<div class="card"><h2>队列通知 / 握手</h2><div id="notify"><div class="skel"></div></div></div>
</section>
<section class="cols-2">
<div class="card"><h2>需求台账<span class="hint">tasks.json</span></h2><div id="tasks"><div class="skel"></div></div></div>
<div class="card"><h2>会话明细<span class="hint">本项目 · 含历史</span></h2> <span class="qh" tabindex="0" role="button" aria-label="会话明细板块说明">?<span class="qtip">
分工看架构图第三层。本表列本项目全部会话,含已完成的。
</span></span><div id="sess"><div class="skel"></div></div></div>
</section>
</main>
<script>
var D=null, LIVE=(location.protocol==='http:'||location.protocol==='https:');
/* 🔴 board.html 的签名(服务端随快照下发)。布局与样式都在本文件里,而页面只轮询 board.json
⇒ 改了看板**不刷新页面就看不到**(用户 2026-09-30 为此连问两次:"怎么没有变化呢")。
⇒ 发现签名变了就**自动重载一次**,⛔ 不再要求用户手动 F5。 */
var HTML_SIG=null;
/* 🔴 风格切换:只改 <html data-style>。颜色/字体/网格全走 CSS 令牌 ⇒ **不用重画图**。
⛔ `base` 是"保留的当前风格" —— 选它时把属性**摘掉**,好让它继续跟随系统深浅色。 */
var STYLE_KEY='dsh-board-style';
function currentStyle(){
var s=document.documentElement.getAttribute('data-style');
return (s==='archify-dark'||s==='archify-light')?s:'base';
}
function applyStyle(s){
if(s!=='archify-dark'&&s!=='archify-light') s='base';
if(s==='base') document.documentElement.removeAttribute('data-style');
else document.documentElement.setAttribute('data-style',s);
try{ localStorage.setItem(STYLE_KEY,s) }catch(e){}
var btns=document.querySelectorAll('#stylesw button');
for(var i=0;i<btns.length;i++){
var on=(btns[i].getAttribute('data-style')===s);
btns[i].classList.toggle('on',on);
btns[i].setAttribute('aria-pressed', on?'true':'false');
}
}
function initStyleSwitcher(){
var box=document.getElementById('stylesw');
if(!box) return;
box.addEventListener('click',function(ev){
var b=ev.target.closest?ev.target.closest('button[data-style]'):null;
if(b) applyStyle(b.getAttribute('data-style'));
});
/* 键盘:方向键在三个按钮间走 */
box.addEventListener('keydown',function(ev){
if(ev.key!=='ArrowLeft'&&ev.key!=='ArrowRight') return;
var order=['base','archify-dark','archify-light'], cur=order.indexOf(currentStyle());
cur=(cur+(ev.key==='ArrowRight'?1:order.length-1))%order.length;
applyStyle(order[cur]);
var b=box.querySelector('button[data-style="'+order[cur]+'"]'); if(b) b.focus();
ev.preventDefault();
});
applyStyle(currentStyle()); /* 把按钮高亮同步到实际生效的风格 */
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function ago(iso){
if(!iso)return '—';
var d=new Date(String(iso).replace(' ','T')); if(isNaN(d))return String(iso);
var s=Math.max(0,(Date.now()-d.getTime())/1000);
return s<90?Math.round(s)+' 秒前':(s<5400?Math.round(s/60)+' 分钟前':Math.round(s/3600)+' 小时前');
}
function chip(t,cls,title){return '<span class="chip '+(cls||'')+'"'
+(title?(' title="'+esc(title)+'"'):'')+'>'+esc(t)+'</span>'}
function fmtB(n){
if(n==null||isNaN(n))return '—';
n=Number(n);
return n<1024?(n+' B'):(n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'));
}
function emptyBox(title,action){
return '<div class="empty"><strong>'+esc(title)+'</strong>'+esc(action||'')+'</div>'}
function tailOf(p){return String(p==null?'':p).replace(/\\/g,'/').replace(/\/+$/,'').split('/').pop()||'—'}
/* ⛔ 界面上不出现裸的 done/running/completed(用户看的是中文) */
function zhState(st){return {done:'已完成',running:'进行中',todo:'待办',pending:'待办',
blocked:'受阻',claimed:'已认领'}[String(st||'')]||(st||'—')}
function zhSess(st){return {working:'执行中',completed:'已完成',error:'出错',archived:'已归档',
idle:'空闲'}[String(st||'')]||(st||'—')}
/* 🔴 顶部「本项目」卡 —— 用户 2026-10-01 定调:
「为什么看板里不显示**项目目标**了,项目的**属性标签 规则 用一列描述**就好,
重点是看**目标需求 完成情况**」
⇒ 版面主角=**目标**(第一行)+**完成情况**(第二行,进度条 + 逐条点名未完项);
属性(目标 id / 默认主会话 / 工作区)与规则(归属判据)压成**最后那一列描述**,⛔ 不再各占一行抢戏。 */
/* 🔴🔴 2026-10-02:`accIsPass` **必须放全局作用域**。
它同时被 `renderProject()`(主区「完成情况」进度条)与 `renderTabs()`(tab 摘要徽章)调用,
而这两个是**并列的顶层函数**、不是嵌套 —— 我第一版把它定义在 `renderProject()` 内部 ⇒
`renderTabs()` 里 `ReferenceError` ⇒ **整页崩在那行之后**(实测:主区 `accNum` 与 tab 双双消失)。
⇒ 教训:**跨函数共用的判据,必须放在两者的共同作用域**;且改完必须**渲染取一次证**,
⛔ 不能只靠 `node --check`(语法过 ≠ 不抛异常 —— 这一条本轮当场被证实)。 */
/* 🔴🔴🔴 **判定词白名单 —— 必须与 `board.py::ACC_PASS_WORDS` 逐字同款**(2026-10-04 立)。
用户原话:「为什么还是0通过」——真源写 `达(…)`,而判据只认 pass/过/通过 ⇒ 15 条全判非 pass。
⚠️ **改这里必须同时改 `board.py` 的 `ACC_PASS_WORDS`**,⛔ 别只改一边(本规则已漂过 3 次)。 */
var ACC_PASS_WORDS=['pass','过','通过','达','达标','合格','完成'];
/* 🔴🔴🔴 **判定词前缀「副词」白名单 —— 必须与 `board.py::ACC_ADV_PREFIX` 逐字同款**
(2026-10-05 立 —— **第 5 次复发**,这次改成治形状、⛔ 不再补词)。
病根:判定词前会带一个**表已完成/确认的副词**,而 `indexOf(...)===0` 要求**从第一个字符**
就命中 ⇒ 带副词就**永远不命中** ⇒ 明明写着「已过」却判「非过」。
· 实测(vibe-product 页签):9 条真值**全写 `已过|…`**,看板显示 **0 / 9 通过**
—— 因为 `已过` 的第一个字是 `已`、⛔ 不是 `过`。用户原话:
「完成情况 0 / 9 通过,但是我看到都显示已过呀」。
⛔ 前四次(10-02「过」/10-03「逐段找」/10-04「达」)都在**补词** ⇒ 换个措辞必然再栽
—— 这一次**换个方向**:**先把副词剥掉再比**。
⚠️ 白名单**只收表"完成/确认"的副词**;⛔ **绝不许收否定副词**(未/不/没/非/待)——
它们一律**留给 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。
⚠️ 改这里**必须同时**改 `board.py::ACC_ADV_PREFIX`。 */
var ACC_ADV_PREFIX=['已','已经','均','都','经复核','复核后'];
function accIsPass(v){
var s=String(v==null?'':v).replace(/^\*+/,'').trim();
if(!s) return false; /* 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed) */
var head=s.split(/[((]/)[0]; /* 只取判定词,丢掉后面那段说明 */
/* 🔴🔴 2026-10-03 19:4x **必须与 `board.py::acc_is_pass()` 逐条同款** ——
原来这里是 `split(/[::]/).pop()`=**只取最后一段**,而真源值里常有多处冒号
(说明文字 + **时间里的**)⇒ 明明写着「过」被判非过
⇒ 前端显示「判据 3/4 通过」、后端却是「全部 pass(4 条)」=**同一条规则两份实现打架**(本轮实测撞到)。
✅ 改法:与后端同款——**逐段找**「某一段以判定词开头」;`不过`/`未过`/`待重验` 天然不命中 ⇒ 仍 fail-closed。
⚠️ 改这一处**必须同时**改 `board.py::acc_is_pass()`;⛔ 别只改一边。 */
var segs=head.split(/[::]/), ok=false;
for(var i=0;i<segs.length&&!ok;i++){
var t=segs[i].replace(/^\*+/,'').trim().toLowerCase();
for(var j=0;j<ACC_PASS_WORDS.length;j++){
if(t.indexOf(ACC_PASS_WORDS[j])===0){ ok=true; break; }
}
if(ok) break;
/* 🔴🔴 2026-10-05 第 5 次复发:**剥掉"完成副词"再比**(治形状,⛔ 不补词)。
⚠️ **可叠加**:实测真源写 `均已通过`/`都已达标` ⇒ 要能连剥(`已`+`均`/`都`)。
⚠️ 上限 3 轮(有界):正常最多两级;⛔ 不是省事。 */
var t2=t;
for(var r=0;r<3;r++){
var cut='';
for(var q=0;q<ACC_ADV_PREFIX.length;q++){
if(t2.indexOf(ACC_ADV_PREFIX[q])===0 && ACC_ADV_PREFIX[q].length>cut.length){
cut=ACC_ADV_PREFIX[q]; /* 取**最长**匹配(`已经` 优先于 `已`) */
}
}
if(!cut) break;
t2=t2.slice(cut.length).replace(/^\*+/,'').trim();
for(var k=0;k<ACC_PASS_WORDS.length;k++){
if(t2.indexOf(ACC_PASS_WORDS[k])===0){ ok=true; break; }
}
if(ok) break;
}
}
return ok;
}
function renderProject(d){
/* 🔴🔴 2026-10-04(用户:「统一完成情况的状态标准,不要换个工作区换个目标就统计不准确」):
**`_` 开头的键是说明行**(`_说明`/`_更新`…),⛔ 不是判据 ⇒ **不许进分母**。
旧写法 `keys=Object.keys(acc)` 把它们算进分母 ⇒ 通过率**永远到不了 100%**
(实测真源 14/15 真判据全过,却显示 `14 / 17 通过`)。
⇒ 与后端 `board.py`(L1616 已剔 `_`)/`collabd.py`(L2613 已剔)**同一条口径**:
**分母=有效判据条数**。三处必须同款。 */
/* 🔴🔴 2026-10-05 修 **`acc is not defined`**(用户报障:「快照读取失败:acc is not defined」)。
真因=**变量从来没声明过**:本函数收到的实参是 `paintScope()` 传来的
`scopeView(d, g)` 视图对象,而**验收判据不在顶层** —— 它挂在 `goal.acceptance`
(实测快照顶层键里**没有** `acc`,只有 `goal.acceptance`)。
上一版直接写了 `acc` ⇒ `ReferenceError` ⇒ `.catch()` 把**渲染期异常**当成
**取数失败**来报 ⇒ 页面显示「快照读取失败」+「服务还开着吗」——
**把"代码 bug"伪装成"服务挂了"**,排查方向整个被带偏(同族 P0:假归因)。
✅ 取法与 tab 摘要(本文件 `renderTabs` 的 `var acc=(g.goal||{}).acceptance||{}`)
**同款** —— 同一份数据⛔ 不许两种取法。
⚠️ 教训:**`.catch()` 里一律标清是"取数失败"还是"渲染失败"**,⛔ 别把两者混成一句话。 */
var acc=(d.goal||{}).acceptance||{};
var keys=Object.keys(acc).filter(function(k){return k.charAt(0)!=='_'}).sort();
var noteKeys=Object.keys(acc).filter(function(k){return k.charAt(0)==='_'}).sort();
/* 🔴🔴 2026-10-05 修(**静默错文案**,与上面 `acc` 同族、但更隐蔽):
实测快照顶层**没有** `short`/`title`/`criteria`/`id`/`main_sid8`/`workspace`/`topics*`
—— 它们全挂在 **`d.project`** 下(`goal` 那份只装 `acceptance`)。
⇒ 原来裸写 `d.xxx` 一律取到 `undefined` ⇒ **不抛异常**,只是**悄悄显示兜底值/'—'**:
「目标(未声明主题)(未声明目标)」+ 归属判据全 '—'(用户 2026-10-05 报障原话)。
🔴 判据:**同一份数据⛔ 不许两种取法** —— 一律 `pj=d.project||{}` 取。 */
var pj=d.project||{};
var src=pj.topics_source||{}, mbt=pj.main_by_topic||{}, tops=pj.topics||[];
/* ── ① 目标(主角)── */
/* 🔴🔴 2026-10-05 修 **「目标(未声明主题)(未声明目标)」**(用户报障原话)。
真因=**取值层级错**:目标数据不在顶层 —— 它挂在 `goal` 下(`d.goal.short` / `d.goal.title`)。
实测快照:顶层 `short`/`title` **都是 None**,`goal.short='本机协作'`、`goal.title=完整目标`。
本行上一版写的是裸 `d.short` / `d.title` ⇒ 永远取不到 ⇒ **恒显兜底文案**
⇒ 用户看到「目标(未声明主题)(未声明目标)」,而**数据其实完整**。
⚠️ 与上面 `acc is not defined` 是**同族病**:那里是"少写一层(直接写 `acc`)"⇒ 抛异常;
这里是"少写一层(直接写 `d.short`)"⇒ **不抛异常、静默显示错文案**(更难发现)。
✅ 正解=与 `acc`/`renderTabs` **同款取法**:一律走 `d.goal`。
🔴 判据:**同一份数据⛔ 不许两种取法**(本文件里 `d.goal.*` 已是既有口径)。 */
var gj=d.goal||{};
/* 🔴🔴🔴 2026-10-05 **目标标题前面带上「所属工作区名」**(用户逐字:
「看板里面应该明确指出 对应的 工作区 和 目标」)。
为什么必须加:看板把**别的工作区**也并列成 tab(peer 格),而**各目标标题常常同一句**
(或同一模板换参数),⚠️ **tab 名与标题都不足以让读者一眼确认"这是哪个工作区的"**
⇒ 用户要"明确"。
✅ 口径(与 `ws_name` 字段同一条来源,⛔ 不另造一份):
· **peer 格**(`d.peer` 有值)⇒ 前缀用**对方工作区真实目录名**;
· **本区格**(`peer` 为空)⇒ 前缀用**本工作区真实目录名**(`d.ws_name`)
—— 用户要"明确",本区**也要写**,⛔ 不搞"自己就不标"的特例。
⚠️ `d.ws_name` 已由后端保证是**真实目录名**(⛔ 不是"本工作区"这种占位词,见 `_goal_block()`)。
⛔ 兜底 `'—'`:宁可显示"未知",⛔ 也不许拼出半个假名。 */
var _wsn=String(d.ws_name||'').trim()||'—';
var goalHtml='<div class="projGoal"><span class="critLead">目标</span>'
+'<span class="pill" title="所属工作区">'+esc(_wsn)+'</span>'
+'<b>'+esc(gj.title||'(未声明目标)')+'</b></div>';
/* ── ② 完成情况(第二主角)──
⚠️ 2026-09-30:**没有有效验收判据时 ⛔ 不许显示成"无验收记录"那种中性字样** ——
判据是"**有没有有效项**",不是"字典空不空"(只写 `_说明` 行时旧代码会当成"全过")。
⚠️ 非 pass 的**逐条点名** —— 只写"4/5 通过"看不出**还差什么**(用户要的是"看完成情况")。 */
/* 🔴🔴 2026-10-02 修「恒算 0 通过」的假红(用户报:「还是0通过」)。
原判据 `String(acc[k]).toLowerCase()==='pass'` **只认英文 `pass`**,而 goal.json 的
`acceptance_state` 真值写的是**中文**:`过(…)`/`**复核:过(…)**`/`待重验(…)`
⇒ 前端恒算 **0 / N 通过**。后端 `board.py::_acc_is_pass` 当天 05:4x 已修、**这里漏同步**
⇒ 同族「判据写死期望值、永远不命中真源」(pitfalls P0-13/P0-20)。
🔴 **两处判据必须同款** ⇒ 改这里就去看 board.py 那一个函数,反之亦然。 */
var pass=keys.filter(function(k){return accIsPass(acc[k])}).length;
var bad =keys.filter(function(k){return !accIsPass(acc[k])});
var accHtml;
if(keys.length){
var pct=Math.round(pass/keys.length*100);
accHtml='<div class="accRow"><span class="critLead">完成情况</span>'
+'<span class="accBar'+(bad.length?' is-badn':'')+'" role="img" aria-label="验收通过 '+pass+' / '+keys.length+'">'
+'<i style="width:'+pct+'%"></i></span>'
+'<b class="accNum '+(bad.length?'is-bad':'is-ok')+'">'+pass+' / '+keys.length+' 通过</b>'
+(bad.length
? '<span class="oldline accLead">未完:</span>'
+'<ul class="accList">'+bad.map(function(k){
return '<li><code class="accKey">'+esc(k)+'</code>'
+'<span class="accWhy">'+esc(acc[k])+'</span></li>'}).join('')+'</ul>'
: '<span class="oldline">全部通过</span>')
+'</div>';
}else{
accHtml='<div class="accRow"><span class="critLead">完成情况</span>'
+chip('⚠️ 未声明验收判据(⛔ 不因此判完成)','is-warn')+'</div>';
}
/* 🔴 2026-10-04:只有说明行时**补一句**它们不是判据 —— ⛔ 否则读者看到「未声明判据」会以为
目标没写任何东西,而其实写了 `_说明`/`_更新`。措辞与后端 `_acc_summary` 同向。 */
if(keys.length===0&&noteKeys.length){
accHtml+='<div class="accRow"><span class="oldline">另有 %d 条说明行(%s)⇒ ⛔ 不计入通过率</span></div>'
.replace('%d',noteKeys.length).replace('%s',esc(noteKeys.join('、')));
}
/* 🔴🔴 2026-10-05 **删掉「任务类别」整行**(用户逐字:「任务类别(声明任务类别)这个没用就删除 看着烦」)。
依据(⛔ 不是口味,是可核对的读数):
① **它对用户没有操作价值** —— 这行的事实在「协作架构」那块**已经画了**
(`TOPS` + `TSRC` + `main_by_topic` 一起渲染成有向图,见下方 `TOPS`/`TSRC` 段);
这里再摆一遍 chips 属**同一份数据两处渲染**(同族红线)。
② **它最容易说假话** —— 实测:`topics=['会话协作自检']`、`topics_source.kind='declared'`,
但 `main_by_topic` 为空时,这行渲染出「任务类别 ⚠ 未声明」⇒ **明明声明过却说没声明**。
⚠️ 本轮已把 `main_by_topic` 的默认类别兜底修好(`board.py::_resolve_main`),
"未声明"这条假话没了;但**重复渲染**这条仍在 ⇒ 按用户口径删。
③ 用户 2026-10-01 已定口径:「**看板要精简的展示重点有效的信息,不是写说明书**」。
⛔ 删的是**版面这一行**;`tops`/`mbt`/`src` 三个变量**保留**(下方架构图仍在用,⛔ 别顺手删变量)。
⚠️ 类别清单与其来源**一条都没丢** —— 全在「协作架构」图 + `?` 提示里。 */
document.getElementById('proj').innerHTML = goalHtml + accHtml;
/* 🔴🔴 2026-10-01(用户两次:「**看板要精简的展示重点有效的信息,不是写说明书**」)
⇒ 版面上**只留三行**:目标 / 完成情况 / 任务类别。
其余全是**背景与规则**(目标 id、默认主会话、工作区、归属判据、类别来源的时间与人),
⛔ 不再占版面 —— 它们**一条都没丢**,全部追加进这一块右上角 `?` 里
(`?` 本来就是"小字说明"的正式落点;本文件 `frontTip` 早就是这个写法,此处同款)。
⚠️ 动态部分每轮**先还原 static 文案**再追加 ⇒ ⛔ 不会一轮轮叠加。 */
var tip=document.getElementById('projTip');
if(tip){
if(!tip.dataset.base) tip.dataset.base=tip.innerHTML; /* 首轮把静态文案存下来 */
tip.innerHTML=tip.dataset.base; /* 每轮先还原,防重复追加 */
var _crit=(pj.criteria||[]).map(esc).join(' / ')||'—';
tip.innerHTML+='<br><br><b>这一格目标的依据</b>'
+'(目标 id <code>'+esc(pj.id||'—')+'</code>'
+' · 默认主会话 <code>'+esc(pj.main_sid8||'—')+'</code>'
+' · 工作区 <code>'+esc(tailOf(pj.workspace))+'</code>)<br>'
+'<b>归属判据</b>(任一命中即算作本项目):'+esc(_crit)+'<br>'
+'<b>任务类别的来源</b>:'+(src.kind==='declared'
? ('调用时在对话里说明'+(src.at?(' · '+esc(src.at)):'')+(src.by?(' · '+esc(src.by)):''))
: (src.kind==='fallback'
? '⛔ 还没在对话里说明过 ⇒ 现按目标简称走单类别;说明方式:调用本技能时讲清「任务类别」'
: '⛔ 目标与类别都还没声明(调用本技能时在对话里说明)'));
}
}
/* 🔴🔴 架构图布局(**用户 2026-09-30 起逐次指定**)—— 纵向,从上到下:
① 用户 ② **主会话**(独占一行) ②′ **唤醒 + 跟进**(单独一行,左唤醒右跟进)
③ 任务会话1 … 任务会话n(**个数是活的**,按本项目在册会话渲染)
④ 协作 + 上报(旧名「监督程序」,并排) ⑤ Hook进程 + 底部一条「宿主地基」带
🔴🔴 2026-10-01 **第四改**:上面这些**不全是"层"了** —— 用户要求「用一个**虚线大框**把
主会话 唤醒会话 和 跟进会话都框起来,这个虚线大框 **连接 协作会话 虚线大框**就好」
⇒ 版面上现在有**两个虚线分组大框**(`.grp`):
`UA` = ① 用户下头那一坨(**主会话 + 唤醒 + 跟进**)—— 机制侧的三个角色同组;
`UB` = 任务会话那一排(**个数活的那一层**);
两个大框之间**一条线**=① 派活(**取代**了原来"主会话往每一格射线"的扇形)。
⚠️ 分层数没变(还是那几层),变的是**读图方式**:以前是"一层层看",现在是
"**先看两个组、再看组里几格**";大框⛔ 不填色、线细一点虚一点 ⇒ 表示"这是**分组**,不是节点"。
⚠️ 🔴🔴 **2026-10-03 会话只两类**(主/协作)—— 唤醒、跟进、**队列上报**整套退役。
本行原写"共**四类**(主/协作/唤醒/跟进)"**已作废**。
⚠️ `UA` 那个虚线大框**也已删除**(它原本是为了把"主+唤醒+跟进"圈成一组;
唤醒与跟进退役后框里只剩一格 ⇒ 一个只装一格的分组框是**噪声**)⇒ 现在图上**只剩 `UB` 一个 `.grp`**。
每条边都走**空白带**(⛔ 不横穿节点框):
① 派活带(两个大框之间那 58px) ② 写台账带(任务会话 → 检查程序 `--report`)
④ 收结果走地基带上方 ⛔ **原 ③「上报走右侧竖井」已删**(那条线的收件人是跟进会话,已退役)
⚠️ 走线带的 y **一律自动取中**(`B1/B2/B3` 由行位算出)⇒ ⛔ 别在注释或代码里写死数字
(写死过一次:改布局后这里还标着旧值,读的人按旧值去图上找,找不到)。
🔴 每格都带真状态:会话取宿主库 `status`,程序取状态戳 mtime(`board.json.runtime`)。 */
function fmtAge(m){
if(m==null)return '—';
m=Number(m);
return m<1?'刚刚':(m<90?Math.round(m)+' 分钟前':(m<2880?Math.round(m/60*10)/10+' 小时前':Math.round(m/1440*10)/10+' 天前'));
}
/* ⚠️ 中日韩字符 ≈ 1em(13px 字号 ⇒ 约 13px/字),ASCII 约 6.8px。
⛔ 别再用"每字符 8.4px"那种估法 —— 会把标题算长一倍、**文字溢出框外**(第一版就是这个毛病)。
下面按**逐字符实际宽度**累加,比"数够 N 个字符就切"稳得多。 */
function textW(s, cjk, ascii){
s=String(s==null?'':s); var w=0;
for(var i=0;i<s.length;i++){ w += (s.charCodeAt(i) > 0x2E80 ? cjk : ascii); }
return w;
}
function fitText(s, maxPx, cjk, ascii){
s=String(s==null?'':s);
if(textW(s,cjk,ascii)<=maxPx) return s;
var out='';
for(var i=0;i<s.length;i++){
var c=s.charAt(i), w=(s.charCodeAt(i) > 0x2E80 ? cjk : ascii);
if(textW(out,cjk,ascii)+w+ascii>maxPx) return out+'…';
out+=c;
}
return out;
}
/* 两行版:先按整宽切一行,剩下的切第二行(用于"标题较长、框较窄"的格子) */
function fitTwo(t, maxPx, cjk, ascii){
t=String(t==null?'':t);
if(textW(t,cjk,ascii)<=maxPx) return [t];
var cut=0, w=0;
for(var i=0;i<t.length;i++){
var cw=(t.charCodeAt(i)>0x2E80?cjk:ascii);
if(w+cw>maxPx) break;
w+=cw; cut=i+1;
}
return [t.slice(0,cut), fitText(t.slice(cut), maxPx, cjk, ascii)];
}
function drawArch(d){
var S=d.sessions||[], rt=d.runtime||{}, LB=d.labor||[];
/* 🔴 2026-10-01(用户报「**创建了协作会话 但是 看板中没有展示**」):
同工作区、**但按命名判据没归入本项目**的会话。⛔ 它们**不是**本项目会话
(不算进 `S`、不进第三层、不进分工板);**但必须说出口** ——
否则一条真在跑的棒会「凭空消失」,读者只会以为机制没派活(同族红线:读到了却不说)。
典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号、或用 `goal.short` 而非 `topics`)⇒
`parse_session_name()` / `in_project()` 双双认不出。 */
var UNREC=d.sessions_unrecognized||[];
/* 🔴🔴 2026-10-01 加(用户:「**通过接续会话的时候,如何处理过期的会话,避免越堆越多**」):
**已退场/超窗口**的会话。它们**不进 `S`**(⇒ ⛔ 不进第三层、⛔ 不进分工板、⛔ 不进「会话」表),
**但必须报数** —— 否则读者会以为那些棒**凭空消失了**(同族红线:读到了却不说)。
⚠️ 退场 ≠ 删除:宿主库里原样在;判据与写入口在服务端
(`board.py::_sessions()` 的退场筛 / `collabd.py --retire self`),
⛔ 本文件**一个字都不许自己判**(判据两处打架是本项目踩过最狠的一类坑)。 */
var RET=d.sessions_retired||{};
/* 🆕 2026-09-30:**每个任务类别各自的主会话**(`{类别: sid8}`)—— 第三层按类别渲染要用它 */
var MBT=(d.project||{}).main_by_topic||{};
var main=(S.filter(function(x){return x.role==='主会话'})[0])||null;
var runningS=S.filter(function(x){return x.status==='working'});
/* 🔴🔴 2026-10-01 修:这一排=**任务会话**,判据从 `role !== '主会话'` 改成 **`role === '任务会话'`**。
原判据**把唤醒会话与第④类跟进会话也收进来了**(服务端原来二分:不是主会话就是任务会话)⇒
唤醒会话会**画两遍**(主会话左侧一次、这排一次)、跟进会话**顶着错名字**。
已同步:`board.py::_role_label()` 现在输出**四类**(主/协作/唤醒/跟进)。
⚠️ 两者**必须同款**,⛔ 改一边不改另一边 = 这排又开始吃错格子。 */
/* 🔴🔴 2026-10-03 11:1x 这一排**只放协作会话**,⛔ **检查会话不许进来**(用户纠正逐字:
「检查会话不是协作会话,不应该出现在看板协作会话区域中」)。
⚠️ 服务端已把它单列到 `sessions_checks`,但这里用的是全量 `S` ⇒ **必须再挡一次**
(⛔ 两处只靠「服务端单列」会漏:任何把检查会话并回 S 的改动都会让它复活)。
⇒ 判据=`role==='任务会话'` **且** 标题里**不含**「检查」。 */
// 🔴🔴 2026-10-03 23:4x 改名:「协作目标」→「执行会话」(用户定案)⇒ **判据跟着标签走**
// ⚠️ 这个字符串**必须与 `board.py::_ROLE_LABEL['worker']` 逐字相同**
// ⛔ 只改一边 ⇒ 这一排**整排空掉**(19:2x 已踩过一次,根因就在这行)。
var works=S.filter(function(x){return x.role==='任务会话' && String(x.title||'').indexOf('检查')<0});
/* 🔴🔴 2026-10-01 用户定 —— **这一排第三次换语义**(前两次的来历见 §0.5.6):
「**主会话 下面 那一排只放协作会话**,横着排 有几个放几个,当前没有对应协作会话 就空着。
**唤醒机制如果是主会话的 事就放到主会话框里去**」
⇒ 这一排 = **任务会话**(判据 `role === '任务会话'`,⛔ 不再按"任务类别"铺格):
· ⚠️ **任务类别**:曾按用户 2026-10-01「**唤醒机制如果是主会话的 事就放到主会话框里去**」
搬进 R2 主会话框(一行「负责类别:…」);**同日又删掉了** —— 理由见 `② 主会话` 段注释。
⇒ 现在任务的类别**只在两处**说:顶部「本项目」卡的 **chips** + 图外说明「当前」块第 1 条。
· 🔴 台账里的**旧线(legacy)** 也⛔ 不占这一排 ⇒ 汇总改到**图外说明**里摊开说(⛔ 不静默丢)。
⚠️ 仍算 `_lg`:**图外说明**要报"几条线、多少件"(本线红线:读到了就得说出口)。 */
var _lg=LB.filter(function(x){return x.kind==='legacy'});
var _fold=null;
if(_lg.length){
var _ft=0,_fd=0,_fo=0,_fr=[];
_lg.forEach(function(x){_ft+=x.total||0;_fd+=x.done||0;_fo+=x.open||0;
_fr=_fr.concat(x.running||[])});
_fold={total:_ft,done:_fd,open:_fo,running:_fr};
}
/* 这一排画谁:**任务会话**。用户要"有几个放几个" ⇒ 放宽上限(MAXW 只防极端,超了在图外点名)。
⛔ 不再按宽度截断类别 —— 类别不在这排了。 */
var MAXW=8;
var cells=works.slice(0,MAXW), moreW=Math.max(0,works.length-MAXW);
var TOPS=(d.project||{}).topics||[];
/* 🆕 2026-10-01:**类别的来源**(`declared`/`fallback`/`none`)。
⚠️ `TOPS` 自己分不出"在对话里声明过"和"按目标简称回落来的" —— 而这两件事**必须分开说**
(见下方「当前」块第 1 条的注释:混在一起就会把回落值当成已声明的类别)。 */
var TSRC=(d.project||{}).topics_source||{};
var working=S.filter(function(x){return x.status==='working'}).length;
var o=[];
/* 🔴 两类"让主会话动起来"的外部来源,⛔ 别混为一谈:
· **Hook进程**(R5 那格)—— **事件驱动**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退;
· **唤醒 等**(画在 `RW` 行的**左格**)—— **时间驱动**:由 `front.triggers` 给的那条周期任务。
用户 2026-09-30 三连:「把心跳的节点也放到看板协作架构图中」→「把唤醒 图框做小一点 放到
主会话左边就行 本来就是主会话创建的」→「把心跳 改为 唤醒 里面是每5分钟核对目标状态」。
⚠️ 它们**不由技能侧写死**(⛔ 技能里不出现任何项目的组件名)——
由**使用方**经 `front.triggers` 给,形状:
`[{name, detail, detail2, label, up, edge, tip}]`。空 ⇒ `RW` 行左格不画格子。 */
var TG=(d.front&&d.front.triggers)||[];
/* 🔴 行距(用户 2026-09-30:「摆不下就**加高**些,线都没连全 有些线都看不到」)
⇒ **只加高、⛔ 不加宽**:`svg.arch{width:100%}` ⇒ 缩放比由**宽度**定 ⇒ 加高**不会**把字变小,
只是每层之间更宽敞(走线带、拐点、标签都放得下)。 */
/* 🔴🔴 2026-10-01 **第三改布局**(用户原话逐字):
「可以把 **唤醒会话和 跟进会话 单独放一行** / **主会话** / **换新会话 跟进会话** / **协作会话**」
⇒ 第二层**拆成两行**:`R2` = **主会话**(独占一行);
`RW` = **唤醒会话 + 跟进会话**(**单独一行**,左=唤醒、右=跟进)。
⚠️ **为什么要拆**(这不是纯美观):本轮口径把「唤醒」与「队列上报」**都改指跟进会话**
⇒ 若跟进会话还贴在主会话**右边**,唤醒那条线就得**从主会话左边跨过主会话框**才能连到它
(画出来是一根跨框的绕线,读者多半会以为连的是主会话 —— 正是本轮要消灭的误读)。
拆成单独一行后:唤醒 → 跟进 是一条**同一行的短横线**,谁叫谁一眼看清;主会话上方
**只剩"用户"这一条竖线**(="主会话只能是用户触发"在图上成立)。
⚠️ 新行插在 R2 与 R3 之间 ⇒ **R3 起整体下移 94**,`viewBox` 同步加高
(⛔ 不同步 ⇒ 底部地基带被裁,几何自检 ① 段会报越界)。 */
/* 🔴🔴 2026-10-01 **第四改布局**(用户原话):
「先等等 有个办法更好,用一个**虚线大框**把 主会话 唤醒会话 和 跟进会话都框起来,
这个虚线大框 **连接 协作会话 虚线大框**就好」
⇒ 多两个**分组大框**(`.grp`,虚线、不填色):`UA` = 主会话+唤醒+跟进;`UB` = 任务会话;
**两组之间一条连线**(① 派活)**取代**原来"主会话往每一格射线"的扇形。
⚠️ 这一改顺手**消掉了两个麻烦**(⛔ 不是巧合,是分组本身带来的):
· ① 派活再也不必**穿过 RW 那一行**去找格子 ⇒ 它只剩 UA 底 → UB 顶那一段(58px);
· 唤醒 → 跟进 那条实线再也不与 ① 竖线**十字相交** ⇒ 上一版担心的"十字交叉"不存在了,
用户那句「**增加连接…的实线**」落到实处(一条干干净净的直线)。
⛔ `R2` 的上边**不能再往上**:R1 底 116 + 用户→主会话 那条箭头 + 它的标签都要塞进 40px。
⚠️ `UA` 的**上边也顶在 156**:再往上就贴到那条"用户→主会话"的箭头与标签(`y≈116..176`)。
⚠️ ⛔ 别把 `UA/UB` 当成"又加了两层" —— 它们**不占层**,只是把已有几层**套起来**
⇒ `rows` 镜像(几何自检)里**没有** UA/UB,那两个框是**单独**用包围盒判的。 */
// 🔴🔴 2026-10-03 **删掉主会话外面那个虚线大框 `UA`**(用户逐字:「主会话周围的虚线框也去掉」)。
// 理由(不是版面口味):那个框原本是**为了把"主会话+唤醒+跟进"三个角色圈成一组**才存在的;
// 唤醒与跟进退役后**框里只剩一格** ⇒ 一个只装一格的"分组框"是**无意义的框**(=噪声)。
// ⇒ 只保留 `UB`(框住任务会话那一排 —— 那一排**确实有多格**,分组有意义)。
// ⚠️ 连带改:`B1`(派活带走线带)改成 **R2 底 → UB 顶**;派活线起点改成 `R2` 底;
// `RKX` 里避让 `UA` 的那一项删掉(框没了,⛔ 留着就是减一个不存在的边界)。
var UB={y:300,h:178}; // 任务会话那排的分组大框(⛔ UA 已于 2026-10-03 整框删除)
/* ⚠️ `UB` 的横向尺寸**不在这里**(`x/w` 由 `③` 段现算)——
它取决于任务会话的**条数**与**格宽**(写在这儿会变成"两处各存一份",改一处另一处就过期)。 */
// 🔴🔴 2026-10-03 **删掉 `RW` 整行**(用户逐字:「让你把 架构图里面的 唤醒和跟进 以及上报都删除」)。
// 唤醒会话 / 跟进会话 / 队列上报**已整套退役** ⇒ 图上**不再画那一行**(⛔ 不是画成"已退役"灰格 ——
// 用户要的是**删除**;灰格那版是上一轮的选择,本轮按用户明确要求推翻)。
// ⇒ `R2`(主会话)底 → `R3`(任务会话)顶**直接相连**,`UA` 与 `UB` 之间不再夹那一行。
// ⚠️ 删行后 **`R3` 起整体上移 94**(= RW.y 300 − R2 底 260 + 56 间距),
// `viewBox` 同步缩高 94(原 1054 → **960**);⚠️ 几何自检的 `rows` 镜像**必须同批改**。
var R1={y:36,h:80}, R2={y:176,h:84}, R3={y:322,h:134},
R4={y:538,h:146}, R5={y:718,h:96}, RB={y:850,h:56};
/* ⚠️ R2 高度来回:84 → 112(2026-10-01 加第四行「负责类别:…」)→ **回到 84**(同日删掉那行)。
原 84 = 三行文案(标题 / 名称 / 状态)刚好装下;加第四行才抬到 112。
🔴 删行的理由见下方 `② 主会话` 那段注释 —— 一句话:**那行会说出假话,且同一事实别处已有两处**。
⚠️ `tmp/arch-geom-check.mjs` 的 `rows` 镜像**必须逐行同步**(那文件里写着:「必须与 board.html
同步,否则几何自检=假绿」)—— 现在共 **6** 行:R1 / R2 / R3 / R4 / R5 / RB
(⛔ `RW` 那一行 2026-10-03 随唤醒/跟进/上报退役**整行删除**)。 */
var CX=640, SX0=60, SX1=1220; // 会话行 60..1220(⚠️ 右侧竖井 x **现算**,见 ③ 段;⛔ 别在这儿写死)
/* 🔴 三条走线带**自动取中**(⛔ 别写死):B1=**UA 底 → UB 顶**(① 派活带,2026-10-03 起
**不再需要穿过 RW 那一行的空白** —— 那一行已整行删除 ⇒ 直接取两个分组大框之间那一段)。 */
/* ① 派活带 = **两个分组大框之间**那一段(2026-10-01 分组后:不再从主会话往每一格射线);
② 上报带 = 任务会话那排底 → 常驻程序顶(⛔ 不受 UB 边界影响,它本来就穿过去)。 */
var B1=(R2.y+R2.h+UB.y)/2, B2=(R3.y+R3.h+R4.y)/2; // 🔴 2026-10-03:UA 已删 ⇒ B1 改 R2 底→UB 顶
var B3=(R4.y+R4.h+R5.y)/2; // R4↔R5 之间的走线带(⛔ 别写死 779)
/* 🔴🔴 2026-10-03 **④ 这一行原本是「两格」:左=常驻程序、右=投递**(用户 2026-10-03:
「**不是 协作程序常驻吗**」)。
🔴 **两格是「投递时代」的遗留** —— 当时真的**有两个进程**(`--once` 投影 + `--supervise` 投递)。
投递退役后**只剩一个进程** ⇒ 拆两格是**历史噪声**,且右格那个名字
(我一度写成"常驻")是**用模糊词替代了具体词** —— 角色本来就叫**常驻程序**。
✅ **处置=合并成一格**(⚠️ 20:05x 已**收窄**到 470;20:16x 改为**跟随画布中心** `PX=CX-PW/2`),标题「协作程序」;⛔ 不留"常驻"这个第二节点。
⚠️ 合并后 `UX` 变量**已删**(⛔ 别再引用);`PW` 由 300 → **720**。
⚠️ `RKX`/`TICKX` 两处随之改算(原来它们指向右格中心)—— 见下方各自注释。 */
// 2026-10-03 20:16x **水平居中**(用户:「改完了协作程序水平居中」)
// ⚠️ 之前 `PX=280` 是"两格合一"时期的值 ⇒ 框 280..750、**中心 515**,而画布中心 `CX=640` ⇒ **肉眼偏左**。
// ✅ 改成**跟着画布中心算**(⛔ 不写死 405:CX 变了它自动跟随)。
// ⚠️ 联动:`PX+PW/2` 的连线中点、右侧标签 x(按连线现算)**都会自动跟随**,⛔ 不必逐个改。
// 📌 对照:`R1`/`R2`(用户/主会话)本来就居中(`x1=CX-w1/2`),`Hook进程` 框 400..890 中心 645 ≈ 居中
// ⇒ **此前只有常驻程序这一格偏左**,现已与其余元素同一中轴。
var PW=470, PX=CX-PW/2;
// ⚠️ 依据=**框内实际用到的最右位置**(实测:队列那行约 240px、检查会话条目约 288px ⇒ 收窄到 470 仍有余量)
// ⚠️ 之前是 720(280..1000),因为**要并排放 4 条检查会话**;改成「只显示最新一条」后右侧空了一大半 ⇒ 收窄。
// 🔴 `PX+PW` 被**别处**引用(连线中点、右侧节点位置):它们会**自动跟随**,⛔ 不必逐个改;
// 但凡**写死 1000** 的注释/常量要一并核(本轮已核:只有注释里出现过,运行期无硬编码)。
function rect(x,y,w,h,tone){
var m={on:'node-on',off:'node-off',base:'node-base'};
o.push('<rect class="'+(m[tone]||'node-bg')+'" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="14"/>');
}
function tx(x,y,t,cls,anchor,tip){
/* 🔴 2026-10-03 19:55x `tip`=**悬停提示**(SVG 原生 `<title>` 子元素,⛔ 不需要 JS 事件)。
⚠️ 必须放在 `<text>` 的**第一个子元素**(SVG 规范),且内容要 `esc()` 转义。
用途:版面只留**最新一条**,其余信息**移上去才看得到**(用户要求:别的进下拉/悬停)。 */
o.push('<text class="'+(cls||'n-sub')+'" x="'+x+'" y="'+y+'"'+(anchor?' text-anchor="'+anchor+'"':'')+'>'
+(tip?'<title>'+esc(tip)+'</title>':'')+esc(t)+'</text>');
}
function edge(dstr,grey,noArrow){
o.push('<path class="edge'+(grey?'-grey':'')+(noArrow?'':' live')+'" d="'+dstr+'"'
+(noArrow?'':' marker-end="url(#ar)"')+'/>');
}
function lbl(x,y,t,anchor){tx(x,y,t,'e-label',anchor||'middle')}
function toneOf(st){return st==='working'?'n-ok':(st==='completed'?'n-idle':'n-warn')}
function wordOf(st){return st==='working'?'执行中':(st==='completed'?'已完成':(st||'—'))}
/* ── 🆕 2026-10-01 **第四改:两个虚线分组大框**(用户逐字原话)——
「先等等 有个办法更好,用一个**虚线大框**把 主会话 唤醒会话 和 跟进会话都框起来,
这个虚线大框 **连接 协作会话 虚线大框**就好」
⇒ `UA` = 主会话+唤醒+跟进(**机制侧那三个角色本来就在同一组里**:用户只进主会话,
机制侧的"推"只投/叫醒跟进会话);`UB` = 任务会话那一排(在下面 `③` 段画)。
🔴🔴 **UA 必须画在 ① 用户之前** —— SVG 靠**文档顺序**叠放,后画的压住先画的;
大框要垫在里面的会话格**下面**(⛔ 反了 ⇒ 虚线横穿格子上的文字,整块糊掉)。 */
/* 🔴 里面那三格的**几何必须先算出来**(⛔ 不能等画格子时再算)——
因为 `UA` 的包围盒就是从它们推的,而 UA 又要先落笔。
⚠️ **只留这一处**:这三个常量以前在 `② 主会话` 与 `②′` 段里各写一份 ⇒
改一处另一处必然过期(本轮已栽过这个坑)⇒ 统一提到这里。 */
var w2=380, x2=CX-w2/2, in2=w2-40; // 主会话那一行(R2)的横向几何
// 🔴🔴 2026-10-03:`TW/FW/RW_GAP/tbx/fbx/fin/RW_Y/RW_H/RW_MID` **全部删除**
// (唤醒会话 / 跟进会话 / 队列上报**整套退役** ⇒ 图上不再有那一行)。
// ⛔ 保留这些变量=保留一条"看起来还能用"的死路 ⇒ 后人会拿它当契约。⛔ 所以**真删**。
// 🔴🔴 2026-10-03 同批**删掉 `UA` 那个虚线大框**(用户:「主会话周围的虚线框也去掉」)
// ⇒ 它的包围盒计算(`UA_PAD/_uL/_uR/UAx/UAw`)与绘制一并删除。
// ⛔ 若将来要恢复:**按本段下面的定义重写**(`UA_PAD/_uL/_uR/UAx/UAw` + 绘制)
// **并**要恢复 `RW` 那一行(`UA` 原本是"框住三角色"的,画出来只有一格就是噪声框)。
// ── ① 用户 —— 🔴 写**职责 + 可执行动作**(用户 2026-09-30 要求;⛔ 不写"你 · 一张看板"这种空话)
var w1=300, x1=CX-w1/2, in1=w1-40;
rect(x1,R1.y,w1,R1.h,'bg');
tx(x1+20,R1.y+28,'用户','n-title');
tx(x1+20,R1.y+50, fitText('职责:制定目标 · 调整方向 · 做决策', in1, 11, 6), 'n-sub');
tx(x1+20,R1.y+70, fitText('可做:看板 · 放行授权 · 改目标 · 喊停', in1, 11, 6), 'n-sub');
// ── ② 主会话 —— 🔴 显示**会话名**(用户 2026-09-30 要求),⛔ 不只给一个 id
// 🔴🔴 2026-10-01 本轮两次改动落点:
// ① 用户:「**就是把主会话 跟进职责 放给跟进会话了**」⇒ 状态行**只说「判断 + 派活」**,
// ⛔ 不再提「跟进」(职责已移交;同一职责两处都写,读者就分不清谁在干)。
// ② 用户:「**唤醒会话和 跟进会话 单独放一行**」⇒ 跟进会话**不在这格里**了,
// 搬到 `RW` 那一行(见下方 `②′ 唤醒 + 跟进(RW 行)`)。
// ⚠️ `w2/x2/in2` **不在这里声明** —— 已提到上方(`UA` 的包围盒要用它们)⇒ ⛔ 别写第二份。
var mOn=!!(main&&main.status==='working');
rect(x2,R2.y,w2,R2.h, main?(mOn?'on':'bg'):'bg');
tx(x2+20,R2.y+26,'主会话','n-title');
tx(x2+20,R2.y+50, main?fitText(main.title, in2, 13, 7):'(未声明主会话)', 'n-title-sm');
tx(x2+20,R2.y+70, main?fitText('判断 + 派活 · '+wordOf(main.status)+' · '+main.id8, in2, 11, 6)
:'——', main?(mOn?'n-ok':'n-idle'):'n-idle');
/* 🔴🔴 2026-10-01 **删掉了原来贴在这里的那行「负责类别:…」**
(用户原话:「**这个是什么意思,如果没用就删掉**」)。
原实现:用 `TOPS` 按 `main_by_topic` **反查**这条主会话负责的类别,查不到就渲染
「负责类别:—(未在对话里说明)」并用警示色。**判它"没用且会误导"的三条依据**(实测,⛔ 不是口味):
① **它会说出假话** —— 实测读数 `project.topics = ['会话协作自检']`、
`topics_source.kind = 'declared'`(20:25 在对话里说明过),但 `main_by_topic = {}`
⇒ 这行渲染成「**未在对话里说明**」。**类别明明声明过**,它却说"没说过" ⇒ 假。
(真因是"**这条**主会话没对应到任何类别",与"有没有说明过"是两件事,原判据把两者混成一个。)
② **同一事实版面上已说过两处,而且方向更对**:
· 顶部「本项目」卡的**任务类别 chips** —— 每个 chip 直接带「主会话 `<id8>`」/「⚠️ 无主会话」,
`?` 里还带来源(谁说的、什么时候说的);
· 图外说明「当前」块里的「主会话**负责的任务类别**:…」。
⇒ 这一行是**第三遍**,还是**反方向**(主会话→类别),读者得倒着对一遍。
③ 用户反复要求「**看板要精简的展示重点有效的信息,不是写说明书**」。
⚠️ 它当初是照用户 2026-10-01「**唤醒机制如果是主会话的 事就放到主会话框里去**」搬进来的
⇒ 本次删除**推翻了那次摆放**;理由就是上面三条:**该说的话别处都在说,而且说得更准**。
⛔ 还原点:`E:/ProgramData/AIProject/ai1net-dsh-server/tmp/bak-负责类别行-20261001/board.html.bak`
(要恢复 ⇒ 把这段换回那三行,**并把 `R2.h` 一起改回 112**)。 */
// 用户 → 主会话 —— 🔴🔴 这是**主会话的唯一入口**(用户 2026-10-01:「**主会话只能是用户触发**」)
// ⇒ 标签必须把"唯一"写出来:图上别处**没有任何一条线指向主会话**(唤醒、队列上报都已改指跟进会话)
// ⇒ 写清"唯一"= 把**图上画出来的事实**说出口,⛔ 不是修辞。
edge('M'+CX+' '+(R1.y+R1.h)+' L'+CX+' '+R2.y);
lbl(CX+56, (R1.y+R1.h+R2.y)/2+3, '发消息 · 看板(主会话的唯一入口)', 'start');
/* 🔴 2026-10-01 **第四改**:原来**这里**画的是「① 派活」的**竖段**(主会话底 → 走线带)。
它当时必须**先于 RW 行**落笔 —— 因为要压在 `②′` 那条横线的**跨线跳弧**下面。
⇒ 分组大框落地后,**提前的理由和那段竖线一起没了**:① 现在只是「`UA` 底 → `UB` 顶」
那一小段(两个大框之间),不再穿过 RW 那一行。它挪到下方 `③` 段(`UB` 落笔之后)。
⚠️ 同时作废的还有 `②′` 里那句"与 ① 竖线十字相交" —— 那条横线现在谁也不压。 */
// ── ②′ 原「唤醒 + 跟进」那一行:**2026-10-03 整行删除**(用户逐字:「让你把 架构图里面的
// 唤醒和跟进 以及上报都删除」)⇒ 原 857–968 这 112 行**真删**,⛔ 不留代码也不留死变量
// ( 已一并删 ⛔ 留着等于留一条假契约)。
// ⇒ 图形顺序变成:用户 → 主会话(UA 组)→ **直连** 任务会话(UB 组)→ 常驻程序。
// ── ③ **任务会话**(🔴 2026-10-01 用户定:「**这一排只放协作会话**,横着排 有几个放几个,
// 当前没有对应协作会话 就空着」+「放一个空的框 说明 暂无协作会话」)——
// ⚠️ 这一排**换过两次语义**(按线 → 按任务类别 → 现在是**按任务会话**):
// · ⚠️ **任务类别**:曾在 2026-10-01 搬进 R2(一行「负责类别:…」),**同日又删掉了**
// (用户:「**这个是什么意思,如果没用就删掉**」)⇒ 现在类别**只在两处**说:
// 顶部「本项目」卡的 **chips** + 图外说明「当前」块第 1 条;
// · 台账旧线(legacy)的汇总 ⇒ 已搬到**图外说明**(⛔ 不静默丢)。
// 每格=**一条任务会话**;格内:会话名 / 状态·id / 所属类别 / 多久没动。
var cx=[], n=cells.length;
/* 🔴 2026-10-01 **第四改**:**先算布局预算、先画分组大框 `UB`,最后才画格子** ——
顺序不能反:SVG 靠**文档顺序**叠放,大框必须垫在格子**下面**(反了 ⇒ 虚线横穿格子上的文字)。
⚠️ `UB` 的包围盒**贴在内容上**(留 `UB_PAD` 内边距),⛔ 不写死成 `SX0..SX1` 那么宽 ——
只有两条任务会话时,那会画出一个 1160 宽、里面空一大半的假框:既说不出"这一组有几条",
又比上面那个框宽一大截,会被读成"下面这组更重要"(⛔ 用户要的是**分组**,不是主次)。
⚠️ 改 `gap / w / SX1 / UB_PAD` 任何一个都要**回来重算 UB**(自检里有一条守"UB 包住所有格子")。 */
var UB_PAD=18, UBx, UBw;
if(n===0){
UBx=CX-240-UB_PAD; UBw=480+2*UB_PAD; // 空态:包住那个 480 宽的空框
}else{
var gap=(n>5)?16:24, avail=SX1-SX0;
var w=Math.min(340, Math.max(150, (avail-(n-1)*gap)/n));
var total=n*w+(n-1)*gap, sx=CX-total/2;
UBx=sx-UB_PAD; UBw=total+2*UB_PAD;
}
// ── `UB`:任务会话那一组(虚线大框,⛔ 不填色;画在格子与 ② 连线**之前**)
o.push('<rect class="grp" x="'+UBx+'" y="'+UB.y+'" width="'+UBw+'" height="'+UB.h+'" rx="20">'
+'<title>'+esc('一组:任务会话 —— 主会话建自动化排期、宿主到点开出来的棒。'
+'有几条画几条,⛔ 不是固定几格。')+'</title></rect>');
/* ── 🔴 **① 派活 = 两个分组大框之间的一条线**(2026-10-01 第四改)——
用户:「这个虚线大框 **连接 协作会话 虚线大框**就好」⇒ 它**取代**了原来"主会话往每一格
射线"的扇形。⛔ 这不只是为了少画几条线:
· 扇形要从主会话**穿过 RW 那一行的空档**再撒到各格 ⇒ 读者得顺线倒推"到底谁派的";
· 而且**空态时那条通道一个落点都没有**(上一版只能画一个孤零零的标签,看不出通向哪)。
现在它就是一条 58px 的竖线(`UA` 底 → `UB` 顶),**空态照画**
(同族红线:空态 ≠ 通道不存在,只是这条通道上暂时没有会话)。
⚠️ 它落在**格子之前**,但两头都在两个大框的边上、**不与任何格子相交** ⇒ 叠放无所谓。 */
edge('M'+CX+' '+(R2.y+R2.h)+' L'+CX+' '+UB.y); // 🔴 2026-10-03:UA 已删 ⇒ 起点改 R2 底
lbl(CX+14, B1+4, '① 派活 · 自动化排期', 'start');
if(n===0){
/* 🔴 **空态也要画框**(用户 2026-10-01:「**放一个空的框 说明 暂无协作会话**」)——
空 ≠ 坏了。⛔ 不许因为"没内容"就把这一层省略掉(读者会以为图缺了一块)。 */
o.push('<rect class="node-bg" x="'+(CX-240)+'" y="'+R3.y+'" width="480" height="'+R3.h+'" rx="14"'
+' stroke-dasharray="6 5"/>');
tx(CX,R3.y+56,'暂无任务会话','n-idle','middle');
tx(CX,R3.y+82,'主会话派活时创建','n-sub','middle');
/* ⚠️ **② 的标签在空态也要画**(2026-10-01 修)——
⚠️ ① 的标签**已提到上面**(第四改后它不再属于任何分支:恒画那一条竖线的标签)。
⛔ 空态 ≠ ② 上报这条回程不存在;只是当前没有会话可以连。 */
lbl(CX,B2-8,'② 写台账 · --report');
}else{
cells.forEach(function(W){
var x=sx+cx.length*(w+gap); cx.push(x+w/2);
var inner=w-28;
var tone=(W.status==='working')?'on':((W.status==='completed')?'bg':'warn');
rect(x,R3.y,w,R3.h,tone);
tx(x+14,R3.y+24, fitText(W.title||'(未命名会话)', inner, 13, 7), 'n-title-sm');
tx(x+14,R3.y+50, fitText(wordOf(W.status)+' · '+W.id8, inner, 11, 6), toneOf(W.status));
/* ⚠️ `fmtAge()` 的返回值**自带"前"字**(`4 分钟前`)⇒ ⛔ 别再加一个"前"
(2026-10-01 实测拼成过「4 分钟前前有活动」)。 */
tx(x+14,R3.y+72, fmtAge(W.age_min)+'有活动', 'n-sub');
tx(x+14,R3.y+94, '它承接的件见下方「需求台账」', 'n-sub');
});
/* ⚠️ ① 派活**不在这里**(第四改后 = 两个分组大框之间那一条,已提到 `UB` 落笔之后);
各格到 `UB` 顶的那几条射线**也一并拆掉** —— 用户:「虚线大框 **连接 协作会话 虚线大框**就好」。 */
// ② 上报:各条任务会话 → 常驻程序(走下方带)
cx.forEach(function(c){ edge('M'+c+' '+(R3.y+R3.h)+' L'+c+' '+B2, null, true) });
edge('M'+Math.min.apply(null,cx.concat([PX+PW/2]))+' '+B2+' L'+Math.max.apply(null,cx.concat([PX+PW/2]))+' '+B2, null, true);
edge('M'+(PX+PW/2)+' '+B2+' L'+(PX+PW/2)+' '+R4.y);
lbl(Math.max.apply(null,cx), B2-8, '② 写台账 · --report', 'end');
}
if(moreW>0){
/* 🔴 2026-10-01 **第四改**:这句话原来画在任务会话排**下面**(`R3.y+R3.h+16`)——
那是"没有分组大框"的年代。现在它在 `UB` 的**底边里侧 6px**,贴着虚线 ⇒ 读起来像"被框切了一半"。
⇒ 挪到 `UB` 的**右上角内侧**(框顶 与 格子顶 之间那条 22px 的空带里),
并且**右端跟着框走**(`UBx+UBw-10`)⇒ ⛔ 不会因为框随会话数变宽变窄而跑到框外。
⛔ 不许因为"不好放"就把它删掉 —— 它是**读到了却不说**的对面:少画了就得说出口。 */
tx(UBx+UBw-10, R3.y-7, '另有 '+moreW+' 条任务会话未画(图上最多 '+MAXW+' 条)', 'n-warn', 'end');
}
// ── ④ 常驻程序(🔴 2026-10-03:原「协作」+「常驻」两格合并成一格 —— 一个进程、一个角色)
var pr=rt.prog||{}, gu=rt.deliver||{}, q=d.queue||{}, qb=q.by||{}; /* gu = 常驻那组读数(键名沿用 deliver,⛔ 不改:board.py 还按这个键喂) */
// 常驻程序:🔴 带**队列计数**(用户 2026-09-30:「协作程序 也要显示当前待验收队列数量」)
o.push('<rect class="'+(pr.up?'node-on':'node-off')+'" x="'+PX+'" y="'+R4.y+'" width="'+PW+'" height="'+R4.h+'" rx="14">'
+'<title>队列 = **当前目标**的台账(`tasks.json` 里 `goal_fp` 命中当前目标的那几条)四态:待执行 '+((qb.pending)||0)+' / 执行中 '+((qb.running)||0)
+' / 已完成 '+((qb.done)||0)+' / 有阻碍 '+((qb.blocked)||0)+'。机制里没有「待验收」这个态。'
+((q.legacy||0)?('<br>⚠️ 另有 '+(q.legacy)+' 件**无目标归属**的历史条目(早期写的、未打标记)⇒ 不计入本行。'):'')
+((q.fp)?'':'<br>⚠️ **未声明目标** ⇒ 台账全部按「无归属」处理,本行显示 0(⛔ 不等于"没有活")。')
+'<br>职责:接收=--report 写台账;处理=建检查会话排期;一直运行。'
+'<br>'+esc(pr.how||'')+'</title></rect>');
/* ⚠️ `R4.h=112` ⇒ **基线最大约 96**(留 16px 下边距)⇒ ⛔ 别再往这格加第 5 行;
加第 5 行必须先 `R4.h` 加高(并同步 `R5.y`/走线带,`B3` 是自动算的)。
静态职责文案已搬进上方 `<title>` tooltip(同「说明只进 ?」口径,不占版面)。 */
/* 🔴🔴 2026-10-06 **改名「常驻程序」→「检查程序」**(用户逐字:
「**看板中 常驻程序 改为 目标检查**」→ 我复述成"目标检查",用户再纠正:
「**没有常驻程序的说法 ,改为 检查程序**」)。
🔴 **这是一次"角色名"纠错,不是换词** —— 同族历史(2026-10-03):
我第一版把「上报」改成「常驻」,用户驳回「**怎么又把上报 改成常驻了 常驻什么**」,
根因=**把「角色名」与「运行形态」混成一个词**。
现在这一版同样踩了:**「常驻」是运行形态(`--supervise` 一直跑),⛔ 不是角色**。
✅ 角色实际干的两件事=**① 接收(`--report` 写台账)② 建检查会话排期** ⇒
名字取其中最可辨识的职责:**检查程序**。
⚠️ 改的只是**这一格的标题**(用户可见面);代码标识符/文件名/配置键**一律不动**
(`collabd.py`/`prog`/`rt.prog` 等 ⛔ 不改 —— 改名会波及 20+ 文件且无收益)。
⚠️ 同屏其它用户可见处一并跟(见本文件 `检查程序` 全文替换点):
`检查会话:暂无在册(…按需建)`/图例/tooltip/底部说明。 */
tx(PX+18,R4.y+26,'检查程序','n-title');
/* 🔴🔴 2026-10-06 **队列行改为「当前目标」口径**(用户逐字:
「看板中 常驻程序 改为 目标检查,里面的队列 **应该是随着目标的不是目标累积的**」)。
⚠️ 改前 = `q.total`(`tasks.json` **全量累计**)⇒ 换过目标后**把历史目标的活也数进来**。
✅ 改后 = 服务端 `_queue(..., cur_fp)` **已按目标过滤**(`q.total` 现在就是当前目标那部分);
另有 `q.legacy` =**无归属的存量条目数**(早期写的、没打 `goal_fp`)——
⚠️ 有就**如实点出来**(⛔ 不静默吞掉:否则读者会以为"台账总共就这几件")。
⚠️ 未声明目标(`q.fp` 空)⇒ 服务端把全部算 legacy ⇒ 这里显示 `0 件` + 点明"未声明目标",
⛔ 不假装"队列为空"(那会把"没有目标"说成"没有活")。 */
var _qleg=(q.legacy||0);
tx(PX+18,R4.y+50, '队列 '+((q.total)||0)+' 件 · 已完成 '+((qb.done)||0)+' · 未完 '+((q.open)||0)
+(((qb.blocked)||0)?(' · 受阻 '+qb.blocked):'')
+(_qleg?(' (另有历史 '+_qleg+' 件,不计入)'):''), ((q.open)||0)?'n-warn':'n-idle');
/* 🔴 2026-10-03 订正(用户 09-30「协作程序 应该一直运行,改成 在线」):
状态词取 `pr.label`(服务端给)—— **正常态就两个字「在线」**,
⛔ 不堆"钩子 --tick · N 分钟前"那种细节长串(细节进 tooltip 的 `pr.how`)。
⛔ 但不放假绿:真没轮动就如实说「在线 · 已 N 分钟没轮」⇒ **不写「已停」**
—— 常驻真会被打断(换会话/宿主回收),停着是**真读数**,⛔ 不是"该常驻却被停"的故障。 */
/* 🔴🔴 2026-10-03 10:1x **新增「检查程序在不在跑」这一行**(用户报障逐字:
「协作程序这个时候应该显示 **有检查程序在运行**」)。
⚠️ 改前那格只有静态职责文案 + `pr.label`(在线/停)⇒ **完全看不到它刚建的检查会话跑到哪一步**,
图上看着像"什么都没发生"(实测:检查会话 `44b547d4` 早在 09:57 就 `working`,图上毫无反应)。
🔴 判据=**服务端给**的 `rt.check.n`(=`sessions.status='working'` 且标题带 `[结果检查]`/`[目标检查]`)
⇒ **与会话明细/看板亮灭同一个口径**,⛔ 前端不自己数(那会造出第二套判据)。
⚠️ `unknown`(读库失败)⇒ 如实说「检查状态读不到」,⛔ **不假装"没有检查在跑"**
(那会把机制故障说成正常 —— 同"降级不静默"那条红线)。 */
var ck=rt.check||{};
tx(PX+18,R4.y+92, ck.label||'检查状态未知', (ck.n||ck.unknown)?'n-ok':'n-idle');
/* 🔴🔴 2026-10-03 11:1x **检查会话画在这里**(常驻程序框内),⛔ **不进任务会话那一排**。
用户纠正逐字:「检查会话不是协作会话,不应该出现在看板协作会话区域中。
检查会话属于协作程序的会话(本来也是协作程序创建),可以放在协作程序框图中展示」。
⚠️ 判据=**服务端单列的 `sessions_checks`**(⛔ 不在前端按标题猜,
否则又会出现「前端猜一套、后端判另一套」的漂移)。
⚠️ `R4.h=112` 空间已满(基线 26/50/70/92)⇒ 这几行**画在框的下沿外侧**
(`R4.y+R4.h+16` 那一带,落在 R4↔R5 的走线带里)⇒ ⛔ 别再往框内塞第五行。
📌 几何判据:这几行的 y 必须 `< R4.y+R4.h+40`(实测 R5.y=R4.y+146 ⇒ 还有 ~30px 余量)。 */
var CHK=(d.sessions_checks||[]).filter(function(x){return x.status!=='archived'});
var _cy=R4.y+112; // 🔴 画在**框内**(R4 已加高到 146);⛔ 不用 R4.y+R4.h+18(那会落进走线带、压 R5)
if(CHK.length){
/* 2026-10-03 19:55x **只显示最新一条**,其余**移上去才看得到**(用户要求逐字:
「协作程序 只显示最新的一条会话情况就行,别的可以做到下拉列表中或者鼠标移动上去的时候 展示」)。
做法=SVG 原生 **title 子元素**(tx() 的新参数 tip)—— 不用 JS 事件、不用点、⛔ 不会点开又忘收。
底色:有在跑 => node-on(绿)/只有历史 => node-base(中性;"跑完了"是正常状态,不该染成危险色)。 */
var _anyRun=CHK.some(function(x){return x.status==='working'});
var _sorted=CHK.slice().sort(function(a,b){return (a.age_min||0)-(b.age_min||0);});
var _top=_sorted[0];
var _short=function(x){
var _raw=String(x.title||'');
var _pp=_raw.match(/\[[^\]]*\]/g);
var _tag=_pp&&_pp.length?('['+_pp[_pp.length-1].replace(/[\[\]]/g,'')+'] '):'';
var _body=_raw.replace(/^(\[[^\]]*\][\s\-]*)+/,'');
var _ct=String(x.cwd_tail||'');
if(_ct&&_body.indexOf(_ct)>=0) _body=_body.split(_ct).join('');
_body=_body.replace(/-{2,}/g,'-').replace(/^[\s\-]+|[\s\-]+$/g,'');
var t=_tag+_body;
// 20:06x 上限 24→16:条目前面加了「最新会话:」和时间,⛔ 再长会撞右边的列表图标
return t.length>16?t.slice(0,16)+'…':t;
};
o.push('<rect class="'+(_anyRun?'node-on':'node-base')+'" x="'+PX+'" y="'+_cy+'" width="'+PW+'" height="22" rx="6"'
+' fill="none" stroke="var(--color-border-subtle)" stroke-dasharray="3 3"></rect>');
var _tip='检查会话(检查程序建的)共 '+CHK.length+' 条\n\n'
+'最新:'+_top.title+'\n状态:'+(wordOf(_top.status))+' 最后活动:'+Math.round(_top.age_min||0)+' 分钟前';
if(_sorted.length>1){
_tip+='\n\n其余 '+(_sorted.length-1)+' 条:\n'+_sorted.slice(1).map(function(x){
return '· '+_short(x)+'('+wordOf(x.status)+','+Math.round(x.age_min||0)+' 分钟前)';
}).join('\n');
}
// 2026-10-03 20:06x 按用户要求排版:「**最新会话:xxx 时间**」+ 右边一个**列表图标**,
// **图标与文字都能悬停看全部历史**(用户逐字:「最新会话:XXXXXXXXXX 时间 列表ICON 鼠标移动上去看历史的就可以了」)
var _when=Math.round(_top.age_min||0);
var _mins=_when<60?(_when+' 分钟前'):(Math.round(_when/60)+' 小时前');
tx(PX+10,_cy+15, '检查会话', 'n-sub', null, _tip);
tx(PX+64,_cy+15, (_top.status==='working'?'● ':'')+'最新会话:'+_short(_top)+' '+_mins,
_top.status==='working'?'n-ok':'n-idle', null, _tip);
// 🔴 列表图标(≡):**SVG 原生悬停提示** ⇒ 鼠标移上去即出全部历史清单,⛔ 不用点
var _ix=PX+PW-36, _iy=_cy+5;
o.push('<g><title>'+esc(_tip)+'</title>'
+'<rect x="'+_ix+'" y="'+_iy+'" width="24" height="12" rx="3" fill="none"'
+' stroke="var(--color-text-tertiary)" stroke-width="1"></rect>'
+'<line x1="'+(PX+PW-30)+'" y1="'+(_iy+4)+'" x2="'+(PX+PW-18)+'" y2="'+(_iy+4)+'"'
+' stroke="var(--color-text-tertiary)" stroke-width="1"></line>'
+'<line x1="'+(PX+PW-30)+'" y1="'+(_iy+8)+'" x2="'+(PX+PW-18)+'" y2="'+(_iy+8)+'"'
+' stroke="var(--color-text-tertiary)" stroke-width="1"></line>'
+'</g>');
// ⚠️ 2026-10-03 20:06x 旧的那行条目(`PX+150`)**已删** —— 上面 1042 行才是它的替代
// (带「最新会话:」前缀 + 时间);⛔ 两条都留着会**画两遍**(20:05x 改版时漏删,本轮实查才发现)。
} else {
tx(PX+10,_cy+15, '检查会话:暂无在册(检查程序按需建)', 'n-idle');
}
tx(PX+18,R4.y+70, pr.label||'状态未知', pr.up?'n-ok':'n-bad');
/* 🔴🔴 2026-10-03 09:5x **删掉那行「队列投递已退役(曾叫『上报』)」小字** —— 用户原话逐字:
「队列投递已于 2026-10-03 退役(曾叫# 看板上不要写这些没用过的东西」。
⚠️ 上一版这里写「退役这件事**必须**留在图上(⛔ 不当没发生过)」——**与用户口径直接打架,已作废**。
⛔ 判据:**没用的东西不占版面**(与「说明只进 ?」同族)—— 讲历史的话不进图面。
⚠️ 下面关于 `gu`/`rt.deliver` 的说明**仍保留**(它讲「这组数据前端不消费」=现状,不是历史)。 */
/* ⚠️ `gu`(`rt.deliver`)**此格不再渲染** —— 它读的是 `_tick.stamp`(投递轮痕迹),
而投递已退役 ⇒ 那组读数只对"钩子还在跑 --tick"有意义,⛔ 不该占版面。
⛔ 变量留着(board.py 还供这组数据,将来重建收件人要用)⇒ **刻意不删引用以外的东西**。 */
// ③ 上报 → **跟进会话**(右侧竖井,⛔ 不横穿会话行)
// 🔴🔴 2026-10-01 用户口径:「换新会话 唤醒的是 跟进会话,**主会话只能是用户触发**」
// ⇒ 这条回程**改落右半那格**(原来落主会话)—— 与 `collabd.py::_deliver_str()` 的投递目标
// **必须同款**:图上画到哪、程序就得投到哪(⛔ 图说一套、程序做一套 = 看板说谎)。
var y4=R4.y+R4.h/2;
/* 🔴 2026-10-02(用户:「**上报 到 跟进的连线 改为和 唤醒 到 协作的连线 一样可以自适应**」):
通道 x **现算**(⛔ 不再写死 `CH=1254`)—— 与 ④ 那条**同款手法**(④ 取 `Math.min(...)`=最左,
本条取 `Math.max(...)`=最右):
· `UAx+UAw+28` :避开上面的分组大框 `UA`;
· `UBx+UBw+28` :避开**任务会话分组大框 `UB`** —— 🔴 **UB 宽度随会话数变**(`:929-932`):
`w` 触到底 `150` 时(约 8 条任务会话)它右边界会越过 `SX1` ⇒ **写死 1254 必被横穿**。
⛔ **不要退到 `SX1`**(🔴 2026-10-02 用户原话:「**上报 到 跟进的连线 不要那么开**」)——
会话格**全部落在 `UB` 框内**(`sx`/`total` 都在 UB 的包围盒里)⇒ **避开 `UB` 就等于避开了整排**;
退到 `SX1` 只会让水平段凭空拉长(1254 → 1038,白绕 216px)。
⚠️ 现状(2 条协作会话)下三者取最大仍是 `SX1+34` = **1254** ⇒ **线本身与改前逐字相同**(无回归);
它买的是**防御性**:会话变多时不再穿框(几何自检 ⑧ 段守着)。
⚠️ 配套:标签 x 也**跟线现算**(=水平段中点),⛔ 不再写死 `1080`(旧值偏左、线一动就飘)。
⚠️ 待办(另记):`viewBox` 仍是写死的 `1280` —— 会话极多时 `RKX` 会超出画布右缘被裁,
届时**须一并放宽 viewBox**(本轮不改,避免扩大范围)。 */
/* 🔴🔴 2026-10-02(用户原话:「**上报 到 跟进的线 折的方向不对**,参考 **唤醒 到 协作连线** **镜像**往右侧连」):
真因=旧式 `Math.max(UAx+UAw+28, UBx+UBw+28)` **缺了"源框(上报框)右侧"这一项** ——
它只保证"通道在**别的框**右边",却**不管源框自己**。实测(`chrome --headless=new --dump-dom`
读渲染后真实坐标,当日 1 条任务会话):`UA` x=319 w=637 ⇒ 右界 956(+28=984);
`UB` x=452 w=376 ⇒ 右界 828(+28=856)⇒ 旧 `RKX=max(984,856)=984`,
而**上报框自身右边界=`UX+PW`=1000** ⇒ 通道落在源框**之内** ⇒ 第一段折线
**984−1000=−16px ⇒ 向左** =用户看到的"方向反了"。
✅ 镜像 ④:④ 用 `Math.min(PX-80, …)`(目标框**左**边再让 80)⇒ 本条镜像成
`(UX+PW)+80`(**源框右**边再让 80)。补上 ⇒ `RKX=max(1000+80,984,856)`=**1080**
⇒ 第一段 **+80px 向右**、末段 **144px 向左**;④ 则是 144 左 / 80 右 —— **逐段对称**。
⚠️ 通道取 `max` ⇒ 会话变多、`UB` 右界超过 `1080` 时通道自动右移让位(不会穿框)。 */
/* 🔴🔴 2026-10-03 **本段整体退役,变量一并删除**(用户 07:20「不是 协作程序常驻吗」):
原来这里是「③ 上报 → 跟进会话」那条右侧竖井的**通道计算**,两端(`UX` 右格、`RW` 唤醒行)
在 10-03 07:0x 那轮**都真删了** ⇒ `RKX` 这个变量**已无任何消费者**(保留就是死变量,
且它的表达式还引用已删的 `UX` ⇒ **一改就会 JS 报错**)。
✅ **处置=连注释带变量一起删**(不是留个注释占位)。
⚠️ 原文已随历史备份一并清理,⛔ 要恢复得**按本段描述重写**(`UX`=空态文案节点;`RKX`=右侧小字位)。
⚠️ 曾经踩过的坑(留着提醒):①「上报 → 跟进」折线方向反了 —— 真因是
`Math.max(UAx+UAw+28, …)` **缺"源框自己右边"这一项** ⇒ 通道落在源框**之内** ⇒
第一段算出 −16px(向左)=用户看到的"方向反了";②「唤醒 → 协作」那条线**从已删格出发**
⇒ 线头悬在空处。⇒ **判据:线段的每一端都必须真实存在**(P0-25 同族:画出来就是骗)。 */
// ── ⑤ 🔴 **Hook进程** —— 用户 2026-09-30 指出「图里是不是还缺少关键节点,比如宿主起的子进程」。
// 它**才是上报的真正执行者**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退 ⇒ **事件驱动**。
// ⚠️ 时间驱动的那些(如「唤醒」)**不在这一行**,画在 `RW` 行左格(见上)—— 两者不是一回事:
// 整机没活动时 Hook 不会被唤起,「唤醒」补的正是这个缺口。
var hasTG=TG.length>0;
/* 🔴 2026-10-01(用户:「**Hook进程 框图宽度往右边延长一些,让 tick 那条线能竖直摆放**」)
⇒ 框宽 480 → **490**(右边界 880 → 890)。
⚠️ 加宽**不是为了好看**,是为了让 `--tick` 的出口点能落在**框顶边之内**(见下方 TICKX)——
⛔ 改宽度时别把它缩回去,否则出口点会跑到框外、线头悬空。
🔴🔴 2026-10-03 **左边界被我误改过(已还原为 400)**:
我曾为给 ④ 那条竖直线腾位置把它 400 → 430,用户指正「**还把 hook 图框挤到旁边
多难看**」⇒ ④ 改成**折线绕行**(见下)后,**本框位置恢复原样**。
📌 **教训(比那条线本身重要)**:**框的位置是它自己的版面决策**,
⛔ 不许为了迁就某条连线去动别人的框 —— 要动的是那条线(P0-25 同族:
**线段的每一端都必须真实存在**,但反过来也成立:**不许为凑一条线而挪框**)。 */
var hkX=400, hkW=490;
rect(hkX,R5.y,hkW,R5.h,'on');
tx(hkX+20,R5.y+26,'Hook进程','n-title');
tx(hkX+20,R5.y+48, fitText('WorkBuddy 在特定时机喊它 · 干完就退', hkW-36, 11, 6), 'n-sub');
tx(hkX+20,R5.y+66, fitText('它是 WorkBuddy 的子进程 ⇒ 拿得到网关口令', hkW-36, 11, 6), 'n-sub');
/* 🔴🔴 2026-10-03 **改掉一句已经说假的说明**(用户「协作程序相关的 连线 有用的保留
没用的可以去掉」⇒ 我逐条去核实这三条线还活着吗,撞出下面这件更大的事):
原写「两个模式:--once(**读队列**)/ --tick(补检查排期)」——
🔴 「读队列」是**投递时代**的职责,`collabd.py` 头部已注明 `--once` **⛔ 不投递**
(投递 10-03 整体退役)⇒ 这行字在讲一个**不存在的活**(同族红线:写出来就是骗)。
✅ 改成现在的真实分工(源码 `--once` / `--tick` 两段注释逐字对齐):
`--once`=读库判定+信号+看板;`--tick`=顺手续命常驻+结束即建检查会话。
⚠️ 改这里必须**先读 `collabd.py` 的头部注释**,⛔ 别照着旧文案改(那正是它说假话的来源)。 */
// 🔴🔴 2026-10-05 `--tick` **已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick once
// 去触发?」)⇒ 建排期/park 探针**全搬进常驻主循环** ⇒ 钩子**只剩一条**出口=`--ensure`
// (顺手续命)。⛔ 图上⛔ 不许再画 `--tick`:机制没了还画着就是**骗**(画出来=说谎)。
tx(hkX+20,R5.y+84, fitText('两个入口:--once(读判定出信号)/ --ensure(续命常驻;判定由常驻自己做)', hkW-36, 11, 6), 'n-sub');
// WorkBuddy → 钩子(它在钩子时机起它)
edge('M'+(hkX+70)+' '+RB.y+' L'+(hkX+70)+' '+(R5.y+R5.h), true);
lbl(hkX+82, (RB.y+R5.y+R5.h)/2, '用时才喊它', 'start');
// 钩子 → 常驻程序(--once):直接向上
edge('M'+(hkX+40)+' '+(R5.y)+' L'+(hkX+40)+' '+(R4.y+R4.h), true);
lbl(hkX+52, (R5.y+R4.y+R4.h)/2, '--once', 'start');
/* 钩子 → 常驻程序(--ensure):🔴 **一条纯竖直的线**(用户 2026-10-01:
「Hook进程 框图宽度往右边延长一些,**让 tick 那条线能竖直摆放**」)。
🔴🔴 2026-10-05 **标签 `--tick` → `--ensure`**:`--tick` 机制已删,这条线现在代表的
是**续命**(`maybe_ensure_supervise()` ⇒ `collabd.py --ensure`)。
⛔ 坐标**一律不动**(只是换标签)⇒ 下面这段几何推算**仍然成立**,⛔ 别重算。
🔴🔴 2026-10-03 **两处改算**(沿用至今):
① 原式 `TICKX=UX+PW/2`(上报框中心 850),而 `UX` 那个右格随「两格合一」**已删**
⇒ 现在取**常驻程序框中心 + 120 偏右**(`PX+PW/2+120`=760)。
② ⛔ 偏右是为了和 `--once`(`hkX+40`=470)**不重叠**(实测间隔 290px)。
⚠️ **出口 x 必须落在 Hook 框顶边之内** —— 🔴 2026-10-03 `hkX` 已 400→430(见 ⑤ 段),
故范围是 `hkX..hkX+hkW`=**430..920**,760 在其中 ✔。
⚠️ 改 `hkX/hkW/PX/PW` 时**必须一起验这条**:出口 x 若跑出该范围,线头就悬在框外
(同族红线:**线段的每一端都必须真实存在**,P0-25)。 */
var TICKX=PX+PW/2+120;
edge('M'+TICKX+' '+R5.y+' L'+TICKX+' '+(R4.y+R4.h), true);
lbl(TICKX+12, B3+4, '--ensure', 'start');
// ── 最底一条:WorkBuddy
// (用户 2026-09-30:「**宿主地基 这个就叫 WorkBuddy** 不要用这些AI语言」⇒ ⛔ 不用自造词)
rect(SX0,RB.y,SX1-SX0,RB.h,'base');
tx(SX0+20,RB.y+33,'WorkBuddy · 只读它的数据库 / 排期 / 钩子 / 文件占用登记','n-sub');
// ✅ 折线几何(全部现算,⛔ 不写死):`RXV=PX-40`(框外左侧竖直通道)↑ 到 `RYY`
// (= 常驻程序框**垂直中点**)→ 右折到 `RXH=PX` ⇒ **终点落在框的左边**,箭头朝右。
// ⚠️ 两条要求同时成立的原因:`RXV=PX-40=365 < hkX=400`(⛔ 竖直段在 Hook 框左边)
// 且 `RXH=PX=405` 恰在框左边上(✅ 连对目标)。
// 🔴🔴 2026-10-03 **三改=终点改连「左边」**(用户原话:「**线连到左边不行 非的连到下边
// 是咋想的**」⇒ **我上一版绕了左边却仍从下边钻进去,那是白绕**):
// 🔴 上一版路径 `(365,850)→(365,668)→(495,668)→(495,684)` —— 最后一段**向下**钻框底边
// ⇒ 视觉上「先往左绕、再退回下边进」,**那 130px 横移毫无意义**
// (同族红线:**线的每一段都得有理由**,无意义的段=自己骗自己)。
// ✅ 既然通道本来就在左边,**终点就该直接落在左边框上**(`RXH=PX`);
// `RYY` 取框**垂直中点**(⛔ 别写死 y,改 `R4` 高度自动跟随)⇒ 折线只剩**两段**。
var RXV=PX-40, RXH=PX, RYY=R4.y+(R4.h/2);
edge('M'+RXV+' '+RB.y+' L'+RXV+' '+RYY+' L'+RXH+' '+RYY, true);
// ⚠️ 标签贴竖直段**左边**(anchor end)—— 放右边会和「在钩子时机起它」挤成一行连读。
lbl(RXV-14, RB.y-14, '④ 收结果 · 读 WorkBuddy 库', 'end');
o.push('<defs><marker id="ar" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
+'<path d="M2 1L8 5L2 9" fill="none" stroke="var(--color-accent)" stroke-width="1.5" stroke-linecap="round"/></marker></defs>');
document.getElementById('arch').innerHTML=o.join('');
/* ══════════════════════════════════════════════════════════════════════════
🔴 2026-10-01(用户:「**这段乱七八糟的 也精简 分段落描述**」)
原版把八九条说明用 ` · ` 串成**一整行**(还要加上面那行静态小字),读起来是一堵墙 ——
看不出哪句在说哪件事。⇒ 改成 **分块 + 一行一条**:
· 「连线」 —— ①→④ 与队列**分别是什么意思**(静态语义)
· 「当前」 —— **实时读数**(每次刷新会变的那几项)
· 「<唤醒会话名>」 —— 那一格**和别的是什么关系**(仅当画了它)
⛔ 只**拆句、删冗余**,⛔ **不新增信息**;与右上角 `?` 重复的那段("第三层横着排/
空框占位")就在这里去掉 —— 它本来就是 `?` 里的内容,露两遍反而像两回事。
══════════════════════════════════════════════════════════════════════════ */
function _blk(lead, items){
items=(items||[]).filter(function(x){return !!x});
if(!items.length) return '';
return '<div class="anBlock"><div class="anLead">'+lead+'</div>'
+items.map(function(x){return '<div class="anItem">'+x+'</div>'}).join('')+'</div>';
}
/* ── 块①:当前(**实时读数**)──
🔴 2026-10-01 用户:「**连线 · 图上那几条线分别是什么** 这段话不需要,**看框架图就行了**」
⇒ 删掉原来那块 ①派活/②上报/③上报/④收结果/队列 的图例 —— 那几条线**图上本来就标了字**
(`① 派活 · 自动化排期` 等 SVG 标签就在线旁边),图外再列一遍是**重复**。
⚠️ 但其中"**① 是唯一能开新会话的通道**"这一条**图上看不出来**(是硬约束,不是线名)
⇒ 它搬进「协作架构」右上角 `?`,⛔ 不丢(同族红线:读到了就要说)。 */
var _now=[
/* 🔴 2026-10-01 **修一处会说出假话的判据**(与刚删掉的那行同源,一并治)。
原来只看 `TOPS.length` —— 而 `TOPS` 走的是 `board.py::_goal_topics()`,
**它会在"没声明过"时回落到 `[goal.short]`** ⇒ `TOPS.length` 恒 ≥1
⇒ 两个后果:❶ 从没在对话里说明过时,这里把**回落值当成"已声明的类别"**报出去
(同族红线:**没有却说有**);❷ 那句「⚠ 未在对话里说明过」实际上**永远轮不到**=死分支。
⇒ 判据改成 **`topics_source.kind`**(`declared` / `fallback` / `none`),与顶部
「本项目」卡的 chips 用**同一个字段**(⛔ 否则版面上两处口径又会打架)。 */
'主会话<b>负责的任务类别</b>:'+((TSRC.kind==='declared' && TOPS.length)
? TOPS.map(function(t){var s=MBT[t]||''
return '<code>'+esc(t)+'</code>'+(s?('('+esc(s)+')'):'(⚠ 无主会话)')}).join('、')
: '⚠ <b>还没在对话里说明过</b>(现按目标简称走单类别 ⇒ ⛔ 别当成已声明)'),
'任务会话:在跑 <b>'+runningS.length+'</b> 个 · 第三层画 <b>'+cells.length+'</b> 格'
+((moreW>0)?('(另有 <b>'+moreW+'</b> 条未画)'):'')
+(cells.length?'':'(一条都没有 ⇒ 画空框占位)'),
'本项目会话共 <b>'+S.length+'</b> 个(含历史)· 明细见下方「会话」表',
/* 🔴 2026-10-01:**同工作区但没归入本项目**的会话 —— 数据披露,⛔ 不许静默丢。
⚠️ 用 `is-warn` 的语气说,但**措辞不许判它是本项目**(归属判据没变,它确实不算)。
位置说明:这是**机制层**的问题(棒命名不合规 ⇒ 会被漏管),所以留在**版面上**、⛔ 不进 `?`。 */
/* ⚠️ **只把"在跑的 / 近 3 小时的"摊在版面上**(用户反复要求"精简")——
陈年历史(早已 completed 的旧棒)**只计一个数**,⛔ 不铺半屏。
🔴 但**计数必须是全量**(⛔ 不许因为"它很旧"就少说一句)—— 这是同族红线的底线。 */
UNREC.length ? (function(){
var _hot=UNREC.filter(function(u){return u.status==='working'||Number(u.age_min||1e9)<=180});
var _show=_hot.slice(0,3);
return '⚠️ 本工作区另有 <b>'+UNREC.length+'</b> 条会话**按命名判据未归入本项目**'
+'(⛔ 不算本项目会话 ⇒ **会被漏管**):'
+(_show.length?('在跑/近 3 小时 <b>'+_hot.length+'</b> 条 —— '
+_show.map(function(u){return '<code>'+esc(u.id8)+'</code> '+esc(u.title)}).join(' · ')
+(_hot.length>_show.length?(' … 等 <b>'+_hot.length+'</b> 条'):''))
:'其中**近 3 小时内没有**(都是历史)')
+' · 其余 <b>'+(UNREC.length-_hot.length)+'</b> 条是历史'
+'(全量见 `board.json` 的 `sessions_unrecognized`)'
+' —— 成因:棒名须为 `[执行]-[<类别>]-<具体>`(第 2 级**带方括号**、'
+'值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称)';
})() : '',
/* 🔴 2026-10-01 加:**已退场/超窗口**的会话(用户:「接续会话的时候,如何处理过期的会话,
避免越堆越多」)。
⚠️ **这一条是"退场机制的可见性"**,⛔ 不能省 ——
收起而不说=读者把"收起了"读成"本来就没有"(同族红线:读到了却不说)。
⚠️ 措辞要点:① 说**不是删除**(否则读者会去库里找,找不到又成一条假情报)
② 说**窗口多少分钟**(数值随配置变,⛔ 不许写死在文案里)
③ 给**主动退场的那条命令**(接续棒收尾该做的事,写在版面上才有人做)。 */
((Number(RET.retired||0)+Number(RET.stale||0))>0) ? (function(){
var _r=RET.recent||[];
return '🧹 已收起 <b>'+(Number(RET.retired||0)+Number(RET.stale||0))+'</b> 条过期会话'
+'(显式退场 <b>'+Number(RET.retired||0)+'</b> + 超 '+String(RET.live_min||'?')
+' 分钟未动 <b>'+Number(RET.stale||0)+'</b>):'
+(_r.length?_r.map(function(u){return '<code>'+esc(u.id8)+'</code> '+esc(u.title)}).join(' · '):'')
+' —— ⛔ **不是删除**(宿主库里原样在,明细见 <code>retired.json</code>);'
+'接续棒收尾应执行 <code>collabd.py --retire self</code> 主动退场。';
})() : '',
// ⚠️ 「另有 N 个未画」是**数据披露**(⛔ 不许静默丢)⇒ 留在版面上;它随数据变,⛔ 不能进 `?`
(TG.length>1)?('另有 <b>'+(TG.length-1)+'</b> 个时间驱动源未画(图上只画最靠前的一个)'):'',
];
/* 🔴 「未归类」必须在**图外说清**(⛔ 不许静默丢):这些线⛔ 不属任何当前任务类别、
也⛔ 不画在第三层(那排只放任务会话)—— 但"几条线、多少件"必须说出口。 */
if(_fold) _now.push('<b>未归类</b>:<b>'+_lg.length+'</b> 条线('
+_lg.map(function(z){return '<code>'+esc(z.name)+'</code>'}).join('、')
+')不在当前任务类别清单里,共 <b>'+_fold.total+'</b> 件 · 已完成 <b>'+_fold.done+'</b>'
+'(⛔ 不是第三层的任务会话,明细见下方台账表)');
/* 🔴 2026-10-01 用户:「这个**不需要**吧,看框架图就行了」⇒ 原来那块「<唤醒会话名>」**整块删掉**。
它的三条定性(**独立会话**/⛔ **不在主会话上处理**/**随需求确定时才创建**)+
徽章色义(绿 ● 在跑 · 黄 ‖ 停着 · 灰 ○ 还没建)**一条都没丢** ——
全在「协作架构」右上角 `?` 里(那块本来就是小字说明的正式落点:
`agent-operating-rules`「⛔ 别往版面摊大段说明」)。
⛔ 也别"在图里再标一遍" —— 图上已经有格名、类别、状态色、位置这四样了。
⚠️ 形状改过两次(六边形 → 圆角矩形,靠**位置**区分)—— 那段历史留在 CSS 与本文注释里。 */
document.getElementById('archNote').innerHTML=_blk('当前', _now);
/* 标题提示:按"图上真画了哪几层"说,⛔ 不用自造词(用户 2026-09-30:
「宿主地基 这个就叫 WorkBuddy 不要用这些AI语言」) */
var _ah=document.getElementById('archHint');
if(_ah) _ah.textContent='用户 · 会话 · 任务会话 · 程序 · WorkBuddy';
}
function render(d){
D=d; document.getElementById('err').hidden=true;
var iv=(d.board&&d.board.refresh_interval)||3;
POLL=Math.max(1000, Math.round(iv*1000));
document.getElementById('iv').textContent=iv;
/* 🔴 本文件(布局/样式)改了就自动重载一次 —— 首次拿到当作基线,之后只在**变了**那一刻 reload。 */
var hs=(d.board&&d.board.html_sig)||'';
if(hs){
if(HTML_SIG===null) HTML_SIG=hs;
else if(HTML_SIG!==hs){ HTML_SIG=hs; location.reload(); return; }
}
/* ⏱「保留的快照时刻」只在这一处显示(协作架构右上角)⇒ ⛔ 不再往 header 放第二份 */
document.getElementById('ts2').textContent=(d.ts||'').slice(11)||'—';
paintAge();
var wb=document.getElementById('warnbar'), wn=(d.warn||[]);
if(wn.length){ wb.hidden=false; wb.textContent='⚠️ 有数据没读到(看板仍可用):'+wn.join(';'); }
else wb.hidden=true;
/* ⚠️ 2026-10-01:`renderProject(d)` **从这一行搬走了** —— 它属于"**随 tab 变**"的那一组,
统一由 `paintScope(d)` 在下面画(⛔ 这里再画一次会被立刻覆盖,白画)。 */
/* 🔴 「前置」整块**通用渲染** —— 技能侧⛔ 不认识任何具体组件。
内容全部来自**使用方**的 `board_ext.py`(经 board.json 的 `front`):
chips[{label,up,ok,bad,tip}] · paragraphs[{text,tone}] · tip
⛔ 技能里不许再出现某个项目的端口/路径/真名(用户 2026-09-30 定则:
「技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里」)。 */
/* 🔴 `deliver` = 旧名「监督程序」的那条职责(**上报**)。2026-09-30 改名。
🔴 2026-10-01 晚订正:常驻定案**回归** ⇒ 投递**应当一直运行**(`--supervise`),
⇒「停着」是**有效读数**(⛔ 不再是 09-30 那句"没有启动/停止这回事");钩子只作补充。 */
var f=d.front||{}, rt2=d.runtime||{}, pg=rt2.prog||{}, gd=rt2.deliver||{};
var chips='';
(f.chips||[]).forEach(function(c){
chips+=chip(String(c.label||'')+(c.up?' '+(c.ok||''):' '+(c.bad||'')),
c.up?'is-ok':'is-bad', c.tip||'');
});
var paras='';
(f.paragraphs||[]).forEach(function(p){
var t=String(p.text||'');
paras+='<p class="lede" style="margin:8px 0 0'+(p.tone==='warn'?';color:var(--color-warning)':'')+'">'+esc(t)+'</p>';
});
document.getElementById('front').innerHTML='<div class="rowchips">'
+chip('检查程序 '+(pg.label||'未知'), pg.up?'is-ok':'is-bad')
+chip('常驻 '+(gd.label||'未知'), gd.up?'is-ok':'is-warn')
+'</div>'
+(chips?'<div class="rowchips" style="margin-top:8px">'+chips+'</div>':'')
+paras
+((f._missing||f._err)?('<p class="lede" style="margin:8px 0 0">看板扩展:'+esc(String(f._missing||f._err))+'</p>'):'');
/* 🔴 长解释一律进「?」—— ⛔ 不摊在版面上(用户 2026-09-30 要求) */
var tip=document.getElementById('frontTip');
if(tip){
if(!tip.dataset.base) tip.dataset.base=tip.innerHTML; /* 首轮把静态文案存下来 */
tip.innerHTML=tip.dataset.base; /* 每轮先还原,防重复追加 */
if(f.tip) tip.innerHTML+='<br><br>'+String(f.tip); /* 项目侧说明(允许含标签) */
if(gd.stopped && gd.reason){
tip.innerHTML+='<br><br><b>投递为什么是停的</b>(主会话主动止损,不是崩溃):'
+esc(String(gd.reason)).replace(/\*\*/g,'').replace(/`/g,'');
}
}
/* 板块标题与类别标签也由使用方给(⛔ 不在技能里写死"链路前置/组件状态") */
var fh=document.querySelector('#frontCard h2');
if(fh) fh.innerHTML=esc(f.title||'前置')+(f.tag?('<span class="hint">'+esc(f.tag)+'</span>'):'');
var n=d.notify||{};
// 🔴🔴 2026-10-03 09:5x 用户口径「看板上不要写这些没用过的东西」⇒ **「已退役/曾叫」这类话一律不上图面**。
// ⛔ 卡片**保留**(删卡会动 cols-3 栅格、版面留空),正文只留**当前规则**,⛔ 不讲历史。
var _retired = (n.phase==='retired' || (d.meta&&d.meta.deliver_retired));
document.getElementById('notify').innerHTML= _retired
? emptyBox('队列通知(当前无自动通知)',
'队列变化<b>不会自动通知任何人</b>。'
+'要落这件事,得<b>显式建一条任务会话</b>去读队列干活。')
: '<table><tr><th>本条待处理</th><td><code>'+esc(n.awaiting||'(无)')+'</code></td></tr>'
+'<tr><th>握手阶段</th><td>'+esc(n.phase||'—')+'</td></tr>'
+'<tr><th>最近发出</th><td><span class="ellip"><code>'+esc(n.sent||n.notice||'(无)')+'</code></span></td></tr></table>';
// 🗑️ 2026-10-03 **删掉「最近上报 wakeups.jsonl」整块渲染**(卡片已在 HTML 里删除):
// `wakeups.jsonl` 已不再被写(实测文件不存在)⇒ 恒空 ⇒ 假面板。
// ⛔ `d.wakeups` 由 board.py 继续供:它是"判定是否退役"的另一条证据链,删它要动 board.py 两处,
// 且别的线(手机接入退役前)也可能读它 ⇒ **前端不消费、后端保留**。
/* 🔴 剩下的四块(本项目 / 架构图 / 需求台账 / 会话明细)**随 tab 变** ⇒ 交给 `paintScope(d)`。
⛔ 不在这里画 —— 这里画的是**工作区级**的(前置 / 队列通知)。 */
paintScope(d);
}
/* ══════════════════════════════════════════════════════════════════════════════
🔴🔴 多目标 tab(用户 2026-10-01:「把协作实时看板改为 tab 支持多个目标执行协作状态展示」)
数据:快照的 `goals[]`(`board.py` 产)—— **一格 = 一个目标**,活跃的排第一。
⛔ 老快照(没有 `goals` 键)⇒ 回落成"一格 tab",**行为与旧版一致**(向后兼容)。
作用域:切 tab **只换四块**(本项目 / 协作架构 / 需求台账 / 会话明细);
「前置 / 队列通知」是**工作区级**的 ⇒ ⛔ 不随 tab 变(它们本就不属于某一个目标)。
记住选择:当前 tab 写进 URL hash(`#g=<目标 key>`)⇒ **刷新、分享链接都不丢**当前选的是哪个目标。
⛔ 此处**不含任何项目知识**(目标名一律来自快照)—— 同 `board_ext` 那条职责边界。
══════════════════════════════════════════════════════════════════════════════ */
var GIDX=0;
function goalKey(g){
return String((g&&(g.key||(g.goal&&(g.goal.short||g.goal.title))))||'');
}
function pickGoalIdx(gs){
var m=String(location.hash||'').match(/[#&]g=([^&]*)/);
if(!m) return 0;
var k='';
try{ k=decodeURIComponent(m[1]); }catch(e){ k=m[1]; }
for(var i=0;i<gs.length;i++) if(goalKey(gs[i])===k) return i;
return 0; /* ⛔ hash 里的目标没了 ⇒ 回到第一个,不报错 */
}
/* 一格 tab = 目标名 + 状态徽章 + 完成情况摘要(都是实读,⛔ 不写死)。 */
/* 🔴🔴 2026-10-04 用户定案:「**最多显示 6 个目标,超出的用下拉菜单显示**」
⇒ 前6 格照常;第 7 个起收进一个下拉(点开列出全部超出项,可直接切换)。
🔴 2026-10-06 用户改口径:「**tab 的排列顺序应该按创建时间顺序**」(推翻 10-04 的活跃度排序)。
⚠️ 排序由**后端**做(`board.py` 按 `tab_created`=`goal.json` 的 `declared_at` 升序
⇒ **最早创建的目标在最前**,顺序**不随活跃度漂**),
本函数⛔ 不再自己排序(⛔ 两处排序 = 改一处忘另一处必打架,同 P0-40)。
⚠️ 超出数用**后端给的 `tab_more`**(⛔ 不在前端重算 `gs.length-6`:
那是把"业务规则"复制一份;后端已算好,且它知道排序后的真实分界)。 */
var TAB_MAX = 6; /* 🔴 上限常量:⛔ 别在下面再写一个 6(两处数字必漂) */
function renderTabs(gs){
var box=document.getElementById('tabs');
if(!box) return;
if(!gs.length){ box.hidden=true; return; }
box.hidden=false;
var more=Math.max(0, gs.length-TAB_MAX);
var shown=more?gs.slice(0,TAB_MAX):gs;
box.innerHTML=shown.map(function(g,i){
var acc=(g.goal||{}).acceptance||{}, ks=Object.keys(acc);
/* 🔴 2026-10-02 同批修(**同族第 2 处,差点漏掉**):tab 摘要这里原本**另写了一份**英文判据
`String(acc[k]).toLowerCase()==='pass'`(变量名是 `ks` 不是 `keys`,所以按 `keys` 那条锚点搜**搜不到**)
⇒ 恒算 `0/N 通过` + 徽章恒 `is-bad`。⇒ **一个文件里同一条规则有两份实现** 就是病灶:
统一改走上面那个 `accIsPass()`,⛔ 不要再就地内联判据。 */
var pass=ks.filter(function(k){return accIsPass(acc[k])}).length;
var cls=!ks.length?'is-warn':(pass===ks.length?'is-ok':'is-bad');
var txt=!ks.length?'未声明判据':(pass+'/'+ks.length+' 通过');
/* 🔴🔴 2026-10-03 19:1x **标签与内容对齐**(用户报障逐字:「**为什么目标已完成 下面的目标状态还是不对**」)。
真因=**名不符**:第二行的标签写「目标状态」,内容却是**判据通过数**(`4/4 通过`)
⇒ 目标早就标完成了,读者在第二行看到的却还是判据数 ⇒ "不对"。
✅ 改法(两个数据源各归各位,⛔ 不互相冒充):
· **判据通过数 → 挪到第一行的徽章**(它是"判据",⛔ 不是"状态");
· 第二行「目标状态」**真显示生命周期**(`已完成`/`进行中`/`已停`),由快照的 `life` 提供。 */
var lifeS=String(g.life||''),
lifeTxt=lifeS||(g.paused?'已停':'进行中'),
lifeCls=lifeS.indexOf('已完成')>=0?'is-ok':'is-warn';
/* 🔴 2026-10-01(用户:「再确认下**目标名称**显示在哪里的,没看到呢」)
⇒ 根因是我上一版**第一行只写了目标简称**(`short`),全名只藏在 `title=` 提示里
—— 简称认不出是"哪个目标"。**改:第一行就是完整目标名**(超出省略 + 悬停看全),
简称退到第二行当小字前缀。⛔ 名字一律来自快照,技能侧不写死任何目标名。
⚠️ **2026-10-02 订正**:上面那句「简称退到第二行当小字前缀」**已作废**(用户要第二行改成状态标签,见下)。
第一行的结论不变(完整目标名 = 第一行),⛔ 不要因为第二行改了又把名字挪回去。 */
/* 🔴 2026-10-02(用户逐字:「看板的TAB选项里,有一项 本机协作 也改为 目标状态」)
⇒ tab 第二行⛔ **不再显示目标简称**,改成**固定标签「目标状态」** + 状态徽章。
真因:简称(`goal.short`)是"这个项目自己起的名字",摊在第二行会被当成**名字**读,
而这一行的本职是**说状态**("6/7 通过")—— 名字已经在第一行写全了,第二行不必再重复一遍。
⚠️ 简称**照旧来自快照**(`goal.short` 一个字没删、仍是目标 key 的回落),
只是**这一格不再展示**;该简称的正式落点=顶部「目标」行那颗 pill(`renderProject()` 内,未动)。
⛔ 原文留痕(已作废,不再生效):
var short=String((g.goal&&g.goal.short)||'');
+'<span class="tsub">'+(short&&short!==full?esc(short)+' · ':'')+chip(txt,cls)+'</span>' */
var full=String((g.goal&&(g.goal.title||g.goal.short))||'(未声明目标)');
return '<button type="button" class="tab'+(i===GIDX?' is-on':'')+'" data-gi="'+i+'" role="tab"'
+' aria-selected="'+(i===GIDX?'true':'false')+'" title="目标:'+esc(full)+'">'
+'<span class="tt">'
/* ⚠️ 2026-10-03 20:31x **删掉「当前」徽章**(用户:「什么 当前 …… 没用的东西不要显示」)
—— 它表的是"这是当前选中的 tab",而 tab 本身已有 `is-on` 高亮 ⇒ **纯冗余**,⛔ 白占版面。 */
/* 🔴🔴 2026-10-03 15:45x **跨工作区 tab**(用户报障:「tab 不能切换看另外两个工作区的目标」)
⇒ 来自其它工作区的目标**必须标出是哪个工作区**,否则三个 tab 标题一样、读者分不清。
⚠️ 标记一律用**快照给的 `peer`**(=工作区目录名),技能侧⛔ 不写死任何工作区名。
⚠️ 这是**只读**的并列查看:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态。 */
/* 🔴🔴 2026-10-03 18:5x **「目标已完成」徽章**(用户报障:「主会话说目标都已完成,⛔ 为什么看板上还是没完成」)
⇒ `collabd.py --set-life 已完成` 写的是 **`lifecycle`** 字段,
而看板原先**没有任何展示位**读它(tab 第二行那个是**判据通过数**,不是生命周期)⇒ 永远看不到。
⚠️ 与下面那行判据通过数**并存、互不覆盖** —— 两者是两个数据源
(一个是主会话标的生命周期,一个是判据算出来的)⇒ ⛔ **不许用一个冒充另一个**:
真出现「已完成但判据不全」时,**两个都摆出来**才是如实的。 */
+(lifeTxt?'<span class="badge '+lifeCls+'">'+esc(lifeTxt)+'</span>':'')
+(g.paused?'<span class="badge is-warn">已停</span>':'')+esc(full)+'</span>'
+'<span class="tsub">'
/* ⚠️ 2026-10-03 20:31x 顺序=**状态在前、名称在后**(用户:「你要是喜欢 状态放前面
那就把工作区名称 放在后面」)⇒ 与第一行「目标状态 + 目标名称」**同款句式**,
⛔ 不让第二行反着来(两行读法不一致=读者要重新适应一次)。
⚠️ 名称用**真实名**(后端 `ws_name`)+**大字体**;⛔ 本区不再兜底成"本工作区"(占位词)。 */
/* ⚠️ 判据徽章与**目标状态徽章同款**(用户:「判据的状态的 字体和边框 和 目标状态一致」)
⇒ ⛔ 原来用的是 `chip()`(另一套 class ⇒ 字体/边框都不一样,看着像两种东西)
⇒ 改用**同一个 `.badge`** ⇒ 字体、边框、圆角**天然一致**,⛔ 不再手工对齐两套样式。 */
+(ks.length?('<span class="badge '+cls+'">'+esc(txt)+'</span>')
:'<span class="badge is-warn">未声明判据</span>')
+'<b class="wsname">'+esc(g.ws_name||g.peer||'')+'</b></span>'
+'</button>';
}).join('')
/* 🔴🔴 2026-10-04 超出部分收进下拉(用户定案:「最多显示 6 个目标,超出的用下拉菜单显示」)。
⚠️ 为什么放最后一块而不是挤进前 6 格:超出的目标**常常正是刚启动的**(最新在跑)⇒
用户要一键切到它,下拉里能直接点选,不必先记住它在第几格。 */
+ (more? ('<div class="tabwrap">'
+'<button type="button" class="tab tabmore-btn" id="tabMoreBtn"'
+' aria-haspopup="listbox" aria-expanded="false" title="还有 '+more+' 个目标(下拉选择)">'
+'<span class="tt"><span class="badge is-warn">更多</span>还有 '+more+' 个目标</span>'
+'<span class="tsub">点此展开<svg width="10" height="6" viewBox="0 0 10 6" aria-hidden="true">'
+'<path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></span>'
+'</button>'
+'<div class="tabmenu" id="tabMoreMenu" role="listbox" hidden>'
+ gs.slice(TAB_MAX).map(function(g){
var f=String((g.goal&&(g.goal.title||g.goal.short))||'(未声明目标)');
return '<button type="button" class="tabmenu-item" data-gi="'+gs.indexOf(g)+'" role="option"'
+' title="目标:'+esc(f)+'">'
+'<b class="wsname">'+esc(g.ws_name||g.peer||'')+'</b>'
+'<span class="tt">'+esc(f)+'</span></button>';
}).join('')
+'</div></div>') : '')
;
/* 🔴 2026-10-01(用户:「**下面这段话是干啥的,为什么也在 tab 区域**」)——
**tab 条上⛔ 不放任何说明文字**。上一版在两格之间塞了一句「只登记了 1 个目标 ⇒ 只有一格 /
要并行看第二个目标:在 goals/ 里放一份 …json」—— 那是**使用说明**,不是**数据**,
摊在版面上就成了噪声(同族老毛病:把小字说明写到版面上)。
⇒ **删掉**;"怎么加第二个目标"搬进「本项目」右上角 `?` 里(见 projCard 的 qtip)。
⛔ 也别退回「共 N 个目标」那种话 —— 有几格**看格子就知道**,写出来信息量为零。 */
[].forEach.call(box.querySelectorAll('.tab[data-gi]'), function(b){
b.onclick=function(){
var i=Number(b.dataset.gi)||0;
if(i>=gs.length) return;
var k=goalKey(gs[i]);
location.hash='g='+encodeURIComponent(k);
GIDX=i;
var mn=document.getElementById('tabMoreMenu');
if(mn) mn.hidden=true; /* 选完自动收起(⛔ 否则菜单悬着挡版面) */
if(D) paintScope(D); /* ⛔ 不重新拉数据 ⇒ 切 tab 是**零延迟零请求**的 */
};
});
/* 下拉开合(⛔ 用 hidden 属性,不引第三方;点外面/Esc 收起) */
var mb=document.getElementById('tabMoreBtn'), mn=document.getElementById('tabMoreMenu');
if(mb&&mn){
mb.onclick=function(e){
e.stopPropagation();
var open=!mn.hidden;
mn.hidden=open;
mb.setAttribute('aria-expanded', open?'false':'true');
};
mn.onclick=function(e){ e.stopPropagation(); };
document.addEventListener('click', function(){ mn.hidden=true; mb.setAttribute('aria-expanded','false'); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape'){ mn.hidden=true; mb.setAttribute('aria-expanded','false'); } });
}
}
/* 把"当前目标"那一段投影成顶层字段的形状 ⇒ 下面四个渲染器**原样复用**(⛔ 不复制代码)。 */
function scopeView(d,g){
if(!g||g===d) return d;
var v={}, k;
for(k in d) if(Object.prototype.hasOwnProperty.call(d,k)) v[k]=d[k];
v.goal=g.goal||d.goal; v.project=g.project||d.project;
v.tasks=g.tasks||{}; v.sessions=g.sessions||[];
v.labor=g.labor||[]; v.orphan=g.orphan||{};
v.queue=g.queue||{}; v.progress=g.progress||{};
v.others_running=g.others_running;
/* 🔴🔴 2026-10-03 20:22x **补两个漏取字段**(用户报障:「为什么不同目标,协作程序框图里面的内容是一样的」)。
真因=**服务端数据是对的**(实测:本区块内 checks=4 条、两个 peer 块各 0 条),
但这里**只覆盖了 7 个字段** ⇒ 其余字段**原封不动沿用顶层 `d`**(=**本工作区**那份)
⇒ 切到别的 tab 时,「协作程序状态/检查状态/检查会话列表」全都显示**本工作区**的 ⇒ 看起来一样。
⚠️ 这就是「**加字段必须顺着数据流查到消费点**」的第三种翻法:前两处是"加了没渲染",
这一处是"**块里有、视图没取**"——⛔ 合并视图里的字段清单是**白名单**,漏一个就静默回落。 */
v.sessions_checks=g.sessions_checks||d.sessions_checks||[];
v.sessions_unrecognized=g.sessions_unrecognized||d.sessions_unrecognized||[];
v.sessions_retired=g.sessions_retired||d.sessions_retired||{};
v.runtime=g.runtime||d.runtime||{};
// 🔴 20:22x 补最后一项:**「完成情况」那一行**(数据驱动检查查出来的漏网之鱼)
v.acc_summary=g.acc_summary||d.acc_summary;
/* 🔴🔴 2026-10-05 12:0x 补 `ws_name` —— 与 10-03 那次**同族**的漏网字段。
真因(实测,不是猜):`renderProject(d)` 第 660 行读的是 **`d.ws_name`**(=所属工作区真实目录名,
用户要求「明确写出来,不搞『自己就不标』特例」),而 `d` 在第 1738 行=`scopeView(d,gs[GIDX])` 的产物
⇒ 该字段原先**不在视图白名单里** ⇒ 切到别的 tab 时**静默沿用本工作区的名字**
⇒ 「这个目标属于哪个工作区」在跨 tab 时显示错,与 10-03「几个 tab 看起来一样」是**同一个病根**。 */
v.ws_name=g.ws_name||d.ws_name;
return v;
}
/* 🔴 2026-10-03 16:1x **peer 格的来源说明**(⛔ 没有它 ⇒ 读者会误读)。
那一格是**只读概览**:下面每一块只来自**那个工作区自己的文件**,
⛔ 本看板不读它的进程、也**不拿本工作区的数据补位**(补位=把本区执行情况挂到别人名下)。
⇒ 所以必须明说"读到了什么、多旧、完整版在哪"。 */
function renderPeerNote(g){
var el=document.getElementById('peerNote');
if(!el){
el=document.createElement('div'); el.id='peerNote';
var tb=document.getElementById('tabs');
if(tb&&tb.parentNode) tb.parentNode.insertBefore(el, tb.nextSibling);
}
if(!el) return;
if(!g||!g.peer){ el.innerHTML=''; el.hidden=true; return; }
var ps=g.peer_scope||{};
el.hidden=false;
el.innerHTML='<div style="margin:0 0 14px;padding:10px 12px;border:1px dashed var(--color-warning);'
+'font-size:12px;color:var(--color-text-secondary);line-height:1.8">'
+'这一格是「<b>'+esc(g.peer)+'</b>」工作区的<b>只读概览</b>:下面每块只来自<b>它自己目录下的文件</b>,'
+'⛔ 不读它的进程、⛔ 不拿本工作区的数据补位。<br>'
+'台账文件:'+(ps.tasks_present?'<b>有</b>':'<b>暂无</b>(它还没有上报过任务)')
+' | 常驻状态文件:'+(ps.state_present?'<b>有</b>':'<b>暂无</b>(它的检查程序还没跑起来)')
+' | 常驻心跳:'+(ps.heartbeat_age_min!=null
?('<b>'+esc(String(ps.heartbeat_age_min))+' 分钟前</b>')
:'<b>查不到</b>(它的检查程序没在跑)')
+'<br>⇒ 要看它的<b>完整执行情况</b>,请打开<b>该工作区自己的看板</b>。</div>';
}
function paintScope(d){
/* ⛔ 老快照没有 `goals` ⇒ 回落成"一格 tab = 整份快照",行为与旧版一致。 */
var gs=(d.goals&&d.goals.length)?d.goals:[d];
GIDX=pickGoalIdx(gs);
if(GIDX<0||GIDX>=gs.length) GIDX=0;
renderTabs(gs);
renderPeerNote(gs[GIDX]);
var v=scopeView(d,gs[GIDX]);
renderProject(v);
renderTasks(v);
renderSessions(v);
drawArch(v);
}
/* 需求台账表(⛔ 随 tab 变 —— 台账条目按**任务类别**归属到目标)。 */
function renderTasks(d){
var LN={}, LK={}; (d.labor||[]).forEach(function(L){ LN[L.line]=L.name; LK[L.line]=L.kind||'topic' });
/* 🔴 台账条目的 `line` 现在语义=**任务类别**。旧数据里存的是**跨工作区时代的工作区名**
⇒ 列名与值对不上。⛔ 不隐藏、⛔ 不改历史记录,只**如实标一句**(同族红线:读到了就要说)。 */
var TP={}; (((d.project||{}).topics)||[]).forEach(function(t){ TP[t]=1 });
var tk=Object.keys(d.tasks||{}).sort();
document.getElementById('tasks').innerHTML = tk.length
? '<table><tr><th>需求</th><th>状态</th><th>任务类别</th></tr>'+tk.map(function(k){var v=d.tasks[k];
var cls=v.state==='done'?'is-ok':(v.state==='blocked'?'is-bad':'is-warn');
var _ln=v.line||'', _stale=(_ln && LK[_ln]==='legacy') || (_ln && !TP[_ln] && LK[_ln]!=='topic');
return '<tr><td><code>'+esc(k)+'</code></td><td>'+chip(zhState(v.state),cls)+'</td><td>'
+esc(LN[_ln]||_ln||'—')
+(_stale?'<span class="oldline">(旧值 · 不在当前任务类别清单)</span>':'')
+'</td></tr>'}).join('')+'</table>'
: emptyBox('台账为空','任务会话用 --report 把状态写进这里(四态:待执行/执行中/已完成/有阻碍)。');
}
/* 会话明细表(⛔ 随 tab 变 —— 会话按"标题里的任务类别"归属到目标)。 */
function renderSessions(d){
var ss=d.sessions||[];
document.getElementById('sess').innerHTML = (ss.length
? '<table><tr><th>会话</th><th>角色</th><th>任务类别</th><th>状态</th><th>活动</th></tr>'+ss.map(function(x){
return '<tr><td><code>'+esc(x.id8)+'</code> <span class="ellip">'+esc(x.title)+'</span></td>'
+'<td><span class="role'+(x.role==='主会话'?' is-main':'')+'">'+esc(x.role||'—')+'</span></td>'
+'<td>'+(x.topic?esc(x.topic):'—')+'</td><td>'
+(x.status==='working'?chip('执行中','is-ok'):chip(zhSess(x.status)))+'</td><td class="num">'+esc(x.age_min)+' 分钟前</td></tr>'}).join('')+'</table>'
: emptyBox('本项目暂无相关会话','只含主会话,和标题带 [任务类别] 的任务会话。'));
}
/* 数据延迟 = 现在 − 快照生成时刻(同机时钟可比)。⛔ 延迟不是错误 —— 是"看板不打扰程序"的代价。 */
function paintAge(){
if(!D||!D.epoch) return;
var s=Math.max(0, Math.round(Date.now()/1000 - D.epoch));
var el=document.getElementById('lag');
if(el) el.textContent=s+' 秒';
var w=document.getElementById('herometa'); /* 超 15 秒 ⇒ 整块转警示色 */
if(w) w.classList.toggle('is-old', s>15);
}
function load(){
if(!LIVE){
document.getElementById('err').hidden=false;
document.getElementById('err').textContent='要经本地服务打开:先跑 python board.py --serve 8788,再开 http://127.0.0.1:8788/(直接 file:// 打不开,取不到数据)';
return;
}
/* 🔴🔴 2026-10-05 **取数与渲染必须分开报**(用户报障:「快照读取失败:acc is not defined(服务还开着吗?)」):
当时两者共用一句「快照读取失败…服务还开着吗」⇒ 一个**渲染期**的 `ReferenceError`
被说成**服务挂了**,用户去重跑 `board.py`(⛔ 白费力,服务本来好的),
真正的 bug 反而被这句话盖住(同族 P0:**假归因 → 排查方向被带偏**)。
✅ 分成两段:`.then(render)` 里的异常 ⇒ "**页面渲染出错**"(附代码位置,与服务无关);
`fetch` 的异常 ⇒ 才是"取数失败"(这才该怀疑服务)。 */
var raw=null;
fetch('board.json?t='+Date.now(),{cache:'no-store'}).then(function(r){
if(!r.ok) throw new Error('HTTP '+r.status);
return r.json();
}).then(function(d){
raw=d;
try{ render(d); }
catch(e){
var el=document.getElementById('err'); el.hidden=false;
el.textContent='页面渲染出错(⛔ 与服务无关,服务是好的):'+(e&&e.message||e)
+' —— 请保留此信息报给维护者,⛔ 不用重跑 board.py';
throw e; /* ⛔ 别吞:控制台要留栈,方便定位 */
}
}).catch(function(e){
var el=document.getElementById('err'); el.hidden=false;
/* ⚠️ 只有"还没拿到快照"时才算取数失败;已拿到 ⇒ 上面那段已报过,别覆盖成误导信息 */
if(raw===null){
el.textContent='快照读取失败:'+(e&&e.message||e)+'(服务还开着吗?重跑 python board.py --serve 8788)';
}
});
}
/* 🔴 轮询节奏 = 服务的快照节奏(⛔ 页面不自行加频:加了只是空转,不会更快) */
var POLL=3000;
function loop(){ load(); setTimeout(loop, POLL); }
initStyleSwitcher();
if(LIVE){ loop(); setInterval(paintAge,1000); } else { load(); }
</script>
</body>
</html>