From efbe1286706ad09cebc895a53091ca7a007afb1b Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 19 Oct 2026 22:35:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(board/graph):=20=E5=9B=BE=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E6=8E=A5=E9=A1=B9=E7=9B=AE=E7=9C=8B=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=C2=B7=E7=B1=BB=E5=9E=8B=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bfix(hook):=20=E8=A3=B8=E3=80=8C=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E7=9B=AE=E6=A0=87=E3=80=8D=E4=B9=9F=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=8A=80=E8=83=BD=E9=97=B8=EF=BC=9Bfix(launcher):=20pythonw=20?= =?UTF-8?q?=E5=85=9C=20stdout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- session-mechanism/SKILL.md | 6 +- .../boards/项目看板/project-board-launch.py | 25 +++++++ .../boards/项目看板/project-board.html | 62 ++++++++++++++++ .../boards/项目看板/project-board.py | 73 +++++++++++++++++++ .../scripts/hooks/skill-load-guard.py | 6 +- 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/session-mechanism/SKILL.md b/session-mechanism/SKILL.md index a21af4b..d87c1f0 100644 --- a/session-mechanism/SKILL.md +++ b/session-mechanism/SKILL.md @@ -73,6 +73,8 @@ agent_created: true > **判据(一句话)**:**用户说了触发句 ⇒ 第 0 步是先加载本技能,然后才动手。⛔ 没有"我大概知道"这条捷径。** **触发句(命中任一条即须加载)**:🔴 `使用项目机制完成目标:XXXX`(**2026-10-08 用户令的现行写法**)/ +🔴 **裸「完成目标」**(2026-10-19 用户令,逐字:「把skill 改成 **完成目标** 也能触发」—— 用户口语里常把机制名字省掉, +所以 `项目机制完成目标` 与 `完成目标` 都算派活授权)/ `使用任务会话完成 X 目标` / `使用执行会话完成 X 目标` / `继续 X 目标` / `用执行会话完成 X` / `派任务会话` / `建任务会话` / 任何"让我去把某个目标做完/接着做"的表述。 @@ -1046,7 +1048,9 @@ assets/ board.html design-tokens.css 命令面:`init`/`check`/`ready`/`start`/`report`/`expand`/`show`/`list`/`approve`/`cancel`/`tick`/`plan`/`progress`。 用法与语义见 `graph/README.md`,自测 `python graph/test_graphctl.py`(应 `Ran 122 tests … OK`,约 240 秒)。 起一棒的最短路径:`init --ws --title … --nodes-json -` ⇒ `check` ⇒ `tick`(算完就开)⇒ 执行方 `report` ⇒ 反复 `tick` 直到 `progress` 到 100%。 -⚠️ 它现在**没有接进看板、也没有排期**(`plan` 只出规范行)⇒ 图模式由会话手工调用,日常流程里看不到它。要不要接入口见工作区待拍板项。 +✅ **已接项目看板(2026-10-19)**:`boards/项目看板/project-board.py::graph_view()` 只读 `/.workbuddy/graph/*/graph.json`, +页面「图模式」块按**节点类型标签**(普通/子图/展开/子节点/人审/条件)+ 状态色显示,**只画不派活**。 +⚠️ 仍**没有排期**(`plan` 只出规范行)⇒ 往前推进靠会话敲 `tick`。 **⛔ 已无必需外部依赖(2026-10-04)**:原先声明「依赖 `agent-operating-rules`」——现已把**排版核心块收进本包并定为权威** ⇒ `references/03-回复排版-核心块.md` (2026-10-06 校正:原措辞是「内联副本」,与 03 号自称「权威源」打架 ⇒ 权威声明分裂)、把 `_env.py` 的技能库识别特征改为**候选数组(首个=本包自己)** ⇒ **本包自包含**。📌 **2026-10-07 订正**:上面末句原写「**那个技能**若同时装着,属可选增强(多了跨项目排版/去 AI 味的完整版),⛔ 不装也能跑」—— diff --git a/session-mechanism/boards/项目看板/project-board-launch.py b/session-mechanism/boards/项目看板/project-board-launch.py index cba0362..b17a930 100644 --- a/session-mechanism/boards/项目看板/project-board-launch.py +++ b/session-mechanism/boards/项目看板/project-board-launch.py @@ -54,6 +54,28 @@ _sm_load_roots() os.environ.setdefault("PYTHONIOENCODING", "utf-8") os.environ.setdefault("DSH_PROJECT_BOARD_UPSTREAM", "http://127.0.0.1:20099/board.json") +# 🔴🔴 2026-10-19 补:「兜 stdout/stderr」(同族 P0-73 的第六类坑) +# `pythonw.exe` 是 GUI 子系统 ⇒ 进程里 **`sys.stdout` 是 None**,看板一 `print()` 就 +# `AttributeError` ⇒ 进程当场退出、任务显示 `Running` 但端口从没绑上(实测: +# 计划任务起的实例静默死掉,而同一份代码用 `python.exe` 跑得起来 ⇒ 极难查)。 +# ⇒ 起来第一件事把两根流兜到日志文件;日志落本目录 `logs/`(不存在就建)。 +def _wrap_streams() -> None: + if sys.stdout is not None and sys.stderr is not None: + return + base = os.path.join(HERE, "logs") + try: + os.makedirs(base, exist_ok=True) + f = open(os.path.join(base, "project-board.log"), "a", encoding="utf-8", errors="replace") + except Exception: + f = open(os.devnull, "w", encoding="utf-8") + if sys.stdout is None: + sys.stdout = f + if sys.stderr is None: + sys.stderr = f + + +_wrap_streams() + # 🔴 「本工作区」=**拥有一这层的那个工作区**(用户 2026-10-06 要:本工作区的卡片换色突出)。 # ⛔ 不许靠 `Path.cwd()`(计划任务的工作目录不是工作区根)。 # 2026-10-14 起:本文件在技能包内 ⇒ 用 `DSH_WS_ROOT`(来自宿主 env 或包内 `env/roots.env`)。 @@ -80,6 +102,9 @@ def main() -> int: except SystemExit: raise except Exception: + # ⛔ 不许静默吞:pythonw 下没有 stderr 就没人知道它为什么死(`_wrap_streams` 已兜到日志) + import traceback + traceback.print_exc() return 1 return 0 diff --git a/session-mechanism/boards/项目看板/project-board.html b/session-mechanism/boards/项目看板/project-board.html index 314a213..745e396 100644 --- a/session-mechanism/boards/项目看板/project-board.html +++ b/session-mechanism/boards/项目看板/project-board.html @@ -261,8 +261,32 @@
+ +
+
@@ -798,8 +822,46 @@ function paintGoals(){ } /* ── 视图分发 ── */ +/* ── 图模式块(只读)───────────────────────────────────────────────────── + 数据来自 `/api/projects` 每个工作区的 `p.graph`(后端 `graph_view()`)。 + 🔴 用户 2026-10-19:「图可以接看板,显示类型标签区分下」⇒ 每个节点一枚 + 「类型标签 + 标题」的胶囊,颜色表示状态,标签文字表示节点类型 + (普通/子图/展开/子节点/人审/条件)。⛔ 这里只画,不派活、不改图。 */ +function paintGraphs(){ + var el = document.getElementById("graphs"); + if(!el) return; + var ps = (STATE.data && STATE.data.projects) || []; + if(STATE.cur >= 0) ps = ps[STATE.cur] ? [ps[STATE.cur]] : []; + var rows = []; + ps.forEach(function(p){ + var gv = p.graph || {}; + (gv.graphs || []).forEach(function(g){ rows.push({p:p, g:g}); }); + }); + if(!rows.length){ + el.innerHTML = '
图模式:本机暂无图(会话里用 graphctl.py init 建一张,这里就会显示)
'; + return; + } + var h = ['
图模式' + + rows.length + ' 张图 · 胶囊上的粗体是节点类型,颜色是状态
']; + rows.forEach(function(r){ + var g = r.g, p = r.p; + h.push('
' + esc(g.title) + + '' + esc(p.dir) + ' · ' + esc(g.gid) + + ' · ' + esc(String(g.percent)) + '%
'); + (g.nodes || []).forEach(function(n){ + h.push('' + esc(n.kind) + '' + + esc(n.title) + ''); + }); + if(g.truncated) h.push('…还有 ' + g.truncated + ' 个'); + h.push('
'); + }); + el.innerHTML = h.join(""); +} + function render(){ if(!STATE.data) return; + paintGraphs(); var colsEl = document.getElementById("cols"); if(STATE.view === "goals"){ colsEl.classList.add("aslist"); // 🔴 4 列 grid ⇒ block(否则整页挤进第 1 列) diff --git a/session-mechanism/boards/项目看板/project-board.py b/session-mechanism/boards/项目看板/project-board.py index 9a542a4..c8d0ca9 100644 --- a/session-mechanism/boards/项目看板/project-board.py +++ b/session-mechanism/boards/项目看板/project-board.py @@ -361,6 +361,77 @@ def _region_dir(ws: Path, goal: dict) -> Path: return ws / "执行会话" +# ── 图模式(只读快照 · 2026-10-19 接入看板)───────────────────────────────── +# 落点=`/.workbuddy/graph//graph.json`(图模式自己的目录)。 +# ⛔ 只读:看板不写任何图文件、不派活、不改状态 —— 图模式仍然零侵入。 +_GRAPH_KINDS = ( + ("sub", "子图"), # 节点的 subgraph 展开出来的父容器 + ("map", "展开"), # map/foreach 展开出来的父容器 + ("child", "子节点"), # 展开出来的子节点 + ("approve", "人审"), # approval: true + ("cond", "条件"), # 带 when 子句 +) + + +def _graph_node_kind(n: dict) -> str: + """给前端一个中文类型标签(用户 2026-10-19:「显示类型标签区分下」)。""" + if n.get("parent"): + return "子节点" + if n.get("subgraph"): + return "子图" + if n.get("map"): + return "展开" + if n.get("approval"): + return "人审" + if n.get("when"): + return "条件" + return "普通" + + +def graph_view(ws: Path) -> dict: + base = ws / ".workbuddy" / "graph" + graphs = [] + if base.is_dir(): + for d in sorted(base.iterdir()): + gp = d / "graph.json" + if not gp.is_file(): + continue + try: + g = _load_json(gp, {}) or {} + except Exception: # noqa: BLE001 —— 单个坏图不许弄崩整块看板 + continue + nodes = g.get("nodes") or {} + if not isinstance(nodes, dict) or not nodes: + continue + cnt = {} + chips = [] + for nid, n in nodes.items(): + if not isinstance(n, dict): + continue + st = str(n.get("status") or "") + cnt[st] = cnt.get(st, 0) + 1 + chips.append({ + "id": str(nid), + "title": str(n.get("title") or nid), + "status": st, + "kind": _graph_node_kind(n), + }) + active = len(nodes) - cnt.get("skipped", 0) - cnt.get("cancelled", 0) + done = cnt.get("done", 0) + graphs.append({ + "gid": str(g.get("gid") or d.name), + "title": str(g.get("title") or d.name), + "percent": round(100.0 * done / active, 1) if active else 100.0, + "counts": cnt, + "total": len(nodes), + "nodes": chips[:40], # 只发前 40 个,够看进度又不撑爆页面 + "truncated": max(0, len(chips) - 40), + "updated_at": str(g.get("updated_at") or g.get("created_at") or ""), + }) + graphs.sort(key=lambda x: x["updated_at"], reverse=True) + return {"graphs": graphs, "count": len(graphs), "dir": str(base).replace("\\", "/")} + + def build_project(ws: Path) -> dict: """把**一个工作区**读成「项目」(纯读,⛔ 不写任何东西)。""" inbox = ws / "tmp" / "supervise-inbox" @@ -551,6 +622,8 @@ def build_project(ws: Path) -> dict: "goal_by_col": by, # 🔴 有目标但**落不进四列**的(状态读不出)⇒ 前端另行提示,⛔ 不硬塞列 "life_unknown": life_unknown, + # 🔴 图模式快照(只读):`/.workbuddy/graph/*/graph.json` ⇒ 前端「图模式」块 + "graph": graph_view(ws), "acc": {"n": len(eff), "pass": acc_pass, "items": acc_items}, "region": str(_region_dir(ws, goal)).replace("\\", "/"), "port": cfg.get("singleton_port"), diff --git a/session-mechanism/scripts/hooks/skill-load-guard.py b/session-mechanism/scripts/hooks/skill-load-guard.py index 5eb1371..76d0d29 100644 --- a/session-mechanism/scripts/hooks/skill-load-guard.py +++ b/session-mechanism/scripts/hooks/skill-load-guard.py @@ -157,6 +157,10 @@ TRIGGERS = ( '使用项目机制', '项目机制完成', '项目机制', # 🔴 2026-10-08 用户令:用法=「**使用项目机制完成目标:XXXX**」——**项目机制=机制的名字**; # ⚠️ 它建出来的会话仍叫「执行会话/检查会话」⇒ ⛔ 角色名不进词表、也不改名。 + # 🔴 2026-10-19 用户令(逐字「把skill 改成 完成目标也能触发」):光写「项目机制完成目标」不够 —— + # 用户口语里会把机制名字省掉,只说「完成目标」;词表里必须有**裸「完成目标」**这一条, + # 否则那句同样是派活授权,却一条都命不中。 + '完成目标', '使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成', '继续完成目标', '继续目标', '继续完成之前', '继续之前的', '会话技能', '会话机制', '创建任务会话', '建任务会话', @@ -178,7 +182,7 @@ _LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则' # 🔴 为什么单列一族:这三族**目标技能不同**,⛔ 不能混在一个 if 里 —— # 命中"使用任务会话完成目标"的人,要的是**会话机制本体**(怎么建排期/怎么派活/ # 自决策白名单在 `references/02-功能优先协作协议.md`),⛔ 不是决策方法论。 -_LOAD_SESSION = ('使用项目机制', '项目机制完成', '项目机制', +_LOAD_SESSION = ('使用项目机制', '项目机制完成', '项目机制', '完成目标', '使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成', '继续完成目标', '继续目标', '继续完成之前', '继续之前的', '会话技能', '会话机制', '创建任务会话', '建任务会话',