hubkit-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubcore/__init__.py +1 -0
- hubcore/cli.py +273 -0
- hubcore/compat.py +187 -0
- hubcore/config.py +107 -0
- hubcore/data/hub/LICENSE +202 -0
- hubcore/data/hub/SKILL.md +125 -0
- hubcore/data/hub/config.example.toml +84 -0
- hubcore/data/hub/docs/architecture.md +133 -0
- hubcore/data/hub/docs/commands.md +66 -0
- hubcore/data/hub/docs/configuration.md +151 -0
- hubcore/data/hub/docs/troubleshooting.md +21 -0
- hubcore/data/hub/intents.example.toml +74 -0
- hubcore/data/hub/references/design-sources.md +14 -0
- hubcore/data/hub/references/research.md +49 -0
- hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
- hubcore/data/hub/scripts/hubcore/cli.py +273 -0
- hubcore/data/hub/scripts/hubcore/compat.py +187 -0
- hubcore/data/hub/scripts/hubcore/config.py +107 -0
- hubcore/data/hub/scripts/hubcore/gate.py +145 -0
- hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
- hubcore/data/hub/scripts/hubcore/install.py +66 -0
- hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
- hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
- hubcore/data/hub/scripts/hubcore/orders.py +508 -0
- hubcore/data/hub/scripts/hubcore/redact.py +89 -0
- hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
- hubcore/data/hub/scripts/hubcore/slow.py +531 -0
- hubcore/data/hub/scripts/hubcore/status.py +535 -0
- hubcore/data/hub/scripts/hubcore/store.py +172 -0
- hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
- hubcore/data/hub/scripts/hubkit +37 -0
- hubcore/data/hub/scripts/learn.py +84 -0
- hubcore/data/hub-mep/SKILL.md +35 -0
- hubcore/data/hubkit-setup/SKILL.md +50 -0
- hubcore/gate.py +145 -0
- hubcore/hooks.py +326 -0
- hubcore/install.py +66 -0
- hubcore/learnings.py +232 -0
- hubcore/lessons.py +168 -0
- hubcore/orders.py +508 -0
- hubcore/redact.py +89 -0
- hubcore/routed.py +1169 -0
- hubcore/slow.py +531 -0
- hubcore/status.py +535 -0
- hubcore/store.py +172 -0
- hubcore/watcher.py +167 -0
- hubkit_cli-0.1.0.dist-info/METADATA +195 -0
- hubkit_cli-0.1.0.dist-info/RECORD +51 -0
- hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
- hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hubkit_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Live work file, derived status.json, tick (watchdog), mep adapter, hook-error scan, EOD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import config, learnings, store
|
|
16
|
+
from .orders import Orders
|
|
17
|
+
from .redact import redact
|
|
18
|
+
|
|
19
|
+
VERSION = 1
|
|
20
|
+
WORK_KINDS = ("active", "tracked", "priority", "routed", "ask")
|
|
21
|
+
STALE_MIN = 15
|
|
22
|
+
MAX_BLOCK_LINES = 25
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---- live work file: only live items; finished items move to archive_<day> ----
|
|
26
|
+
|
|
27
|
+
def work_path(sd: Path) -> Path:
|
|
28
|
+
"""File holding live work items."""
|
|
29
|
+
return sd / "work.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def work_load(sd: Path) -> list[dict]:
|
|
33
|
+
"""Live work items from the state dir."""
|
|
34
|
+
p = work_path(sd)
|
|
35
|
+
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def work_save(sd: Path, items: list[dict]) -> None:
|
|
39
|
+
"""Write live work items atomically."""
|
|
40
|
+
store.write_json_atomic(work_path(sd), items)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def next_work_id(sd: Path, items: list[dict]) -> str:
|
|
44
|
+
"""The next work id (W1, W2, ...). Ids are never reused: the highest id ever issued is kept in `work.seq`
|
|
45
|
+
(first seeded from the live items and the archive), so an archived item's id does not come back for another job.
|
|
46
|
+
Call it under the work lock."""
|
|
47
|
+
seq = sd / "work.seq"
|
|
48
|
+
try:
|
|
49
|
+
last = int(seq.read_text(encoding="utf-8").strip())
|
|
50
|
+
except ValueError, OSError:
|
|
51
|
+
archived = [r.get("id", "") for f in (sd / "archive").glob("archive_*.jsonl") for r in store.read_rows(f)[0] if r.get("type") == "work"]
|
|
52
|
+
last = max([int(x[1:]) for x in archived if re.fullmatch(r"W\d+", str(x))] + [0])
|
|
53
|
+
nxt = max([int(i["id"][1:]) for i in items] + [last]) + 1
|
|
54
|
+
store.write_atomic(seq, str(nxt))
|
|
55
|
+
return f"W{nxt}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def work_add(cfg: dict, kind: str, title: str, owner: str = "", note: str = "", estimate: int | None = None) -> dict:
|
|
59
|
+
"""Add a live work item and return it."""
|
|
60
|
+
from .redact import redact_obj
|
|
61
|
+
|
|
62
|
+
if kind not in WORK_KINDS:
|
|
63
|
+
raise ValueError(f"kind must be one of {WORK_KINDS}")
|
|
64
|
+
sd = store.state(cfg)
|
|
65
|
+
with store.locked(sd / "work.lock"):
|
|
66
|
+
items = work_load(sd)
|
|
67
|
+
item = redact_obj({"id": next_work_id(sd, items), "kind": kind, "title": title, "owner": owner,
|
|
68
|
+
"note": note, "added": time.time(),
|
|
69
|
+
**({"estimate_min": estimate, "estimates": [{"t": time.time(), "min": estimate, "source": "brief"}]} if estimate else {})})
|
|
70
|
+
items.append(item)
|
|
71
|
+
work_save(sd, items)
|
|
72
|
+
return item
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def work_done(cfg: dict, wid: str, note: str = "") -> str:
|
|
76
|
+
"""Archive a work item with a note; returns a confirmation line."""
|
|
77
|
+
sd = store.state(cfg)
|
|
78
|
+
with store.locked(sd / "work.lock"):
|
|
79
|
+
items = work_load(sd)
|
|
80
|
+
hit = next((i for i in items if i["id"] == wid), None)
|
|
81
|
+
if hit is None:
|
|
82
|
+
raise KeyError(f"no live item {wid}")
|
|
83
|
+
now = time.time()
|
|
84
|
+
day = store.day_of(cfg, now)
|
|
85
|
+
store.append_row(sd / "archive" / f"archive_{day}.jsonl",
|
|
86
|
+
{"type": "work", **hit, "outcome": "done", "done_note": note, "ts": now})
|
|
87
|
+
work_save(sd, [i for i in items if i["id"] != wid])
|
|
88
|
+
return f"{wid} archived to archive_{day}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---- adapters ----
|
|
92
|
+
|
|
93
|
+
def mep_binary(cfg: dict) -> str | None:
|
|
94
|
+
"""Resolve the mep CLI: configured path or name, PATH, then common user bin dirs (cron has a short PATH)."""
|
|
95
|
+
want = os.path.expanduser(cfg["tools"]["mep"])
|
|
96
|
+
if os.sep in want:
|
|
97
|
+
return want if os.access(want, os.X_OK) else None
|
|
98
|
+
found = shutil.which(want)
|
|
99
|
+
if found:
|
|
100
|
+
return found
|
|
101
|
+
for d in ("~/.local/bin", "/usr/local/bin", "~/bin"):
|
|
102
|
+
cand = os.path.join(os.path.expanduser(d), want)
|
|
103
|
+
if os.access(cand, os.X_OK):
|
|
104
|
+
return cand
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def mep_state(cfg: dict, prev: dict | None = None) -> dict:
|
|
109
|
+
"""mep status. A slow or failing call keeps the last good answer, labelled stale, instead of UNVERIFIED."""
|
|
110
|
+
if not cfg["mep"]["enabled"]:
|
|
111
|
+
return {"state": "off"}
|
|
112
|
+
env = dict(os.environ)
|
|
113
|
+
if cfg["mep"].get("data_dir"):
|
|
114
|
+
env["MISEENPLACE_DIR"] = os.path.expanduser(cfg["mep"]["data_dir"])
|
|
115
|
+
exe = mep_binary(cfg)
|
|
116
|
+
try:
|
|
117
|
+
if exe is None:
|
|
118
|
+
raise FileNotFoundError("mep not found")
|
|
119
|
+
r = subprocess.run([exe, "status", "--json"], capture_output=True, encoding="utf-8", errors="replace",
|
|
120
|
+
timeout=cfg["mep"]["timeout_s"], env=env)
|
|
121
|
+
titles = [i.get("title", "") for i in json.loads(r.stdout)["on_you"]]
|
|
122
|
+
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError) as e:
|
|
123
|
+
if prev and prev.get("state") in ("ok", "stale"):
|
|
124
|
+
return {**prev, "state": "stale", "why": type(e).__name__,
|
|
125
|
+
"good_at": prev.get("good_at", prev.get("at", time.time()))}
|
|
126
|
+
why = "mep CLI not found; it is optional, set [mep] enabled = false in config.toml" if exe is None else type(e).__name__
|
|
127
|
+
return {"state": "UNVERIFIED", "why": why}
|
|
128
|
+
return {"state": "ok", "count": len(titles), "titles": titles[:5], "good_at": time.time()}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_our_gate(att: dict) -> bool:
|
|
132
|
+
"""The Stop gate blocks on purpose (exit 0 with a decision); the harness logs that as a blocking error, but it
|
|
133
|
+
is the hook working, not failing."""
|
|
134
|
+
msg = att.get("blockingError")
|
|
135
|
+
text = msg.get("blockingError", "") if isinstance(msg, dict) else ""
|
|
136
|
+
return att.get("hookEvent") == "Stop" and str(text).startswith("hubkit")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def hook_errors(cfg: dict) -> dict:
|
|
140
|
+
"""Scan recent transcripts for failures of our hooks. Lives outside the hooks."""
|
|
141
|
+
cutoff = time.time() - cfg["watchdog"]["transcript_scan_hours"] * 3600
|
|
142
|
+
marker = "hubkit"
|
|
143
|
+
count, last = 0, ""
|
|
144
|
+
kinds = ("hook_non_blocking_error", "hook_cancelled", "hook_blocking_error")
|
|
145
|
+
for f in glob.glob(os.path.expanduser(cfg["paths"]["transcripts_glob"])):
|
|
146
|
+
try:
|
|
147
|
+
if os.path.getmtime(f) < cutoff:
|
|
148
|
+
continue
|
|
149
|
+
with open(f, encoding="utf-8", errors="replace") as fh:
|
|
150
|
+
for line in fh:
|
|
151
|
+
if marker in line and any(k in line for k in kinds):
|
|
152
|
+
try:
|
|
153
|
+
att = json.loads(line).get("attachment", {})
|
|
154
|
+
except ValueError:
|
|
155
|
+
continue
|
|
156
|
+
cmd = json.dumps(att)
|
|
157
|
+
if att.get("type") in kinds and marker in cmd and not _is_our_gate(att):
|
|
158
|
+
count += 1
|
|
159
|
+
last = json.loads(line).get("timestamp", last)
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
return {"count": count, "last": last}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def extensions(cfg: dict) -> list:
|
|
166
|
+
"""Load the user's ext/*.py modules; a broken one is logged and skipped."""
|
|
167
|
+
d = config.path_of(cfg, "extensions_dir", Path(cfg["_config_dir"]) / "ext")
|
|
168
|
+
mods = []
|
|
169
|
+
for f in sorted(d.glob("*.py")) if d.is_dir() else []:
|
|
170
|
+
spec = importlib.util.spec_from_file_location(f"hubkit_ext_{f.stem}", f)
|
|
171
|
+
if spec is None or spec.loader is None:
|
|
172
|
+
continue
|
|
173
|
+
mod = importlib.util.module_from_spec(spec)
|
|
174
|
+
try:
|
|
175
|
+
spec.loader.exec_module(mod)
|
|
176
|
+
mods.append(mod)
|
|
177
|
+
except Exception as e: # a broken extension must not break the tick
|
|
178
|
+
store.append_row(config.state_dir(cfg) / "hook-errors.jsonl",
|
|
179
|
+
{"where": f"ext:{f.name}", "error": repr(e)[:200]})
|
|
180
|
+
return mods
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---- status.json ----
|
|
184
|
+
|
|
185
|
+
def block_learnings(cfg: dict, lpath: Path) -> list[dict]:
|
|
186
|
+
"""Learnings for the block: seen in the last 30 days, 5 at most; addressed ones are on probation 14 days."""
|
|
187
|
+
now = time.time()
|
|
188
|
+
out = []
|
|
189
|
+
for b in learnings.recent(lpath, 40):
|
|
190
|
+
last = learnings.last_seen(b)
|
|
191
|
+
if not last or (now - time.mktime(time.strptime(last, "%Y-%m-%d"))) > 30 * 86400:
|
|
192
|
+
continue
|
|
193
|
+
addr = learnings.addressed(b)
|
|
194
|
+
tag = ""
|
|
195
|
+
if addr:
|
|
196
|
+
if (now - time.mktime(time.strptime(addr[0], "%Y-%m-%d"))) > 14 * 86400:
|
|
197
|
+
continue
|
|
198
|
+
tag = " (probation: addressed " + addr[0] + ")"
|
|
199
|
+
out.append({"cls": learnings.block_class(b) + tag, "right": learnings.field(b, "right")})
|
|
200
|
+
return out[-5:]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_status(cfg: dict) -> dict:
|
|
205
|
+
"""Assemble the status document: work, orders, mep, learnings, hook errors."""
|
|
206
|
+
sd = store.state(cfg)
|
|
207
|
+
orders = Orders(cfg)
|
|
208
|
+
lpath = learnings.default_file(cfg)
|
|
209
|
+
recent = block_learnings(cfg, lpath)
|
|
210
|
+
extra: list[str] = []
|
|
211
|
+
for mod in extensions(cfg):
|
|
212
|
+
if hasattr(mod, "status_lines"):
|
|
213
|
+
try:
|
|
214
|
+
extra += [str(x) for x in mod.status_lines(cfg)]
|
|
215
|
+
except Exception as e:
|
|
216
|
+
extra.append(f"extension {mod.__name__} failed: {type(e).__name__}")
|
|
217
|
+
return {
|
|
218
|
+
"version": VERSION, "built": time.time(), "work": work_load(sd),
|
|
219
|
+
"orders": [orders.status_line(o) for o in orders.live()],
|
|
220
|
+
"mep": mep_state(cfg, (read_status(cfg) or {}).get("mep")), "learnings": recent, "hook_errors": hook_errors(cfg),
|
|
221
|
+
"extra": extra, "jobs": job_records(cfg, orders), "watcher": watcher_lines(cfg, orders), "decisions": decisions_tail(cfg),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def decisions_tail(cfg: dict, n: int = 5) -> list[str]:
|
|
226
|
+
"""The last lines of the active slug's decisions.md (`[think] decisions`), so a ruling survives a restart."""
|
|
227
|
+
path = cfg["think"]["decisions"]
|
|
228
|
+
if not path:
|
|
229
|
+
return []
|
|
230
|
+
try:
|
|
231
|
+
lines = [x.strip() for x in Path(path).expanduser().read_text(encoding="utf-8").splitlines() if x.strip()]
|
|
232
|
+
except OSError:
|
|
233
|
+
return []
|
|
234
|
+
return [store.safe(x, 200) for x in lines[-n:]]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def watcher_lines(cfg: dict, orders: Orders) -> list[str]:
|
|
238
|
+
"""What the outside watcher finds right now, for the hub to see (a failure is logged and shows nothing)."""
|
|
239
|
+
from . import watcher
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
return [f"watcher: {a['msg']}" for a in watcher.checks(cfg, orders, time.time())]
|
|
243
|
+
except Exception as e:
|
|
244
|
+
store.append_row(config.state_dir(cfg) / "hook-errors.jsonl", {"where": "watcher_lines", "error": repr(e)[:200]})
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def job_records(cfg: dict, orders: Orders) -> list[dict]:
|
|
249
|
+
"""The live job records (see slow.py). A failure is logged and leaves the block without them."""
|
|
250
|
+
from . import slow
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
return slow.jobs(cfg, orders, time.time(), deep=True)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
store.append_row(config.state_dir(cfg) / "hook-errors.jsonl", {"where": "job_records", "error": repr(e)[:200]})
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def write_status(cfg: dict) -> dict:
|
|
260
|
+
"""Build status.json, write it and return it."""
|
|
261
|
+
sd = store.state(cfg)
|
|
262
|
+
st = build_status(cfg)
|
|
263
|
+
store.write_json_atomic(sd / "status.json", st)
|
|
264
|
+
return st
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def read_status(cfg: dict) -> dict | None:
|
|
268
|
+
"""The last status.json, or None."""
|
|
269
|
+
p = store.state(cfg, create=False) / "status.json"
|
|
270
|
+
try:
|
|
271
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
272
|
+
except (OSError, ValueError):
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def render_block(cfg: dict, orders: Orders, st: dict | None, now: float | None = None) -> str:
|
|
277
|
+
"""The text block printed at session start and with each prompt."""
|
|
278
|
+
now = now or time.time()
|
|
279
|
+
out = [f"hubkit: you are the {cfg['hub']['name']} session for {cfg['user']['name']}. "
|
|
280
|
+
"Re-read the hub SKILL.md and the overlay before acting."]
|
|
281
|
+
if st is None:
|
|
282
|
+
out.append("status: UNVERIFIED (no status.json yet; run `hubkit tick`)")
|
|
283
|
+
else:
|
|
284
|
+
age = int((now - st["built"]) / 60)
|
|
285
|
+
out.append(f"status built {age} min ago" + (f": UNVERIFIED, stale over {STALE_MIN} min" if age > STALE_MIN else ""))
|
|
286
|
+
for kind in ("priority", *(k for k in WORK_KINDS if k != "priority")):
|
|
287
|
+
if kind == "routed":
|
|
288
|
+
continue # listed below with owner, age and last-heard
|
|
289
|
+
mine = [i for i in st["work"] if i["kind"] == kind]
|
|
290
|
+
if mine:
|
|
291
|
+
ahead = ""
|
|
292
|
+
if kind == "priority" and any(i["kind"] == "active" for i in st["work"]):
|
|
293
|
+
ahead = " | AHEAD: active work is running while this waits; switch, or say why not"
|
|
294
|
+
out.append(f"{kind}: " + "; ".join(f"{i['id']} {i['title']}" for i in mine) + ahead)
|
|
295
|
+
from . import routed
|
|
296
|
+
|
|
297
|
+
out += routed.block_lines(cfg, st["work"], now)
|
|
298
|
+
m = st["mep"]
|
|
299
|
+
if m["state"] in ("ok", "stale"):
|
|
300
|
+
out.append(f"on the user (mep): {m['count']}; first: " + " | ".join(m["titles"][:3])
|
|
301
|
+
+ (f" (STALE: last good {int((now - m['good_at']) / 60)} min ago, now {m['why']})" if m["state"] == "stale" else ""))
|
|
302
|
+
elif m["state"] != "off":
|
|
303
|
+
out.append(f"mep: {m['state']} ({m.get('why', '')})")
|
|
304
|
+
he = st["hook_errors"]
|
|
305
|
+
out.append(f"hook errors in scan window: {he['count']}" + (f" (last {he['last']})" if he["last"] else ""))
|
|
306
|
+
from . import gate, slow
|
|
307
|
+
|
|
308
|
+
out += slow.block_lines(st.get("jobs", []), now)
|
|
309
|
+
if st.get("jobs") and not gate.watch_live(cfg):
|
|
310
|
+
out.append(f"hubkit watch is NOT running with live jobs. NEXT: start in the background: {gate.WATCH_CMD}")
|
|
311
|
+
if st.get("decisions"):
|
|
312
|
+
out.append("recent decisions (decisions.md):")
|
|
313
|
+
out += [" " + x for x in st["decisions"]]
|
|
314
|
+
out += st.get("watcher", [])
|
|
315
|
+
out += st.get("extra", [])
|
|
316
|
+
if st["learnings"]:
|
|
317
|
+
out.append("recent learnings:")
|
|
318
|
+
out += [f" {x['cls']}: {x['right'][:140]}" for x in st["learnings"]]
|
|
319
|
+
live = orders.live()
|
|
320
|
+
out.append(f"open orders: {len(live)}")
|
|
321
|
+
out += [" " + orders.status_line(o, now) + f' | asked: "{o["quote"][:100]}"' for o in live]
|
|
322
|
+
cap = MAX_BLOCK_LINES
|
|
323
|
+
if len(out) > cap:
|
|
324
|
+
out = out[: cap - 1] + [f"... {len(out) - cap + 1} more lines cut; run hubkit status"]
|
|
325
|
+
return "\n".join(out)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---- tick: watchdog ----
|
|
329
|
+
|
|
330
|
+
def _gap(n: int) -> float:
|
|
331
|
+
return min(1800.0, 600.0 * 2 ** max(n - 1, 0)) if n else 0.0
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def held_rows(orders: Orders, oid: str) -> list[dict]:
|
|
335
|
+
"""Ledger `hold` rows for an order."""
|
|
336
|
+
return [r for r in store.read_rows(orders.path)[0] if r.get("id") == oid and r.get("op") == "hold"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def evaluate(cfg: dict, orders: Orders, hook_err: dict, now: float) -> list[dict]:
|
|
340
|
+
"""Watchdog conditions that hold now, as alert dicts."""
|
|
341
|
+
found: list[dict] = []
|
|
342
|
+
ttl = float(orders.intents.get("intake", {}).get("unconfirmed_ttl_min", 30))
|
|
343
|
+
held_max = float(orders.intents.get("intake", {}).get("held_review_min", 120))
|
|
344
|
+
for o in orders.live():
|
|
345
|
+
age_min = (now - o["opened"]) / 60
|
|
346
|
+
if o["state"] == "unconfirmed" and age_min > ttl:
|
|
347
|
+
found.append({"key": f"{o['id']}:unconfirmed_expired", "action": ("expire", o["id"]),
|
|
348
|
+
"msg": f"{o['id']} {o['kind']}: unconfirmed for {int(age_min)} min, expired"})
|
|
349
|
+
if o["state"] == "held":
|
|
350
|
+
since = max([o["opened"]] + [r["ts"] for r in held_rows(orders, o["id"])])
|
|
351
|
+
if (now - since) / 60 > held_max:
|
|
352
|
+
found.append({"key": f"{o['id']}:held_long",
|
|
353
|
+
"msg": f"{o['id']} {o['kind']}: held for {int((now - since) / 60)} min; resume or cancel it"})
|
|
354
|
+
if o["state"] != "open": # held and unconfirmed orders have no clock
|
|
355
|
+
continue
|
|
356
|
+
spec = orders.intents["kinds"].get(o["kind"], {})
|
|
357
|
+
age = (now - o["opened"]) / 60
|
|
358
|
+
if not (o["moves"] or o["results"]) and age > spec.get("first_action_min", 5):
|
|
359
|
+
found.append({"key": f"{o['id']}:no_first_action",
|
|
360
|
+
"msg": f"{o['id']} {o['kind']}: no first action after {int(age)} min"})
|
|
361
|
+
idle = (now - orders.last_movement(o)) / 60
|
|
362
|
+
if idle > spec.get("stall_min", 10):
|
|
363
|
+
found.append({"key": f"{o['id']}:stalled",
|
|
364
|
+
"msg": f"{o['id']} {o['kind']}: no movement for {int(idle)} min"})
|
|
365
|
+
why = orders.blocker_problem(o, now)
|
|
366
|
+
if why:
|
|
367
|
+
found.append({"key": f"{o['id']}:blocker:{'nofix' if not o['blocker']['fix'] else 'stale'}",
|
|
368
|
+
"msg": f"{why}. NEXT: start the fix on a free owner and run: hubkit order block {o['id']} --on \"..\" --fix \"..\" "
|
|
369
|
+
f"--holder <who>; when it ran: hubkit order result {o['id']} --note \"..\""})
|
|
370
|
+
if o.get("deadline") and now > o["deadline"]:
|
|
371
|
+
found.append({"key": f"{o['id']}:deadline",
|
|
372
|
+
"msg": f"{o['id']} {o['kind']}: past its deadline {store.clock(cfg, o['deadline'])}"})
|
|
373
|
+
try:
|
|
374
|
+
from . import routed, slow, watcher
|
|
375
|
+
|
|
376
|
+
found += routed.alerts(cfg, now)
|
|
377
|
+
found += slow.alerts(cfg, orders, now)
|
|
378
|
+
found += watcher.checks(cfg, orders, now)
|
|
379
|
+
except Exception as e: # routed tracking must not break the tick
|
|
380
|
+
found.append({"key": "routed:failed", "msg": f"routed-work check failed: {type(e).__name__}"})
|
|
381
|
+
if hook_err["count"]:
|
|
382
|
+
found.append({"key": f"hook_errors:{hook_err['last']}",
|
|
383
|
+
"msg": f"{hook_err['count']} hubkit hook failures in transcripts (last {hook_err['last']})"})
|
|
384
|
+
for mod in extensions(cfg):
|
|
385
|
+
if hasattr(mod, "tick_checks"):
|
|
386
|
+
try:
|
|
387
|
+
found += [{"key": str(a["key"]), "msg": str(a["msg"])} for a in mod.tick_checks(cfg, orders.live())]
|
|
388
|
+
except Exception as e:
|
|
389
|
+
found.append({"key": f"ext:{mod.__name__}", "msg": f"extension failed: {type(e).__name__}"})
|
|
390
|
+
return found
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def routed_pass(cfg: dict, now: float) -> list[dict]:
|
|
394
|
+
"""Heard-from scan of the hub transcript, then one gh read per PR. Failures never reach the tick."""
|
|
395
|
+
from . import hooks, routed
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
b = hooks.read_bind(cfg)
|
|
399
|
+
path = routed.transcript_of(cfg, b["session_id"]) if hooks.bind_alive(b) else None
|
|
400
|
+
if path:
|
|
401
|
+
routed.scan_transcript(cfg, path)
|
|
402
|
+
routed.agent_activity(cfg, now)
|
|
403
|
+
return routed.refresh_prs(cfg, now)
|
|
404
|
+
except Exception as e:
|
|
405
|
+
store.append_row(config.state_dir(cfg) / "hook-errors.jsonl", {"where": "routed_pass", "error": repr(e)[:200]})
|
|
406
|
+
return []
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def tick(cfg: dict, notify: bool = True, now: float | None = None) -> list[dict]:
|
|
410
|
+
"""One watchdog pass. Returns the NEW alerts (back-off applied)."""
|
|
411
|
+
now = now or time.time()
|
|
412
|
+
sd = store.state(cfg)
|
|
413
|
+
with store.locked(sd / "status.lock", blocking=False) as got:
|
|
414
|
+
if not got:
|
|
415
|
+
return []
|
|
416
|
+
orders = Orders(cfg)
|
|
417
|
+
orders.repair_archive()
|
|
418
|
+
closed = routed_pass(cfg, now)
|
|
419
|
+
apath = sd / "alerts.jsonl"
|
|
420
|
+
prior = store.read_rows(apath)[0]
|
|
421
|
+
new = []
|
|
422
|
+
for a in [*closed, *evaluate(cfg, orders, hook_errors(cfg), now)]:
|
|
423
|
+
if a.get("action"):
|
|
424
|
+
orders.expire(a["action"][1], "unconfirmed order expired (no confirmation)")
|
|
425
|
+
same = [r for r in prior if r.get("key") == a["key"]]
|
|
426
|
+
last = max([r["ts"] for r in same], default=0.0)
|
|
427
|
+
if now - last >= _gap(len(same)):
|
|
428
|
+
store.append_row(apath, {**a, "ts": now})
|
|
429
|
+
new.append(a)
|
|
430
|
+
write_status(cfg)
|
|
431
|
+
store.touch_seen(sd, "tick")
|
|
432
|
+
if notify and new:
|
|
433
|
+
from . import watcher
|
|
434
|
+
|
|
435
|
+
watcher.notify(cfg, new)
|
|
436
|
+
return new
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def watch(cfg: dict, interval: float, max_cycles: int) -> int:
|
|
440
|
+
"""Loop tick; exit 0 on the first alert (the exit wakes the hub), 3 after max_cycles.
|
|
441
|
+
|
|
442
|
+
An alert a concurrent cron tick already logged since the watch started also ends the watch, so
|
|
443
|
+
the hub is woken even when the cron run consumed the back-off slot first.
|
|
444
|
+
"""
|
|
445
|
+
from . import gate, hooks
|
|
446
|
+
|
|
447
|
+
if gate.watch_live(cfg):
|
|
448
|
+
print("watch already running; this one exits without waking anyone")
|
|
449
|
+
return 4
|
|
450
|
+
pidfile = store.state(cfg) / "watch.pid"
|
|
451
|
+
store.write_atomic(pidfile, f"{os.getpid()} {hooks.proc_start(os.getpid())}")
|
|
452
|
+
try:
|
|
453
|
+
return _watch_loop(cfg, interval, max_cycles)
|
|
454
|
+
finally:
|
|
455
|
+
pidfile.unlink(missing_ok=True)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _watch_loop(cfg: dict, interval: float, max_cycles: int) -> int:
|
|
459
|
+
began = time.time()
|
|
460
|
+
for i in range(max_cycles):
|
|
461
|
+
tick(cfg, notify=True)
|
|
462
|
+
fresh = [r for r in store.read_rows(store.state(cfg) / "alerts.jsonl")[0] if r["ts"] >= began]
|
|
463
|
+
if fresh:
|
|
464
|
+
for r in fresh:
|
|
465
|
+
print("ALERT " + r["msg"])
|
|
466
|
+
return 0
|
|
467
|
+
if i + 1 < max_cycles:
|
|
468
|
+
time.sleep(interval)
|
|
469
|
+
return 3
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---- EOD ----
|
|
473
|
+
|
|
474
|
+
def glance(cfg: dict, day: str) -> list[str]:
|
|
475
|
+
"""The day-at-a-glance lines of the end-of-day review."""
|
|
476
|
+
sd = store.state(cfg)
|
|
477
|
+
orders = Orders(cfg)
|
|
478
|
+
arch = store.read_rows(sd / "archive" / f"archive_{day}.jsonl")[0]
|
|
479
|
+
ao = [r for r in arch if r.get("type") == "order"]
|
|
480
|
+
aw = [r for r in arch if r.get("type") == "work"]
|
|
481
|
+
m = orders.metrics(day)
|
|
482
|
+
live = orders.live()
|
|
483
|
+
alerts = [r for r in store.read_rows(sd / "alerts.jsonl")[0] if store.day_of(cfg, r["ts"]) == day]
|
|
484
|
+
rev = learnings.review(learnings.default_file(cfg), day)
|
|
485
|
+
held = [r for r in store.read_rows(orders.path)[0]
|
|
486
|
+
if r.get("op") == "hold" and store.day_of(cfg, r["ts"]) == day]
|
|
487
|
+
return [
|
|
488
|
+
f"Day at a glance {day}",
|
|
489
|
+
f"orders opened {m['orders']}, closed {sum(r['outcome'] == 'closed' for r in ao)}, "
|
|
490
|
+
f"cancelled {sum(r['outcome'] == 'cancelled' for r in ao)}, held {len(held)}, still open {len(live)}",
|
|
491
|
+
f"chases {m['chases']} ({m['chases_per_order']} per order)",
|
|
492
|
+
f"work items finished {len(aw)}, live now {len(work_load(sd))}",
|
|
493
|
+
f"watchdog alerts {len(alerts)}",
|
|
494
|
+
f"hook errors (scan window) {hook_errors(cfg)['count']}",
|
|
495
|
+
"Learnings vs archive:",
|
|
496
|
+
f" new today: {', '.join(rev['new']) or '-'}",
|
|
497
|
+
f" repeated today: {', '.join(rev['repeated']) or '-'}",
|
|
498
|
+
f" addressed and holding: {', '.join(rev['holding']) or '-'}",
|
|
499
|
+
f" addressed but repeated (not fixed): {', '.join(rev['regressed']) or '-'}",
|
|
500
|
+
f" seen 2+ times, not addressed (promote or fix): {', '.join(rev['unaddressed']) or '-'}",
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def reflection(cfg: dict, day: str) -> list[str]:
|
|
505
|
+
"""Self-reflection from facts, no model: orders that needed chasing or stalled, still open."""
|
|
506
|
+
orders = Orders(cfg)
|
|
507
|
+
from . import slow
|
|
508
|
+
|
|
509
|
+
lines = ["Self-reflection (from the record):"]
|
|
510
|
+
for o in orders.all().values():
|
|
511
|
+
if store.day_of(cfg, o["opened"]) != day:
|
|
512
|
+
continue
|
|
513
|
+
if o["chases"]:
|
|
514
|
+
lines.append(f" chased {o['chases']}x: {o['id']} {o['kind']} (first words: \"{o['quote'][:80]}\")")
|
|
515
|
+
if o["state"] in LIVE:
|
|
516
|
+
lines.append(f" still {o['state']}: {o['id']} {o['kind']}")
|
|
517
|
+
if len(lines) == 1:
|
|
518
|
+
lines.append(" no order needed a chase and none is left open")
|
|
519
|
+
return lines + slow.reflection_lines(cfg, day)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
LIVE = ("open", "unconfirmed", "held") # expired orders are finished
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def eod(cfg: dict, day: str, note: str = "") -> Path:
|
|
526
|
+
"""Write and return the end-of-day review file for `day`."""
|
|
527
|
+
sd = store.state(cfg)
|
|
528
|
+
Orders(cfg).repair_archive()
|
|
529
|
+
body = glance(cfg, day) + [""] + reflection(cfg, day)
|
|
530
|
+
if note:
|
|
531
|
+
body += ["", "Self-improvement note:", " " + learnings.clean(note)]
|
|
532
|
+
out = sd / "eod" / f"eod_{day}.md"
|
|
533
|
+
store.write_atomic(out, "\n".join(redact(x)[0] for x in body) + "\n")
|
|
534
|
+
store.touch_seen(sd, "eod")
|
|
535
|
+
return out
|