hubkit-cli 0.1.0__tar.gz → 0.2.0__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 (58) hide show
  1. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/CHANGELOG.md +20 -1
  2. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/CONTRIBUTING.md +1 -1
  3. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/PKG-INFO +14 -20
  4. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/README.md +13 -19
  5. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/SKILL.md +2 -2
  6. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/docs/architecture.md +12 -4
  7. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/docs/commands.md +9 -5
  8. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/docs/configuration.md +14 -0
  9. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/intents.example.toml +2 -1
  10. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/pyproject.toml +1 -1
  11. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/cli.py +51 -2
  12. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/config.py +2 -2
  13. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/gate.py +30 -2
  14. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/hooks.py +8 -4
  15. hubkit_cli-0.2.0/scripts/hubcore/inbound.py +43 -0
  16. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/install.py +1 -1
  17. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/learnings.py +40 -6
  18. hubkit_cli-0.2.0/scripts/hubcore/lessons.py +341 -0
  19. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/orders.py +30 -3
  20. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/routed.py +78 -6
  21. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/slow.py +3 -3
  22. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/status.py +72 -4
  23. hubkit_cli-0.2.0/scripts/hubcore/trust.py +41 -0
  24. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/learn.py +2 -1
  25. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/skills/hubkit-setup/SKILL.md +1 -1
  26. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/run_reverts.py +66 -8
  27. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_blockers.py +19 -0
  28. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_compat.py +1 -1
  29. hubkit_cli-0.2.0/tests/test_guards2.py +45 -0
  30. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_hubkit.py +2 -2
  31. hubkit_cli-0.2.0/tests/test_inbound.py +41 -0
  32. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_install.py +18 -1
  33. hubkit_cli-0.2.0/tests/test_learning_rules.py +52 -0
  34. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_lessons.py +19 -0
  35. hubkit_cli-0.2.0/tests/test_own_work.py +27 -0
  36. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_peer_reports.py +43 -10
  37. hubkit_cli-0.2.0/tests/test_review_chain.py +44 -0
  38. hubkit_cli-0.2.0/tests/test_state_rules.py +82 -0
  39. hubkit_cli-0.2.0/tests/test_tool_guards.py +135 -0
  40. hubkit_cli-0.1.0/scripts/hubcore/lessons.py +0 -168
  41. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/.gitignore +0 -0
  42. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/CLAUDE.md +0 -0
  43. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/LICENSE +0 -0
  44. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/SECURITY.md +0 -0
  45. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/config.example.toml +0 -0
  46. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/docs/troubleshooting.md +0 -0
  47. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/references/design-sources.md +0 -0
  48. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/references/research.md +0 -0
  49. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/__init__.py +0 -0
  50. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/compat.py +0 -0
  51. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/redact.py +0 -0
  52. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/store.py +0 -0
  53. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubcore/watcher.py +0 -0
  54. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/scripts/hubkit +0 -0
  55. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/skills/hub-mep/SKILL.md +0 -0
  56. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/conftest.py +0 -0
  57. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_slow.py +0 -0
  58. {hubkit_cli-0.1.0 → hubkit_cli-0.2.0}/tests/test_watcher.py +0 -0
