1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。
2) session-rules-check 三处判据:
· hook_reg 按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
· snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
(变异对照:截断快照能报 fail,非恒绿)
· mem_ptr 只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」
3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)
4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
878 lines
53 KiB
Python
878 lines
53 KiB
Python
# -*- 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 re
|
||
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()
|
||
|
||
|
||
def _resolve_cfg() -> Path:
|
||
"""🔴🔴 部署配置解析(2026-10-06 修)—— ⛔ **不许写死成技能目录**。
|
||
|
||
**病根**:原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**),
|
||
而按定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」,
|
||
**技能目录里⛔ 不放生产配置** ⇒ 该文件恒不存在 ⇒ `_load(CFG,{})` 恒返回 `{}`。
|
||
|
||
**症状(不报错、不崩溃,只是判定那一路永远读不到)**:
|
||
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ 目标换了图没换时**照旧踩**
|
||
(vibe-product 2026-10-06 实测:配置里已改指 proto-board,而 `goals_open()`
|
||
仍报 `bad=['任务图读不到(taskgraph.json)']` —— 即**改了配置不生效**);
|
||
· 其余读 `CFG` 的命令(`wake_enable`/`targets` / 写回配置)**全部作用在一个不存在的文件上**。
|
||
|
||
✅ 与 `collabd._cfg_candidates()` **同款三级**(⛔ 不新造顺序,否则又是一处口径分叉):
|
||
① 环境变量 `COLLABD_CONFIG`(钩子/启动器显式指定,首选)
|
||
② `<工作区>/.workbuddy/collab/collabd.config.json`(使用方**标准落点**)
|
||
③ 都不在 ⇒ 回落到旧的 `HERE/collabd.config.json` **并打醒目告警**(⛔ 不静默)。
|
||
"""
|
||
_env = os.environ.get("COLLABD_CONFIG")
|
||
if _env and Path(_env).is_file():
|
||
return Path(_env)
|
||
_std = WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||
if _std.is_file():
|
||
return _std
|
||
_legacy = HERE / "collabd.config.json"
|
||
sys.stderr.write(
|
||
"⚠️ goalctl:没找到部署配置(`COLLABD_CONFIG` 未设、`%s` 不存在)"
|
||
"⇒ 回落到 `%s`\n" % (_std, _legacy))
|
||
return _legacy
|
||
|
||
|
||
CFG = _resolve_cfg()
|
||
INBOX = WS / "tmp" / "supervise-inbox"
|
||
GOAL, TASKS = INBOX / "goal.json", INBOX / "tasks.json"
|
||
# 🔴🔴 2026-10-05 **历史目标归档目录**(用户口径「一个工作区 历史的旧的目标会有很多个」)。
|
||
# 判据:`goal.json` = **唯一当前目标**(同时只有一个);换目标时**旧的必须移到这里**,
|
||
# ⛔ 不许原地覆盖丢掉(本目录此前**代码零实现** —— 那份 `机制自检-20261004.json`
|
||
# 是手工造的 ⇒ 换目标 = 旧目标消失,只留用户手写的 `_前身` 一行字)。
|
||
GOALS_DIR = INBOX / "goals"
|
||
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
|
||
# 🔴🔴 2026-10-05 **「用户已确认切换目标」的显式开关**(用户口径:换目标**需要用户确认**)。
|
||
# 判据:`--switch-goal` = **人**说"同意放弃当前目标";`--yes` = 只是"干跑转真写"。
|
||
# ⛔ **两者不许合并** —— 合并就等于把"确认"降级成"顺手加个 yes",闸门失效。
|
||
SWITCH_OK = "--switch-goal" 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 _acc_is_pass(v) -> bool:
|
||
"""🔴 一条验收判据「算不算过」—— 🔴 **本文件唯一实现,且⛔ 不自带词表**。
|
||
|
||
🔴🔴 **2026-10-06 立**(vibe-product 实测踩到,属"判据分叉"第 N 次复发):
|
||
|
||
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,并在注释里点明
|
||
三处判据(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)
|
||
**必须同款**。🔴 **实际是四处** —— 本文件 `goals_open()` 里那句
|
||
`if str(acc[k]) != "pass":` **被漏掉了**,既不归一化、也不复用真身。
|
||
|
||
**症状(不报错、不崩溃,只少说一句真话)**:本机制的存值**真源就是中文**
|
||
(`过(PID 3552 …)`/`已过|…`/`达(实测 7 个…)`)⇒ 死板比 `"pass"` ⇒
|
||
**已通过的验收被全部计入 why** ⇒ 恒判「未完成」。
|
||
实测(vibe-product 2026-10-06):四条验收**全过**(`V1=pass` 已归一化存值),
|
||
而 `goalctl.py status` 仍会因历史中文值报「未完成」;用户原话
|
||
「**为什么完成了还说没完成**」。⇒ 与 `collabd._acc_is_pass()` 的沿革逐字同族。
|
||
|
||
**正解=复用真身,⛔ 不复制词表**(复制就是下一次漂的来源):
|
||
首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`);
|
||
拿不到(副本被单独拷走 / 循环导入)⇒ **fail-closed 声明式兜底**:
|
||
用与 `board.py::ACC_PASS_WORDS` **同款**的白名单判,并在 stderr 留一行。
|
||
⛔ **绝不静默返回 True/False** —— 那会把"代码没同步"伪装成"判据没过/过了"。
|
||
"""
|
||
try:
|
||
import sys as _sys
|
||
_sd = str(HERE)
|
||
if _sd not in _sys.path:
|
||
_sys.path.insert(0, _sd)
|
||
import collabd as _cd # ⚠️ 同目录 ⇒ 无副作用
|
||
return bool(_cd._acc_is_pass(v))
|
||
except Exception as _e:
|
||
try:
|
||
sys.stderr.write("⚠️ goalctl._acc_is_pass: 拿不到 collabd(%s)⇒ 走兜底白名单\n" % _e)
|
||
except Exception:
|
||
pass
|
||
# ── fail-safe 兜底:与 `board.py::ACC_PASS_WORDS` / `ACC_ADV_PREFIX` **同款**
|
||
# (⛔ 改了那边必须改这里 —— 两处都列出来,免得只改词表漏掉副词)
|
||
s = str(v or "").strip()
|
||
if not s:
|
||
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
|
||
_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
|
||
# 🔴 剥"完成副词"再比(与 `board.py::acc_is_pass` 同款)。
|
||
# ⚠️ 否定副词(未/不/没/非/待)**不在**表里 ⇒ 剥不动 ⇒ 仍 fail-closed。
|
||
_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 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:
|
||
# 🔴🔴 2026-10-06 **改走真身**(原为 `str(acc[k]) != "pass"`,死板比字面值)——
|
||
# 存值真源是**中文**(`过(…)`/`已过|…`/`达(…)`)⇒ 死板比 ⇒ 已通过的
|
||
# 验收被计入 why ⇒ 恒判「未完成」。详见 `_acc_is_pass()` 的 docstring。
|
||
if not _acc_is_pass(acc[k]):
|
||
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=过;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` ⇒ 保持现有标题**(⛔ 不回落成 `NAME`=工作区名)。
|
||
# 🔴 2026-10-05 实测踩到:旧写法 `_opt("title") or NAME` 在"只想补 `--why`/`--kpi`"
|
||
# (即**同一目标在细化**,标题根本没打算动)时,会把标题**悄悄改成工作区名**
|
||
# ⇒ 触发换目标闸 ⇒ **把一个纯细化动作拦成"未确认换目标"**(放大成事故)。
|
||
# 判据:`--title` 是**显式动作**;没给就是"不动它"(与 `--topics`/`--kpi` 同一规矩)。
|
||
# ⚠️ 只有**一个目标都没有**(首次声明)时,才允许回落到 `NAME`。
|
||
_t_given = _opt("title", _SURE)
|
||
title = (_t_given if _t_given is not _SURE else "") or str(read_goal().get("title") or "") 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()
|
||
old_title = str(g.get("title") or "")
|
||
print(" 现有目标:%s" % (old_title or "(无)"))
|
||
print(" 目标改为:%s" % title)
|
||
|
||
# 🔴🔴🔴 2026-10-05 **换目标的确认闸 + 旧目标自动归档**(用户口径,逐字:
|
||
# 「**一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
|
||
# 然后切换和关注检查切换后的目标**」)。
|
||
# 🔴 **改前取证**:`declare` **直接覆盖 `title`**、**从不归档旧目标**
|
||
# (全文搜 `goals/` 零命中)⇒ 换目标 = 旧目标当场消失,只剩人手写的 `_前身` 一行。
|
||
# ⇒ 补两道:
|
||
# ① **确认闸**:标题变了 ⇒ **默认拒绝**,必须带 `--switch-goal` 明示"用户已确认切换"
|
||
# (⛔ 与 `--yes` 分开:`--yes` 是"干跑转真写",⛔ 不是"用户同意换目标")。
|
||
# ② **归档**:确认后把旧 `goal.json` **整份**移到 `goals/<旧标题>__<时间>.json`,
|
||
# 并在新目标里留 `_前身目标` 指向归档件(⛔ 不靠人手写)。
|
||
# ⚠️ 只改措辞/补 why/补 kpi(标题**没变**)⇒ ⛔ **不触发本闸**(那是同一目标在细化)。
|
||
_is_switch = bool(old_title) and old_title != title
|
||
if _is_switch and not SWITCH_OK:
|
||
print("\n⛔ **这是「切换目标」,需要用户确认 —— 已拒绝写入。**")
|
||
print(" 当前目标:%s" % old_title)
|
||
print(" 拟换为 :%s" % title)
|
||
print()
|
||
print(" 用户口径(2026-10-05 定案,逐字):")
|
||
print(" 「一个工作区 同时只能执行一个目标,如果要切换目标,**需要用户确认**,")
|
||
print(" 然后切换和关注检查切换后的目标」")
|
||
print()
|
||
print(" ⇒ 请**先向用户确认**「要放弃当前目标、换成新的吗」;")
|
||
print(" 得到确认后,在原命令上补 `--switch-goal` 重跑:")
|
||
print(" python goalctl.py declare --title \"%s\" --switch-goal --yes" % title[:40])
|
||
print(" ⚠️ `--switch-goal` ⛔ **不等于** `--yes` —— 前者是「用户同意换目标」,")
|
||
print(" 后者只是「干跑转真写」。⛔ 不确认就带上它 = 伪造用户授权。")
|
||
print("\n⇒ 本轮**一个字节都没写**。")
|
||
return 3
|
||
|
||
if _is_switch:
|
||
print(" 🔴 换目标已确认(`--switch-goal`)⇒ 旧目标将**整份归档**到 `%s`" % GOALS_DIR)
|
||
new = dict(g)
|
||
new["title"] = title
|
||
# 🔴🔴🔴 2026-10-05 **换目标 ⇒ 自动把 `lifecycle` 复位成「进行中」**(用户定案:
|
||
# 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,**然后切换和关注检查
|
||
# 切换后的目标**」)。
|
||
# **改前事实**:`declare` **从不碰 `lifecycle`**(全文只在上文末尾打一句
|
||
# 「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」让人手补)。
|
||
# **为什么必须自动做**(不是"顺手"):
|
||
# ① 旧目标做完了 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后**机制照旧不拉**
|
||
# (判据见 `collabd.py`:`life != 进行中` 时只在"队列还有非 done 件"才拉)
|
||
# ⇒ **新目标没人检查** —— 与用户"切换后要关注检查"的要求**方向相反**。
|
||
# ② 靠人手补=**又一条"要记得"的待办**(同族:用户 2026-10-05 原话「最好是记录会话,
|
||
# 这样就不用每次会话都去做声明了」——凡"要人记"的都会忘)。
|
||
# ⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(下面紧跟的归档步),旧值可回查;
|
||
# 且只在**已确认换目标**(`--switch-goal`)时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
|
||
# ⚠️ **只动 `lifecycle` 三件套**,⛔ 不碰 `acceptance_state`(那是上一条目标的判据,
|
||
# 另有静默陷阱告警处理;清它会毁证据 —— 见下面那段 `--kpi` 警告)。
|
||
if _is_switch:
|
||
_old_life = str(g.get("lifecycle") or "").strip()
|
||
new["lifecycle"] = "进行中"
|
||
new["lifecycle_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||
new["lifecycle_by"] = "goalctl declare --switch-goal(换目标自动复位)"
|
||
new["lifecycle_why"] = ("换目标 ⇒ 复位(改前=%s);旧目标整份归档,可在归档件里回查"
|
||
% (_old_life or "未声明"))
|
||
print(" 🔴 `lifecycle` 自动复位:%s ⇒ 进行中" % (_old_life or "(未声明)"))
|
||
print(" 理由:不复位 ⇒ 新目标**不被检查**(机制只在『进行中』时才拉检查会话)")
|
||
# 🔴 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=过;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`,"
|
||
"也可写中文 `过`/`已过`/`达` —— 判定层会归一化,⛔ 不是只认 `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`")
|
||
if _is_switch:
|
||
print(" ⚠️ 本命令是**换目标** ⇒ 真写时还需要 `--switch-goal`(确认闸已过则不必再加)")
|
||
return 0
|
||
|
||
# 🔴🔴 2026-10-05 **旧目标整份归档**(换目标时;⛔ 不许原地覆盖丢掉)。
|
||
# 落点:`tmp/supervise-inbox/goals/<旧标题≤40字>__<YYYYMMDD-HHMM>.json`
|
||
# ⛔ 文件名净化:去掉 `/ \ : * ? " < > |`(Windows 非法字符)+ 折叠空白。
|
||
if _is_switch:
|
||
try:
|
||
GOALS_DIR.mkdir(parents=True, exist_ok=True)
|
||
_safe = re.sub(r'[\\/:*?"<>|\r\n\t]+', "-", old_title).strip()[:40] or "untitled"
|
||
_dst = GOALS_DIR / ("%s__%s.json" % (_safe, time.strftime("%Y%m%d-%H%M")))
|
||
_n = 1
|
||
while _dst.exists():
|
||
_dst = GOALS_DIR / ("%s__%s_%d.json" % (_safe, time.strftime("%Y%m%d-%H%M"), _n))
|
||
_n += 1
|
||
_dst.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
|
||
encoding="utf-8", newline="\n")
|
||
new["_前身目标"] = ("%s(已于 %s 换目标时归档至 goals/%s)"
|
||
% (old_title, time.strftime("%Y-%m-%dT%H:%M"), _dst.name))
|
||
print(" ✅ 旧目标已归档:goals/%s(%d 字节)" % (_dst.name, _dst.stat().st_size))
|
||
except Exception as e:
|
||
# ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但必须**喊出来**(⛔ 不许静默)
|
||
print(" 🔴 **旧目标归档失败**:%s" % e)
|
||
print(" ⚠️ 旧目标**未被保存** —— 若还要它,请先手工备份 `%s`" % GOAL)
|
||
new["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||
new["declared_by"] = by
|
||
_write_json(GOAL, new)
|
||
print("\n⇒ 已登记(落点 `%s`)。" % GOAL)
|
||
if _is_switch:
|
||
print(" 🔴 **切换完成** —— 从现在起,机制**关注和检查的是新目标**:%s" % title[:60])
|
||
print(" ✅ `lifecycle` 已**自动复位为「进行中」**(2026-10-05 起 `declare` 自己管它,⛔ 不用手补)")
|
||
print(" ⚠️ 若新目标其实**还没开工**,再手动改成别的:")
|
||
print(" python collabd.py --set-life 等待 --by \"主会话\"")
|
||
else:
|
||
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)
|