项目管理界面:工作区侧改成薄转发(真身已入会话技能 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:
1 parent
d933181a55
commit
6b783aaf82
4 files changed
+593
-1516
No files matched your search
@@ -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:
|
||||||
runpy.run_path(TARGET, run_name="__main__")
|
d = WS / "tmp"
|
||||||
except SystemExit:
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
raise
|
with open(d / "project-board-launch.err.log", "a",
|
||||||
|
encoding="utf-8", errors="replace") as f:
|
||||||
|
f.write("[薄转发] 找不到真身:%s\n" % TARGET)
|
||||||
except Exception:
|
except Exception:
|
||||||
return 1
|
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
@@ -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><技能包>/boards/项目管理界面/project-board.html</code></li>
|
||||||
<div class="ahead">
|
<li>后端:<code><技能包>/boards/项目管理界面/project-board.py</code></li>
|
||||||
<div class="ttl" id="dtitle">—</div>
|
<li>启动器:<code><技能包>/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 ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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
@@ -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:
|
try:
|
||||||
f.write(msg + "\n")
|
import json
|
||||||
f.flush()
|
d = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
s = (d.get("skill_root") or "").strip()
|
||||||
|
if s:
|
||||||
|
return Path(s)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
return DEFAULT_SKILL
|
||||||
|
|
||||||
|
|
||||||
# 台账四态 → 列(🔴 与 board.py::_queue 的 `by` 键**逐字一致**)
|
SKILL = _skill_root()
|
||||||
COLS = [
|
BOARD_DIR = SKILL / "boards" / "项目管理界面"
|
||||||
("waiting", "待执行"),
|
TARGET = BOARD_DIR / "project-board.py"
|
||||||
("active", "执行中"),
|
|
||||||
("blocked", "有阻碍"),
|
|
||||||
("done", "已完成"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# 🔴🔴🔴 **四列装的是「目标」不是「任务」**(2026-10-06 用户定案:
|
# ── 变量(⛔ 不覆盖宿主已设的;计划任务动作没有 env 字段 ⇒ 只能在本进程里设)──
|
||||||
# 「下面的 待执行 执行中 有阻碍 已完成 里面显示的都是目标 目标就是项目能明白吗」)。
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||||
# ⇒ 列的内容=**目标本身**(`goal.json` 一条=一个目标=一个项目),按 `lifecycle` 分组。
|
os.environ.setdefault("DSH_SKILL_ROOT", str(SKILL))
|
||||||
# ⛔ 改前我用 `tasks.json` 的**台账条目**填这四列 —— 填错了:
|
os.environ.setdefault("DSH_SKILL_BOARD_DIR", str(SKILL / "boards" / "会话看板"))
|
||||||
# 台账条目是"任务"(`3b-v1` / `dark-tokens` 这种执行记录),不是目标。
|
os.environ.setdefault("DSH_SKILL_SCRIPTS", str(SKILL / "scripts"))
|
||||||
# ⚠️ 这两者在本机制里**是不同层级**:目标(goal)= 一件事;任务(task)= 那件事下的一步。
|
os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json")
|
||||||
#
|
# 🔴 「本工作区」=**本区根**(卡片换色用)。本文件就在本区里 ⇒ 上跳两级即得,⛔ 不猜。
|
||||||
# 🔴 **lifecycle 真身取值**(⛔ 别自己另定一套,抄 `collabd.py` 的 `GOAL_LIFE_*`):
|
os.environ.setdefault("DSH_PM_BOARD_SELF_WS", str(WS).replace("\\", "/"))
|
||||||
# `等待` / `进行中` / `阻碍` / `已完成` / `已暂停`
|
# 🔴🔴 真身要 `import collabd`(在技能包 `scripts/`)—— 把两个目录入 path。
|
||||||
# ⇒ 映射到本界面四列:等待→waiting|进行中→active|阻碍→blocked|已完成→done;
|
for _p in (str(BOARD_DIR), str(SKILL / "scripts")):
|
||||||
# `已暂停` **没有对应列**(⛔ 不许硬塞进任一列,会误导)⇒ 归入 `_LIFE_NOCOL`,另行提示。
|
if _p not in sys.path:
|
||||||
LIFE_TO_COL = {
|
sys.path.insert(0, _p)
|
||||||
"等待": "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:
|
|
||||||
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:
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user