create-agent-rig 0.2.0 → 0.3.1

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 (39) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +66 -10
  3. package/package.json +9 -2
  4. package/packages/cli/dist/commands/init.js +73 -18
  5. package/packages/cli/dist/index.js +11 -1
  6. package/packages/cli/dist/lib/init-settings.js +52 -0
  7. package/packages/cli/dist/lib/summary.js +19 -5
  8. package/packages/cli/dist/templates.js +8 -0
  9. package/templates/agent-os/init/CLAUDE.md +133 -0
  10. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  11. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  12. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
  13. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  14. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  15. package/templates/agent-os/universal/.claude/queue.json +3 -0
  16. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  17. package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
  18. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  25. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  26. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  27. package/templates/agent-os/universal/.claude/settings.json +4 -0
  28. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
  29. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  30. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  31. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  32. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  33. package/templates/agent-os/universal/CLAUDE.md +57 -7
  34. package/templates/agent-os/universal/PLAN.md +28 -2
  35. package/templates/agent-os/universal/layers.json +20 -1
  36. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  37. package/templates/skeleton/aws-serverless/gitignore +8 -0
  38. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  39. package/templates/skeleton/node-service/gitignore +8 -0
@@ -1,45 +1,302 @@
1
1
  ---
2
2
  name: loop
3
- description: The unattended work driver. Runs the Agent queue from PLAN.md task by task under the autonomy rules, journals progress, and ends the session when the queue is empty — never inventing work.
3
+ description: The unattended work driver. Picks the next queue item through the queue adapter, runs it under the autonomy rules, journals at checkpoints, and ends the session on a stated stop condition — never inventing work. Use at the start of every autonomous run and between tasks.
4
+ allowed-tools: Read, Grep, Glob, Write, Edit, Bash, Task
4
5
  argument-hint: [max-tasks]
5
6
  ---
6
7
 
