项目管理界面:工作区侧改成薄转发(真身已入会话技能 boards/)

承接 52566c7(技能仓):两个看板整包搬进 session-mechanism/boards/。

工作区侧不留第二份实现(同族 P0:一份规则两处实现 ⇒ 必然漂):
* project-board.py   38380B 完整实现 → 薄转发(解析技能根 → 设变量 → runpy 真身)
* project-board.html 54102B 真身页面 → 路标页(说明三处位置,⛔ 别再当源文件改)
* project-board-launch.py 上一轮已改薄转发

计划任务不用改:动作指向本目录 project-board-launch.py(薄转发会转到技能包真身)——
留这一层的目的就是路径稳定(技能升级/换机时只改一处)。

验收:
* python 交付物/项目管理界面/project-board.py --once ⇒ 4 个项目全列出(与技能包真身同读数)
* HTTP / 200 · 54102 字节(=技能包真身那份,证明确实是转发)· /api/projects 出数据 · /healthz ok
* 计划任务重起:pid 35080 → 23596

原件备份 ⇒ tmp/pm-board-bak-20261014/
This commit is contained in:
admin committed 2026-10-14 15:40:32 +08:00
1 parent d933181a55
commit 6b783aaf82
4 files changed
+595 -1518

No files matched your search

+432
View File
@@ -0,0 +1,432 @@
# 2026-10-14 工作日志
## 主会话 · 排查「执行/检查会话批量操作不能归档、不能删除」(07:2x~07:4x)
**用户提问**:为什么会话机制创建的执行会话和检查会话,批量操作时不能归档也无法删除。
**取证路径**(全部只读,未改任何代码与数据):
1. 只读打开宿主库 `E:\ProgramData\.workbuddy\workbuddy.db`,查 `sessions` 表结构与取值分布。
2. 读出 asar 头部索引(13910 文件),自建 `tmp/asar-extract.js` / `tmp/asar-grep.js` 做定点抽取与全量检索。
3. 抽取并核实 `main/conversations.js`、`main/handlers.js`、`main/server.js`、
`renderer/assets/adapters-*.js`、`renderer/assets/DataManagementPanel-*.js`、
`renderer/assets/ui-docs-viewer-*.js`。
**结论(产品层设计,非本项目机制缺陷)**:
1. 产品**没有会话的批量归档/批量删除接口** —— `archiveSessions`/`deleteSessions`/`batchArchive` 全量检索 0 命中。
2. 归档可用性判据(`ui-docs-viewer`):
`_n = e => !((e.status??'').toLowerCase()==='archived' || e.projectId || I4n(e, 空间))`
⇒ `archived` / `projectId` 非空 / `I4n` 命中,三者任一即不可归档。
`I4n(e,t) = e.transport==='cloud' ? false : e.spaceType==='assistant' || M4n(e.cwd,t)`。
3. 归档只改 `status`,`deleted_at` 保持 `NULL`;而保留策略自动清理对已归档项是 `archivedSkipped += 1; continue`
⇒ 归档不会让会话消失,只移出默认列表。
4. 「删除」在本地是**软删**(`SET deleted_at = ?`,`transport='local'`),行仍留在 `sessions` 表
⇒ 从库里看「像没删掉」。`deleted_at = -1` 是另一套语义(云端索引 `WHERE deleted_at = -1` 表未删)。
5. `DataManagementPanel` 的批量交互操作的是「归档列表项」,按 `kind` 分流;
`readonly` 项(`origin` 为 `project`/`cloud`)**结构上不渲染操作按钮**。
**现场读数**:`sessions` 412 行,`deleted_at IS NOT NULL` 319 行;
`[执行]`/`[检查]` 会话全部 `is_background_automation=1` + `transport='local'`。
**产出**:`tmp/会话批量归档删除-排查-20261014.md`(完整证据链与可复现取证方法)。
**未做(等拍板)**:机制层加「批量归档/清理已完结执行·检查会话」的动作 —— 属直接改宿主库,
须守活库改动铁律(停 app 或 `backup()` API,⛔ 禁文件级 `cp`)。
**本机坑(再次命中)**:⛔ 别把 MSYS 路径(`/e/…`)交给 Windows 原生 node,会落到 `E:\e\…`;一律传 `E:/…`。
---
## 主会话 · 排查「mcn-aigc-ontology 为什么自己创建了一个主会话」(07:34~07:40)
**用户提问**:mcn-aigc-ontology 为什么这个工作区运行会话项目机制,自己创建了一个主会话。
**现场读数**(全部只读):
1. 该区已装机制:`.workbuddy/collab/collabd.config.json` 在位,`supervise.switch` = `on`。
2. `sessions` 表里该 `cwd` **只有 1 条会话**:`9e069caf`「加载会话技能」,`status=working`,
`is_background_automation=None`(**不是**机制建的),`created_at` 07:12:14 —— **这条就是当前用户在用的主会话本身**。
3. 该区 `automations` 只有 **2 条**:
- `473c13a3`「`[主]-super-i卡片采集-主会话`」`recurring` `FREQ=DAILY;BYHOUR=09;BYMINUTE=0`
`cwds=["E:/ProgramData/AIProject/mcn-aigc-ontology"]`,`created_at` **07:31:11**,`last_run_at=None`
(**下次触发 09:00,从没跑过**)。prompt 逐字:「你是本工作区的主会话……要推进就直接建 `[执行]` 排期拉起任务会话」。
- `5e16f0de`「`[执行]-super-i卡片采集-全量抓取与落盘`」`once`,`scheduled_at=2026-10-14T07:35`,`created_at` **07:31:19**。
4. 常驻:`logs/supervise-heartbeat.json` → `pid=38624`、`started_h=07:30:37`、`round=25`、
`argv0` 指向本区 `.workbuddy/collab/collabd.py`(**argv0 合规**)。
5. 🔴 `logs/_collabd.log` 每 10 s 一条 **`all_sessions_idle 读库失败 no such table: sessions`**
—— **典型「启动器丢了 `CODEBUDDY_CONFIG_DIR`」故障**(读 0 字节空库 ⇒ 检查程序静默失效)。
**结论:机制没有"自己创建主会话"。** 真实发生了四件事,被误读成"机制自创主会话":
> 🔴🔴 **2026-10-14 07:53 订正(用户点破)**:本行结论**只对了一半,且说偏了**。
> 用户原话:「**那就更不对了,技能中没有任何创建主会话的机制,除非是创建接续会话**」。
> 「机制**运行中**没有偷偷建主会话」这半句对;但**技能文档里确实存在"排期创建主会话"这条路**(写成了合法场景),
> 那才是真问题。⇒ **别照本行往下读,改看本文件末节《订正「技能里存在"创建主会话"这条路"》》。**
> (本行保留仅为留痕 —— 「过程 ≠ 成品」同理:错结论⛔ 不删,加状态块指向订正处。)
1. **07:12** 用户自己在该区开了一条会话(标题「加载会话技能」)= 本区**真主会话**。
2. **07:30** 该区常驻被拉起(`pid 38624`),随即把这条 07:12 的主会话**登记进 goal.json**
(日志逐字:`登记主会话 9e069caf(创建目标时顺手登记)`)。
3. **07:31** 该会话(或同轮动作)**新建了一条 recurring 排期** `[主]-super-i卡片采集-主会话`,
`cwds` 指本区、每天 09:00 触发 —— **这条排期到点会真的开出一条新的主会话类型会话**。
4. 同一分钟还建了一条 `[执行]` once 排期(07:35 触发)。
⇒ 「自己创建了一个主会话」的**可见现象**=排期台账里那条 `[主]-…-主会话`;
它**尚未运行**(`last_run_at=None`),**不是已经多出来一个窗口**。
**风险(实测,未处置)**:
- 该 recurring 排期**每天 09:00 会开一条新会话**;若长此以往 ⇒ 每天多一条"主会话"⇒ 与用户实际在用的 `9e069caf` **并存**,
变成"一个工作区两个主会话"。技能口径:**主会话由用户手动开,⛔ 不该由排期生产**。
- `_collabd.log` 的 `no such table: sessions` ⇒ 该区**检查程序(建检查会话那条腿)静默失效**。
与该排期是否清理**是两件独立的事**。
**本机机制层观察**:排期 `scheduled_at` 给了毫秒级时间戳;`rrule` 为空串(once)。
---
## 主会话 · 修复 mcn-aigc-ontology 两项(07:35~07:45)
用户令:「两个问题都按照建议进行修复」⇒ 按倾向 A 执行。
**修复一:撤掉每日主会话排期**
- `[主]-super-i卡片采集-主会话`(id `473c13a3`)已置 **`PAUSED`**,`next_run_at` 清空。
- 用 `automation_update` 改状态(⛔ 未用 rm/sqlite,符合机制铁律);**零删除、可逆**(改回 ACTIVE 即恢复)。
- 那条 `[执行]-super-i卡片采集-全量抓取与落盘`(`5e16f0de`)保持 ACTIVE,07:35:09 已正常触发建出执行会话 `c6226e12`。
**修复二:常驻读库故障(`no such table: sessions`)**
- 真因(证据链):`supervise-launch.py` 的默认值在 **10-11 被改成 `~/.workbuddy`**,
而本机 `C:\Users\Administrator\.workbuddy\workbuddy.db` 是 **0 字节、零张表**(实测);
真库在 `E:\ProgramData\.workbuddy\workbuddy.db`(124 MB)。ai1net 区没炸是因为它 config 里 `host_db` 兜住了。
- 修法(双保险,与技能 P0-73 口径一致):
① `supervise-launch.py`:默认值改回**真库绝对路径** `E:\ProgramData\.workbuddy`(`setdefault` 语义保留,宿主 env 优先);
② `collabd.config.json`:补 `"host_db": "E:/ProgramData/.workbuddy/workbuddy.db"`(**本该有的单区兜底**)。
- 改前备份:`supervise-launch.py.bak-configdir-20261014-073619`、`collabd.config.json.bak-hostdb-20261014-073619`。
- 重启:计划任务 `collabd-keepalive-mcn-aigc-ontology`(`pythonw.exe`,合规)⇒ 旧 `pid 38624` → 新 `pid 28628`。
**验收(三条对齐,非恒绿)**:
1. 启动器解析出的库 = `E:/ProgramData/.workbuddy/workbuddy.db`,`sessions` 413 行读通。
2. 重启后日志:`all_sessions_idle:还有 1 条 working(c6226e12)⇒ 不算全结束` —— **正常读到库**,
本轮 `no such table` **0 条**(重启前是每 20s 一条、连续 25+ 轮)。
3. **变异对照**:旧形态(`~/.workbuddy` + 无 `host_db`)⇒ 复现 `no such table: sessions`;
新形态 ⇒ 读通 413 行。⇒ 判据有效,不是恒绿。
**遗留(技能/其他区层面,本轮未动)**:
- 🔴 **技能源 `E:\ProgramData\.workbuddy\skills\session-mechanism\scripts\supervise-launch.py` 第 30 行仍是
`os.environ.setdefault("CODEBUDDY_CONFIG_DIR", os.path.expanduser("~/.workbuddy"))`** ⇒
**凡是照技能源部署的新区,都会命中同一个空库故障**。本区已单区修好,技能源未动 = 待拍板。
- 各区部署副本形态不一(实测):
`ai1net-dsh-server`/`contentm_agent`/`mcn-aigc-ontology`(已修)= 写死 `E:\ProgramData\.workbuddy` + config 有 `host_db`;
`aigc-idea-impression` = 写死绝对路径但 **config 无 `host_db`**(靠启动器那条腿活着,实测日志正常);
`vibe-product` = 未部署启动器。
- 本机 `~/.workbuddy/workbuddy.db` 那个 0 字节空文件仍在(无害,但会持续误导"以为 `~` 是活动环境")。
**终态复核**:写法定为与 `contentm_agent` 逐行一致(`setdefault` + 绝对路径),语法 OK、重启后 `pid 38428`、读库失败 0 条。
---
## 主会话 · 技能源相对化 + 四区统一部署(07:45~07:52)
用户令:「按照建议处理,技能中 **文件路径 必须用相对路径**」⇒ 该条**推翻了上一轮"给各区写死绝对路径"的做法**。
**动因**:上一轮为救 mcn 区把技能源里的 `~/.workbuddy` 换成写死绝对路径 —— 这本身是**把「一台机器的环境」焊进技能**,
与既有的 `collabctl.py::_resolve_airoot()` 口径(⛔ 不在代码里写死盘符/目录)冲突。用户第四条指令正是纠这个。
**改法:`HERE` 相对基准 + 三级外置取值 + ⛔ 禁回落 `~`**
```
HERE = os.path.dirname(os.path.abspath(__file__)) # 全部路径以 HERE 为基准(相对)
CB = <HERE 推导出的 collabd.py>;CFG = <HERE 推导出的 collabd.config.json>
_resolve_config_dir() 三级:
① os.environ["CODEBUDDY_CONFIG_DIR"] —— 宿主 env 已给 ⇒ 直接用,⛔ 不动
② 本区 collabd.config.json 的 host_db —— 取其父目录
③ 包内 env/roots.env 的 CODEBUDDY_CONFIG_DIR —— 向上最多 7 层找 env/roots.env
三级全空 ⇒ 置空 + stderr 告警,⛔ 绝不回落 ~/.workbuddy(那是 0 字节空库)
```
- 三级取值均**外置**(宿主 env/本区配置/包内 env),代码内**零绝对路径**。
- 保留 `setdefault` 语义的反面:**宿主 env 优先级最高**(命中①时原样不动)。
- docstring 里示例路径改写为正斜杠(`E:/ProgramData/.workbuddy`)—— 顺带解掉 `\U` unicodeescape 报错。
- 新增 `import json`。
**验证(探针,非纸面)**:造临时区跑探针 ⇒
① 宿主 env 命中(`D:/fake/host/env`,原样采用);
② 无宿主 env 但有 `host_db` ⇒ 解析出 `E:\ProgramData\.workbuddy`;
③ 三者皆无 ⇒ `CODEBUDDY_CONFIG_DIR` 为 `None` 并打出告警(**fail-safe 方向正确**:宁可报错也不静默读空库)。
**分发与终态**:
- `deploy_code.py --ws <区>` 分发四区,自动备份旧副本 ⇒ **四区 `supervise-launch.py` md5 统一 `bfa03b2f`**。
- `aigc-idea-impression` 补 `"host_db": "E:/ProgramData/.workbuddy/workbuddy.db"`(JSON 校验通过)——
这是四级兜底里它原本缺的一环。
- 技能包 `env/roots.env` 第 9 行已含 `CODEBUDDY_CONFIG_DIR=E:/ProgramData/.workbuddy` ⇒ 第③级在位,无需补。
**四区常驻重启终验**:
```
ai1net-dsh-server 旧 20680 → 37280 07:50:41 ✅ 读库正常
mcn-aigc-ontology 旧 38428 → 4728 07:50:46 ✅ 读库正常
aigc-idea-impression 旧 29364 → 20304 07:50:52 ✅ 读库正常
contentm_agent 旧 27092 → 29400 07:50:58 ✅ 读库正常
日志实证:all_sessions_idle:还有 1 条 working(f920d73e)⇒ 不算全结束 / no such table 共 0 条
```
**🔴 必须记住的一条口径**:技能源改动**只影响将来部署的新区**;已在跑的四区是靠**部署副本 + 重启**生效的。
⇒ 以后改技能源,动作永远是「改源 → `deploy_code.py` 分发 → 重启常驻 → 验心跳 `started_h` 晚于部署时间」,三步缺一即未生效。
**遗留**:`~/.workbuddy/workbuddy.db`(0 字节)仍在,无害但会持续误导;`vibe-product` 未部署启动器。
---
## 沉淀(跨区通用,供后续同类问题直接引用)
- 🔴 **读写宿主库的脚本,路径一律「外置 + 相对基准」**:代码里只认 `CODEBUDDY_CONFIG_DIR`/本区 `host_db`/包内 `env/roots.env`,
⛔ 不写死盘符、⛔ 不猜 `~`。判据:把区**整个搬到另一台机器**仍能跑通 ⇒ 合格。
- 🔴 **绝对路径修复是「治好了但焊死了」**:同一症状下次换机器/换盘符必复发 ⇒ 只能作**应急**,须在同轮内补上相对/外置改造。
- 🔴 **`~/.workbuddy` 在本机是空壳**(0 字节库),凡代码里出现 `expanduser("~/.workbuddy")` 一律视为**高危默认值**。
---
## 主会话 · 订正「技能里存在"创建主会话"这条路」(07:53~08:00)
**用户订正原话**:「**那就更不对了,技能中没有任何创建主会话的机制,除非是创建接续会话**」。
⇒ 我上轮把那条排期说成"多出来的主会话(只是还没跑)",**说岔了** —— 用户的点不在"它跑没跑",
而在**技能里根本就不该有"排期创建主会话"这条路**。核实结论:**用户判断正确,技能里确实有这么一条(错的)路**。
**病灶(四处,同源)**:`supervise-persistence.md` 早期把「**定时任务创建主会话**」与「用户手动创建主会话」
并列成**两种合法场景**,`SKILL.md` §⑥ 顺着抄了一遍 ⇒ 读到的人(或 AI)会理解成"这条路是允许的",于是照做。
**事故链条(mcn-aigc-ontology,全部实测)**:
1. 07:12 用户手动开 `9e069caf`「加载会话技能」= 该区**真主会话**(`is_background_automation=None`)。
2. 07:30 `init_workspace.py` 装机制(`goal.json` 的 `lifecycle_why` 逐字「init_workspace.py 新建」),
常驻拉起并顺手登记主会话(日志逐字「登记主会话 9e069caf(创建目标时顺手登记)」)。
3. **07:31:11 建出 `recurring` 排期 `[主]-super-i卡片采集-主会话`**(`FREQ=DAILY;BYHOUR=09;BYMINUTE=0`),
**prompt 逐字**:「你是本工作区(…)的主会话,只负责目标与方向,不亲自干活。……职责:判断与派活
(要推进就直接建 `[执行]` 排期拉起任务会话)」
⇒ **这是"主会话"的口径,被套到了一条排期身上** —— 排期开出来的会话恒是任务会话那一档。
4. ⇒ 每天 09:00 会开出一条**顶着主会话名头的任务会话**;长期跑 ⇒ 台账里"主会话"越积越多,用户那边**没多窗口**。
**权威口径(技能里本来就有,只是被上述那处盖住了)**:`SKILL.md`《三类会话》类别表逐字
——「**主会话 | 用户手动开 | 管目标与方向、派活 | 用户关窗**」;
`architecture.md §1` 同款;排期只服务**派活线**(`[执行]-…`)与**检查线**(`[检查]-…`)。
「接续会话」是主会话撞阈值时**由它自己建出来的形态**(角色继承),⛔ 不是第三条建主会话的路。
**修复(三件,已全部落地并验收)**:
**① 改文档(4 处)**:
- `references/supervise-persistence.md §〇`:表格从"场景 A/场景 B 两列"改成**只有一列**(「用户手动打开主会话」),
并新增《为什么⛔ 不能"用排期拉起主会话"》小节 —— 含事故回放 + 明确写「本档早期表格是病灶,2026-10-14 已改掉」。
- 同档 §八 对照表:**删掉「B · 定时任务创建主会话」那一行**,只留有价值的读数(`once` 过期即哑)。
- `SKILL.md §⑥`:那句「用户手动创建主会话 / 定时任务创建主会话」改成
「**形态只有一种:主会话由用户手动开** —— ⛔ 排期拉不出主会话」。
- `references/architecture.md §1`:新增 🔴🔴 段《主会话怎么来:只能由用户手动开》,含判据指针。
**② 加判据(第 ⑬ 条,`session-rules-check.py`)**:
- 扫本区排期,命中 `name` 以 `[主]` 开头或含"主会话" ⇒ `fail`(还在 ACTIVE)/`warn`(已非 ACTIVE 的留痕)/否则 `ok`。
- ⚠️ **扫描面 recurring 与 once 都含**(当年 ⑧ 只扫 recurring 才看不见机制建的 once 排期,同类坑)。
- **变异对照(证明非恒绿)**:喂 mcn 区真数据三种输入 —— 原样 PAUSED ⇒ `warn`;伪造成 ACTIVE ⇒ `fail`;
无此类排期的区 ⇒ `ok`。**三档全判得出**。夹具 `tmp/probe-main-sched-judge.py`。
- `SKILL.md §体检` 清单从「查三类、十二项」改成「**十三项**」;变异对照记录补上 ⑬ 那行。
**③ 分发与验收**:
- `deploy_code.py` 分发四区,`session-rules-check.py` md5 统一 **`4b53c88d`**(技能源同值)。
- mcn 区用部署副本真跑 ⇒ 第 ⑬ 条按预期报 `warn`(那排期现为 PAUSED)。
- **顺带抓出另一条真隐患**:那条排期 `model_id=deepseek-v4.1-flash` + `model_is_thinking=0`
⇒ **每次触发必被服务端拒**(判据 ⑧ 报 `fail`)。⚠️ 该排期现已 PAUSED ⇒ 不触发,但**若哪天改回 ACTIVE 必须同时改模型**。
**沉淀(本机口径)**:
- 🔴 **主会话只能由用户手动开**。排期开出来的会话**恒是任务会话那一档**(`is_background_automation=1`);
名字里带 `[主]`/"主会话"**不改变它的档位**。判据=排期名,见第 ⑬ 条。
- 🔴 **技能文档里"并列两种合法场景"的写法极危险** —— 它会把一条**本该唯一的路**读成"二选一"。
⇒ 写"形态"类内容一律**单列**;要留旧形态只作**事故回放/留痕**,并明确写"⛔ 别照做"。
---
## 主会话 · 三项拍板执行(09:46~09:55)
用户令(逐字):「**1、A / 2、确认调整无误 就更新 / 3、不需要就删除,避免创建无效文件**」。
**① 排期保持 PAUSED(候选 A)**
- mcn 区 `[主]-super-i卡片采集-主会话` **保持 PAUSED 不动**(`next_run_at=None` ⇒ 不触发;零风险、可逆)。
- 终态复核:该区 4 条排期 —— `[主]-…` PAUSED|`[执行]-…第2棒` ACTIVE(`next_run_at` 已排)|`[检查]-第1棒` ACTIVE|
`[执行]-…全量抓取与落盘`(首棒)已跑完。链路正常。
**② 核实无误后更新(推送技能总仓)**
- **入库前必查三件**(全部通过):白名单核对 `git ls-tree -d` = **7 个技能目录**、与 `.gitignore` 放行条逐条对上;
凭据扫描(`password/token/secret/api_key/PRIVATE/x-access-token/ghp_/sk-`)**零命中**;两个 py 语法编译通过。
- **错口径清零复核**:`SKILL.md`/`architecture.md` 里「定时任务创建主会话」「场景 B」**零命中**;
`supervise-persistence.md` 剩 4 处**全是事故回放/留痕**(明写「⛔ 别照做」)。
- **编号冲突复核**:体检清单 ⑬ 唯一;`pitfalls.md` 里那套 ⑦~⑬ 是 `t_srsm_busy` 夹具的独立编号,不冲突。
- **提交**:`053a6a6`「session-mechanism:主会话只能由用户开(撤掉"排期建主会话"那条路)」,5 files / +180 / -26。
- **推送**:`[email protected]:admin/workbuddy_skills.git` ⇒ `20eacef..053a6a6 master -> master`(成功)。
- **终验**:本地 HEAD = 远端 `origin/master` = `053a6a67231fe39bc6fcbcfe983d8ab5b6438196`;`git status` 干净。
**③ 删除 0 字节空壳库(避免误导)**
- 清单:`C:\Users\Administrator\.workbuddy\workbuddy.db` —— **0 字节**、2026-10-03 00:06 建、零张表、**无 WAL/SHM 伴生**
(该目录其余 `MEMORY.md`/`device-id`/`logs`/`observe`/`pending-telemetry` **一律不动**)。
- 做法:**送回收站**(`SHFileOperationW` + `FOF_ALLOWUNDO`,⛔ 不直删)。
- ⚠️ **删前加了一道自检**:脚本先查大小,**非 0 字节就拒绝执行**(防误删真库)。
- 结果:`rc=2`(`SHFileOperation` 的"部分成功"语义),原位已不存在。
- 影响面复核:真库 `E:\ProgramData\.workbuddy\workbuddy.db` 在位(`sessions` 416 行/`automations` 341 行);
四区常驻读库正常 —— `no such table` 最后一次出现分别是 **10-05 12:14**(ai1net)与 **今天 07:37**(mcn),
**均为重启前历史行**,aigc/contentm 为 0 条。本轮删除**未造成任何新故障**。
**临时文件清理**(用户令「避免创建无效文件」)
- 送回收站:`who-created-main-sched.py`/`main-sched-prompt.py`/`trash-empty-db.py`/`cleanup-20261014.py`(清理脚本自身也一并送走)。
- **保留 1 个**:`tmp/probe-main-sched-judge.py` —— 技能文档已把它登记为判据 ⑬ 的**变异对照夹具**,
删了以后 ⑬ 的红绿对照就没法复现(技能纪律:夹具"跑完即弃"指不占常驻位置,⛔ 不等于删掉证据)。
---
## 主会话 · 核实「行尾符与同源冲突」接手单(11:09~11:20)
**来件**:`C:\Users\Administrator\Desktop\workbuddy_skills-行尾符与同源冲突-交仓库会话.md`
(另一个环境留下的接手单;用户令「**确认是否有相关问题**」⇒ 只要核实结论,⛔ 不动手改)。
**⚠️ 来件与实际不符的一处(先说清)**:单子写本机仓是 `D:\.workbuddy\skills`,
实际**该路径不存在**;D 盘那个仓在 `D:/github/_alotbuy-gitea/workbuddy_skills`(remote 指向 `work.alotbuy.com`,与本机用的不是同一个 origin)。
更要紧的是:**单子称该仓「本地领先远端 2 个提交」`1774693` / `886d99a` —— 这两笔提交在 D 盘仓里不存在**
(`git cat-file` 报 `Not a valid object name`),且该仓 `origin/master..HEAD` 与 `HEAD..origin/master` **双向为空**(既不领先也不落后)。
⇒ **单子里第三条("有 2 笔提交停在本地待同步")在本机无法证实**,⛔ 不作为待办。
**核实结论:三条里第一、二条属实,且第一条比单子描述更严重。**
**① 行尾符 —— 属实,实测读数**(仓 `E:/ProgramData/.workbuddy/skills`):
- `.gitattributes` **不存在**;`core.autocrlf = true`;`.gitattributes` 属性列 **全仓为空**(零覆盖)。
- 全仓入库形态统计:`i/lf` **4576** / `i/crlf` **244** / `i/-text` 28 / `i/none` 5 / **`i/mixed` 1**。
- CRLF 244 个的分布:**`product-planning/references/` 占 237 个**(冲突重灾区),`session-mechanism/scripts` 5 个、
`session-mechanism/references` 1 个、`product-planning/SKILL.md` 1 个。
- 🔴 **单子没提到的一个更糟样本**:`product-planning/references/stage-requirements/references/create-prd.md`
是 **`i/lf` + `w/crlf`**(入库 LF、工作副本 CRLF)⇒ 这正是"本地一改就整文件 diff / 合并整块冲突"的最典型形态。
- 另两处同批读数:`stage-delivery/SKILL.md` = `i/crlf w/crlf`;`stage-discovery/.../grill-me.md` = `i/lf w/lf`。
- **本机当前无受害症状**:`git status` 干净(0 行)、无冲突标记残留 ⇒ **隐患在位、尚未发作**。
- ⚠️ 我自己上一笔提交 `053a6a6` 的 5 个文件**全部 `i/lf w/lf`**,不受影响。
**② `session-mechanism/SKILL.md` frontmatter —— 属实**:
- 实况:第 6 行 `last_change: 2026-10-05 10:5x · 🔴🔴 …` 是一行超长标量,
其后**12 行以 `·` 开头且无缩进** ⇒ 顶层出现无键的行,**不是合法 YAML**(单子描述准确)。
- 同文件其它字段(`name`/`description`/`version`/`updated_at`)写法正常,确属孤例。
**③ 同源冲突(两份各写一版)—— 本机无异常**:
- 单子列的 4 个冲突文件**路径写少了一层**(实际都在二级 `references/` 下):
`stage-delivery/SKILL.md`、`stage-delivery/references/execution-runbook.md`、
`stage-discovery/references/grill-me.md`、`stage-requirements/references/create-prd.md`。
- 本机仓里这 4 个文件**无未提交改动、无冲突标记**(`grep '^<<<<<<<'` 零命中)⇒ 当前是合好的状态。
- 单子那句"动手前先 `git fetch`、同一件事只在一处定稿"是**作业纪律**,本机已在守(本轮推送前就跑了 `git fetch`)。
**待办(⛔ 本轮未动,属另一个仓/需出方案)**:见本轮回复末尾的待拍板项。
---
## 主会话 · 执行「行尾符 + frontmatter」三项修复并推送(11:22~11:40)
用户令:「**修改完成后 检查无误 推到技能仓库**」⇒ 按上一轮三项的**倾向 A** 全做。
**① `.gitattributes`(新文件,仓根)**
- 内容:默认 `* text=auto eol=lf`,再对文本类逐类显式声明(md/py/sh/json/css/html/js/mjs/yml/yaml 等);
**`.bat`/`.cmd` 保留 CRLF**(Windows 批处理对 LF 敏感);图片/字体/压缩包/音视频/数据库一律 `binary`。
- 🔴 **`.gitignore` 同步放行 `!/.gitattributes`** —— 本仓是「先 `/*` 全挡再逐个放行」的白名单模式,
不放行 ⇒ 这个文件根本进不了库;而它跟 `.gitignore` 同属**仓级规则**,必须入库,
否则别的机器取仓后仍按各自 `core.autocrlf` 各行其是。
- 提交 `cf0ec41`(2 files, +68)。
**② 本机 `core.autocrlf` 设 false(立刻止血)**
- `true` → `false`(仓级配置,只影响本仓)。
- 验:`git ls-files --eol` 的 `attr` 列从**空**变成 **`attr/text eol=lf`** ⇒ 属性真的生效了(⛔ 不是只写了文件)。
**③ `--renormalize` 重新规范化(单独一笔)**
- 实跑改动 **245 个文件**:`product-planning` 239 / `session-mechanism` 6。
- 🔴 **关键验收:纯行尾符,零内容改动** —— `git diff --cached --numstat --ignore-cr-at-eol` **输出为空**;
且 `insertions 44412 / deletions 44412` **完全对称**(行尾符转换的数学特征)。
- 复核**没碰二进制与资源**(`grep -E '\.(png|jpg|ttf|woff2?|zip|gz)$'` 零命中)。
- 提交 `9e779c4`。**单独一笔**的理由:把「改规则」与「按规则刷数据」分开,
将来撞车时可直接回退这一笔而不牵连 `.gitattributes`。
- **行尾符终态**:`i/crlf` **244 → 0**;`i/lf` 4822;剩 `i/-text` 28(二进制)+ `i/none` 5。
**④ `session-mechanism/SKILL.md` frontmatter 合法化(内容一字不动)**
- 原形态:`last_change:` 一行超长标量 + 其后 **12 行**以 `·` 开头无缩进 ⇒ 顶层无键行 12 处。
- 🔴 **顺带发现接手单没提的第二处**:`agent_created:` 前面还夹着一行游离的
`· **本包自身**:install.py …` —— 同样是顶层无键行(改前共 **12** 处,接手单只指出了前 11 行)。
- 改法:块标量 `last_change: |-` + 续行统一两格缩进(含那行游离内容)。
- **验收四条(改前 vs 改后逐字比对)**:
① 顶层无键行 **12 → 0**;② `last_change` 正文 **12 行逐字一致**;
③ 正文(第二个 `---` 之后)**逐字一致**;④ **PyYAML 解析通过**,
字段=`name/description/version/updated_at/last_change/agent_created`,`last_change` 3358 字符 / 36 行。
- 提交 `5d16c6d`(1 file, +14/-13)。
- ⚠️ 验证脚本**第一版有 bug**(`split("|-")[1]` 把首段读成空串)⇒ 一度误报"内容不守恒";
修掉解析后复验才通过。**教训:验证脚本自身也要先自证**,否则会把"改动正确"误判成"改坏了"。
**⑤ 推送与终验**
- `git push origin master` ⇒ `d9445b5..5d16c6d`(成功)。
- 终验:本地 HEAD = `origin/master` = `5d16c6df706f63543073ff63c50889dd99c55c81`;三笔提交在远端齐全;工作区干净。
**沉淀(跨项目可用)**:
- 🔴 **白名单 `.gitignore` 模式下,新加仓级文件(如 `.gitattributes`)必须同步放行** —— 否则"写了但没入库",
而且 `git status` 里它显示为 `??`(未跟踪),**很容易被误读成"已经生效了"**。
- 🔴 **行尾符规范化的验收判据 = `--ignore-cr-at-eol` 后零差异 + 增删行数对称**。
只看文件数会误判(246 个文件听着很吓人,实际一个字都没改)。
- 🔴 **验证脚本要先自证**:解析器有 bug 时会产出"看起来很严重"的假红(本轮就撞了一次)。
---
## 两个看板整包搬进会话技能 `boards/`(用户令「1 boards 2 A」)
**用户原话**:「把这两个看板都纳入会话技能中 ,分两个看板文件夹 放在assert中(判断是否应该放在这里)」
→「1 肯定是整包啊 2 就两个看板 对应两个文件夹」→「但是当前情况 是两个看板最好代码和页面放一起,
要不单独开个 workshop 文件夹 放两个看板」→「这里面有html代码适合放到 scripts中吗」→「1 boards 2 A」。
**拍板结果**:文件夹名 `boards`(⛔ 不是 workshop);A = 本区部署与计划任务**本轮一起改**。
**新增第三层分层**(判据,已写进 SKILL.md):
- `scripts/` = 可执行程序(被计划任务/hook 调)|`assets/` = 静态素材(样式、模板)
- 🔴 `boards/` = **自带页面的看板程序**(一个看板一个文件夹,页面 + 脚本同处一包)
- ⛔ **HTML 不进 `scripts/`**:那层是可执行程序层(现有全是 `.py`),
`session-rules-check.py`/`install.py` 会按脚本扫(语法检查、副本比对)⇒ HTML 进去会被误判。
**搬了什么**:
- `boards/会话看板/`(6 文件):`board.py`(150141B)/ `board_ext.py` / `board-launch.py` /
`board-launch.py.tpl` / `board.html`(151811B)/ `board-render-probe.js`(端口 20099)
- `boards/项目管理界面/`(3 文件):`project-board.py` / `project-board.html`(54102B)/
`project-board-launch.py`(端口 20100)
**🔴 搬迁带出的真缺陷(最重要的一条)**:
`board.py` 里 `importlib` 按 `Path(__file__).parent / "collabd.py"` 取判据 ——
**「与 collabd.py 同目录」这个前提搬走后不成立** ⇒ 静默落到 **except 分支里本地抄的那份判据**。
危害:判据可能漂(同族"共用同一实现"当场 FAIL),**但看板照跑、肉眼完全看不出来**。
修法:候选表(同目录 → 包内 `scripts/` → `DSH_SKILL_ROOT/scripts/`)+ 兜底分支
**写 stderr 留痕**(⛔ 不许静默降级)。`CFG_P` 同病同治(对齐 `collabd.py::load_cfg()` 查找顺序)。
**selftest.py 的三处假设修正**(都源于"一个路径指两处"):
- `t_scope_parity` 的子进程代码用**一个 `H`** 同时指 board.py 与 collabd.py ⇒ 拆成 `H`/`B` 两参数
- `selftest.py` 里 `HERE / "board.py"` 27 处 ⇒ `board_py()`(带旧位置兜底)
- `HERE.parent / "assets" / "board.html"` 15 处 ⇒ `BOARD_DIR / "board.html"`
**顺带修一条既有 FAIL(与搬迁无关)**:判据锚点没跟文档改 ——
`supervise-persistence.md` 的措辞在 `053a6a6` 已改成「排期按点拉起主会话这条路不存在」,
判据仍锚旧句「排期代替不了载体」(那句现在只在 `SKILL.md` 里)⇒ 恒红。
**验收读数**:
- `--filter 看板`:**PASS 110 / FAIL 0**(另 1 条报告型不计入)。迭代轨迹 92/18 → 104/6 → 105/5 → 109/1 → **110/0**。
- 全量:**PASS 91 / FAIL 1**(同一条报告型「产物落点」,实质 FAIL 0)。
⚠️ 「合计」行缺失的真因=`safe-delete` 安全闸在收尾时拦了 64 个清理目标(> 阈值 50)
⇒ 本机工具层策略,⛔ 与技能无关。
- 会话看板 `board.py --out` ⇒ **50515 字节**快照,**无「回落本地判据」告警**(证明真从 collabd.py 取)。
- 项目管理界面 `--once` ⇒ 4 个项目全列出;HTTP `/` 200 · **54102 字节**
(=技能包真身那份 ⇒ 证明工作区侧确实是转发);`/api/projects` 出数据;`/healthz` ok。
**工作区侧改成薄转发**(⛔ 不留第二份实现):
- `交付物/项目管理界面/project-board.py`:改成薄转发(原 38380B 完整实现 → 转发壳)
- `交付物/项目管理界面/project-board.html`:改成**路标页**(54KB 真身 → 说明指向)
- `交付物/项目管理界面/project-board-launch.py`:上一轮已改薄转发
- 原件备份 ⇒ `tmp/pm-board-bak-20261014/`
- 🔴 **计划任务不用改**:动作指向工作区启动器(薄转发会转到技能包真身)⇒ 稳定路径正是留这一层的目的。
重起后 pid **35080 → 23596**,命令行仍是 `pythonw.exe ".../project-board-launch.py"`。
**入库**:提交 `52566c7`(9 新文件 + 3 改名 + 6 修改),推送 `5d16c6d..52566c7` 成功。
- 🔴 `.gitignore` **无需放行** `boards/` —— 白名单放的是整包 `!/session-mechanism/`,包内新增目录自动被包含。
- 凭据扫描:`token` 命中全是文案里的普通词(「0 token」「SyntaxError: Unexpected token」),**无真凭据**。
- 行尾符:`git ls-files --eol` 全部 `i/lf w/lf attr/text eol=lf` ✅。
**沉淀(跨项目可用)**:
- 🔴 **搬家会静默破坏"同目录"隐式前提** —— 这类 import 都有 `except` 兜底,
于是**行为看着正常、判据却已漂**。判据=「搬迁后必须有一条用例专门验『真的从模块取、不是本地抄』」;
正解 = 兜底分支**必须留痕**(⛔ 宁可见地坏,不可静默降级)。
- 🔴 **一个路径常量指两处 = 下一次搬家的地雷**。判据:凡 `HERE / "x.py"` 里 `HERE` 是"包根/脚本目录"
这类**位置代称**,就该问一句"另一个文件搬家时它还成立吗"。
- 🔴 **判据锚点必须逐字抄原文**(本轮第三次栽):文档改措辞后判据恒红,
⛔ 别把"判据红"直接读成"实现坏了"——先问"是不是锚点过期了"。
@@ -1,63 +1,86 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""项目管理界面的**启动器**(计划任务动作指向它,⛔ 不许直起 `project-board.py`)。 """项目管理界面 —— **工作区侧的入口(薄转发)**。
## 为什么必须有这一层(同族 P0-73/74) 🔴🔴 2026-10-14:看板**整包搬进会话技能**(用户令「把这两个看板都纳入会话技能中,
分两个看板文件夹,开个 boards 文件夹放两个看板」)⇒ 真身在
`<技能包>/boards/项目管理界面/`。本文件**只是工作区侧的落点**:
计划任务的动作指向它(动作里没有 env 字段 ⇒ 变量只能在进程内设),它把活转给真身。
· 计划任务的**动作没有 env 字段** ⇒ 变量只能在**进程内**设; ## 为什么留这一层(⛔ 不是复制)
· 直接用 `python.exe` 起 ⇒ 控制台程序 ⇒ 计划任务**每次触发闪一个黑窗口**;
· 直起业务脚本 ⇒ 将来加变量/判活时**没有落点**,只能再开第二条旁路(闪黑窗的真因)。
## 本启动器做什么 · 计划任务的动作**要有一个稳定路径** —— 指技能包也行,但技能包路径会随**技能升级/换机**变;
留一个工作区侧入口,换路径时只改这一个文件。
· ⛔ **不留第二份实现** —— 本文件**不复制**任何业务代码
(同族 P0:一份规则两处实现 ⇒ 必然漂)。
① 设好编码与上游地址(⛔ 不依赖宿主 env); ## 它做什么
② 调 `project-board.py --serve 20100`;
③ **判活**:端口已在听 ⇒ 直接退出 0(⛔ 不重复起第二实例)。 ① 解析真身位置(宿主 env `DSH_SKILL_ROOT` → 本区配置 → 默认技能根);
② 把变量设好(编码 / 上游 / **本工作区**=本区根);
③ 调真身 `project-board-launch.py`。
⚠️ 必须用 `pythonw.exe` 跑本文件(无控制台 ⇒ 不闪窗)。 ⚠️ 必须用 `pythonw.exe` 跑本文件(无控制台 ⇒ 不闪窗)。
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import socket
import sys import sys
import runpy import runpy
from pathlib import Path
PORT = int(os.environ.get("DSH_PROJECT_BOARD_PORT") or "20100") HERE = Path(__file__).resolve().parent
HERE = os.path.dirname(os.path.abspath(__file__)) WS = HERE.parents[1] # <ws>/交付物/项目管理界面 → <ws>
TARGET = os.path.join(HERE, "project-board.py") DEFAULT_SKILL = Path(r"E:/ProgramData/.workbuddy/skills/session-mechanism")
def _skill_root() -> Path:
"""技能包根 —— 宿主 env 优先,再看本区配置,最后用默认值。"""
v = (os.environ.get("DSH_SKILL_ROOT") or "").strip()
if v:
return Path(v)
for rel in (".workbuddy/collab/collabd.config.json",):
p = WS / rel
if p.is_file():
try:
import json
d = json.loads(p.read_text(encoding="utf-8"))
s = (d.get("skill_root") or "").strip()
if s:
return Path(s)
except Exception:
pass
return DEFAULT_SKILL
SKILL = _skill_root()
TARGET = SKILL / "boards" / "项目管理界面" / "project-board-launch.py"
# ── 变量只在这里设(计划任务动作没有 env 字段)── # ── 变量只在这里设(计划任务动作没有 env 字段)──
os.environ.setdefault("PYTHONIOENCODING", "utf-8") os.environ.setdefault("PYTHONIOENCODING", "utf-8")
os.environ.setdefault("DSH_SKILL_ROOT", str(SKILL))
os.environ.setdefault("DSH_SKILL_BOARD_DIR", str(SKILL / "boards" / "会话看板"))
os.environ.setdefault("DSH_SKILL_SCRIPTS", str(SKILL / "scripts"))
os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json") os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
# 🔴 「本工作区」=**本区根**(卡片换色用)。本文件就在本区里 ⇒ 上跳两级即得,⛔ 不猜。
# 🔴 「本工作区」=**拥有一这层的那个工作区**(用户 2026-10-06 要:本工作区的卡片换色突出)。 os.environ.setdefault("DSH_PM_BOARD_SELF_WS", str(WS).replace("\\", "/"))
# ⛔ 不许靠 `Path.cwd()`:计划任务的 **工作目录是 `交付物/项目管理界面`**(实测),
# 不是工作区根 ⇒ 那样算出来的"本工作区"是错的。
# 正解=从本文件位置**往上两级**:`<ws>/交付物/项目管理界面/project-board-launch.py`。
os.environ.setdefault("DSH_PM_BOARD_SELF_WS",
os.path.dirname(os.path.dirname(HERE)))
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: def main() -> int:
if port_listening(PORT): if not TARGET.is_file():
return 0 # 已在跑 ⇒ 静默退出(⛔ 不起第二实例) # ⛔ 不静默退出:写本区日志留痕(`pythonw` 下 stderr 可能是 None)
sys.argv = [TARGET, "--serve", str(PORT)] try:
try: d = WS / "tmp"
runpy.run_path(TARGET, run_name="__main__") d.mkdir(parents=True, exist_ok=True)
except SystemExit: with open(d / "project-board-launch.err.log", "a",
raise encoding="utf-8", errors="replace") as f:
except Exception: f.write("[薄转发] 找不到真身:%s\n" % TARGET)
return 1 except Exception:
pass
return 2
sys.argv = [str(TARGET), "--serve",
str(os.environ.get("DSH_PROJECT_BOARD_PORT") or "20100")]
runpy.run_path(str(TARGET), run_name="__main__")
return 0 return 0
+46 -771
View File
@@ -3,787 +3,62 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>项目管理 · 执行任务</title> <title>项目管理 · 执行任务(页面已迁入会话技能)</title>
<style> <style>
:root{ :root{ color-scheme: light dark; }
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --line:#2a2f3a; body{
--fg:#e6e9ef; --dim:#98a2b3; --dim2:#6b7686; font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
--accent:#4c6ef5; --warn:#f0a020; --bad:#e5534b; --ok:#3fb950; max-width:720px; margin:8vh auto; padding:0 24px;
--r:10px;
} }
*{box-sizing:border-box} h1{ font-size:18px; margin:0 0 4px; }
body{margin:0;background:var(--bg);color:var(--fg); .sub{ opacity:.65; margin:0 0 24px; font-size:13px; }
font:14px/1.55 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif} code{
a{color:inherit;text-decoration:none} font-family:ui-monospace,Consolas,monospace; font-size:12.5px;
background:color-mix(in srgb,currentColor 8%,transparent);
/* ── 顶栏:项目选择 ── */ padding:1px 5px; border-radius:4px;
header{position:sticky;top:0;z-index:20;background:rgba(15,17,21,.92); }
backdrop-filter:blur(8px);border-bottom:1px solid var(--line)} .box{
.hrow{display:flex;align-items:center;gap:14px;padding:12px 18px;flex-wrap:wrap} border:1px solid color-mix(in srgb,currentColor 18%,transparent);
.brand{font-weight:600;letter-spacing:.2px} border-radius:10px; padding:16px 18px; margin:0 0 16px;
.brand small{color:var(--dim2);font-weight:400;margin-left:8px} }
select,button{background:var(--panel2);color:var(--fg);border:1px solid var(--line); ol{ margin:6px 0 0; padding-left:22px; }
border-radius:8px;padding:7px 11px;font:inherit;cursor:pointer} li{ margin:3px 0; }
select:focus,button:focus{outline:2px solid var(--accent);outline-offset:1px} .warn{ border-left:3px solid #d97706; }
.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.danger{background:transparent;border-color:rgba(229,83,75,.5);color:#f2a09a}
button.danger:hover{background:rgba(229,83,75,.12)}
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)}
</style> </style>
</head> </head>
<body> <body>
<header>
<div class="hrow">
<div class="brand">项目管理<small id="ws">—</small></div>
<label for="psel" class="stamp">工作区</label>
<select id="psel" aria-label="选择工作区"></select>
<button id="btndel" type="button" class="danger" title="删除当前工作区(可选粒度)">删除</button>
<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> <h1>📍 这个页面已迁入会话技能</h1>
<div class="cols" id="cols"></div> <p class="sub">2026-10-14 · 项目管理界面(本机所有工作区 · 项目 × 卡片)</p>
</main>
<div class="toast" id="toast"></div> <div class="box">
<strong>真身位置</strong>
<div class="mask" id="mask"></div> <ol>
<aside id="drawer" aria-hidden="true"> <li>页面:<code>&lt;技能包&gt;/boards/项目管理界面/project-board.html</code></li>
<div class="ahead"> <li>后端:<code>&lt;技能包&gt;/boards/项目管理界面/project-board.py</code></li>
<div class="ttl" id="dtitle">—</div> <li>启动器:<code>&lt;技能包&gt;/boards/项目管理界面/project-board-launch.py</code></li>
<button id="dclose" type="button">关闭</button> </ol>
</div> <p style="margin:10px 0 0">
<div class="abody" id="dbody"></div> 技能包默认根:<code>E:/ProgramData/.workbuddy/skills/session-mechanism</code>
</aside> </p>
<!-- 新建目标(新工作区)对话框 -->
<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> </div>
<script> <div class="box warn">
"use strict"; <strong>本文件为什么留着</strong>
var STATE = { data:null, cur:-1, epoch:0, sameHits:0, sig:"" }; // cur=-1 ⇒ 「全部」 <ol>
<li>历史路径不要断(旧笔记 / 脚本 / 书签按这个路径找得到东西)。</li>
<li>它<strong>不是</strong>第二份实现 —— 页面与后端都只在技能包里一份,
这里只作<strong>路标</strong>,⛔ 别把它当源文件改。</li>
</ol>
</div>
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, function(c){ <div class="box">
return ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]; }); } <strong>怎么访问</strong>
<p style="margin:6px 0 0">
服务起来后访问 <code>http://127.0.0.1:20100/</code> 即可(端口 20100,与 20099 的会话看板各起各的)。
计划任务的动作指向本目录下的 <code>project-board-launch.py</code>(薄转发,会转到技能包真身)。
</p>
</div>
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);
document.getElementById("ws").textContent = (d.roots||[]).join(" · ") || "—";
/* 🔴 时效**只用服务给的 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>';
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>';
}
function _colHtml(key, label, list){
var inner = list.length ? list.map(_cardHtml).join("")
: '<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"), function(el){
el.addEventListener("click", function(){
openDetail(el.getAttribute("data-tid"), el.getAttribute("data-pw"));
});
});
}
/* ── 统一视图(⛔ 不按工作区分块):一张四列看板,卡片自带「目标/工作区」标签 ──
用户 2026-10-06:「这里分类不要按照工作区分了,记住 项目管理是统一管理」 */
function renderAll(){
var d = STATE.data, ps = d.projects;
var sums = document.getElementById("summary");
var cols = document.getElementById("cols");
document.getElementById("btndel").disabled = true; // ⛔ 「全部」不是单个工作区,删不得
// 🔴🔴 **两个维度分开报**(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;
}
// 🔴 所有目标的所有卡片汇到一张板;每张卡片标出「目标 · 工作区」
var byState = {};
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 || "";
(byState[c.state] = byState[c.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; }
document.getElementById("btndel").disabled = false;
// 概要(🔴 顺序=**目标在前、任务细账在后**;用户口径:四列装目标)
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 = {};
(p.cards || []).forEach(function(c){
var x = {}; for(var k in c){ x[k] = c[k]; }
x._ws = p.dir; x._goal = p.name; x._self = !!p.self; x._life = p.life || "";
(byState[c.state] = byState[c.state] || []).push(x);
});
cols.innerHTML = d.cols.map(function(c){
return _colHtml(c.key, c.label, byState[c.key] || []);
}).join("");
// 卡片点开 → 详情(含跳会话)
_bindCards();
}
/* ── 视图分发 ── */
function render(){
if(!STATE.data) return;
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;
});
}
/* ── 删除当前项目 ── */
function doDelete(){
var p = (STATE.data||{}).projects && STATE.data.projects[STATE.cur];
if(!p) return;
var m = prompt("删除「"+p.name+"」——选择粒度:\n\n"+
"输入 1 = 只删【目标登记】(工作区与台账原样保留,可逆;项目从列表消失)\n"+
"输入 2 = 把【整个工作区】送回收站(不永久删除,可从回收站还原)\n\n"+
"(不输入或取消 = 什么都不做)", "1");
if(m !== "1" && m !== "2") return;
var mode = (m === "2") ? "ws" : "goal";
if(mode === "ws" &&
!confirm("确认把整个工作区送回收站?\n\n"+p.ws+"\n\n会连同台账、目标、脚本一起移走。")) return;
fetch("/api/delete", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ws:p.ws, mode:mode})})
.then(function(r){ return r.json(); })
.then(function(d){
if(d.ok){
toast("ok", '已删除('+(mode==="ws"?"整个工作区送回收站":"目标登记归档")+
'):'+esc(d.moved_to||d.trashed||""));
load();
} else {
toast("err", '删除失败:'+esc(d.err||"未知错误"));
}
}).catch(function(e){
toast("err", '请求失败:'+esc(e));
});
}
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);
document.getElementById("btndel").addEventListener("click", doDelete);
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); } });
loop(); // 轮询节奏会随「数据有没有变」自适应(见 currentPoll)
setInterval(paintAge, 1000); // 时效读数每秒重画(⛔ 不重新取数)
</script>
</body> </body>
</html> </html>
+56 -709
View File
@@ -1,741 +1,88 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""项目管理界面 —— 管**本机所有工作区**的项目(照 dsh-task-board 的「项目 × 卡片」模式)。 """项目管理界面 —— **工作区侧的落点(薄转发)**。
## 这是什么(用户原话) 🔴🔴 2026-10-14:看板**整包搬进会话技能**(用户令「把这两个看板都纳入会话技能中,
分两个看板文件夹,开个 boards 文件夹放两个看板」)⇒ **真身**在
`<技能包>/boards/项目管理界面/project-board.py`(页面 `project-board.html` 与它同目录)。
> 「不动现有任务会话看板,不是让你整体搬,是照的他的模式做一个 项目管理的界面 ## 为什么留这一层(⛔ 不是复制)
> 来管理 执行任务,**目标等于项目**」
> 「项目管理管的是**本机所有工作区的项目** 不是单个工作区的, · 历史路径不要断 —— 别人(含我自己的旧笔记、脚本、书签)按本路径找得到东西。
> 可以通过项目管理工具 **创建新目标(新工作区)**,管理目标」 · ⛔ **不留第二份实现** —— 本文件**不含**任何业务代码
(同族 P0:一份规则两处实现 ⇒ 必然漂,本轮搬迁就是在收这个尾)。
⇒ 硬约束: ## 它做什么
① ⛔ **不动**现有看板(`board.py` / `board.html` / 20099 一律零改动);
② ⛔ **不整体搬** dsh-task-board(DSH 插件、20k 行 TS,宿主不同);
③ ✅ 照它的模式:项目列表 + 卡片列 + 点卡片看详情;
④ ✅ 范围 = **本机所有装了机制的工作区**(⛔ 不是单区、⛔ 不是靠同伴清单);
⑤ ✅ 能**建新目标(新工作区)**、能**删**、能**查**;⛔ **不改状态**(状态交给会话机制自己处理)。
## 数据从哪来(🔴 只读 + 一处写) ① 解析真身位置(宿主 env `DSH_SKILL_ROOT` → 本区配置 `skill_root` → 默认技能根);
② 把变量设好(编码 / 看板目录 / 机制脚本目录 / **本工作区**);
③ 调真身,**原样转发 argv**。
**读**:直接扫本机工作区根目录下每个区自己的文件 —— ⚠️ 真身所在目录会进 `sys.path`:`project-board.py` 与 `board.py` 同族,两者都要
· `<区>/tmp/supervise-inbox/goal.json` → **项目**(title/short/lifecycle/acceptance) `import collabd`(同包 `scripts/`)⇒ 少这条会落到本地兜底判据(静默漂移)。
· `<区>/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 from __future__ import annotations
import argparse
import json
import os import os
import re
import shutil
import subprocess
import sys import sys
import time import runpy
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
PAGE = HERE / "project-board.html" WS = HERE.parents[1] # <ws>/交付物/项目管理界面 → <ws>
DEFAULT_SKILL = Path(r"E:/ProgramData/.workbuddy/skills/session-mechanism")
# 🔴 扫哪些根目录找工作区(`;` 分隔;可用环境变量覆盖)
WS_ROOTS = [Path(p) for p in (
os.environ.get("DSH_PM_BOARD_ROOTS") or "E:/ProgramData/AIProject"
).split(";") if p.strip()]
# 技能自带的新建入口(⛔ 不自己另写一套建区逻辑 —— 同族"一条规则两份实现"必漂)
SKILL_SCRIPTS = Path(os.environ.get("DSH_SKILL_SCRIPTS")
or r"E:/ProgramData/.workbuddy/skills/session-mechanism/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: def _skill_root() -> Path:
"""🔴🔴 **稳健的输出**(2026-10-06 实测栽过)。 """技能包根 —— 宿主 env 优先,再看本区配置,最后用默认值。"""
v = (os.environ.get("DSH_SKILL_ROOT") or "").strip()
病根:计划任务用 **`pythonw.exe`** 跑 ⇒ **没有控制台** ⇒ `sys.stdout is None` if v:
⇒ `print()` / `sys.stdout.flush()` 直接 `AttributeError: 'NoneType' object has no return Path(v)
attribute 'flush'` ⇒ **进程当场崩**(而 `Result=1` 只说"失败",看不出为什么)。 p = WS / ".workbuddy" / "collab" / "collabd.config.json"
✅ 有 stdout 才写;没有就**静默丢掉**(这是常驻进程,⛔ 不该因为没地方打印就崩)。 if p.is_file():
"""
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;
# `已暂停` **没有对应列**(⛔ 不许硬塞进任一列,会误导)⇒ 归入 `_LIFE_NOCOL`,另行提示。
LIFE_TO_COL = {
"等待": "waiting",
"进行中": "active",
"阻碍": "blocked",
"已完成": "done",
}
_LIFE_NOCOL = ("已暂停",) # 有此项无列,⛔ 不硬塞
# 🔴 别名表**必须与 `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_SCRIPTS) not in sys.path:
sys.path.insert(0, str(SKILL_SCRIPTS))
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:
# `已暂停` 等"有此项无列"的、以及状态读不出的 ⇒ 记下来另行提示,⛔ 不硬塞进任一列。
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: try:
import ctypes import json
from ctypes import wintypes d = json.loads(p.read_text(encoding="utf-8"))
s = (d.get("skill_root") or "").strip()
class SHFILEOPSTRUCTW(ctypes.Structure): if s:
_fields_ = [ return Path(s)
("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: except Exception:
pass pass
return DEFAULT_SKILL
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 SKILL = _skill_root()
path = self.path.split("?")[0] BOARD_DIR = SKILL / "boards" / "项目管理界面"
if path in ("/", "/index.html", "/project-board.html"): TARGET = BOARD_DIR / "project-board.py"
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 # ── 变量(⛔ 不覆盖宿主已设的;计划任务动作没有 env 字段 ⇒ 只能在本进程里设)──
path = self.path.split("?")[0] os.environ.setdefault("PYTHONIOENCODING", "utf-8")
try: os.environ.setdefault("DSH_SKILL_ROOT", str(SKILL))
ln = int(self.headers.get("Content-Length") or 0) os.environ.setdefault("DSH_SKILL_BOARD_DIR", str(SKILL / "boards" / "会话看板"))
raw = self.rfile.read(ln) if ln else b"{}" os.environ.setdefault("DSH_SKILL_SCRIPTS", str(SKILL / "scripts"))
body = json.loads(raw.decode("utf-8") or "{}") os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
except Exception as e: # 🔴 「本工作区」=**本区根**(卡片换色用)。本文件就在本区里 ⇒ 上跳两级即得,⛔ 不猜。
self._json(400, {"ok": False, "err": "body 解析失败:%r" % (e,)}) os.environ.setdefault("DSH_PM_BOARD_SELF_WS", str(WS).replace("\\", "/"))
return # 🔴🔴 真身要 `import collabd`(在技能包 `scripts/`)—— 把两个目录入 path。
if path == "/api/create": for _p in (str(BOARD_DIR), str(SKILL / "scripts")):
self._json(200, act_create(str(body.get("root") or ""), if _p not in sys.path:
str(body.get("name") or ""), sys.path.insert(0, _p)
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: def main() -> int:
ap = argparse.ArgumentParser(description="项目管理界面(本机所有工作区 · 增删查)") if not TARGET.is_file():
ap.add_argument("--serve", type=int, default=0, metavar="PORT", # ⛔ 不静默退出:写本区日志留痕(`pythonw` 下 stderr 可能是 None)
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: try:
srv.serve_forever() d = WS / "tmp"
except KeyboardInterrupt: d.mkdir(parents=True, exist_ok=True)
with open(d / "project-board-launch.err.log", "a",
encoding="utf-8", errors="replace") as f:
f.write("[薄转发] 找不到真身:%s\n" % TARGET)
except Exception:
pass pass
return 0 return 2
sys.argv = [str(TARGET)] + sys.argv[1:]
if a.once: runpy.run_path(str(TARGET), run_name="__main__")
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 return 0