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:
1 parent
5d16c6df70
commit
52566c79f9
16 files changed
+2044
-92
No files matched your search
@@ -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 字段**
|
||||||
|
|||||||
+6
-2
@@ -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))
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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())
|
||||||
@@ -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」「节点进程」 |
|
||||||
|
|
||||||
|
|||||||
@@ -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` 起本体。
|
||||||
|
|
||||||
|
|||||||
@@ -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`**。
|
||||||
|
|||||||
@@ -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'`,不亮 ≡ 其余三档。
|
||||||
⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。
|
⇒ **本机制内「会话是否在执行」统一以这一条为唯一标准**(看板、闸②、看板文案同源)。
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in new issue
Block a user