7
- You drive an unattended session. Work comes from one place only: the **Agent
8
- queue** in `PLAN.md`. The autonomy tiers and stop rules
9
- (`.claude/rules/autonomy.md`) govern every step; this skill adds the
10
- loop-level protocol around them.
11
-
12
- ## The loop
13
-
14
- 1. **Read `PLAN.md`.** If the Agent queue is empty → write a journal line
15
- ("queue empty, session ended") and **end the session. Do not invent work:**
16
- no unbidden refactors, no speculative polish, no "improvements". An empty
17
- queue is a completed state, not a vacuum to fill.
18
- 2. **Take the top task.** Judge its tier first: Tier 2 territory → move it to
19
- the **Operator queue** with a short plan (what, why, risk, rollback) and
20
- take the next task instead.
21
- 3. **Do the work** under the standing rules: failing test first, gates before
22
- any PR (`pr-ship`), boundaries enforced by the hooks.
23
- 4. **Close the task:** remove it from the queue, add one journal line (what
24
- moved, what it touched, any follow-up created — follow-ups go into a
25
- queue, never into ad-hoc scope creep).
26
- 5. Repeat from 1, within `max-tasks` if given.
27
-
28
- ## Loop-level stop conditions (distinct from per-task ones)
29
-
30
- Stop the session with the journal updated and a short diagnosis — when:
31
-
32
- - the Agent queue is empty (the normal, successful end);
33
- - **two consecutive tasks** ended in a per-task stop (N-strike, invariant
34
- conflict, surprise scope) the queue itself is probably mis-scoped; that
35
- is operator information, not something to push through;
36
- - your context has degraded or files changed under you (the session-staleness
37
- rule): write the summary, end, let a fresh session continue;
38
- - `max-tasks` is reached.
39
-
40
- ## Journal discipline
41
-
42
- One line per event in the `Journal` section of `PLAN.md`, newest first,
43
- **stateless in form** (no timestamps needed order carries the sequence).
44
- Prune freely: the journal is operational memory, not an archive that only
45
- grows.
8
+ You drive an unattended session. `.claude/rules/autonomy.md` sets the behaviour
9
+ boundaries; the **queue** holds the work; `PLAN.md` holds state, standing
10
+ decisions and the journal. This skill is the driver in between: what gets picked,
11
+ what keeps the loop going, what stops it, and where the report goes.
12
+
13
+ Per-task procedure is unchanged: (worktree if another session may run) → failing
14
+ test first → implement → `pr-ship` → merge on the named criterion → verify the
15
+ deployed surface if one changed.
16
+
17
+ ## 0. The queue is behind an adapter
18
+
19
+ Selection never reads a tracker directly. It goes through
20
+ `.claude/scripts/queue/index.mjs`, which resolves the adapter named in
21
+ `.claude/queue.json`:
22
+
23
+ ```bash
24
+ node .claude/scripts/queue/index.mjs next # the item to take, and why the rest were skipped
25
+ node .claude/scripts/queue/index.mjs next --json
26
+ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies
27
+ ```
28
+
29
+ - **`plan-md`** (default) the Agent queue in `PLAN.md`. The only adapter that
30
+ works in a freshly generated project. Its limit is real and stated in the
31
+ adapter: a flat list carries **no dependency links**, so the blocker filter is
32
+ absent rather than satisfied.
33
+ - **`github-issues`** the upgrade once the project has a remote. Per-item state,
34
+ a comment thread, and dependencies written as `Blocked by #7` in the body.
35
+ - **`jira`**for a team that already lives there. Native issue links, so the
36
+ dependency needs no convention. Credentials come from the environment
37
+ (`JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`) and never from a file in the
38
+ repo; the project or the JQL goes in `.claude/queue.json`.
39
+
40
+ Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
41
+ decision and each adapter only maps its tracker's records onto the neutral shape.
42
+
43
+ 🔴 **If the queue cannot be read, stop the run and say so.** Never fall back to
44
+ memory, to a stale copy, or to "what I remember was next". A remembered queue is
45
+ how a loop works items that no longer exist, and it is exactly the rot the
46
+ state-vs-queue split exists to prevent.
47
+
48
+ ## 1. Preflight — once, before the first task
49
+
50
+ ```bash
51
+ node .claude/scripts/preflight.mjs
52
+ ```
53
+
54
+ Three items are scripted (kill switch absent · local default branch matches the
55
+ remote · the last deploy concluded successfully) and the script **prints the ones
56
+ it did not check, every time**. Paste the block into the journal: a checklist that
57
+ leaves no record cannot tell you it was skipped.
58
+
59
+ Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
60
+ knowing which ground is soft. **GO** → the scripted three are clean; the rest are
61
+ still yours.
62
+
63
+ **An `unknown` never becomes a `pass`.** A probe that could not run tells you
64
+ nothing.
65
+
66
+ **First-ever run: attended and short** — one normal item, owner watching. Go
67
+ unattended only after the escalation path and the post-deploy verdict have each
68
+ been seen working at least once.
69
+
70
+ ## 2. Selection — filters in order, then the sort
71
+
72
+ The queue is queried **fresh before every task**, never from a cached list: the
73
+ loop itself closes items and unblocks their dependents, so a list read at the
74
+ start of the run is wrong by the second task. Re-resolve after every close.
75
+
76
+ The filters, in order, are implemented in `.claude/scripts/queue/core.mjs` — read
77
+ them there rather than re-deriving them here. Two of them are load-bearing enough
78
+ to restate:
79
+
80
+ 🔴 **Blockers resolve from links, never from labels.** A `ready`/`blocked` label is
81
+ a hand-maintained snapshot; the links are the dependency. Nothing updates a
82
+ dependent's label when its blocker lands — and in continuous mode the loop is what
83
+ landed it. A label-driven loop stalls on work it just unblocked itself, and takes
84
+ work whose blocker is still open. Both directions happen. Stale labels are
85
+ **reported as queue hygiene, never silently corrected**: quietly fixing the
86
+ metadata destroys the evidence that the metadata is unreliable.
87
+
88
+ 🔴 **A missing trigger marker means unconditional, not missing data.** Work that is
89
+ genuinely conditional says so. A `trigger-human` item — a "security pass", a
90
+ "window", "user demand" without a named metric — is **never self-taken**; the human
91
+ hands it over explicitly. A `trigger-auto` item needs its trigger verified *this
92
+ run*: unverified is not fired, and rationalising a trigger into firing builds for
93
+ scale that does not exist.
94
+
95
+ **The elevated tier is rationed by spacing, not by counting** — a per-run count is
96
+ meaningless when the run has no end. Never two elevated items back to back: land a
97
+ normal item on a healthy runtime in between. One unreviewed permissions or schema
98
+ change is recoverable; a chain of them compounding overnight is not.
99
+
100
+ **The tier marker is a pre-filter, not the authority.** If an item passed as normal
101
+ and the work turns out to touch an elevated path (`CLAUDE.md` →
102
+ `elevated-paths`), run the gate anyway, record the verdict on the PR, and treat it
103
+ as this run's elevated item for spacing.
104
+
105
+ ## 3. What keeps the loop running, and what stops it
106
+
107
+ Per-task stops (three strikes, attempt budget, invariant conflict, a blocking
108
+ reviewer verdict) **do not end the run**: escalate that item (§5) and take the
109
+ next one.
110
+
111
+ The run-level conditions are in `stopConditionOf` in `core.mjs`, checked in
112
+ severity order: **queue unreadable** · **runtime regression** · **kill switch** ·
113
+ **two escalations in a row** · **budget** · **queue empty**.
114
+
115
+ Three of them deserve their reasons repeated:
116
+
117
+ - **Runtime regression** → deploy the revert first, diagnose second, start no new
118
+ work on top. A regression compounds into everything built above it.
119
+ - **Two escalations in a row** → stop. If two consecutive tasks hit walls, the
120
+ third likely will too: the wall is systemic, not task-local. This is the main
121
+ guard against grinding a broken assumption for hours.
122
+ - **Queue empty after the filters** → **stop; do not invent work.** No refactoring
123
+ sprees, no polish, no pre-emptive optimisation. An empty filtered queue is a
124
+ legitimate, successful end of session; refilling it is the owner's job.
125
+ **Expect this to be the most common ending** — the queue is finite and the loop
126
+ drains it. That is the system working.
127
+
128
+ 🔴 **The kill switch is a real file, not an intention:**
129
+
130
+ ```bash
131
+ touch ~/.claude/__PROJECT_NAME__-loop-STOP # brake on
132
+ rm ~/.claude/__PROJECT_NAME__-loop-STOP # brake off
133
+ ```
134
+
135
+ While it exists, `guard-bash.mjs` **denies the merge at the tool layer**, so
136
+ nothing lands even if this file is never read. Everything else stays allowed on
137
+ purpose: finish the current task, push the branch, open the PR, write the journal,
138
+ stop. Losing in-flight work is not what stopping cleanly means. Check for the flag
139
+ between tasks rather than waiting to be denied.
140
+
141
+ ## 4. Budget — a rate, not a task count
142
+
143
+ There is no "max N tasks" ceiling by default; the bound is **cost per run or per
144
+ rolling day**, declared at the start. Meter it **before** picking the next task,
145
+ not after. If the remaining allowance cannot plausibly fit the next task's size,
146
+ stop now rather than starting something that will be abandoned half-done.
147
+
148
+ The journal's `cost` block carries only what the session **observes**: reviewer
149
+ subagents run, CI runs consumed (re-runs included — the cheapest signal that a task
150
+ fought its tests), deploys triggered.
151
+
152
+ ⚠ **There is deliberately no token or currency column.** The harness does not
153
+ reliably expose per-subagent accounting to the agent, and a plausible number in a
154
+ cost column **will be believed** — by the next reader, and by the next run
155
+ reasoning about its own budget. A field the loop cannot observe stays **visibly
156
+ empty, never estimated.**
157
+
158
+ ## 5. Every task carries an outcome state
159
+
160
+ The stop conditions say why the loop stopped. None of them says whether what it
161
+ produced is trustworthy, and those are different questions — a run can stop for a
162
+ perfectly good reason having produced something nobody should build on.
163
+
164
+ | State | Means |
165
+ | --- | --- |
166
+ | `clean-pass` | every stage produced its artifact from its **documented inputs**; checks green; the deployed surface healthy where one changed |
167
+ | `documented-stall` | it stopped at a real wall, and the diagnosis names **which stage needed what, and which upstream stage should have supplied it** |
168
+ | `incomplete` | it stopped and the record does not explain where or why |
169
+
170
+ 🔴 **`documented-stall` is a success, and reading it as a failure is how this stops
171
+ working.** A stall that names its under-supply is the most useful thing an
172
+ unattended run produces: it converts a vague gap into a located, fixable defect.
173
+ `incomplete` is the only failing state — and it fails on the **record**, not the
174
+ outcome. A task that shipped nothing but explained exactly where it hit the wall
175
+ did better work than one that shipped something nobody can retrace.
176
+
177
+ ### 5.1 The no-hand-feeding rule
178
+
179
+ > **When a stage asks for context an earlier stage should have supplied, do not
180
+ > answer it with new facts.** Record what it needed and which stage should have
181
+ > carried it, then either continue from the documented inputs plus a **labelled
182
+ > assumption**, or stop. **The stall is the finding.**
183
+
184
+ An unattended run is structurally biased the other way: a later stage asks for
185
+ something nobody supplied, the driver answers from its own head because it happens
186
+ to know, and the line keeps moving. The output looks clean and is fiction —
187
+ assembled from context no documented input contains and no reader can retrace.
188
+
189
+ Two corollaries, because they are the ones rationalised away mid-run:
190
+
191
+ - **A stage that finishes without producing its named artifact is an under-supply
192
+ finding, not a retry.** Never rerun it until the output looks clean — that is
193
+ re-running CI until it goes green, one level up.
194
+ - **"I already know this" is the signal, not the exemption.** The question is not
195
+ whether the fact is true; it is whether the pipeline supplied it. A true fact
196
+ injected by hand still leaves the next run starving in the same place.
197
+
198
+ ⚠ **Known limit, documented rather than trusted:** both halves rely on the run
199
+ reporting on itself, and hand-feeding is ordinary helpful completion rather than a
200
+ deliberate act a model catches itself performing. A stated rule still shifts
201
+ behaviour and costs nothing per run — but it is precisely why the one status that
202
+ mechanises fully (`missed`, `.claude/rules/autonomy.md`) needs no self-report.
203
+
204
+ ## 6. Escalation — two channels, by scope
205
+
206
+ **Task-scoped — the item is the home, and the loop continues.** Three strikes, the
207
+ attempt budget, an invariant conflict, or a blocking reviewer verdict:
208
+
209
+ 1. Comment the diagnosis on the queue item: what fails, what was tried, the
210
+ current hypothesis, links to the PR and the failing run. **Name the outcome
211
+ state in the same comment** — `incomplete` if the diagnosis cannot say which
212
+ stage needed what. Writing `incomplete` on your own task is uncomfortable and
213
+ is the point: the run that produced it is the only witness.
214
+ 2. Mark it `escalated` and leave it claimed — **not** back to a selectable state,
215
+ or the next query picks it up and works it twice.
216
+ 3. Journal it. 4. **Take the next item.** One stuck task does not end a run; two
217
+ in a row does (§3).
218
+
219
+ **Run-scoped — the run itself is broken, and it ends.** A runtime regression, two
220
+ escalations in a row, a systemic wall, a queue-data anomaly: open an escalation
221
+ issue with the diagnosis and links, notify the owner if the harness can, and write
222
+ the journal entry. In continuous mode the notification matters more than it does
223
+ in a bounded run — nobody is watching, so a silent stop is indistinguishable from
224
+ a run still working.
225
+
226
+ ## 7. The journal, and closing the loop
227
+
228
+ Write a checkpoint entry **every few completed items and at every stop**, not only
229
+ at the end: a run that dies unexpectedly must not take its history with it. The
230
+ field list is in `PLAN.md` under `## Journal`.
231
+
232
+ At every **stop** — not at a checkpoint — turn the run's findings into **at most
233
+ three** improvement proposals. **The cap is the mechanism, not a budget:** an
234
+ unbounded improvement list is another diary, and three forces a choice. Each names
235
+ four things, and a proposal missing any of them is not ready to file:
236
+
237
+ 1. the finding it came from, cited as the journal line it appears on;
238
+ 2. the part to change — a skill, an agent spec, a hook, a rule file, `CLAUDE.md`,
239
+ the CI workflow;
240
+ 3. the change, concretely enough to diff;
241
+ 4. how the next run would prove it worked — the observation that would differ.
242
+
243
+ Filing is the adapter's `proposeTriage`, which the CLI deliberately does **not**
244
+ expose — `index.mjs` is read-only (`next`, `list`, `hygiene`) so that no accidental
245
+ invocation can write to the queue. Call it directly:
246
+
247
+ ```bash
248
+ node --input-type=module -e '
249
+ const a = await import("./.claude/scripts/queue/plan-md.mjs"); // or github-issues / jira
250
+ console.log(await a.proposeTriage({
251
+ finding: "<the journal line it came from>",
252
+ part: "<skill | agent | hook | rule | CLAUDE.md | workflow>",
253
+ change: "<concretely enough to diff>",
254
+ proof: "<the observation that would differ next run>",
255
+ }));
256
+ '
257
+ ```
258
+
259
+ A proposal missing any of the four parts is refused rather than filed half-formed.
260
+
261
+ 🔴 **The loop proposes; the owner patches.** Self-applying a change to its own
262
+ rulebook is how an unattended run drifts irreversibly, and it collides head-on
263
+ with the rule that the agent authors no work for itself.
264
+
265
+ 🔴 **Proposals land in `triage`, never in the queue the loop selects from.** A
266
+ triage item is unselectable twice over — no ready marker, and `triage` is excluded
267
+ outright. **Deduplicate by fingerprint:** twenty "queue empty" stops must produce
268
+ one proposal with a count of twenty, not twenty proposals. **A stop with nothing
269
+ worth proposing files nothing** — zero is a legitimate number, and padding to
270
+ three poisons the only channel by which this project learns.
271
+
272
+ ## 8. What the loop does NOT do
273
+
274
+ | Does not | Why |
275
+ | --- | --- |
276
+ | **Create its own work items** | The queue is human-filled. Self-authored work drifts scope, and unattended it drifts unwatched |
277
+ | Take items needing a human decision | It cannot unblock itself; those wait in the Operator queue |
278
+ | Take a `trigger-human` item | It would build for scale that does not exist |
279
+ | Take two elevated items back to back | One unreviewed schema/permissions change is recoverable; a chain overnight is not |
280
+ | Merge past a blocking reviewer verdict | The reviewer gate is what replaced the human merge |
281
+ | Trust a `blocked` label over the links | The label is a snapshot; the links are the dependency |
282
+ | "Improve" on an empty queue | An empty filtered queue is the end of the run, not an invitation |
283
+ | Start new work on an unhealthy runtime | The regression compounds into everything above it |
284
+ | Act on the "Never" tier | A hard stop, enforced by hooks |
285
+
286
+ ## 9. State updates bracket the task, they are not a follow-up
287
+
288
+ - **Opening:** claim the item **before the first file is edited** — the same turn
289
+ that creates the branch or worktree. Not when the PR opens. An item being worked
290
+ while it still reads as available is invisible to the human and re-selectable by
291
+ the very next query.
292
+ - **Closing:** close it with the merged PR linked, immediately after the
293
+ post-merge verdict — not in a cleanup pass.
294
+
295
+ Between those two moments the item keeps absorbing what happens **as it happens** —
296
+ a decision, a deviation, a defect found in passing, a tier discovered mid-work. A
297
+ run that dies mid-task leaves its whole trail on the item; a run that batches its
298
+ comments to the end leaves nothing.
299
+
300
+ If the task changed a **fact** in `PLAN.md` — a state row, a standing decision, an
301
+ Operator-queue item it unblocked — that edit lands **in the same PR that changed
302
+ the fact**, never a docs-only follow-up.
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: new-invariant
3
+ description: Turn a project rule into a mechanically enforced invariant — one stated rule, one PreToolUse hook that blocks its violation, one test for the hook. Use when a rule keeps being broken, when a review finding recurs, or when a post-mortem ends in "nothing stopped us doing that".
4
+ allowed-tools: Read, Grep, Glob, Write, Edit, Bash
5
+ argument-hint: [the invariant, in one sentence]
6
+ ---
7
+
8
+ You add **one** invariant to this project's enforced set, using the pattern in
9
+ `.claude/rules/invariants.md`: a stated rule, a hook that blocks its violation,
10
+ and a test for the hook. All three land in the same change.
11
+
12
+ ## Step 0 — get the invariant from the project, not from your own judgement
13
+
14
+ 🔴 **Do not invent the invariant.** If the argument did not name one, ask. The
15
+ whole point of this layer is that the project chooses what is load-bearing; an
16
+ agent that supplies its own answer has handed the project a rule nobody agreed
17
+ to, and that rule will be obeyed without thought.
18
+
19
+ Ask for, or find, three things:
20
+
21
+ 1. **The rule, in one sentence**, in the form "X never happens in Y".
22
+ 2. **What it cost the last time it was broken.** If nobody can finish that
23
+ sentence, stop and say so: an invariant with no incident behind it is a guess,
24
+ and a guessed invariant fires on honest work.
25
+ 3. **The compliant form.** What the code should look like instead — you need it
26
+ for the allow case, and a rule with no stated alternative is a dead end.
27
+
28
+ Then check the fit against the table in `.claude/rules/invariants.md`. If it
29
+ cannot be decided from a single edit fragment — "this is too complex", "the
30
+ naming is off" — say so and stop: it belongs to `code-reviewer`, a lint rule or a
31
+ type, and forcing it into a hook produces a guard people fight.
32
+
33
+ Also check it is not already enforced. `grep` the existing hooks first; a second
34
+ guard for the same rule is two places to keep in step.
35
+
36
+ ## Step 1 — write the failing test
37
+
38
+ TDD is not suspended here (`.claude/rules/workflow.md`). Copy
39
+ `guard-invariant.example.test.mjs` from this skill's directory as the shape, and
40
+ write the cases **before** the hook exists:
41
+
42
+ - **blocks** the violation — exit code 2, and the reason names the rule;
43
+ - **allows** the compliant form — exit code 0;
44
+ - **allows** a file outside the guarded scope — a guard that polices the whole
45
+ repo will be disabled by lunchtime;
46
+ - **allows** prose that merely mentions the violation. This case is not optional.
47
+ A guard that fires on a commit message, a doc line or a test fixture *about* the
48
+ rule produces **false positives**, and false positives are how guards get
49
+ switched off. Every real hook in this project strips or scopes text before
50
+ matching for exactly this reason.
51
+
52
+ Run it. Watch it fail for the right reason (the hook does not exist yet).
53
+
54
+ ## Step 2 — write the hook
55
+
56
+ Copy `guard-invariant.example.mjs` from this skill's directory to
57
+ `.claude/hooks/guard-<invariant-name>.mjs` and replace the parts the comments
58
+ mark. Name the file after **the invariant**, never after the tool it intercepts —
59
+ `guard-core-purity`, not `guard-write`.
60
+
61
+ Hold the contract:
62
+
63
+ - JSON payload on stdin; **exit 0 = allow, exit 2 = block**; stderr is the reason
64
+ the agent reads, so write it as an instruction — what to do instead, and where
65
+ the rule is stated.
66
+ - **Scope first, match second.** Return 0 immediately for files the invariant does
67
+ not cover.
68
+ - **Fail open** on a malformed payload or any internal error. A crashed guard that
69
+ blocks everything gets deleted within the hour.
70
+ - Zero dependencies, `node:` builtins only.
71
+
72
+ Run the test. Make it pass. Do not weaken a case to get there.
73
+
74
+ ## Step 3 — wire it, or it enforces nothing
75
+
76
+ Add the hook to `.claude/settings.json` under `PreToolUse`, in the block whose
77
+ `matcher` covers the tools the invariant needs (`Write|Edit` for file content,
78
+ `Bash` for commands). An unwired hook is a file that passes its own test and
79
+ guards nothing — verify by triggering the violation once, for real, and watching
80
+ it be refused.
81
+
82
+ ## Step 4 — state the rule where a reader will look
83
+
84
+ Add the sentence to the relevant file in `.claude/rules/` (or
85
+ `.claude/rules/invariants.md` if it fits nowhere else), next to a line saying
86
+ which hook enforces it and what the hook cannot see. A check with no stated rule
87
+ is a booby trap: someone will hit it, not understand it, and route around it.
88
+
89
+ ## Done when
90
+
91
+ - [ ] The test failed before the hook existed, and passes now
92
+ - [ ] The violation is refused in a real session, not only in the test
93
+ - [ ] The hook is wired in `settings.json`
94
+ - [ ] The rule is written down, with the hook named next to it
95
+ - [ ] The hook guards **one** invariant, and its scope is as narrow as the rule
96
+
97
+ ## A candidate that is already sitting there
98
+
99
+ The stack rules say service code logs through the shared structured logger, never
100
+ `console.log` — and nothing enforces it. That is the invariant the shipped example
101
+ encodes, so if the rule matters in your project, promoting the example into a real
102
+ hook is a copy, a rename and a wiring line.
@@ -0,0 +1,78 @@
1
+ // TEMPLATE — copy to .claude/hooks/guard-<invariant-name>.mjs and edit the three
2
+ // marked parts. It is real, working code, not a sketch: the `new-invariant` skill
3
+ // tests it, so what you copy is known to work before you change anything.
4
+ //
5
+ // The invariant it encodes as the example:
6
+ //
7
+ // Service code logs through the shared structured logger, never `console.log`.
8
+ //
9
+ // That one is a genuine candidate — the stack rules state it and nothing enforces
10
+ // it — but it is here to show the SHAPE. Replace it with your project's invariant.
11
+ //
12
+ // Contract (Claude Code): JSON payload on stdin; exit 0 = allow, exit 2 = block,
13
+ // and stderr is shown to the agent as the reason. Zero dependencies.
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ // ── PART 1: the scope ────────────────────────────────────────────────────────
17
+ // Which files the invariant covers. Scope FIRST, match second: a guard that
18
+ // polices the whole repo fires on honest work and gets switched off by lunchtime.
19
+ const inScope = (filePath) => /^services\/[^/]+\/src\//.test(filePath);
20
+
21
+ // ── PART 2: the violation ────────────────────────────────────────────────────
22
+ // What the invariant forbids, decided from the text of THIS edit alone.
23
+ // Return a reason string, or null to allow.
24
+ const violation = (text) => {
25
+ // Strip line comments and string literals before matching: a doc line or a
26
+ // fixture that merely MENTIONS the forbidden form is prose, not a violation.
27
+ // Skipping this step is the single most common way a guard earns false
28
+ // positives — and false positives are how guards get disabled.
29
+ const code = text
30
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
31
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
32
+ .replace(/`(?:[^`\\]|\\.)*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""');
33
+
34
+ const match = /\bconsole\.(log|info|warn|error|debug)\s*\(/.exec(code);
35
+ if (!match) return null;
36
+ return (
37
+ `BLOCKED — service code logs through the shared structured logger, never ` +
38
+ `console.${match[1]}(). Structured JSON lines are what makes a production ` +
39
+ `incident searchable; a bare console call is invisible the moment it matters. ` +
40
+ `Import the logger from the shared package instead. See .claude/rules/ for the rule.`
41
+ );
42
+ };
43
+
44
+ // ── PART 3: nothing below here normally changes ──────────────────────────────
45
+
46
+ /** The text an edit is about to introduce, across the tool shapes that carry one. */
47
+ const incomingText = (toolInput) =>
48
+ [toolInput?.content, toolInput?.new_string, toolInput?.new_str]
49
+ .filter((value) => typeof value === 'string')
50
+ .join('\n');
51
+
52
+ function main() {
53
+ let input;
54
+ try {
55
+ input = JSON.parse(readFileSync(0, 'utf8'));
56
+ } catch {
57
+ return 0; // malformed payload → fail open, never make the session unusable
58
+ }
59
+
60
+ const filePath = String(input?.tool_input?.file_path ?? '').replaceAll('\\', '/');
61
+ if (!filePath || !inScope(filePath)) return 0;
62
+
63
+ const text = incomingText(input?.tool_input);
64
+ if (!text) return 0;
65
+
66
+ try {
67
+ const reason = violation(text);
68
+ if (reason) {
69
+ process.stderr.write(`${reason}\n`);
70
+ return 2;
71
+ }
72
+ } catch {
73
+ return 0; // a guard that crashes must not block the work
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ process.exit(main());
@@ -0,0 +1,89 @@
1
+ // TEMPLATE — the third of the three parts (.claude/rules/invariants.md).
2
+ //
3
+ // Copy alongside your hook and rewrite the cases for your invariant. Run with:
4
+ //
5
+ // node --test .claude/hooks/guard-<invariant-name>.test.mjs
6
+ //
7
+ // The four cases below are not a suggestion — they are the minimum set. The last
8
+ // two are the ones people skip, and they are exactly the ones that decide whether
9
+ // the guard survives contact with real work: a guard that polices out-of-scope
10
+ // files, or that fires on prose about its own rule, gets switched off.
11
+ import assert from 'node:assert/strict';
12
+ import { execFile } from 'node:child_process';
13
+ import { describe, it } from 'node:test';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const hook = join(dirname(fileURLToPath(import.meta.url)), 'guard-invariant.example.mjs');
18
+
19
+ /** Feed a synthetic PreToolUse payload to the hook, exactly as Claude Code does. */
20
+ const runHook = (filePath, content) =>
21
+ new Promise((resolve, reject) => {
22
+ const child = execFile(process.execPath, [hook], (error, stdout, stderr) => {
23
+ resolve({ code: error ? (error.code ?? 1) : 0, stderr });
24
+ });
25
+ if (!child.stdin) return reject(new Error('no stdin'));
26
+ child.stdin.write(
27
+ JSON.stringify({
28
+ hook_event_name: 'PreToolUse',
29
+ tool_name: 'Write',
30
+ tool_input: { file_path: filePath, content },
31
+ }),
32
+ );
33
+ child.stdin.end();
34
+ });
35
+
36
+ const IN_SCOPE = 'services/api/src/handlers/create-note.ts';
37
+
38
+ describe('the example invariant: service code logs through the shared logger', () => {
39
+ it('blocks the violation, and the reason names what to do instead', async () => {
40
+ const result = await runHook(IN_SCOPE, 'console.log("created", id);');
41
+ assert.equal(result.code, 2);
42
+ assert.match(result.stderr, /logger/i);
43
+ });
44
+
45
+ it('blocks the other console methods too, not just log', async () => {
46
+ for (const method of ['info', 'warn', 'error', 'debug']) {
47
+ const result = await runHook(IN_SCOPE, `console.${method}("x");`);
48
+ assert.equal(result.code, 2, method);
49
+ }
50
+ });
51
+
52
+ it('allows the compliant form', async () => {
53
+ const result = await runHook(IN_SCOPE, 'logger.info({ msg: "created", id });');
54
+ assert.equal(result.code, 0);
55
+ });
56
+
57
+ it('allows files outside the guarded scope', async () => {
58
+ // A guard with repo-wide reach fires on honest work and gets disabled.
59
+ for (const path of ['scripts/one-off.mjs', 'apps/web/src/app/page.tsx', 'README.md']) {
60
+ const result = await runHook(path, 'console.log("fine here");');
61
+ assert.equal(result.code, 0, path);
62
+ }
63
+ });
64
+
65
+ it('allows prose that merely mentions the violation', async () => {
66
+ // The false-positive case. Skipping it is how a guard ends up switched off.
67
+ for (const content of [
68
+ '// never use console.log here — use the logger',
69
+ '/* console.error is banned in service code */',
70
+ 'const banned = "console.log(";',
71
+ 'const help = `do not call console.warn(...)`;',
72
+ ]) {
73
+ const result = await runHook(IN_SCOPE, content);
74
+ assert.equal(result.code, 0, content);
75
+ }
76
+ });
77
+
78
+ it('fails open on a payload it does not understand', async () => {
79
+ const result = await new Promise((resolve, reject) => {
80
+ const child = execFile(process.execPath, [hook], (error) => {
81
+ resolve({ code: error ? (error.code ?? 1) : 0 });
82
+ });
83
+ if (!child.stdin) return reject(new Error('no stdin'));
84
+ child.stdin.write('{not json');
85
+ child.stdin.end();
86
+ });
87
+ assert.equal(result.code, 0);
88
+ });
89
+ });