@@ -4,8 +4,27 @@ All notable changes to this project are recorded here. The format follows [Keep
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-11-06
8
+
9
+ ### Added
10
+ - Commands: `hubkit digest [--since]`, `hubkit trust record|ls`, `hubkit inbound ingest`, `hubkit work verify`, `learn.py address --check CMD`.
11
+ - The warn-only PreToolUse hook (`hubkit hooks --merge-guard`, matcher `Bash|TaskStop|AskUserQuestion`) now also warns on posts without a typed yes, banned commands, repeated commands, a whole-suite `pytest`, `TaskStop` without a stop word, and a decision card whose first option is not marked (Recommended). The routed hook warns on a brief with no done-when, output or estimate line, on a brief that forbids the fix a blocker needs, and when more than `routed.max_agents` agents hold work.
12
+ - Stop gate reasons: a "tests pass" or "it works" claim in a turn that ran no command, a PR or issue number with no link, an approval while CI at the head is not green, built work not pushed, an ask asked 3 times with no answer, and an unanswered prompt not re-read before "done".
13
+ - Order close: evidence names in a kind's `sha_evidence` must hold a commit sha.
14
+ - Watcher: stale finished agents (`watcher.stale_agent_min`), replies owed to other people (`watcher.reply_owed_min`), and own work not verified for `watcher.verify_stale_min`.
15
+ - `doctor` warns on a hubkit hook registered twice in `settings.json` and on no scheduled `hubkit eod`.
16
+ - Learning loop: `address --check`, an overlay cap of 200 lines, expiry and prose-only lists in `review`, and an ineffective mark on a class that repeats after it was addressed.
17
+ - New tests (336 pass on Linux) and revert entries for each guard.
18
+
19
+ ### Changed
20
+ - Correction patterns in `intents.example.toml` match insults aimed at the hub and no longer fire on one aimed at a tool.
21
+ - A routed item no longer closes on the peer's own "done"; it closes on an artifact (merged or closed PR, or a moved head with CI not failing). A merged or closed PR closes a review item.
22
+ - A one-line resume command may be given in the message as typed (`SKILL.md`).
23
+
24
+ ## [0.1.0]
25
+
7
26
  ### Added
8
- - Install like miseenplace: `uv tool install hubkit-cli` (wheel built and installed in a scratch venv; not published), `hubkit skills --install DIR` (hub, hub-mep, hubkit-setup), `hubkit hooks [--skills-dir DIR] [--merge-guard]` prints the settings.json hooks block, and a `hubkit-setup` skill.
27
+ - Install like miseenplace: `uv tool install hubkit-cli` (published on PyPI as 0.1.0), `hubkit skills --install DIR` (hub, hub-mep, hubkit-setup), `hubkit hooks [--skills-dir DIR] [--merge-guard]` prints the settings.json hooks block, and a `hubkit-setup` skill.
9
28
  - macOS and Windows support through one platform module (`hubcore/compat.py`); only Linux has been run so far.
10
29
  - Blockers (`order block|result`), watcher fixes, peer-report closing, `asks ls`, and warn-only merge guard.
11
30
  - Apache-2.0 license, `README.md` rewrite, `docs/` (architecture, configuration, commands, troubleshooting), `CONTRIBUTING.md`, `SECURITY.md`.
@@ -7,7 +7,7 @@ Thanks for looking. hubkit is small on purpose: standard library only, Python 3.
7
7
  ```bash
8
8
  git clone https://github.com/ankitkpandey1/hubkit && cd hubkit
9
9
  pip install pytest ruff mypy coverage
10
- python3 -m pytest tests -q # about 2 minutes: real subprocesses
10
+ python3 -m pytest tests -q # about 4 minutes: real subprocesses
11
11
  ruff check .
12
12
  mypy scripts/hubcore scripts/learn.py scripts/hubkit
13
13
  python3 tests/run_reverts.py # slow; run it when you touch a guarded behaviour
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hubkit-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -45,14 +45,15 @@ In a live hub, the prompt hook opens that order itself from your sentence, and t
45
45
  - **Order clock.** Orders open from your own prompts (never from peer or harness text), each with a first-action limit, a stall limit and a definition of done checked by evidence on close.
46
46
  - **Routed-work tracking.** A hook on `SendMessage` and `Agent` records what the hub asked of whom, one item per (owner, PR). It follows last heard-from, moved heads and GitHub reviews at the PR head.
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
- - **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).
49
- - **Status, end-of-day review.** A status block at session start and on each prompt, a daily archive, and `hubkit eod`.
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
+ - **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
51
 
51
52
  `SKILL.md` is the behaviour contract the hub session reads. This file is the operator view.
52
53
 
53
54
  ## Requirements
54
55
 
55
- - Linux. hubkit has only been run on Linux; macOS and Windows code exists but has never been run (see [Platform support](#platform-support)).
56
+ - Linux. hubkit has only been run on Linux so far (see [Platform support](#platform-support)).
56
57
  - Python 3.14 or newer (the code uses `except A, B:` without parentheses, PEP 758). Standard library only.
57
58
  - [Claude Code](https://code.claude.com), which provides the hooks and the skills directory.
58
59
  - Optional: `gh` (GitHub CLI) on `PATH` for PR checks; without it items show "PR unknown". Optional: [`mep`](https://pypi.org/project/miseenplace-cli/) for a personal task list in the status block; without it the block shows `mep: UNVERIFIED (mep CLI not found ...)`, and `[mep] enabled = false` silences that.
@@ -64,7 +65,7 @@ Install hubkit with `uv tool install hubkit-cli`, run `hubkit skills --install D
64
65
  for your tool, then run the hubkit-setup skill and follow it. Ask me only for things you cannot find out yourself.
65
66
  ```
66
67
 
67
- hubkit runs on Claude Code hooks, so the skills directory is Claude Code's: `~/.claude/skills` (user level) or `.claude/skills` (project level). The PyPI name `hubkit-cli` is free as of 2026-11-06 and is not published yet; until it is, install straight from GitHub with `uv tool install git+https://github.com/ankitkpandey1/hubkit` (or from a clone with `uv tool install ./hubkit`).
68
+ hubkit runs on Claude Code hooks, so the skills directory is Claude Code's: `~/.claude/skills` (user level) or `.claude/skills` (project level). `hubkit-cli` is on [PyPI](https://pypi.org/project/hubkit-cli/). To install the latest commit instead: `uv tool install git+https://github.com/ankitkpandey1/hubkit`.
68
69
 
69
70
  `hubkit skills --install DIR` copies three skills into `DIR`: `hub` (the rules and the scripts the hooks run), `hub-mep` (optional) and `hubkit-setup` (finds your facts, writes the config, registers the hooks, binds, proves it with `hubkit doctor`). Run it again after an upgrade to refresh the copies. `hubkit hooks --skills-dir DIR` prints the hooks block for `~/.claude/settings.json`.
70
71
 
@@ -140,12 +141,13 @@ Every key is documented in [docs/configuration.md](docs/configuration.md).
140
141
 
141
142
  | Behaviour | Strength |
142
143
  |---|---|
143
- | The Stop hook holds the turn while the hub owes something: no watch running, a stalled item, an unbacked verdict, an unsolved blocker, an item over its estimate, a "done" while your newest prompt is unread | Enforced (blocks the turn end) |
144
+ | The Stop hook holds the turn while the hub owes something: no watch running, a stalled item, an unbacked verdict, an unsolved blocker, an item over its estimate, a "done" while your newest prompt is unread, a claim of passing tests with no command run, an approval while CI is not green | Enforced (blocks the turn end) |
144
145
  | An order closes only with the evidence its kind requires | Enforced (the close is refused) |
145
- | A learned class counts as fixed only if a rule for it exists | Enforced (the address is refused) |
146
- | A second job sent to a busy owner while an order is open; a brief that bans the action a blocker needs | Warning only |
147
- | `gh pr merge` with no live order naming that PR (the merge guard, `hubkit hook pretool`) | Warning only, never denies |
148
- | `hubkit doctor` warning when your `intents.toml` lacks keys the example has | Warning only |
146
+ | A learned class counts as fixed only if a rule for it exists (and, with `--check`, its command passes) | Enforced (the address is refused) |
147
+ | A merge order closes only with the merge commit sha | Enforced (the close is refused) |
148
+ | A second job sent to a busy owner while an order is open; a brief that bans the action a blocker needs; a brief with no done-when, output or estimate line; more than 4 agents holding work | Warning only |
149
+ | `gh pr merge` with no live order naming that PR, a post without your typed yes, a banned command, `TaskStop`, a repeated command, a whole-suite `pytest`, an unmarked decision card (`hubkit hook pretool`) | Warning only, never denies |
150
+ | `hubkit doctor` warning when your `intents.toml` lacks keys the example has, a hook registered twice, or no scheduled `hubkit eod` | Warning only |
149
151
 
150
152
  Details and exact rules: [SKILL.md](SKILL.md) and [docs/architecture.md](docs/architecture.md#stop-gate-gatepy).
151
153
 
@@ -168,19 +170,11 @@ python3 tests/run_reverts.py # each guard reverted in a copy must fail it
168
170
 
169
171
  ## Platform support
170
172
 
171
- All OS-specific code is in one module, `scripts/hubcore/compat.py` (standard library only): process start time and parent (`/proc` on Linux, `ps` on macOS, kernel32 through `ctypes` on Windows), file locks (`flock`, or `msvcrt` on Windows), the scheduler check (`crontab -l`, or `schtasks /query` on Windows) and UTF-8 stdio.
172
-
173
- | OS | Status |
174
- |---|---|
175
- | Linux | Full test suite run and passing here (ruff, mypy, pytest). |
176
- | macOS | The `ps` code path is exercised on Linux against the real `ps` (`tests/test_compat.py`), and mypy checks it with `--platform darwin`. Never run on a Mac. Not claimed to work until someone runs it there. |
177
- | Windows | The `ctypes` and `msvcrt` code is type-checked with `--platform win32` only. Never run on Windows. Tests that need a POSIX `sh` shim for `gh`, `crontab` or a notifier are skipped there. Not claimed to work until someone runs it there. There is no default desktop notification on Windows: set `tools.notify_cmd`. |
178
-
179
- There is no CI. Update this table only from a real run on that OS. On Windows the schedule is a Task Scheduler task whose name or command holds `hubkit` (for example `schtasks /create /sc minute /mo 5 /tn hubkit-tick /tr "python %USERPROFILE%\.claude\skills\hub\scripts\hubkit tick --if-bound"`), and hooks in `settings.json` call `python ...\hubkit hook <event>`.
173
+ hubkit has only been run on Linux so far, and the full test suite, ruff and mypy pass there. The code has paths for macOS and Windows, all in `scripts/hubcore/compat.py` (process lookup, file locks, scheduler check, UTF-8 stdio), but they have never been run on those systems. Treat them as unsupported until someone does.
180
174
 
181
175
  ## Limits and known gaps
182
176
 
183
- - Python 3.14 only. macOS and Windows are written but unproven; see Platform support.
177
+ - Python 3.14 only, and Linux only so far; see Platform support.
184
178
  - It targets one hub per machine. A second live hub is refused at bind.
185
179
  - Order creation takes a blocking `flock` (`scripts/hubcore/orders.py`); a process that holds the lock forever stalls order opens.
186
180
  - A hook payload with no `origin` field is treated as human input.
@@ -31,14 +31,15 @@ In a live hub, the prompt hook opens that order itself from your sentence, and t
31
31
  - **Order clock.** Orders open from your own prompts (never from peer or harness text), each with a first-action limit, a stall limit and a definition of done checked by evidence on close.
32
32
  - **Routed-work tracking.** A hook on `SendMessage` and `Agent` records what the hub asked of whom, one item per (owner, PR). It follows last heard-from, moved heads and GitHub reviews at the PR head.
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
- - **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).
35
- - **Status, end-of-day review.** A status block at session start and on each prompt, a daily archive, and `hubkit eod`.
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
+ - **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
37
 
37
38
  `SKILL.md` is the behaviour contract the hub session reads. This file is the operator view.
38
39
 
39
40
  ## Requirements
40
41
 
41
- - Linux. hubkit has only been run on Linux; macOS and Windows code exists but has never been run (see [Platform support](#platform-support)).
42
+ - Linux. hubkit has only been run on Linux so far (see [Platform support](#platform-support)).
42
43
  - Python 3.14 or newer (the code uses `except A, B:` without parentheses, PEP 758). Standard library only.
43
44
  - [Claude Code](https://code.claude.com), which provides the hooks and the skills directory.
44
45
  - Optional: `gh` (GitHub CLI) on `PATH` for PR checks; without it items show "PR unknown". Optional: [`mep`](https://pypi.org/project/miseenplace-cli/) for a personal task list in the status block; without it the block shows `mep: UNVERIFIED (mep CLI not found ...)`, and `[mep] enabled = false` silences that.
@@ -50,7 +51,7 @@ Install hubkit with `uv tool install hubkit-cli`, run `hubkit skills --install D
50
51
  for your tool, then run the hubkit-setup skill and follow it. Ask me only for things you cannot find out yourself.
51
52
  ```
52
53
 
53
- hubkit runs on Claude Code hooks, so the skills directory is Claude Code's: `~/.claude/skills` (user level) or `.claude/skills` (project level). The PyPI name `hubkit-cli` is free as of 2026-11-06 and is not published yet; until it is, install straight from GitHub with `uv tool install git+https://github.com/ankitkpandey1/hubkit` (or from a clone with `uv tool install ./hubkit`).
54
+ hubkit runs on Claude Code hooks, so the skills directory is Claude Code's: `~/.claude/skills` (user level) or `.claude/skills` (project level). `hubkit-cli` is on [PyPI](https://pypi.org/project/hubkit-cli/). To install the latest commit instead: `uv tool install git+https://github.com/ankitkpandey1/hubkit`.
54
55
 
55
56
  `hubkit skills --install DIR` copies three skills into `DIR`: `hub` (the rules and the scripts the hooks run), `hub-mep` (optional) and `hubkit-setup` (finds your facts, writes the config, registers the hooks, binds, proves it with `hubkit doctor`). Run it again after an upgrade to refresh the copies. `hubkit hooks --skills-dir DIR` prints the hooks block for `~/.claude/settings.json`.
56
57
 
@@ -126,12 +127,13 @@ Every key is documented in [docs/configuration.md](docs/configuration.md).
126
127
 
127
128
  | Behaviour | Strength |
128
129
  |---|---|
129
- | The Stop hook holds the turn while the hub owes something: no watch running, a stalled item, an unbacked verdict, an unsolved blocker, an item over its estimate, a "done" while your newest prompt is unread | Enforced (blocks the turn end) |
130
+ | The Stop hook holds the turn while the hub owes something: no watch running, a stalled item, an unbacked verdict, an unsolved blocker, an item over its estimate, a "done" while your newest prompt is unread, a claim of passing tests with no command run, an approval while CI is not green | Enforced (blocks the turn end) |
130
131
  | An order closes only with the evidence its kind requires | Enforced (the close is refused) |
131
- | A learned class counts as fixed only if a rule for it exists | Enforced (the address is refused) |
132
- | A second job sent to a busy owner while an order is open; a brief that bans the action a blocker needs | Warning only |
133
- | `gh pr merge` with no live order naming that PR (the merge guard, `hubkit hook pretool`) | Warning only, never denies |
134
- | `hubkit doctor` warning when your `intents.toml` lacks keys the example has | Warning only |
132
+ | A learned class counts as fixed only if a rule for it exists (and, with `--check`, its command passes) | Enforced (the address is refused) |
133
+ | A merge order closes only with the merge commit sha | Enforced (the close is refused) |
134
+ | A second job sent to a busy owner while an order is open; a brief that bans the action a blocker needs; a brief with no done-when, output or estimate line; more than 4 agents holding work | Warning only |
135
+ | `gh pr merge` with no live order naming that PR, a post without your typed yes, a banned command, `TaskStop`, a repeated command, a whole-suite `pytest`, an unmarked decision card (`hubkit hook pretool`) | Warning only, never denies |
136
+ | `hubkit doctor` warning when your `intents.toml` lacks keys the example has, a hook registered twice, or no scheduled `hubkit eod` | Warning only |
135
137
 
136
138
  Details and exact rules: [SKILL.md](SKILL.md) and [docs/architecture.md](docs/architecture.md#stop-gate-gatepy).
137
139
 
@@ -154,19 +156,11 @@ python3 tests/run_reverts.py # each guard reverted in a copy must fail it
154
156
 
155
157
  ## Platform support
156
158
 
157
- All OS-specific code is in one module, `scripts/hubcore/compat.py` (standard library only): process start time and parent (`/proc` on Linux, `ps` on macOS, kernel32 through `ctypes` on Windows), file locks (`flock`, or `msvcrt` on Windows), the scheduler check (`crontab -l`, or `schtasks /query` on Windows) and UTF-8 stdio.
158
-
159
- | OS | Status |
160
- |---|---|
161
- | Linux | Full test suite run and passing here (ruff, mypy, pytest). |
162
- | macOS | The `ps` code path is exercised on Linux against the real `ps` (`tests/test_compat.py`), and mypy checks it with `--platform darwin`. Never run on a Mac. Not claimed to work until someone runs it there. |
163
- | Windows | The `ctypes` and `msvcrt` code is type-checked with `--platform win32` only. Never run on Windows. Tests that need a POSIX `sh` shim for `gh`, `crontab` or a notifier are skipped there. Not claimed to work until someone runs it there. There is no default desktop notification on Windows: set `tools.notify_cmd`. |
164
-
165
- There is no CI. Update this table only from a real run on that OS. On Windows the schedule is a Task Scheduler task whose name or command holds `hubkit` (for example `schtasks /create /sc minute /mo 5 /tn hubkit-tick /tr "python %USERPROFILE%\.claude\skills\hub\scripts\hubkit tick --if-bound"`), and hooks in `settings.json` call `python ...\hubkit hook <event>`.
159
+ hubkit has only been run on Linux so far, and the full test suite, ruff and mypy pass there. The code has paths for macOS and Windows, all in `scripts/hubcore/compat.py` (process lookup, file locks, scheduler check, UTF-8 stdio), but they have never been run on those systems. Treat them as unsupported until someone does.
166
160
 
167
161
  ## Limits and known gaps
168
162
 
169
- - Python 3.14 only. macOS and Windows are written but unproven; see Platform support.
163
+ - Python 3.14 only, and Linux only so far; see Platform support.
170
164
  - It targets one hub per machine. A second live hub is refused at bind.
171
165
  - Order creation takes a blocking `flock` (`scripts/hubcore/orders.py`); a process that holds the lock forever stalls order opens.
172
166
  - A hook payload with no `origin` field is treated as human input.
@@ -43,7 +43,7 @@ A human prompt containing an order word (words come from `intents.toml`; hotfix,
43
43
  - **(a2) Saying it is blocked is not solving it.** A final message that calls an open order blocked, down, failed or waiting on a host, tool or person is held until a block record with a fix exists (word list: `intents.toml` `[stop_gate]`). *Enforced in code.*
44
44
  - **(b) An urgent step never queues behind other work on a busy agent.** Send it to a free owner. The routed hook warns when a second job goes to an owner already holding a live job while an order is open. *Warning enforced in code; the choice of owner is prose.*
45
45
  - **(f) Re-read his words before saying done.** `hubkit asks ls --since 12h` prints his recent prompts one per line; a final message that says done while his newest prompt is newer than the last `work done` and the last `asks ls` is held. *Enforced in code.*
46
- - **(g) A merge is done on his order only.** `hubkit hook pretool` (register it as a PreToolUse hook on Bash) warns, never denies, on `gh pr merge` with no live order and none of his typed words naming that PR. *Warning only.*
46
+ - **(g) A merge is done on his order only.** `hubkit hook pretool` (register it with `hubkit hooks --merge-guard`) warns, never denies, on `gh pr merge` with no live order and none of his typed words naming that PR; the same hook also warns on posts without his typed yes, banned commands, stopping an agent and an unmarked decision card. *Warning only.*
47
47
  - **(c) An agent's "that is a person's call" is checked, not relayed.** Read what the person already decided (the record, the order's words) before asking or telling anyone it needs them. *Checked in code:* a final message that relays "a person's call" with no Read/Grep/Glob/Bash call in that turn is held at the Stop gate (words: `intents.toml` `[stop_gate] unchecked_words`).
48
48
  - **(d) Before saying something is gone or absent, read the code that writes it.** A missing file, row or log line is proof of nothing until you know what produces it and when. *Checked in code:* a final message that says something is gone, lost or absent, with no lookup in that turn, is held the same way.
49
49
  - **(e) A brief must not forbid the one action that clears the blocker.** Before sending a brief, check its banned-command list against the step the order needs. *Checked in code:* the routed hook warns when a brief's "do not / never" line shares a key word with an open order's blocker or fix.
@@ -80,7 +80,7 @@ Silence on routed work past `stall_min` (reviews: `review_stall_min`, hard `OVER
80
80
  - At most 15 lines. Lead with what changed or what the user must decide. Tag claims verified / read / inferred. Full URLs. Times in the user's zone.
81
81
  - One line names what was left out and where it is. Input needed: the question tool after the report, recommended option first.
82
82
  - Hand over all PRs of a wave in one list, only when reviewed and ready.
83
- - Code or commands for the user go to a file, not a raw block (the overlay may name the clipboard step).
83
+ - Code or commands for the user go to a file, not a raw block (the overlay may name the clipboard step). A one-line resume command is the exception: put it in the message as typed.
84
84
 
85
85
  ## 8. End of day
86
86
 
@@ -46,7 +46,7 @@ State lives in `$HUBKIT_STATE`, else `$XDG_STATE_HOME/hubkit` (default `~/.local
46
46
  | PostToolUse `AskUserQuestion` | `answer` | A card answer goes through the same intake and human-only filter as a typed prompt, and closes `ask` work items. |
47
47
  | PostToolUse `SendMessage\|Agent` | `routed` | Records or refreshes a routed-work item (see Routed work). One `gh` baseline read per named PR, 3 s in total. |
48
48
  | Stop | `stop` | The gate. Prints JSON `{"decision": "block", "reason": ...}` while the hub owes something. |
49
- | PreToolUse `Bash` | `pretool` | Warn-only merge guard: `gh pr merge` with no live order and none of his typed words naming the PR adds a warning as additional context. Never denies. Register it yourself in `settings.json`. |
49
+ | PreToolUse `Bash\|TaskStop\|AskUserQuestion` | `pretool` | Warn-only guard; the warning goes in as additional context and nothing is ever denied. It warns on: `gh pr merge` with no live order and none of his typed words naming the PR; a post to a person or thread (`mep send|reply|post`, a chat send, `gh pr|issue comment|review`, a `gh api` comments or reviews call, or a Slack post or send) with no typed yes of his in the last 10 minutes; a banned command (`rm -rf`, `pkill`, `git reset --hard`, `git stash`, `git clean`, a forced push, `git checkout <ref> -- <path>`); the same command run 3 times in 10 minutes; `pytest` with no path, `-k` or `-m`; `TaskStop` without a stop word of his in the last 30 minutes; a decision card whose first option is not marked (Recommended). Register it with `hubkit hooks --merge-guard`, or add it to `settings.json` yourself. |
50
50
  | any | `record` | Schema canary. Every hub run writes the real payload's keys and value types (never values) to `payload-samples/`. |
51
51
 
52
52
  A hook failure is shown and logged (`hook-errors.jsonl`) only for the hub. The stop gate's own blocks are not counted as failures.
@@ -59,7 +59,7 @@ An order is a row family in `orders.jsonl` (`open`, `move`, `hold`, `resume`, `c
59
59
  - **Clock.** A first action is due within the kind's `first_action_min`; after that, movement (`order move`, or a touched `movement_files` glob) is due every `stall_min`. A deadline in the prompt ("in 30 min", "by 17:00") is parsed too.
60
60
  - **Holds.** Only explicit `hold_words` hold an order; `urgent_words` keep or restart the clock; `cancel_words` cancel. Long prompts (`verb_max_chars`) never hold or cancel.
61
61
  - **Chases.** A short prompt matching `intake.chase` while an order is open is recorded as a chase. Three chases print a stop-loss line.
62
- - **Close.** `order close` requires every evidence name the kind's `done_when` lists; `file:<path>` evidence must exist and be newer than the order. A closed order is archived under `archive/archive_<day>.jsonl`.
62
+ - **Close.** `order close` requires every evidence name the kind's `done_when` lists; `file:<path>` evidence must exist and be newer than the order. Evidence names in the kind's `sha_evidence` (the merge kind's `merged`) must hold a commit sha. A closed order is archived under `archive/archive_<day>.jsonl`.
63
63
 
64
64
  ## Routed work (`routed.py`)
65
65
 
@@ -98,6 +98,9 @@ Every tick (cron with `--if-bound`, or inside `hubkit watch`) also checks things
98
98
  - **Notification.** New alerts go to `tools.notify_cmd` when set. With none set, only `hub:*` and `peer:*` alerts use the platform default (`notify-send` on Linux, `osascript` on macOS, when installed). Nothing here ever posts to chat.
99
99
  - **False alarms.** A silent item is not a stall while it is `blocked_on` another live job (`hubkit work block`), or while its holder's session is `busy` and its transcript is still growing (for up to 3x the limit). A head moved by the owner's own newest commit is progress, not a moved head. Every stall alert names the holder's state (`holder: ...`).
100
100
  - **Short asks.** A short message to a peer is tracked when a request verb opens a sentence or it ends in a question; acknowledgements, status questions and thanks are not.
101
+ - **Own work.** `active` and `priority` items need an estimate within 5 minutes, say so out loud when over it, and raise a stale-verification alert when not stamped with `hubkit work verify` for `watcher.verify_stale_min`.
102
+ - **Finished agents.** A background agent that reported finished more than `watcher.stale_agent_min` ago while its item is still listed raises `agent:<owner>:stale`.
103
+ - **Inbound asks.** `hubkit inbound ingest` opens one `reply` item per message id from the inbox watcher's lines; one owed longer than `watcher.reply_owed_min` raises one alert.
101
104
  - **PRs are keyed by repo and number.** `owner/repo#N`, a full URL, or `<alias> N` for a `routed.repo_aliases` entry name their repo; only a bare `#N` falls back to `routed.repo`.
102
105
 
103
106
  ## Stop gate (`gate.py`)
@@ -110,7 +113,12 @@ The Stop hook blocks the end of the turn, with the exact next action, while any
110
113
  4. A job with an estimate on record is past it and not yet addressed (see the slowness check).
111
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.
112
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).
113
- 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.
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.
118
+
119
+ ## Digest and trust
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.
114
122
 
115
123
  ## The learn loop
116
124
 
@@ -119,7 +127,7 @@ This is the adaptive part. The mechanism, in the order it runs (`hooks.py`, `lea
119
127
  1. **Detect.** The prompt hook injects a `CORRECTION` line, with the quote, when a typed prompt matches a `[corrections] patterns` regex or repeats a recent prompt (word-set similarity at least `repeat_similarity` within `repeat_window_min`).
120
128
  2. **Record** (`learn.py add`). The hub records a class of mistake: kebab-case class, the gate it broke (G1 to G8 in SKILL.md), the exact quote, situation, wrong, right, evidence. A repeat of an existing class appends a dated `seen` line. Each entry stores a `basis` hash of the rule texts (overlay plus SKILL.md) at the time. Every field is redacted; edits are locked and atomic.
121
129
  3. **Promote** (`learn.py promote-candidates`, then `learn.py promote <class> --gate G --rule "..."`). A class seen two or more times is a candidate. Promoting writes one short rule line tagged `[<class>]` into your overlay under its gate. Personal rules stay out of the shared `SKILL.md`.
122
- 4. **Address** (`learn.py address --class <c> --how "..."`). The class is marked fixed only if the rules changed since the entry was last seen **and** a rule line tagged `[<class>]` exists. Otherwise it is refused: a fix without a rule is not a fix.
130
+ 4. **Address** (`learn.py address --class <c> --how "..."`). The class is marked fixed only if the rules changed since the entry was last seen **and** a rule line tagged `[<class>]` exists. Otherwise it is refused: a fix without a rule is not a fix. With `--check CMD` the command must also exit 0, and every earlier check must still pass. `promote` is refused when the overlay would pass 200 lines. A class seen again after it was addressed is marked ineffective.
123
131
  5. **Probation.** The status block shows at most five learnings seen in the last 30 days; an addressed class stays visible for 14 days as "probation". If the class is seen again after it was addressed, `hubkit eod` and `learn.py review` list it as regressed (a fix that did not work), and it is to be fixed the same day.
124
132
 
125
133
  hubkit does the detecting, storing, refusing and reporting; the hub model does the recording and the rule writing, so the loop only adapts as far as the hub follows it.
@@ -7,7 +7,7 @@
7
7
  | Verb | What it does |
8
8
  |---|---|
9
9
  | `skills --install DIR` | Copies the hub, hub-mep and hubkit-setup skills into DIR (re-run refreshes; the running copy is left alone). |
10
- | `hooks [--skills-dir DIR] [--merge-guard]` | Prints the `hooks` JSON for settings.json. `--merge-guard` adds the warn-only PreToolUse merge guard. Never edits settings.json. |
10
+ | `hooks [--skills-dir DIR] [--merge-guard]` | Prints the `hooks` JSON for settings.json. `--merge-guard` adds the warn-only PreToolUse guard (matcher `Bash|TaskStop|AskUserQuestion`, see [architecture](architecture.md#hooks-scriptshubcorehookspy)). Never edits settings.json. |
11
11
  | `hubkit bind [--pid N]` | Bind this session (found by walking up the process tree, or the process `N`) as the hub. Interactive sessions only; refuses while another hub is live. |
12
12
  | `hubkit unbind` | Remove the bind. |
13
13
  | `hubkit status [--json]` | Rebuild `status.json` and print the status block (or the JSON). |
@@ -16,6 +16,10 @@
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. |
20
+ | `hubkit inbound ingest [--file F]` | Turns the inbox watcher's lines into `reply` work items (one per asker, no duplicates). |
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
+ | `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. |
19
23
  | `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. |
20
24
  | `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. |
21
25
  | `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. |
@@ -34,8 +38,8 @@
34
38
  | `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). |
35
39
  | `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. |
36
40
  | `hubkit eod [--day D] [--note ".."]` | Write and print the end-of-day review. |
37
- | `hubkit doctor [--check-clean]` | Print config, state, bind, hook heartbeats, ledger health and a count of secret-shaped strings in state. `--check-clean` also lists checkout files containing a `private.terms` word. Exit 1 on a problem. |
38
- | `hubkit hook EVENT` | Run one hook (`session`, `prompt`, `answer`, `routed`, `stop`, `record`); reads the payload from stdin. For Claude Code, not for people. |
41
+ | `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
+ | `hubkit hook EVENT` | Run one hook (`session`, `prompt`, `answer`, `routed`, `stop`, `record`, `pretool`); reads the payload from stdin. For Claude Code, not for people. |
39
43
 
40
44
  ## learn.py
41
45
 
@@ -47,8 +51,8 @@
47
51
  | `list [--last N]` | The N most recently seen entries (default 30). |
48
52
  | `promote-candidates [--min N]` | Classes seen at least N times (default 2). |
49
53
  | `promote CLASS --gate G --rule "..."` | Write a rule line tagged `[CLASS]` into the overlay under its gate. |
50
- | `address --class C --how "..."` | Mark a class fixed. Refused unless the overlay or SKILL.md changed since it was last seen and holds a `[C]` rule line. |
51
- | `review [--day D]` | New, repeated, regressed, holding and unaddressed classes for a day. |
54
+ | `address --class C --how "..." [--check CMD]` | Mark a class fixed. Refused unless the overlay or SKILL.md changed since it was last seen and holds a `[C]` rule line. `--check` also runs a command that must exit 0 first, and every earlier `--check` must still pass. Promote is refused past the overlay's 200-line cap. |
55
+ | `review [--day D]` | New, repeated, regressed, holding and unaddressed classes for a day, plus rules to expire (addressed and not seen since) and rules that exist only as prose. A class seen again after it was addressed is marked ineffective. |
52
56
 
53
57
  ## Example
54
58
 
@@ -81,6 +81,9 @@ Estimates and the slowness check (hub-internal, see architecture).
81
81
  | `waiting_min` | `3` | A peer waiting on a prompt this long raises `peer:<name>:waiting`. |
82
82
  | `active_s` | `120` | A busy holder whose transcript grew this recently is working, not stalled. |
83
83
  | `watch_grace_min` | `5` | Live work with no running `hubkit watch` this long raises `watch:dead`. |
84
+ | `stale_agent_min` | `30` | A background agent that reported finished this long ago while its item is still listed raises `agent:<owner>:stale`. |
85
+ | `reply_owed_min` | `60` | A `reply` item (from `hubkit inbound ingest`) open this long raises one alert. |
86
+ | `verify_stale_min` | `180` | An `active` or `priority` item not stamped with `hubkit work verify` for this long raises an alert. |
84
87
  | `notify_user` | `true` | Use the platform default desktop notification for hub and peer alerts when `tools.notify_cmd` is empty. |
85
88
 
86
89
  ### `[stop_guard]`
@@ -101,6 +104,7 @@ Estimates and the slowness check (hub-internal, see architecture).
101
104
  | `review_stall_min` | `5` | Reviews: chase after this. |
102
105
  | `review_overdue_min` | `10` | Reviews: hard OVERDUE this long after routing, whatever the owner said. |
103
106
  | `skip_owners` | `["inbox"]` | Owners that are never tracked. |
107
+ | `max_agents` | `4` | More distinct agents than this holding live routed work makes the routed hook warn. |
104
108
  | `min_chars` | `200` | A message without a PR is work only from this length. |
105
109
  | `gh_timeout_s` | `10` | Timeout for one `gh` read. |
106
110
  | `queued_wait_min` | `60` | Work queued "after X" gets no review clock until this long after the ask (or until a review shows). |
@@ -126,11 +130,20 @@ The first kind whose words match wins, so put the narrower kind first (`hotfix`
126
130
  | `not_order_phrases` | Phrases that contain an order word but are not orders ("release notes"). |
127
131
  | `question_start` | Regex; an order word in a question is never an order, urgent words or not (the hook then says so and shows `hubkit order open` for opening one by hand). |
128
132
  | `unconfirmed_ttl_min` | `tick` expires unconfirmed orders after this long. |
133
+ | `blocker_fix_min` | A blocker with no fix, or a fix with no result after this many minutes (default `3`), alerts and holds the Stop gate. |
129
134
  | `held_review_min` | A hold this long raises an alert. |
130
135
  | `cancel_words`, `resume_words` | Words for the cancel and resume verbs. |
131
136
  | `verb_max_chars` | Prompts longer than this never hold, cancel, resume or count as chases. |
132
137
  | `chase` | Regex; a short prompt matching it while an order is open is a chase. |
133
138
 
139
+ ### `[stop_gate]`
140
+ | Key | Meaning |
141
+ |---|---|
142
+ | `blocked_words` | A final message with one of these, while an order is open and none has a fix on record, cannot end the turn. |
143
+ | `unchecked_words` | "A person's call" style claims held when the turn made no lookup. |
144
+ | `verified_words` | "Tests pass", "it works", "is deployed" style claims held when the turn ran no Bash command. |
145
+ | `full_links` | Default `true`. Set `false` to stop holding messages that name a PR or issue number with no link. |
146
+
134
147
  ### `[corrections]` (the learn loop)
135
148
  | Key | Meaning |
136
149
  |---|---|
@@ -146,6 +159,7 @@ The first kind whose words match wins, so put the narrower kind first (`hotfix`
146
159
  | `stall_min` | No movement for this long raises an alert. |
147
160
  | `dispatch` | Text printed when the order opens. |
148
161
  | `done_when` | Evidence names `order close` requires (`--evidence name=value`). |
162
+ | `sha_evidence` | Evidence names that must hold a commit sha (the example merge kind uses `["merged"]`). |
149
163
  | `movement_files` | Globs; a file touched after the order opened counts as movement. |
150
164
 
151
165
  Add your own kinds the same way; the words are matched as whole words, case-insensitively.
@@ -37,7 +37,7 @@ blocked_words = ["blocked", "is down", "are down", "failed", "failing", "no host
37
37
  [corrections]
38
38
  # A human prompt matching one of these regexes (or repeating the previous prompt) makes the prompt
39
39
  # hook inject a reminder to record it: learn.py add with the quote.
40
- patterns = ["(?i)\\b(useless|stupid|dumb|idiot|wtf|r u (dumb|kidding|sleeping))\\b", "(?i)^\\W*(wrong|no+|nope)[!. ]*$", "(?i)\\bwhy (did|didn't|do|are) (you|u)\\b", "(?i)\\bi (told|asked|said)\\b", "(?i)\\balready (told|said|asked|replied)\\b", "(?i)\\bnot what i (asked|said|meant)\\b"]
40
+ patterns = ["(?i)\\b(you|u|ur|you're|youre|r u)\\s+(are\\s+|r\\s+)?(so\\s+|really\\s+)?(useless|stupid|dumb|idiot|kidding|sleeping)\\b", "(?i)^\\W*(useless|stupid|dumb|idiot|wtf)\\W*$", "(?i)\\bwtf\\b", "(?i)^\\W*(wrong|no+|nope)[!. ]*$", "(?i)\\bwhy (did|didn't|do|are) (you|u)\\b", "(?i)\\bi (told|asked|said)\\b", "(?i)\\balready (told|said|asked|replied)\\b", "(?i)\\bnot what i (asked|said|meant)\\b"]
41
41
  repeat_similarity = 0.6
42
42
  repeat_window_min = 30
43
43
 
@@ -63,6 +63,7 @@ first_action_min = 5
63
63
  stall_min = 30
64
64
  dispatch = "Merge exactly the named set."
65
65
  done_when = ["merged"]
66
+ sha_evidence = ["merged"] # evidence names that must hold the merge commit sha
66
67
  movement_files = []
67
68
 
68
69
  [kinds.code_red]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hubkit-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
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"
@@ -4,12 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
8
+ import re
7
9
  import sys
8
10
  import time
9
11
  import tomllib
10
12
  from pathlib import Path
11
13
 
12
- from . import compat, config, hooks, install, lessons, routed, status, store, watcher
14
+ from . import compat, config, hooks, inbound, install, lessons, routed, status, store, trust, watcher
13
15
  from .orders import Orders
14
16
  from .redact import count_hits_text
15
17
 
@@ -81,6 +83,8 @@ def cmd_work(a, cfg: dict) -> int:
81
83
  from . import slow
82
84
 
83
85
  print(slow.re_estimate(cfg, a.id, a.minutes or 0, a.why))
86
+ elif a.action == "verify":
87
+ print(status.work_verify(cfg, a.id))
84
88
  elif a.action == "done":
85
89
  print(status.work_done(cfg, a.id, a.note))
86
90
  else:
@@ -122,6 +126,29 @@ def intents_gaps(cfg: dict) -> list[str]:
122
126
  return gaps
123
127
 
124
128
 
129
+ def duplicate_hooks(settings: Path) -> list[str]:
130
+ """Events for which the user's settings.json runs `hubkit hook <event>` more than once (each run double-counts). Read only."""
131
+ if not settings.exists():
132
+ return []
133
+ try:
134
+ hooks_cfg = json.loads(settings.read_text(encoding="utf-8")).get("hooks", {})
135
+ except ValueError, AttributeError:
136
+ return []
137
+ seen: dict[str, int] = {}
138
+ for blocks in hooks_cfg.values():
139
+ for block in blocks if isinstance(blocks, list) else []:
140
+ for h in block.get("hooks", []):
141
+ m = re.search(r"hubkit\S*\s+hook\s+(\w+)", str(h.get("command", "")))
142
+ if m:
143
+ seen[m.group(1)] = seen.get(m.group(1), 0) + 1
144
+ return sorted(ev for ev, n in seen.items() if n > 1)
145
+
146
+
147
+ def eod_scheduled() -> bool:
148
+ """True when a scheduler entry runs `hubkit ... eod`."""
149
+ return any("hubkit" in x and re.search(r"\beod\b", x) for x in compat.scheduler_lines())
150
+
151
+
125
152
  def cmd_doctor(a, cfg: dict) -> int:
126
153
  """Print config, bind, hook heartbeat and state health; exit 1 on secret-shaped strings or private terms."""
127
154
  sd = store.state(cfg)
@@ -138,6 +165,11 @@ def cmd_doctor(a, cfg: dict) -> int:
138
165
  print(f"orders.jsonl: {len(rows)} rows, {bad} bad lines")
139
166
  errs = store.read_rows(sd / "hook-errors.jsonl")[0]
140
167
  print(f"hook-errors.jsonl: {len(errs)} rows")
168
+ if not eod_scheduled():
169
+ print(f"WARNING no {compat.scheduler_name()} entry runs `hubkit eod`: the end-of-day review and its learning pass only happen when someone asks for them.")
170
+ dup = duplicate_hooks(Path(os.environ.get("HUBKIT_SETTINGS") or Path.home() / ".claude" / "settings.json"))
171
+ if dup:
172
+ print(f"WARNING settings.json runs hubkit hook more than once for: {', '.join(dup)}. Each run double-counts prompts and answers; remove the duplicate entries.")
141
173
  gaps = intents_gaps(cfg)
142
174
  if gaps:
143
175
  print(f"WARNING intents.toml lacks what intents.example.toml has: {', '.join(gaps)}. Copy those lines from the example; the checks they configure use built-in defaults or stay off.")
@@ -183,6 +215,15 @@ def main(argv: list[str] | None = None) -> int:
183
215
  hk.add_argument("--skills-dir", default="$HOME/.claude/skills")
184
216
  hk.add_argument("--merge-guard", action="store_true", help="include the warn-only PreToolUse merge check")
185
217
 
218
+ tr = sub.add_parser("trust")
219
+ tr.add_argument("action", choices=["record", "ls"])
220
+ tr.add_argument("--class", dest="cls", default="")
221
+ tr.add_argument("--outcome", default="")
222
+
223
+ ib = sub.add_parser("inbound")
224
+ ib.add_argument("action", choices=["ingest"])
225
+ ib.add_argument("--file", help="the inbox watcher's output (default: stdin)")
226
+
186
227
  k = sub.add_parser("asks")
187
228
  k.add_argument("action", choices=["ls"])
188
229
  k.add_argument("--since", default="12h", help="how far back, for example 30m, 12h, 2d")
@@ -202,7 +243,7 @@ def main(argv: list[str] | None = None) -> int:
202
243
  o.add_argument("--estimate", type=int, help="move: minutes this order should take (default: the usual time)")
203
244
 
204
245
  w = sub.add_parser("work")
205
- w.add_argument("action", choices=["add", "done", "ls", "estimate", "block"])
246
+ w.add_argument("action", choices=["add", "done", "ls", "estimate", "block", "verify"])
206
247
  w.add_argument("id", nargs="?")
207
248
  w.add_argument("--kind", default="active")
208
249
  w.add_argument("--title", default="")
@@ -224,6 +265,8 @@ def main(argv: list[str] | None = None) -> int:
224
265
 
225
266
  st = sub.add_parser("status")
226
267
  st.add_argument("--json", action="store_true")
268
+ dg = sub.add_parser("digest")
269
+ dg.add_argument("--since", default="12h", help="how far back, for example 30m, 12h, 2d")
227
270
  e = sub.add_parser("eod")
228
271
  e.add_argument("--day")
229
272
  e.add_argument("--note", default="")
@@ -254,6 +297,10 @@ def main(argv: list[str] | None = None) -> int:
254
297
  print("\n".join(install.install_skills(Path(a.install).expanduser())))
255
298
  elif a.cmd == "hooks":
256
299
  print(install.hooks_block(a.skills_dir, a.merge_guard))
300
+ elif a.cmd == "trust":
301
+ print(trust.record(cfg, a.cls, a.outcome) if a.action == "record" else "\n".join(f"{c}: {m}" for c, m in sorted(trust.mode(cfg).items())) or "no outcomes recorded")
302
+ elif a.cmd == "inbound":
303
+ print("\n".join(inbound.ingest(cfg, Path(a.file).read_text(encoding="utf-8") if a.file else sys.stdin.read())) or "nothing new")
257
304
  elif a.cmd == "asks":
258
305
  print("\n".join(lessons.asks_lines(cfg, a.since)))
259
306
  elif a.cmd == "watch":
@@ -261,6 +308,8 @@ def main(argv: list[str] | None = None) -> int:
261
308
  elif a.cmd == "status":
262
309
  st_ = status.write_status(cfg)
263
310
  print(json.dumps(st_, indent=1) if a.json else status.render_block(cfg, Orders(cfg), st_))
311
+ elif a.cmd == "digest":
312
+ print("\n".join(status.digest(cfg, a.since)))
264
313
  elif a.cmd == "eod":
265
314
  day = a.day or store.day_of(cfg, time.time())
266
315
  out = status.eod(cfg, day, a.note)
@@ -28,14 +28,14 @@ DEFAULTS: dict = {
28
28
  "extensions_dir": "", # default: <config dir>/ext
29
29
  },
30
30
  "tools": {"mep": "mep", "notify_cmd": "", "gh": "gh"},
31
- "routed": {"stall_min": 15, "min_chars": 200, "skip_owners": ["inbox"], "gh_timeout_s": 10,
31
+ "routed": {"stall_min": 15, "min_chars": 200, "max_agents": 4, "skip_owners": ["inbox"], "gh_timeout_s": 10,
32
32
  "repo": "", "reply_window_min": 60, "queued_wait_min": 60, "owner_logins": {}, "skip_briefs": ["mep watch", "inbox watch"],
33
33
  "activity_globs": [
34
34
  "~/.claude/projects/*/*/subagents/agent-{id}.jsonl",
35
35
  "/tmp/claude-*/*/*/tasks/{id}.output",
36
36
  ], "reviewer_logins": [], "review_words": ["review"], "review_stall_min": 5, "review_overdue_min": 10},
37
37
  "slow": {"min_samples": 3, "order_default_min": 30, "floor_min": 2, "tool_min": 2, "scan_kb": 2048},
38
- "watcher": {"stale_agent_min": 30, "stuck_min": 30, "unhandled_min": 10, "waiting_min": 3, "active_s": 120, "watch_grace_min": 5, "notify_user": True},
38
+ "watcher": {"stale_agent_min": 30, "reply_owed_min": 60, "verify_stale_min": 180, "stuck_min": 30, "unhandled_min": 10, "waiting_min": 3, "active_s": 120, "watch_grace_min": 5, "notify_user": True},
39
39
  "mep": {"enabled": True, "timeout_s": 30},
40
40
  "watchdog": {"transcript_scan_hours": 24, "cron_stale_min": 15},
41
41
  "think": {"decisions": ""}, # path of the active slug's decisions.md; its last lines go in the status block
@@ -65,6 +65,23 @@ def unverified_verdict(cfg: dict, last: str) -> list[str]:
65
65
  return sorted(routed.pr_num(u) for u in urls.values() if not _reviewed_at_head(cfg, u, timeout))
66
66
 
67
67
 
68
+ def ci_not_green(cfg: dict, last: str) -> list[str]:
69
+ """PRs the message gives an approving verdict on while CI at the head is failing or pending. A PR is resolved from
70
+ its URL, a repo-qualified ref or `routed.repo`; a bare number is matched to a tracked item's PR."""
71
+ out: list[str] = []
72
+ timeout = float(routed.opts(cfg)["gh_timeout_s"])
73
+ tracked = [u for i in routed.items(cfg) for u in [*i["prs"], *i.get("queue", [])]]
74
+ for sentence in re.split(r"(?<=[.!?])\s+|\n", last):
75
+ if not (re.search(r"(?i)\bapprov", sentence) and (PR_REF.search(sentence) or REPO_PR.search(sentence)) and not AWAITING.search(sentence)):
76
+ continue
77
+ for ref in routed.refs(cfg, sentence):
78
+ for u in [ref] if ref.startswith("http") else [t for t in tracked if routed.same_pr(t, ref)]:
79
+ info = routed.pr_info(cfg, u, timeout)
80
+ if info and info.get("ci") in ("failing", "pending"):
81
+ out.append(f"#{routed.pr_num(u)} ({info['ci']})")
82
+ return sorted(set(out))
83
+
84
+
68
85
  def _reviewed_at_head(cfg: dict, url: str, timeout: float) -> bool:
69
86
  """True when GitHub shows a non-author review at the head, or cannot be read (an unreadable PR proves nothing)."""
70
87
  info = routed.pr_info(cfg, url, timeout)
@@ -124,21 +141,32 @@ def blocker_reasons(cfg: dict, last: str, now: float) -> list[str]:
124
141
  return out
125
142
 
126
143
 
127
- def stop_reasons(cfg: dict, last: str, now: float | None = None, transcript: str | None = None) -> list[str]:
144
+ def stop_reasons(cfg: dict, last: str, now: float | None = None, transcript: str | None = None, cwd: str | None = None) -> list[str]:
128
145
  """Every reason the turn cannot end yet, each with its exact action. Empty = the turn may end."""
129
146
  now = now or time.time()
147
+ ci_bad = ci_not_green(cfg, last) # before prompt_lines: a reviewed item closes there
130
148
  reasons = routed.prompt_lines(cfg, now)
131
149
  reasons += blocker_reasons(cfg, last, now)
132
- reasons += [r for r in (lessons.unswept_asks(cfg, last, now), lessons.unchecked_claim(cfg, last, transcript)) if r]
150
+ if r := lessons.unpushed_work(last, cwd):
151
+ reasons.append(r)
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
+ if refs := lessons.bare_refs(cfg, last):
154
+ reasons.append(f"hubkit: your message names {', '.join(refs[:4])} with no link. NEXT: give the full URL of each PR or issue so he can click it")
133
155
  try:
134
156
  reasons += slow.gate_lines(cfg, now)
135
157
  except Exception as e: # a failing estimate check must not wedge the hub's turn end
136
158
  store.append_row(store.state(cfg) / "hook-errors.jsonl", {"where": "slow.gate", "error": repr(e)[:200]})
137
159
  if open_work(cfg) and not watch_live(cfg):
138
160
  reasons.append(f"hubkit: nothing will wake you when routed work stalls. NEXT: start in the background: {WATCH_CMD}")
161
+ reasons += [f"hubkit: the ask {i['id']} ({i['title'][:50]}) was asked {i['asked']} times with no answer. Stop asking. NEXT: take the safest default, record it with the reason, "
162
+ f"and go on: hubkit work done {i['id']} --note \"defaulted to ...\"" for i in work_load(store.state(cfg)) if i.get("kind") == "ask" and int(i.get("asked", 1)) >= 3]
139
163
  for n in unverified_verdict(cfg, last):
140
164
  reasons.append(f"hubkit: your message gives a verdict on #{n}, but GitHub shows no review at its current head "
141
165
  "(agent words are not evidence). NEXT: correct the message; relay a verdict only after the item closes itself")
166
+ if ci_bad:
167
+ reasons.append(f"hubkit: your message approves {', '.join(ci_bad)} but CI at the head is not green. NEXT: say CI is not green, or wait for it, before relaying an approval")
168
+ if (r := lessons.prose_ask(last, transcript, is_question(cfg, last) and blocked_on_user(cfg))):
169
+ reasons.append(r)
142
170
  if is_question(cfg, last) and not blocked_on_user(cfg):
143
171
  reasons.append("hubkit: you ended on a question to the user but nothing is blocked on him. NEXT: settle it from the record "
144
172
  "or route it; only if it truly needs him run: hubkit work add --kind ask --title \"<the one question>\" --note \"<ordered work it blocks>\"")