hubkit-cli 0.2.0__tar.gz → 0.2.1__tar.gz

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 (59) hide show
  1. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CHANGELOG.md +8 -0
  2. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/PKG-INFO +3 -2
  3. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/README.md +2 -1
  4. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/architecture.md +2 -2
  5. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/commands.md +3 -2
  6. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/pyproject.toml +1 -1
  7. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/cli.py +6 -1
  8. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/gate.py +1 -1
  9. hubkit_cli-0.2.1/scripts/hubcore/ledger.py +248 -0
  10. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/lessons.py +284 -57
  11. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/status.py +6 -2
  12. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/run_reverts.py +7 -0
  13. hubkit_cli-0.2.1/tests/test_ledger.py +61 -0
  14. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_tool_guards.py +25 -0
  15. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/.gitignore +0 -0
  16. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CLAUDE.md +0 -0
  17. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CONTRIBUTING.md +0 -0
  18. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/LICENSE +0 -0
  19. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/SECURITY.md +0 -0
  20. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/SKILL.md +0 -0
  21. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/config.example.toml +0 -0
  22. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/configuration.md +0 -0
  23. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/troubleshooting.md +0 -0
  24. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/intents.example.toml +0 -0
  25. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/references/design-sources.md +0 -0
  26. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/references/research.md +0 -0
  27. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/__init__.py +0 -0
  28. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/compat.py +0 -0
  29. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/config.py +0 -0
  30. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/hooks.py +0 -0
  31. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/inbound.py +0 -0
  32. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/install.py +0 -0
  33. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/learnings.py +0 -0
  34. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/orders.py +0 -0
  35. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/redact.py +0 -0
  36. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/routed.py +0 -0
  37. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/slow.py +0 -0
  38. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/store.py +0 -0
  39. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/trust.py +0 -0
  40. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/watcher.py +0 -0
  41. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubkit +0 -0
  42. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/learn.py +0 -0
  43. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/skills/hub-mep/SKILL.md +0 -0
  44. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/skills/hubkit-setup/SKILL.md +0 -0
  45. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/conftest.py +0 -0
  46. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_blockers.py +0 -0
  47. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_compat.py +0 -0
  48. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_guards2.py +0 -0
  49. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_hubkit.py +0 -0
  50. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_inbound.py +0 -0
  51. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_install.py +0 -0
  52. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_learning_rules.py +0 -0
  53. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_lessons.py +0 -0
  54. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_own_work.py +0 -0
  55. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_peer_reports.py +0 -0
  56. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_review_chain.py +0 -0
  57. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_slow.py +0 -0
  58. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_state_rules.py +0 -0
  59. {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_watcher.py +0 -0
@@ -4,6 +4,14 @@ All notable changes to this project are recorded here. The format follows [Keep
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.1] - 2026-11-06
8
+
9
+ ### Added
10
+ - `hubkit find WORD [--since]` and, in `digest` and `eod`, the PRs the user's account opened plus what each peer session was asked (`hubcore/ledger.py`; read only on peers, prompts redacted, `gh` failures shown).
11
+
12
+ ### Changed
13
+ - The unpushed-work Stop reason only counts files changed, and commits made, since the turn began; older dirt in the checkout no longer holds a report.
14
+
7
15
  ## [0.2.0] - 2026-11-06
8
16
 
9
17
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hubkit-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only.
5
5
  Project-URL: Repository, https://github.com/ankitkpandey1/hubkit
6
6
  Project-URL: Documentation, https://github.com/ankitkpandey1/hubkit/tree/main/docs
@@ -47,7 +47,7 @@ In a live hub, the prompt hook opens that order itself from your sentence, and t
47
47
  - **Watchdog and wake-up.** `hubkit tick` (cron) and `hubkit watch` (a background process) raise alerts with back-off; a watch exit wakes an idle hub.
48
48
  - **Stop gate.** The Stop hook refuses to let the turn end while the hub owes something (no watch running, a stalled item, an unbacked verdict, a question to you that blocks nothing, a "tests pass" claim with no command run, a PR number with no link, an approval on red CI, built work not pushed).
49
49
  - **Tool guards.** An optional warn-only PreToolUse hook (`hubkit hooks --merge-guard`) warns about an unordered merge, a post without your typed yes, a banned command, stopping an agent and an unmarked decision card. It never denies.
50
- - **Status, digest, end-of-day review.** A status block at session start and on each prompt, a daily archive, `hubkit digest` for when you come back, `hubkit eod`, a per-class `hubkit trust` ledger, and `hubkit inbound ingest` to track asks from other people so none goes unanswered.
50
+ - **Status, digest, end-of-day review.** A status block at session start and on each prompt, a daily archive, `hubkit digest` for when you come back (it also lists the PRs your account opened and what each peer session was asked), `hubkit find WORD` to search orders, work, asks, peer prompts and your PRs, `hubkit eod`, a per-class `hubkit trust` ledger, and `hubkit inbound ingest` to track asks from other people so none goes unanswered.
51
51
 
52
52
  `SKILL.md` is the behaviour contract the hub session reads. This file is the operator view.
53
53
 
@@ -113,6 +113,7 @@ hubkit order open --kind hotfix --quote "hotfix the billing bug now" # or let
113
113
  hubkit order ls
114
114
  hubkit work add --kind active --title "triage the flaky test"
115
115
  hubkit status # the status block
116
+ hubkit find billing --since 2d # search orders, asks, peer prompts, your PRs
116
117
  python3 ~/.claude/skills/hub/scripts/learn.py add --class ask-before-merge --gate G5 \
117
118
  --quote "I told you to wait for the review" --situation "..." --wrong "..." --right "..." --evidence "..."
118
119
  ```
@@ -33,7 +33,7 @@ In a live hub, the prompt hook opens that order itself from your sentence, and t
33
33
  - **Watchdog and wake-up.** `hubkit tick` (cron) and `hubkit watch` (a background process) raise alerts with back-off; a watch exit wakes an idle hub.
34
34
  - **Stop gate.** The Stop hook refuses to let the turn end while the hub owes something (no watch running, a stalled item, an unbacked verdict, a question to you that blocks nothing, a "tests pass" claim with no command run, a PR number with no link, an approval on red CI, built work not pushed).
35
35
  - **Tool guards.** An optional warn-only PreToolUse hook (`hubkit hooks --merge-guard`) warns about an unordered merge, a post without your typed yes, a banned command, stopping an agent and an unmarked decision card. It never denies.
36
- - **Status, digest, end-of-day review.** A status block at session start and on each prompt, a daily archive, `hubkit digest` for when you come back, `hubkit eod`, a per-class `hubkit trust` ledger, and `hubkit inbound ingest` to track asks from other people so none goes unanswered.
36
+ - **Status, digest, end-of-day review.** A status block at session start and on each prompt, a daily archive, `hubkit digest` for when you come back (it also lists the PRs your account opened and what each peer session was asked), `hubkit find WORD` to search orders, work, asks, peer prompts and your PRs, `hubkit eod`, a per-class `hubkit trust` ledger, and `hubkit inbound ingest` to track asks from other people so none goes unanswered.
37
37
 
38
38
  `SKILL.md` is the behaviour contract the hub session reads. This file is the operator view.
39
39
 
@@ -99,6 +99,7 @@ hubkit order open --kind hotfix --quote "hotfix the billing bug now" # or let
99
99
  hubkit order ls
100
100
  hubkit work add --kind active --title "triage the flaky test"
101
101
  hubkit status # the status block
102
+ hubkit find billing --since 2d # search orders, asks, peer prompts, your PRs
102
103
  python3 ~/.claude/skills/hub/scripts/learn.py add --class ask-before-merge --gate G5 \
103
104
  --quote "I told you to wait for the review" --situation "..." --wrong "..." --right "..." --evidence "..."
104
105
  ```
@@ -114,11 +114,11 @@ The Stop hook blocks the end of the turn, with the exact next action, while any
114
114
  5. A blocker is not being solved (no fix running, fix past its limit), a STOP-LOSS has no result since, or the final message calls an open order blocked/down/failed/waiting while no order has a fix on record.
115
115
  6. The final message says done while his newest prompt is newer than the last `work done` and the last `hubkit asks ls`; or it calls something gone/lost/absent, or "a person's call", with no Read/Grep/Glob/Bash call in the turn (transcript read).
116
116
  7. The final message ends on a question (or holds a `stop_guard.phrases` phrase) and no `ask` work item says anything really waits on the user. A question that is really blocked on him must use the card tool, not prose. An `ask` item asked 3 times with no answer also holds the turn: take the safest default and record it.
117
- 8. The final message says "tests pass", "it works" or "is deployed" (`[stop_gate] verified_words`) but the turn ran no Bash command; names a PR or issue number with no link (`[stop_gate] full_links`); approves a PR while CI at its head is not green; or reports built work while the repo at the hook's `cwd` has uncommitted changes or unpushed commits.
117
+ 8. The final message says "tests pass", "it works" or "is deployed" (`[stop_gate] verified_words`) but the turn ran no Bash command; names a PR or issue number with no link (`[stop_gate] full_links`); approves a PR while CI at its head is not green; or reports built work while the repo at the hook's `cwd` has uncommitted changes or unpushed commits made since the turn began (older dirt in the checkout does not count).
118
118
 
119
119
  ## Digest and trust
120
120
 
121
- `hubkit digest [--since 12h]` prints what finished, which orders closed, the latest alerts and what waits on the user. `hubkit trust record --class C --outcome accept|edit|reject` appends to `trust.jsonl`; `trust ls` shows each class as `auto` (enough accepts and nothing else) or `ask` (propose first), and the session status block names the classes that earned `auto` and those he rejected.
121
+ `hubkit digest [--since 12h]` prints what finished, which orders closed, the latest alerts, what waits on the user, the PRs his account opened and what each peer session was asked (`ledger.py`: peer transcripts are read only and prompts are redacted; a `gh` failure is shown, not hidden). `hubkit find WORD` searches the same record plus orders, work, the archive and his asks. `hubkit trust record --class C --outcome accept|edit|reject` appends to `trust.jsonl`; `trust ls` shows each class as `auto` (enough accepts and nothing else) or `ask` (propose first), and the session status block names the classes that earned `auto` and those he rejected.
122
122
 
123
123
  ## The learn loop
124
124
 
@@ -16,10 +16,11 @@
16
16
  | `hubkit order open --kind K --quote "..." [--unconfirmed]` | Open an order by hand. |
17
17
  | `hubkit order confirm ID` | Turn an unconfirmed order into an open one. |
18
18
  | `hubkit order move ID --note "..." [--estimate N]` | Record real movement. The first move also records the order's estimate (N minutes, else the usual time for its kind; printed when defaulted). |
19
- | `hubkit digest [--since 12h]` | What finished, which orders closed, the latest alerts and what waits on the user, for a user coming back. |
19
+ | `hubkit digest [--since 12h]` | What finished, which orders closed, the latest alerts, what waits on the user, the PRs his account opened and what each peer session was asked, for a user coming back. |
20
20
  | `hubkit inbound ingest [--file F]` | Turns the inbox watcher's lines into `reply` work items (one per asker, no duplicates). |
21
21
  | `hubkit trust record --class C --outcome O` / `trust ls` | Per-class record of approved and corrected outcomes; a class with repeated corrections is shown as ask-first. |
22
22
  | `hubkit work verify ID` | Stamp the item as checked against reality just now. An `active` or `priority` item not verified for `watcher.verify_stale_min` raises an alert. It runs no check itself. |
23
+ | `hubkit find WORD [--since 2d]` | Searches orders, work, the archive, his asks, peer session prompts and the PRs his account opened (title, body, changed file paths). Needs `gh` for the PR part; a `gh` failure is printed as UNKNOWN, never skipped. Read only on peer sessions; prompts are redacted. |
23
24
  | `hubkit asks ls [--since 12h]` | His recent prompts (from `recent-prompts.jsonl`), one line each, newest last. Marks the asks as re-read for the Stop gate. |
24
25
  | `hubkit order block ID --on "..." [--fix "..."] [--holder who]` | Record what blocks an order and the fix running now (a second call with the same `--on` adds the fix). No fix, or a fix older than `blocker_fix_min` with no result, alerts and holds the Stop gate. |
25
26
  | `hubkit order result ID --note "..."` | Record the result of an action: movement, clears the blocker and any STOP-LOSS. Notes from `order move` do neither. |
@@ -37,7 +38,7 @@
37
38
  | `hubkit work ls` | List live items. |
38
39
  | `hubkit tick [--if-bound] [--no-notify]` | One watchdog pass; prints `ALERT ...` lines. `--if-bound` is a no-op unless a hub is bound or an order is open (for cron). |
39
40
  | `hubkit watch [--interval S] [--max-cycles N]` | Loop `tick` (defaults 300 s, 12 cycles). Exits 0 printing `ALERT ...`, 3 after the cycles, 4 if another watch runs. |
40
- | `hubkit eod [--day D] [--note ".."]` | Write and print the end-of-day review. |
41
+ | `hubkit eod [--day D] [--note ".."]` | Write and print the end-of-day review, including the day's PRs and what each peer session was asked. |
41
42
  | `hubkit doctor [--check-clean]` | Print config, state, bind, hook heartbeats, ledger health and a count of secret-shaped strings in state. Warns when your `intents.toml` lacks keys the example has, when a hubkit hook is registered twice in `settings.json` (read only), and when no scheduler entry runs `hubkit eod`. `--check-clean` also lists checkout files containing a `private.terms` word. Exit 1 on a problem. |
42
43
  | `hubkit hook EVENT` | Run one hook (`session`, `prompt`, `answer`, `routed`, `stop`, `record`, `pretool`); reads the payload from stdin. For Claude Code, not for people. |
43
44
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hubkit-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Session supervisor for Claude Code: hooks-based multi-agent orchestration with order clocks, routed-work tracking, a watchdog and a stop gate, standard library only."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -11,7 +11,7 @@ import time
11
11
  import tomllib
12
12
  from pathlib import Path
13
13
 
14
- from . import compat, config, hooks, inbound, install, lessons, routed, status, store, trust, watcher
14
+ from . import compat, config, hooks, inbound, install, ledger, lessons, routed, status, store, trust, watcher
15
15
  from .orders import Orders
16
16
  from .redact import count_hits_text
17
17
 
@@ -265,6 +265,9 @@ def main(argv: list[str] | None = None) -> int:
265
265
 
266
266
  st = sub.add_parser("status")
267
267
  st.add_argument("--json", action="store_true")
268
+ fd = sub.add_parser("find")
269
+ fd.add_argument("word")
270
+ fd.add_argument("--since", default="2d", help="how far back for asks, peer prompts and PRs, for example 12h, 2d")
268
271
  dg = sub.add_parser("digest")
269
272
  dg.add_argument("--since", default="12h", help="how far back, for example 30m, 12h, 2d")
270
273
  e = sub.add_parser("eod")
@@ -308,6 +311,8 @@ def main(argv: list[str] | None = None) -> int:
308
311
  elif a.cmd == "status":
309
312
  st_ = status.write_status(cfg)
310
313
  print(json.dumps(st_, indent=1) if a.json else status.render_block(cfg, Orders(cfg), st_))
314
+ elif a.cmd == "find":
315
+ print("\n".join(ledger.find(cfg, a.word, a.since)))
311
316
  elif a.cmd == "digest":
312
317
  print("\n".join(status.digest(cfg, a.since)))
313
318
  elif a.cmd == "eod":
@@ -147,7 +147,7 @@ def stop_reasons(cfg: dict, last: str, now: float | None = None, transcript: str
147
147
  ci_bad = ci_not_green(cfg, last) # before prompt_lines: a reviewed item closes there
148
148
  reasons = routed.prompt_lines(cfg, now)
149
149
  reasons += blocker_reasons(cfg, last, now)
150
- if r := lessons.unpushed_work(last, cwd):
150
+ if r := lessons.unpushed_work(last, cwd, transcript):
151
151
  reasons.append(r)
152
152
  reasons += [r for r in (lessons.unswept_asks(cfg, last, now), lessons.unchecked_claim(cfg, last, transcript), lessons.unrun_claim(cfg, last, transcript)) if r]
153
153
  if refs := lessons.bare_refs(cfg, last):
@@ -0,0 +1,248 @@
1
+ """The day's whole record, not only what the hub routed: PRs the user's account opened, what each peer session was asked,
2
+ and a word search over all of it. Read-only on peers; a gh failure is shown, never swallowed.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import glob
8
+ import json
9
+ import os
10
+ import subprocess
11
+ import time
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from . import config, hooks, routed, store
16
+ from .orders import Orders
17
+ from .redact import redact
18
+ from .status import work_load
19
+
20
+ PRS_TTL_S = 600
21
+ SKIP_PREFIXES = ("<task-notification", "<system-reminder", "<command-")
22
+
23
+
24
+ def _gh_json(cfg: dict, args: list[str]) -> object:
25
+ """Run gh and parse its JSON; raises RuntimeError with the reason when gh is missing, fails or prints no JSON."""
26
+ exe = routed._gh(cfg)
27
+ if exe is None:
28
+ raise RuntimeError("gh not found (set [tools] gh)")
29
+ try:
30
+ r = subprocess.run(
31
+ [exe, *args],
32
+ capture_output=True,
33
+ encoding="utf-8",
34
+ errors="replace",
35
+ timeout=30,
36
+ check=False,
37
+ )
38
+ except (OSError, subprocess.SubprocessError) as e:
39
+ raise RuntimeError(f"gh did not run: {e}") from e
40
+ if r.returncode != 0:
41
+ raise RuntimeError(f"gh {args[0]} failed: {redact(r.stderr.strip())[0][:160]}")
42
+ try:
43
+ return json.loads(r.stdout)
44
+ except ValueError as e:
45
+ raise RuntimeError(f"gh {args[0]} printed no JSON") from e
46
+
47
+
48
+ def fetch_prs(cfg: dict, day: str) -> dict:
49
+ """PRs the user's account opened on or after `day`: number, title, url, body, changed file paths. {'prs': [...], 'error': str}."""
50
+ try:
51
+ found = _gh_json(
52
+ cfg,
53
+ [
54
+ "search",
55
+ "prs",
56
+ "--author",
57
+ "@me",
58
+ "--created",
59
+ f">={day}",
60
+ "--limit",
61
+ "50",
62
+ "--json",
63
+ "number,title,url,body,repository,createdAt",
64
+ ],
65
+ )
66
+ prs = []
67
+ for p in found if isinstance(found, list) else []:
68
+ view = _gh_json(cfg, ["pr", "view", p["url"], "--json", "files"])
69
+ files = (
70
+ [f.get("path", "") for f in view.get("files") or []]
71
+ if isinstance(view, dict)
72
+ else []
73
+ )
74
+ prs.append(
75
+ {
76
+ "number": p["number"],
77
+ "title": p["title"],
78
+ "url": p["url"],
79
+ "body": p.get("body") or "",
80
+ "files": files,
81
+ }
82
+ )
83
+ return {"prs": prs, "error": ""}
84
+ except RuntimeError as e:
85
+ return {"prs": [], "error": str(e)}
86
+
87
+
88
+ def prs_since(cfg: dict, day: str) -> dict:
89
+ """fetch_prs cached for ten minutes in the state dir (the cache file is per day)."""
90
+ cache = store.state(cfg) / f"ledger-prs-{day}.json"
91
+ try:
92
+ hit = json.loads(cache.read_text(encoding="utf-8"))
93
+ if time.time() - hit["at"] < PRS_TTL_S and not hit["data"]["error"]:
94
+ return hit["data"]
95
+ except OSError, ValueError, KeyError:
96
+ pass
97
+ data = fetch_prs(cfg, day)
98
+ store.write_atomic(
99
+ cache, json.dumps({"at": time.time(), "data": redact_obj_safe(data)})
100
+ )
101
+ return data
102
+
103
+
104
+ def redact_obj_safe(data: dict) -> dict:
105
+ """The PR text goes through the same redaction as state before it is written."""
106
+ from .redact import redact_obj
107
+
108
+ return redact_obj(data, limit=4000)
109
+
110
+
111
+ def _text(msg: object) -> str:
112
+ content = (msg or {}).get("content") if isinstance(msg, dict) else None
113
+ if isinstance(content, str):
114
+ return content
115
+ if isinstance(content, list):
116
+ return "\n".join(
117
+ str(b.get("text", ""))
118
+ for b in content
119
+ if isinstance(b, dict) and b.get("type") == "text"
120
+ )
121
+ return ""
122
+
123
+
124
+ def _names(cfg: dict) -> dict[str, str]:
125
+ out: dict[str, str] = {}
126
+ for f in config.sessions_dir(cfg).glob("*.json"):
127
+ try:
128
+ d = json.loads(f.read_text(encoding="utf-8"))
129
+ out[str(d.get("sessionId"))] = str(d.get("name") or "")
130
+ except OSError, ValueError:
131
+ continue
132
+ return out
133
+
134
+
135
+ def peer_prompts(cfg: dict, since: float) -> list[dict]:
136
+ """What each other session was asked since `since` (epoch): {ts, session, text}, redacted, oldest first. Reads transcripts only."""
137
+ bind = hooks.read_bind(cfg) or {}
138
+ own, names, out = str(bind.get("sessionId") or ""), _names(cfg), []
139
+ for f in glob.glob(os.path.expanduser(cfg["paths"]["transcripts_glob"])):
140
+ sid = Path(f).stem
141
+ try:
142
+ if sid == own or os.stat(f).st_mtime < since:
143
+ continue
144
+ lines = Path(f).read_text(encoding="utf-8", errors="replace").splitlines()
145
+ except OSError:
146
+ continue
147
+ for line in lines:
148
+ try:
149
+ o = json.loads(line)
150
+ ts = datetime.fromisoformat(
151
+ str(o["timestamp"]).replace("Z", "+00:00")
152
+ ).timestamp()
153
+ except KeyError, ValueError:
154
+ continue
155
+ text = _text(o.get("message")).strip() if o.get("type") == "user" else ""
156
+ if ts >= since and text and not text.startswith(SKIP_PREFIXES):
157
+ out.append(
158
+ {
159
+ "ts": ts,
160
+ "session": names.get(sid) or sid[:8],
161
+ "text": redact(" ".join(text.split()))[0][:300],
162
+ }
163
+ )
164
+ return sorted(out, key=lambda r: r["ts"])
165
+
166
+
167
+ def day_start(cfg: dict, day: str) -> float:
168
+ """Epoch of 00:00 on `day` in the configured timezone."""
169
+ return (
170
+ datetime.strptime(day, "%Y-%m-%d").replace(tzinfo=store.tzinfo(cfg)).timestamp()
171
+ )
172
+
173
+
174
+ def day_lines(cfg: dict, day: str) -> list[str]:
175
+ """The ledger lines for a day: the user's PRs and what each peer session was asked."""
176
+ data = prs_since(cfg, day)
177
+ out = [f"PRs opened by the user's account since {day}: {len(data['prs'])}"]
178
+ out += [
179
+ f" #{p['number']} {p['title'][:80]} ({len(p['files'])} files) {p['url']}"
180
+ for p in data["prs"]
181
+ ]
182
+ if data["error"]:
183
+ out.append(f" PRs UNKNOWN: {data['error']}")
184
+ peers = peer_prompts(cfg, day_start(cfg, day))
185
+ out.append(f"Peer session prompts since {day}: {len(peers)}")
186
+ seen: dict[str, int] = {}
187
+ for r in peers:
188
+ seen[r["session"]] = seen.get(r["session"], 0) + 1
189
+ if seen[r["session"]] <= 3:
190
+ out.append(
191
+ f" {store.clock(cfg, r['ts'])} {r['session']}: {r['text'][:110]}"
192
+ )
193
+ out += [f" {s}: {n - 3} more" for s, n in seen.items() if n > 3]
194
+ return out
195
+
196
+
197
+ def _snip(text: str, word: str) -> str:
198
+ i = text.lower().find(word.lower())
199
+ return redact(" ".join(text[max(0, i - 40) : i + 80].split()))[0]
200
+
201
+
202
+ def find(cfg: dict, word: str, span: str = "2d") -> list[str]:
203
+ """Lines `[source] id: ...text...` for every record that holds `word` (case-insensitive): orders, work, the archive,
204
+ the user's asks, peer session prompts, and the user's PRs (title, body, changed file paths).
205
+ """
206
+ from .lessons import parse_span
207
+
208
+ sd, now = store.state(cfg), time.time()
209
+ since = now - parse_span(span)
210
+ hits: list[str] = []
211
+
212
+ def check(source: str, ident: str, *texts: str) -> None:
213
+ for t in texts:
214
+ if t and word.lower() in t.lower():
215
+ hits.append(f"[{source}] {ident}: {_snip(t, word)}")
216
+ return
217
+
218
+ for o in Orders(cfg).all().values():
219
+ check("order", o["id"], o.get("quote", ""), o.get("kind", ""))
220
+ for i in work_load(sd):
221
+ check("work", i["id"], i.get("title", ""), i.get("note", ""))
222
+ for f in sorted(glob.glob(str(sd / "archive" / "archive_*.jsonl"))):
223
+ for r in store.read_rows(Path(f))[0]:
224
+ check(
225
+ "archive",
226
+ str(r.get("id", "?")),
227
+ str(r.get("title", "")),
228
+ str(r.get("note", "")),
229
+ str(r.get("done_note", "")),
230
+ str(r.get("quote", "")),
231
+ )
232
+ for r in store.read_rows(sd / "recent-prompts.jsonl")[0]:
233
+ if r["ts"] >= since:
234
+ check("ask", store.clock(cfg, r["ts"]), r.get("text", ""))
235
+ for r in peer_prompts(cfg, since):
236
+ check("peer", f"{r['session']} {store.clock(cfg, r['ts'])}", r["text"])
237
+ data = prs_since(cfg, store.day_of(cfg, since))
238
+ for p in data["prs"]:
239
+ check(
240
+ "pr",
241
+ f"#{p['number']} {p['url']}",
242
+ p["title"],
243
+ p["body"],
244
+ " ".join(p["files"]),
245
+ )
246
+ if data["error"]:
247
+ hits.append(f"[pr] UNKNOWN, PRs could not be searched: {data['error']}")
248
+ return hits or [f"no record holds {word!r} in the last {span}"]
@@ -1,12 +1,15 @@
1
1
  """Checks that come from repeated corrections: re-reading the user's asks, unchecked claims, briefs that forbid the
2
- fix, and an unasked merge. Every check is a warning or a Stop reason; none denies a tool call."""
2
+ fix, and an unasked merge. Every check is a warning or a Stop reason; none denies a tool call.
3
+ """
3
4
 
4
5
  from __future__ import annotations
5
6
 
6
7
  import json
8
+ import os
7
9
  import re
8
10
  import subprocess
9
11
  import time
12
+ from datetime import datetime
10
13
  from pathlib import Path
11
14
 
12
15
  from . import store
@@ -28,6 +31,8 @@ DONE_CLAIM = re.compile(
28
31
  def claims_done(text: str) -> bool:
29
32
  """True when a sentence of the message says the user's work is finished."""
30
33
  return any(DONE_CLAIM.search(s) for s in re.split(r"(?<=[.!?])\s+|\n", text))
34
+
35
+
31
36
  SPAN = re.compile(r"^(\d+)\s*([mhd])$")
32
37
 
33
38
 
@@ -35,7 +40,9 @@ def parse_span(text: str) -> float:
35
40
  """`30m`, `12h` or `2d` as seconds; raises ValueError otherwise."""
36
41
  m = SPAN.match(text.strip().lower())
37
42
  if not m:
38
- raise ValueError(f"--since takes a number and m, h or d (for example 12h), not {text!r}")
43
+ raise ValueError(
44
+ f"--since takes a number and m, h or d (for example 12h), not {text!r}"
45
+ )
39
46
  return int(m[1]) * {"m": 60, "h": 3600, "d": 86400}[m[2]]
40
47
 
41
48
 
@@ -50,14 +57,18 @@ def asks_lines(cfg: dict, span: str) -> list[str]:
50
57
  """`HH:MM words` per prompt, newest last. Reading them marks the asks as swept for the Stop gate."""
51
58
  rows = recent_prompts(cfg, parse_span(span))
52
59
  store.write_atomic(store.state(cfg) / "asks-swept", str(time.time()))
53
- return [f"{store.clock(cfg, r['ts'])} {r['text']}" for r in rows] or ["(no prompts in that span)"]
60
+ return [f"{store.clock(cfg, r['ts'])} {r['text']}" for r in rows] or [
61
+ "(no prompts in that span)"
62
+ ]
54
63
 
55
64
 
56
65
  def _last_work_done(cfg: dict, now: float) -> float:
57
66
  last = 0.0
58
67
  for back in (0, 1):
59
68
  day = store.day_of(cfg, now - back * 86400)
60
- for r in store.read_rows(store.state(cfg) / "archive" / f"archive_{day}.jsonl")[0]:
69
+ for r in store.read_rows(store.state(cfg) / "archive" / f"archive_{day}.jsonl")[
70
+ 0
71
+ ]:
61
72
  if r.get("type") == "work" and r.get("outcome") == "done":
62
73
  last = max(last, float(r["ts"]))
63
74
  return last
@@ -74,18 +85,34 @@ def unswept_asks(cfg: dict, last: str, now: float | None = None) -> str | None:
74
85
  return None
75
86
  try:
76
87
  swept = float((store.state(cfg) / "asks-swept").read_text(encoding="utf-8"))
77
- except (OSError, ValueError):
88
+ except OSError, ValueError:
78
89
  swept = 0.0
79
90
  if prompts[-1]["ts"] <= max(swept, _last_work_done(cfg, now)):
80
91
  return None
81
- return ("hubkit: you say it is done but have not re-read his words since his last prompt. NEXT: hubkit asks ls --since 12h; "
82
- "list every requirement in them and tick each with evidence before saying done")
92
+ return (
93
+ "hubkit: you say it is done but have not re-read his words since his last prompt. NEXT: hubkit asks ls --since 12h; "
94
+ "list every requirement in them and tick each with evidence before saying done"
95
+ )
83
96
 
84
97
 
85
98
  # ---- claims that need a lookup in the same turn ----
86
99
 
87
- UNCHECKED_WORDS = ["gone", "lost", "absent", "vanished", "disappeared", "does not exist", "doesn't exist", "never written", "was never there",
88
- "a person's call", "needs your call", "needs his call", "needs her call", "needs a human decision"]
100
+ UNCHECKED_WORDS = [
101
+ "gone",
102
+ "lost",
103
+ "absent",
104
+ "vanished",
105
+ "disappeared",
106
+ "does not exist",
107
+ "doesn't exist",
108
+ "never written",
109
+ "was never there",
110
+ "a person's call",
111
+ "needs your call",
112
+ "needs his call",
113
+ "needs her call",
114
+ "needs a human decision",
115
+ ]
89
116
  LOOKUP_TOOLS = ("Read", "Grep", "Glob", "Bash")
90
117
 
91
118
 
@@ -94,7 +121,9 @@ def tools_this_turn(transcript: str | Path | None) -> list[str] | None:
94
121
  if not transcript:
95
122
  return None
96
123
  try:
97
- lines = Path(transcript).read_text(encoding="utf-8", errors="replace").splitlines()
124
+ lines = (
125
+ Path(transcript).read_text(encoding="utf-8", errors="replace").splitlines()
126
+ )
98
127
  except OSError:
99
128
  return None
100
129
  names: list[str] = []
@@ -105,8 +134,11 @@ def tools_this_turn(transcript: str | Path | None) -> list[str] | None:
105
134
  continue
106
135
  c = (o.get("message") or {}).get("content")
107
136
  blocks = [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
108
- if o.get("type") == "user" and (isinstance(c, str) or any(b.get("type") == "text" for b in blocks)) \
109
- and (o.get("origin") or {}).get("kind", "human") == "human":
137
+ if (
138
+ o.get("type") == "user"
139
+ and (isinstance(c, str) or any(b.get("type") == "text" for b in blocks))
140
+ and (o.get("origin") or {}).get("kind", "human") == "human"
141
+ ):
110
142
  break
111
143
  if o.get("type") == "assistant":
112
144
  names += [str(b.get("name")) for b in blocks if b.get("type") == "tool_use"]
@@ -131,28 +163,62 @@ def unchecked_claim(cfg: dict, last: str, transcript: str | Path | None) -> str
131
163
  """A reason when the message calls something gone/lost/absent, or a person's call, and this turn made no lookup."""
132
164
  from .gate import _asserted
133
165
 
134
- words = Orders(cfg).intents.get("stop_gate", {}).get("unchecked_words", UNCHECKED_WORDS)
135
- hit = next((s.strip()[:100] for s in re.split(r"(?<=[.!?])\s+|\n", last) if any(_asserted(s.lower(), w.lower()) for w in words)), None)
166
+ words = (
167
+ Orders(cfg).intents.get("stop_gate", {}).get("unchecked_words", UNCHECKED_WORDS)
168
+ )
169
+ hit = next(
170
+ (
171
+ s.strip()[:100]
172
+ for s in re.split(r"(?<=[.!?])\s+|\n", last)
173
+ if any(_asserted(s.lower(), w.lower()) for w in words)
174
+ ),
175
+ None,
176
+ )
136
177
  if not hit or lookups_this_turn(transcript) != 0:
137
178
  return None
138
- return (f"hubkit: your message claims {hit!r} but this turn read nothing. A report that something is gone, or is a person's call, "
139
- "is checked, not relayed. NEXT: read the code or record that writes it (or what he already decided), then say it")
140
-
141
-
142
- VERIFIED_WORDS = ["tests pass", "tests passed", "all tests pass", "all green", "ci is green", "it works", "now works", "is deployed", "was deployed", "is live now", "fixed and verified"]
179
+ return (
180
+ f"hubkit: your message claims {hit!r} but this turn read nothing. A report that something is gone, or is a person's call, "
181
+ "is checked, not relayed. NEXT: read the code or record that writes it (or what he already decided), then say it"
182
+ )
183
+
184
+
185
+ VERIFIED_WORDS = [
186
+ "tests pass",
187
+ "tests passed",
188
+ "all tests pass",
189
+ "all green",
190
+ "ci is green",
191
+ "it works",
192
+ "now works",
193
+ "is deployed",
194
+ "was deployed",
195
+ "is live now",
196
+ "fixed and verified",
197
+ ]
143
198
 
144
199
 
145
200
  def unrun_claim(cfg: dict, last: str, transcript: str | Path | None) -> str | None:
146
201
  """A reason when the message says tests pass, it works or it is deployed and this turn ran no command (no Bash call)."""
147
202
  from .gate import _asserted
148
203
 
149
- words = Orders(cfg).intents.get("stop_gate", {}).get("verified_words", VERIFIED_WORDS)
150
- hit = next((s.strip()[:100] for s in re.split(r"(?<=[.!?])\s+|\n", last) if any(_asserted(s.lower(), w.lower()) for w in words)), None)
204
+ words = (
205
+ Orders(cfg).intents.get("stop_gate", {}).get("verified_words", VERIFIED_WORDS)
206
+ )
207
+ hit = next(
208
+ (
209
+ s.strip()[:100]
210
+ for s in re.split(r"(?<=[.!?])\s+|\n", last)
211
+ if any(_asserted(s.lower(), w.lower()) for w in words)
212
+ ),
213
+ None,
214
+ )
151
215
  names = tools_this_turn(transcript)
152
216
  if not hit or names is None or "Bash" in names:
153
217
  return None
154
- return (f"hubkit: your message says {hit!r} but this turn ran no command to show it. A pass, a fix or a deploy is shown by a run, not remembered. "
155
- "NEXT: run the check now and quote its result, or drop the claim")
218
+ return (
219
+ f"hubkit: your message says {hit!r} but this turn ran no command to show it. A pass, a fix or a deploy is shown by a run, not remembered. "
220
+ "NEXT: run the check now and quote its result, or drop the claim"
221
+ )
156
222
 
157
223
 
158
224
  LINK_REF = re.compile(r"(?<![\w/])(?:[\w.-]+/[\w.-]+)?#\d{4,6}\b")
@@ -160,34 +226,74 @@ LINK_REF = re.compile(r"(?<![\w/])(?:[\w.-]+/[\w.-]+)?#\d{4,6}\b")
160
226
 
161
227
  def bare_refs(cfg: dict, last: str) -> list[str]:
162
228
  """PR/issue numbers like `#7457` named in the message while it holds no link at all: the user must be able to click."""
163
- if not Orders(cfg).intents.get("stop_gate", {}).get("full_links", True) or "http" in last:
229
+ if (
230
+ not Orders(cfg).intents.get("stop_gate", {}).get("full_links", True)
231
+ or "http" in last
232
+ ):
164
233
  return []
165
234
  return sorted(set(LINK_REF.findall(re.sub(r"`[^`]*`", "", last))))
166
235
 
167
236
 
168
237
  # ---- a brief must not forbid the step that clears a blocker ----
169
238
 
170
- FORBID = re.compile(r"(?i)\b(do not|don't|dont|never|must not|mustn't|not allowed|forbidden|banned)\b")
171
- STOP_WORDS = {"with", "that", "this", "from", "have", "been", "when", "then", "than", "into", "does", "host", "need", "needs", "start",
172
- "running", "after", "before", "about", "their", "there", "which", "would", "could", "should"}
239
+ FORBID = re.compile(
240
+ r"(?i)\b(do not|don't|dont|never|must not|mustn't|not allowed|forbidden|banned)\b"
241
+ )
242
+ STOP_WORDS = {
243
+ "with",
244
+ "that",
245
+ "this",
246
+ "from",
247
+ "have",
248
+ "been",
249
+ "when",
250
+ "then",
251
+ "than",
252
+ "into",
253
+ "does",
254
+ "host",
255
+ "need",
256
+ "needs",
257
+ "start",
258
+ "running",
259
+ "after",
260
+ "before",
261
+ "about",
262
+ "their",
263
+ "there",
264
+ "which",
265
+ "would",
266
+ "could",
267
+ "should",
268
+ }
173
269
 
174
270
 
175
271
  def _keys(text: str) -> set[str]:
176
- return {w for w in re.findall(r"[a-z][a-z0-9_-]{3,}", text.lower()) if w not in STOP_WORDS}
272
+ return {
273
+ w
274
+ for w in re.findall(r"[a-z][a-z0-9_-]{3,}", text.lower())
275
+ if w not in STOP_WORDS
276
+ }
177
277
 
178
278
 
179
279
  def brief_forbids_fix(cfg: dict, brief: str) -> str | None:
180
280
  """The line of a brief that forbids a step an open order is blocked on (shares a key word with its blocker or fix)."""
181
- blockers = [(o["id"], b) for o in Orders(cfg).live() if o["state"] == "open" and (b := o.get("blocker"))]
281
+ blockers = [
282
+ (o["id"], b)
283
+ for o in Orders(cfg).live()
284
+ if o["state"] == "open" and (b := o.get("blocker"))
285
+ ]
182
286
  for line in brief.splitlines():
183
287
  if not FORBID.search(line):
184
288
  continue
185
289
  for oid, b in blockers:
186
290
  shared = _keys(line) & _keys(b["on"] + " " + b["fix"])
187
291
  if shared:
188
- return (f"hubkit WARNING: this brief says {line.strip()[:100]!r}, which touches what {oid} is blocked on ({b['on'][:60]}; "
189
- f"shared: {', '.join(sorted(shared)[:3])}). A brief must not forbid the one action that clears the blocker. "
190
- "NEXT: reword it, or allow that step explicitly")
292
+ return (
293
+ f"hubkit WARNING: this brief says {line.strip()[:100]!r}, which touches what {oid} is blocked on ({b['on'][:60]}; "
294
+ f"shared: {', '.join(sorted(shared)[:3])}). A brief must not forbid the one action that clears the blocker. "
295
+ "NEXT: reword it, or allow that step explicitly"
296
+ )
191
297
  return None
192
298
 
193
299
 
@@ -212,39 +318,57 @@ def merge_warning(cfg: dict, command: str, now: float | None = None) -> str | No
212
318
  return None
213
319
  now = now or time.time()
214
320
  for o in Orders(cfg).live():
215
- if o["state"] == "open" and (re.search(rf"(?<!\d){num}(?!\d)", " ".join([o["quote"], *o["refs"]])) if num else o["kind"] == "merge"):
321
+ if o["state"] == "open" and (
322
+ re.search(rf"(?<!\d){num}(?!\d)", " ".join([o["quote"], *o["refs"]]))
323
+ if num
324
+ else o["kind"] == "merge"
325
+ ):
216
326
  return None
217
327
  for r in recent_prompts(cfg, 7200, now):
218
- if re.search(r"(?i)\bmerge", r["text"]) and (not num or re.search(rf"(?<!\d){num}(?!\d)", r["text"])):
328
+ if re.search(r"(?i)\bmerge", r["text"]) and (
329
+ not num or re.search(rf"(?<!\d){num}(?!\d)", r["text"])
330
+ ):
219
331
  return None
220
332
  what = f"#{num}" if num else "a PR"
221
- return (f"hubkit WARNING: merging {what} with no live order and none of his typed words naming it. A merge is done on his order only. "
222
- "NEXT: stop unless he ordered it; if he did, open the order: hubkit order open --kind merge --quote \"<his words>\"")
333
+ return (
334
+ f"hubkit WARNING: merging {what} with no live order and none of his typed words naming it. A merge is done on his order only. "
335
+ 'NEXT: stop unless he ordered it; if he did, open the order: hubkit order open --kind merge --quote "<his words>"'
336
+ )
223
337
 
224
338
 
225
339
  # ---- warn-only checks before a tool call: posts, banned commands, stopping an agent ----
226
340
 
227
- POST_CMD = re.compile(r"(?i)\b(?:mep\s+(?:send|reply|post)|chat\S*\s+send|gh\s+(?:pr|issue)\s+(?:comment|review)|gh\s+api\b[^\n]*\b(?:comments|reviews)\b|slack\S*\s+(?:post|send))")
341
+ POST_CMD = re.compile(
342
+ r"(?i)\b(?:mep\s+(?:send|reply|post)|chat\S*\s+send|gh\s+(?:pr|issue)\s+(?:comment|review)|gh\s+api\b[^\n]*\b(?:comments|reviews)\b|slack\S*\s+(?:post|send))"
343
+ )
228
344
  BANNED_CMD = re.compile(
229
345
  r"(?i)(?:\brm\s+-\w*r\w*f|\brm\s+-\w*f\w*r|\bpkill\b|\bgit\s+reset\s+--hard|\bgit\s+stash\b|\bgit\s+clean\b"
230
346
  r"|\bgit\s+push\b[^\n;&|]*\s(?:--force\b|--force-with-lease\b|-f\b)|\bgit\s+checkout\s+\S+\s+--\s)"
231
347
  )
232
- YES_WORD = re.compile(r"(?i)\b(yes|yep|go|go ahead|send it|post it|approved?|ok|okay|do it|ship it)\b")
348
+ YES_WORD = re.compile(
349
+ r"(?i)\b(yes|yep|go|go ahead|send it|post it|approved?|ok|okay|do it|ship it)\b"
350
+ )
233
351
  STOP_WORD = re.compile(r"(?i)\b(kill|stop|cancel|abort|terminate)\b")
234
352
 
235
353
 
236
- def _typed_recently(cfg: dict, pattern: re.Pattern[str], seconds: float, now: float) -> bool:
354
+ def _typed_recently(
355
+ cfg: dict, pattern: re.Pattern[str], seconds: float, now: float
356
+ ) -> bool:
237
357
  return any(pattern.search(r["text"]) for r in recent_prompts(cfg, seconds, now))
238
358
 
239
359
 
240
- def tool_warning(cfg: dict, tool: str, tool_input: dict, now: float | None = None) -> str | None:
360
+ def tool_warning(
361
+ cfg: dict, tool: str, tool_input: dict, now: float | None = None
362
+ ) -> str | None:
241
363
  """A warning for one tool call, else None. Never denies. Merges, posts, banned commands and TaskStop."""
242
364
  now = now or time.time()
243
365
  if tool == "TaskStop":
244
366
  if _typed_recently(cfg, STOP_WORD, 1800, now):
245
367
  return None
246
- return ("hubkit WARNING: stopping a running agent that he did not ask to stop. Its half-done work is lost. "
247
- "NEXT: let it finish and harvest the result, or SendMessage it to redirect; stop it only on his word")
368
+ return (
369
+ "hubkit WARNING: stopping a running agent that he did not ask to stop. Its half-done work is lost. "
370
+ "NEXT: let it finish and harvest the result, or SendMessage it to redirect; stop it only on his word"
371
+ )
248
372
  if tool == "AskUserQuestion":
249
373
  return card_warning(tool_input)
250
374
  command = str(tool_input.get("command") or "")
@@ -257,39 +381,130 @@ def tool_warning(cfg: dict, tool: str, tool_input: dict, now: float | None = Non
257
381
  if m := BANNED_CMD.search(command):
258
382
  return f"hubkit WARNING: {m[0].strip()!r} is a banned command. NEXT: use the safe form (revert with a new commit, remove one named file, send a normal push) or ask him"
259
383
  if POST_CMD.search(command) and not _typed_recently(cfg, YES_WORD, 600, now):
260
- return ("hubkit WARNING: posting to a person or a thread with no typed yes of his in the last 10 minutes. A message goes out on his word, "
261
- "from the right account. NEXT: show him the draft and send on his yes")
384
+ return (
385
+ "hubkit WARNING: posting to a person or a thread with no typed yes of his in the last 10 minutes. A message goes out on his word, "
386
+ "from the right account. NEXT: show him the draft and send on his yes"
387
+ )
262
388
  return merge_warning(cfg, command, now)
263
389
 
264
390
 
265
391
  # ---- delivery: a report on built work with the work still only on this machine ----
266
392
 
267
- BUILT_WORDS = re.compile(r"(?i)\b(built|implemented|fixed|committed|ready|shipped|finished|completed|done)\b")
268
- LOCAL_OK = re.compile(r"(?i)\b(not (yet )?pushed|unpushed|local only|uncommitted|not committed|before (the )?push)\b")
393
+ BUILT_WORDS = re.compile(
394
+ r"(?i)\b(built|implemented|fixed|committed|ready|shipped|finished|completed|done)\b"
395
+ )
396
+ LOCAL_OK = re.compile(
397
+ r"(?i)\b(not (yet )?pushed|unpushed|local only|uncommitted|not committed|before (the )?push)\b"
398
+ )
269
399
 
270
400
 
271
- def unpushed_work(last: str, cwd: str | None) -> str | None:
272
- """A reason when the message reports built work while the repo at `cwd` has uncommitted changes or commits no remote has."""
401
+ def turn_start(transcript: str | Path | None) -> float | None:
402
+ """Epoch time of the last typed prompt in the transcript (when this turn began), or None when unknown."""
403
+ if not transcript:
404
+ return None
405
+ try:
406
+ lines = (
407
+ Path(transcript).read_text(encoding="utf-8", errors="replace").splitlines()
408
+ )
409
+ except OSError:
410
+ return None
411
+ for line in reversed(lines):
412
+ try:
413
+ o = json.loads(line)
414
+ except ValueError:
415
+ continue
416
+ c = (o.get("message") or {}).get("content")
417
+ blocks = [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []
418
+ if (
419
+ o.get("type") == "user"
420
+ and (isinstance(c, str) or any(b.get("type") == "text" for b in blocks))
421
+ and (o.get("origin") or {}).get("kind", "human") == "human"
422
+ ):
423
+ try:
424
+ return datetime.fromisoformat(
425
+ str(o["timestamp"]).replace("Z", "+00:00")
426
+ ).timestamp()
427
+ except KeyError, ValueError:
428
+ return None
429
+ return None
430
+
431
+
432
+ def _touched_since(cwd: str, porcelain: list[str], since: float) -> list[str]:
433
+ """The changed files of `git status --porcelain` lines whose mtime is at or after `since` (a deleted file counts as old)."""
434
+ out = []
435
+ for ln in porcelain:
436
+ path = ln[3:].split(" -> ")[-1].strip().strip('"')
437
+ try:
438
+ if os.stat(Path(cwd) / path).st_mtime >= since:
439
+ out.append(path)
440
+ except OSError:
441
+ continue
442
+ return out
443
+
444
+
445
+ def _unpushed_since(cwd: str, since: float) -> int:
446
+ """Unpushed commits made at or after `since`."""
447
+ try:
448
+ r = subprocess.run(
449
+ ["git", "-C", cwd, "log", "@{u}..HEAD", "--format=%ct"],
450
+ capture_output=True,
451
+ encoding="utf-8",
452
+ errors="replace",
453
+ timeout=5,
454
+ check=False,
455
+ )
456
+ except OSError, subprocess.SubprocessError:
457
+ return 0
458
+ return sum(1 for x in r.stdout.split() if x.isdigit() and float(x) >= since)
459
+
460
+
461
+ def unpushed_work(
462
+ last: str, cwd: str | None, transcript: str | Path | None = None
463
+ ) -> str | None:
464
+ """A reason when the message reports built work while the repo at `cwd` has changes or commits from this turn that
465
+ no remote has. With the turn's start known (the transcript), older dirt is not this turn's work and does not hold.
466
+ """
273
467
  if not cwd or not BUILT_WORDS.search(last) or LOCAL_OK.search(last):
274
468
  return None
275
469
  try:
276
- r = subprocess.run(["git", "-C", cwd, "status", "--porcelain", "-b"], capture_output=True, encoding="utf-8", errors="replace", timeout=5, check=False)
277
- except (OSError, subprocess.SubprocessError):
470
+ r = subprocess.run(
471
+ ["git", "-C", cwd, "status", "--porcelain", "-b"],
472
+ capture_output=True,
473
+ encoding="utf-8",
474
+ errors="replace",
475
+ timeout=5,
476
+ check=False,
477
+ )
478
+ except OSError, subprocess.SubprocessError:
278
479
  return None
279
480
  lines = r.stdout.splitlines()
280
481
  if r.returncode != 0 or not lines:
281
482
  return None
282
483
  dirty = [ln for ln in lines[1:] if not ln.startswith("??")]
283
484
  ahead = re.search(r"\[ahead (\d+)", lines[0])
284
- if not dirty and not ahead:
485
+ n_ahead = int(ahead[1]) if ahead else 0
486
+ since = turn_start(transcript)
487
+ if since is not None:
488
+ dirty = _touched_since(cwd, dirty, since)
489
+ n_ahead = _unpushed_since(cwd, since) if n_ahead else 0
490
+ if not dirty and not n_ahead:
285
491
  return None
286
- what = ", ".join([*([f"{len(dirty)} changed file(s) not committed"] if dirty else []), *([f"{ahead[1]} commit(s) not pushed"] if ahead else [])])
492
+ what = ", ".join(
493
+ [
494
+ *([f"{len(dirty)} changed file(s) not committed"] if dirty else []),
495
+ *([f"{n_ahead} commit(s) not pushed"] if n_ahead else []),
496
+ ]
497
+ )
287
498
  return f"hubkit: you report the work as built but this checkout has {what}. A report on work only on this machine is not delivery. NEXT: commit and push it (or say plainly it is local only)"
288
499
 
289
500
 
290
501
  # ---- briefs: required lines and the agent cap ----
291
502
 
292
- BRIEF_LINES = {"Done-when": re.compile(r"(?i)done[- ]when"), "Output": re.compile(r"(?i)\boutput\b"), "Estimate": re.compile(r"(?i)\bestimate\b")}
503
+ BRIEF_LINES = {
504
+ "Done-when": re.compile(r"(?i)done[- ]when"),
505
+ "Output": re.compile(r"(?i)\boutput\b"),
506
+ "Estimate": re.compile(r"(?i)\bestimate\b"),
507
+ }
293
508
 
294
509
 
295
510
  def brief_missing(cfg: dict, text: str) -> str | None:
@@ -299,10 +514,20 @@ def brief_missing(cfg: dict, text: str) -> str | None:
299
514
  if len(text) < int(routed.opts(cfg)["min_chars"]) or routed.ACK.match(text):
300
515
  return None
301
516
  missing = [k for k, rx in BRIEF_LINES.items() if not rx.search(text)]
302
- if len(missing) == len(BRIEF_LINES) and not (routed.DELIVERABLE.search(text) or routed.DIRECTIVE.search(text) or routed.REQUEST.search(text)):
517
+ if len(missing) == len(BRIEF_LINES) and not (
518
+ routed.DELIVERABLE.search(text)
519
+ or routed.DIRECTIVE.search(text)
520
+ or routed.REQUEST.search(text)
521
+ ):
303
522
  return None # not an ask
304
- return (f"hubkit WARNING: this brief has no {', '.join(missing)} line. An agent without a done-when never stops, one without an output shape returns prose, "
305
- "one without an estimate cannot be judged slow. NEXT: add the missing line(s) and re-send") if missing else None
523
+ return (
524
+ (
525
+ f"hubkit WARNING: this brief has no {', '.join(missing)} line. An agent without a done-when never stops, one without an output shape returns prose, "
526
+ "one without an estimate cannot be judged slow. NEXT: add the missing line(s) and re-send"
527
+ )
528
+ if missing
529
+ else None
530
+ )
306
531
 
307
532
 
308
533
  def agent_cap_warning(cfg: dict) -> str | None:
@@ -319,7 +544,9 @@ def agent_cap_warning(cfg: dict) -> str | None:
319
544
  # ---- repeating the same command, full test runs, cards without a recommendation ----
320
545
 
321
546
 
322
- def repeat_warning(cfg: dict, command: str, now: float, limit: int = 3, window_s: float = 600.0) -> str | None:
547
+ def repeat_warning(
548
+ cfg: dict, command: str, now: float, limit: int = 3, window_s: float = 600.0
549
+ ) -> str | None:
323
550
  """A warning when the same command has run `limit` times within the window (the ledger is the state file `commands.jsonl`)."""
324
551
  p = store.state(cfg) / "commands.jsonl"
325
552
  rows = [r for r in store.read_rows(p)[0] if now - r["ts"] <= window_s][-50:]
@@ -566,7 +566,9 @@ def digest(cfg: dict, span: str = "12h", now: float | None = None) -> list[str]:
566
566
  out += [f" alert: {r.get('msg', '')}" for r in alerts[-5:]]
567
567
  out += [f" waits on you {i['id']}: {i.get('title', '')}" for i in asks] or [" nothing waits on you"]
568
568
  out.append(f" still live: {len(live) - len(asks)} job(s)")
569
- return out
569
+ from . import ledger
570
+
571
+ return out + ledger.day_lines(cfg, store.day_of(cfg, since))
570
572
 
571
573
 
572
574
  def reflection(cfg: dict, day: str) -> list[str]:
@@ -594,7 +596,9 @@ def eod(cfg: dict, day: str, note: str = "") -> Path:
594
596
  """Write and return the end-of-day review file for `day`."""
595
597
  sd = store.state(cfg)
596
598
  Orders(cfg).repair_archive()
597
- body = glance(cfg, day) + [""] + reflection(cfg, day)
599
+ from . import ledger
600
+
601
+ body = glance(cfg, day) + [""] + ledger.day_lines(cfg, day) + [""] + reflection(cfg, day)
598
602
  if note:
599
603
  body += ["", "Self-improvement note:", " " + learnings.clean(note)]
600
604
  out = sd / "eod" / f"eod_{day}.md"
@@ -343,6 +343,13 @@ REVERTS = [
343
343
  ('unrun claim needs Bash', 'hubcore/lessons.py', 'if not hit or names is None or "Bash" in names:', 'if True:', 'tests/test_tool_guards.py::test_a_claim_that_tests_pass_needs_a_command_run_this_turn'),
344
344
  ('bare PR refs held', 'hubcore/lessons.py', '"http" in last:', '"http" in last or True:', 'tests/test_tool_guards.py::test_a_report_naming_a_pr_number_with_no_link_is_held'),
345
345
  ('approval on red CI held', 'hubcore/gate.py', 'if info and info.get("ci") in ("failing", "pending"):', 'if False:', 'tests/test_tool_guards.py::test_an_approval_relayed_while_ci_is_red_at_the_head_is_held'),
346
+ ('older dirt does not hold', 'hubcore/lessons.py', ' if since is not None:\n dirty =', ' if False:\n dirty =', 'tests/test_tool_guards.py::test_dirt_older_than_the_turn_does_not_hold_a_report_but_a_file_touched_in_the_turn_does'),
347
+ ('find searches PR files', 'hubcore/ledger.py', '" ".join(p["files"]),', '"",', 'tests/test_ledger.py::test_find_searches_pr_title_body_files_and_peer_prompts_not_only_routed_work'),
348
+ ('find searches peer prompts', 'hubcore/ledger.py', 'for r in peer_prompts(cfg, since):\n check(', 'for r in []:\n check(', 'tests/test_ledger.py::test_find_searches_pr_title_body_files_and_peer_prompts_not_only_routed_work'),
349
+ ('find shows gh failure', 'hubcore/ledger.py', ' if data["error"]:\n hits.append', ' if False:\n hits.append', 'tests/test_ledger.py::test_find_shows_a_gh_failure_instead_of_swallowing_it'),
350
+ ('peer prompts redacted', 'hubcore/ledger.py', '"text": redact(" ".join(text.split()))[0][:300]', '"text": " ".join(text.split())[:300]', 'tests/test_ledger.py::test_peer_prompts_are_redacted_like_state'),
351
+ ('eod lists PRs and peers', 'hubcore/status.py', 'glance(cfg, day) + [""] + ledger.day_lines(cfg, day)', 'glance(cfg, day)', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
352
+ ('digest lists PRs and peers', 'hubcore/status.py', 'return out + ledger.day_lines(cfg, store.day_of(cfg, since))', 'return out', 'tests/test_ledger.py::test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked'),
346
353
  ]
347
354
 
348
355
 
@@ -0,0 +1,61 @@
1
+ """The day's whole record: the user's PRs, peer session prompts, and one search over all of it."""
2
+
3
+ import json
4
+ import time
5
+
6
+ from test_hubkit import iso
7
+
8
+ SECRET = "AKIA" + "ABCDEFGHIJKLMNOP"
9
+
10
+
11
+ def fake_gh_by_verb(env, fail=False):
12
+ """A gh stand-in that answers `search prs` and `pr view` differently (the one boundary the tests fake)."""
13
+ gh = env.root / "gh"
14
+ search = json.dumps([{"number": 7457, "title": "Add the helpdesk agent", "url": "https://github.com/acme/repo/pull/7457", "body": "Answers questions from the docs.", "createdAt": "x"}])
15
+ view = json.dumps({"files": [{"path": "api/agents/helpdesk/handlers.py"}]})
16
+ (env.root / "search.json").write_text(search)
17
+ (env.root / "view.json").write_text(view)
18
+ body = 'echo boom >&2; exit 1' if fail else f'case "$1" in search) cat {env.root / "search.json"};; pr) cat {env.root / "view.json"};; esac'
19
+ gh.write_text(f"#!/bin/sh\n{body}\n")
20
+ gh.chmod(0o755)
21
+ env.cfg.write_text(env.cfg.read_text() + f'[tools]\ngh="{gh}"\n')
22
+
23
+
24
+ def peer_session(env, name="peer-e8", text="build the helpdesk agent for me"):
25
+ (env.sessions / "9991.json").write_text(json.dumps({"pid": 9991, "sessionId": "peer-sid-1", "name": name}))
26
+ lines = [json.dumps({"type": "user", "timestamp": iso(time.time() - 60), "message": {"content": text}})]
27
+ (env.transcripts / "peer-sid-1.jsonl").write_text("\n".join(lines) + "\n")
28
+
29
+
30
+ def test_find_searches_pr_title_body_files_and_peer_prompts_not_only_routed_work(env):
31
+ fake_gh_by_verb(env)
32
+ peer_session(env)
33
+ env.bind()
34
+ out = env.run("find", "helpdesk").stdout
35
+ assert "[pr] #7457" in out and "[peer] peer-e8" in out
36
+ assert "[pr] #7457" in env.run("find", "docs").stdout # found in the PR body
37
+ assert "[pr] #7457" in env.run("find", "handlers.py").stdout # found in a changed file path
38
+ assert "no record holds" in env.run("find", "zebra").stdout
39
+
40
+
41
+ def test_find_shows_a_gh_failure_instead_of_swallowing_it(env):
42
+ fake_gh_by_verb(env, fail=True)
43
+ env.bind()
44
+ out = env.run("find", "anything").stdout
45
+ assert "UNKNOWN" in out and "gh search failed" in out
46
+
47
+
48
+ def test_peer_prompts_are_redacted_like_state(env):
49
+ fake_gh_by_verb(env)
50
+ peer_session(env, text=f"use the key {SECRET} to build the helpdesk agent")
51
+ env.bind()
52
+ for out in (env.run("find", "use the key").stdout, env.run("digest", "--since", "12h").stdout):
53
+ assert "use the key" in out and SECRET not in out
54
+
55
+
56
+ def test_digest_and_eod_list_todays_prs_and_what_each_peer_was_asked(env):
57
+ fake_gh_by_verb(env)
58
+ peer_session(env)
59
+ env.bind()
60
+ for out in (env.run("digest", "--since", "12h").stdout, env.run("eod").stdout):
61
+ assert "#7457 Add the helpdesk agent" in out and "peer-e8: build the helpdesk agent" in out
@@ -133,3 +133,28 @@ def test_an_approval_relayed_while_ci_is_red_at_the_head_is_held(env):
133
133
  data["statusCheckRollup"] = [{"conclusion": "SUCCESS"}]
134
134
  (env.root / "gh.json").write_text(json.dumps(data))
135
135
  assert stop(env, last_assistant_message="#101 is approved.") is None
136
+
137
+
138
+ def test_dirt_older_than_the_turn_does_not_hold_a_report_but_a_file_touched_in_the_turn_does(env, tmp_path):
139
+ import os
140
+ import subprocess
141
+ import time
142
+
143
+ env_git = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "PATH": "/usr/bin:/bin:/usr/local/bin"}
144
+ work = tmp_path / "work"
145
+ work.mkdir()
146
+ for a in (["init", "-b", "main"], ):
147
+ subprocess.run(["git", *a], cwd=work, check=True, capture_output=True, env=env_git)
148
+ (work / "old.txt").write_text("one\n")
149
+ subprocess.run(["git", "add", "old.txt"], cwd=work, check=True, capture_output=True, env=env_git)
150
+ subprocess.run(["git", "commit", "-m", "one"], cwd=work, check=True, capture_output=True, env=env_git)
151
+ (work / "old.txt").write_text("two\n")
152
+ long_ago = time.time() - 7200
153
+ os.utime(work / "old.txt", (long_ago, long_ago))
154
+ env.bind()
155
+ msg = {"last_assistant_message": "The role change is built and ready.", "cwd": str(work)}
156
+ assert stop(env, transcript_path=transcript(env, ["Read"]), **msg) is None # dirt from before the turn
157
+ (work / "new.txt").write_text("x\n")
158
+ subprocess.run(["git", "add", "new.txt"], cwd=work, check=True, capture_output=True, env=env_git)
159
+ held = stop(env, transcript_path=transcript(env, ["Read"]), **msg)
160
+ assert held and "1 changed file(s) not committed" in held["reason"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes