Files
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

673 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""goalctl —— 「**一个需求**」的**统一启停控制台**(🔴 属于本工作区,⛔ 不属于技能)。
由来(用户 2026-09-30):
> 「使用协作skill后 某个需求 统一管理 各种对应任务和程序的启停,包括协作程序等」
## 一个需求到底牵扯哪些"可启停的东西"(本机实测四层,⛔ 别多列)
| 层 | 物 | 谁能改 |
|---|---|---|
| ① **排期层(⛔ 2026-10-01 废弃:不用自动任务当闹钟)** | `automations` 表(唤醒 A/B、各线唤醒) | ⛔ **只有会话能改**(`automation_update` 工具)——脚本只读+打印待办 |
| ② **程序层** | `collabd.config.json` 的 `wake_enable`(协作程序**投递总闸**) | ✅ 脚本可改(配置现成消费者:`collabd.py` 启动即读) |
| ③ **总开关层** | `goal.json` 的 `run` 段(`active`/`paused`) | ✅ 脚本可改(唤醒第 0 步读它;⛔ `paused` ⇒ 不许自启、不许派活) |
| ④ **服务层** | 看板 `board.py --serve`/守护 `guard`/网关唤醒 | ✅ 复用现成 `stop-collab.py`(⛔ 不重写) |
## 三条硬约束(决定了为什么长这样)
1. 🔴 **钩子是全局注册、跨项目共用的**(`<配置目录>/settings.json`,里面还混着
其它工作区的 `decision_bridge.py`)⇒ ⛔ **不能靠装卸钩子来启停某个需求**(会连累所有项目)
⇒ 只能「**开关文件 + 入口早退**」。所以本控制台不碰钩子注册面。
2. 🔴 **排期只能由会话改**(脚本改 `workbuddy.db` 的 `automations` 表=改活库+宿主是否重读未验)
⇒ 脚本**只打印**该做的两步,由会话执行。⚠️ 降级有保障:即便排期照旧触发,
唤醒第 0 步读到 `run=paused` 会**立刻退出**(一次空跑,不派活、不改文件)。
3. 🔴 **⛔ 不 kill 任何会话/worker**;⛔ 不重启宿主;⛔ 不改全局配置。停服务一律走
「写标志让对端优雅退出」(`guard.stop`)或停自建看板实例。
## 用法
python goalctl.py # 状态台(默认,只读,⛔ 不动任何东西)
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass"] [--yes]
# 🔴 **登记"调用本技能时在对话里说明的目标"**
# (用户 2026-10-01:「目标是**通过对话在调用 会话协作skill时说明的,不是固定的**」
# ⇒ ⛔ 技能/脚本不许预设目标与任务类别,⛔ 也不许从目录名猜)
python goalctl.py wake # 🔴 **统一唤醒入口**:拨一下闹钟 ⇒ 协作程序判条件并投递
python goalctl.py stop # 干跑:列出"停"会做什么
python goalctl.py stop --yes # 真停:① run=paused ② wake_enable=false ③ 调 stop-collab --yes
python goalctl.py start "<目标名>" --yes # 真启:① run=active ② wake_enable=true
python goalctl.py start --yes # 同(不改标题)
## 🔴 `wake` —— 「手动补拨一次投递轮」(⛔ 投递目标=**跟进会话**,不是主会话)
⚠️ **2026-10-01 用户定案**:投递的**主路径=常驻 `collabd --supervise`**;
⛔ 不再用「自动任务当闹钟」(用户原话「**定时任务的方案已经废弃了**」)。
⇒ 本入口**保留、但不再是主路径** —— 它的现行作用是**手动补拨一次投递轮**(排查/兜底用)。
🔴 **2026-10-01 口径订正:目标从「主会话」改指「跟进会话」** —— 用户原话
「**换新会话 唤醒的是 跟进会话,主会话只能是用户触发**」⇒ 下面这张图的终点是**跟进会话**。
常驻投递(`--supervise` · 时钟本体)/ 手拨(`goalctl wake`)
└─ collabd --tick(协作程序的**一次性投递轮**)
└─ 协作程序 = **唯一的投递出口**(判条件 + reply 投递)
├─ 条件不符/刚投过/**跟进会话在忙** ⇒ **静默**(零打扰,零 token)
├─ 投成(http 2xx) ⇒ **跟进会话**被唤醒 ⇒ 本轮就此结束
└─ 没人能收(`no-follow-session` / `follow-not-live` / `target-deaf`)
⇒ **喊用户**(写 `NEED-USER.md`),⛔ **不降级**、⛔ 不盲投
(`no-live-session` 例外:整机一条活会话都没有 ⇒ 才降级为"自己干")
🔴 **判读三分**(脚本已内建,别自己猜):
· 「投成 / 目标在忙 / 刚投过 / 已停」⇒ **结束**(⛔ 不抢锁、⛔ 不派活、⛔ 不起会话)
· 「没人能收」 ⇒ **需人看**(⛔ 不降级 —— 缺的是**跟进会话**,不是"没人干活")
· 「一条活会话都没有」 ⇒ 降级自己干(**此时才抢域锁**)
⚠️ `target-busy` **不是**"没投出去",是"**跟进会话已经在跑**" ⇒ ⛔ **绝不能降级**(降级=同一件事干两遍)。
⚠️ 任何写动作前**先备份**(`.bak-goalctl-<日期>/`);⛔ 脚本从不删文件。
"""
from __future__ import annotations
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
def _sm_load_roots():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
for _up in range(4):
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
_p = _os.path.normpath(_p)
if _os.path.isfile(_p):
try:
with open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
_os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_sm_load_roots()
# 🔴 2026-10-01 加 · **输出编码兜底**:脚本一旦被重定向(钩子/常驻/后台任务都会这么干),
# Windows 本地编码(GBK)编不出 ⛔/✅/🔴 这类字符 ⇒ `print` 抛 UnicodeEncodeError
# ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(实测:本包里连续 4 次 `fatal 'gbk' codec ...`)。
# ⇒ 出口一律 UTF-8 + errors="replace"(⛔ 不让"打不出字"升级成"程序死")。
try:
import sys as _sys
_sys.stdout.reconfigure(encoding="utf-8", errors="replace")
_sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
def _resolve_ws() -> Path:
"""🔴 工作区解析(2026-10-01 修)。
⛔ **别把按 `__file__` 推层级当默认** —— 实测它推出来是**技能包的上级目录**
(`.../.workbuddy/skills`)⇒ 不带环境变量手跑时**静默**读写错工作区
(读数全「(无)」、还会在技能目录下建 `tmp/supervise-inbox/`)。
⚠️ 这与本项目已定的红线同族:「⛔ 别用 `dirname`/`__file__` 推工作区」。
顺序:`DSH_COLLAB_WS`(显式)→ `DSH_WS_ROOT`(`roots.env` 注入)→ 当前目录(含 `.workbuddy/collab/`)。
三路都不中 ⇒ 才回落到旧行为,并且**打醒目告警**(⛔ 不许静默)。
"""
for _k in ("DSH_COLLAB_WS", "DSH_WS_ROOT"):
_v = os.environ.get(_k)
if _v:
return Path(_v)
_c = Path.cwd()
if (_c / ".workbuddy" / "collab").is_dir():
return _c
sys.stderr.write(
"⚠️ goalctl:拿不到工作区(`DSH_COLLAB_WS`/`DSH_WS_ROOT` 都空,当前目录也没有 "
"`.workbuddy/collab/`)⇒ 回落到 `%s`(技能包上级目录,⚠️ 大概不是你想要的)\n"
% HERE.parent.parent)
return HERE.parent.parent
WS = _resolve_ws()
CFG = HERE / "collabd.config.json"
INBOX = WS / "tmp" / "supervise-inbox"
GOAL, TASKS = INBOX / "goal.json", INBOX / "tasks.json"
STOPPER = HERE / "stop-collab.py"
HOSTDB = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")) / "workbuddy.db"
BAK = WS / ".workbuddy" / "collab" / ("bak-goalctl-%s" % time.strftime("%Y%m%d"))
ARGS = [a for a in sys.argv[1:] if not a.startswith("-")]
DO = "--yes" in sys.argv
CMD = (ARGS[0] if ARGS else "status")
NAME = (ARGS[1] if len(ARGS) > 1 else "")
def _norm(s) -> str:
return str(s or "").replace("\\", "/").strip().lower().rstrip("/")
def _load(p: Path, default=None):
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return default
def _rp(rel: str, default: Path) -> Path:
"""🔴 与 `collabd.py:_rp` 同规则:**配置里的相对路径按工作区解析**。
⚠️ 踩过的坑(2026-09-30 本棒):任务图在配置里是 `交付物/任务图.json`,
而 **`tmp/supervise-inbox/taskgraph.json` 根本不存在** —— 照默认点读会静默读到空
⇒ `goals_open()` 少一路 ⇒ **可能误判"已完成"**。"""
if not rel:
return default
p = Path(rel)
return p if p.is_absolute() else (WS / p)
def _backup(p: Path) -> str:
if not p.is_file():
return ""
BAK.mkdir(parents=True, exist_ok=True)
dst = BAK / p.name
shutil.copy2(p, dst)
return str(dst)
def _cells(v) -> list:
"""automations.cwds 可能是 JSON 数组、也可能是逗号串。"""
if isinstance(v, list):
return [_norm(x) for x in v]
s = str(v or "").strip()
if s.startswith("["):
try:
return [_norm(x) for x in json.loads(s)]
except Exception:
pass
return [_norm(x) for x in s.split(",") if x.strip()]
# ── 各层读数 ───────────────────────────────────────────────────────────
def read_goal() -> dict:
g = _load(GOAL, {}) or {}
return g if isinstance(g, dict) else {}
def read_automations() -> list:
"""只读宿主库 ⇒ 本工作区相关的排期。⛔ 不写、不开事务。"""
if not HOSTDB.is_file():
return []
try:
con = sqlite3.connect("file:%s?mode=ro" % HOSTDB.as_posix(), uri=True, timeout=5)
con.execute("PRAGMA busy_timeout=3000")
cols = [d[1] for d in con.execute("PRAGMA table_info(automations)")]
rows = list(con.execute("select * from automations"))
con.close()
except Exception as e:
return [{"_err": str(e)}]
out = []
for r in rows:
d = dict(zip(cols, r))
if d.get("deleted_at"):
continue # ⚠️ 删除是**软删**:判"还在不在跑"要看这列
cells = _cells(d.get("cwds"))
if cells and _norm(WS) not in cells:
continue
out.append({
"id": str(d.get("id") or ""), "name": str(d.get("name") or ""),
"status": str(d.get("status") or ""), "rrule": str(d.get("rrule") or ""),
"next": d.get("next_run_at"), "kind": str(d.get("schedule_type") or ""),
})
return out
def goals_open() -> dict:
"""目标是否仍未完成 —— 三路并集(与 `collabd.goals_open()` 同判据,⛔ 不缩水)。
⚠️ **一路读不到不许当成"已完成"** —— 只把它标成 `读不到`,避免"误判完成"(源码里的
铁证:任务图 N14=done 而它判的正是「V1 未过」⇒ 只看"活干完没"会错)。"""
why, bad, t = [], [], _load(TASKS, {}) or {}
for k, v in (t if isinstance(t, dict) else {}).items():
if str((v or {}).get("state") or "") != "done":
why.append("台账 %s=%s" % (k, (v or {}).get("state")))
tg = _rp(str((_load(CFG, {}) or {}).get("taskgraph") or ""), INBOX / "taskgraph.json")
g = _load(tg, None)
if not isinstance(g, dict):
bad.append("任务图读不到(%s)" % tg.name)
else:
# 🔴 2026-10-01 加(与 `collabd.taskgraph` 同口径)—— **台账优先**:
# 任务图**无任何代码回写**(纯手维护),会话「上报完成」只写 `tasks.json`
# ⇒ 只认图会让「早已做完的件」永久卡住目标判定(实测 M5)。
for n in (g.get("nodes") or []):
if str(n.get("status") or "") == "done":
continue
if str(((t.get(n.get("id")) or {}).get("state")) or "") == "done":
continue # 台账已 done ⇒ 视同 done
why.append("任务图 %s" % n.get("id"))
acc = (read_goal().get("acceptance_state") or {})
eff = [k for k in acc if not str(k).startswith("_")] # 🔴 **有效项**=键不以 `_` 开头
for k in eff:
if str(acc[k]) != "pass":
why.append("验收 %s=%s" % (k, acc[k]))
if not eff:
# 🔴 2026-09-30 修(**"读不到"被当成了"全过"**):判据是"**有没有有效项**",
# ⛔ 不是"字典空不空" —— 旧写法 `if not acc` 在"只有 `_说明`/`_更新` 说明行"时
# **永不触发** ⇒ 控制台显示"三路全过"(**实测踩到**,同一族:不崩溃,只是少说一句话)。
bad.append("无有效验收判据(只有说明行)")
return {"open": bool(why) or bool(bad), "why": why[:6], "bad": bad}
def stamp_age(p: Path) -> str:
if not p.is_file():
return "无"
d = time.time() - p.stat().st_mtime
return "%.0f 分钟前" % (d / 60) if d < 7200 else "%.1f 小时前" % (d / 3600)
def run_stopper(dry: bool) -> str:
if not STOPPER.is_file():
return "(没找到 stop-collab.py)"
cmd = [sys.executable, str(STOPPER)] + ([] if dry else ["--yes"])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
errors="replace", cwd=str(HERE),
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
return (r.stdout or "") + (("\n[stderr] " + r.stderr) if r.stderr else "")
# ── 状态台 ─────────────────────────────────────────────────────────────
def status() -> int:
g, cfg, opens = read_goal(), _load(CFG, {}) or {}, goals_open()
autos = read_automations()
print("═══ 需求控制台 · %s ═══" % time.strftime("%Y-%m-%d %H:%M"))
print("工作区 = %s" % WS)
print()
run = str(g.get("run") or ("active" if g else "?"))
print("【总开关】run = %s %s" % (run, ("" if run != "?" else "(goal.json 里还没有 run 段)")))
print(" 目标 = %s" % (g.get("title") or "⚠️ 未声明任务目标"))
# 🔴 2026-09-30 改三态(⛔ 旧写法把"**判不出来**"和"三路全过"混成一句 ⇒ 会误报"已达成了")
if opens["why"]:
_st = "**未完成**"
elif opens["bad"]:
_st = "⚠️ **判不出来**(⛔ 不因此判完成)"
else:
_st = "三路全过"
print(" 目标状态 = %s%s%s" % (_st,
("(%s)" % ";".join(opens["why"])) if opens["why"] else "",
(" ⚠️ %s" % ";".join(opens["bad"])) if opens["bad"] else ""))
t = _load(TASKS, {}) or {}
cnt = {}
for v in (t if isinstance(t, dict) else {}).values():
k = str((v or {}).get("state") or "?")
cnt[k] = cnt.get(k, 0) + 1
print(" 队列 = %s" % (cnt or "空"))
print()
print("【② 程序闸】wake_enable = %s" % cfg.get("wake_enable"))
print(" 钩子链最后唤起 = tick %s / once %s" % (
stamp_age(INBOX / "_tick.stamp"), stamp_age(INBOX / "collabd-once.stamp")))
print()
rec = [a for a in autos if a.get("kind") == "recurring"]
onc = [a for a in autos if a.get("kind") != "recurring" and not a.get("_err")]
err = [a for a in autos if a.get("_err")]
print("【① 排期层】周期排期 %d 条(**这才是启停对象**)/一次性棒 %d 条(已耗,⛔ 不是启停对象)"
% (len(rec), len(onc)))
if err:
print(" ⚠️ 读宿主库失败:%s" % err[0]["_err"])
for a in rec:
st = a["status"]
mark = "🟢" if st == "ACTIVE" else "⚪"
nx = a.get("next")
try:
nx = time.strftime("%m-%d %H:%M", time.localtime(int(nx) / (1000 if int(nx) > 10 ** 11 else 1))) if nx else "—"
except Exception:
nx = "—"
print(" %s %s %-9s next=%s %s" % (mark, a["id"][:8], st, nx, a["name"][:44]))
if not rec and not err:
print(" (没有周期排期 ⇒ 无人值守的「叫醒」通道当前是关着的)")
print()
print("【④ 服务层】")
print(run_stopper(dry=True).rstrip())
print()
on = (run == "active") and bool(cfg.get("wake_enable"))
print("⇒ 一句话:这个需求现在是 **%s**" % ("开着" if on else "停着"))
print("⇒ 下一步:%s" % ("按需求推进(有缺口就派棒)" if on else "没有人在推进;要开就 goalctl start --yes,要派活就直接派"))
return 0
# ── 启停 ───────────────────────────────────────────────────────────────
def _write_json(p: Path, d: dict) -> None:
b = _backup(p)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if b:
print(" (备份 → %s)" % b)
def _automation_todo(want: str) -> None:
# 🔴 2026-10-01 修:原来只认名字含「唤醒轮」的排期 —— 而排期命名口径已改为
# `[唤醒]-<类别>-<具体>`(见 collabd.config.json `_默认形态`)⇒ 老判据**一条都找不到**,
# 于是 `stop`/`start` 永远打印「没找到名为「唤醒轮」的排期」(**判据跟着旧名字漂了**)。
# ⇒ 两道一起认:旧的「唤醒轮」+ 新的 `[唤醒]` 前缀。
autos = [a for a in read_automations()
if ("唤醒" in a.get("name", "") or a.get("name", "").startswith("[唤醒]"))]
print(" ⚠️ 排期层**只能由会话改**(⛔ 脚本不写活库)。请对本工作区的唤醒类排期执行:")
if not autos:
print(" (没找到「唤醒轮」或 `[唤醒]-…` 排期 —— 可能已删,或改名了)")
for a in autos:
if a.get("status") == want:
print(" ✓ %s 已是 %s,跳过" % (a["id"][:8], want))
continue
print(" automation_update mode=update id=%s status=%s ← %s"
% (a["id"], want, a["name"][:34]))
print(" 💡 降级有保障:即便漏改,唤醒第 0 步读到 run=%s 也会立刻退出。"
% ("paused" if want == "PAUSED" else "active"))
def stop() -> int:
print("═══ 需求控制台 · 停(%s)═══" % ("真做" if DO else "dry-run"))
g = read_goal()
if DO:
g["run"], g["run_at"] = "paused", time.strftime("%Y-%m-%dT%H:%M")
g["run_by"] = "goalctl stop"
print("① 总开关:goal.json run → paused")
_write_json(GOAL, g)
else:
print("① 总开关:[dry-run] 会写 goal.json run=paused")
cfg = _load(CFG, {}) or {}
if DO:
cfg["wake_enable"] = False
print("② 程序闸:collabd.config.json wake_enable → false")
_write_json(CFG, cfg)
else:
print("② 程序闸:[dry-run] 会写 wake_enable=false")
print("③ 服务层:%s" % ("调 stop-collab.py --yes" if DO else "[dry-run] 会调 stop-collab.py --yes"))
out = run_stopper(dry=not DO)
print("\n".join(" " + x for x in out.rstrip().splitlines()))
print("④ 排期层:")
_automation_todo("PAUSED")
print(" 🔴 停机**再起**时:常驻/看板都会**跑启动时那份代码**(⛔ 不热加载)⇒")
print(" ① 看板必须 `--takeover`(Windows 同端口可**静默并存**,⛔ 否则看到的是**旧进程**画的那张图);")
print(" ② 常驻(`collabd --supervise`)必须先停掉旧 PID 再起,⛔ 别以为重起过就是新代码。")
print(" ⇒ 依据 `references/pitfalls.md` P0-17 / P0-9。")
print("\n⇒ %s" % ("已停。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真停请加 --yes"))
return 0
def start() -> int:
print("═══ 需求控制台 · 启(%s)═══" % ("真做" if DO else "dry-run"))
g = read_goal()
if not g.get("title"):
print("⛔ goal.json 里没有 title ⇒ 机制不知道围绕什么跑。请先声明目标(不会帮你编一个)。")
return 2
if NAME and NAME != g.get("title"):
print(" (把目标名改成:%s)" % NAME)
if DO:
g["run"], g["run_at"], g["run_by"] = "active", time.strftime("%Y-%m-%dT%H:%M"), "goalctl start"
if NAME:
g["title"] = NAME
print("① 总开关:goal.json run → active")
_write_json(GOAL, g)
else:
print("① 总开关:[dry-run] 会写 goal.json run=active")
cfg = _load(CFG, {}) or {}
if DO:
cfg["wake_enable"] = True
print("② 程序闸:collabd.config.json wake_enable → true")
_write_json(CFG, cfg)
else:
print("② 程序闸:[dry-run] 会写 wake_enable=true")
print("③ 服务层:⛔ 不自启任何常驻(常驻在本机已被判**走不通**:detached 活不过工具调用边界)")
print(" ⚠️ 但**看板是要看的**(用户 2026-10-01:「我说的看板是**实时动态看板**」)——")
print(" 脚本起不了会话后台任务,请在**会话里**跑这一行(本机唯一可行的起法):")
_bp = (Path(Path(_collabd()).parent) / "board.py") if str(_collabd()) else (Path("<技能>") / "scripts" / "board.py")
print(" COLLABD_CONFIG=%s DSH_COLLAB_WS=%s \\" % (str(CFG).replace("\\", "/"), str(WS).replace("\\", "/")))
print(" <python> \"%s\" --serve 8788 --takeover > %s/tmp/board-serve.out.log 2>&1"
% (str(_bp).replace("\\", "/"), str(WS).replace("\\", "/")))
print(" (`--takeover` ⇒ 换代码重启的正规姿势;单实例护栏:已有看板时会拒绝裸起)")
print(" 🔴 常驻(`collabd --supervise`)同理会**跑启动时那份代码** ⇒ 改完代码必须**先停旧 PID 再重起**,")
print(" ⛔ 它不是热加载的(现场:22:06 起的那份跑的是 22:06 内存里的旧代码,日志里还在打已删的探针)。见 P0-17。")
print("④ 排期层:")
_automation_todo("ACTIVE")
print(" 🔴 相位校准:两条唤醒 nextRunAt 应相差约 30 分钟;不对就把其中一条 rrule 重送。")
print("\n⇒ %s" % ("已启。⛔ 未动任何别的工作区/别的需求。" if DO else "这是干跑;要真启请加 --yes"))
return 0
def _opt(name: str, dv: str = "") -> str:
"""读 `--name value` 或 `--name=value`(本脚本只用这两种形态,⛔ 不引 argparse)。"""
a = sys.argv[1:]
for i, x in enumerate(a):
if x == "--" + name:
return a[i + 1] if i + 1 < len(a) else dv
if x.startswith("--" + name + "="):
return x.split("=", 1)[1]
return dv
def _items(s: str) -> list:
"""按逗号切(中英文逗号都吃),**保留原样大小写/空白**(⛔ 不用 `_norm` —— 那是给路径用的)。"""
return [x.strip() for x in str(s or "").replace(",", ",").split(",") if x.strip()]
_SURE = "\x00" # 哨兵:区分「没给这个参数」与「给了空串(=显式清空)」
def declare() -> int:
"""🔴 **把"调用本技能时在对话里说明的目标"登记下来** —— 目标不是固定配置。
用户 2026-10-01 原话(订正我上一轮的做法):
> 「目标是 **通过对话在调用 会话协作skill时说明的,不是固定的**」
我上一轮做的**错事**:把 `goal.json.topics` 按**工作区里的目录名/接续入口**填了 4 类,
还拿去请用户"拍板定死"。⇒ 那是**猜**,不是**说明**。
⇒ 本命令是"说明"的**唯一落点**;⛔ 技能侧不许预设类别、⛔ 不许从文件名推。
用法:
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass;V2=未过"]
python goalctl.py declare --title "…" --topics "" # 显式清空类别(回到单类别回落)
⛔ 默认 **dry-run**;真写加 `--yes`(与 stop/start 同一个语义开关)。
🔴 `--kpi` 的**分隔符**(2026-10-01 22:3x 实测订正,⛔ 别再搞反):
实现是 `str(kpi).replace(";", ";").split(";")` ⇒ **先把中文 `;` 换成 `;` 再切**
⇒ **ASCII `;` 与中文 `;` 都是分隔符**,**两者都不能出现在条目内容里**。
⚠️ 我原先记的是"中文 `;` 会被当内容拼进去" —— **正好是反的**。实测写
`V1=过(… 被杀);_collabd.log 节拍 …` ⇒ 后半截被切成**独立的一条没有 `=` 的判据**、
**静默跳过**(只在 stderr 上印一行「没有 `=` ⇒ 跳过」)⇒ **落库的是被截断的值**,
而且**看起来"已登记"**(rc=0)—— 属于「看着成功、其实半丢」那一族。
✅ **条目内要断句**:用 `,` / `、` / `。` / `——`(这些**不是**分隔符,随便用)。
✅ ⚠️ **两个参数规则不同、别混**:`--topics` 走 `_items()` ⇒ **按逗号切**(中英文逗号都吃);
`--kpi` 走上面那条 ⇒ **按分号切**。
✅ 稳妥姿势:先 `declare`(dry-run,**不加 `--yes`**)跑一遍,**读回显里「验收判据:…」那一行**,
确认条目数与内容都完整,再补 `--yes` 真写。
三条不许静默的规矩:
· `--title` **必填** —— ⛔ 本脚本**不替你编目标**;
· `--topics` 省略 ⇒ **不动**(⛔ 不拿旧值凑数);给了 ⇒ 整表替换,并记 `topics_declared_at/by`;
· `--kpi` 省略 ⇒ **不动** `acceptance_state`;给了 ⇒ 整表重写,且**会点明丢掉了哪些说明行**。
"""
title = _opt("title") or NAME
why = _opt("why", _SURE)
topics = _opt("topics", _SURE)
kpi = _opt("kpi", _SURE)
by = _opt("by", "对话(调用 session-mechanism 时说明)")
print("═══ 需求控制台 · 声明目标(%s)═══" % ("真做" if DO else "dry-run"))
if not title:
print("⛔ 缺 `--title`。⛔ 本脚本**不替你编目标** —— 目标是**在对话里说明**的。")
print(" 例:python goalctl.py declare --title \"<一句话说清要做成什么>\" --yes")
return 2
g = read_goal()
print(" 现有目标:%s" % (g.get("title") or "(无)"))
print(" 目标改为:%s" % title)
new = dict(g)
new["title"] = title
# 🔴 2026-10-01 加(**静默陷阱告警**):换目标名却不给 `--kpi` ⇒ 上一条目标的验收判据
# 会**原样留着** ⇒ 新目标可能**一声明就被判「全过」**、机制完全不排队不投递(实测踩到:
# 新目标零件零判据,协作程序却报「目标三路全过」,digest 写「没有可推进的活」)。
# ⛔ 这里**只警告、不擅自清**(清了会让"只想改目标措辞"的人丢掉整表验收判据 —— 那是破坏性动作)。
if (g.get("title") or "") != title and kpi is _SURE:
_oldacc = [k for k in (g.get("acceptance_state") or {}) if not str(k).startswith("_")]
if _oldacc:
print(" 🔴 **注意(静默陷阱)**:你没给 `--kpi` ⇒ 上一条目标的验收判据会**原样留下** ——")
print(" 现存判据:%s" % "、".join(_oldacc))
print(" ⚠️ 新目标可能**一声明就被判『全过』**、机制不排队不投递(判据不绑目标)。")
print(" 要换判据:加 `--kpi \"V1=pass;V2=未过\"`(留空表也请显式给 `--kpi \"\"`)。")
if why is not _SURE:
new["why"] = why
print(" 为什么 :%s" % (why or "(清空)"))
if topics is not _SURE:
ts = _items(topics)
print(" 任务类别:%s" % (("、".join(ts)) if ts else "(清空 ⇒ 回落目标简称,单类别)"))
if "_" in topics:
print(" ⚠️ 以 `_` 开头的类别名会被当『说明行』忽略 —— 类别名请取正常词")
new["topics"] = ts
new["topics_declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
new["topics_declared_by"] = by
if kpi is not _SURE:
acc, dropped = {}, []
for seg in [x for x in str(kpi).replace(";", ";").split(";") if x.strip()]:
if "=" not in seg:
print(" ⚠️ 判据 `%s` 没有 `=` ⇒ 跳过(要 `名字=pass` 或 `名字=未过`)" % seg.strip())
continue
k, v = seg.split("=", 1)
acc[k.strip()] = v.strip()
for k in (g.get("acceptance_state") or {}):
if str(k).startswith("_"):
dropped.append(k)
if dropped:
print(" ⚠️ 重写验收会**丢掉历史说明行**:%s(如需保留,请把它们写进新表)" % "、".join(dropped))
new["acceptance_state"] = acc
_acc_s = "、".join("%s=%s" % (k, v) for k, v in acc.items()) or "(一条都没有 ⇒ 会显式判『未声明』)"
print(" 验收判据:%s" % _acc_s)
if not DO:
print("\n⇒ 这是干跑(⛔ 一个字节都没写)。要真写:末尾加 `--yes`")
return 0
new["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
new["declared_by"] = by
_write_json(GOAL, new)
print("\n⇒ 已登记(落点 `%s`)。" % GOAL)
print(" 🔴 目标**不是固定的**:下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可。")
return 0
# ── 统一唤醒入口 ───────────────────────────────────────────────────────
def _collabd() -> Path:
"""协作程序本体(**技能里的通用代码**,靠 `COLLABD_CONFIG` 指向本工作区配置)。"""
base = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy"))
for p in (Path(os.environ.get("COLLABD_SCRIPT") or ""),
# 🔴 2026-10-01:原 `multi-session-collab` 已并入 `session-mechanism`(本包即主技能)
# ⇒ **新落点优先**;旧落点只作**过渡兜底**(⛔ 别删 —— 包未装/被移走时不至于
# 静默返回空 ⇒ 唤醒入口静默停摆。那正是本文件 docstring 记过的原事故)。
base / "skills" / "session-mechanism" / "scripts" / "collabd.py",
base / "skills" / "multi-session-collab" / "scripts" / "collabd.py"):
if p and p.is_file():
return p
return Path()
# `. deliver=` 字段的读法(值来自 `_deliver_str` / `supervise`)
# ⚠️ 本表必须与 `collabd._deliver_str()` 实际写出的 `skipped=` 值**逐字同款**。⛔ 漏登记不是"少一句话":
# 它会落进下面的兜底分支、被**静默当成「降级自己干」** —— 实测 2026-10-01:`no-follow-session`/
# `follow-not-live`/`target-deaf` 三个新值**一个都没登记** ⇒ 打印一句「未登记的跳过原因」然后**抢锁去干活**。
_WHY = {
"target-busy": ("结束", "⏸ **跟进会话正在执行** ⇒ 不必打扰,⛔ 绝不能降级(降级=同一件事干两遍)"),
"same-item": ("结束", "✓ 同样内容刚投过 ⇒ 不重复"),
"too-soon": ("结束", "✓ 距上次投递太近 ⇒ 不重复(去抖)"),
"locked": ("结束", "→ 另一进程正在投 ⇒ 让给它"),
"disabled": ("结束", "⛔ `wake_enable=false` ⇒ 本需求**已停**(要开:goalctl start --yes)"),
"no-token": ("异常", "🔴 拿不到网关口令 —— 说明**没跑在宿主进程树内**,当前架构下不该发生"),
# 🔴 2026-10-01 新增三类:**没人能收**(缺的是「跟进会话」,⛔ 不是「没人干活」)⇒ 喊用户,⛔ 不降级
"no-follow-session": ("异常", "🔴 本工作区**一条 `[跟进]-<类别>-…` 都没有** ⇒ 上报没人收"
"(协作程序已写 `NEED-USER.md`)⇒ 按开工清单建一条跟进会话"),
"follow-not-live": ("异常", "🔴 那条跟进会话**此刻不在活会话里**(已关/已换)⇒ 程序拒绝盲投"
"(协作程序已写 `NEED-USER.md`)"),
# 🔴🔴 2026-10-02 两类改造:**跟进会话整套退役**(`follow_for_topic()` 已短路成这一档)。
# ⛔ 标「需人看」而非「异常」:⛔ **不许再引导去"建跟进会话"**(那条路已不存在),
# ⛔ 也不许降级去抢锁自己干(那会变成"没人派活就自己干",掩盖了"这条没人接"的真状态)。
# 🔴🔴 2026-10-03 **投递段已真删** ⇒ 这一档**生产侧已无来源**(`supervise()` 不再调
# `_deliver_str()`)⇒ 只在**单测**与**将来重建收件人**时才可能读到它。
# ⛔ 保留登记(⛔ 不删):它是"当年这条判据存在过"的机读痕迹,删了将来无法分辨
# 「从没这档」与「把这档删了」。**登记文案按投递退役后的口径改**(旧文案里
# "别等上报"隐含"还有上报可等",⛔ 现在是**根本没有上报**)。
"follow-retired": ("需人看", "⚪ **投递链路已无收件人**(2026-10-02 退役唤醒/跟进;2026-10-03 投递段已真删)"
"⇒ ⛔ **不存在「等通知」这条路**了:要落这件事,**显式建一条协作会话**去读队列干活"),
"target-deaf": ("异常", "🔴 目标跟进会话**已哑**(诊断日志撞 ~10 MiB ⇒ 界面不再刷新)⇒ 投了也看不见,"
"须把它换到一条新会话"),
"no-live-session": ("降级", "🔴 **整机没有任何活会话** ⇒ 没有可投的对象(只有这一档才降级:自己就是那条活的)"),
"no-gateway": ("异常", "🔴 没扫到网关口"),
# ⛔ 下面两个只剩在**已停用**的 `wake_round()`(2026-09-29)里作死代码 ——
# `resolve_main()` 现在**只服务看板读数**,⛔ 不再是投递判读 ⇒ 见到它们就说明**有代码在走旧路径**。
"main-not-live": ("异常", "⚠️ 旧路径的值(主会话不在活会话里)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
"no-main-session": ("异常", "⚠️ 旧路径的值(解析不出主会话)—— 现行投递⛔ 不产出它,请查谁在走旧路径"),
}
def wake() -> int:
print("═══ 拨闹钟 · 协作程序判条件并投递(%s)═══" % time.strftime("%H:%M:%S"))
if str((_load(CFG, {}) or {}).get("wake_enable")).lower() == "false":
print("⛔ 程序闸已关(wake_enable=false)⇒ 不投递,直接结束。要开:goalctl start --yes")
return 0
cb = _collabd()
if not cb.is_file():
print("⛔ 找不到协作程序 collabd.py(⛔ 不猜路径;可用 COLLABD_SCRIPT 指定)")
return 3
env = dict(os.environ)
env["COLLABD_CONFIG"] = str(CFG)
try:
r = subprocess.run([sys.executable, str(cb), "--tick"], cwd=str(WS), env=env,
capture_output=True, text=True, timeout=180, errors="replace",
creationflags=0x08000000) # CREATE_NO_WINDOW:⛔ 闪窗
except Exception as e:
print("⛔ 调协作程序失败:%s" % e)
return 3
out = (r.stdout or "") + (("\n" + r.stderr) if r.stderr else "")
line = next((l for l in out.splitlines() if l.startswith("tick:")), "")
if not line:
print("⚠️ 协作程序没给出 `tick:` 结论(rc=%s)⇒ 按「没投出去」处理(⛔ 不假装成功)" % r.returncode)
print("\n".join(" " + x for x in out.strip().splitlines()[:8]))
return 1
f = dict(x.split("=", 1) for x in line[6:].split(" ") if "=" in x)
dv = f.get("deliver", "-")
print(" 协作程序结论:%s" % line)
print()
if dv.startswith("http"):
code = dv.split("=", 1)[-1]
print("✅ 判定:**已投递**(http %s)⇒ 主会话已被唤醒。" % code)
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活、⛔ 不起新会话(唤醒已由协作程序完成)。")
return 0
if dv == "-":
print("✓ 判定:**没有需要投的东西**(队列无新变化 / 无停滞)⇒ 静默。")
print("⇒ 本轮**就此结束**(这就是「符合条件时才唤」的否定面 —— 零打扰)。")
return 0
# ⚠️ 兜底=**需人看**,⛔ 不是降级:判读表漏登记时**绝不许静默抢锁去干活**(2026-10-01 实测踩过)。
kind, why = _WHY.get(dv, ("异常", "🔴 **未登记的跳过原因 `%s`** —— 投递判读表漏登记(⛔ 不是「没人干活」)" % dv))
print("◻ 判定:**未投出**(%s)—— %s" % (dv, why))
if kind == "结束":
print("⇒ 本轮**就此结束**:⛔ 不抢域锁、⛔ 不派活。")
return 0
if kind == "异常":
print("⇒ 🔴 **需人看**:这不是正常路径(详见 tmp/supervise-inbox/NEED-USER.md)。本轮结束。")
return 2
print("⇒ **降级**:没有可唤醒的对象 ⇒ 你(本次自动任务起的会话)**就是那个会话**。")
print(" 请抢域锁后按状态表干活:")
print(" bash D:/github/dsh_shenxian/dsh-server-docs/07-scripts/handoff-guard.sh "
"--claim-exec \"<你的会话名>\" --domains ai1net-dsh-server")
return 10
if __name__ == "__main__":
if CMD in ("status", "st", "s"):
sys.exit(status())
if CMD in ("wake", "wk", "tick"):
sys.exit(wake())
if CMD in ("stop", "down"):
sys.exit(stop())
if CMD in ("start", "up"):
sys.exit(start())
if CMD in ("declare", "set", "goal"):
sys.exit(declare())
print(__doc__.split("## 用法")[1].split("⚠️")[0].strip())
sys.exit(0)