feat(board/graph): 图模式接项目看板(只读·类型标签);fix(hook): 裸「完成目标」也触发技能闸;fix(launcher): pythonw 兜 stdout

This commit is contained in:
admin committed 2026-10-19 22:35:08 +08:00
1 parent 12b7dd9b02
commit efbe128670
5 files changed
+170 -2

No files matched your search

+5 -1
View File
@@ -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 <ws> --title … --nodes-json -` ⇒ `check` ⇒ `tick`(算完就开)⇒ 执行方 `report` ⇒ 反复 `tick` 直到 `progress` 到 100%。
⚠️ 它现在**没有接进看板、也没有排期**(`plan` 只出规范行)⇒ 图模式由会话手工调用,日常流程里看不到它。要不要接入口见工作区待拍板项。
✅ **已接项目看板(2026-10-19)**:`boards/项目看板/project-board.py::graph_view()` 只读 `<ws>/.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 味的完整版),⛔ 不装也能跑」——
@@ -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
@@ -261,8 +261,32 @@
<div class="summary" id="summary"></div>
</header>
<style id="graphstyle">
/* 图模式块(2026-10-19 接入 · 只读展示,节点用「类型标签 + 状态色」区分) */
.graphs { margin: 4px 24px 40px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.12); }
.ghead { font-size: 16px; font-weight: 700; margin-bottom: 10px; }
.gsub { font-weight: 400; opacity: .6; font-size: 12px; margin-left: 8px; }
.gcard { border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
padding: 10px 12px; margin-bottom: 10px; background: rgba(255,255,255,.03); }
.gttl { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
.gmeta { font-weight: 400; opacity: .55; font-size: 11px; margin-left: 6px; }
.gchips { display: flex; flex-wrap: wrap; gap: 6px; }
.gchip { font-size: 11px; padding: 2px 7px; border-radius: 999px;
border: 1px solid rgba(255,255,255,.16); background: rgba(255,255,255,.05);
white-space: nowrap; }
.gchip b { font-weight: 600; opacity: .75; margin-right: 5px; font-size: 10px; }
.gchip.gst-running { border-color: #4a9eff; background: rgba(74,158,255,.16); }
.gchip.gst-done { border-color: #3fbf7f; background: rgba(63,191,127,.16); }
.gchip.gst-blocked { border-color: #ff6b6b; background: rgba(255,107,107,.16); }
.gchip.gst-awaiting { border-color: #ffb454; background: rgba(255,180,84,.16); }
.gchip.gst-gating { border-color: #b48bff; background: rgba(180,139,255,.16); }
.gchip.gst-skipped, .gchip.gst-cancelled { opacity: .45; }
.gempty { opacity: .55; font-size: 12px; padding: 6px 0; }
</style>
<main>
<div class="cols" id="cols"></div>
<div class="graphs" id="graphs"></div>
</main>
<div class="toast" id="toast"></div>
@@ -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 = '<div class="gempty">图模式:本机暂无图(会话里用 <code>graphctl.py init</code> 建一张,这里就会显示)</div>';
return;
}
var h = ['<div class="ghead">图模式<span class="gsub">'
+ rows.length + ' 张图 · 胶囊上的粗体是节点类型,颜色是状态</span></div>'];
rows.forEach(function(r){
var g = r.g, p = r.p;
h.push('<div class="gcard"><div class="gttl">' + esc(g.title)
+ '<span class="gmeta">' + esc(p.dir) + ' · ' + esc(g.gid)
+ ' · ' + esc(String(g.percent)) + '%</span></div><div class="gchips">');
(g.nodes || []).forEach(function(n){
h.push('<span class="gchip gst-' + esc(n.status) + '" title="'
+ esc(n.id + ' · ' + n.status) + '"><b>' + esc(n.kind) + '</b>'
+ esc(n.title) + '</span>');
});
if(g.truncated) h.push('<span class="gchip">…还有 ' + g.truncated + ' 个</span>');
h.push('</div></div>');
});
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 列)
@@ -361,6 +361,77 @@ def _region_dir(ws: Path, goal: dict) -> Path:
return ws / "执行会话"
# ── 图模式(只读快照 · 2026-10-19 接入看板)─────────────────────────────────
# 落点=`<ws>/.workbuddy/graph/<gid>/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,
# 🔴 图模式快照(只读):`<ws>/.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"),
@@ -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 = ('使用项目机制', '项目机制完成', '项目机制', '完成目标',
'使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
'会话技能', '会话机制', '创建任务会话', '建任务会话',