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.
- package/CHANGELOG.md +170 -0
- package/README.md +66 -10
- package/package.json +9 -2
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/summary.js +19 -5
- package/packages/cli/dist/templates.js +8 -0
- package/templates/agent-os/init/CLAUDE.md +133 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/node-service/gitignore +8 -0
|
@@ -1,45 +1,302 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: loop
|
|
3
|
-
description: The unattended work driver.
|
|
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.
|
|
8
|
-
queue**
|
|
9
|
-
|
|
10
|
-
loop
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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());
|
package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs
ADDED
|
@@ -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
|
+
});
|