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.
Files changed (51) hide show
  1. hubcore/__init__.py +1 -0
  2. hubcore/cli.py +273 -0
  3. hubcore/compat.py +187 -0
  4. hubcore/config.py +107 -0
  5. hubcore/data/hub/LICENSE +202 -0
  6. hubcore/data/hub/SKILL.md +125 -0
  7. hubcore/data/hub/config.example.toml +84 -0
  8. hubcore/data/hub/docs/architecture.md +133 -0
  9. hubcore/data/hub/docs/commands.md +66 -0
  10. hubcore/data/hub/docs/configuration.md +151 -0
  11. hubcore/data/hub/docs/troubleshooting.md +21 -0
  12. hubcore/data/hub/intents.example.toml +74 -0
  13. hubcore/data/hub/references/design-sources.md +14 -0
  14. hubcore/data/hub/references/research.md +49 -0
  15. hubcore/data/hub/scripts/hubcore/__init__.py +1 -0
  16. hubcore/data/hub/scripts/hubcore/cli.py +273 -0
  17. hubcore/data/hub/scripts/hubcore/compat.py +187 -0
  18. hubcore/data/hub/scripts/hubcore/config.py +107 -0
  19. hubcore/data/hub/scripts/hubcore/gate.py +145 -0
  20. hubcore/data/hub/scripts/hubcore/hooks.py +326 -0
  21. hubcore/data/hub/scripts/hubcore/install.py +66 -0
  22. hubcore/data/hub/scripts/hubcore/learnings.py +232 -0
  23. hubcore/data/hub/scripts/hubcore/lessons.py +168 -0
  24. hubcore/data/hub/scripts/hubcore/orders.py +508 -0
  25. hubcore/data/hub/scripts/hubcore/redact.py +89 -0
  26. hubcore/data/hub/scripts/hubcore/routed.py +1169 -0
  27. hubcore/data/hub/scripts/hubcore/slow.py +531 -0
  28. hubcore/data/hub/scripts/hubcore/status.py +535 -0
  29. hubcore/data/hub/scripts/hubcore/store.py +172 -0
  30. hubcore/data/hub/scripts/hubcore/watcher.py +167 -0
  31. hubcore/data/hub/scripts/hubkit +37 -0
  32. hubcore/data/hub/scripts/learn.py +84 -0
  33. hubcore/data/hub-mep/SKILL.md +35 -0
  34. hubcore/data/hubkit-setup/SKILL.md +50 -0
  35. hubcore/gate.py +145 -0
  36. hubcore/hooks.py +326 -0
  37. hubcore/install.py +66 -0
  38. hubcore/learnings.py +232 -0
  39. hubcore/lessons.py +168 -0
  40. hubcore/orders.py +508 -0
  41. hubcore/redact.py +89 -0
  42. hubcore/routed.py +1169 -0
  43. hubcore/slow.py +531 -0
  44. hubcore/status.py +535 -0
  45. hubcore/store.py +172 -0
  46. hubcore/watcher.py +167 -0
  47. hubkit_cli-0.1.0.dist-info/METADATA +195 -0
  48. hubkit_cli-0.1.0.dist-info/RECORD +51 -0
  49. hubkit_cli-0.1.0.dist-info/WHEEL +4 -0
  50. hubkit_cli-0.1.0.dist-info/entry_points.txt +2 -0
  51. 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