Skip to content

Commit f7cd376

Browse files
committed
refactor(plugin): clarify UserPromptSubmit bootstrap architecture
The plugin uses two distinct hook layers: plugin-local hooks.json for SessionStart/PreToolUse/PostToolUse/Stop, and a SessionStart-driven global install in ~/.claude/settings.json for UserPromptSubmit. Because UserPromptSubmit is absent from hooks.json, contributors auditing the package repeatedly mistake the missing entry for a bug (see #1380), when in fact session-start.py is the deliberate source of truth. Approach A (document + regression-test) was chosen over Approach B (migrate UserPromptSubmit into hooks.json and delete the global install). Rationale: - low risk: no behavior change, only comments + docs + tests - preserves the global-scope semantics for PLAN/ACT/EVAL/AUTO keyword detection (fires in any session, not just CodingBuddy projects) - keeps file footprint separate from #1381 (stale legacy hook cleanup), which must remain parallel-safe until that migration lands - Approach B remains viable as a future follow-up once #1381 is done Changes: - hooks/hooks.json: add top-level $comment field pointing contributors to session-start.py and bootstrap-architecture.md - hooks/session-start.py: expand module docstring to name the bootstrap role and document the two-layer architecture; expand register_hook_in_settings docstring with the why behind global install - docs/bootstrap-architecture.md: new long-form document covering both layers, rationale, source-of-truth cheat sheet, and drift safeguards - tests/test_bootstrap_architecture.py: 18 regression tests locking the invariants (hooks.json shape, $comment presence, session-start docstring, doc existence and contents) so future refactors can't silently drift between the layers Test plan: - yarn workspace codingbuddy-claude-plugin lint|format:check|typecheck - yarn workspace codingbuddy-claude-plugin test:coverage (123 passed) - python3 -m pytest tests/ (766 passed, +18 new bootstrap tests) - yarn workspace codingbuddy-claude-plugin circular|build Closes #1380
1 parent b987135 commit f7cd376

4 files changed

Lines changed: 437 additions & 13 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Plugin Hook Bootstrap Architecture
2+
3+
> **Status:** current as of `codingbuddy-claude-plugin` v5.3.0
4+
> **Related issues:** [#1380](https://github.com/JeremyDev87/codingbuddy/issues/1380) (this document), [#1376](https://github.com/JeremyDev87/codingbuddy/issues/1376) (parent), [#1381](https://github.com/JeremyDev87/codingbuddy/issues/1381) (stale legacy hook migration)
5+
6+
This document explains **how** and **where** the CodingBuddy Claude Code plugin registers its hooks — and in particular, why `UserPromptSubmit` is nowhere to be found in `hooks/hooks.json`.
7+
8+
If you are here because you opened `hooks/hooks.json`, noticed `UserPromptSubmit` was missing, and thought "this plugin forgot to register PLAN/ACT/EVAL detection" — you are not the first. This page exists to make that confusion impossible.
9+
10+
---
11+
12+
## TL;DR
13+
14+
| Event | Where it is registered | Who loads it |
15+
|-------|------------------------|--------------|
16+
| `SessionStart` | `hooks/hooks.json` (plugin-local) | Claude Code at plugin load time |
17+
| `PreToolUse` | `hooks/hooks.json` (plugin-local) | Claude Code at plugin load time |
18+
| `PostToolUse` | `hooks/hooks.json` (plugin-local) | Claude Code at plugin load time |
19+
| `Stop` | `hooks/hooks.json` (plugin-local) | Claude Code at plugin load time |
20+
| **`UserPromptSubmit`** | **`~/.claude/settings.json`** (installed by `hooks/session-start.py` at runtime) | Claude Code globally, for every session |
21+
22+
`UserPromptSubmit` is intentionally installed **globally** so that PLAN/ACT/EVAL/AUTO keyword detection is active in every Claude Code session, regardless of the current working directory.
23+
24+
---
25+
26+
## The two hook layers
27+
28+
CodingBuddy's plugin ships with two distinct hook registration layers. They serve different purposes and are loaded by Claude Code at different times.
29+
30+
### Layer 1 — plugin-local (`hooks/hooks.json`)
31+
32+
Claude Code's plugin runtime reads `hooks/hooks.json` when the plugin is loaded. Hooks declared here are scoped to **this plugin** — they fire when Claude Code is operating inside a project where the CodingBuddy plugin is active.
33+
34+
This layer declares the hooks that only make sense in a project context:
35+
36+
- **`SessionStart`** — runs `session-start.py`, which in turn bootstraps the global `UserPromptSubmit` layer (see below) along with the status line, MCP entry, and other per-user integrations.
37+
- **`PreToolUse`** — gatekeeps `Bash` commands through `pre-tool-use.py`.
38+
- **`PostToolUse`** — post-processes tool results via `post-tool-use.py`.
39+
- **`Stop`** — saves session stats via `stop.py`.
40+
41+
The file carries a top-level `$comment` field pointing readers to this document. JSON has no comment syntax, so `$comment` is used by convention (JSON Schema follows the same convention).
42+
43+
### Layer 2 — global (`~/.claude/settings.json`, installed by `session-start.py`)
44+
45+
`UserPromptSubmit` is handled differently. On **every** session start, `hooks/session-start.py`:
46+
47+
1. Copies `hooks/user-prompt-submit.py` (and its `lib/` dependencies) into `~/.claude/hooks/codingbuddy-mode-detect.py`.
48+
2. Calls `register_hook_in_settings()` to add a `UserPromptSubmit` entry to `~/.claude/settings.json`, using file locking on Unix to prevent concurrent-write races.
49+
50+
The net effect: once a user has started a Claude Code session with the CodingBuddy plugin installed even once, the PLAN/ACT/EVAL/AUTO keyword hook is registered **globally** and fires in every subsequent session, regardless of project.
51+
52+
---
53+
54+
## Why install `UserPromptSubmit` globally instead of plugin-locally?
55+
56+
This is the first question every reviewer asks, and it is a fair question. A plugin-local entry in `hooks/hooks.json` would be simpler to read and audit.
57+
58+
The reason is **scope of detection**:
59+
60+
- PLAN/ACT/EVAL/AUTO keyword detection is meant to work **any time** the user types those keywords to Claude Code, not only when the current working directory is a CodingBuddy-enabled project.
61+
- Claude Code evaluates plugin-local hooks in the context of the active plugin root, which means a plugin-local `UserPromptSubmit` would only be considered when the plugin is resolved for the current session. That is too narrow for a workflow primitive that should "just work" everywhere.
62+
- A global install mirrors how IDE extensions typically register global keybinding handlers: once installed, they are ambient.
63+
64+
The trade-off is that contributors auditing the plugin package can't tell at a glance where `UserPromptSubmit` lives — which is exactly the confusion that motivated [#1380](https://github.com/JeremyDev87/codingbuddy/issues/1380) and this document.
65+
66+
---
67+
68+
## Source of truth (cheat sheet)
69+
70+
| Question | Answer |
71+
|----------|--------|
72+
| Where is `UserPromptSubmit` registered in the package? | It isn't. It is installed at runtime into `~/.claude/settings.json`. |
73+
| Which file performs the install? | `packages/claude-code-plugin/hooks/session-start.py` — specifically `register_hook_in_settings()`. |
74+
| Which script actually runs on `UserPromptSubmit`? | `~/.claude/hooks/codingbuddy-mode-detect.py`, a copy of `packages/claude-code-plugin/hooks/user-prompt-submit.py`. |
75+
| How is the install idempotent? | `register_hook_in_settings()` calls `_is_hook_in_settings()` first; if a matching entry exists, it is a no-op. |
76+
| How do I confirm the install happened? | Inspect `~/.claude/settings.json` and look for a `hooks.UserPromptSubmit` array entry whose `command` contains `codingbuddy-mode-detect.py`. |
77+
78+
---
79+
80+
## Regression safeguards
81+
82+
Two kinds of drift have happened historically and must be prevented going forward:
83+
84+
1. **Silent removal.** Refactors in `session-start.py` can accidentally delete or short-circuit the global install, leaving keyword detection broken for users who install the plugin fresh.
85+
2. **Duplicate registration.** A contributor who doesn't know about the global install path might "fix" the missing entry by adding `UserPromptSubmit` to `hooks/hooks.json`, producing two competing registrations — one plugin-scoped, one global — with no clear precedence.
86+
87+
The `tests/test_bootstrap_architecture.py` suite locks both invariants:
88+
89+
- `hooks.json` is asserted to declare exactly `{SessionStart, PreToolUse, PostToolUse, Stop}` and to **not** declare `UserPromptSubmit`.
90+
- `hooks.json` must carry a `$comment` field naming `session-start.py`, `UserPromptSubmit`, and `bootstrap-architecture` so readers are redirected here.
91+
- `session-start.py`'s module docstring must mention `UserPromptSubmit` and `~/.claude` so its bootstrap role is discoverable from `head -50`.
92+
- This document must exist, mention both layers, name the global settings path, and reference issue `#1380`.
93+
94+
If you legitimately need to change the architecture, update the tests in the same commit so the invariants move together with the behavior.
95+
96+
---
97+
98+
## Future simplification
99+
100+
An alternative single-path model was considered in [#1380](https://github.com/JeremyDev87/codingbuddy/issues/1380) — move `UserPromptSubmit` into plugin-local `hooks/hooks.json` and delete the global install from `session-start.py`. It was deferred for two reasons:
101+
102+
1. It would break the global-scope semantics described above; PLAN/ACT/EVAL detection would only fire inside CodingBuddy-aware projects.
103+
2. [#1381](https://github.com/JeremyDev87/codingbuddy/issues/1381) already tracks cleaning up **stale** global entries left over by previous plugin versions. Migrating to a single-path model while stale cleanup is still in flight would stack two incompatible migrations on top of each other.
104+
105+
If and when Claude Code grows a first-class mechanism for "global, plugin-originated" hooks, the bootstrap layer here can be retired and this document retracted. Until then, the two-layer model is the deliberate state.
106+
107+
---
108+
109+
## Related files
110+
111+
- `packages/claude-code-plugin/hooks/hooks.json` — plugin-local hook manifest (`$comment` links back here)
112+
- `packages/claude-code-plugin/hooks/session-start.py` — global `UserPromptSubmit` bootstrap (`register_hook_in_settings`)
113+
- `packages/claude-code-plugin/hooks/user-prompt-submit.py` — the actual PLAN/ACT/EVAL keyword detection script
114+
- `packages/claude-code-plugin/tests/test_bootstrap_architecture.py` — invariant tests for this document
115+
- `packages/claude-code-plugin/scripts/migrate-legacy-hooks.js` — stale-entry cleanup (tracked in #1381)

packages/claude-code-plugin/hooks/hooks.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"$comment": "UserPromptSubmit is intentionally absent from this file. The hook is installed globally into ~/.claude/settings.json at session start by hooks/session-start.py (which copies hooks/user-prompt-submit.py into ~/.claude/hooks/). See docs/bootstrap-architecture.md and issue #1380 for the rationale. Do NOT add a UserPromptSubmit entry here without updating bootstrap-architecture.md and the session-start global install logic in the same change.",
23
"description": "CodingBuddy plugin hooks — PLAN/ACT/EVAL workflow enforcement, quality gates, and operational tracking",
34
"hooks": {
45
"SessionStart": [

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,46 @@
11
#!/usr/bin/env python3
22
"""
3-
CodingBuddy Session Start Hook
4-
5-
Automatically installs the UserPromptSubmit hook for mode detection
6-
when a Claude Code session starts.
7-
8-
This hook:
9-
1. Checks if the mode detection hook is already installed
10-
2. If not, copies it to ~/.claude/hooks/
11-
3. Registers it in ~/.claude/settings.json
3+
CodingBuddy Session Start Hook — Bootstrap for global UserPromptSubmit.
4+
5+
This file is the **source of truth** for how the CodingBuddy plugin
6+
installs its ``UserPromptSubmit`` hook into Claude Code.
7+
8+
Why this file exists
9+
--------------------
10+
CodingBuddy uses two distinct hook layers:
11+
12+
1. **Plugin-local** ``hooks/hooks.json`` — registers ``SessionStart``,
13+
``PreToolUse``, ``PostToolUse``, and ``Stop`` hooks that Claude Code
14+
loads directly from the plugin package at runtime.
15+
2. **Global install** (this script) — at every session start, copies
16+
``hooks/user-prompt-submit.py`` into ``~/.claude/hooks/`` and
17+
registers a ``UserPromptSubmit`` hook in the user's global
18+
``~/.claude/settings.json``.
19+
20+
``UserPromptSubmit`` is deliberately **not** in ``hooks/hooks.json``.
21+
The global install is what actually wires up PLAN/ACT/EVAL/AUTO mode
22+
detection today, and contributors reading ``hooks.json`` first have
23+
repeatedly mistaken the missing entry for a bug (see #1380).
24+
25+
For the long-form rationale and migration options, see:
26+
``packages/claude-code-plugin/docs/bootstrap-architecture.md``.
27+
28+
What this hook does on every session start
29+
-------------------------------------------
30+
1. Check whether the mode-detection hook file already exists at
31+
``~/.claude/hooks/codingbuddy-mode-detect.py``.
32+
2. If not, copy ``user-prompt-submit.py`` there (alongside its ``lib/``
33+
dependencies).
34+
3. Ensure ``~/.claude/settings.json`` has a ``UserPromptSubmit`` hook
35+
entry pointing at that file, creating the settings file if needed
36+
and using file locking on Unix to avoid concurrent writes.
37+
4. Additionally installs the status line, ``~/.claude/mcp.json`` entry,
38+
system-prompt injection, and briefing-recovery suggestions.
39+
40+
Invariant: any change that moves ``UserPromptSubmit`` registration into
41+
``hooks/hooks.json`` **must** delete the corresponding install logic
42+
below and update ``docs/bootstrap-architecture.md`` plus
43+
``tests/test_bootstrap_architecture.py`` in the same change.
1244
"""
1345

1446
import json
@@ -325,15 +357,30 @@ def _read_settings_file(settings_file: Path) -> dict:
325357

326358
def register_hook_in_settings(settings_file: Path) -> bool:
327359
"""
328-
Register the UserPromptSubmit hook in settings.json.
360+
Register the UserPromptSubmit hook in the user's global settings.json.
361+
362+
This is the **bootstrap source of truth** for CodingBuddy's
363+
UserPromptSubmit registration. The plugin-local ``hooks/hooks.json``
364+
intentionally does NOT declare UserPromptSubmit; instead, this
365+
function writes the hook entry into ``~/.claude/settings.json`` so
366+
it is active for *all* Claude Code sessions, not just sessions
367+
inside the CodingBuddy project.
368+
369+
Why global, not plugin-local? PLAN/ACT/EVAL keyword detection is
370+
meant to work from any working directory once the plugin is
371+
installed. A plugin-local hook would only fire when
372+
``CLAUDE_PLUGIN_ROOT`` resolves to this package, which is too
373+
narrow. See ``docs/bootstrap-architecture.md`` and #1380.
329374
330-
Uses file locking on Unix systems to prevent concurrent write issues.
375+
Uses file locking on Unix systems to prevent concurrent write
376+
issues when multiple Claude Code sessions start at the same time.
331377
332378
Args:
333-
settings_file: Path to ~/.claude/settings.json
379+
settings_file: Path to ``~/.claude/settings.json``.
334380
335381
Returns:
336-
True if registered successfully, False if already exists
382+
``True`` if the hook was newly registered, ``False`` if an
383+
entry already existed (idempotent).
337384
"""
338385
settings_file.parent.mkdir(parents=True, exist_ok=True)
339386

0 commit comments

Comments
 (0)