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.
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/CHANGELOG.md +13 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/PKG-INFO +3 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/README.md +2 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/architecture.md +2 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/commands.md +3 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/pyproject.toml +1 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/cli.py +6 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/gate.py +1 -1
- hubkit_cli-0.2.2/scripts/hubcore/ledger.py +248 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/lessons.py +284 -57
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/routed.py +314 -83
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/status.py +6 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/run_reverts.py +8 -0
- hubkit_cli-0.2.2/tests/test_ledger.py +61 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_review_chain.py +13 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_tool_guards.py +25 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/.gitignore +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/CLAUDE.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/LICENSE +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/SECURITY.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/config.example.toml +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/configuration.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/docs/troubleshooting.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/intents.example.toml +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/references/design-sources.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/references/research.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/__init__.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/compat.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/config.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/hooks.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/inbound.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/install.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/learnings.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/orders.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/redact.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/slow.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/store.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/trust.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubcore/watcher.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/hubkit +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/scripts/learn.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/skills/hub-mep/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/skills/hubkit-setup/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/conftest.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_blockers.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_compat.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_guards2.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_hubkit.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_inbound.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_install.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_learning_rules.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_lessons.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_own_work.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_peer_reports.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_slow.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.2}/tests/test_state_rules.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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}"]
|