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.
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CHANGELOG.md +8 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/PKG-INFO +3 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/README.md +2 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/architecture.md +2 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/commands.md +3 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/pyproject.toml +1 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/cli.py +6 -1
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/gate.py +1 -1
- hubkit_cli-0.2.1/scripts/hubcore/ledger.py +248 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/lessons.py +284 -57
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/status.py +6 -2
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/run_reverts.py +7 -0
- hubkit_cli-0.2.1/tests/test_ledger.py +61 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_tool_guards.py +25 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/.gitignore +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CLAUDE.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/CONTRIBUTING.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/LICENSE +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/SECURITY.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/config.example.toml +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/configuration.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/docs/troubleshooting.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/intents.example.toml +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/references/design-sources.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/references/research.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/__init__.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/compat.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/config.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/hooks.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/inbound.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/install.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/learnings.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/orders.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/redact.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/routed.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/slow.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/store.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/trust.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubcore/watcher.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/hubkit +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/scripts/learn.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/skills/hub-mep/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/skills/hubkit-setup/SKILL.md +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/conftest.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_blockers.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_compat.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_guards2.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_hubkit.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_inbound.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_install.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_learning_rules.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_lessons.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_own_work.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_peer_reports.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_review_chain.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_slow.py +0 -0
- {hubkit_cli-0.2.0 → hubkit_cli-0.2.1}/tests/test_state_rules.py +0 -0
- {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.
|
|
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
|
|
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.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(
|
|
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 [
|
|
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")[
|
|
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
|
|
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 (
|
|
82
|
-
|
|
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 = [
|
|
88
|
-
|
|
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 =
|
|
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
|
|
109
|
-
|
|
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 =
|
|
135
|
-
|
|
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 (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 =
|
|
150
|
-
|
|
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 (
|
|
155
|
-
|
|
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
|
|
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(
|
|
171
|
-
|
|
172
|
-
|
|
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 {
|
|
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 = [
|
|
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 (
|
|
189
|
-
|
|
190
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
222
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
247
|
-
|
|
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 (
|
|
261
|
-
|
|
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(
|
|
268
|
-
|
|
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
|
|
272
|
-
"""
|
|
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(
|
|
277
|
-
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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 (
|
|
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 (
|
|
305
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|