session-mechanism:两个看板整包搬进 boards/(页面与脚本同处一包)

用户令:「把这两个看板都纳入会话技能中,分两个看板文件夹,开个 boards 文件夹放两个看板」
「1 boards 2 A」(文件夹名 boards;本区部署与计划任务本轮一起改)

## 分层(新增第三层)
  scripts/  = 可执行程序(被计划任务/hook 调)
  assets/   = 静态素材(样式、模板)
  boards/   = 自带页面的看板程序(一个看板一个文件夹,页面+脚本同处一包)

## 动了什么
* boards/会话看板/(6 文件):board.py / board_ext.py / board-launch.py /
  board-launch.py.tpl / board.html / board-render-probe.js(从 scripts/ 与 assets/ 搬入)
* boards/项目管理界面/(3 文件):project-board.py / project-board.html /
  project-board-launch.py(从工作区 交付物/项目管理界面/ 复制入)

## 搬迁带出的真缺陷(已修,非补丁)
* board.py:「与 collabd.py 同目录」这个前提搬走后不成立 ⇒ 原来会**静默**落到
  本地抄的那份判据(自测「两侧共用同一实现」判 FAIL,但看板照跑、肉眼看不出来)。
  改成候选表(同目录 → 包内 scripts/ → DSH_SKILL_ROOT/scripts/),并在兜底分支**写 stderr 留痕**。
* board.py:CFG_P 同样按 HERE 找配置 ⇒ 与 collabd.py::load_cfg() 对齐查找顺序。
* selftest.py:一个 H 指两处的假设(board.py 与 collabd.py 同目录)拆成 H/B 两参数;
  board.html / board.py 全部改走 BOARD_DIR / board_py()。

## 顺带修一条既有 FAIL(与搬迁无关)
* 判据锚点没跟文档改:supervise-persistence.md 措辞在 053a6a6 已改成
  「排期按点拉起主会话这条路不存在」,判据仍锚旧句 ⇒ 恒红。判据改成锚档内现存那句。

## 验收
* selftest:--filter 看板 PASS 110 / FAIL 0(报告型 1 条不计入);
  全量 PASS 91 / FAIL 1(同一条报告型,实质 FAIL 0)。
* 会话看板 board.py --out ⇒ 50515 字节快照,无「回落本地判据」告警(证明真从 collabd.py 取)。
* 项目管理界面 --once ⇒ 4 个项目全列出;HTTP / 200 · 54102 字节(=技能包真身那份)·
  /api/projects 出数据 · /healthz ok。
This commit is contained in:
admin committed 2026-10-14 15:39:41 +08:00
1 parent 5d16c6df70
commit 52566c79f9
16 files changed
+2044 -92

No files matched your search

+8 -7
View File
@@ -216,7 +216,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
| 启动器 | 服务的程序 | 进程内必须设 | ⛔ 缺了会怎样 | | 启动器 | 服务的程序 | 进程内必须设 | ⛔ 缺了会怎样 |
|---|---|---|---| |---|---|---|---|
| `scripts/supervise-launch.py` | 协作程序(`collabd.py --supervise`) | **`CODEBUDDY_CONFIG_DIR`** + `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **检查程序静默失效**(读 0 字节空库 ⇒ `no such table: sessions` ⇒ fail-safe 恒判"有会话"⇒ **再也不建检查会话**) | | `scripts/supervise-launch.py` | 协作程序(`collabd.py --supervise`) | **`CODEBUDDY_CONFIG_DIR`** + `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **检查程序静默失效**(读 0 字节空库 ⇒ `no such table: sessions` ⇒ fail-safe 恒判"有会话"⇒ **再也不建检查会话**) |
| `scripts/board-launch.py` | 看板(`board.py --serve 20099`) | `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **看板崩溃重启循环**(`已拒跑`)、**20099 从没绑上** | | `boards/会话看板/board-launch.py` | 看板(`board.py --serve 20099`) | `COLLABD_CONFIG` + `PYTHONIOENCODING` + 兜 `stdout/stderr` | **看板崩溃重启循环**(`已拒跑`)、**20099 从没绑上** |
🔴🔴 **两条"起法"必须收敛成同一个(P0-74)**:机制里**曾经有第二条起法** —— 🔴🔴 **两条"起法"必须收敛成同一个(P0-74)**:机制里**曾经有第二条起法** ——
`collabd.py` 的 `_escalate_to_keeper()`(自我供给旁路)会建 `collabd-supervise-<区>` 任务, `collabd.py` 的 `_escalate_to_keeper()`(自我供给旁路)会建 `collabd-supervise-<区>` 任务,
@@ -344,11 +344,11 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
> >
> **二之二、🔴 顺带查清的判据:「改看板要不要重启」有四类答案,混成一句话就是误导** > **二之二、🔴 顺带查清的判据:「改看板要不要重启」有四类答案,混成一句话就是误导**
> (用户第二句纠正:「**看板每次修改都不处理看板**」⇒ 我把"改配置要重启"说成了"改看板都要重启",**是分类错误**): > (用户第二句纠正:「**看板每次修改都不处理看板**」⇒ 我把"改配置要重启"说成了"改看板都要重启",**是分类错误**):
> · 改 `assets/board.html` ⛔ **不用**(`board.py:1293` `serve()` 里 `html_p.read_bytes()` **每请求实时读盘**; > · 改 `boards/会话看板/board.html` ⛔ **不用**(`board.py:1293` `serve()` 里 `html_p.read_bytes()` **每请求实时读盘**;
> `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载)—— 现跑实证 `tmp/_probe_hotreload.py`: > `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载)—— 现跑实证 `tmp/_probe_hotreload.py`:
> 改 `<title>` 插标记 → ⛔ 未重启 → 页面立即含标记(md5 已还原一致 ✔)。 > 改 `<title>` 插标记 → ⛔ 未重启 → 页面立即含标记(md5 已还原一致 ✔)。
> · 改 `.workbuddy/collab/board_ext.py` ⛔ **不用**(`EXT_CACHE` 按 `(mtime,size)` 签名**热重载**,`board.py:855-870`)。 > · 改 `.workbuddy/collab/board_ext.py` ⛔ **不用**(`EXT_CACHE` 按 `(mtime,size)` 签名**热重载**,`board.py:855-870`)。
> · 改 `scripts/board.py` 自身 ✅ **必须**(进程里是启动那一刻载入的旧代码)。 > · 改 `boards/会话看板/board.py` 自身 ✅ **必须**(进程里是启动那一刻载入的旧代码)。
> · 改 `collabd.config.json` ✅ **必须**(`C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径)。 > · 改 `collabd.config.json` ✅ **必须**(`C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径)。
> ⇒ **判据=先答"这个文件是谁在读、什么时候读"**;⛔ 别把"改了 X"与"改看板"当同一件事。 > ⇒ **判据=先答"这个文件是谁在读、什么时候读"**;⛔ 别把"改了 X"与"改看板"当同一件事。
> ⇒ 全文 ⇒ `references/pitfalls.md` **P0-38**(与 P0-17/P0-33 同族但**方向相反**:那两条讲"该重启却没重启", > ⇒ 全文 ⇒ `references/pitfalls.md` **P0-38**(与 P0-17/P0-33 同族但**方向相反**:那两条讲"该重启却没重启",
@@ -426,6 +426,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
🔴🔴 **未装即装(2026-10-14 用户令)**:用户说「使用项目机制完成目标:XXXX」而本区没装 ⇒ **先 `--apply --workspace <本区根>` 装好再干活**(判据与停止线见上面「第 0 步的连体动作」)。 🔴🔴 **未装即装(2026-10-14 用户令)**:用户说「使用项目机制完成目标:XXXX」而本区没装 ⇒ **先 `--apply --workspace <本区根>` 装好再干活**(判据与停止线见上面「第 0 步的连体动作」)。
🔴 **`--workspace` 要点名本区绝对路径**:`roots.env` 里 `DSH_WS_ROOT` 可能指向别的工作区(实测本机指向 `ai1net-dsh-server`)⇒ ⛔ 不让它顶替,装到别区 = 静默错归属。 🔴 **`--workspace` 要点名本区绝对路径**:`roots.env` 里 `DSH_WS_ROOT` 可能指向别的工作区(实测本机指向 `ai1net-dsh-server`)⇒ ⛔ 不让它顶替,装到别区 = 静默错归属。
🔴 **包内目录怎么分(2026-10-11 用户令:「脚本是脚本 配置是配置」)**:**每台机器各不同**的配置一律放本包 **`env/`**(`env/roots.env` 本机生成、不入库;`env/roots.env.example` 是入库样例);`scripts/` 只留通用代码。仓根 `.gitignore` 只放行 `env/**/*.example`。 🔴 **包内目录怎么分(2026-10-11 用户令:「脚本是脚本 配置是配置」)**:**每台机器各不同**的配置一律放本包 **`env/`**(`env/roots.env` 本机生成、不入库;`env/roots.env.example` 是入库样例);`scripts/` 只留通用代码。仓根 `.gitignore` 只放行 `env/**/*.example`。
🔴🔴 **看板一律进 `boards/<看板名>/`(2026-10-14 用户令:「把这两个看板都纳入会话技能中,分两个看板文件夹」)**:**自带页面的看板程序**(页面 + 脚本 + 启动器)**整包**放本包 `boards/` 下一个看板一个文件夹 ⇒ ⛔ 别把看板的 `.py` 散在 `scripts/`、页面散在 `assets/`(那样一个看板的东西分两处,改的时候必漏)。现有两个:`boards/会话看板/`(会话架构看板 · 端口 20099)/`boards/项目管理界面/`(项目+任务看板 · 端口 20100)。🔑 判据:**装进来的是"一个能跑起来的界面"⇒ 进 `boards/`;是"被别的程序调用的机制代码"⇒ 进 `scripts/`;是"纯样式或模板"⇒ 进 `assets/`**。
**⓪ 我要动手(建判据/写验收/报"已完成"/任何带"现在·已·还差"的结论)** ⇒ **先读 `references/00-动手前必过.md`**(🔴 **动手层**,2026-10-04 建,只有三条动作:① 查状态**问程序自己**,⛔ 不许 glob/手拼路径 ② 引用任何读数**先看它什么时候写的** ③ 写判据**先让基线全绿**再谈抓得住 —— 每条都对应当天真实翻车,且都已落判据不许退化)。 **⓪ 我要动手(建判据/写验收/报"已完成"/任何带"现在·已·还差"的结论)** ⇒ **先读 `references/00-动手前必过.md`**(🔴 **动手层**,2026-10-04 建,只有三条动作:① 查状态**问程序自己**,⛔ 不许 glob/手拼路径 ② 引用任何读数**先看它什么时候写的** ③ 写判据**先让基线全绿**再谈抓得住 —— 每条都对应当天真实翻车,且都已落判据不许退化)。
@@ -640,9 +641,9 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
**病根**:同一条「这条验收算不算过」的判据,曾在**三处各写一遍**,于是必然漂移: **病根**:同一条「这条验收算不算过」的判据,曾在**三处各写一遍**,于是必然漂移:
1. `scripts/board.py` → `acc_is_pass()`(**唯一实现,改判据只改这里**) 1. `boards/会话看板/board.py` → `acc_is_pass()`(**唯一实现,改判据只改这里**)
2. `scripts/collabd.py` → `_acc_is_pass()`(**只转发 board,⛔ 不许自己写词表**) 2. `scripts/collabd.py` → `_acc_is_pass()`(**只转发 board,⛔ 不许自己写词表**)
3. `assets/board.html` → `accIsPass()`(前端跑不了 Python ⇒ **抄同一份白名单**) 3. `boards/会话看板/board.html` → `accIsPass()`(前端跑不了 Python ⇒ **抄同一份白名单**)
**白名单(全库唯一)**:`pass` / `过` / `通过` / `达` / `达标` / `合格` / `完成` **白名单(全库唯一)**:`pass` / `过` / `通过` / `达` / `达标` / `合格` / `完成`
(Python 侧常量 `ACC_PASS_WORDS`;JS 侧常量 `ACC_PASS_WORDS` —— 两处**必须逐字相同**。) (Python 侧常量 `ACC_PASS_WORDS`;JS 侧常量 `ACC_PASS_WORDS` —— 两处**必须逐字相同**。)
@@ -801,7 +802,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
· **派活** ⇒ 自动化(**唯一能开新会话的通道**;⛔ 钩子做不到)。 · **派活** ⇒ 自动化(**唯一能开新会话的通道**;⛔ 钩子做不到)。
· **收结果** ⇒ 直读宿主库(0 token)。 · **收结果** ⇒ 直读宿主库(0 token)。
· **机械判定** ⇒ 下沉到本地只读程序(`collabd.py` / `board.py` / `goalctl.py`)。 · **机械判定** ⇒ 下沉到本地只读程序(`collabd.py` / `board.py` / `goalctl.py`)。
· **人看的** ⇒ 只有一个看板(`assets/board.html` + `board_ext.py`)。 · **人看的** ⇒ 只有一个看板(`boards/会话看板/board.html` + `board_ext.py`)。
**铁律(全部实测得来)** **铁律(全部实测得来)**
- ⚠️ **【2026-10-03 口径已改 · 本条「四类」部分作废】** 现行=**两类**(① 主会话 ② 任务会话;唤醒会话/跟进会话/队列上报**整套退役**)⇒ 以**文首口径块**为准,⛔ **别照本条去建会话**。(原文保留仅为留痕,⛔ 不删。) - ⚠️ **【2026-10-03 口径已改 · 本条「四类」部分作废】** 现行=**两类**(① 主会话 ② 任务会话;唤醒会话/跟进会话/队列上报**整套退役**)⇒ 以**文首口径块**为准,⛔ **别照本条去建会话**。(原文保留仅为留痕,⛔ 不删。)
@@ -902,7 +903,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
· 🔴🔴🔴 **全局只允许一个看板 / 一个常驻(用户 2026-10-04 21:0x 定案,逐字:「看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来」)**: · 🔴🔴🔴 **全局只允许一个看板 / 一个常驻(用户 2026-10-04 21:0x 定案,逐字:「看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来」)**:
**现状即标准** —— 就是本轮已经跑通并取证的那一套,⛔ 不要再发明第二套。 **现状即标准** —— 就是本轮已经跑通并取证的那一套,⛔ 不要再发明第二套。
- **看板载体**:计划任务 **`dsh-board-keepalive`(🔴 全局唯一一条,⛔ 不每区一个 —— 2026-10-05 收敛,旧名 `dsh-board-20099` 已禁用)**。 - **看板载体**:计划任务 **`dsh-board-keepalive`(🔴 全局唯一一条,⛔ 不每区一个 —— 2026-10-05 收敛,旧名 `dsh-board-20099` 已禁用)**。
· 🔴🔴 **动作 = `pythonw.exe` 起"启动器" `scripts/board-launch.py`**(⛔ **不许直起 `board.py`**)。 · 🔴🔴 **动作 = `pythonw.exe` 起"启动器" `boards/会话看板/board-launch.py`**(⛔ **不许直起 `board.py`**)。
**为什么(2026-10-05 实测)**:直起 ⇒ **崩溃重启循环、20099 从没绑上**。现场输出: **为什么(2026-10-05 实测)**:直起 ⇒ **崩溃重启循环、20099 从没绑上**。现场输出:
`⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设)⇒ 已拒跑` —— `board.py` 顶层用 `⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设)⇒ 已拒跑` —— `board.py` 顶层用
**`COLLABD_CONFIG`** 定位配置(它要 `import collabd.py`),而**计划任务的动作里没有 env 字段** **`COLLABD_CONFIG`** 定位配置(它要 `import collabd.py`),而**计划任务的动作里没有 env 字段**
@@ -44,9 +44,13 @@ _sm_load_roots()
def _fail(msg: str, code: int = 2): def _fail(msg: str, code: int = 2):
"""⛔ 不静默退出:`pythonw` 下 `stderr` 可能是 `None` ⇒ 先写包内 `logs/`(不入库)留痕,再退出。""" """⛔ 不静默退出:`pythonw` 下 `stderr` 可能是 `None` ⇒ 先写包内 `logs/`(不入库)留痕,再退出。
⚠️ 2026-10-14:看板整包搬进 `boards/会话看板/` ⇒ 到包根要上跳**两层**
(`boards/会话看板/` → `boards/` → 包根),⛔ 不是原来的一层。
"""
try: try:
_d = os.path.join(os.path.dirname(HERE), "logs") _d = os.path.join(HERE, "..", "..", "logs")
os.makedirs(_d, exist_ok=True) os.makedirs(_d, exist_ok=True)
with open(os.path.join(_d, "board-launch.err.log"), "a", encoding="utf-8", errors="replace") as _f: with open(os.path.join(_d, "board-launch.err.log"), "a", encoding="utf-8", errors="replace") as _f:
_f.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)) _f.write("%s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
@@ -1,6 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。 """board.py —— 生成**看板快照** `board.json`(供 `boards/会话看板/board.html` 实时渲染)。
设计原则: 设计原则:
· **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。 · **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。
@@ -76,10 +76,44 @@ from pathlib import Path
# ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path` # ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path`
# (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。 # (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。
# ⚠️ 加载失败 ⇒ `_is_check_agent` 回落成"只认新名"的保守实现(⛔ 宁可少判、不可崩)。 # ⚠️ 加载失败 ⇒ `_is_check_agent` 回落成"只认新名"的保守实现(⛔ 宁可少判、不可崩)。
def _find_collabd() -> "Path | None":
"""`collabd.py` 的位置。🔴🔴 2026-10-14:看板整包搬进 `boards/会话看板/` ⇒
「与 `collabd.py` 同目录」这个前提**不再成立**(机制脚本仍在包内 `scripts/`)。
⇒ 按候选表找:① 同目录(部署方把两者放一起时仍然可用)② 包内 `scripts/`
③ 宿主 env `DSH_SKILL_ROOT` 指的技能根下 `scripts/`(跨区副本被搬到别处时的兜底)。
⚠️ 找不到 ⇒ ⛔ 不许静默降级到本地抄的那份判据(那就是"第 N 次漂移"的种子)……
但也不许崩 ⇒ 仍保留兜底,只是**必须留痕**(见下面 except 分支)。"""
_here = Path(__file__).resolve().parent
_roots = [os.environ.get("DSH_SKILL_ROOT") or ""]
for _r in _roots:
if _r:
try:
_cands = [_here / "collabd.py",
_here.parent / "scripts" / "collabd.py",
_here.parent.parent / "scripts" / "collabd.py",
Path(_r) / "scripts" / "collabd.py"]
except Exception:
continue
for _c in _cands:
if _c.is_file():
return _c
# env 没设:仍试包内相对位置(`boards/<看板名>/` → `boards/` → 包根/scripts)
for _c in (_here / "collabd.py",
_here.parent / "scripts" / "collabd.py",
_here.parent.parent / "scripts" / "collabd.py"):
if _c.is_file():
return _c
return None
try: # pragma: no cover - 环境相关 try: # pragma: no cover - 环境相关
import importlib.util as _ilu import importlib.util as _ilu
_sp = _ilu.spec_from_file_location("_cb_shared", str(Path(__file__).resolve().parent / "collabd.py")) _cb_p = _find_collabd()
__dsh_collabd_path = str(_cb_p) if _cb_p else ""
if not _cb_p:
raise FileNotFoundError("找不到 collabd.py(候选:同目录 / 包内 scripts/ / DSH_SKILL_ROOT/scripts/)")
_sp = _ilu.spec_from_file_location("_cb_shared", str(_cb_p))
_cb = _ilu.module_from_spec(_sp) _cb = _ilu.module_from_spec(_sp)
_sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全 _sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全
_is_check_agent = _cb.is_check_agent _is_check_agent = _cb.is_check_agent
@@ -93,7 +127,19 @@ try: # pragma: no cover - 环境相关
# 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。 # 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。
# ⇒ 这里统一取模块、再从它上面取函数。 # ⇒ 这里统一取模块、再从它上面取函数。
_cb_shared = _cb _cb_shared = _cb
except Exception: # pragma: no cover except Exception as _cb_err: # pragma: no cover
# 🔴🔴 2026-10-14:搬迁后「同目录找 collabd.py」失败过一次(静默落到本地抄的判据 ⇒
# 自测「两侧共用同一实现」判 FAIL,但**看板照跑**,肉眼看不出来)⇒ 这里**必须留痕**:
# 写 stderr(被重定向到日志)+ 进程内标记,⛔ 不许静默降级。
import sys as _sys_
try:
_sys_.stderr.write("[board] ⚠️ collabd.py 加载失败,已回落本地保守判据(判据可能漂移):"
"%s\n" % (_cb_err,))
_sys_.stderr.flush()
except Exception:
pass
COLLABD_FALLBACK = True
def _is_check_agent(name: str) -> bool: def _is_check_agent(name: str) -> bool:
"""⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。""" """⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。"""
return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]")) return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]"))
@@ -103,9 +149,38 @@ except Exception: # pragma: no cover
⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。""" ⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。"""
m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip()) m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip())
return bool(m) and m.group(1).strip() in ("跟进", "唤醒") return bool(m) and m.group(1).strip() in ("跟进", "唤醒")
else:
COLLABD_FALLBACK = False
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
CFG_P = Path(os.environ.get("COLLABD_CONFIG") or (HERE / "collabd.config.json")) def _find_cfg() -> Path:
"""部署配置的位置。🔴 2026-10-14:搬迁后 `HERE` 变成 `boards/会话看板/` ⇒
「同目录 `collabd.config.json`」这个兜底不再指得到用户的工作区配置。
⇒ 与 `collabd.py::load_cfg()` 的查找顺序对齐(共享同一套事实源,⛔ 不各写一份):
① env `COLLABD_CONFIG` ② `<DSH_COLLAB_WS 或 cwd>/.workbuddy/collab/collabd.config.json`
③ 兜底同目录(部署方把配置拷到看板旁边时仍可用)。"""
env = os.environ.get("COLLABD_CONFIG")
if env:
return Path(env)
ws = os.environ.get("DSH_COLLAB_WS") or ""
cands = []
if ws:
cands.append(Path(ws) / ".workbuddy" / "collab" / "collabd.config.json")
try:
cands.append(Path.cwd() / ".workbuddy" / "collab" / "collabd.config.json")
except Exception:
pass
cands.append(HERE / "collabd.config.json")
for c in cands:
try:
if c.is_file():
return c
except Exception:
continue
return cands[0]
CFG_P = _find_cfg()
WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None
@@ -1616,7 +1691,7 @@ def _orphan(lb: list) -> dict:
# ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。 # ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。
# ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。 # ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。
# #
# ⚠️ **判定词只许在这里增**(改完必须同时改 `assets/board.html` 的 `ACC_PASS_WORDS`)。 # ⚠️ **判定词只许在这里增**(改完必须同时改 `boards/会话看板/board.html` 的 `ACC_PASS_WORDS`)。
# 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。 # 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。
ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成") ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成")
@@ -1635,7 +1710,7 @@ ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成")
# 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。 # 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。
ACC_ADV_PREFIX = ("已", "已经", "均", "都", "经复核", "复核后") ACC_ADV_PREFIX = ("已", "已经", "均", "都", "经复核", "复核后")
# 🔴 与 `ACC_PASS_WORDS` 同理:`assets/board.html` 的 `ACC_ADV_PREFIX` 必须**逐字同款**。 # 🔴 与 `ACC_PASS_WORDS` 同理:`boards/会话看板/board.html` 的 `ACC_ADV_PREFIX` 必须**逐字同款**。
@@ -1822,7 +1897,7 @@ def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: l
"others_running": _ss["others_running"], "others_running": _ss["others_running"],
# 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话 # 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话
# 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板), # 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板),
# 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。 # 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `boards/会话看板/board.html` 在图外说明里点名。
# 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。 # 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。
# 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字): # 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字):
# 「检查会话不是协作会话,不应该出现在看板协作会话区域中; # 「检查会话不是协作会话,不应该出现在看板协作会话区域中;
@@ -2105,7 +2180,9 @@ def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> in
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
html_p = HERE.parent / "assets" / "board.html" # 🔴 2026-10-14:看板整包搬进 `boards/会话看板/`(用户令「开个 boards 文件夹放两个看板」)
# ⇒ 页面与脚本**同目录**了,⛔ 不再走 `HERE.parent/assets/`。
html_p = HERE / "board.html"
cache = {"bytes": None, "at": 0.0, "err": "", "n": 0} cache = {"bytes": None, "at": 0.0, "err": "", "n": 0}
def _refresh_loop(): def _refresh_loop():
@@ -7,7 +7,7 @@
技能只提供**机制**(通用看板 + 一个扩展挂点);「链路前置」要看哪些组件、叫什么真名、 技能只提供**机制**(通用看板 + 一个扩展挂点);「链路前置」要看哪些组件、叫什么真名、
端到端怎么算通过 —— 都是**本项目的知识**,所以落在本文件里。 端到端怎么算通过 —— 都是**本项目的知识**,所以落在本文件里。
## 契约(由 `session-mechanism/scripts/board.py` 调用) ## 契约(由 `session-mechanism/boards/会话看板/board.py` 调用)
```python ```python
def build(ws: str) -> dict def build(ws: str) -> dict
``` ```
@@ -214,7 +214,7 @@ def _triggers_gw(ws: str) -> list:
nm = "唤醒" nm = "唤醒"
# ⚠️ 用户 2026-09-30:「把心跳 图框做小一点 放到**主会话左边**就行,本来就是主会话创建的」 # ⚠️ 用户 2026-09-30:「把心跳 图框做小一点 放到**主会话左边**就行,本来就是主会话创建的」
# 🔴 **位置这半句已被推翻** —— 用户 2026-10-01「**唤醒会话 和 跟进会话 单独放一行**」⇒ # 🔴 **位置这半句已被推翻** —— 用户 2026-10-01「**唤醒会话 和 跟进会话 单独放一行**」⇒
# 现行位置=**主会话下面那一行(`RW`)的左格**(布局常量见 `assets/board.html`)。 # 现行位置=**主会话下面那一行(`RW`)的左格**(布局常量见 `boards/会话看板/board.html`)。
# 这条只留**尺寸**依据(=「做小一点」,故 `TBW=TW`),⛔ 别再照它摆位置。 # 这条只留**尺寸**依据(=「做小一点」,故 `TBW=TW`),⛔ 别再照它摆位置。
# v2:「**里面是每5分钟核对目标状态**」⇒ detail 用这句。 # v2:「**里面是每5分钟核对目标状态**」⇒ detail 用这句。
# v3:「把唤醒的**真实执行情况 时间**也展示到架构图上」⇒ 第 3 行改**真读数** # v3:「把唤醒的**真实执行情况 时间**也展示到架构图上」⇒ 第 3 行改**真读数**
@@ -518,7 +518,7 @@ def _triggers(ws: str) -> list:
"up": bool(up), "up": bool(up),
"state": state, # 🆕 给样式用:running/paused/none "state": state, # 🆕 给样式用:running/paused/none
# 🔴 2026-10-02 **删掉死字段 `edge`**(原为 `"edge": "唤醒"`,注释写"箭头指主会话"): # 🔴 2026-10-02 **删掉死字段 `edge`**(原为 `"edge": "唤醒"`,注释写"箭头指主会话"):
# ⚠️ 它**已经没有任何消费方** —— `assets/board.html` 里那句 `if(t.edge) lbl(...)` # ⚠️ 它**已经没有任何消费方** —— `boards/会话看板/board.html` 里那句 `if(t.edge) lbl(...)`
# 在 2026-10-02 改连线(唤醒 → 常驻程序)时被换成了**固定文案** # 在 2026-10-02 改连线(唤醒 → 常驻程序)时被换成了**固定文案**
# (`唤醒 · --tick 推一次`)⇒ 这个键从此没人读。 # (`唤醒 · --tick 推一次`)⇒ 这个键从此没人读。
# 🔴 判据(可复跑):`grep -rn "\.edge" --include=*.js --include=*.py --include=*.html` # 🔴 判据(可复跑):`grep -rn "\.edge" --include=*.js --include=*.py --include=*.html`
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""项目管理界面的**启动器**(计划任务动作指向它,⛔ 不许直起 `project-board.py`)。
## 为什么必须有这一层(同族 P0-73/74)
· 计划任务的**动作没有 env 字段** ⇒ 变量只能在**进程内**设;
· 直接用 `python.exe` 起 ⇒ 控制台程序 ⇒ 计划任务**每次触发闪一个黑窗口**;
· 直起业务脚本 ⇒ 将来加变量/判活时**没有落点**,只能再开第二条旁路(闪黑窗的真因)。
## 本启动器做什么
① 设好编码与上游地址(⛔ 不依赖宿主 env);
② 调 `project-board.py --serve 20100`;
③ **判活**:端口已在听 ⇒ 直接退出 0(⛔ 不重复起第二实例)。
⚠️ 必须用 `pythonw.exe` 跑本文件(无控制台 ⇒ 不闪窗)。
"""
from __future__ import annotations
import os
import socket
import sys
import runpy
PORT = int(os.environ.get("DSH_PROJECT_BOARD_PORT") or "20100")
HERE = os.path.dirname(os.path.abspath(__file__))
TARGET = os.path.join(HERE, "project-board.py")
# 🔴 2026-10-14:本包配置从 `env/roots.env` 读(同 `board-launch.py` 的口径)——
# 看板搬进技能包后,⛔ 不能再靠"从本文件位置往上跳"推算工作区根
# (那会算出技能包自己的路径)。**宿主 env 优先,本文件兜底**。
def _sm_load_roots():
_here = os.path.dirname(os.path.abspath(__file__))
for _up in range(5):
_base = os.path.normpath(os.path.join(_here, *([".."] * _up)))
for _rel in (("env", "roots.env"), ("roots.env",)):
_p = os.path.join(_base, *_rel)
if os.path.isfile(_p):
try:
with open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_sm_load_roots()
# ── 变量只在这里设(计划任务动作没有 env 字段)──
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
# 🔴 「本工作区」=**拥有一这层的那个工作区**(用户 2026-10-06 要:本工作区的卡片换色突出)。
# ⛔ 不许靠 `Path.cwd()`(计划任务的工作目录不是工作区根)。
# 2026-10-14 起:本文件在技能包内 ⇒ 用 `DSH_WS_ROOT`(来自宿主 env 或包内 `env/roots.env`)。
# ⚠️ 取不到 ⇒ **留空**(前端一律不高亮,⛔ 不猜一个错的工作区根出来)。
os.environ.setdefault("DSH_PM_BOARD_SELF_WS",
(os.environ.get("DSH_WS_ROOT") or "").strip())
def port_listening(port: int) -> bool:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(0.4)
try:
return s.connect_ex(("127.0.0.1", port)) == 0
finally:
s.close()
def main() -> int:
if port_listening(PORT):
return 0 # 已在跑 ⇒ 静默退出(⛔ 不起第二实例)
sys.argv = [TARGET, "--serve", str(PORT)]
try:
runpy.run_path(TARGET, run_name="__main__")
except SystemExit:
raise
except Exception:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,980 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>项目管理 · 执行任务</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a;
--fg:#e6e9ef; --dim:#98a2b3; --dim2:#6b7686;
--accent:#4c6ef5; --warn:#f0a020; --bad:#e5534b; --ok:#3fb950;
--r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
a{color:inherit;text-decoration:none}
/* ── 顶栏:项目选择 ── */
header{position:sticky;top:0;z-index:20;background:rgba(15,17,21,.92);
backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.hrow{display:flex;align-items:center;gap:14px;padding:12px 18px;flex-wrap:wrap}
.brand{font-weight:600;letter-spacing:.2px}
.brand small{color:var(--dim2);font-weight:400;margin-left:8px}
select,button{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer}
select:focus,button:focus{outline:2px solid var(--accent);outline-offset:1px}
.spacer{flex:1}
.stamp{color:var(--dim2);font-size:12px}
/* ── 项目概要 ── */
.summary{display:flex;gap:10px;padding:0 18px 12px;flex-wrap:wrap}
.chip{background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:4px 11px;font-size:12px;color:var(--dim)}
.chip b{color:var(--fg);font-weight:600}
/* ── 列 ── */
main{padding:16px 18px 40px}
/* 🔴🔴 `align-items:start` —— 用户 2026-10-06:「卡片上下之间的距离太远了」。
真因:grid **默认 `stretch`** ⇒ 12 张卡片全在「已完成」、另三列是空的,
但四列**被拉成等高** ⇒ 空列白占 1100px、整体高度虚胖。
✅ 改 `start` ⇒ 每列**各自撑自己的高度**,空列就是一个小盒子。*/
/* 🔴🔴 **四列并排等宽**(2026-10-06 用户「之前是这样吗 现在是改乱后的样子」⇒ 恢复)。
⛔ 别再动这四行 —— 我曾两度以「空列白占屏 / 卡片看不到」为由改过列宽,
改成 flex+空列收窄(三窄一宽),用户判为「改乱了」。
📌 判据:**四态列是固定维度,必须并排等宽**(一眼看全四个状态区),
"某个状态是空的"不是布局问题 —— ⛔ 不许因空列去改列宽。*/
.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;
align-items:start}
@media(max-width:1100px){.cols{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:640px){.cols{grid-template-columns:1fr}}
.col{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
display:flex;flex-direction:column;min-height:0}
.colhead{display:flex;align-items:center;gap:8px;padding:11px 13px;
border-bottom:1px solid var(--line)}
.dot{width:8px;height:8px;border-radius:50%;flex:none}
.coltitle{font-weight:600;font-size:13px}
.count{margin-left:auto;background:var(--panel2);border:1px solid var(--line);
border-radius:999px;padding:1px 9px;font-size:12px;color:var(--dim)}
/* 🔴 卡片间距压紧(原 `gap:9px` + `padding:10px` 在长列里显得很空)*/
.cards{padding:8px;display:flex;flex-direction:column;gap:6px}
/* ── 统一视图:卡片**末行**显示工作区(🔴 归属靠这一行,⛔ 不靠分区)── */
/* 用户 2026-10-06:「卡片 标签要显示 工作区名称,不要显示 本机协作这种」
+「工作区名称要不要 放到卡片下面」⇒ 末行单列,⛔ 不混进 badge 堆里 */
.wsrow{display:flex;align-items:center;gap:6px;margin-top:7px;padding-top:6px;
border-top:1px solid var(--line)}
.wsname{font-size:11.5px;color:var(--dim);font-family:ui-monospace,Consolas,monospace;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wsself{margin-left:auto;flex:none;font-size:10.5px;padding:0 5px;border-radius:4px;
background:rgba(76,110,245,.2);border:1px solid rgba(76,110,245,.5);
color:#b9c8ff}
.badge.when{font-family:ui-monospace,Consolas,monospace}
/* 🔴 本工作区的卡片换色突出(用户 2026-10-06:「本工作区的卡片是不得 换个颜色」)*/
.card.is-self{background:rgba(76,110,245,.13);border-color:rgba(76,110,245,.55)}
.card.is-self:hover{border-color:var(--accent)}
.card.is-self .t{color:#c9d4ff}
.card{background:var(--panel2);border:1px solid var(--line);border-left-width:3px;
border-radius:8px;padding:9px 11px;cursor:pointer;
transition:transform .08s ease,border-color .12s ease}
.card:hover{transform:translateY(-1px);border-color:var(--accent)}
.card .t{font-weight:500;word-break:break-word}
.card .meta{margin-top:6px;color:var(--dim2);font-size:12px;
display:flex;gap:9px;flex-wrap:wrap}
.badge{display:inline-block;border-radius:5px;padding:1px 6px;font-size:11px;
border:1px solid var(--line);color:var(--dim)}
/* 🔴 「当前目标」标记(一个区常有多个目标,得看得出现在在跑哪个) */
.badge.cur{background:rgba(76,110,245,.2);border-color:rgba(76,110,245,.6);color:#b9c8ff}
.empty{color:var(--dim2);font-size:12px;text-align:center;padding:18px 6px}
.warnline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(240,160,32,.09);border:1px solid rgba(240,160,32,.35);
color:#f4c67a;font-size:12.5px}
.errline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(229,83,75,.1);border:1px solid rgba(229,83,75,.4);color:#f2a09a}
.okline{margin:0 0 12px;padding:10px 12px;border-radius:8px;
background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.4);color:#8fe39a;
font-size:12.5px;white-space:pre-wrap;word-break:break-word}
pre.out{margin:8px 0 0;padding:10px;border-radius:8px;background:#12141a;
border:1px solid var(--line);color:var(--dim);font-size:11.5px;
max-height:260px;overflow:auto;white-space:pre-wrap}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button[disabled]{opacity:.5;cursor:not-allowed}
/* ── 新建对话框 ── */
.modal{position:fixed;z-index:40;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);
width:min(560px,92vw);background:var(--panel);border:1px solid var(--line);
border-radius:12px;box-shadow:0 18px 50px rgba(0,0,0,.5);
opacity:0;pointer-events:none;transition:opacity .14s ease,transform .14s ease}
.modal.on{opacity:1;pointer-events:auto;transform:translate(-50%,-50%) scale(1)}
.hint{color:var(--dim);font-size:12.5px;margin:0 0 14px}
.hint b{color:var(--fg)}
.fld{display:flex;flex-direction:column;gap:5px;margin-bottom:11px}
.fld>span{color:var(--dim);font-size:12px}
.fld input,.fld select{background:var(--panel2);color:var(--fg);border:1px solid var(--line);
border-radius:8px;padding:8px 10px;font:inherit;width:100%}
.fld input:focus,.fld select:focus{outline:2px solid var(--accent);outline-offset:1px}
.fnote{margin:2px 0 12px;font-size:12px;color:var(--dim2);min-height:1.2em}
.fnote.bad{color:#f2a09a}
.facts{display:flex;align-items:center;gap:10px}
/* ── 「!」状态图标 + 悬浮说明(⚠️ 提示一律收进这里,⛔ 不铺在工作台上)── */
.info{position:relative;display:inline-flex;align-items:center;justify-content:center;
width:22px;height:22px;border-radius:50%;font-size:13px;font-weight:700;line-height:1;
border:1px solid var(--line);background:var(--panel2);color:var(--dim);cursor:pointer;
user-select:none;flex:none}
.info.idle{opacity:.42} /* 没内容时淡掉,但仍在位 */
.info.fancy:not(.idle){border-color:var(--warn);color:var(--warn)}
.info:focus{outline:2px solid var(--accent);outline-offset:2px}
.infopop{position:absolute;top:calc(100% + 8px);right:0;z-index:30;width:min(480px,92vw);
background:var(--panel);border:1px solid var(--line);border-radius:10px;
box-shadow:0 12px 34px rgba(0,0,0,.34);padding:12px 14px;display:none}
.infopop.on{display:block}
.infopop .ih{font-size:12px;color:var(--dim2);font-weight:600;letter-spacing:.5px;
text-transform:uppercase;margin:0 0 8px}
.infopop .iw{font-size:13px;line-height:1.62;padding:7px 0;border-top:1px dashed var(--line);
color:var(--fg)}
.infopop .iw:first-of-type{border-top:0;padding-top:0}
.infopop .iw b{color:var(--warn)}
.infopop .iw code{background:var(--panel2);padding:1px 5px;border-radius:4px;font-size:12px}
/* ── 一次性操作反馈(自动消失;⛔ 不常驻)── */
.toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,14px);z-index:60;
max-width:min(680px,92vw);padding:11px 15px;border-radius:10px;font-size:13px;
line-height:1.55;border:1px solid var(--line);background:var(--panel);
box-shadow:0 10px 30px rgba(0,0,0,.3);opacity:0;pointer-events:none;
transition:opacity .16s ease, transform .16s ease;word-break:break-word}
.toast.on{opacity:1;transform:translate(-50%,0);pointer-events:auto}
.toast.ok{border-color:var(--ok)}
.toast.err{border-color:var(--bad);color:#f2a09a}
.toast pre.out{margin:8px 0 0;max-height:150px}
/* 时效警示:数据 > 15 秒没动 ⇒ 转黄(⛔ 不靠用户猜"刷新了没") */
.stamp.is-stale{color:var(--warn)}
/* ── 抽屉 ── */
.mask{position:fixed;inset:0;background:rgba(0,0,0,.5);opacity:0;pointer-events:none;
transition:opacity .15s ease;z-index:40}
.mask.on{opacity:1;pointer-events:auto}
aside{position:fixed;top:0;right:0;height:100%;width:min(560px,94vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;transform:translateX(100%);
transition:transform .18s ease;display:flex;flex-direction:column}
aside.on{transform:none}
.ahead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
.ahead .ttl{font-weight:600;flex:1;word-break:break-word}
.abody{overflow:auto;padding:14px 16px 30px}
.kv{display:grid;grid-template-columns:104px 1fr;gap:7px 12px;font-size:13px}
.kv dt{color:var(--dim2)}.kv dd{margin:0;word-break:break-word}
.sect{margin-top:18px}
.sect h4{margin:0 0 8px;font-size:12px;color:var(--dim2);font-weight:600;
text-transform:uppercase;letter-spacing:.6px}
.sess{display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--line);
border-radius:8px;margin-bottom:7px;background:var(--panel2);cursor:pointer}
.sess:hover{border-color:var(--accent)}
.sess .sn{flex:1;word-break:break-word;font-size:13px}
.sess .go{color:var(--accent);font-size:12px;flex:none}
/* 🔴 旧「行式」判据样式(.flow)—— 2026-10-06 用户:「判据用卡片显示,现在列表字段都挤成竖列了」。
⛔ 保留定义仅作兼容,新代码一律用下面 `.pcard`(卡片式)。 */
.flow{display:flex;align-items:center;gap:9px;padding:6px 0;border-bottom:1px dashed var(--line);font-size:13px}
.flow:last-child{border-bottom:0}
.flow .fn{flex:1}
.flow .fc{color:var(--dim2);font-size:12px}
/* ── 🔴🔴 **判据卡片**(2026-10-06 用户:「判据 用卡片显示,现在列表字段都挤成竖列了」)──
病根:旧 `.flow{display:flex}` 把「判据键」和「判据值」**摆在同一行的左右两侧**
⇒ 抽屉宽度只有 ~560px,判据值又长(实测一条 100+ 字)⇒ **被压成窄竖条**,几乎不可读。
✅ 判据卡=**垂直布局**:① 首行=键 + 通过/未过标记(右对齐)② 下面=值全文,
`word-break` 让它自然换行铺满卡宽(⛔ 不再被挤成竖列)。
📌 一卡一条判据,卡与卡之间留白 —— 比"虚线分割的行列表"好读得多。 */
.pcards{display:flex;flex-direction:column;gap:9px}
.pcard{background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--line);
border-radius:8px;padding:9px 11px}
.pcard.ok{border-left-color:var(--ok)}
.pcard.no{border-left-color:var(--bad)}
.pcard .phead{display:flex;align-items:baseline;gap:9px;margin-bottom:5px}
/* 键:等宽字体(判据键多半是 `V1`/`U27`/`A6` 这类机读标号) */
.pcard .pk{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;
font-weight:600;word-break:break-all}
.pcard .pv{font-size:12.5px;color:var(--dim);word-break:break-word;line-height:1.6}
/* 通过/未过标记:靠右、不换行 */
.pcard .pst{margin-left:auto;flex:none;font-size:11px;border-radius:5px;padding:1px 7px;
border:1px solid var(--line)}
.pcard.ok .pst{background:rgba(63,185,80,.15);border-color:rgba(63,185,80,.5);color:#8fe39a}
.pcard.no .pst{background:rgba(229,83,75,.15);border-color:rgba(229,83,75,.5);color:#f2a09a}
/* 任务细账(工作流分组)也改卡片:名称一行、计数一行(⛔ 不再左右挤) */
.fcard{background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:8px 11px;margin-bottom:7px}
.fcard .fname{font-size:12.5px;font-weight:600;margin-bottom:3px;word-break:break-word}
.fcard .fmeta{font-size:12px;color:var(--dim2)}
/* ── 「最后状态时间」小标(2026-10-11 用户:「所有目标项目都需要 加上时间」)── */
.badge.miss{color:var(--warn);border-color:rgba(240,160,32,.5)}
/* ── 「更多」入口 + 项目目标列表页(页内视图,URL = `#/goals/<列>`)──
🔴 用户 2026-10-11:「已完成显示前8各,其余点击更多 到项目目标列表页查看」。
🔴🔴 `.cols.aslist{display:block}` 是**必需**的:列表页占满主区,
而 `#cols` 本身带 `class="cols"`(4 列 grid)⇒ 不切 block 的话整页会被
塞进**第 1 列**(列宽 1/4、卡片挤成窄条)。同族踩过:外层套内层 grid(见 renderAll 注释)。 */
.cols.aslist{display:block}
.lvwrap{display:flex;flex-direction:column;gap:12px;max-width:1180px}
.lvhead{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.lvtitle{font-size:16px;font-weight:600}
.lvsub{color:var(--dim2);font-size:12.5px}
.lvsub b{color:var(--fg)}
.lvchips{display:flex;gap:8px;flex-wrap:wrap}
.lvchip{background:var(--panel);border:1px solid var(--line);border-radius:999px;
padding:4px 12px;font-size:12.5px;color:var(--dim);cursor:pointer;user-select:none}
.lvchip:hover{border-color:var(--accent)}
.lvchip.on{border-color:var(--accent);background:rgba(76,110,245,.16);color:#c9d4ff}
.lvchip b{color:var(--fg);font-weight:600}
.lvlist{display:flex;flex-direction:column;gap:7px}
.lvrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--panel2);
border:1px solid var(--line);border-left-width:3px;border-radius:8px;
padding:9px 12px;cursor:pointer;transition:transform .08s ease,border-color .12s ease}
.lvrow:hover{transform:translateY(-1px);border-color:var(--accent)}
.lvrow .lt{flex:1;min-width:200px;font-weight:500;word-break:break-word}
.lvrow .lws{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--dim);
max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lvrow .lwhen{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--dim2);
white-space:nowrap}
.lvrow .lwhen.miss{color:var(--warn)}
.lvrow.is-self{background:rgba(76,110,245,.13);border-color:rgba(76,110,245,.55)}
.more{display:block;width:100%;text-align:center;background:transparent;
border:1px dashed var(--line);border-radius:8px;padding:7px;color:var(--dim);
font:inherit;font-size:12.5px;cursor:pointer}
.more:hover{border-color:var(--accent);color:#c9d4ff}
</style>
</head>
<body>
<header>
<div class="hrow">
<div class="brand">项目管理</div>
<select id="psel" aria-label="选择工作区" title="选择工作区"></select>
<div class="spacer"></div>
<span class="stamp" id="stamp">加载中…</span>
<span class="info fancy" id="infobtn" tabindex="0" role="button" aria-label="状态说明"
title="状态说明">!</span>
<button id="btnnew" type="button" class="primary">+ 新建目标</button>
<button id="refresh" type="button" title="立即重新扫一遍本机所有工作区">刷新</button>
</div>
<div class="summary" id="summary"></div>
</header>
<main>
<div class="cols" id="cols"></div>
</main>
<div class="toast" id="toast"></div>
<div class="mask" id="mask"></div>
<aside id="drawer" aria-hidden="true">
<div class="ahead">
<div class="ttl" id="dtitle">—</div>
<button id="dclose" type="button">关闭</button>
</div>
<div class="abody" id="dbody"></div>
</aside>
<!-- 新建目标(新工作区)对话框 -->
<div class="mask" id="cmask"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mtitle">
<div class="ahead">
<div class="ttl" id="mtitle">新建目标(新工作区)</div>
<button id="mclose" type="button">关闭</button>
</div>
<div class="abody">
<p class="hint">在选定的根目录下建一个新工作区,并写入目标登记。走技能自带的建区入口(幂等)。
<b>目录名</b>同时作为工作区名,<b>目标标题</b>是「目标=项目」里的项目名。</p>
<label class="fld"><span>根目录</span><select id="froot"></select></label>
<label class="fld"><span>工作区名(目录名)</span>
<input id="fname" type="text" placeholder="例:my-new-project" autocomplete="off"></label>
<label class="fld"><span>目标标题</span>
<input id="ftitle" type="text" placeholder="例:把 XX 做成 YY" autocomplete="off"></label>
<label class="fld"><span>项目简称(可空 ⇒ 用工作区名)</span>
<input id="fshort" type="text" placeholder="例:XX 项目" autocomplete="off"></label>
<div class="fnote" id="fnote"></div>
<div class="facts">
<button id="mok" type="button" class="primary">创建</button>
<button id="mcancel" type="button">取消</button>
<span class="stamp" id="mstamp"></span>
</div>
</div>
</div>
<script>
"use strict";
/* cur=-1 ⇒ 「全部」;view = `board`(四列看板)|`goals`(项目目标列表页);
goalsFilter = 列表页的状态筛选(`all` 或四列键)—— 由 URL `#/goals/<列>` 决定。 */
var STATE = { data:null, cur:-1, epoch:0, sameHits:0, sig:"", view:"board", goalsFilter:"all" };
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){
return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); }
function fmtWhen(s){
if(!s) return "—";
var str = String(s).trim();
// 🔴🔴 2026-10-06 实测补:台账里的 `t_start`/`t_end` 有两种形态 ——
// ① `"2026-10-06T09:26:53"`(ISO 字符串)② **纯数字 Unix 秒**(`1791957640.7257342`)。
// 原实现只认 ①,遇到 ② 正则不匹配 ⇒ **原样返回** ⇒ 卡片上印出
// `1791957640.7257342` 一整串(用户看就是一堆乱数字)。
// ✅ 先判纯数字(10 位上下)⇒ 转本地时间;⛔ 不猜、⛔ 不硬编时区。
var n = Number(str);
if(isFinite(n) && n > 1e9 && n < 4e9){ // 2001-09 ~ 2096-11 之间的秒级时间戳
var d = new Date(n * 1000);
if(!isNaN(d.getTime())){
var p = function(x){ return (x<10?"0":"")+x; };
return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+
p(d.getHours())+":"+p(d.getMinutes());
}
}
var m = str.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/);
if(!m) return str;
return m[1]+"-"+m[2]+"-"+m[3]+" "+m[4]+":"+m[5];
}
/* ── 时效条:数据「多久没动」 ──
🔴 两条口径(照现有看板 board.html::paintAge 的做法):
① 读数由**服务给的 epoch** 算,⛔ 不用本地时钟顶上;
② 超阈值 ⇒ 整块转警示色,让"数据停了"一眼可见(⛔ 不靠用户猜)。 */
var AGE_STALE = 15; // 秒;超过 ⇒ 转警示色
function paintAge(){
var el = document.getElementById("stamp");
if(!el || !STATE.epoch){ return; }
var s = Math.max(0, Math.round(Date.now()/1000 - STATE.epoch));
el.textContent = "数据 " + s + " 秒前";
el.classList.toggle("is-stale", s > AGE_STALE);
}
/* 🔴 轮询节奏=服务的扫描节奏(⛔ 页面不自行加频:加了只是空转,不会更快)。
联动:数据是新的 ⇒ 3 秒一探;连续多次没变化 ⇒ 逐步放到 15 秒(省电、省 CPU),
一旦有变化立刻回到 3 秒。⛔ 不做"页面醒了才刷"这类花活,避免假刷新。 */
var POLL_FAST = 3000, POLL_SLOW = 15000;
function currentPoll(){
return (STATE.sameHits >= 3) ? POLL_SLOW : POLL_FAST;
}
function loop(){
load();
setTimeout(loop, currentPoll());
}
function setInfo(items){
var btn = document.getElementById("infobtn");
var old = document.getElementById("infopop");
if(old) old.parentNode.removeChild(old);
if(!items || !items.length){
btn.classList.add("idle"); // 无内容 ⇒ 淡显、不可点
btn.title = "无特别说明";
btn.setAttribute("aria-disabled","true");
return;
}
btn.classList.remove("idle");
btn.removeAttribute("aria-disabled");
btn.title = items.length + " 条状态说明(点开看)";
var pop = document.createElement("div");
pop.className = "infopop"; pop.id = "infopop";
pop.innerHTML = '<div class="ih">状态说明 · '+items.length+' 条</div>' + items.join("");
btn.parentNode.appendChild(pop);
}
/* ── 操作结果提示(新建/删除这类一次性反馈;⛔ 不常驻工作台)── */
function toast(kind, html, ms){
var el = document.getElementById("toast");
el.className = "toast on " + (kind || "ok");
el.innerHTML = html;
clearTimeout(toast._t);
if(kind !== "err") toast._t = setTimeout(function(){ el.className = "toast"; }, ms || 4200);
}
function toggleInfo(force){
var pop = document.getElementById("infopop");
if(!pop) return;
var on = (typeof force === "boolean") ? force : !pop.classList.contains("on");
pop.classList.toggle("on", on);
}
function load(manual){
if(manual) document.getElementById("stamp").textContent = "扫描中…";
fetch("/api/projects", {cache:"no-store"}).then(function(r){
if(!r.ok) throw new Error("HTTP " + r.status); // ⛔ 别把 404 当空数据静默吞掉
return r.json();
})
.then(function(d){
if(!d || d.ok === false){
toast("err", '扫工作区失败:' + esc((d&&d.err)||"未知错误"));
return;
}
STATE.data = d;
if(manual) toast("ok", "已刷新(" + esc(d.ts || "") + ")", 2200);
/* 头部不再显示根目录(2026-10-11 用户令:删掉「E:/ProgramData/AIProject 工作区」这几个字) */
/* 🔴 时效**只用服务给的 epoch** 算(⛔ 不用本地 Date.now() 顶上),
由 setInterval(paintAge) 每秒重画 ⇒ 页面"看起来在动"但数据没变时,
读数会如实爬到 5 秒以上 ⇒ 一眼看出"只是轮询、数据没动"。 */
STATE.epoch = Number(d.epoch || 0) || (Date.now() / 1000);
paintAge();
/* 变化探测:内容没变 ⇒ 逐步拉长轮询间隔(省电);一变立刻回到 3 秒 */
var sig = JSON.stringify(d.projects.map(function(p){
return [p.dir, p.life, p.life_at, p.has_goal, (p.cards||[]).length,
p.queue.done, p.queue.open, p.queue.blocked,
(p.acc||{}).pass, (p.acc||{}).n]; }));
if(sig === STATE.sig){ STATE.sameHits = (STATE.sameHits||0) + 1; }
else { STATE.sig = sig; STATE.sameHits = 0; }
var sel = document.getElementById("psel");
if(!d.projects.length){
sel.innerHTML = '<option value="-1">(本机暂无装了机制的工作区)</option>';
STATE.cur = -1;
} else {
// 🔴🔴 **本下拉=工作区选择器**(用户 2026-10-06 明确纠正:
// 「下拉框是选工作区的 跟目标没关系」)。
// ⇒ ① 选项文字用 **工作区名**(`dir` 目录名),⛔ 不是目标名;
// ② 计数说 **N 个工作区**,⛔ 不说"N 个目标"(那是另一个维度);
// ③ 装了机制的工作区**一律列出**(含还没登记目标的)—— 用户明令
// 「加载机制就可以显示」(⛔ 不许因为没目标就藏起来)。
// ⚠️ 我上一版写成「全部(N 个目标)」,是把两个维度混了 —— 已纠正。
sel.innerHTML = '<option value="-1">全部('+d.projects.length+' 个工作区)</option>'
+ d.projects.map(function(p,i){
// 🔴🔴 **只印工作区名(`dir`)**(用户 2026-10-06 再次纠正:
// 「工作区下拉筛选为什么要显示目标信息」)。
// ⛔ 别拼 `p.name`(目标名)—— 目标名可能很长(实测那条 30+ 字),
// 把它塞进选项 ⇒ 下拉被撑爆、且**语义错了**(这个控件选的是工作区)。
// ⚠️ 我上一版就写了「有目标 ⇒ 目录名 + 目标名」,注释里明明写着
// 「跟目标没关系」,代码却照样拼了目标名 —— 注释与代码打架,以本条为准。
// 📌 目标信息该显示在**别处**(概要区/卡片),⛔ 不在这个下拉里。
return '<option value="'+i+'">'+esc(p.dir)+'</option>'; }).join("");
if(STATE.cur >= d.projects.length) STATE.cur = -1;
sel.value = String(STATE.cur);
}
render();
}).catch(function(e){
toast("err", '请求失败:'+esc(e));
});
}
/* 🔴🔴 **列键 → 颜色**(2026-10-06 四列改为「目标」后,列键换成 lifecycle 口径)。
⚠️ 列键改名了:`waiting/active/blocked/done`(⛔ 旧的 `pending/running` 已废)。*/
function _stateDot(key){
return key==="done" ? "var(--ok)" : key==="blocked" ? "var(--bad)"
: key==="active" ? "var(--accent)" : "var(--dim2)";
}
/* 🔴🔴🔴 **卡片 = 目标**(2026-10-06 用户三次定案:
「下面的 待执行 执行中 有阻碍 已完成 里面显示的都是目标 目标就是项目」
+「但是你现在 已完成里面显示的 就不是目标信息,连一个目标的名称都没有」)。
⛔ 改前这里渲染的是 `tasks.json` 的**台账条目** ⇒ 标题印出来是任务 id(`3b-v1`/`dark-tokens`),
**一个目标名都没有** —— 用户看的正是这张屏。
✅ 现在服务端 `cards[]` 一条 = 一个目标,字段:`title`(目标名)/`life`/`current`/`_ws`。
📌 卡片三样:**目标名**(主标题,必须是人话标题)|**状态**badge|**工作区名**(末行归属)。
⛔ 没有 `by`/`line`/`artifact`/`t_end` 了 —— 那些是**任务**字段,目标没有。
🔴 当前目标(`current`)用一个小标记区分:一个区常有好几个目标,得看得出"现在在跑哪个"。*/
function _cardHtml(t){
var dot = _stateDot(t.state);
var ws = t._ws
? '<div class="wsrow"><span class="wsname">'+esc(t._ws)+'</span>'+
(t._self ? '<span class="wsself">本工作区</span>' : '')+'</div>'
: '';
var badges = '<span class="badge">'+esc(t.life||"")+'</span>';
if(t.current) badges += '<span class="badge cur">当前</span>';
// 🔴🔴 「最后状态时间」(2026-10-11 用户:「所有目标项目都需要 加上时间(最后状态的时间)」)。
// 取 `cards[].life_at`(目标自己的 `lifecycle_at`)——⛔ **不是 `declared_at`**(那是登记时间,
// 两者实测能差好几天:`contentm_agent` 一张卡 登记 10-10 22:24 / 状态 10-10 09:35)。
badges += _whenBadge(t.life_at);
return '<div class="card'+(t._self?" is-self":"")+'" style="border-left-color:'+dot+
'" data-tid="'+esc(t.id)+'" data-pw="'+esc(t._ws||"")+'">'+
// 🔴 主标题=**目标名**(人话标题);⛔ 别回落到 id(那正是"看着不像目标"的原因)
'<div class="t">'+esc(t.title || "(未命名目标)")+'</div>'+
'<div class="meta">'+badges+'</div>'+
ws+'</div>';
}
/* ── 「最后状态时间」小标 ──
🔴 数据源=`cards[].life_at`(=目标 `goal.json::lifecycle_at`,即**最后一次状态变更**的时刻)。
⛔ 不许回落成 `declared_at`:那是"登记目标"的时间,用户要的是"最后状态的时间"。
⚠️ 空值 ⇒ **明说「未记录」**(实测 `aigc-idea-impression` 当前目标就是空的:
`goalctl` 建目标时不一定写 `lifecycle_at`)—— ⛔ 不静默留白、⛔ 不拿别的时间顶。 */
function _whenBadge(v){
if(!v) return '<span class="badge when miss" title="最后状态时间:目标没记 lifecycle_at">未记录</span>';
return '<span class="badge when" title="最后状态时间">'+esc(fmtWhen(v))+'</span>';
}
/* ── 把**当前视图**下所有工作区的目标卡摊平成一维 ──
看板四列分栏 与 项目目标列表页 **共用这一份**(⛔ 别各写一遍:同族"一条规则两份实现必然漂")。
`cur >= 0` ⇒ 只看选中工作区(与下拉选择一致)。 */
function _collectCards(){
var ps = (STATE.data && STATE.data.projects) || [];
if(STATE.cur >= 0){ ps = ps[STATE.cur] ? [ps[STATE.cur]] : []; }
var out = [];
ps.forEach(function(p){
(p.cards || []).forEach(function(c){
var x = {}; for(var k in c){ x[k] = c[k]; }
x._ws = p.dir; // 🔴 卡片标签=**工作区名**(用户口径);也是 openDetail 反查键
x._goal = p.name; // 目标短名(⛔ 不上卡片标签,仅供详情/排查)
x._self = !!p.self; // 🔴 本工作区 ⇒ 卡片换色突出
x._life = p.life || "";
out.push(x);
});
});
return out;
}
/* 列表页的状态筛选项(🔴 从服务给的 `cols` 派生 ⇒ ⛔ 不在前端另抄一份四态) */
function _filterDefs(){
var defs = [["all", "全部"]];
((STATE.data && STATE.data.cols) || []).forEach(function(c){ defs.push([c.key, c.label]); });
return defs;
}
/* 🔴 「已完成」列在看板上只铺前 8 张(用户 2026-10-11:「已完成显示前8各」)——
其余进「更多」⇒ 目标列表页看全量。只截这一列,其余三列照旧全显。 */
var DONE_LIMIT = 8;
function _colHtml(key, label, list){
var shown = list, more = "";
if(key === "done" && list.length > DONE_LIMIT){
shown = list.slice(0, DONE_LIMIT);
more = '<button class="more" type="button" data-col="'+esc(key)+'" '+
'title="点开「项目目标列表」看这一列的全部目标">更多(还有 '+
(list.length - DONE_LIMIT)+' 个)→</button>';
}
var inner = list.length ? shown.map(_cardHtml).join("") + more
: '<div class="empty">(此列无目标)</div>';
// 🔴 四态列**一律等宽**(⛔ 别给空列收窄;2026-10-06 用户「现在是改乱后的样子」)。
// ⚠️ 计数徽标=该列**总数**(⛔ 不是"已铺张数")—— 收起多少由「更多」那行自己说。
return '<section class="col"><div class="colhead"><span class="dot" style="background:'+
_stateDot(key)+'"></span><span class="coltitle">'+esc(label)+'</span><span class="count">'+
list.length+'</span></div><div class="cards">'+inner+'</div></section>';
}
function _bindCards(){
Array.prototype.forEach.call(document.querySelectorAll(".card, .lvrow"), function(el){
el.addEventListener("click", function(){
openDetail(el.getAttribute("data-tid"), el.getAttribute("data-pw"));
});
});
// 「更多」⇒ 项目目标列表页(「已完成」列张数 > 8 时才存在)
Array.prototype.forEach.call(document.querySelectorAll(".more"), function(el){
el.addEventListener("click", function(e){
e.stopPropagation();
openGoals(el.getAttribute("data-col") || "done");
});
});
}
/* ── 统一视图(⛔ 不按工作区分块):一张四列看板,卡片自带「目标/工作区」标签 ──
用户 2026-10-06:「这里分类不要按照工作区分了,记住 项目管理是统一管理」 */
function renderAll(){
var d = STATE.data, ps = d.projects;
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
// 🔴🔴 **两个维度分开报**(2026-10-06 用户定案:四列是「目标」维度):
// ① 工作区=目录(装了机制的)② 目标=登记过的那件事(**=四列里的卡片**,
// 🔴 一个工作区可有**多个**目标:当前 1 个 + 历史 N 个)
// ③ 任务=目标下的台账条目(细账,⛔ 不再上四列)。
var nWs = ps.length;
var nGoal = 0;
ps.forEach(function(p){ nGoal += (p.n_goals || 0); });
var gcol = {waiting:0, active:0, blocked:0, done:0};
ps.forEach(function(p){
var g = p.goal_by_col || {};
for(var k in gcol) gcol[k] += (g[k] || 0);
});
var s = [];
s.push('<span class="chip">工作区 <b>'+nWs+'</b> 个</span>');
s.push('<span class="chip">目标 <b>'+nGoal+'</b> 个</span>');
s.push('<span class="chip">其中执行中 <b>'+gcol.active+'</b></span>');
s.push('<span class="chip">已完成 <b>'+gcol.done+'</b></span>');
if(gcol.blocked) s.push('<span class="chip">有阻碍 <b>'+gcol.blocked+'</b></span>');
// 任务细账(⛔ 与上面分栏:这是"目标之下的工作量")
var taskTot = 0, taskDone = 0;
ps.forEach(function(p){ taskTot += (p.queue&&p.queue.total)||0; taskDone += (p.queue&&p.queue.done)||0; });
if(taskTot) s.push('<span class="chip">任务台账 <b>'+taskDone+' / '+taskTot+'</b></span>');
sums.innerHTML = s.join("");
// 提示(同样收进「!」)
var nt = [];
var noGoal = ps.filter(function(x){ return !x.has_goal; });
if(noGoal.length)
nt.push('<div class="iw">有 <b>'+noGoal.length+'</b> 个工作区<b>装了机制但还没登记目标</b>:'+
esc(noGoal.map(function(x){ return x.dir; }).join("、"))+
' ⇒ 在那些工作区里用 <code>goalctl declare --title "…"</code> 登记</div>');
// 🔴 有目标但**落不进四列**的(已暂停 / 状态读不出)⇒ 必须说出来,⛔ 不静默丢
// (同族 P0:「作用域不许静默排除」—— 否则"目标怎么少了一个"永远查不出)
var unknown = [];
ps.forEach(function(x){ (x.life_unknown||[]).forEach(function(u){ unknown.push(u); }); });
if(unknown.length)
nt.push('<div class="iw">有 <b>'+unknown.length+'</b> 个目标<b>不在四列里</b>(已暂停/状态读不出):'+
esc(unknown.join("、"))+'</div>');
setInfo(nt);
if(!ps.length){
cols.innerHTML = '<div class="empty">本机没有装了机制的工作区。点右上角「+ 新建目标」建一个。</div>';
return;
}
// 🔴 所有目标的所有卡片汇到一张板;每张卡片标出「目标 · 工作区」
// (摊平逻辑走 `_collectCards()` —— 与目标列表页**共用一份**,⛔ 不各写一遍)
var byState = {};
_collectCards().forEach(function(x){
(byState[x.state] = byState[x.state] || []).push(x);
});
// 🔴🔴 容器 `#cols` **本身**就带 `class="cols"`(已是 4 列 grid)⇒ 这里**只铺列**,
// ⛔ 绝不能再包一层 `<div class="cols">`。2026-10-06 实测踩到:套两层 grid 后,
// 内层作为外层的一个 grid item 先被压到 `min-width:auto`(=内容宽 399px),
// 再在 399px 里自己切 4 列 ⇒ 每列 89px、卡片 67px,**整块挤在左边一条**。
cols.innerHTML = d.cols.map(function(c){
return _colHtml(c.key, c.label, byState[c.key] || []);
}).join("");
_bindCards();
}
/* ── 视图 B:单个目标(原行为)── */
function renderOne(){
var d = STATE.data;
var p = d.projects[STATE.cur];
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
if(!p){ renderAll(); return; }
// (原「btndel 启用/禁用」两处已随删除按钮一并移除,2026-10-11)
// 概要(🔴 顺序=**目标在前、任务细账在后**;用户口径:四列装目标)
var q = p.queue, s = [];
if(p.life) s.push('<span class="chip">目标状态 <b>'+esc(p.life)+'</b></span>');
s.push('<span class="chip">目标名 <b>'+esc(p.name)+'</b></span>');
s.push('<span class="chip">工作区 <b>'+esc(p.dir)+'</b></span>');
s.push('<span class="chip">建立 <b>'+esc(fmtWhen(p.declared_at))+'</b></span>');
if(p.acc && p.acc.n) s.push('<span class="chip">验收 <b>'+p.acc.pass+' / '+p.acc.n+'</b></span>');
// 任务细账(⛔ 与"目标"分栏:这是目标之下的工作量)
if(q.total)
s.push('<span class="chip">任务 <b>'+q.done+' / '+q.total+'</b></span>');
if(q.blocked) s.push('<span class="chip">其中受阻 <b>'+q.blocked+'</b></span>');
if(p.topics && p.topics.length)
s.push('<span class="chip">任务类别 <b>'+esc(p.topics.join("、"))+'</b></span>');
sums.innerHTML = s.join("");
// ── 提示:一律收进右上角「!」图标里(⛔ 不铺在工作台)──
var nt = [];
if(!p.has_goal)
nt.push('<div class="iw">这个工作区<b>还没有登记目标</b>(只有机制骨架)⇒ 四列会是空的 —— '+
'用 <code>goalctl declare --title "…"</code> 登记后才会出现目标卡。</div>');
if(p.life && p.life !== "进行中")
nt.push('<div class="iw">该目标状态为「'+esc(p.life)+'」('+esc(fmtWhen(p.life_at))+')。</div>');
if(p.life_raw && p.life_raw !== p.life)
nt.push('<div class="iw">该目标 <code>lifecycle</code> 原始值=「'+esc(p.life_raw)+
'」,已归一为「'+esc(p.life||"(认不出)")+'」。</div>');
setInfo(nt);
// 列(🔴 单目标视图 ⇒ 四列里最多就这一张目标卡,落它自己的状态列)
var byState = {};
_collectCards().forEach(function(x){
(byState[x.state] = byState[x.state] || []).push(x);
});
cols.innerHTML = d.cols.map(function(c){
return _colHtml(c.key, c.label, byState[c.key] || []);
}).join("");
// 卡片点开 → 详情(含跳会话)
_bindCards();
}
/* ── 视图 C:**项目目标列表页**(页内视图,URL = `#/goals/<列>`)──
🔴 用户 2026-10-11:「已完成显示前8各,其余点击更多 到项目目标列表页查看」。
形态选择(用户说"新开一个页面,或页内切一个视图,都可以"):
✅ 走**页内视图 + URL hash 路由** —— 理由三条:
① 数据已经在手(`STATE.data`)⇒ 不重取、不新增接口 ② 头部/样式/详情抽屉全部复用,
⛔ 不出现"两套皮肤" ③ 改的只有 `.html`,服务每次请求读盘 ⇒ **不用重启**。
🔴 URL 带 hash ⇒ 可收藏、可直接刷新到列表页、浏览器「后退」能回看板。 */
function openGoals(col){
STATE.goalsFilter = col || "done";
location.hash = "#/goals/" + STATE.goalsFilter;
}
function backToBoard(){
closeDetail(); // 🔴 切视图顺手关掉抽屉 —— 否则遮罩(mask)盖着看板,按钮点不到
location.hash = "#/board";
}
/* 从 URL 判当前视图(刷新/后退/直接粘链接进来都能对上) */
function applyHash(){
var m = String(location.hash || "").match(/^#\/goals(?:\/([a-z]+))?/);
if(m){
STATE.view = "goals";
if(m[1] && _filterDefs().some(function(x){ return x[0] === m[1]; })) STATE.goalsFilter = m[1];
} else {
STATE.view = "board";
}
}
/* 铺列表页(🔴 直接覆盖 `#cols` —— 头部概要/提示仍用看板那套,两视图口径一致) */
function paintGoals(){
var colsEl = document.getElementById("cols");
var defs = _filterDefs();
var label = {}; defs.forEach(function(x){ label[x[0]] = x[1]; });
var all = _collectCards();
// 各状态张数(⛔ 不重算口径:state 是服务按 lifecycle 定的,这里只数个数)
var cnt = {all: all.length};
defs.forEach(function(x){ if(x[0] !== "all") cnt[x[0]] = 0; });
all.forEach(function(c){ if(cnt[c.state] != null) cnt[c.state] += 1; });
// 排序:**最后状态时间新的在前**(空值垫底,再用登记时间兜底)——
// 「更多」是从"最近做完的"往下看,时间倒序最合直觉。
all.sort(function(a, b){
var x = String(a.life_at || ""), y = String(b.life_at || "");
if(!!x !== !!y) return x ? -1 : 1;
if(x !== y) return x < y ? 1 : -1;
var u = String(a.declared_at || ""), v = String(b.declared_at || "");
if(u === v) return 0;
return u < v ? 1 : -1;
});
var f = (cnt[STATE.goalsFilter] != null) ? STATE.goalsFilter : "all";
var list = (f === "all") ? all : all.filter(function(c){ return c.state === f; });
var chips = defs.map(function(x){
return '<span class="lvchip'+(x[0] === f ? " on" : "")+'" data-f="'+esc(x[0])+'" '+
'role="button" tabindex="0">'+esc(x[1])+' <b>'+cnt[x[0]]+'</b></span>';
}).join("");
var rows = list.map(function(c){
// 🔴 每行都要有「最后状态时间」(同卡片口径:取 `life_at`,⛔ 不用 `declared_at`)
var when = c.life_at
? '<span class="lwhen" title="最后状态时间">'+esc(fmtWhen(c.life_at))+'</span>'
: '<span class="lwhen miss" title="最后状态时间:目标没记 lifecycle_at">时间未记录</span>';
return '<div class="lvrow'+(c._self ? " is-self" : "")+
'" style="border-left-color:'+_stateDot(c.state)+
'" data-tid="'+esc(c.id)+'" data-pw="'+esc(c._ws||"")+'">'+
'<span class="lt">'+esc(c.title || "(未命名目标)")+'</span>'+
'<span class="badge">'+esc(c.life || "")+'</span>'+
(c.current ? '<span class="badge cur">当前</span>' : '')+
when+'<span class="lws">'+esc(c._ws || "")+'</span></div>';
}).join("");
if(!list.length) rows = '<div class="empty">(这个状态下没有目标)</div>';
// 🔴 不在四列里的目标(状态读不出)也说出来 —— ⛔ 列表页不许静默少东西
// (同族 P0:「作用域不许静默排除」)
var unknown = [];
(STATE.data.projects || []).forEach(function(p){
(p.life_unknown || []).forEach(function(u){ unknown.push(u); });
});
var tail = unknown.length
? '<div class="lvsub">另有 '+unknown.length+' 个目标<b>不在四列里</b>(状态读不出):'+
esc(unknown.join("、"))+'</div>'
: "";
var scope = (STATE.cur >= 0 && STATE.data.projects[STATE.cur])
? esc(STATE.data.projects[STATE.cur].dir) : "全部工作区";
colsEl.innerHTML = '<div class="lvwrap">'+
'<div class="lvhead">'+
'<button id="lvback" type="button">← 返回看板</button>'+
'<div class="lvtitle">项目目标列表</div>'+
'<div class="lvsub">范围 '+scope+' · 共 <b>'+all.length+'</b> 个目标'+
(f === "all" ? "" : ' · 当前只看「'+esc(label[f])+'」')+'</div>'+
'</div>'+
'<div class="lvchips">'+chips+'</div>'+
'<div class="lvlist">'+rows+'</div>'+
tail+
'<div class="lvsub">点任一行看详情(含验收判据、任务细账)。'+
'看板「已完成」列每屏只铺前 '+DONE_LIMIT+' 张,其余都在这里。</div>'+
'</div>';
document.getElementById("lvback").addEventListener("click", backToBoard);
Array.prototype.forEach.call(colsEl.querySelectorAll(".lvchip"), function(el){
var go = function(){
var nf = el.getAttribute("data-f");
if(nf === STATE.goalsFilter){ render(); return; } // hash 不变 ⇒ 自己重铺一次
STATE.goalsFilter = nf;
location.hash = "#/goals/" + nf; // ⇒ hashchange ⇒ render()
};
el.addEventListener("click", go);
el.addEventListener("keydown", function(e){
if(e.key === "Enter" || e.key === " "){ e.preventDefault(); go(); }
});
});
_bindCards();
}
/* ── 视图分发 ── */
function render(){
if(!STATE.data) return;
var colsEl = document.getElementById("cols");
if(STATE.view === "goals"){
colsEl.classList.add("aslist"); // 🔴 4 列 grid ⇒ block(否则整页挤进第 1 列)
// 头部(概要/提示「!」)复用看板那套 ⇒ 先铺一次,随后用列表覆盖列区。
// ⚠️ 代价=多渲染一次列 DOM(几十个节点),换来"两视图头部口径完全一致 + 零重复代码"。
if(STATE.cur < 0) renderAll(); else renderOne();
paintGoals();
return;
}
colsEl.classList.remove("aslist");
if(STATE.cur < 0) renderAll(); else renderOne();
}
function openDetail(tid, wsHint){
var proj = STATE.data.projects;
// 「全部」视图下卡片自带所属工作区;单项目视图下就是当前那个
var p = (wsHint ? proj.filter(function(x){ return x.dir === wsHint; })[0] : null)
|| proj[STATE.cur] || {};
var t = (p.cards || []).filter(function(x){ return x.id === tid; })[0] || {};
var label = {}; STATE.data.cols.forEach(function(c){ label[c.key] = c.label; });
// 🔴 抽屉标题=**目标名**(⛔ 不是 id:id 是 `vibe-product@cur` 这种机读串,人看不懂)
document.getElementById("dtitle").textContent = t.title || p.name || tid;
var h = [];
h.push('<dl class="kv">');
// 🔴🔴 卡片=**目标**(2026-10-06):详情页主档印**该目标自己**的字段。
// ⛔ 不要用 `p.xxx`(`p` 是**工作区**,它的 `declared_at`/`topics`/`life` 只属于
// **当前目标**)⇒ 点历史目标会读到当前目标的值(张冠李戴,同 `acc` 那个缺陷)。
// ⛔ 也不再印任务的 `line`/`by`/`t_start`/`t_end`(目标没有这些)。
h.push('<dt>目标</dt><dd>'+esc(t.title || p.name || "—")+'</dd>');
h.push('<dt>状态</dt><dd>'+esc(t.life || label[t.state] || t.state || "—")+'</dd>');
if(t.current) h.push('<dt>当前目标</dt><dd>是</dd>');
if(t.short) h.push('<dt>目标 id</dt><dd>'+esc(t.short)+'</dd>');
h.push('<dt>工作区</dt><dd>'+esc(p.dir || "—")+'</dd>');
if(t.declared_at) h.push('<dt>登记于</dt><dd>'+esc(fmtWhen(t.declared_at))+'</dd>');
if(t.life_at) h.push('<dt>状态更新</dt><dd>'+esc(fmtWhen(t.life_at))+'</dd>');
if(t.topics && t.topics.length) h.push('<dt>任务类别</dt><dd>'+esc(t.topics.join("、"))+'</dd>');
h.push('<dt>目标目录</dt><dd>'+esc(t.goal_dir || p.goal_dir || "—")+'</dd>');
h.push('</dl>');
// 验收判据(🔴🔴 2026-10-06 用户:「判据 用卡片显示,现在列表字段都挤成竖列了」)
// ⇒ **一卡一条**:首行=键 + 通过标记(右对齐);下面=值**全文**(⛔ 不再 `slice(0,120)` 截断
// —— 截断会让用户以为"判据就这么短",而实际一条上百字;卡片布局已能自然换行)。
// 🔴🔴 **判据取卡片自己的 `t.acc`**(⛔ 不是 `p.acc`):一个区可有多个目标,各有各的判据,
// 点历史目标的卡却显示当前目标的判据=**张冠李戴**(本轮实测到的缺陷)。
var acc = t.acc || p.acc || {};
h.push('<div class="sect"><h4>验收判据('+(acc.n||0)+' 条 · 通过 '+(acc.pass||0)+')</h4>');
if(acc.items && acc.items.length){
h.push('<div class="pcards">');
acc.items.forEach(function(a){
h.push('<div class="pcard '+(a.pass?"ok":"no")+'">'+
'<div class="phead"><span class="pk">'+esc(a.k)+'</span>'+
'<span class="pst">'+(a.pass?"过":"未过")+'</span></div>'+
'<div class="pv">'+esc(a.v || "(空)")+'</div></div>');
});
h.push('</div>');
} else h.push('<div class="empty">(无验收判据)</div>');
h.push('</div>');
// 工作流(🔴 这一节=**任务维度**细账,⛔ 不属于目标本身;标题里点明,免混淆)
h.push('<div class="sect"><h4>任务细账(工作流分组)</h4>');
if((p.flows||[]).length){
p.flows.forEach(function(f){
h.push('<div class="fcard"><div class="fname">'+esc(f.line)+'</div>'+
'<div class="fmeta">共 '+f.total+' · 完成 '+f.done+' · 未完 '+f.open+'</div></div>');
});
} else h.push('<div class="empty">(无任务台账)</div>');
h.push('</div>');
document.getElementById("dbody").innerHTML = h.join("");
document.getElementById("mask").classList.add("on");
document.getElementById("drawer").classList.add("on");
document.getElementById("drawer").setAttribute("aria-hidden","false");
}
function closeDetail(){
document.getElementById("mask").classList.remove("on");
document.getElementById("drawer").classList.remove("on");
document.getElementById("drawer").setAttribute("aria-hidden","true");
}
/* ── 新建目标(新工作区) ── */
function openNew(){
var d = STATE.data || {};
var r = document.getElementById("froot");
r.innerHTML = (d.roots||[]).map(function(x){
return '<option value="'+esc(x)+'">'+esc(x)+'</option>'; }).join("");
document.getElementById("fname").value = "";
document.getElementById("ftitle").value = "";
document.getElementById("fshort").value = "";
document.getElementById("fnote").textContent = "";
document.getElementById("fnote").className = "fnote";
document.getElementById("mstamp").textContent = "";
document.getElementById("mok").disabled = false;
document.getElementById("cmask").classList.add("on");
document.getElementById("modal").classList.add("on");
document.getElementById("fname").focus();
}
function closeNew(){
document.getElementById("cmask").classList.remove("on");
document.getElementById("modal").classList.remove("on");
}
function doCreate(){
var root = document.getElementById("froot").value;
var name = document.getElementById("fname").value.trim();
var title = document.getElementById("ftitle").value.trim();
var short = document.getElementById("fshort").value.trim();
var note = document.getElementById("fnote");
if(!name || /[\/\\<>:"|?*]/.test(name)){
note.className = "fnote bad";
note.textContent = "工作区名不合法(不许空、不许带路径分隔符或 <>:\"|?*)";
return;
}
if(!title){ title = name; }
document.getElementById("mok").disabled = true;
document.getElementById("mstamp").textContent = "创建中…(要跑建区脚本,稍等)";
fetch("/api/create", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({root:root, name:name, title:title, short:short})})
.then(function(r){ return r.json(); })
.then(function(d){
document.getElementById("mok").disabled = false;
if(d.ok){
note.className = "fnote";
note.innerHTML = '✅ 已创建:<b>'+esc(d.ws)+'</b>';
document.getElementById("mstamp").textContent = "";
toast("ok", '创建成功:'+esc(d.ws) + (d.out ? '<pre class="out">'+esc(d.out)+'</pre>' : ""));
setTimeout(function(){ closeNew(); load(); }, 900);
} else {
note.className = "fnote bad";
note.textContent = "创建失败:" + (d.err || "未知错误");
document.getElementById("mstamp").textContent = "rc=" + (d.rc==null?"-":d.rc);
}
}).catch(function(e){
document.getElementById("mok").disabled = false;
note.className = "fnote bad";
note.textContent = "请求失败:" + e;
});
}
/* (原 doDelete「删除当前项目」已移除,2026-10-11 用户令:去掉工作区后的删除功能) */
document.getElementById("mask").addEventListener("click", closeDetail);
document.getElementById("dclose").addEventListener("click", closeDetail);
document.getElementById("cmask").addEventListener("click", closeNew);
document.getElementById("mclose").addEventListener("click", closeNew);
document.getElementById("mcancel").addEventListener("click", closeNew);
document.getElementById("mok").addEventListener("click", doCreate);
document.getElementById("btnnew").addEventListener("click", openNew);
/* (原 btndel → doDelete 的事件绑定已移除,2026-10-11) */
document.getElementById("psel").addEventListener("change", function(e){
STATE.cur = parseInt(e.target.value, 10);
if(isNaN(STATE.cur)) STATE.cur = -1;
render(); });
document.getElementById("refresh").addEventListener("click", function(){ load(true); });
/* 「!」状态说明:点开/再点关、悬浮可看、点外面关 */
(function(){
var btn = document.getElementById("infobtn");
btn.addEventListener("click", function(e){ e.stopPropagation(); toggleInfo(); });
btn.addEventListener("keydown", function(e){
if(e.key === "Enter" || e.key === " "){ e.preventDefault(); toggleInfo(); } });
document.addEventListener("click", function(e){
var pop = document.getElementById("infopop");
if(pop && pop.classList.contains("on") && !btn.contains(e.target)) toggleInfo(false);
});
})();
document.addEventListener("keydown", function(e){
if(e.key === "Escape"){ closeDetail(); closeNew(); toggleInfo(false); } });
/* 🔴 URL 路由:`#/goals/<列>` = 目标列表页;其余 ⇒ 看板。
(刷新页面 / 粘链接 / 按浏览器后退 都能对到正确视图,⛔ 不靠内存里的开关) */
window.addEventListener("hashchange", function(){ applyHash(); render(); });
applyHash();
loop(); // 轮询节奏会随「数据有没有变」自适应(见 currentPoll)
setInterval(paintAge, 1000); // 时效读数每秒重画(⛔ 不重新取数)
</script>
</body>
</html>
@@ -0,0 +1,754 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""项目管理界面 —— 管**本机所有工作区**的项目(照 dsh-task-board 的「项目 × 卡片」模式)。
## 这是什么(用户原话)
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面
> 来管理 执行任务,**目标等于项目**」
> 「项目管理管的是**本机所有工作区的项目** 不是单个工作区的,
> 可以通过项目管理工具 **创建新目标(新工作区)**,管理目标」
⇒ 硬约束:
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动);
② ⛔ **不整体搬** dsh-task-board(DSH 插件、20k 行 TS,宿主不同);
③ ✅ 照它的模式:项目列表 + 卡片列 + 点卡片看详情;
④ ✅ 范围 = **本机所有装了机制的工作区**(⛔ 不是单区、⛔ 不是靠同伴清单);
⑤ ✅ 能**建新目标(新工作区)**、能**删**、能**查**;⛔ **不改状态**(状态交给会话机制自己处理)。
## 数据从哪来(🔴 只读 + 一处写)
**读**:直接扫本机工作区根目录下每个区自己的文件 ——
· `<区>/tmp/supervise-inbox/goal.json` → **项目**(title/short/lifecycle/acceptance)
· `<区>/tmp/supervise-inbox/tasks.json` → **卡片**(台账,`state` → 列)
· `<区>/tmp/supervise-inbox/collabd-state.json` → 队列统计
· `<区>/.workbuddy/collab/collabd.config.json` → 该区判「装没装机制」的依据
⚠️ **为什么直读文件而不是拉各区看板**:并非每个区都起着 `board.py`(实测本机 3 个装了
机制的区里,只有 1 个在跑看板)⇒ 拉看板会**静默漏掉没起看板的区**(="只看到一部分")。
直读文件是**各区自己的真源**,不依赖谁在跑。
**写**:只有两处,且都是**动作型**(⛔ 不在轮询里写任何东西)——
· 建新目标/新工作区:调技能自带的 `init_workspace.py`(幂等,五步齐全)
· 删:把该工作区**整体送回收站**(⛔ 不 `rm`;可逆),或只删该区的目标登记(轻量,见下)
## 映射
· 一个**工作区** → 一个**项目**(`goal.json` 的 `short`/`title` 是项目名)
· 该区**台账条目** → **卡片**(`state` → 四列)
· 该区**会话** → 卡片可跳的会话(`id8` 是跳转键)
## 列(⛔ 用机制真实四态,不硬套它那五列)
本机制台账只有 **待执行 / 执行中 / 有阻碍 / 已完成** 四态;
硬套 dsh-task-board 的五列(含「待规划」)会凭空多出机制里**不存在**的一列。
## 起法
python project-board.py --serve 20100 # ⛔ 与 20099 各起各的,互不影响
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HERE = Path(__file__).resolve().parent
PAGE = HERE / "project-board.html"
# 🔴 扫哪些根目录找工作区(`;` 分隔;可用环境变量覆盖)
WS_ROOTS = [Path(p) for p in (
os.environ.get("DSH_PM_BOARD_ROOTS") or "E:/ProgramData/AIProject"
).split(";") if p.strip()]
# 技能自带的新建入口(⛔ 不自己另写一套建区逻辑 —— 同族"一条规则两份实现"必漂)
# ⚠️ 2026-10-14:看板整包搬进 `boards/`(用户令「开个 boards 文件夹放两个看板」)⇒
# **看板真身**与**机制脚本**不再同目录,⛔ 别再用一个常量指两处。
# · `SKILL_BOARD_DIR` = 会话看板(`board.py::acc_is_pass` 的取用处)
# · `SKILL_SCRIPTS` = 机制脚本(`init_workspace.py` 的取用处)
_SKILL_ROOT = Path(os.environ.get("DSH_SKILL_ROOT")
or r"E:/ProgramData/.workbuddy/skills/session-mechanism")
SKILL_BOARD_DIR = Path(os.environ.get("DSH_SKILL_BOARD_DIR")
or str(_SKILL_ROOT / "boards" / "会话看板"))
SKILL_SCRIPTS = Path(os.environ.get("DSH_SKILL_SCRIPTS")
or str(_SKILL_ROOT / "scripts"))
# 可选:某个区的旧看板(**只作补充**,主力是直读文件)
UPSTREAM = os.environ.get("DSH_PROJECT_BOARD_UPSTREAM") or ""
# 🔴 「本工作区」=**起这个服务的那个工作区**(2026-10-06 用户要:本工作区的卡片换色突出)。
# ⛔ 不许在前端硬编码目录名 —— 换机 / 改名就漂。
# 取法:环境变量优先(launch 脚本可显式注入),否则用**进程 cwd**(计划任务的动作 cwd
# 就是工作区根)⇒ 两者都拿不到就 `""`(前端一律不高亮,⛔ 不猜)。
SELF_WS = (os.environ.get("DSH_PM_BOARD_SELF_WS") or "").strip() or str(Path.cwd())
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ⛔ 绕过 HTTP_PROXY
def _say(msg: str) -> None:
"""🔴🔴 **稳健的输出**(2026-10-06 实测栽过)。
病根:计划任务用 **`pythonw.exe`** 跑 ⇒ **没有控制台** ⇒ `sys.stdout is None`
⇒ `print()` / `sys.stdout.flush()` 直接 `AttributeError: 'NoneType' object has no
attribute 'flush'` ⇒ **进程当场崩**(而 `Result=1` 只说"失败",看不出为什么)。
✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。
"""
f = getattr(sys, "stdout", None)
if f is None:
return
try:
f.write(msg + "\n")
f.flush()
except Exception:
pass
# 台账四态 → 列(🔴 与 board.py::_queue 的 `by` 键**逐字一致**)
COLS = [
("waiting", "待执行"),
("active", "执行中"),
("blocked", "有阻碍"),
("done", "已完成"),
]
# 🔴🔴🔴 **四列装的是「目标」不是「任务」**(2026-10-06 用户定案:
# 「下面的 待执行 执行中 有阻碍 已完成 里面显示的都是目标 目标就是项目能明白吗」)。
# ⇒ 列的内容=**目标本身**(`goal.json` 一条=一个目标=一个项目),按 `lifecycle` 分组。
# ⛔ 改前我用 `tasks.json` 的**台账条目**填这四列 —— 填错了:
# 台账条目是"任务"(`3b-v1` / `dark-tokens` 这种执行记录),不是目标。
# ⚠️ 这两者在本机制里**是不同层级**:目标(goal)= 一件事;任务(task)= 那件事下的一步。
#
# 🔴 **lifecycle 真身取值**(⛔ 别自己另定一套,抄 `collabd.py` 的 `GOAL_LIFE_*`):
# `等待` / `进行中` / `阻碍` / `已完成` / `已暂停`
# ⇒ 映射到本界面四列:等待→waiting|进行中→active|阻碍→blocked|已完成→done;
# `已暂停`(=换目标时被弃、没做完的归档目标)**归入 waiting** ——
# 2026-10-11 用户定:「这类就属于暂停状态 可以归入到 待执行」。
LIFE_TO_COL = {
"等待": "waiting",
"进行中": "active",
"阻碍": "blocked",
"已完成": "done",
"已暂停": "waiting",
}
_LIFE_NOCOL = () # 无「有此项无列」的状态(原装「已暂停」,2026-10-11 起它有列)
# 🔴 别名表**必须与 `collabd.py::_LIFE_ALIAS` 同源**(同族"一条规则两份实现必然漂")。
_LIFE_ALIAS = {"等待": "waiting", "进行中": "active", "阻碍": "blocked",
"已完成": "done", "已暂停": "paused",
"waiting": "等待", "active": "进行中", "blocked": "阻碍",
"done": "已完成", "paused": "已暂停"}
_LIFE_ALL = ("等待", "进行中", "阻碍", "已完成", "已暂停")
def goal_life_of(goal: dict) -> str:
"""把 `goal.json` 的 `lifecycle` **归一到标准五值之一**;认不出 ⇒ 返回 `""`。
🔴🔴 **必须与机制同源**(`collabd.py::goal_life_of` 的口径),否则两边判两个样:
· **剥括号后缀**:实测 `vibe-product` 写过 `"已完成(机器可判部分)"`(手改进去的),
不剥 ⇒ 精确匹配失败 ⇒ 静默回落,这类"读错"在界面上的样子就是**目标凭空消失**。
· 别名容错:接受旧英文写法(`waiting`/`active`/…)。
⚠️ 认不出返回 `""`(⛔ 不猜、不回落成某列)—— 让调用方把它当"状态未知"另行提示,
而不是塞进某个列里冒充正常(同族 P0:「认不出不许静默回落」)。
"""
raw = str((goal or {}).get("lifecycle") or "").strip()
v = raw
if "(" in v:
v = v.split("(", 1)[0].strip()
elif "(" in v:
v = v.split("(", 1)[0].strip()
if v in _LIFE_ALL:
return v
v2 = _LIFE_ALIAS.get(v, v)
if v2 in _LIFE_ALL:
return v2
return ""
def _load_json(p: Path, default=None):
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return default
def _load_all_goals(inbox: Path) -> list:
"""读一个工作区的**全部目标**:`goal.json`(当前)+ `goals/*.json`(历史归档)。
返回 `[(goal_dict, src), ...]`,`src` = `"cur"` 或归档文件名(去 `.json`)。
🔴🔴🔴 **为什么要读 `goals/`**(2026-10-06 用户第三次纠正,实测坐实):
我连错两次 —— 先以为「一个工作区只有一个目标」,并把这条写进了注释与代码。
真相:**一个工作区可以有多个目标**;`goal.json` 只是**当前那个**。
`vibe-product` 实测:当前 1 个(`规划产品原型项目管理工具`,已完成)
+ 历史 2 个(`把产品规划技能整理成唯一形态` 进行中 /
`补抓跳跃视界暗色主题并升级视觉规范 skill…` 已完成)
⇒ 历史目标必须一并列出来,否则「已完成」列永远少一大截
(这正是用户两次追问「已完成里面明明有一堆内容」的答案)。
⚠️ 排序:**当前目标排最前**,历史按 `declared_at` 新的在前(⛔ 不按文件名,
文件名里的时间戳是**归档时刻**,不是登记时刻,两者可能差很多)。
⚠️ 单个归档文件坏了(非 JSON / 读不出)⇒ **跳过它但记一条**,⛔ 不整批失败
(同族 P0:一个坏文件不许把整个列表拖垮,也不许静默丢)。
"""
out = []
cur = _load_json(inbox / "goal.json", None)
if isinstance(cur, dict) and cur:
out.append((cur, "cur"))
gdir = inbox / "goals"
if gdir.is_dir():
hist = []
for p in sorted(gdir.glob("*.json")):
g = _load_json(p, None)
if isinstance(g, dict) and g:
hist.append((g, p.stem))
hist.sort(key=lambda x: str(x[0].get("declared_at") or ""), reverse=True)
out.extend(hist)
return out
def _acc_is_pass(v) -> bool:
"""一条验收判据算不算过 —— **复用技能真身**(⛔ 不自己写词表;见 pitfalls P0-87)。"""
try:
if str(SKILL_BOARD_DIR) not in sys.path:
sys.path.insert(0, str(SKILL_BOARD_DIR))
import board as _b
return bool(_b.acc_is_pass(v))
except Exception:
pass
s = str(v or "").strip()
if not s:
return False
head = re.split(r"[((]", s, maxsplit=1)[0]
for _seg in re.split(r"[::]", head):
_t = _seg.strip().lstrip("*").strip().lower()
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
if _t.startswith(_w):
return True
_t2 = _t
for _ in range(3):
_cut = ""
for _a in ("已", "已经", "均", "都", "经复核", "复核后"):
if _t2.startswith(_a) and len(_a) > len(_cut):
_cut = _a
if not _cut:
break
_t2 = _t2[len(_cut):].strip().lstrip("*").strip()
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
if _t2.startswith(_w):
return True
return False
def scan_workspaces() -> list:
"""扫本机所有工作区根目录,返回**装了机制**的那些区(⛔ 没装的跳过但**单独登记**)。
🔴 「装了机制」的判据(两条任一):
① 有 `<区>/.workbuddy/collab/collabd.config.json`(=部署配置,机制跑起来的必要条件)
② 有 `<区>/tmp/supervise-inbox/goal.json`(=登记过目标)
⚠️ 返回 `(installed, skipped)` 两个列表 —— **跳过的也带出来**,
⛔ 不静默丢(同族 P0:「作用域不许静默排除」)。用户口径是"只列装了机制的",
但界面上要能看见"扫到了但没装",否则"少了一个区"永远查不出来。
"""
installed, skipped = [], []
seen = set()
for root in WS_ROOTS:
if not root.is_dir():
continue
for d in sorted(root.iterdir()):
if not d.is_dir() or d.name.startswith("_") or d.name.startswith("."):
continue
try:
key = str(d.resolve()).lower()
except Exception:
continue
if key in seen:
continue
seen.add(key)
cfg = d / ".workbuddy" / "collab" / "collabd.config.json"
gj = d / "tmp" / "supervise-inbox" / "goal.json"
if cfg.is_file() or gj.is_file():
installed.append(d)
else:
skipped.append(d.name)
return installed, skipped
def _region_dir(ws: Path, goal: dict) -> Path:
"""目标文件夹(`执行会话/目标-<区名>-<短哈希>`)—— ⛔ 与技能同口径,不从目录名猜。"""
ex = str((goal or {}).get("execution_doc") or "").strip()
if ex:
p = ws / ex
if p.parent.is_dir():
return p.parent
return ws / "执行会话"
def build_project(ws: Path) -> dict:
"""把**一个工作区**读成「项目」(纯读,⛔ 不写任何东西)。"""
inbox = ws / "tmp" / "supervise-inbox"
goal = _load_json(inbox / "goal.json", {}) or {}
tasks = _load_json(inbox / "tasks.json", {}) or {}
st = _load_json(inbox / "collabd-state.json", {}) or {}
cfg = _load_json(ws / ".workbuddy" / "collab" / "collabd.config.json", {}) or {}
short = str(goal.get("short") or "").strip()
title = str(goal.get("title") or "").strip()
# 🔴🔴 **`title` 优先,`short` 兜底**(2026-10-06 用户纠正)。
# 改前是 `short or title` ⇒ 凡登记过 `short` 的区,界面一律显示**短名**
# (`vibe-product` 这种目录名),用户原话:「上面的这个…显示的是 目录名,
# 应该是真正名称」。`short` 是给**文件名/路径**用的短标识,⛔ 不是给人读的名字。
# ⇒ 有 `title` 就显示 `title`;没 `title` 才回落 `short`;都没有 ⇒ 标「未登记目标」。
name = title or short or ("未登记目标 · %s" % ws.name)
# ── 卡片 = **目标**(🔴🔴🔴 2026-10-06 用户两次定案)
#
# ⛔ 改前:遍历 `tasks.json`,把**台账条目**当卡片 ⇒ 列里显示的是任务
# (`3b-v1` / `ACCEP-FIX-01` / `dark-tokens`…)⇒ 用户:「已完成里面显示的到底
# 是不是目标 怎么看起来不像」「里面显示的都是目标 目标就是项目」。
# ✅ 现在:**一个目标 = 一张卡**,落哪一列看它自己的 `lifecycle`。
#
# 🔴🔴🔴 **一个工作区并非只有一个目标**(用户第三次纠正,实测坐实):
# · `tmp/supervise-inbox/goal.json` = **当前目标**(恒 1 个)
# · `tmp/supervise-inbox/goals/*.json` = **历史目标归档**(0..N 个,
# 文件名形如 `<标题截断>__YYYYMMDD-HHMM.json`,换目标时旧的那份挪进来)
# ⇒ **全部目标 = 当前 + 历史**,各自按 `lifecycle` 落列。
# 📌 实测 `vibe-product`:当前 1 个(已完成)+ 历史 2 个(进行中 1 / 已完成 1)
# ⇒ 「已完成」列**本来就该有一堆**(历史上做完的目标),这正是用户两次问的
# 「已完成里面明明有一堆内容」。
#
# ⚠️ 没登记目标的区(`agent-product` 实测)⇒ **不产卡片**(⛔ 别造一张"未登记目标"的卡
# 塞进「待执行」冒充目标 —— 那不是目标,是空位)。它的存在由概要/提示区说明。
# ⚠️ `lifecycle` 认不出(空/怪值)⇒ 也**不产卡片**,只计数(同族:⛔ 认不出不许静默塞列)。
goals_all = _load_all_goals(inbox)
cards = []
life_unknown = []
for g, src in goals_all:
g_title = str(g.get("title") or "").strip()
g_short = str(g.get("short") or "").strip()
if not (g_title or g_short):
continue # 空壳(无标题无短名)⇒ 不是目标
g_name = g_title or g_short
life = goal_life_of(g)
col = LIFE_TO_COL.get(life) if life else None
if col:
# 🔴🔴 **判据必须挂在"该目标"自己身上**(2026-10-06 实测缺陷):
# 历史目标各有自己的 `acceptance_state`(实测:`把产品规划技能整理成唯一形态`
# 有 V1–V4,`补抓跳跃视界暗色主题…` 有 N1–N3),而 `p.acc` 只有**当前目标**的
# ⇒ 点历史目标的卡,详情页会显示**当前目标的判据**(张冠李戴)。
# ✅ 每条目标卡自带 `acc`(该目标自己的判据全集)。
g_acc = g.get("acceptance_state") or {}
g_eff = [k for k in g_acc if not str(k).startswith("_")]
g_items = [{"k": k, "v": str(g_acc[k]), "pass": _acc_is_pass(g_acc[k])}
for k in g_eff]
cards.append({
# 目标短 id + 来源后缀(防同 `short` 的多个目标 id 撞车,跳详情要用)
"id": (g_short or ws.name) + ("@cur" if src == "cur" else "@%s" % src),
"state": col, # ← 列键,来自 **lifecycle**(⛔ 非任务 state)
"title": g_name, # 目标名(=项目名)
"life": life, # 标准五值之一(原样带给前端显示)
"short": g_short,
"current": (src == "cur"), # 🔴 是不是**当前**目标(前端标出来)
"declared_at": str(g.get("declared_at") or "").strip(),
"life_at": str(g.get("lifecycle_at") or "").strip(),
"topics": [str(x) for x in (g.get("topics") or []) if str(x).strip()],
# 🔴 该目标**自己的**判据(⛔ 不是 `p.acc` —— 那是当前目标的)
"acc": {"n": len(g_eff), "pass": sum(1 for i in g_items if i["pass"]),
"items": g_items},
"goal_dir": str(inbox).replace("\\", "/"),
})
else:
# 状态读不出的 ⇒ 记下来另行提示,⛔ 不硬塞进任一列
# (`已暂停` 自 2026-10-11 起有列=waiting,不再走这里)。
life_unknown.append("%s(%s)" % (g_name, life or "状态读不出"))
keys = {c[0] for c in COLS}
by = {k: 0 for k in keys}
for c in cards:
by[c["state"]] += 1
# ── 队列统计(以 collabd-state.json 为准,⛔ 不重算)
qi = st.get("queue_info") or {}
qby = qi.get("by") or {}
# ── 验收判据(🔴 走真身归一化,⛔ 不比字面值)
acc = goal.get("acceptance_state") or {}
eff = [k for k in acc if not str(k).startswith("_")]
acc_pass = sum(1 for k in eff if _acc_is_pass(acc[k]))
acc_items = [{"k": k, "v": str(acc[k]), "pass": _acc_is_pass(acc[k])} for k in eff]
# ── 工作流分组(按**任务台账**的 `line` 聚合)
# ⚠️ 这一块仍走 `tasks.json`(它本来就是"任务维度"的统计,与四列的目标维度**不冲突**):
# 四列=目标视图;`flows`/`queue`=该目标下的任务细账。两者**分层**,⛔ 不再混用。
flows = {}
task_cards = []
for tid, t in (tasks or {}).items():
t = t or {}
s = str(t.get("state") or "").strip().lower()
if s not in keys:
continue
task_cards.append({
"id": tid, "state": s,
"title": str(t.get("title") or tid),
"by": str(t.get("by") or ""),
"line": str(t.get("line") or ""),
"artifact": str(t.get("artifact") or ""),
"block_reason": str(t.get("block_reason") or ""),
"t_start": str(t.get("t_start") or ""),
"t_end": str(t.get("t_end") or ""),
})
for c in task_cards:
ln = c["line"] or "(未分组)"
f = flows.setdefault(ln, {"line": ln, "total": 0, "done": 0, "open": 0})
f["total"] += 1
if c["state"] == "done":
f["done"] += 1
else:
f["open"] += 1
topics = [str(x) for x in (goal.get("topics") or []) if str(x).strip()]
# 🔴 「本工作区」标记(2026-10-06 用户要:本工作区的卡片换个颜色突出)。
# ⛔ 不在前端硬编码目录名 —— 那份名单会随换机/改名漂掉。
# 判据=**服务进程自己的启动目录**(launch 脚本就是从该工作区起的),
# 与 `SelfWs` 同源;比不出来就一律 False(⛔ 不猜)。
try:
_self_ws = Path(SELF_WS).resolve()
_is_self = ws.resolve() == _self_ws
except Exception: # noqa: BLE001
_is_self = False
return {
"ws": str(ws).replace("\\", "/"),
"dir": ws.name,
# 🔴 `name` = **当前目标**的名字(单项目视图的标题用它;无目标时是兜底串)
"name": name,
"self": _is_self,
"title": title,
"topics": topics,
# 🔴🔴 `life` / `life_raw` = **当前目标**的状态(归一化值 / 原始值)。
# ⚠️ 一个工作区可有**多个目标**(当前+历史)⇒ 别把这个当成"该区唯一状态";
# 四列的内容看 `cards`(每张卡自带 `life`)。
"life": goal_life_of(goal),
"life_raw": str(goal.get("lifecycle") or "").strip(),
"life_at": str(goal.get("lifecycle_at") or "").strip(),
"declared_at": str(goal.get("declared_at") or "").strip(),
# 🔴 有**任何**目标(含历史)就算登记过 —— ⛔ 别只看 `goal.json`
"has_goal": bool(goals_all) and any(
(str(g.get("title") or "").strip() or str(g.get("short") or "").strip())
for g, _ in goals_all),
# 🔴🔴 `n_goals` = 该区目标总数(当前+历史);`n_cur` 恒 1 或 0
"n_goals": len(cards) + len(life_unknown),
"n_current": sum(1 for c in cards if c.get("current")),
# 🔴 `cards` = **目标卡**(列内容;一个目标一张)|`tasks` = 任务细账(详情页用)
"cards": cards,
"tasks": task_cards,
"flows": [flows[k] for k in sorted(flows)],
# 🔴🔴 `queue` = **任务队列**统计(⛔ 不是四列计数!两者语义不同,别混用):
# 来源=`collabd-state.json::queue_info`(机制自己的台账)⇒ ⛔ 界面不重算。
# ⚠️ 改前 `pending/running/blocked/done` 用 `qby.get(x) or by.get(x)` 兜底,
# 而 `by` 当时数的是任务 ⇒ 碰巧对;**现在 `by` 数的是目标**,
# 若再留着这个兜底 ⇒ 会把"目标数"当"任务数"报出去(静默串数)。已去掉兜底。
"queue": {
"total": qi.get("n") or 0,
"pending": qby.get("pending") or 0,
"running": qby.get("running") or 0,
"blocked": qby.get("blocked") or 0,
"done": qby.get("done") or 0,
"open": (qby.get("pending") or 0) + (qby.get("running") or 0)
+ (qby.get("blocked") or 0),
},
# 🔴 目标维度计数(=四列各列的张数;一个目标算一个)
"goal_by_col": by,
# 🔴 有目标但**落不进四列**的(状态读不出)⇒ 前端另行提示,⛔ 不硬塞列
"life_unknown": life_unknown,
"acc": {"n": len(eff), "pass": acc_pass, "items": acc_items},
"region": str(_region_dir(ws, goal)).replace("\\", "/"),
"port": cfg.get("singleton_port"),
"goal_dir": str(inbox).replace("\\", "/"),
}
def to_project_view() -> dict:
"""本机所有装了机制的工作区 → 项目管理视图。"""
installed, skipped = scan_workspaces()
projects = [build_project(w) for w in installed]
# 🔴 排序:先按「有目标」→ 再按**建立时间**升序(与看板 tab 同口径:次序不漂)
projects.sort(key=lambda p: (0 if p["has_goal"] else 1, p["declared_at"] or "~", p["name"]))
for i, p in enumerate(projects):
p["i"] = i
return {
"ok": True,
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
# 🔴 `epoch` = 服务出这份快照时的时间戳(秒)。页面拿它算「数据多久没动了」——
# ⛔ 不许页面自己 `Date.now()` 顶数:那样刷新一次就"变新",**只看得出页面在轮询,
# 看不出数据其实没变**。参照现有看板 board.html::paintAge 的口径。
"epoch": time.time(),
"roots": [str(r).replace("\\", "/") for r in WS_ROOTS],
"cols": [{"key": k, "label": lb} for k, lb in COLS],
"projects": projects,
"skipped": skipped,
}
# ── 动作层(⛔ 只在点按钮时跑,⛔ 不在轮询里写任何东西)──────────────────────
def _valid_ws_name(n: str) -> str:
"""工作区目录名校验(⛔ 不许路径穿越、⛔ 不许空)。"""
n = str(n or "").strip()
if not n or n in (".", "..") or "/" in n or "\\" in n:
return ""
if re.search(r'[<>:"|?*]', n):
return ""
return n
def act_create(root: str, name: str, title: str, short: str = "") -> dict:
"""**建新目标(新工作区)** —— 调技能自带的 `init_workspace.py`(幂等)。
🔴 ⛔ 不自己另写一套建区逻辑(那会与技能漂成两份)。只做:
① 校验名字 ② 拼路径 ③ 传给技能脚本 ④ 如实回报它的输出。
⚠️ 非 ASCII 路径:走**列表参数**调子进程(⛔ 不经 shell),避免编码问题。
"""
rootp = Path(root)
nm = _valid_ws_name(name)
if not nm:
return {"ok": False, "err": "工作区名不合法(不许空、不许带路径分隔符或 <>:\"|?*)"}
if not rootp.is_dir():
return {"ok": False, "err": "根目录不存在:%s" % rootp}
ws = rootp / nm
if ws.exists():
return {"ok": False, "err": "已存在:%s(⛔ 不覆盖;要改就进去改)" % ws}
script = SKILL_SCRIPTS / "init_workspace.py"
if not script.is_file():
return {"ok": False, "err": "找不到技能脚本:%s" % script}
cmd = [sys.executable, str(script), str(ws),
"--title", str(title or nm), "--short", str(short or nm)]
# 🔴🔴 **必须显式指定编码**(2026-10-06 实测踩到):
# 子进程输出的是 **UTF-8**(`init_workspace.py` 开头就 `reconfigure("utf-8")`),
# 而不给 `encoding` 时 Python 按**本机默认编码**(中文 Windows = GBK)解码
# ⇒ 中文全变乱码(`锛屽埆璺戞妧鑳界洰褰�`)⇒ 界面上给用户看的日志**完全没法读**。
# ⚠️ 同族:本项目已登记「出口一律声明编码」(P0-10)—— 这里是**入口**侧的同一条。
# ⚠️ 顺带设 `PYTHONIOENCODING`,兜住子进程自己内部再起孙进程的情况。
_env = dict(os.environ)
_env["PYTHONIOENCODING"] = "utf-8"
try:
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
encoding="utf-8", timeout=300, cwd=str(rootp), env=_env)
except Exception as e:
return {"ok": False, "err": "跑 init_workspace 失败:%r" % (e,)}
out = ((r.stdout or "") + (r.stderr or "")).strip()
return {"ok": r.returncode == 0, "rc": r.returncode, "ws": str(ws).replace("\\", "/"),
"out": out[-4000:], "cmd": " ".join(cmd)}
def _trash_dir(p: Path):
"""把目录送进**系统回收站**(🔴 可逆;⛔ 绝不 `rm -rf`)。返回 `(ok, info)`。
🔴 2026-10-06 实测:本机 **PortableGit 里 `gio` / `trash-put` / `trash` 一个都没有**
⇒ 原来的 `gio trash` 那条路**恒失败**。而 WorkBuddy 的 `Add-Type` 被安全策略拦
⇒ 也用不了 `Microsoft.VisualBasic.FileIO`。
⇒ 现走 **纯 ctypes 调 Windows Shell API `SHFileOperationW`**(不编译任何代码,
不碰 .NET,不弹 UAC)—— 这是本机唯一实测可用的回收站途径。
"""
# ① Windows:SHFileOperationW + FO_DELETE + FOF_ALLOWUNDO(=真正进回收站)
if os.name == "nt":
try:
import ctypes
from ctypes import wintypes
class SHFILEOPSTRUCTW(ctypes.Structure):
_fields_ = [
("hwnd", wintypes.HWND),
("wFunc", wintypes.UINT),
("pFrom", ctypes.c_void_p), # 🔴 手搓双 \0 结尾缓冲,见下
("pTo", ctypes.c_void_p),
("fFlags", ctypes.c_uint16),
("fAnyOperationsAborted", wintypes.BOOL),
("hNameMappings", ctypes.c_void_p),
("lpszProgressTitle", ctypes.c_void_p),
]
FO_DELETE = 0x0003
FOF_SILENT = 0x0004
FOF_NOCONFIRMATION = 0x0010
FOF_ALLOWUNDO = 0x0040 # 🔴 有它才进回收站;没它就=永久删除
FOF_NOERRORUI = 0x0400
FOF_NOCONFIRMMKDIR = 0x0200
# 🔴🔴 `pFrom` 必须**双 \0 结尾**。ctypes 的 `LPCWSTR` **会在第一个 \0 截断**
# ⇒ 必须手搓 `c_wchar_p` 缓冲并把地址塞进 `c_void_p` 字段。
abspath = str(p.resolve()).replace("/", "\\")
_buf = ctypes.create_unicode_buffer(abspath, len(abspath) + 2)
op = SHFILEOPSTRUCTW()
op.hwnd = None
op.wFunc = FO_DELETE
op.pFrom = ctypes.cast(_buf, ctypes.c_void_p)
op.pTo = None
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT
| FOF_NOERRORUI | FOF_NOCONFIRMMKDIR)
rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
# 🔴🔴 **判据=「东西没了 且 没被中止」,⛔ 不是 `rc == 0`**。
# 2026-10-06 实测:送回收站**成功**(目录消失、回收站里 18 条 `$I` 元数据
# 都能解出原路径),但返回值恒为 **rc=2(ERROR_FILE_NOT_FOUND)** ——
# 这是 Shell 内部探测残留的 `GetLastError`,**不是真失败**。
# ⇒ 拿 `rc` 当判据会**假报错**(用户看到"失败"但东西已经进回收站了)。
# ✅ 真判据只有两条:① 原路径不存在了 ② `fAnyOperationsAborted` 为假。
if (not p.exists()) and (not op.fAnyOperationsAborted):
return True, abspath
return False, "SHFileOperationW rc=%s aborted=%s exists=%s" % (
rc, bool(op.fAnyOperationsAborted), p.exists())
except Exception as e: # noqa: BLE001
_sh_err = "SHFileOperationW 异常:%r" % (e,)
else:
_sh_err = "非 Windows 平台"
# ② 兜底:类 Unix 的 `gio trash`(本机实测没有,但留着以备换机)
for cmd in (["gio", "trash", str(p)], ["trash-put", str(p)]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
encoding="utf-8", timeout=120)
if r.returncode == 0:
return True, str(p).replace("\\", "/")
except FileNotFoundError:
continue
except Exception: # noqa: BLE001
continue
return False, "%s;且 gio/trash-put 均不可用" % _sh_err
def act_delete(ws_path: str, mode: str = "goal") -> dict:
"""**删**(⛔ 不改状态;两种粒度)。
· `mode="goal"`(默认,轻量):只删该区的**目标登记**(`goal.json` ⇒ 改名归档),
工作区与台账**原样保留** ⇒ 该项目从列表消失,但东西都还在(可逆)。
· `mode="ws"`(重):把**整个工作区**送回收站(⛔ 不 `rm`)⇒ 需要二次确认。
🔴 两条都**可逆**;⛔ 绝不 `rm -rf`;⛔ 不碰别的区。
"""
ws = Path(ws_path)
if not ws.is_dir():
return {"ok": False, "err": "工作区不存在:%s" % ws}
if mode == "goal":
gj = ws / "tmp" / "supervise-inbox" / "goal.json"
if not gj.is_file():
return {"ok": False, "err": "该区没有目标登记(%s)" % gj}
bak = gj.with_name("goal.json.archived-%s" % time.strftime("%Y%m%d-%H%M%S"))
try:
gj.rename(bak)
except Exception as e:
return {"ok": False, "err": "归档目标失败:%r" % (e,)}
return {"ok": True, "mode": "goal", "moved_to": str(bak).replace("\\", "/")}
if mode == "ws":
ok, info = _trash_dir(ws)
if ok:
return {"ok": True, "mode": "ws", "trashed": info}
# 🔴 **fail-closed**:本机没有任何可用的回收站途径 ⇒ **如实报错,⛔ 不 rm 兜底**
return {"ok": False, "err": "送回收站失败(⛔ 不用 rm 兜底):%s" % info}
return {"ok": False, "err": "未知 mode:%s(只支持 goal / ws)" % mode}
# ── HTTP ─────────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
server_version = "ProjectBoard/2.0"
def _send(self, code: int, body: bytes, ctype: str) -> None:
try:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
except Exception:
pass
def _json(self, code: int, obj) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def do_GET(self): # noqa: N802
path = self.path.split("?")[0]
if path in ("/", "/index.html", "/project-board.html"):
try:
self._send(200, PAGE.read_bytes(), "text/html; charset=utf-8")
except Exception as e:
self._send(500, ("页面读不到:%s" % e).encode("utf-8"),
"text/plain; charset=utf-8")
elif path == "/api/projects":
try:
self._json(200, to_project_view())
except Exception as e:
self._json(500, {"ok": False, "err": "扫工作区失败:%r" % (e,)})
elif path == "/healthz":
self._json(200, {"ok": True, "roots": [str(r) for r in WS_ROOTS]})
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def do_POST(self): # noqa: N802
path = self.path.split("?")[0]
try:
ln = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(ln) if ln else b"{}"
body = json.loads(raw.decode("utf-8") or "{}")
except Exception as e:
self._json(400, {"ok": False, "err": "body 解析失败:%r" % (e,)})
return
if path == "/api/create":
self._json(200, act_create(str(body.get("root") or ""),
str(body.get("name") or ""),
str(body.get("title") or ""),
str(body.get("short") or "")))
elif path == "/api/delete":
self._json(200, act_delete(str(body.get("ws") or ""),
str(body.get("mode") or "goal")))
else:
self._send(404, b"not found", "text/plain; charset=utf-8")
def log_message(self, *a):
pass
def main() -> int:
ap = argparse.ArgumentParser(description="项目管理界面(本机所有工作区 · 增删查)")
ap.add_argument("--serve", type=int, default=0, metavar="PORT",
help="起 HTTP 服务(默认不启,只做一次扫描并打印摘要)")
ap.add_argument("--once", action="store_true", help="扫一次并打印摘要")
a = ap.parse_args()
if a.serve:
srv = ThreadingHTTPServer(("127.0.0.1", a.serve), Handler)
_say("项目管理界面已起:http://127.0.0.1:%d/ (根目录 %s)"
% (a.serve, ", ".join(str(r) for r in WS_ROOTS)))
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
return 0
if a.once:
v = to_project_view()
_say("根目录=%s" % ", ".join(v["roots"]))
_say("项目 %d 个:" % len(v["projects"]))
for p in v["projects"]:
_say(" %-22r 建立=%-16s 生命=%-5s 卡片=%d 台账:总%s/待%s/中%s/阻%s/完成%s 验收 %d/%d"
% (p["name"][:22], p["declared_at"] or "-", p["life"] or "-",
len(p["cards"]), p["queue"]["total"], p["queue"]["pending"],
p["queue"]["running"], p["queue"]["blocked"], p["queue"]["done"],
p["acc"]["pass"], p["acc"]["n"]))
if v["skipped"]:
_say("(扫到但**没装机制**、未列出:%s)" % "、".join(v["skipped"]))
return 0
ap.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -54,7 +54,7 @@
| **投递** | ⛔🔴 **2026-10-03 07:2x 已退役,不是"降级"而是真删**:`supervise()` 203→71 行、两个 `_deliver_str()` 调用点删除、`--tick` 的 `check_delivery_consumed()` 调用删除、`collabd.config.json` 的 `wake_enable` **true→false**。⚠️ 保留的 4 个函数(`_deliver_str`/`follow_for_topic`/`wake_round`/`check_delivery_consumed`)是**零调用点**实现,`selftest` 有 4 处**真调用**前两个 ⇒ ⛔ 删函数体=`NameError`;⚠️ 判"函数体内没调用 X"**必须走 AST**(`selftest.py::_calls_in()`),字面 grep 会被退役 docstring **永久假红**(P0-36)。⛔ 「唯一投递方=常驻投递进程」整条**作废**。 | | **投递** | ⛔🔴 **2026-10-03 07:2x 已退役,不是"降级"而是真删**:`supervise()` 203→71 行、两个 `_deliver_str()` 调用点删除、`--tick` 的 `check_delivery_consumed()` 调用删除、`collabd.config.json` 的 `wake_enable` **true→false**。⚠️ 保留的 4 个函数(`_deliver_str`/`follow_for_topic`/`wake_round`/`check_delivery_consumed`)是**零调用点**实现,`selftest` 有 4 处**真调用**前两个 ⇒ ⛔ 删函数体=`NameError`;⚠️ 判"函数体内没调用 X"**必须走 AST**(`selftest.py::_calls_in()`),字面 grep 会被退役 docstring **永久假红**(P0-36)。⛔ 「唯一投递方=常驻投递进程」整条**作废**。 |
| **接收/处理(现行两条腿)** | **接收**=`--report` 写 `tasks.json`(**四态台账,唯一权威**,唯一真源);`--once` =**纯投影**,⛔ 不写。<br>**处理**=`maybe_spawn_check_agent()` 建 `[检查]-…` 排期(五道闸),由常驻 `--supervise` 每 2 轮判一次 —— ⛔ 这是这条腿的**唯一载体**(**检查会话只能靠排期开**)。<br>⛔ **没有第三环**:队列变化**不再自动通知任何人**。 | | **接收/处理(现行两条腿)** | **接收**=`--report` 写 `tasks.json`(**四态台账,唯一权威**,唯一真源);`--once` =**纯投影**,⛔ 不写。<br>**处理**=`maybe_spawn_check_agent()` 建 `[检查]-…` 排期(五道闸),由常驻 `--supervise` 每 2 轮判一次 —— ⛔ 这是这条腿的**唯一载体**(**检查会话只能靠排期开**)。<br>⛔ **没有第三环**:队列变化**不再自动通知任何人**。 |
| **⛔ 已废弃** | **「用自动任务当闹钟」**(2026-10-01 用户明确废弃,原话:「**定时任务的方案已经废弃了**」)⇒ §4.0/§4.1 只作历史。|🔴 **2026-10-03 新增**:**队列投递段 + 唤醒回路**(载体就是投递)—— 见本节上方状态块。 | | **⛔ 已废弃** | **「用自动任务当闹钟」**(2026-10-01 用户明确废弃,原话:「**定时任务的方案已经废弃了**」)⇒ §4.0/§4.1 只作历史。|🔴 **2026-10-03 新增**:**队列投递段 + 唤醒回路**(载体就是投递)—— 见本节上方状态块。 |
| **本机起法** | 🔴🔴 **2026-10-02 23:0x 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**<br>① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。<br>② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。<br>③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。<br>④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。<br>⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。<br>⑥ 🔴 **改看板要不要重启有四类答案(P0-38,⛔ 别一律重启)**:改 `assets/board.html` **不用**(`board.py:1293` 每请求 `read_bytes()`)/改 `board_ext.py` **不用**(`EXT_CACHE` 按 `(mtime,size)` 热重载)/改 `board.py` 自身**必须**/改 `collabd.config.json` **必须**(`C = _cfg()` 在 `board.py:81` 模块级执行一次)⇒ **判据=先答"这个文件是谁在读、什么时候读"**。 | | **本机起法** | 🔴🔴 **2026-10-02 23:0x 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**<br>① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。<br>② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。<br>③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。<br>④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。<br>⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。<br>⑥ 🔴 **改看板要不要重启有四类答案(P0-38,⛔ 别一律重启)**:改 `boards/会话看板/board.html` **不用**(`board.py:1293` 每请求 `read_bytes()`)/改 `board_ext.py` **不用**(`EXT_CACHE` 按 `(mtime,size)` 热重载)/改 `board.py` 自身**必须**/改 `collabd.config.json` **必须**(`C = _cfg()` 在 `board.py:81` 模块级执行一次)⇒ **判据=先答"这个文件是谁在读、什么时候读"**。 |
| **看板服务(单实例)** | 🔴 **2026-09-30 加护栏**:Windows 的 `SO_REUSEADDR` 允许**同端口重复绑定且不报错** ⇒ 多个 `board.py --serve` **静默并存**、同一 URL 被**随机**应答 ⇒ 快照/代码版本**打架**。现:检测到已有看板 ⇒ **拒绝启动**;换新代码 ⇒ `--takeover`。停机 ⇒ `stop-collab.py`。🔴 **2026-10-03**:`/board.json` 的 `meta.deliver_retired == "2026-10-03"` = 前端把「队列通知」卡画成退役说明的判据。 | | **看板服务(单实例)** | 🔴 **2026-09-30 加护栏**:Windows 的 `SO_REUSEADDR` 允许**同端口重复绑定且不报错** ⇒ 多个 `board.py --serve` **静默并存**、同一 URL 被**随机**应答 ⇒ 快照/代码版本**打架**。现:检测到已有看板 ⇒ **拒绝启动**;换新代码 ⇒ `--takeover`。停机 ⇒ `stop-collab.py`。🔴 **2026-10-03**:`/board.json` 的 `meta.deliver_retired == "2026-10-03"` = 前端把「队列通知」卡画成退役说明的判据。 |
| **唤醒** | ⛔🔴 **2026-10-03 07:2x:这条整条失效** —— 唤醒的载体就是投递,投递已真删 ⇒ 所谓「三条件合取」(主会话未在处理 ∧ 无待反馈 ∧ 需求未完成)**不再有执行者**。⚠️ 下方 §2.0.1 那条「未读验收判据 ⇒ 误判完成」的缺口**随本条一起冻结**(判据本身还在,⛔ 但没人跑了)。⚠️ **「常驻=唤醒时钟」这个类比 ⛔ 别再沿用。** | | **唤醒** | ⛔🔴 **2026-10-03 07:2x:这条整条失效** —— 唤醒的载体就是投递,投递已真删 ⇒ 所谓「三条件合取」(主会话未在处理 ∧ 无待反馈 ∧ 需求未完成)**不再有执行者**。⚠️ 下方 §2.0.1 那条「未读验收判据 ⇒ 误判完成」的缺口**随本条一起冻结**(判据本身还在,⛔ 但没人跑了)。⚠️ **「常驻=唤醒时钟」这个类比 ⛔ 别再沿用。** |
| **同工作区 + 会话类别** | 🔴 **默认形态 = 一个工作区**(2026-10-01 用户定)。🔴🔴 **2026-10-03 起会话只两类:① 主会话 ② 任务会话**(唤醒会话/跟进会话/队列上报**已整套退役**)—— ⛔ 原文写的"四类"**作废**。两类**同工作区**,⛔ 不靠 cwd。⚠️ **「接续会话」=形态,⛔ 不是第三类**(撞阈值时由它自己建出来、**角色继承被接续那条**)。 | | **同工作区 + 会话类别** | 🔴 **默认形态 = 一个工作区**(2026-10-01 用户定)。🔴🔴 **2026-10-03 起会话只两类:① 主会话 ② 任务会话**(唤醒会话/跟进会话/队列上报**已整套退役**)—— ⛔ 原文写的"四类"**作废**。两类**同工作区**,⛔ 不靠 cwd。⚠️ **「接续会话」=形态,⛔ 不是第三类**(撞阈值时由它自己建出来、**角色继承被接续那条**)。 |
@@ -162,7 +162,7 @@
落点:`board.py::_goal_topics_source()` → `project.topics_source.kind ∈ {declared, fallback, none}` 落点:`board.py::_goal_topics_source()` → `project.topics_source.kind ∈ {declared, fallback, none}`
(`declared` 时一并显示说明时间/说明人)。回归用例:`selftest.py::看板:任务类别来源必须说得出口`。 (`declared` 时一并显示说明时间/说明人)。回归用例:`selftest.py::看板:任务类别来源必须说得出口`。
## 0.5 🔴 看板(`scripts/board.py` + `assets/board.html`) ## 0.5 🔴 看板(`boards/会话看板/board.py` + `boards/会话看板/board.html`)
### 0.5.0 🔴🔴 「看板」默认指**那个实时动态看板** —— 它是**要起着的**(2026-10-01 用户点破) ### 0.5.0 🔴🔴 「看板」默认指**那个实时动态看板** —— 它是**要起着的**(2026-10-01 用户点破)
@@ -174,7 +174,7 @@
|---|---| |---|---|
| 地址 | **`http://127.0.0.1:8788/`**(⛔ 只绑回环) | | 地址 | **`http://127.0.0.1:8788/`**(⛔ 只绑回环) |
| 起法 | **会话后台任务 + stdout 落文件**(本机唯一可行的起法) | | 起法 | **会话后台任务 + stdout 落文件**(本机唯一可行的起法) |
| 命令 | `COLLABD_CONFIG=<ws>/.workbuddy/collab/collabd.config.json DSH_COLLAB_WS=<ws> "<python>" "<技能>/scripts/board.py" --serve 8788 --takeover > <ws>/tmp/board-serve.out.log 2>&1` | | 命令 | `COLLABD_CONFIG=<ws>/.workbuddy/collab/collabd.config.json DSH_COLLAB_WS=<ws> "<python>" "<技能>/boards/会话看板/board.py" --serve 8788 --takeover > <ws>/tmp/board-serve.out.log 2>&1` |
| 换代码重起 | 加 **`--takeover`**(先停旧的再接管)。⛔ **别裸起第二个** —— Windows `SO_REUSEADDR` 会让两实例**静默并存**、同一 URL 随机应答("改了、也重启了、却还是旧的"就是这么来的)。单实例护栏:已有看板 ⇒ **拒绝启动** | | 换代码重起 | 加 **`--takeover`**(先停旧的再接管)。⛔ **别裸起第二个** —— Windows `SO_REUSEADDR` 会让两实例**静默并存**、同一 URL 随机应答("改了、也重启了、却还是旧的"就是这么来的)。单实例护栏:已有看板 ⇒ **拒绝启动** |
| 改完必须重启 | ⚠️ 服务进程**已 import** 技能里的 `board.py` ⇒ 改快照内容后**不重启就跑旧代码** | | 改完必须重启 | ⚠️ 服务进程**已 import** 技能里的 `board.py` ⇒ 改快照内容后**不重启就跑旧代码** |
| 停机出口 | `.workbuddy/collab/stop-collab.py`(dry-run 只报告)|`goalctl stop --yes` 会顺带停它 | | 停机出口 | `.workbuddy/collab/stop-collab.py`(dry-run 只报告)|`goalctl stop --yes` 会顺带停它 |
@@ -373,7 +373,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但"
⇒ `file://` 双击即看,⛔ 不依赖本地服务、⛔ 不依赖网络。**桩验"内容与几何",人眼验"观感"**,两件都要做。 ⇒ `file://` 双击即看,⛔ 不依赖本地服务、⛔ 不依赖网络。**桩验"内容与几何",人眼验"观感"**,两件都要做。
🔴🔴 **改之前必须知道的两件位置事实**(2026-10-01 实测,⛔ 不知道就会白改): 🔴🔴 **改之前必须知道的两件位置事实**(2026-10-01 实测,⛔ 不知道就会白改):
- **`board.html` 从「包内」读**(`board.py` 用 `HERE.parent/assets/board.html`)⇒ 改包内**即生效**。 - **`board.html` 从「包内」读**(`board.py` 用 `HERE/board.html`)⇒ 改包内**即生效**。
- **`board_ext.py` 从「使用方工作区」读**(配置项 `board_ext`,默认 `<工作区>/.workbuddy/collab/board_ext.py`) - **`board_ext.py` 从「使用方工作区」读**(配置项 `board_ext`,默认 `<工作区>/.workbuddy/collab/board_ext.py`)
⇒ **只改包内那份等于没改** —— 必须 `cp` 同步过去,并**核 md5 一致**。 ⇒ **只改包内那份等于没改** —— 必须 `cp` 同步过去,并**核 md5 一致**。
⚠️ 同理还有 `goalctl.py` / `stop-collab.py` / `deliver-gateway-token.py` 等**工作区副本**。 ⚠️ 同理还有 `goalctl.py` / `stop-collab.py` / `deliver-gateway-token.py` 等**工作区副本**。
@@ -420,7 +420,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但"
- ⛔ **技能里不写死这一格叫什么** —— 名字由使用方经 `front.triggers[].name` 给。 - ⛔ **技能里不写死这一格叫什么** —— 名字由使用方经 `front.triggers[].name` 给。
🔴 **用户 2026-10-01 定的名(现行)=「唤醒会话」**;同日更早叫过「唤醒主会话」(**已作废**,用户原话「**唤醒主会话 改为 唤醒会话**」),再早叫「唤醒脉冲会话」「唤醒定时任务」(均**已作废**)。 🔴 **用户 2026-10-01 定的名(现行)=「唤醒会话」**;同日更早叫过「唤醒主会话」(**已作废**,用户原话「**唤醒主会话 改为 唤醒会话**」),再早叫「唤醒脉冲会话」「唤醒定时任务」(均**已作废**)。
属**项目侧**,`board.html` 里⛔ 不写死这个名字 —— 由使用方经 `front.triggers[].name` 给。 属**项目侧**,`board.html` 里⛔ 不写死这个名字 —— 由使用方经 `front.triggers[].name` 给。
**判据:`assets/board.html` 的渲染逻辑里不该出现具体项目名。** **判据:`boards/会话看板/board.html` 的渲染逻辑里不该出现具体项目名。**
- 🔴 **2026-10-01 23:1x 图上改「简称」(用户第六条口径)**:`唤醒会话 →` **唤醒**、`队列上报的跟进会话 →` **跟进** - 🔴 **2026-10-01 23:1x 图上改「简称」(用户第六条口径)**:`唤醒会话 →` **唤醒**、`队列上报的跟进会话 →` **跟进**
(⛔ 用户明令**两者字号一致**,`n-title-sm` 13px)、`目标检查 →` **协作**(`n-title` 16px)。 (⛔ 用户明令**两者字号一致**,`n-title-sm` 13px)、`目标检查 →` **协作**(`n-title` 16px)。
⚠️ **简称 ≠ 改角色** —— 正文/代码里的**全称不变**;图里 `?`/`aria-label` **必须带简称对照** ⚠️ **简称 ≠ 改角色** —— 正文/代码里的**全称不变**;图里 `?`/`aria-label` **必须带简称对照**
@@ -930,7 +930,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但"
| **常驻程序** | `collabd.py`(自述"协作守护程序") | 队列闸门(`NEXT.md`)+ **唤醒投递** + 证据分级/真空/断链告警/体检/看板覆写。**⛔ 不做派活** | 「判定层」「常驻程序」「机械层」 | | **常驻程序** | `collabd.py`(自述"协作守护程序") | 队列闸门(`NEXT.md`)+ **唤醒投递** + 证据分级/真空/断链告警/体检/看板覆写。**⛔ 不做派活** | 「判定层」「常驻程序」「机械层」 |
| **会话** | `sessions` 表 | 执行体:「读—判—写」;**一次性**、可替换 | —— | | **会话** | `sessions` 表 | 执行体:「读—判—写」;**一次性**、可替换 | —— |
| **棒** | 一次派活 = 一个会话 | 同一件事的执行单位(「一棒一线」) | —— | | **棒** | 一次派活 = 一个会话 | 同一件事的执行单位(「一棒一线」) | —— |
| **看板** | `scripts/board.py --serve` → `assets/board.html` | **用户唯一入口**(只读旁路 · 异步快照) | ⛔ 别把 `digest.md`(机械摘要)当用户入口;⛔ 也别再提已退役的 `交付物/手机接入-实时状态.md` | | **看板** | `boards/会话看板/board.py --serve` → `boards/会话看板/board.html` | **用户唯一入口**(只读旁路 · 异步快照) | ⛔ 别把 `digest.md`(机械摘要)当用户入口;⛔ 也别再提已退役的 `交付物/手机接入-实时状态.md` |
| **设备接入 · 本地反代** | `dsh-client` · 真身 `@dsh-client/device-shim`(`127.0.0.1:20090`) | 只绑回环的 HTTP+WS 反代,**在进程内注入本机 DSH 的会话凭据**,供覆盖网络中继从设备入口访问桌面 DSH。⚠️ 该包已标「已退役」,计划并入 `@dsh-local/ai1net` 的第 6 块模块**M6「设备接入」**,但**尚未落地**(仓库里还没有 `ai1net` 包) | ⛔ 别再叫它「**垫片**」「shim」「薄垫片」 | | **设备接入 · 本地反代** | `dsh-client` · 真身 `@dsh-client/device-shim`(`127.0.0.1:20090`) | 只绑回环的 HTTP+WS 反代,**在进程内注入本机 DSH 的会话凭据**,供覆盖网络中继从设备入口访问桌面 DSH。⚠️ 该包已标「已退役」,计划并入 `@dsh-local/ai1net` 的第 6 块模块**M6「设备接入」**,但**尚未落地**(仓库里还没有 `ai1net` 包) | ⛔ 别再叫它「**垫片**」「shim」「薄垫片」 |
| **覆盖网络节点 · 中继客户端** | `dsh-client` · `<Repo>/lib/net/relay/main.js --client`,由 `overlay-node-daemon.ps1` 监护 | 本机作为**节点**接入覆盖网络:向中继注册(`registered host=… accepted=[20090]`)、维护长连、转发流 | ⛔ 别再叫它「**设备侧 worker**」「worker」「节点进程」 | | **覆盖网络节点 · 中继客户端** | `dsh-client` · `<Repo>/lib/net/relay/main.js --client`,由 `overlay-node-daemon.ps1` 监护 | 本机作为**节点**接入覆盖网络:向中继注册(`registered host=… accepted=[20090]`)、维护长连、转发流 | ⛔ 别再叫它「**设备侧 worker**」「worker」「节点进程」 |
+1 -1
View File
@@ -112,7 +112,7 @@
| **L0 技能包级**(每台机器一份) | `<技能包>/env/roots.env`(真文件不入库;样例 `*.example` 入库) | 这台机器的根:`DSH_WS_ROOT`、`DSH_AIROOT`、`WB_RESULT_HOOK_WS`、`COLLABD_PROD_CONFIG`、`CODEBUDDY_CONFIG_DIR`、`DSH_DOCS_ROOT` | **包内脚本**:钩子(`scripts/hooks/*`)、`board.py`、`goalctl.py`、`collabctl.py` | | **L0 技能包级**(每台机器一份) | `<技能包>/env/roots.env`(真文件不入库;样例 `*.example` 入库) | 这台机器的根:`DSH_WS_ROOT`、`DSH_AIROOT`、`WB_RESULT_HOOK_WS`、`COLLABD_PROD_CONFIG`、`CODEBUDDY_CONFIG_DIR`、`DSH_DOCS_ROOT` | **包内脚本**:钩子(`scripts/hooks/*`)、`board.py`、`goalctl.py`、`collabctl.py` |
| **L1 工作区级**(每工作区一份) | `<工作区>/.workbuddy/collab/collabd.config.json` | 这个工作区自己的东西:`workspace`/`inbox`/`live`/`taskgraph`/`host_db`(可写本机绝对路径)/`peer_workspaces` | **部署副本**:`<ws>/.workbuddy/collab/` 下的 `collabd.py`、`goalctl.py` | | **L1 工作区级**(每工作区一份) | `<工作区>/.workbuddy/collab/collabd.config.json` | 这个工作区自己的东西:`workspace`/`inbox`/`live`/`taskgraph`/`host_db`(可写本机绝对路径)/`peer_workspaces` | **部署副本**:`<ws>/.workbuddy/collab/` 下的 `collabd.py`、`goalctl.py` |
**桥接(谁把两层接起来)**:`scripts/supervise-launch.py`(常驻)与 `scripts/board-launch.py`(看板)。 **桥接(谁把两层接起来)**:`scripts/supervise-launch.py`(常驻)与 `boards/会话看板/board-launch.py`(看板)。
计划任务的动作里**没有 env 字段**,所以这两个启动器在**进程内**设 计划任务的动作里**没有 env 字段**,所以这两个启动器在**进程内**设
`COLLABD_CONFIG=<本区>/collabd.config.json` + `CODEBUDDY_CONFIG_DIR`,再 `runpy` 起本体。 `COLLABD_CONFIG=<本区>/collabd.config.json` + `CODEBUDDY_CONFIG_DIR`,再 `runpy` 起本体。
+6 -6
View File
@@ -254,7 +254,7 @@
- **形状**:看板是**一个常驻 HTTP 服务**(`board.py --serve <port>`),代码**载入内存后一直跑**。 - **形状**:看板是**一个常驻 HTTP 服务**(`board.py --serve <port>`),代码**载入内存后一直跑**。
于是**改完 `board.py` / `assets/board.html` / `board_ext.py`,页面上一点变化都没有** —— 于是**改完 `board.py` / `boards/会话看板/board.html` / `board_ext.py`,页面上一点变化都没有** ——
服务还是**改之前**那份代码,它**不会自己重载**。 服务还是**改之前**那份代码,它**不会自己重载**。
- **实测**:用户报「看板中协作会话区域**还是**看不到协作会话」。反复核对页面与代码都对得上, - **实测**:用户报「看板中协作会话区域**还是**看不到协作会话」。反复核对页面与代码都对得上,
最后发现**病根是进程**:服务是 **20:24:38** 起的,而改代码发生在 **21:xx** 最后发现**病根是进程**:服务是 **20:24:38** 起的,而改代码发生在 **21:xx**
@@ -302,7 +302,7 @@
- **形状**:用编辑工具换掉一段 `/* … */` 注释时,替换的两端**都没带上结尾 `*/`** ⇒ - **形状**:用编辑工具换掉一段 `/* … */` 注释时,替换的两端**都没带上结尾 `*/`** ⇒
新注释与它下面那一行代码**粘连成一条注释**,从那行往下的代码**全被吃掉**。 新注释与它下面那一行代码**粘连成一条注释**,从那行往下的代码**全被吃掉**。
- **实测**:`assets/board.html` 改完,`node --check` 只报一句 `SyntaxError: Unexpected token '}'`, - **实测**:`boards/会话看板/board.html` 改完,`node --check` 只报一句 `SyntaxError: Unexpected token '}'`,
并说「多余的 `}` 在 772 行」—— 而**真正的病灶在 ~1000 行**(那里少了一个 `*/`)。 并说「多余的 `}` 在 772 行」—— 而**真正的病灶在 ~1000 行**(那里少了一个 `*/`)。
当场表现为**渲染断言 35 条全红**,看着像"改坏了半个文件"。 当场表现为**渲染断言 35 条全红**,看着像"改坏了半个文件"。
- 🔴 **为什么这个坑难查**:第一反应是数 `{` 和 `}` 的个数。**没用** —— - 🔴 **为什么这个坑难查**:第一反应是数 `{` 和 `}` 的个数。**没用** ——
@@ -992,9 +992,9 @@ GUI 子系统 ⇒ 无 stdout ⇒ **静默无痕**,连"起没起"都查不到
| 改什么 | 要重启吗 | 依据(代码行) | | 改什么 | 要重启吗 | 依据(代码行) |
|---|---|---| |---|---|---|
| `assets/board.html` | ⛔ **不用**(只需刷新页面) | `board.py:1293` `serve()` 里 `html_p.read_bytes()` —— **每次请求实时读盘**;且 `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载 | | `boards/会话看板/board.html` | ⛔ **不用**(只需刷新页面) | `board.py:1293` `serve()` 里 `html_p.read_bytes()` —— **每次请求实时读盘**;且 `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载 |
| `scripts/board_ext.py` | ⛔ **不用** | `EXT_CACHE` 按 `(mtime, size)` 签名**热重载**(`board.py:855-870`) | | `boards/会话看板/board_ext.py` | ⛔ **不用** | `EXT_CACHE` 按 `(mtime, size)` 签名**热重载**(`board.py:855-870`) |
| `scripts/board.py` 自身 | ✅ **必须** | 进程里跑的是**启动那一刻载入**的旧代码(P0-17 本体) | | `boards/会话看板/board.py` 自身 | ✅ **必须** | 进程里跑的是**启动那一刻载入**的旧代码(P0-17 本体) |
| `collabd.config.json` | ✅ **必须** | `C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径(P0-33 第 1 条) | | `collabd.config.json` | ✅ **必须** | `C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径(P0-33 第 1 条) |
**现跑实证**(`tmp/_probe_hotreload.py`,改 `<title>` 插标记再还原,⛔ 未重启任何进程): **现跑实证**(`tmp/_probe_hotreload.py`,改 `<title>` 插标记再还原,⛔ 未重启任何进程):
@@ -2351,7 +2351,7 @@ INBOX = '…\ai1net-dsh-server\tmp\supervise-inbox'
而**计划任务的"动作"里根本没有 env 字段** ⇒ 这个变量只能由**启动器在进程内设**。 而**计划任务的"动作"里根本没有 env 字段** ⇒ 这个变量只能由**启动器在进程内设**。
- 🔴 **注意变量名别混**:包内 `roots.env` 提供的是 **`COLLABD_PROD_CONFIG`**, - 🔴 **注意变量名别混**:包内 `roots.env` 提供的是 **`COLLABD_PROD_CONFIG`**,
而 `board.py` 读的是 **`COLLABD_CONFIG`** —— 名字对不上 ⇒ `roots.env` 兜不住它。 而 `board.py` 读的是 **`COLLABD_CONFIG`** —— 名字对不上 ⇒ `roots.env` 兜不住它。
- ✅ **修法**:新增 **`scripts/board-launch.py`**(启动器)=先在进程内设 - ✅ **修法**:新增 **`boards/会话看板/board-launch.py`**(启动器)=先在进程内设
`COLLABD_CONFIG=<主区>/.workbuddy/collab/collabd.config.json` + `PYTHONIOENCODING` + `COLLABD_CONFIG=<主区>/.workbuddy/collab/collabd.config.json` + `PYTHONIOENCODING` +
兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`),再 `runpy.run_path(board.py, run_name="__main__")`; 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`),再 `runpy.run_path(board.py, run_name="__main__")`;
看板任务的动作改指 **`pythonw.exe board-launch.py`**。 看板任务的动作改指 **`pythonw.exe board-launch.py`**。
+1 -1
View File
@@ -4177,7 +4177,7 @@ def _all_sessions_idle() -> bool:
解决会话是否执行的判断问题 统一一个标准」)⇒ 本函数改成**与看板同款**。 解决会话是否执行的判断问题 统一一个标准」)⇒ 本函数改成**与看板同款**。
👉 **「看板的亮」就是 `status=='working'`** —— ⛔ 不是另有一个字段: 👉 **「看板的亮」就是 `status=='working'`** —— ⛔ 不是另有一个字段:
`assets/board.html:823` 逐字是 `var mOn=!!(main&&main.status==='working');` `boards/会话看板/board.html:823` 逐字是 `var mOn=!!(main&&main.status==='working');`
⇒ 亮 ≡ `status=='working'`,不亮 ≡ 其余三档。 ⇒ 亮 ≡ `status=='working'`,不亮 ≡ 其余三档。
⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。 ⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。
+106 -58
View File
@@ -58,6 +58,28 @@ import time
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
PKG = HERE.parent # 技能包根
BOARD_DIR = PKG / "boards" / "会话看板" # 2026-10-14:看板整包搬这里
# 🔴🔴 2026-10-14:看板搬进 `boards/会话看板/` 后,**它自己 `import collabd`**(同族"判据共用同一实现"),
# 而 `collabd.py` 仍在 `scripts/` ⇒ 两个目录都必须在 `sys.path` 上,
# 否则 `spec_from_file_location("bd_par", board_py())` 一执行就 `ModuleNotFoundError`(本机实测踩到)。
for _p in (str(BOARD_DIR), str(HERE)):
if _p not in sys.path:
sys.path.insert(0, _p)
def board_py() -> Path:
"""会话看板主程序的位置。🔴 2026-10-14 起在 `boards/会话看板/`(用户令「开个 boards 文件夹放两个看板」)。
⚠️ 保留旧位置的兜底 —— 部署方尚未同步时(各区副本)照旧能跑,⛔ 不让自测因搬家而假红。
"""
for p in (BOARD_DIR / "board.py", HERE / "board.py", HERE / "scripts" / "board.py"):
if p.is_file():
return p
return BOARD_DIR / "board.py"
WS = Path(os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT") or Path.cwd()) WS = Path(os.environ.get("DSH_COLLAB_WS") or os.environ.get("DSH_WS_ROOT") or Path.cwd())
TEST_WS = WS / "tmp" / "selftest" TEST_WS = WS / "tmp" / "selftest"
TIN = TEST_WS / "inbox" TIN = TEST_WS / "inbox"
@@ -320,7 +342,7 @@ def t_role_drift():
(2026-09-30 起就是"改一处必须改两处")⇒ 这里**真的把两边拉出来逐样本对账**,⛔ 不靠人盯。""" (2026-09-30 起就是"改一处必须改两处")⇒ 这里**真的把两边拉出来逐样本对账**,⛔ 不靠人盯。"""
m = imp() m = imp()
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_selftest", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_selftest", board_py())
bd = importlib.util.module_from_spec(spec) bd = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bd) spec.loader.exec_module(bd)
samples = ["[协作]-[唤醒机制]-日志事前叫停棒", "[唤醒]-[唤醒机制]-脉冲", "[主]-[唤醒机制]-机制线", samples = ["[协作]-[唤醒机制]-日志事前叫停棒", "[唤醒]-[唤醒机制]-脉冲", "[主]-[唤醒机制]-机制线",
@@ -336,7 +358,7 @@ def t_role_drift():
"下载 open design", ""] "下载 open design", ""]
bad = [t for t in samples if m.parse_session_name(t)["role"] != bd._role_of_title(t)] bad = [t for t in samples if m.parse_session_name(t)["role"] != bd._role_of_title(t)]
sa = (HERE / "collabd.py").read_text(encoding="utf-8") sa = (HERE / "collabd.py").read_text(encoding="utf-8")
sb = (HERE / "board.py").read_text(encoding="utf-8") sb = (board_py()).read_text(encoding="utf-8")
return [ return [
("两边角色判据逐样本一致(%d 条;不一致:%s)" % (len(samples), bad or "无"), not bad), ("两边角色判据逐样本一致(%d 条;不一致:%s)" % (len(samples), bad or "无"), not bad),
("🔴🔴 2026-10-02 两类改造:`[唤醒]`/`[跟进]` 两边**都判空**(已退役)—— ⛔ 判成活角色 ⇒ " ("🔴🔴 2026-10-02 两类改造:`[唤醒]`/`[跟进]` 两边**都判空**(已退役)—— ⛔ 判成活角色 ⇒ "
@@ -736,7 +758,7 @@ def t_acc_is_pass_chinese():
# 真因=**拿错了"真身"**:判定词表住在 `board.py`,collabd 只是 import 它。 # 真因=**拿错了"真身"**:判定词表住在 `board.py`,collabd 只是 import 它。
# ⇒ 判据必须**去 board.py 里读**(这才是"三处同款"要守的那个源)。 # ⇒ 判据必须**去 board.py 里读**(这才是"三处同款"要守的那个源)。
_rows_ok = [] _rows_ok = []
_bp = HERE / "board.py" _bp = board_py()
if not _bp.is_file(): if not _bp.is_file():
_bp = HERE / "scripts" / "board.py" _bp = HERE / "scripts" / "board.py"
try: try:
@@ -756,9 +778,9 @@ def t_acc_is_pass_chinese():
# ⚠️ 2026-10-05 修:`HERE` = **本脚本所在目录(scripts/)**, # ⚠️ 2026-10-05 修:`HERE` = **本脚本所在目录(scripts/)**,
# 而 `board.html` 住在**技能根**下的 `assets/`(= `HERE.parent/assets`) # 而 `board.html` 住在**技能根**下的 `assets/`(= `HERE.parent/assets`)
# ⇒ 原写 `HERE/"assets"` 必 `FileNotFoundError`(本轮实测就是这么红的)。 # ⇒ 原写 `HERE/"assets"` 必 `FileNotFoundError`(本轮实测就是这么红的)。
_html = HERE.parent / "assets" / "board.html" _html = BOARD_DIR / "board.html"
if not _html.is_file(): if not _html.is_file():
_html = HERE / "assets" / "board.html" _html = BOARD_DIR / "board.html"
try: try:
_hs = _html.read_text(encoding="utf-8") _hs = _html.read_text(encoding="utf-8")
_mw = re.search(r"var\s+ACC_PASS_WORDS\s*=\s*(\[[^\]]*\])", _hs) _mw = re.search(r"var\s+ACC_PASS_WORDS\s*=\s*(\[[^\]]*\])", _hs)
@@ -2098,7 +2120,7 @@ def t_main_resolution():
@case("主会话:看板与投递**同一套判据**(⛔ 防两处漂移 —— 漂了就没人能发现换主会话)") @case("主会话:看板与投递**同一套判据**(⛔ 防两处漂移 —— 漂了就没人能发现换主会话)")
def t_main_same_rule(): def t_main_same_rule():
a = (HERE / "collabd.py").read_text(encoding="utf-8") a = (HERE / "collabd.py").read_text(encoding="utf-8")
b = (HERE / "board.py").read_text(encoding="utf-8") b = (board_py()).read_text(encoding="utf-8")
pat = r'def _main_sid\(st: dict\) -> str:' pat = r'def _main_sid\(st: dict\) -> str:'
return [ return [
("两处都定义了 _main_sid", re.search(pat, a) is not None and re.search(pat, b) is not None), ("两处都定义了 _main_sid", re.search(pat, a) is not None and re.search(pat, b) is not None),
@@ -2290,9 +2312,15 @@ sys.dont_write_bytecode = True # ⛔ 别让测试自己往技能目录里落 _
def load(name, path): def load(name, path):
spec = importlib.util.spec_from_file_location(name, path) spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m
H = sys.argv[1] # 🔴 2026-10-14:看板整包搬进 `boards/会话看板/` ⇒ **两个文件不再同目录**
# (机制脚本在 `scripts/`、看板在 `boards/<看板名>/`)⇒ ⛔ 别再用一个 `H` 指两处。
H = sys.argv[1] # scripts/(collabd.py 在这)
B = sys.argv[2] # boards/<看板名>/(board.py 在这)
cb = load("cb", H + "/collabd.py") cb = load("cb", H + "/collabd.py")
bd = load("bd", H + "/board.py") # ⚠️ board.py 自己会去找 collabd.py;把它所在目录入 path,让同名相对导入也走得通。
if B not in sys.path:
sys.path.insert(0, B)
bd = load("bd", B + "/board.py")
cases = [ cases = [
("main1", "任意标题(主会话)", "main1", "手机接入", True), ("main1", "任意标题(主会话)", "main1", "手机接入", True),
("aaa", "[协作]-[手机接入]-N10 复测", "main1", "手机接入", True), ("aaa", "[协作]-[手机接入]-N10 复测", "main1", "手机接入", True),
@@ -2343,12 +2371,13 @@ for _nm, _s in (("collabd", _cbs), ("board", _bds)):
sig = str(inspect.signature(bd.in_project)) sig = str(inspect.signature(bd.in_project))
if "cwd" in sig: if "cwd" in sig:
bad.append("board.in_project 仍带 cwd 参数:%s" % sig) bad.append("board.in_project 仍带 cwd 参数:%s" % sig)
src = open(H + "/board.py", encoding="utf-8").read() src = open(B + "/board.py", encoding="utf-8").read()
if "project_scopes" in src: if "project_scopes" in src:
bad.append("board.py 仍残留 cwd 版的 project_scopes()") bad.append("board.py 仍残留 cwd 版的 project_scopes()")
print(json.dumps({"bad": bad, "sig": sig}, ensure_ascii=False)) print(json.dumps({"bad": bad, "sig": sig}, ensure_ascii=False))
''' '''
p = subprocess.run([PY, "-c", code, str(HERE)], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, p = subprocess.run([PY, "-c", code, str(HERE), str(BOARD_DIR)], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, env=_mk_env(), timeout=30, errors="replace") text=True, env=_mk_env(), timeout=30, errors="replace")
try: try:
r = json.loads((p.stdout or "").strip().splitlines()[-1]) r = json.loads((p.stdout or "").strip().splitlines()[-1])
@@ -2367,7 +2396,7 @@ def t_board_styles():
② **组件里不许出现硬编码颜色** —— 颜色只许出现在令牌块(`--color-*` 等)里。 ② **组件里不许出现硬编码颜色** —— 颜色只许出现在令牌块(`--color-*` 等)里。
⚠️ 一旦有人在 `.card{}`/SVG 里写死 `#fff`,换风格就会**花**(这是最容易犯的)。 ⚠️ 一旦有人在 `.card{}`/SVG 里写死 `#fff`,换风格就会**花**(这是最容易犯的)。
""" """
p = HERE.parent / "assets" / "board.html" p = BOARD_DIR / "board.html"
src = p.read_text(encoding="utf-8") src = p.read_text(encoding="utf-8")
need = ['[data-style="archify-dark"]', '[data-style="archify-light"]', need = ['[data-style="archify-dark"]', '[data-style="archify-light"]',
':root:not([data-style]), :root[data-style="base"]', # base 跟随系统深色 ':root:not([data-style]), :root[data-style="base"]', # base 跟随系统深色
@@ -2395,7 +2424,7 @@ def t_board_acc_summary():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_selftest", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_selftest", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
s_empty = b._acc_summary({}) s_empty = b._acc_summary({})
@@ -2421,7 +2450,7 @@ def t_board_orphan_visible():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_orphan", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_orphan", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -2431,7 +2460,7 @@ def t_board_orphan_visible():
{"line": "old-ws", "kind": "legacy", "total": 2, "done": 2, "open": 0}, {"line": "old-ws", "kind": "legacy", "total": 2, "done": 2, "open": 0},
{"line": "old-ws2", "kind": "legacy", "total": 1, "done": 0, "open": 1}]) {"line": "old-ws2", "kind": "legacy", "total": 1, "done": 0, "open": 1}])
# ② 结构:看板真的会把它画出来 + 真的会点名(⛔ 只改数据不改渲染 = 没修) # ② 结构:看板真的会把它画出来 + 真的会点名(⛔ 只改数据不改渲染 = 没修)
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") H = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
need = ["kind==='legacy'", "未归类", "不属任何当前任务类别", need = ["kind==='legacy'", "未归类", "不属任何当前任务类别",
"不在当前任务类别清单里", # 图外提示要点名 "不在当前任务类别清单里", # 图外提示要点名
"旧值 · 不在当前任务类别清单"] # 台账表要标注 "旧值 · 不在当前任务类别清单"] # 台账表要标注
@@ -2462,11 +2491,11 @@ def t_board_unrecognized_visible():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_unrec", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_unrec", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") H = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
S = (HERE / "board.py").read_text(encoding="utf-8") S = (board_py()).read_text(encoding="utf-8")
need = [ # 看板真会画(⛔ 只算不画 = 没修) need = [ # 看板真会画(⛔ 只算不画 = 没修)
"sessions_unrecognized", "未归入本项目", "会被漏管", "sessions_unrecognized", "未归入本项目", "会被漏管",
"值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称", "值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称",
@@ -2503,7 +2532,7 @@ def t_in_project_parity():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_inproj", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_inproj", board_py())
bd = importlib.util.module_from_spec(spec) bd = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bd) spec.loader.exec_module(bd)
m = imp() m = imp()
@@ -2539,8 +2568,8 @@ def t_in_project_parity():
# 结果拿去跟 `a` 比的是**标题**不是**期望值** ⇒ 全表恒红。 # 结果拿去跟 `a` 比的是**标题**不是**期望值** ⇒ 全表恒红。
# ⇒ 元教训:**对账类断言别用 `_` 当占位名**(一个函数里两个 `_` 必然屏蔽)。 # ⇒ 元教训:**对账类断言别用 `_` 当占位名**(一个函数里两个 `_` 必然屏蔽)。
_wrong = [(t, a, exp) for (t, exp), (_t, a, _b) in zip(cases, rows) if a != exp] _wrong = [(t, a, exp) for (t, exp), (_t, a, _b) in zip(cases, rows) if a != exp]
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") H = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
S = (HERE / "board.py").read_text(encoding="utf-8") S = (board_py()).read_text(encoding="utf-8")
return [ return [
("两处 `in_project` 逐样本一致(%d 条;不一致:%s)" % (len(cases), _mismatch or "无"), ("两处 `in_project` 逐样本一致(%d 条;不一致:%s)" % (len(cases), _mismatch or "无"),
not _mismatch), not _mismatch),
@@ -2572,7 +2601,7 @@ def t_session_retire():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_retire", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_retire", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
m = imp() m = imp()
@@ -2650,7 +2679,7 @@ def t_board_topics_source():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_tsrc", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_tsrc", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -2659,7 +2688,7 @@ def t_board_topics_source():
s_fb = b._goal_topics_source({"short": "简"}) s_fb = b._goal_topics_source({"short": "简"})
s_no = b._goal_topics_source({}) s_no = b._goal_topics_source({})
s_cand = b._goal_topics_source({"_topics候选": {"候选": ["丙"]}, "short": "简"}) s_cand = b._goal_topics_source({"_topics候选": {"候选": ["丙"]}, "short": "简"})
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") H = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
need = ["topics_source", "未声明", "调用时在对话里说明"] need = ["topics_source", "未声明", "调用时在对话里说明"]
miss = [n for n in need if n not in H] miss = [n for n in need if n not in H]
return [("有 topics ⇒ declared(实得 %s)" % s_dec["kind"], s_dec["kind"] == "declared"), return [("有 topics ⇒ declared(实得 %s)" % s_dec["kind"], s_dec["kind"] == "declared"),
@@ -2686,7 +2715,7 @@ def t_board_multi_goal():
os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["COLLABD_CONFIG"] = str(CFG)
os.environ["DSH_COLLAB_WS"] = str(TEST_WS) os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("bd_mg", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_mg", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -2733,7 +2762,7 @@ def t_board_multi_goal():
by = {str(g.get("key")): g for g in gs} by = {str(g.get("key")): g for g in gs}
_ga = by.get("ga", {}) or {} _ga = by.get("ga", {}) or {}
_gb = by.get("gb", {}) or {} _gb = by.get("gb", {}) or {}
H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") H = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
old_top = {"goal", "project", "tasks", "sessions", "labor", "orphan", "queue", old_top = {"goal", "project", "tasks", "sessions", "labor", "orphan", "queue",
"progress", "others_running", "front", "runtime", "notify", "warn", "meta"} "progress", "others_running", "front", "runtime", "notify", "warn", "meta"}
_miss = sorted(old_top - set(snap.keys())) _miss = sorted(old_top - set(snap.keys()))
@@ -2850,7 +2879,7 @@ def t_board_multi_goal():
"COMSPEC": os.environ.get("COMSPEC", ""), "COMSPEC": os.environ.get("COMSPEC", ""),
"COLLABD_CONFIG": str(_cf), "COLLABD_CONFIG": str(_cf),
"COLLABD_NO_ENSURE": "1"} # ⛔ 测试环境⛔ 不许起常驻(见 `_mk_env` 的说明) "COLLABD_NO_ENSURE": "1"} # ⛔ 测试环境⛔ 不许起常驻(见 `_mk_env` 的说明)
_sp8.run([_s8.executable, str(HERE / "board.py"), "--out", str(_in / "board.json")], _sp8.run([_s8.executable, str(board_py()), "--out", str(_in / "board.json")],
capture_output=True, text=True, timeout=180, env=_env8, capture_output=True, text=True, timeout=180, env=_env8,
encoding="utf-8", errors="replace") encoding="utf-8", errors="replace")
_bj = _in / "board.json" _bj = _in / "board.json"
@@ -2882,7 +2911,7 @@ def t_board_multi_goal():
{"id": "t4", "short": "丙4", "title": "丙目标4", "lifecycle": "进行中", {"id": "t4", "short": "丙4", "title": "丙目标4", "lifecycle": "进行中",
"run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False), "run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
encoding="utf-8") encoding="utf-8")
_sp8.run([_s8.executable, str(HERE / "board.py"), "--out", str(_in / "board.json")], _sp8.run([_s8.executable, str(board_py()), "--out", str(_in / "board.json")],
capture_output=True, text=True, timeout=180, env=_env8, capture_output=True, text=True, timeout=180, env=_env8,
encoding="utf-8", errors="replace") encoding="utf-8", errors="replace")
_d9 = _j8.loads((_in / "board.json").read_text(encoding="utf-8")) \ _d9 = _j8.loads((_in / "board.json").read_text(encoding="utf-8")) \
@@ -2905,7 +2934,7 @@ def t_board_multi_goal():
# 🔴 抓「干脆不排序」:夹具里文件出现次序**本就是时间倒序**(t0 居中、t2 最老在后加) # 🔴 抓「干脆不排序」:夹具里文件出现次序**本就是时间倒序**(t0 居中、t2 最老在后加)
# ⇒ 排序与否**输出有差异** ⇒ 上面那条"升序"能抓到;此处再钉一条源码级保险 # ⇒ 排序与否**输出有差异** ⇒ 上面那条"升序"能抓到;此处再钉一条源码级保险
# (⛔ 这是源码级判据的正当用途:验"语句在不在")。 # (⛔ 这是源码级判据的正当用途:验"语句在不在")。
_bsrc = (HERE / "board.py").read_text(encoding="utf-8") _bsrc = (board_py()).read_text(encoding="utf-8")
_R.append(("`blocks.sort(...)` 真在且按 `tab_created`:%s" _R.append(("`blocks.sort(...)` 真在且按 `tab_created`:%s"
% ("tab_created" in _bsrc and "blocks.sort(" in _bsrc), % ("tab_created" in _bsrc and "blocks.sort(" in _bsrc),
'blocks.sort(key=lambda b: (b.get("tab_created"' in _bsrc)) 'blocks.sort(key=lambda b: (b.get("tab_created"' in _bsrc))
@@ -3182,8 +3211,14 @@ def t_no_schedule_as_supervisor():
"2. ⛔ **不许把排期当", "2. ⛔ **不许把排期当",
"3. ⛔ **不许在排期 prompt 里写", "3. ⛔ **不许在排期 prompt 里写",
"4. ⛔ **常驻真死时不许用排期兜底**"))), "4. ⛔ **常驻真死时不许用排期兜底**"))),
("技能自己的文档**早写对了**「⛔ 排期代替不了载体」(判据据此钉住)", # 🔴🔴 2026-10-14 订正:该档同日改成**更硬的措辞** ——
"排期代替不了载体" in PP or "排期代替不了" in PP), # 「排期按点拉起主会话这条路不存在」(原来那句「排期代替不了载体」现只在 SKILL.md 里)。
# ⇒ 判据改成**锚档内现存的那句**(⛔ 别继续锚已搬走的旧措辞 ⇒ 恒红)。
# ⚠️ 这是本档第 N 次「文档改了、判据锚点没跟」⇒ 纪律:**锚点逐字抄原文,⛔ 不凭记忆**。
("技能自己的文档**早写对了**「⛔ 排期代替不了载体/排期拉起主会话这条路不存在」"
"(判据据此钉住;⛔ SKILL.md 与 references 任一在位即可)",
"排期代替不了载体" in PP or "排期代替不了" in PP
or "排期按点拉起主会话这条路不存在" in PP),
] ]
# 🔴 ② env_check 必须查工作区部署(⛔ 原来这一段完全不存在) # 🔴 ② env_check 必须查工作区部署(⛔ 原来这一段完全不存在)
_ec = m.env_check(ws=str(HERE.parent.parent.parent), scope="workspace") _ec = m.env_check(ws=str(HERE.parent.parent.parent), scope="workspace")
@@ -3300,7 +3335,7 @@ def t_board_layout():
① 每个 `.card/.hero` 的说明**只许放在 `.qtip` 里**(至少 4 个 `?`),⛔ 不许摊回版面; ① 每个 `.card/.hero` 的说明**只许放在 `.qtip` 里**(至少 4 个 `?`),⛔ 不许摊回版面;
② 被点名删除的三句**不得复活**(否则又变成"版面上一堆解释性小字")。 ② 被点名删除的三句**不得复活**(否则又变成"版面上一堆解释性小字")。
""" """
src = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") src = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
qh = src.count('class="qh"') qh = src.count('class="qh"')
qtip = src.count('class="qtip"') + src.count('class="qtip" id=') qtip = src.count('class="qtip"') + src.count('class="qtip" id=')
DELETED = ["一张看板只管", "只读旁路观测", "不按工作目录", "其他工作区另有在跑的会话", DELETED = ["一张看板只管", "只读旁路观测", "不按工作目录", "其他工作区另有在跑的会话",
@@ -3331,6 +3366,9 @@ def t_no_jargon():
③ 看板**回读不引用已废弃的项目键**(`front.shim` / `front.worker` / `device_ingress` / `overlay_node`)。 ③ 看板**回读不引用已废弃的项目键**(`front.shim` / `front.worker` / `device_ingress` / `overlay_node`)。
""" """
SK = HERE.parent / "scripts" SK = HERE.parent / "scripts"
# 🔴 2026-10-14:看板搬进 `boards/会话看板/` ⇒ 「哪个文件在哪」要分开给,⛔ 不再同目录。
# `SCRIPTS`=机制脚本目录(`collabd.py` 在这)|`BOARD_DIR`=看板目录(`board.py` 在这)。
SCRIPTS = SK
# ① 技能目录里不许有使用方的**结构性**产物(配置/启动器/编译产物)。 # ① 技能目录里不许有使用方的**结构性**产物(配置/启动器/编译产物)。
# ⚠️ 日志**不作硬失败**:迁移期可能还有"迁移前起的旧进程"在往老路径写 # ⚠️ 日志**不作硬失败**:迁移期可能还有"迁移前起的旧进程"在往老路径写
# (2026-09-30 实测 `wb-supervisor-watch.py --interval 10` 就是这种), # (2026-09-30 实测 `wb-supervisor-watch.py --interval 10` 就是这种),
@@ -3342,8 +3380,10 @@ def t_no_jargon():
leak = [n for n in forbidden if (SK / n).exists()] leak = [n for n in forbidden if (SK / n).exists()]
# ①b 源码级不变量:日志**不得**再挂在技能目录上(`LOG = HERE / …` 是旧写法) # ①b 源码级不变量:日志**不得**再挂在技能目录上(`LOG = HERE / …` 是旧写法)
logbad = [] logbad = []
for fn in ("collabd.py", "board.py", "guard.py"): for fn, _fp in (("collabd.py", SCRIPTS / "collabd.py"),
t = (SK / fn).read_text(encoding="utf-8") ("board.py", BOARD_DIR / "board.py"),
("guard.py", SCRIPTS / "guard.py")):
t = _fp.read_text(encoding="utf-8")
for i, line in enumerate(t.splitlines(), 1): for i, line in enumerate(t.splitlines(), 1):
s = line.strip() s = line.strip()
if s.startswith("#"): if s.startswith("#"):
@@ -3366,9 +3406,11 @@ def t_no_jargon():
# ✅ 判「豁免有没有被滥用」:`_DS_ANCHORS_OTHER` 也豁免,但它是**对照表**(零计算), # ✅ 判「豁免有没有被滥用」:`_DS_ANCHORS_OTHER` 也豁免,但它是**对照表**(零计算),
# 改它只改体检输出 ⇒ 两条一起放行是安全的。 # 改它只改体检输出 ⇒ 两条一起放行是安全的。
_anchor_exempt = re.compile(r"^_(DS_ANCHORS|DS_ANCHORS_OTHER)\s*=") _anchor_exempt = re.compile(r"^_(DS_ANCHORS|DS_ANCHORS_OTHER)\s*=")
for fn in ("board.py", "collabd.py"): # 🔴 2026-10-14:`board.py` 在 `boards/会话看板/`、`collabd.py` 在 `scripts/` ⇒ 逐个给全路径。
for fn, _fp in (("board.py", BOARD_DIR / "board.py"),
("collabd.py", SCRIPTS / "collabd.py")):
try: try:
src = (SK / fn).read_text(encoding="utf-8") src = _fp.read_text(encoding="utf-8")
except Exception: except Exception:
hits.append("%s:读不到" % fn) hits.append("%s:读不到" % fn)
continue continue
@@ -3381,7 +3423,7 @@ def t_no_jargon():
if proj_pat.search(line): if proj_pat.search(line):
hits.append("%s:%d" % (fn, i)) hits.append("%s:%d" % (fn, i))
# ③ 看板不许回读已废弃的项目键 # ③ 看板不许回读已废弃的项目键
html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") html = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
stale = [k for k in ("f.shim", "f.worker", "front.shim", "front.worker", stale = [k for k in ("f.shim", "f.worker", "front.shim", "front.worker",
"f.device_ingress", "f.overlay_node") if k in html] "f.device_ingress", "f.overlay_node") if k in html]
soft = [n for n in ("_collabd.log", "_guard-stdout.log", "tmp") soft = [n for n in ("_collabd.log", "_guard-stdout.log", "tmp")
@@ -3594,7 +3636,7 @@ def t_all_sessions_idle_execution_judge():
✅ **统一后的唯一标准**(本用例守的就是这一条): ✅ **统一后的唯一标准**(本用例守的就是这一条):
**「在执行」≡ `sessions.status=='working'`** —— 与看板那个格**同款** **「在执行」≡ `sessions.status=='working'`** —— 与看板那个格**同款**
(`assets/board.html:823` 逐字 `var mOn=!!(main&&main.status==='working');` (`boards/会话看板/board.html:823` 逐字 `var mOn=!!(main&&main.status==='working');`
⇒ 亮 ≡ `status=='working'`)。 ⇒ 亮 ≡ `status=='working'`)。
⛔ **本函数不再调 `_target_busy()`**(读宿主状态机日志那套是**第二套口径**)。 ⛔ **本函数不再调 `_target_busy()`**(读宿主状态机日志那套是**第二套口径**)。
@@ -3695,7 +3737,7 @@ def t_check_agent_naming():
import importlib.util import importlib.util
import sqlite3 import sqlite3
m = imp() m = imp()
spec = importlib.util.spec_from_file_location("bd_chk", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_chk", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -3753,7 +3795,7 @@ def t_check_agent_role_parity():
⇒ **对账当场抓到 2 条漂移**。⇒ 判据自己会指路,⛔ 不靠人盯。 ⇒ **对账当场抓到 2 条漂移**。⇒ 判据自己会指路,⛔ 不靠人盯。
""" """
import importlib.util import importlib.util
_html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") _html = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
# 🔴🔴 2026-10-03 23:4x 改名后**不要在这里写死标签**(原来写死 `role==='任务会话'`)—— # 🔴🔴 2026-10-03 23:4x 改名后**不要在这里写死标签**(原来写死 `role==='任务会话'`)——
# 标签已改成「执行会话」⇒ 写死的那份**当场报红**(真判据,不是误报)。 # 标签已改成「执行会话」⇒ 写死的那份**当场报红**(真判据,不是误报)。
# ✅ **改成从后端真值派生**:AST 抽 `board.py::_ROLE_LABEL['worker']` 那个字面量 —— # ✅ **改成从后端真值派生**:AST 抽 `board.py::_ROLE_LABEL['worker']` 那个字面量 ——
@@ -3764,7 +3806,7 @@ def t_check_agent_role_parity():
ROLE_FILTER = ("role==='%s' && String(x.title||'').indexOf('检查')<0" ROLE_FILTER = ("role==='%s' && String(x.title||'').indexOf('检查')<0"
% _ROLE_LABEL_WORKER) % _ROLE_LABEL_WORKER)
m = imp() m = imp()
spec = importlib.util.spec_from_file_location("bd_par", HERE / "board.py") spec = importlib.util.spec_from_file_location("bd_par", board_py())
b = importlib.util.module_from_spec(spec) b = importlib.util.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -4380,7 +4422,7 @@ def t_prog_online_judge():
import json as _j import json as _j
import time as _tm import time as _tm
m = imp() m = imp()
spec = _il.spec_from_file_location("bd_hb", HERE / "board.py") spec = _il.spec_from_file_location("bd_hb", board_py())
b = _il.module_from_spec(spec) b = _il.module_from_spec(spec)
spec.loader.exec_module(b) spec.loader.exec_module(b)
@@ -4397,7 +4439,7 @@ def t_prog_online_judge():
dead = b._label_when_down(10.0, 999999, False) dead = b._label_when_down(10.0, 999999, False)
unknown = b._label_when_down(None, None, False) unknown = b._label_when_down(None, None, False)
# ④ `_pid_alive` **复用** collabd(⛔ 不是自己重写) # ④ `_pid_alive` **复用** collabd(⛔ 不是自己重写)
src_b = (HERE / "board.py").read_text(encoding="utf-8") src_b = (board_py()).read_text(encoding="utf-8")
reuses = ("_cb_shared._pid_alive" in src_b) reuses = ("_cb_shared._pid_alive" in src_b)
return [ return [
("① 判据读**常驻心跳**(heartbeat_age_min 字段在位)", has_hb), ("① 判据读**常驻心跳**(heartbeat_age_min 字段在位)", has_hb),
@@ -4505,8 +4547,8 @@ def t_tab_peer_workspace():
import re as _re import re as _re
m = imp() m = imp()
spec = _ins.getsource # 占位(下面直接读文本) spec = _ins.getsource # 占位(下面直接读文本)
src_b = (HERE / "board.py").read_text(encoding="utf-8") src_b = (board_py()).read_text(encoding="utf-8")
src_h = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") src_h = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
# ① 函数在位 # ① 函数在位
has_fn = "_peer_workspaces" in src_b has_fn = "_peer_workspaces" in src_b
@@ -4548,7 +4590,7 @@ def t_tab_peer_workspace():
else: else:
os.environ["DSH_COLLAB_WS"] = str(ws_env) os.environ["DSH_COLLAB_WS"] = str(ws_env)
_u = __import__("importlib.util", fromlist=["util"]) _u = __import__("importlib.util", fromlist=["util"])
_sp = _u.spec_from_file_location(tag, HERE / "board.py") _sp = _u.spec_from_file_location(tag, board_py())
_mm = _u.module_from_spec(_sp) _mm = _u.module_from_spec(_sp)
_sp.loader.exec_module(_mm) _sp.loader.exec_module(_mm)
return _mm return _mm
@@ -4725,7 +4767,7 @@ def t_peer_block_no_self_data():
🔴 **判据可证伪**:把 `_peer_tasks(_wr)` 换回本区的 `tasks` ⇒ ⑤ **必然报红**(两侧 tasks 变相同)。 🔴 **判据可证伪**:把 `_peer_tasks(_wr)` 换回本区的 `tasks` ⇒ ⑤ **必然报红**(两侧 tasks 变相同)。
""" """
import tempfile import tempfile
src = (HERE / "board.py").read_text(encoding="utf-8") src = (board_py()).read_text(encoding="utf-8")
# ① peer 分支喂的是对方数据源 # ① peer 分支喂的是对方数据源
feeds_peer = ("_peer_tasks(_wr)" in src and "_peer_state(_wr)" in src feeds_peer = ("_peer_tasks(_wr)" in src and "_peer_state(_wr)" in src
and "_peer_srows(srows, _wr)" in src) and "_peer_srows(srows, _wr)" in src)
@@ -4759,7 +4801,7 @@ def t_peer_block_no_self_data():
os.environ["COLLABD_CONFIG"] = str(cfg) os.environ["COLLABD_CONFIG"] = str(cfg)
os.environ["DSH_COLLAB_WS"] = str(A) os.environ["DSH_COLLAB_WS"] = str(A)
_u = __import__("importlib.util", fromlist=["util"]) _u = __import__("importlib.util", fromlist=["util"])
_sp = _u.spec_from_file_location("bd_peerblk", HERE / "board.py") _sp = _u.spec_from_file_location("bd_peerblk", board_py())
bd = _u.module_from_spec(_sp) bd = _u.module_from_spec(_sp)
_sp.loader.exec_module(bd) _sp.loader.exec_module(bd)
try: try:
@@ -4819,7 +4861,7 @@ def t_view_takes_all_scoped_fields():
os.environ["COLLABD_CONFIG"] = str(_pcfg) os.environ["COLLABD_CONFIG"] = str(_pcfg)
os.environ.pop("DSH_COLLAB_WS", None) os.environ.pop("DSH_COLLAB_WS", None)
_u = __import__("importlib.util", fromlist=["util"]) _u = __import__("importlib.util", fromlist=["util"])
_sp = _u.spec_from_file_location("bd_viewchk", HERE / "board.py") _sp = _u.spec_from_file_location("bd_viewchk", board_py())
bd = _u.module_from_spec(_sp) bd = _u.module_from_spec(_sp)
_sp.loader.exec_module(bd) _sp.loader.exec_module(bd)
gs = (bd.build() or {}).get("goals") or [] gs = (bd.build() or {}).get("goals") or []
@@ -4840,7 +4882,7 @@ def t_view_takes_all_scoped_fields():
!= json.dumps(b2.get(k), sort_keys=True, ensure_ascii=False)] != json.dumps(b2.get(k), sort_keys=True, ensure_ascii=False)]
# 🔴 提取赋值清单前**先剥掉注释** —— ⛔ 否则"把某行注释掉"这种变异**照样匹配得到** # 🔴 提取赋值清单前**先剥掉注释** —— ⛔ 否则"把某行注释掉"这种变异**照样匹配得到**
# (本轮实测踩到:注释掉 `v.sessions_checks=` 之后判据仍报绿 ⇒ 判据被注释骗了)。 # (本轮实测踩到:注释掉 `v.sessions_checks=` 之后判据仍报绿 ⇒ 判据被注释骗了)。
_html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") _html = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
_html = _re2.sub(r"/\*.*?\*/", "", _html, flags=_re2.S) _html = _re2.sub(r"/\*.*?\*/", "", _html, flags=_re2.S)
_html = _re2.sub(r"//[^\n]*", "", _html) _html = _re2.sub(r"//[^\n]*", "", _html)
_fn = _re2.search(r"function scopeView\(d,g\)\{.*?\n\}", _html, _re2.S) _fn = _re2.search(r"function scopeView\(d,g\)\{.*?\n\}", _html, _re2.S)
@@ -4930,7 +4972,7 @@ def t_board_edges_land_on_boxes():
⇒ 那属于"把条件写复杂了",根治办法是**保持 `RX < hkX` 恒成立**(别靠加更多分支)。 ⇒ 那属于"把条件写复杂了",根治办法是**保持 `RX < hkX` 恒成立**(别靠加更多分支)。
""" """
import re as _re3 import re as _re3
src = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") src = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
# 🔴🔴 2026-10-03 **又踩一次「朴素扫描被注释骗」**(同族坑,见 P0-36): # 🔴🔴 2026-10-03 **又踩一次「朴素扫描被注释骗」**(同族坑,见 P0-36):
# ④ 那段注释里**逐字写着** `RXV=PX-40`/`RXH=PX+28` ⇒ 正则先命中注释 # ④ 那段注释里**逐字写着** `RXV=PX-40`/`RXH=PX+28` ⇒ 正则先命中注释
# ⇒ 抽出来是一整段散文 ⇒ `eval` 失败 ⇒ 判据整条失效。 # ⇒ 抽出来是一整段散文 ⇒ `eval` 失败 ⇒ 判据整条失效。
@@ -5597,7 +5639,7 @@ def t_pkg_hygiene():
# 零引用文件:assets/ 下每个 .js,⛔ 排掉本身被引用的 # 零引用文件:assets/ 下每个 .js,⛔ 排掉本身被引用的
dead = [] dead = []
for p in list((HERE.parent / "assets").glob("*.js")): for p in list((BOARD_DIR).glob("*.js")):
stem = p.stem stem = p.stem
hit = 0 hit = 0
for q in HERE.parent.rglob("*"): for q in HERE.parent.rglob("*"):
@@ -5704,7 +5746,7 @@ def t_warn_text_states_consequence():
凡"⚠/缺了/没读"类文案,**必须同时出现一个后果词**(会…/不会…/导致…)。 凡"⚠/缺了/没读"类文案,**必须同时出现一个后果词**(会…/不会…/导致…)。
""" """
import re as _re6 import re as _re6
html = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") html = (BOARD_DIR / "board.html").read_text(encoding="utf-8")
# 剥掉注释 ⇒ 只查**真正渲染给用户看的文案**(注释里的原话留档不算) # 剥掉注释 ⇒ 只查**真正渲染给用户看的文案**(注释里的原话留档不算)
code = _re6.sub(r"/\*.*?\*/", "", html, flags=_re6.S) code = _re6.sub(r"/\*.*?\*/", "", html, flags=_re6.S)
code = _re6.sub(r"(?m)^\s*//.*$", "", code) code = _re6.sub(r"(?m)^\s*//.*$", "", code)
@@ -5749,7 +5791,7 @@ def _worker_label_from_source() -> str:
""" """
import ast as _ast import ast as _ast
try: try:
t = _ast.parse((HERE / "board.py").read_text(encoding="utf-8")) t = _ast.parse((board_py()).read_text(encoding="utf-8"))
except Exception: except Exception:
return "" return ""
for n in t.body: for n in t.body:
@@ -6757,7 +6799,7 @@ def t_board_render_runs_clean():
· `p.*` —— 参数早已改名成 `d`,函数体里 6 处没跟着改(同族,一次改名的漏网)。 · `p.*` —— 参数早已改名成 `d`,函数体里 6 处没跟着改(同族,一次改名的漏网)。
⇒ 都属于「**变量名与数据流向不符**」,⛔ 语法检查查不出(原版语法是过的)。 ⇒ 都属于「**变量名与数据流向不符**」,⛔ 语法检查查不出(原版语法是过的)。
✅ 本判据的做法=**真跑**:用 `assets/board-render-probe.js` 把页面脚本放进 ✅ 本判据的做法=**真跑**:用 `boards/会话看板/board-render-probe.js` 把页面脚本放进
`vm` 的**全局作用域**执行(⛔ 不能用 `new Function` —— 函数会关在局部, `vm` 的**全局作用域**执行(⛔ 不能用 `new Function` —— 函数会关在局部,
取到 `undefined` ⇒ **假绿**),喂一份**合成快照**,真调 `renderProject()`, 取到 `undefined` ⇒ **假绿**),喂一份**合成快照**,真调 `renderProject()`,
看它抛不抛、`#proj` 真渲染了没。 看它抛不抛、`#proj` 真渲染了没。
@@ -6769,10 +6811,10 @@ def t_board_render_runs_clean():
import json as _json import json as _json
import subprocess as _sp import subprocess as _sp
import tempfile as _tmp_mod import tempfile as _tmp_mod
probe = HERE.parent / "assets" / "board-render-probe.js" probe = BOARD_DIR / "board-render-probe.js"
html = HERE.parent / "assets" / "board.html" html = BOARD_DIR / "board.html"
res = [] res = []
res.append(("探针在位 `assets/board-render-probe.js`", probe.is_file())) res.append(("探针在位 `boards/会话看板/board-render-probe.js`", probe.is_file()))
res.append(("探针路径**自定位**(⛔ 不写死作者机器)", "BOARD_HTML" in probe.read_text(encoding="utf-8"))) res.append(("探针路径**自定位**(⛔ 不写死作者机器)", "BOARD_HTML" in probe.read_text(encoding="utf-8")))
# 源码级:`acc` 必须**先声明后使用**(⛔ 不能只是"出现过") # 源码级:`acc` 必须**先声明后使用**(⛔ 不能只是"出现过")
@@ -6895,6 +6937,12 @@ def t_peer_main_resolved():
# ⚠️ 本用例要调的是 **`board.py`** 的函数(`_scan_ws_mains`/`_resolve_main`/`project_scope`), # ⚠️ 本用例要调的是 **`board.py`** 的函数(`_scan_ws_mains`/`_resolve_main`/`project_scope`),
# ⛔ **不是** `imp()` 装出来的 collabd 模块 —— 那两个模块都有同名函数, # ⛔ **不是** `imp()` 装出来的 collabd 模块 —— 那两个模块都有同名函数,
# 拿错模块会 `AttributeError`(2026-10-05 本用例第一版就是这么红的,同族第 2 次)。 # 拿错模块会 `AttributeError`(2026-10-05 本用例第一版就是这么红的,同族第 2 次)。
# 🔴 2026-10-14:看板搬进 `boards/会话看板/` ⇒ `import board` 前**必须**把该目录放进 `sys.path`
# (原来是靠"它在 scripts/ 里、而 scripts 本就在 path 上"蹭到的,搬完就不成立了)。
import sys as _sys
_bd = str(BOARD_DIR)
if _bd not in _sys.path:
_sys.path.insert(0, _bd)
import board as m import board as m
# ── 合成一条"对方工作区的主会话"(直接写测试库;⛔ 绝不碰生产库) # ── 合成一条"对方工作区的主会话"(直接写测试库;⛔ 绝不碰生产库)
peer_root = str(TEST_WS / "peerws") peer_root = str(TEST_WS / "peerws")
@@ -6937,7 +6985,7 @@ def t_peer_main_resolved():
# ⛔ **证明不了 `_goal_block()` 真把它传下去了** —— 实测变异 M3(删掉调用点的传参) # ⛔ **证明不了 `_goal_block()` 真把它传下去了** —— 实测变异 M3(删掉调用点的传参)
# **本条全绿**,而线上 peer 格照样「无主会话」。 # **本条全绿**,而线上 peer 格照样「无主会话」。
# ⇒ 判据必须**静态咬住那条调用点**:`project_scope(...)` 在 peer 分支里必须带 `ws_root`。 # ⇒ 判据必须**静态咬住那条调用点**:`project_scope(...)` 在 peer 分支里必须带 `ws_root`。
_bs = (HERE / "board.py").read_text(encoding="utf-8") _bs = (board_py()).read_text(encoding="utf-8")
res.append(("⑤ `_goal_block()` 的调用点**真把 `ws_root` 递给 `project_scope`**" res.append(("⑤ `_goal_block()` 的调用点**真把 `ws_root` 递给 `project_scope`**"
"(⛔ 只测函数体等于没测装配)", "(⛔ 只测函数体等于没测装配)",
"project_scope(g, ws_root)" in _bs)) "project_scope(g, ws_root)" in _bs))