diff --git a/session-mechanism/SKILL.md b/session-mechanism/SKILL.md index 122822e..fa37a9f 100644 --- a/session-mechanism/SKILL.md +++ b/session-mechanism/SKILL.md @@ -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/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)**:机制里**曾经有第二条起法** —— `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`: > 改 `` 插标记 → ⛔ 未重启 → 页面立即含标记(md5 已还原一致 ✔)。 > · 改 `.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 路径)。 > ⇒ **判据=先答"这个文件是谁在读、什么时候读"**;⛔ 别把"改了 X"与"改看板"当同一件事。 > ⇒ 全文 ⇒ `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 步的连体动作」)。 🔴 **`--workspace` 要点名本区绝对路径**:`roots.env` 里 `DSH_WS_ROOT` 可能指向别的工作区(实测本机指向 `ai1net-dsh-server`)⇒ ⛔ 不让它顶替,装到别区 = 静默错归属。 🔴 **包内目录怎么分(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/手拼路径 ② 引用任何读数**先看它什么时候写的** ③ 写判据**先让基线全绿**再谈抓得住 —— 每条都对应当天真实翻车,且都已落判据不许退化)。 @@ -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,⛔ 不许自己写词表**) -3. `assets/board.html` → `accIsPass()`(前端跑不了 Python ⇒ **抄同一份白名单**) +3. `boards/会话看板/board.html` → `accIsPass()`(前端跑不了 Python ⇒ **抄同一份白名单**) **白名单(全库唯一)**:`pass` / `过` / `通过` / `达` / `达标` / `合格` / `完成` (Python 侧常量 `ACC_PASS_WORDS`;JS 侧常量 `ACC_PASS_WORDS` —— 两处**必须逐字相同**。) @@ -801,7 +802,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话" · **派活** ⇒ 自动化(**唯一能开新会话的通道**;⛔ 钩子做不到)。 · **收结果** ⇒ 直读宿主库(0 token)。 · **机械判定** ⇒ 下沉到本地只读程序(`collabd.py` / `board.py` / `goalctl.py`)。 -· **人看的** ⇒ 只有一个看板(`assets/board.html` + `board_ext.py`)。 +· **人看的** ⇒ 只有一个看板(`boards/会话看板/board.html` + `board_ext.py`)。 **铁律(全部实测得来)** - ⚠️ **【2026-10-03 口径已改 · 本条「四类」部分作废】** 现行=**两类**(① 主会话 ② 任务会话;唤醒会话/跟进会话/队列上报**整套退役**)⇒ 以**文首口径块**为准,⛔ **别照本条去建会话**。(原文保留仅为留痕,⛔ 不删。) @@ -902,7 +903,7 @@ python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话" · 🔴🔴🔴 **全局只允许一个看板 / 一个常驻(用户 2026-10-04 21:0x 定案,逐字:「看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来」)**: **现状即标准** —— 就是本轮已经跑通并取证的那一套,⛔ 不要再发明第二套。 - **看板载体**:计划任务 **`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 从没绑上**。现场输出: `⚠️ collabd:未找到部署配置(COLLABD_CONFIG 未设)⇒ 已拒跑` —— `board.py` 顶层用 **`COLLABD_CONFIG`** 定位配置(它要 `import collabd.py`),而**计划任务的动作里没有 env 字段** diff --git a/session-mechanism/scripts/board-launch.py b/session-mechanism/boards/会话看板/board-launch.py similarity index 94% rename from session-mechanism/scripts/board-launch.py rename to session-mechanism/boards/会话看板/board-launch.py index e6bdb1b..9663e42 100644 --- a/session-mechanism/scripts/board-launch.py +++ b/session-mechanism/boards/会话看板/board-launch.py @@ -44,9 +44,13 @@ _sm_load_roots() def _fail(msg: str, code: int = 2): - """⛔ 不静默退出:`pythonw` 下 `stderr` 可能是 `None` ⇒ 先写包内 `logs/`(不入库)留痕,再退出。""" + """⛔ 不静默退出:`pythonw` 下 `stderr` 可能是 `None` ⇒ 先写包内 `logs/`(不入库)留痕,再退出。 + + ⚠️ 2026-10-14:看板整包搬进 `boards/会话看板/` ⇒ 到包根要上跳**两层** + (`boards/会话看板/` → `boards/` → 包根),⛔ 不是原来的一层。 + """ try: - _d = os.path.join(os.path.dirname(HERE), "logs") + _d = os.path.join(HERE, "..", "..", "logs") os.makedirs(_d, exist_ok=True) 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)) diff --git a/session-mechanism/assets/board-launch.py.tpl b/session-mechanism/boards/会话看板/board-launch.py.tpl similarity index 100% rename from session-mechanism/assets/board-launch.py.tpl rename to session-mechanism/boards/会话看板/board-launch.py.tpl diff --git a/session-mechanism/assets/board-render-probe.js b/session-mechanism/boards/会话看板/board-render-probe.js similarity index 100% rename from session-mechanism/assets/board-render-probe.js rename to session-mechanism/boards/会话看板/board-render-probe.js diff --git a/session-mechanism/assets/board.html b/session-mechanism/boards/会话看板/board.html similarity index 100% rename from session-mechanism/assets/board.html rename to session-mechanism/boards/会话看板/board.html diff --git a/session-mechanism/scripts/board.py b/session-mechanism/boards/会话看板/board.py similarity index 97% rename from session-mechanism/scripts/board.py rename to session-mechanism/boards/会话看板/board.py index 5b90b8e..cfa4e68 100644 --- a/session-mechanism/scripts/board.py +++ b/session-mechanism/boards/会话看板/board.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。 +"""board.py —— 生成**看板快照** `board.json`(供 `boards/会话看板/board.html` 实时渲染)。 设计原则: · **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。 @@ -76,10 +76,44 @@ from pathlib import Path # ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path` # (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。 # ⚠️ 加载失败 ⇒ `_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 - 环境相关 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) _sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全 _is_check_agent = _cb.is_check_agent @@ -93,7 +127,19 @@ try: # pragma: no cover - 环境相关 # 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。 # ⇒ 这里统一取模块、再从它上面取函数。 _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: """⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。""" return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]")) @@ -103,9 +149,38 @@ except Exception: # pragma: no cover ⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。""" m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip()) return bool(m) and m.group(1).strip() in ("跟进", "唤醒") +else: + COLLABD_FALLBACK = False 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 @@ -1616,7 +1691,7 @@ def _orphan(lb: list) -> dict: # ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。 # ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。 # -# ⚠️ **判定词只许在这里增**(改完必须同时改 `assets/board.html` 的 `ACC_PASS_WORDS`)。 +# ⚠️ **判定词只许在这里增**(改完必须同时改 `boards/会话看板/board.html` 的 `ACC_PASS_WORDS`)。 # 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。 ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成") @@ -1635,7 +1710,7 @@ ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成") # 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。 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"], # 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话 # 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板), - # 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。 + # 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `boards/会话看板/board.html` 在图外说明里点名。 # 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。 # 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字): # 「检查会话不是协作会话,不应该出现在看板协作会话区域中; @@ -2105,7 +2180,9 @@ def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> in import threading 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} def _refresh_loop(): diff --git a/session-mechanism/scripts/board_ext.py b/session-mechanism/boards/会话看板/board_ext.py similarity index 99% rename from session-mechanism/scripts/board_ext.py rename to session-mechanism/boards/会话看板/board_ext.py index 5be6b42..d1868f0 100644 --- a/session-mechanism/scripts/board_ext.py +++ b/session-mechanism/boards/会话看板/board_ext.py @@ -7,7 +7,7 @@ 技能只提供**机制**(通用看板 + 一个扩展挂点);「链路前置」要看哪些组件、叫什么真名、 端到端怎么算通过 —— 都是**本项目的知识**,所以落在本文件里。 -## 契约(由 `session-mechanism/scripts/board.py` 调用) +## 契约(由 `session-mechanism/boards/会话看板/board.py` 调用) ```python def build(ws: str) -> dict ``` @@ -214,7 +214,7 @@ def _triggers_gw(ws: str) -> list: nm = "唤醒" # ⚠️ 用户 2026-09-30:「把心跳 图框做小一点 放到**主会话左边**就行,本来就是主会话创建的」 # 🔴 **位置这半句已被推翻** —— 用户 2026-10-01「**唤醒会话 和 跟进会话 单独放一行**」⇒ - # 现行位置=**主会话下面那一行(`RW`)的左格**(布局常量见 `assets/board.html`)。 + # 现行位置=**主会话下面那一行(`RW`)的左格**(布局常量见 `boards/会话看板/board.html`)。 # 这条只留**尺寸**依据(=「做小一点」,故 `TBW=TW`),⛔ 别再照它摆位置。 # v2:「**里面是每5分钟核对目标状态**」⇒ detail 用这句。 # v3:「把唤醒的**真实执行情况 时间**也展示到架构图上」⇒ 第 3 行改**真读数** @@ -518,7 +518,7 @@ def _triggers(ws: str) -> list: "up": bool(up), "state": state, # 🆕 给样式用:running/paused/none # 🔴 2026-10-02 **删掉死字段 `edge`**(原为 `"edge": "唤醒"`,注释写"箭头指主会话"): - # ⚠️ 它**已经没有任何消费方** —— `assets/board.html` 里那句 `if(t.edge) lbl(...)` + # ⚠️ 它**已经没有任何消费方** —— `boards/会话看板/board.html` 里那句 `if(t.edge) lbl(...)` # 在 2026-10-02 改连线(唤醒 → 常驻程序)时被换成了**固定文案** # (`唤醒 · --tick 推一次`)⇒ 这个键从此没人读。 # 🔴 判据(可复跑):`grep -rn "\.edge" --include=*.js --include=*.py --include=*.html` diff --git a/session-mechanism/boards/项目管理界面/project-board-launch.py b/session-mechanism/boards/项目管理界面/project-board-launch.py new file mode 100644 index 0000000..c5db773 --- /dev/null +++ b/session-mechanism/boards/项目管理界面/project-board-launch.py @@ -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()) diff --git a/session-mechanism/boards/项目管理界面/project-board.html b/session-mechanism/boards/项目管理界面/project-board.html new file mode 100644 index 0000000..22b3485 --- /dev/null +++ b/session-mechanism/boards/项目管理界面/project-board.html @@ -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>项目管理 · 执行任务 + + + +
+
+
项目管理
+ +
+ 加载中… + ! + + +
+
+
+ +
+
+
+ +
+ +
+ + + +
+ + + + + diff --git a/session-mechanism/boards/项目管理界面/project-board.py b/session-mechanism/boards/项目管理界面/project-board.py new file mode 100644 index 0000000..af3b965 --- /dev/null +++ b/session-mechanism/boards/项目管理界面/project-board.py @@ -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()) diff --git a/session-mechanism/references/architecture.md b/session-mechanism/references/architecture.md index c6b130e..6081d9c 100644 --- a/session-mechanism/references/architecture.md +++ b/session-mechanism/references/architecture.md @@ -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)。⛔ 「唯一投递方=常驻投递进程」整条**作废**。 | | **接收/处理(现行两条腿)** | **接收**=`--report` 写 `tasks.json`(**四态台账,唯一权威**,唯一真源);`--once` =**纯投影**,⛔ 不写。
**处理**=`maybe_spawn_check_agent()` 建 `[检查]-…` 排期(五道闸),由常驻 `--supervise` 每 2 轮判一次 —— ⛔ 这是这条腿的**唯一载体**(**检查会话只能靠排期开**)。
⛔ **没有第三环**:队列变化**不再自动通知任何人**。 | | **⛔ 已废弃** | **「用自动任务当闹钟」**(2026-10-01 用户明确废弃,原话:「**定时任务的方案已经废弃了**」)⇒ §4.0/§4.1 只作历史。|🔴 **2026-10-03 新增**:**队列投递段 + 唤醒回路**(载体就是投递)—— 见本节上方状态块。 | -| **本机起法** | 🔴🔴 **2026-10-02 23:0x 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**
① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。
② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。
③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。
④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。
⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。
⑥ 🔴 **改看板要不要重启有四类答案(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 实测订正**:**不是"本机不存在长跑进程",是"载体不同"。**
① 同一时刻用 `start /b` 与 `Popen+DETACHED` 各起一条每秒打点的探针 ⇒ 两条**活到约 11 分钟后停在同一 tick**(67/64 行)⇒ **差别不在起法关键字,在父链**(从**工具调用进程树**里起的活不过当轮)。
② 反证:MCN 工作台 `mcn-work-shop/start.bat` 由**用户从桌面双击**、属用户登录会话 ⇒ **一直活着**。
③ ✅ **正解=走 WorkBuddy 自己的后台任务**(`run_in_background`;⛔ 别用 `subprocess` 自己造)。⚠️ 子进程用 `pythonw.exe`(GUI 子系统 ⇒ ⛔ 不闪窗)|⚠️ `pythonw` 无 stdout ⇒ **必须显式重定向到文件**。
④ **S8 自愈仍保留**(`--supervise` 心跳 + `--tick` 顺手续命 + 判据 `pid 活 ∧ 心跳新鲜(<90 s)`),⛔ 但理由已换:**常驻总会被打断** ⇒ 要能自动补回来。⛔ **别拿 `_tick.stamp`/投递日志当证据**(那些轮次是钩子写的 —— P0-21)。
⑤ 🔴 **启完必查三样**:`netstat` 有 **LISTENING**(⛔ `TIME_WAIT`/`FIN_WAIT_2` 不算)+ `curl` 得 **200** + `tasklist` 进程在。
⑥ 🔴 **改看板要不要重启有四类答案(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-10-03 07:2x:这条整条失效** —— 唤醒的载体就是投递,投递已真删 ⇒ 所谓「三条件合取」(主会话未在处理 ∧ 无待反馈 ∧ 需求未完成)**不再有执行者**。⚠️ 下方 §2.0.1 那条「未读验收判据 ⇒ 误判完成」的缺口**随本条一起冻结**(判据本身还在,⛔ 但没人跑了)。⚠️ **「常驻=唤醒时钟」这个类比 ⛔ 别再沿用。** | | **同工作区 + 会话类别** | 🔴 **默认形态 = 一个工作区**(2026-10-01 用户定)。🔴🔴 **2026-10-03 起会话只两类:① 主会话 ② 任务会话**(唤醒会话/跟进会话/队列上报**已整套退役**)—— ⛔ 原文写的"四类"**作废**。两类**同工作区**,⛔ 不靠 cwd。⚠️ **「接续会话」=形态,⛔ 不是第三类**(撞阈值时由它自己建出来、**角色继承被接续那条**)。 | diff --git a/session-mechanism/references/collab-detail.md b/session-mechanism/references/collab-detail.md index 7a11b64..7cd95bb 100644 --- a/session-mechanism/references/collab-detail.md +++ b/session-mechanism/references/collab-detail.md @@ -162,7 +162,7 @@ 落点:`board.py::_goal_topics_source()` → `project.topics_source.kind ∈ {declared, fallback, none}` (`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 用户点破) @@ -174,7 +174,7 @@ |---|---| | 地址 | **`http://127.0.0.1:8788/`**(⛔ 只绑回环) | | 起法 | **会话后台任务 + stdout 落文件**(本机唯一可行的起法) | -| 命令 | `COLLABD_CONFIG=/.workbuddy/collab/collabd.config.json DSH_COLLAB_WS= "" "<技能>/scripts/board.py" --serve 8788 --takeover > /tmp/board-serve.out.log 2>&1` | +| 命令 | `COLLABD_CONFIG=/.workbuddy/collab/collabd.config.json DSH_COLLAB_WS= "" "<技能>/boards/会话看板/board.py" --serve 8788 --takeover > /tmp/board-serve.out.log 2>&1` | | 换代码重起 | 加 **`--takeover`**(先停旧的再接管)。⛔ **别裸起第二个** —— Windows `SO_REUSEADDR` 会让两实例**静默并存**、同一 URL 随机应答("改了、也重启了、却还是旧的"就是这么来的)。单实例护栏:已有看板 ⇒ **拒绝启动** | | 改完必须重启 | ⚠️ 服务进程**已 import** 技能里的 `board.py` ⇒ 改快照内容后**不重启就跑旧代码** | | 停机出口 | `.workbuddy/collab/stop-collab.py`(dry-run 只报告)|`goalctl stop --yes` 会顺带停它 | @@ -373,7 +373,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但" ⇒ `file://` 双击即看,⛔ 不依赖本地服务、⛔ 不依赖网络。**桩验"内容与几何",人眼验"观感"**,两件都要做。 🔴🔴 **改之前必须知道的两件位置事实**(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`) ⇒ **只改包内那份等于没改** —— 必须 `cp` 同步过去,并**核 md5 一致**。 ⚠️ 同理还有 `goalctl.py` / `stop-collab.py` / `deliver-gateway-token.py` 等**工作区副本**。 @@ -420,7 +420,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但" - ⛔ **技能里不写死这一格叫什么** —— 名字由使用方经 `front.triggers[].name` 给。 🔴 **用户 2026-10-01 定的名(现行)=「唤醒会话」**;同日更早叫过「唤醒主会话」(**已作废**,用户原话「**唤醒主会话 改为 唤醒会话**」),再早叫「唤醒脉冲会话」「唤醒定时任务」(均**已作废**)。 属**项目侧**,`board.html` 里⛔ 不写死这个名字 —— 由使用方经 `front.triggers[].name` 给。 - **判据:`assets/board.html` 的渲染逻辑里不该出现具体项目名。** + **判据:`boards/会话看板/board.html` 的渲染逻辑里不该出现具体项目名。** - 🔴 **2026-10-01 23:1x 图上改「简称」(用户第六条口径)**:`唤醒会话 →` **唤醒**、`队列上报的跟进会话 →` **跟进** (⛔ 用户明令**两者字号一致**,`n-title-sm` 13px)、`目标检查 →` **协作**(`n-title` 16px)。 ⚠️ **简称 ≠ 改角色** —— 正文/代码里的**全称不变**;图里 `?`/`aria-label` **必须带简称对照** @@ -930,7 +930,7 @@ headless chrome /附着用户 Chrome** ⇒ **不许擅自开浏览器**。但" | **常驻程序** | `collabd.py`(自述"协作守护程序") | 队列闸门(`NEXT.md`)+ **唤醒投递** + 证据分级/真空/断链告警/体检/看板覆写。**⛔ 不做派活** | 「判定层」「常驻程序」「机械层」 | | **会话** | `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` · `/lib/net/relay/main.js --client`,由 `overlay-node-daemon.ps1` 监护 | 本机作为**节点**接入覆盖网络:向中继注册(`registered host=… accepted=[20090]`)、维护长连、转发流 | ⛔ 别再叫它「**设备侧 worker**」「worker」「节点进程」 | diff --git a/session-mechanism/references/deploy.md b/session-mechanism/references/deploy.md index 8a82dea..b7f0079 100644 --- a/session-mechanism/references/deploy.md +++ b/session-mechanism/references/deploy.md @@ -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` | | **L1 工作区级**(每工作区一份) | `<工作区>/.workbuddy/collab/collabd.config.json` | 这个工作区自己的东西:`workspace`/`inbox`/`live`/`taskgraph`/`host_db`(可写本机绝对路径)/`peer_workspaces` | **部署副本**:`/.workbuddy/collab/` 下的 `collabd.py`、`goalctl.py` | -**桥接(谁把两层接起来)**:`scripts/supervise-launch.py`(常驻)与 `scripts/board-launch.py`(看板)。 +**桥接(谁把两层接起来)**:`scripts/supervise-launch.py`(常驻)与 `boards/会话看板/board-launch.py`(看板)。 计划任务的动作里**没有 env 字段**,所以这两个启动器在**进程内**设 `COLLABD_CONFIG=<本区>/collabd.config.json` + `CODEBUDDY_CONFIG_DIR`,再 `runpy` 起本体。 diff --git a/session-mechanism/references/pitfalls.md b/session-mechanism/references/pitfalls.md index e2dac39..4b1937d 100644 --- a/session-mechanism/references/pitfalls.md +++ b/session-mechanism/references/pitfalls.md @@ -254,7 +254,7 @@ - **形状**:看板是**一个常驻 HTTP 服务**(`board.py --serve `),代码**载入内存后一直跑**。 - 于是**改完 `board.py` / `assets/board.html` / `board_ext.py`,页面上一点变化都没有** —— + 于是**改完 `board.py` / `boards/会话看板/board.html` / `board_ext.py`,页面上一点变化都没有** —— 服务还是**改之前**那份代码,它**不会自己重载**。 - **实测**:用户报「看板中协作会话区域**还是**看不到协作会话」。反复核对页面与代码都对得上, 最后发现**病根是进程**:服务是 **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 行**(那里少了一个 `*/`)。 当场表现为**渲染断言 35 条全红**,看着像"改坏了半个文件"。 - 🔴 **为什么这个坑难查**:第一反应是数 `{` 和 `}` 的个数。**没用** —— @@ -992,9 +992,9 @@ GUI 子系统 ⇒ 无 stdout ⇒ **静默无痕**,连"起没起"都查不到 | 改什么 | 要重启吗 | 依据(代码行) | |---|---|---| -| `assets/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`) | -| `scripts/board.py` 自身 | ✅ **必须** | 进程里跑的是**启动那一刻载入**的旧代码(P0-17 本体) | +| `boards/会话看板/board.html` | ⛔ **不用**(只需刷新页面) | `board.py:1293` `serve()` 里 `html_p.read_bytes()` —— **每次请求实时读盘**;且 `/board.json` 带 `html_sig`(`mtime.size`)⇒ 页面自动重载 | +| `boards/会话看板/board_ext.py` | ⛔ **不用** | `EXT_CACHE` 按 `(mtime, size)` 签名**热重载**(`board.py:855-870`) | +| `boards/会话看板/board.py` 自身 | ✅ **必须** | 进程里跑的是**启动那一刻载入**的旧代码(P0-17 本体) | | `collabd.config.json` | ✅ **必须** | `C = _cfg()` 在 `board.py:81` **模块级执行一次**,⛔ 无 re-read 路径(P0-33 第 1 条) | **现跑实证**(`tmp/_probe_hotreload.py`,改 `` 插标记再还原,⛔ 未重启任何进程): @@ -2351,7 +2351,7 @@ INBOX = '…\ai1net-dsh-server\tmp\supervise-inbox' 而**计划任务的"动作"里根本没有 env 字段** ⇒ 这个变量只能由**启动器在进程内设**。 - 🔴 **注意变量名别混**:包内 `roots.env` 提供的是 **`COLLABD_PROD_CONFIG`**, 而 `board.py` 读的是 **`COLLABD_CONFIG`** —— 名字对不上 ⇒ `roots.env` 兜不住它。 -- ✅ **修法**:新增 **`scripts/board-launch.py`**(启动器)=先在进程内设 +- ✅ **修法**:新增 **`boards/会话看板/board-launch.py`**(启动器)=先在进程内设 `COLLABD_CONFIG=<主区>/.workbuddy/collab/collabd.config.json` + `PYTHONIOENCODING` + 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`),再 `runpy.run_path(board.py, run_name="__main__")`; 看板任务的动作改指 **`pythonw.exe board-launch.py`**。 diff --git a/session-mechanism/scripts/collabd.py b/session-mechanism/scripts/collabd.py index 14c9d30..d1a230b 100644 --- a/session-mechanism/scripts/collabd.py +++ b/session-mechanism/scripts/collabd.py @@ -4177,7 +4177,7 @@ def _all_sessions_idle() -> bool: 解决会话是否执行的判断问题 统一一个标准」)⇒ 本函数改成**与看板同款**。 👉 **「看板的亮」就是 `status=='working'`** —— ⛔ 不是另有一个字段: - `assets/board.html:823` 逐字是 `var mOn=!!(main&&main.status==='working');` + `boards/会话看板/board.html:823` 逐字是 `var mOn=!!(main&&main.status==='working');` ⇒ 亮 ≡ `status=='working'`,不亮 ≡ 其余三档。 ⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。 diff --git a/session-mechanism/scripts/selftest.py b/session-mechanism/scripts/selftest.py index b0e1a44..eb6dfd3 100644 --- a/session-mechanism/scripts/selftest.py +++ b/session-mechanism/scripts/selftest.py @@ -58,6 +58,28 @@ import time from pathlib import Path 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()) TEST_WS = WS / "tmp" / "selftest" TIN = TEST_WS / "inbox" @@ -320,7 +342,7 @@ def t_role_drift(): (2026-09-30 起就是"改一处必须改两处")⇒ 这里**真的把两边拉出来逐样本对账**,⛔ 不靠人盯。""" m = imp() 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) spec.loader.exec_module(bd) samples = ["[协作]-[唤醒机制]-日志事前叫停棒", "[唤醒]-[唤醒机制]-脉冲", "[主]-[唤醒机制]-机制线", @@ -336,7 +358,7 @@ def t_role_drift(): "下载 open design", ""] 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") - sb = (HERE / "board.py").read_text(encoding="utf-8") + sb = (board_py()).read_text(encoding="utf-8") return [ ("两边角色判据逐样本一致(%d 条;不一致:%s)" % (len(samples), bad or "无"), not bad), ("🔴🔴 2026-10-02 两类改造:`[唤醒]`/`[跟进]` 两边**都判空**(已退役)—— ⛔ 判成活角色 ⇒ " @@ -736,7 +758,7 @@ def t_acc_is_pass_chinese(): # 真因=**拿错了"真身"**:判定词表住在 `board.py`,collabd 只是 import 它。 # ⇒ 判据必须**去 board.py 里读**(这才是"三处同款"要守的那个源)。 _rows_ok = [] - _bp = HERE / "board.py" + _bp = board_py() if not _bp.is_file(): _bp = HERE / "scripts" / "board.py" try: @@ -756,9 +778,9 @@ def t_acc_is_pass_chinese(): # ⚠️ 2026-10-05 修:`HERE` = **本脚本所在目录(scripts/)**, # 而 `board.html` 住在**技能根**下的 `assets/`(= `HERE.parent/assets`) # ⇒ 原写 `HERE/"assets"` 必 `FileNotFoundError`(本轮实测就是这么红的)。 - _html = HERE.parent / "assets" / "board.html" + _html = BOARD_DIR / "board.html" if not _html.is_file(): - _html = HERE / "assets" / "board.html" + _html = BOARD_DIR / "board.html" try: _hs = _html.read_text(encoding="utf-8") _mw = re.search(r"var\s+ACC_PASS_WORDS\s*=\s*(\[[^\]]*\])", _hs) @@ -2098,7 +2120,7 @@ def t_main_resolution(): @case("主会话:看板与投递**同一套判据**(⛔ 防两处漂移 —— 漂了就没人能发现换主会话)") def t_main_same_rule(): 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:' return [ ("两处都定义了 _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): spec = importlib.util.spec_from_file_location(name, path) 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") -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 = [ ("main1", "任意标题(主会话)", "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)) if "cwd" in 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: bad.append("board.py 仍残留 cwd 版的 project_scopes()") 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") try: r = json.loads((p.stdout or "").strip().splitlines()[-1]) @@ -2367,7 +2396,7 @@ def t_board_styles(): ② **组件里不许出现硬编码颜色** —— 颜色只许出现在令牌块(`--color-*` 等)里。 ⚠️ 一旦有人在 `.card{}`/SVG 里写死 `#fff`,换风格就会**花**(这是最容易犯的)。 """ - p = HERE.parent / "assets" / "board.html" + p = BOARD_DIR / "board.html" src = p.read_text(encoding="utf-8") need = ['[data-style="archify-dark"]', '[data-style="archify-light"]', ':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["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(b) s_empty = b._acc_summary({}) @@ -2421,7 +2450,7 @@ def t_board_orphan_visible(): os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["DSH_COLLAB_WS"] = str(TEST_WS) 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) 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-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'", "未归类", "不属任何当前任务类别", "不在当前任务类别清单里", # 图外提示要点名 "旧值 · 不在当前任务类别清单"] # 台账表要标注 @@ -2462,11 +2491,11 @@ def t_board_unrecognized_visible(): os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(b) - H = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") - S = (HERE / "board.py").read_text(encoding="utf-8") + H = (BOARD_DIR / "board.html").read_text(encoding="utf-8") + S = (board_py()).read_text(encoding="utf-8") need = [ # 看板真会画(⛔ 只算不画 = 没修) "sessions_unrecognized", "未归入本项目", "会被漏管", "值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称", @@ -2503,7 +2532,7 @@ def t_in_project_parity(): os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(bd) m = imp() @@ -2539,8 +2568,8 @@ def t_in_project_parity(): # 结果拿去跟 `a` 比的是**标题**不是**期望值** ⇒ 全表恒红。 # ⇒ 元教训:**对账类断言别用 `_` 当占位名**(一个函数里两个 `_` 必然屏蔽)。 _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") - S = (HERE / "board.py").read_text(encoding="utf-8") + H = (BOARD_DIR / "board.html").read_text(encoding="utf-8") + S = (board_py()).read_text(encoding="utf-8") return [ ("两处 `in_project` 逐样本一致(%d 条;不一致:%s)" % (len(cases), _mismatch or "无"), not _mismatch), @@ -2572,7 +2601,7 @@ def t_session_retire(): os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(b) m = imp() @@ -2650,7 +2679,7 @@ def t_board_topics_source(): os.environ["COLLABD_CONFIG"] = str(CFG) os.environ["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(b) @@ -2659,7 +2688,7 @@ def t_board_topics_source(): s_fb = b._goal_topics_source({"short": "简"}) s_no = b._goal_topics_source({}) 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", "未声明", "调用时在对话里说明"] miss = [n for n in need if n not in H] 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["DSH_COLLAB_WS"] = str(TEST_WS) 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) spec.loader.exec_module(b) @@ -2733,7 +2762,7 @@ def t_board_multi_goal(): by = {str(g.get("key")): g for g in gs} _ga = by.get("ga", {}) 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", "progress", "others_running", "front", "runtime", "notify", "warn", "meta"} _miss = sorted(old_top - set(snap.keys())) @@ -2850,7 +2879,7 @@ def t_board_multi_goal(): "COMSPEC": os.environ.get("COMSPEC", ""), "COLLABD_CONFIG": str(_cf), "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, encoding="utf-8", errors="replace") _bj = _in / "board.json" @@ -2882,7 +2911,7 @@ def t_board_multi_goal(): {"id": "t4", "short": "丙4", "title": "丙目标4", "lifecycle": "进行中", "run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False), 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, encoding="utf-8", errors="replace") _d9 = _j8.loads((_in / "board.json").read_text(encoding="utf-8")) \ @@ -2905,7 +2934,7 @@ def t_board_multi_goal(): # 🔴 抓「干脆不排序」:夹具里文件出现次序**本就是时间倒序**(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" % ("tab_created" in _bsrc and "blocks.sort(" in _bsrc), 'blocks.sort(key=lambda b: (b.get("tab_created"' in _bsrc)) @@ -3182,8 +3211,14 @@ def t_no_schedule_as_supervisor(): "2. ⛔ **不许把排期当", "3. ⛔ **不许在排期 prompt 里写", "4. ⛔ **常驻真死时不许用排期兜底**"))), - ("技能自己的文档**早写对了**「⛔ 排期代替不了载体」(判据据此钉住)", - "排期代替不了载体" in PP or "排期代替不了" in PP), + # 🔴🔴 2026-10-14 订正:该档同日改成**更硬的措辞** —— + # 「排期按点拉起主会话这条路不存在」(原来那句「排期代替不了载体」现只在 SKILL.md 里)。 + # ⇒ 判据改成**锚档内现存的那句**(⛔ 别继续锚已搬走的旧措辞 ⇒ 恒红)。 + # ⚠️ 这是本档第 N 次「文档改了、判据锚点没跟」⇒ 纪律:**锚点逐字抄原文,⛔ 不凭记忆**。 + ("技能自己的文档**早写对了**「⛔ 排期代替不了载体/排期拉起主会话这条路不存在」" + "(判据据此钉住;⛔ SKILL.md 与 references 任一在位即可)", + "排期代替不了载体" in PP or "排期代替不了" in PP + or "排期按点拉起主会话这条路不存在" in PP), ] # 🔴 ② env_check 必须查工作区部署(⛔ 原来这一段完全不存在) _ec = m.env_check(ws=str(HERE.parent.parent.parent), scope="workspace") @@ -3300,7 +3335,7 @@ def t_board_layout(): ① 每个 `.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"') qtip = src.count('class="qtip"') + src.count('class="qtip" id=') DELETED = ["一张看板只管", "只读旁路观测", "不按工作目录", "其他工作区另有在跑的会话", @@ -3331,6 +3366,9 @@ def t_no_jargon(): ③ 看板**回读不引用已废弃的项目键**(`front.shim` / `front.worker` / `device_ingress` / `overlay_node`)。 """ 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` 就是这种), @@ -3342,8 +3380,10 @@ def t_no_jargon(): leak = [n for n in forbidden if (SK / n).exists()] # ①b 源码级不变量:日志**不得**再挂在技能目录上(`LOG = HERE / …` 是旧写法) logbad = [] - for fn in ("collabd.py", "board.py", "guard.py"): - t = (SK / fn).read_text(encoding="utf-8") + for fn, _fp in (("collabd.py", SCRIPTS / "collabd.py"), + ("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): s = line.strip() if s.startswith("#"): @@ -3366,9 +3406,11 @@ def t_no_jargon(): # ✅ 判「豁免有没有被滥用」:`_DS_ANCHORS_OTHER` 也豁免,但它是**对照表**(零计算), # 改它只改体检输出 ⇒ 两条一起放行是安全的。 _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: - src = (SK / fn).read_text(encoding="utf-8") + src = _fp.read_text(encoding="utf-8") except Exception: hits.append("%s:读不到" % fn) continue @@ -3381,7 +3423,7 @@ def t_no_jargon(): if proj_pat.search(line): 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", "f.device_ingress", "f.overlay_node") if k in html] 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'`** —— 与看板那个格**同款** - (`assets/board.html:823` 逐字 `var mOn=!!(main&&main.status==='working');` + (`boards/会话看板/board.html:823` 逐字 `var mOn=!!(main&&main.status==='working');` ⇒ 亮 ≡ `status=='working'`)。 ⛔ **本函数不再调 `_target_busy()`**(读宿主状态机日志那套是**第二套口径**)。 @@ -3695,7 +3737,7 @@ def t_check_agent_naming(): import importlib.util import sqlite3 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) spec.loader.exec_module(b) @@ -3753,7 +3795,7 @@ def t_check_agent_role_parity(): ⇒ **对账当场抓到 2 条漂移**。⇒ 判据自己会指路,⛔ 不靠人盯。 """ 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==='任务会话'`)—— # 标签已改成「执行会话」⇒ 写死的那份**当场报红**(真判据,不是误报)。 # ✅ **改成从后端真值派生**: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_LABEL_WORKER) 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) spec.loader.exec_module(b) @@ -4380,7 +4422,7 @@ def t_prog_online_judge(): import json as _j import time as _tm 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) spec.loader.exec_module(b) @@ -4397,7 +4439,7 @@ def t_prog_online_judge(): dead = b._label_when_down(10.0, 999999, False) unknown = b._label_when_down(None, None, False) # ④ `_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) return [ ("① 判据读**常驻心跳**(heartbeat_age_min 字段在位)", has_hb), @@ -4505,8 +4547,8 @@ def t_tab_peer_workspace(): import re as _re m = imp() spec = _ins.getsource # 占位(下面直接读文本) - src_b = (HERE / "board.py").read_text(encoding="utf-8") - src_h = (HERE.parent / "assets" / "board.html").read_text(encoding="utf-8") + src_b = (board_py()).read_text(encoding="utf-8") + src_h = (BOARD_DIR / "board.html").read_text(encoding="utf-8") # ① 函数在位 has_fn = "_peer_workspaces" in src_b @@ -4548,7 +4590,7 @@ def t_tab_peer_workspace(): else: os.environ["DSH_COLLAB_WS"] = str(ws_env) _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) _sp.loader.exec_module(_mm) return _mm @@ -4725,7 +4767,7 @@ def t_peer_block_no_self_data(): 🔴 **判据可证伪**:把 `_peer_tasks(_wr)` 换回本区的 `tasks` ⇒ ⑤ **必然报红**(两侧 tasks 变相同)。 """ import tempfile - src = (HERE / "board.py").read_text(encoding="utf-8") + src = (board_py()).read_text(encoding="utf-8") # ① peer 分支喂的是对方数据源 feeds_peer = ("_peer_tasks(_wr)" in src and "_peer_state(_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["DSH_COLLAB_WS"] = str(A) _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) _sp.loader.exec_module(bd) try: @@ -4819,7 +4861,7 @@ def t_view_takes_all_scoped_fields(): os.environ["COLLABD_CONFIG"] = str(_pcfg) os.environ.pop("DSH_COLLAB_WS", None) _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) _sp.loader.exec_module(bd) 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)] # 🔴 提取赋值清单前**先剥掉注释** —— ⛔ 否则"把某行注释掉"这种变异**照样匹配得到** # (本轮实测踩到:注释掉 `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"//[^\n]*", "", _html) _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` 恒成立**(别靠加更多分支)。 """ 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): # ④ 那段注释里**逐字写着** `RXV=PX-40`/`RXH=PX+28` ⇒ 正则先命中注释 # ⇒ 抽出来是一整段散文 ⇒ `eval` 失败 ⇒ 判据整条失效。 @@ -5597,7 +5639,7 @@ def t_pkg_hygiene(): # 零引用文件:assets/ 下每个 .js,⛔ 排掉本身被引用的 dead = [] - for p in list((HERE.parent / "assets").glob("*.js")): + for p in list((BOARD_DIR).glob("*.js")): stem = p.stem hit = 0 for q in HERE.parent.rglob("*"): @@ -5704,7 +5746,7 @@ def t_warn_text_states_consequence(): 凡"⚠/缺了/没读"类文案,**必须同时出现一个后果词**(会…/不会…/导致…)。 """ 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"(?m)^\s*//.*$", "", code) @@ -5749,7 +5791,7 @@ def _worker_label_from_source() -> str: """ import ast as _ast try: - t = _ast.parse((HERE / "board.py").read_text(encoding="utf-8")) + t = _ast.parse((board_py()).read_text(encoding="utf-8")) except Exception: return "" for n in t.body: @@ -6757,7 +6799,7 @@ def t_board_render_runs_clean(): · `p.*` —— 参数早已改名成 `d`,函数体里 6 处没跟着改(同族,一次改名的漏网)。 ⇒ 都属于「**变量名与数据流向不符**」,⛔ 语法检查查不出(原版语法是过的)。 - ✅ 本判据的做法=**真跑**:用 `assets/board-render-probe.js` 把页面脚本放进 + ✅ 本判据的做法=**真跑**:用 `boards/会话看板/board-render-probe.js` 把页面脚本放进 `vm` 的**全局作用域**执行(⛔ 不能用 `new Function` —— 函数会关在局部, 取到 `undefined` ⇒ **假绿**),喂一份**合成快照**,真调 `renderProject()`, 看它抛不抛、`#proj` 真渲染了没。 @@ -6769,10 +6811,10 @@ def t_board_render_runs_clean(): import json as _json import subprocess as _sp import tempfile as _tmp_mod - probe = HERE.parent / "assets" / "board-render-probe.js" - html = HERE.parent / "assets" / "board.html" + probe = BOARD_DIR / "board-render-probe.js" + html = BOARD_DIR / "board.html" 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"))) # 源码级:`acc` 必须**先声明后使用**(⛔ 不能只是"出现过") @@ -6895,6 +6937,12 @@ def t_peer_main_resolved(): # ⚠️ 本用例要调的是 **`board.py`** 的函数(`_scan_ws_mains`/`_resolve_main`/`project_scope`), # ⛔ **不是** `imp()` 装出来的 collabd 模块 —— 那两个模块都有同名函数, # 拿错模块会 `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 # ── 合成一条"对方工作区的主会话"(直接写测试库;⛔ 绝不碰生产库) peer_root = str(TEST_WS / "peerws") @@ -6937,7 +6985,7 @@ def t_peer_main_resolved(): # ⛔ **证明不了 `_goal_block()` 真把它传下去了** —— 实测变异 M3(删掉调用点的传参) # **本条全绿**,而线上 peer 格照样「无主会话」。 # ⇒ 判据必须**静态咬住那条调用点**:`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`**" "(⛔ 只测函数体等于没测装配)", "project_scope(g, ws_root)" in _bs))