hubkit-cli 0.2.0__tar.gz → 0.2.2__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.2}/CHANGELOG.md +13 -0
  2. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/PKG-INFO +3 -2
  3. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/README.md +2 -1
  4. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/architecture.md +2 -2
  5. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/commands.md +3 -2
  6. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/pyproject.toml +1 -1
  7. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/cli.py +6 -1
  8. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/gate.py +1 -1
  9. hubkit_cli-0.2.2/scripts/hubcore/ledger.py +248 -0
  10. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/lessons.py +284 -57
  11. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/routed.py +314 -83
  12. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/status.py +6 -2
  13. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/run_reverts.py +8 -0
  14. hubkit_cli-0.2.2/tests/test_ledger.py +61 -0
  15. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_review_chain.py +13 -1
  16. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_tool_guards.py +25 -0
  17. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/.gitignore +0 -0
  18. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/CLAUDE.md +0 -0
  19. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/CONTRIBUTING.md +0 -0
  20. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/LICENSE +0 -0
  21. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/SECURITY.md +0 -0
  22. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/SKILL.md +0 -0
  23. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/config.example.toml +0 -0
  24. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/configuration.md +0 -0
  25. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/troubleshooting.md +0 -0
  26. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/intents.example.toml +0 -0
  27. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/references/design-sources.md +0 -0
  28. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/references/research.md +0 -0
  29. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/__init__.py +0 -0
  30. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/compat.py +0 -0
  31. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/config.py +0 -0
  32. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/hooks.py +0 -0
  33. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/inbound.py +0 -0
  34. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/install.py +0 -0
  35. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/learnings.py +0 -0
  36. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/orders.py +0 -0
  37. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/redact.py +0 -0
  38. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/slow.py +0 -0
  39. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/store.py +0 -0
  40. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/trust.py +0 -0
  41. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/watcher.py +0 -0
  42. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubkit +0 -0
  43. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/learn.py +0 -0
  44. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/skills/hub-mep/SKILL.md +0 -0
  45. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/skills/hubkit-setup/SKILL.md +0 -0
  46. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/conftest.py +0 -0
  47. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_blockers.py +0 -0
  48. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_compat.py +0 -0
  49. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_guards2.py +0 -0
  50. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_hubkit.py +0 -0
  51. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_inbound.py +0 -0
  52. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_install.py +0 -0
  53. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_learning_rules.py +0 -0
  54. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_lessons.py +0 -0
  55. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_own_work.py +0 -0
  56. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_peer_reports.py +0 -0
  57. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_slow.py +0 -0
  58. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_state_rules.py +0 -0
  59. {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_watcher.py +0 -0
@@ -4,6 +4,19 @@ All notable changes to this project are recorded here. The format follows [Keep
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.2] - 2026-11-06
8
+
9
+ ### Fixed
10
+ - A fix job whose change verb and PR number are in different sentences ("Fix the gaps. ... PR #7") is still recognised as a fix job, so its owner pushing to the PR is not counted as a stalled or moved head.
11
+
12
+ ## [0.2.1] - 2026-11-06
13
+
14
+ ### Added
15
+ - `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).
16
+
17
+ ### Changed
18
+ - 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.
19
+
7
20
  ## [0.2.0] - 2026-11-06
8
21
 
9
22
  ### 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.2
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.2"
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}"]