create-agent-rig 0.6.0 → 0.6.2
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 +78 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/templates/agent-os/init/AGENTS.md +4 -2
- package/templates/agent-os/init/CLAUDE.md +4 -2
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +46 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
- package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +16 -16
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
- package/templates/agent-os/universal/layers.json +1 -0
- package/templates/hash-history.json +158 -43
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,84 @@ Numbering is ordinary semver — **additive is a minor, a fix is a patch** — s
|
|
|
11
11
|
that "I only take minors" remains a usable policy; 0.3.2 shipped additive
|
|
12
12
|
content as a patch by the owner's call and stays recorded as one.
|
|
13
13
|
|
|
14
|
+
## 0.6.2
|
|
15
|
+
|
|
16
|
+
**Patch hardening for the Agent OS shipped by 0.6.1.** This release closes six
|
|
17
|
+
downstream-found governance and transport defects without changing the public
|
|
18
|
+
CLI or adding a dependency.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **A UTF-8 BOM on a hook's stdin no longer disarms it.** PowerShell prepends
|
|
23
|
+
one on some Windows hosts, `JSON.parse` throws on a leading U+FEFF, and every
|
|
24
|
+
hook resolved that to its documented fail-open — so a well-formed refusal
|
|
25
|
+
became an allow. On such a host all eight hooks failed open together,
|
|
26
|
+
`guard-bash` among them, which carries the Never tier and the kill switch.
|
|
27
|
+
They now read through one shared `.claude/hooks/lib/hook-input.mjs`. Pinned in
|
|
28
|
+
`test/template/hook-stdin.test.ts` › "blocks the same command when PowerShell
|
|
29
|
+
prepends a UTF-8 BOM" and › "reads stdin through the one shared reader, in
|
|
30
|
+
every hook that reads it".
|
|
31
|
+
- **Windows 8.3 short paths no longer hide a rulebook edit from the guard.**
|
|
32
|
+
`realpathSync` normalises separators but leaves a short name (`RUNNER~1`,
|
|
33
|
+
`SERHII~1`) unexpanded, so a checkout reached by two spellings hashed to two
|
|
34
|
+
unattended-flag names and compared as two directories. Both
|
|
35
|
+
`guard-rulebook` and `unattended-flag` now canonicalise with
|
|
36
|
+
`realpathSync.native`. Pinned in `test/template/unattended-flag.test.ts` ›
|
|
37
|
+
"scopes the flag by the checkout, so two spellings of one directory arm one
|
|
38
|
+
file".
|
|
39
|
+
- **Codex hooks carry the canonical repository root in `CLAUDE_PROJECT_DIR` on
|
|
40
|
+
POSIX and Windows.** A session started in a nested directory therefore judges
|
|
41
|
+
a rulebook edit against the checkout the hook came from. Pinned in
|
|
42
|
+
`test/template/codex.test.ts` › "anchors a nested-cwd Codex rulebook edit to
|
|
43
|
+
the canonical repository root"; the same test file decodes and checks the
|
|
44
|
+
Windows command.
|
|
45
|
+
- **Jira retry is limited to safe reads and the semantically read-only search
|
|
46
|
+
POST.** Comment, transition, issue-create and issue-update mutations return
|
|
47
|
+
the first ambiguous transient failure instead of replaying the write. Pinned
|
|
48
|
+
in `test/template/queue-jira.test.ts` › "does not retry %s" and › "retries a
|
|
49
|
+
semantically read-only search POST after a 429".
|
|
50
|
+
- **`.claude/doctor-exemptions.json` is protected as rulebook input.** An
|
|
51
|
+
unattended edit is refused unless the current item's allow-list names that
|
|
52
|
+
exact file. Pinned in `test/template/guard-rulebook.test.ts` › "allows doctor
|
|
53
|
+
exemptions only when the item names that exact rulebook file" and the guarded
|
|
54
|
+
path table in the same suite.
|
|
55
|
+
- **`lastCompletedTier` is explicitly repository-global across board switches.**
|
|
56
|
+
A selector change cannot reset the spacing brake and admit a second elevated
|
|
57
|
+
mechanism change in the same checkout. The ruling is in
|
|
58
|
+
`docs/decisions/spacing-rations-mechanisms.md`, pinned by
|
|
59
|
+
`test/template/queue-board.test.ts` › "keeps completed-tier spacing
|
|
60
|
+
repository-global when the active board switches".
|
|
61
|
+
|
|
62
|
+
## 0.6.1
|
|
63
|
+
|
|
64
|
+
**Security and upgrade hardening for the Agent OS shipped by 0.6.0.** This patch
|
|
65
|
+
closes the rulebook, unattended-run and queue-board gaps found while upgrading a
|
|
66
|
+
live generated repository; it adds no dependency and changes no public CLI
|
|
67
|
+
command.
|
|
68
|
+
|
|
69
|
+
### Security
|
|
70
|
+
|
|
71
|
+
- **`guard-rulebook` now covers the whole shared rulebook and symlink aliases on
|
|
72
|
+
either side of the comparison.** `AGENTS.md` and `.codex/hooks.json` are
|
|
73
|
+
protected alongside the Claude files. Checkout roots and payload paths are
|
|
74
|
+
judged in both their selected and canonical spellings, including a
|
|
75
|
+
payload-only alias.
|
|
76
|
+
- **Queue board names containing terminal control characters are rejected before
|
|
77
|
+
selection, diagnostics or selector writes.** Ordinary names, including names
|
|
78
|
+
with spaces, remain valid; ANSI, OSC, C1 and DEL bytes from repository-owned
|
|
79
|
+
`queue.json` keys can no longer repaint terminal output.
|
|
80
|
+
- **Unattended authorization is checkout-scoped.** Concurrent worktrees derive
|
|
81
|
+
distinct flag paths from canonical checkout identity, legacy machine-wide
|
|
82
|
+
state fails closed, and migration or cleanup refuses when any armed flag cannot
|
|
83
|
+
be removed instead of reporting a partial disarm as success.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- The generated prose-reviewer exception for upstream-only tests now applies
|
|
88
|
+
only to manifest-proven generator snapshots and expires on local drift. This
|
|
89
|
+
keeps intentional generator test references available without turning a dead
|
|
90
|
+
reference in an edited downstream rulebook into a pass.
|
|
91
|
+
|
|
14
92
|
## 0.6.0
|
|
15
93
|
|
|
16
94
|
**The loop now checks its premises against the tracker at three points, and
|
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ wired by both `.claude/settings.json` and `.codex/hooks.json`:
|
|
|
108
108
|
the web talks to the backend over HTTP only;
|
|
109
109
|
- **`guard-rulebook`** — in an unattended run (a flag file the `loop` skill
|
|
110
110
|
writes at claim time), refuses an edit to the rulebook — hooks, wiring,
|
|
111
|
-
`queue.json
|
|
111
|
+
`queue.json` and its board selector, the queue adapters, the router, the gate sweep, the rules,
|
|
112
112
|
`CLAUDE.md` — outside the current item's allow-list; does nothing in an
|
|
113
113
|
attended session.
|
|
114
114
|
- **`guard-secret-file`** — refuses an edit that writes a credential: either the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agent-rig",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"create",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@eslint/js": "^10.0.1",
|
|
53
53
|
"@types/node": "^26.1.1",
|
|
54
|
+
"ajv": "^8.20.0",
|
|
55
|
+
"ajv-formats": "^3.0.1",
|
|
54
56
|
"eslint": "^10.7.0",
|
|
55
57
|
"eslint-config-prettier": "^10.1.8",
|
|
56
58
|
"globals": "^17.7.0",
|
|
@@ -116,13 +116,15 @@ All four are one-liners, and all four are inert until you do them.
|
|
|
116
116
|
no-op, and the Definition of Done is back to being a wish.
|
|
117
117
|
2. **The elevated-path list below is a seed, not a survey.** It names only what
|
|
118
118
|
every repo has. Everything else is yours to add.
|
|
119
|
-
3. **
|
|
119
|
+
3. **Five runtime paths need a `.gitignore` line each**, and `init` cannot add
|
|
120
120
|
them — it installs into your repository and does not edit files it did not
|
|
121
|
-
bring.
|
|
121
|
+
bring. If any are missing, add only the missing entries:
|
|
122
122
|
|
|
123
123
|
```
|
|
124
124
|
# the tier the last close recorded
|
|
125
125
|
.claude/queue.state.json
|
|
126
|
+
# the board this checkout runs on, when the config declares several
|
|
127
|
+
.claude/queue.board
|
|
126
128
|
# gate rounds, one count per branch
|
|
127
129
|
.claude/gate-rounds.json
|
|
128
130
|
# task worktrees
|
|
@@ -116,13 +116,15 @@ All four are one-liners, and all four are inert until you do them.
|
|
|
116
116
|
no-op, and the Definition of Done is back to being a wish.
|
|
117
117
|
2. **The elevated-path list below is a seed, not a survey.** It names only what
|
|
118
118
|
every repo has. Everything else is yours to add.
|
|
119
|
-
3. **
|
|
119
|
+
3. **Five runtime paths need a `.gitignore` line each**, and `init` cannot add
|
|
120
120
|
them — it installs into your repository and does not edit files it did not
|
|
121
|
-
bring.
|
|
121
|
+
bring. If any are missing, add only the missing entries:
|
|
122
122
|
|
|
123
123
|
```
|
|
124
124
|
# the tier the last close recorded
|
|
125
125
|
.claude/queue.state.json
|
|
126
|
+
# the board this checkout runs on, when the config declares several
|
|
127
|
+
.claude/queue.board
|
|
126
128
|
# gate rounds, one count per branch
|
|
127
129
|
.claude/gate-rounds.json
|
|
128
130
|
# task worktrees
|
|
@@ -39,6 +39,32 @@ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, o
|
|
|
39
39
|
(`JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`) and never from a file in the
|
|
40
40
|
repo; the project or the JQL goes in `.claude/queue.json`.
|
|
41
41
|
|
|
42
|
+
A config may declare several boards (`boards: { <name>: options }` plus a
|
|
43
|
+
default `board`); the active one is chosen per checkout, not by editing the
|
|
44
|
+
composed file:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node .claude/scripts/queue/index.mjs board # the active board and the declared ones
|
|
48
|
+
node .claude/scripts/queue/index.mjs board RP # switch this checkout: writes .claude/queue.board
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The selector is per-checkout runtime state, the same class as
|
|
52
|
+
`.claude/queue.state.json`: it needs its own `.gitignore` line, which a generated
|
|
53
|
+
project ships and an `init`-installed rig adds by hand. An undeclared name is
|
|
54
|
+
refused, never read as "no board" (see `test/template/queue-board.test.ts` ›
|
|
55
|
+
"refuses a board nobody declared instead of falling back" — in the generator,
|
|
56
|
+
absent in a generated rig). It is a rulebook path for `guard-rulebook`:
|
|
57
|
+
`.claude/queue.board` is refused even when an item allow-list names it,
|
|
58
|
+
and the `board` command itself refuses a switch while the checkout is unattended. This
|
|
59
|
+
does not prevent an arbitrary direct shell write to the selector — edit-tool
|
|
60
|
+
hooks cannot see one. `.claude/queue.state.json` stays per config, not per board:
|
|
61
|
+
the tier the last close recorded rations the next selection whichever board it
|
|
62
|
+
lands on. This is repository-risk state, not tracker metadata: switching from
|
|
63
|
+
one independent queue to another must not turn a previous mechanism close into
|
|
64
|
+
permission for a second one in the same checkout. Pinned in the generator's
|
|
65
|
+
`test/template/queue-board.test.ts` (absent in a generated rig) › "keeps
|
|
66
|
+
completed-tier spacing repository-global when the active board switches".
|
|
67
|
+
|
|
42
68
|
Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
|
|
43
69
|
decision and each adapter only maps its tracker's records onto the neutral shape.
|
|
44
70
|
|
|
@@ -99,14 +125,15 @@ invocation. What a hook CAN see is a file, so the unattended signal is one:
|
|
|
99
125
|
```bash
|
|
100
126
|
# at claim time, from the paths the item names (repo-relative prefixes, with
|
|
101
127
|
# their trailing slash); the guard refuses every other rulebook edit while it is on
|
|
102
|
-
node .claude/scripts/unattended-flag.mjs on --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
|
|
128
|
+
node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
|
|
103
129
|
```
|
|
104
130
|
|
|
105
131
|
`guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
|
|
106
|
-
on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
path starts with an allowed prefix;
|
|
132
|
+
on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
|
|
133
|
+
rulebook — both harnesses' rules, skills, agents and hook wiring, plus their
|
|
134
|
+
scripts, queue config and integrity manifest — is refused unless its
|
|
135
|
+
path starts with an allowed prefix; the board selector is the one always-refused
|
|
136
|
+
exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
|
|
110
137
|
item that needs a rulebook path names it here — a decision made at claim
|
|
111
138
|
time, never a default — and the stop step below turns the flag off. Pinned in
|
|
112
139
|
the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
|
|
@@ -718,9 +745,18 @@ next attended session would find its rulebook edits refused in the name of an
|
|
|
718
745
|
item nobody is working:
|
|
719
746
|
|
|
720
747
|
```bash
|
|
721
|
-
node .claude/scripts/unattended-flag.mjs off
|
|
748
|
+
node .claude/scripts/unattended-flag.mjs off --root "$PWD"
|
|
722
749
|
```
|
|
723
750
|
|
|
751
|
+
If that command reports a legacy machine-wide flag, it deliberately leaves a
|
|
752
|
+
foreign pre-upgrade authorization in place and the checkout stays fail-closed.
|
|
753
|
+
Inspect the exact reported record and confirm that no pre-upgrade run still uses
|
|
754
|
+
it, then remove only that record with
|
|
755
|
+
`node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>`.
|
|
756
|
+
Run scoped `off --root "$PWD"` again to surface the next record, and repeat the
|
|
757
|
+
inspection one at a time; do not record the flag as off until the scoped command
|
|
758
|
+
succeeds.
|
|
759
|
+
|
|
724
760
|
At every **stop** — not at a checkpoint — turn the run's findings into **at most
|
|
725
761
|
three** improvement proposals. **The cap is the mechanism, not a budget:** an
|
|
726
762
|
unbounded improvement list is another diary, and three forces a choice. Each names
|
|
@@ -750,7 +786,7 @@ node --input-type=module -e '
|
|
|
750
786
|
// mechanism accepts a proposal without them; this procedure does not.
|
|
751
787
|
measured: "<the paths the probe actually exercised>",
|
|
752
788
|
inferred: "<the conclusion, citing only surfaces named in measured>",
|
|
753
|
-
}, { project: "<KEY>" })); // jira only — the
|
|
789
|
+
}, { project: "<KEY>" })); // jira only — the ACTIVE board's key: `queue/index.mjs board --json` → options.project;
|
|
754
790
|
// plan-md and github-issues take no second argument
|
|
755
791
|
'
|
|
756
792
|
```
|
|
@@ -853,7 +889,7 @@ three poisons the only channel by which this project learns.
|
|
|
853
889
|
validation and its take-up — an adapter re-records the take-up after each
|
|
854
890
|
write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
|
|
855
891
|
hold the close; pinned in the generator's
|
|
856
|
-
`test/template/self-inflicted-marker.test.ts` › "continues when the run’s own
|
|
892
|
+
`test/template/self-inflicted-marker.test.ts` (absent in a generated rig) › "continues when the run’s own
|
|
857
893
|
write moved the marker after the last validation" — and its
|
|
858
894
|
state against the `in-progress` a close expects, journals one `revalidation`
|
|
859
895
|
event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
|
|
@@ -864,7 +900,7 @@ three poisons the only channel by which this project learns.
|
|
|
864
900
|
and reads a hold as a stop" and › "re-reads each dependant's state, and names
|
|
865
901
|
one the tracker no longer offers". On a `github-issues` queue that list is
|
|
866
902
|
empty: a single `gh issue view` carries no cross-index, so `find` answers no
|
|
867
|
-
`blocks` there (`test/template/close-transitioned.test.ts` › "github asks `gh
|
|
903
|
+
`blocks` there (`test/template/close-transitioned.test.ts` (absent in a generated rig) › "github asks `gh
|
|
868
904
|
issue view` with the full field list and maps CLOSED to closed"). A
|
|
869
905
|
hold (exit 2) stops the close: re-read the item, record the outcome with
|
|
870
906
|
`node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
|
|
@@ -876,7 +912,7 @@ three poisons the only channel by which this project learns.
|
|
|
876
912
|
`true` says the close landed, because every adapter reads the item back after
|
|
877
913
|
the transition — `jira` the status category after the POST, `github-issues`
|
|
878
914
|
`gh issue view --json state`, `plan-md` the line being there and then gone
|
|
879
|
-
(the generator's `test/template/close-transitioned.test.ts` › "GETs the issue
|
|
915
|
+
(the generator's `test/template/close-transitioned.test.ts` (absent in a generated rig) › "GETs the issue
|
|
880
916
|
status after the transition POST and reports transitioned: true when the
|
|
881
917
|
category is done", › "runs `issue view <id> --json state` after `issue close`
|
|
882
918
|
and reports transitioned: true on CLOSED", › "reports transitioned: true once
|
|
@@ -61,11 +61,16 @@ about it here, so the two gates never file competing opinions on one paragraph.
|
|
|
61
61
|
|
|
62
62
|
⚠ A pointer into a test suite the reader's project does not carry is normally
|
|
63
63
|
item 2, not backing. There is one narrow inherited-snapshot exception from
|
|
64
|
-
`invariants.md`: a generator-authored
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
`invariants.md`: a generator-authored artifact — rules, hooks, skills,
|
|
65
|
+
scripts, or agent specs —
|
|
66
|
+
may point to upstream generator tests that are absent locally only when the
|
|
67
|
+
pointer explicitly says the suite is absent locally and
|
|
68
|
+
`.claude/.rig-manifest.json` proves the current artifact's hash matches the
|
|
69
|
+
installed manifest. A manifest-backed upgrade remains an inherited,
|
|
70
|
+
generator-owned artifact; a changed file in the upgrade diff does not alone
|
|
71
|
+
make it downstream-authored. The exception applies **only while the manifest
|
|
72
|
+
hash matches**. A hash mismatch, missing manifest, or no evidence ends the
|
|
73
|
+
exception and the local test is yours; then an absent pointer is item 2 again.
|
|
69
74
|
|
|
70
75
|
🔴 Three things this is not. It is not item 1: that one is about enforcement the
|
|
71
76
|
mechanism does not provide, this one is about any claim with nothing behind it,
|
|
@@ -12,15 +12,11 @@
|
|
|
12
12
|
// to tokenise: it owns exactly one invariant and stays readable because of it
|
|
13
13
|
// (see .claude/rules/invariants.md, "One invariant per hook"). Use a file rather
|
|
14
14
|
// than a heredoc, or quote the example.
|
|
15
|
-
import {
|
|
15
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
16
16
|
|
|
17
17
|
function main() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
21
|
-
} catch {
|
|
22
|
-
return 0;
|
|
23
|
-
}
|
|
18
|
+
const input = readHookInput();
|
|
19
|
+
if (input === null) return 0;
|
|
24
20
|
if (input.tool_name !== 'Bash') return 0;
|
|
25
21
|
const raw = String(input.tool_input?.command ?? '');
|
|
26
22
|
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
// a per-step budget is how three checks quietly cost three times the wall
|
|
35
35
|
// clock the wiring allows (`.claude/rules/invariants.md` — "an explicit total
|
|
36
36
|
// budget rather than a per-step one").
|
|
37
|
-
// see hooks.test.ts › "gates the stop when a check outruns the budget — unmeasured is not a pass"
|
|
38
|
-
// see hooks.test.ts › "spends one budget across the whole suite, not a fresh one per check"
|
|
39
|
-
// see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
37
|
+
// see hooks.test.ts (absent in a generated rig) › "gates the stop when a check outruns the budget — unmeasured is not a pass"
|
|
38
|
+
// see hooks.test.ts (absent in a generated rig) › "spends one budget across the whole suite, not a fresh one per check"
|
|
39
|
+
// see hooks.test.ts (absent in a generated rig) › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
40
40
|
//
|
|
41
41
|
// ⚠ This rests on ONE assumption about the harness that nothing in this
|
|
42
42
|
// repository can prove or falsify: that a hook outrunning its timeout is
|
|
@@ -48,24 +48,25 @@
|
|
|
48
48
|
// Limits, each with the test that pins it:
|
|
49
49
|
// - a check whose output outgrows the buffer is UNMEASURED, and blocks like
|
|
50
50
|
// any other unmeasured check —
|
|
51
|
-
// see hooks.test.ts › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
|
|
51
|
+
// see hooks.test.ts (absent in a generated rig) › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
|
|
52
52
|
// - below that buffer, output volume is not a verdict: a chatty check that
|
|
53
53
|
// passes, passes —
|
|
54
|
-
// see hooks.test.ts › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
|
|
54
|
+
// see hooks.test.ts (absent in a generated rig) › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
|
|
55
55
|
// - the RIG_DOD_BUDGET_MS override may only LOWER the budget, and an
|
|
56
56
|
// override this hook did not honour is announced rather than ignored —
|
|
57
|
-
// see hooks.test.ts › "clamps a budget override that would outlive the harness, and names the budget it used"
|
|
57
|
+
// see hooks.test.ts (absent in a generated rig) › "clamps a budget override that would outlive the harness, and names the budget it used"
|
|
58
58
|
// - a fail-open is announced on stderr, because a silent exit 0 and a clean
|
|
59
59
|
// pass are the same observation from outside —
|
|
60
|
-
// see hooks.test.ts › "announces a fail-open instead of returning a silent clean pass"
|
|
60
|
+
// see hooks.test.ts (absent in a generated rig) › "announces a fail-open instead of returning a silent clean pass"
|
|
61
61
|
// - an ABSENT config is not a failure and says nothing; only a config that
|
|
62
62
|
// exists and cannot be used announces —
|
|
63
|
-
// see hooks.test.ts › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
|
|
63
|
+
// see hooks.test.ts (absent in a generated rig) › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
|
|
64
64
|
import { execSync, spawnSync } from 'node:child_process';
|
|
65
65
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
66
66
|
import { fileURLToPath } from 'node:url';
|
|
67
67
|
|
|
68
68
|
import { withoutGitLocation } from '../scripts/git-env.mjs';
|
|
69
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
69
70
|
|
|
70
71
|
// 🔴 The tree this gate measures is the project the hook BELONGS to — the
|
|
71
72
|
// directory above `.claude/hooks/` — never the directory the session happens
|
|
@@ -81,9 +82,9 @@ import { withoutGitLocation } from '../scripts/git-env.mjs';
|
|
|
81
82
|
// `worktree-task` worktree is measured at the main checkout, and the refusal
|
|
82
83
|
// says so. Resolved inside `main()`, so a throw here reaches the backstop
|
|
83
84
|
// and announces itself like every other fault of the gate's own.
|
|
84
|
-
// see hooks.test.ts › "runs the checks in the project root, so a check reading the tree sees the session project"
|
|
85
|
-
// see hooks.test.ts › "asks "is the tree clean?" about the project, not about the cwd"
|
|
86
|
-
// see hooks.test.ts › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
|
|
85
|
+
// see hooks.test.ts (absent in a generated rig) › "runs the checks in the project root, so a check reading the tree sees the session project"
|
|
86
|
+
// see hooks.test.ts (absent in a generated rig) › "asks "is the tree clean?" about the project, not about the cwd"
|
|
87
|
+
// see hooks.test.ts (absent in a generated rig) › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
|
|
87
88
|
const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
|
|
88
89
|
|
|
89
90
|
// The default total budget, and the allowance for everything that happens
|
|
@@ -100,8 +101,8 @@ const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.m
|
|
|
100
101
|
// So the preamble gets a number and a leash: `git status` below is given this
|
|
101
102
|
// as its own timeout, which turns the allowance from a guess into a bound.
|
|
102
103
|
// Both numbers are read from this file and compared against the wiring —
|
|
103
|
-
// see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
104
|
-
// see hooks.test.ts › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
|
|
104
|
+
// see hooks.test.ts (absent in a generated rig) › "gives the stop gate a harness timeout its own budget finishes inside"
|
|
105
|
+
// see hooks.test.ts (absent in a generated rig) › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
|
|
105
106
|
const DEFAULT_BUDGET_MS = 600_000;
|
|
106
107
|
const PREAMBLE_MARGIN_MS = 60_000;
|
|
107
108
|
|
|
@@ -137,7 +138,7 @@ const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENF
|
|
|
137
138
|
* it. `Number.isSafeInteger` is the test rather than `isFinite`, because
|
|
138
139
|
* `spawnSync` throws on a fractional `timeout` — and a throw here would land in
|
|
139
140
|
* the backstop and open the gate completely.
|
|
140
|
-
* see hooks.test.ts › "runs the gate on the default budget when the override is unusable, instead of not running it"
|
|
141
|
+
* see hooks.test.ts (absent in a generated rig) › "runs the gate on the default budget when the override is unusable, instead of not running it"
|
|
141
142
|
*/
|
|
142
143
|
function budgetMs(env) {
|
|
143
144
|
const raw = env.RIG_DOD_BUDGET_MS;
|
|
@@ -166,12 +167,8 @@ function budgetMs(env) {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function main() {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
172
|
-
} catch (error) {
|
|
173
|
-
return failOpen(`the Stop payload could not be read: ${error.message}`);
|
|
174
|
-
}
|
|
170
|
+
const input = readHookInput();
|
|
171
|
+
if (input === null) return failOpen('the Stop payload could not be read');
|
|
175
172
|
if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
|
|
176
173
|
if (input.stop_hook_active) return 0;
|
|
177
174
|
|
|
@@ -235,13 +232,13 @@ function main() {
|
|
|
235
232
|
// still ended green. Both were shipped here, one after the other, and both
|
|
236
233
|
// are closed the same way — every usable entry runs, and a skipped entry is
|
|
237
234
|
// a check with no verdict, which blocks.
|
|
238
|
-
// see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
|
|
239
|
-
// see hooks.test.ts › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
|
|
235
|
+
// see hooks.test.ts (absent in a generated rig) › "never lets an unusable config entry hide a failing check behind it (an empty string)"
|
|
236
|
+
// see hooks.test.ts (absent in a generated rig) › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
|
|
240
237
|
//
|
|
241
238
|
// The predicate is exactly the three shapes `spawnSync` throws on, and no
|
|
242
239
|
// wider: the shell answers every other unrunnable string with 127, which is
|
|
243
240
|
// a verdict. Filtering past these three converts a real block into a skip.
|
|
244
|
-
// see hooks.test.ts › "refuses the stop for a config it could read but cannot use, and names the file to fix"
|
|
241
|
+
// see hooks.test.ts (absent in a generated rig) › "refuses the stop for a config it could read but cannot use, and names the file to fix"
|
|
245
242
|
const runnable = (command) =>
|
|
246
243
|
typeof command === 'string' && command !== '' && !command.includes('\0');
|
|
247
244
|
const usable = checks.filter(runnable);
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
//
|
|
25
25
|
// ── The limits, stated exactly — and TESTED ──────────────────────────────────
|
|
26
26
|
//
|
|
27
|
-
// This block is a credibility claim, so
|
|
27
|
+
// This block is a credibility claim, so the generator's
|
|
28
|
+
// `test/template/guard-hardening.test.ts` (absent in a generated rig)
|
|
28
29
|
// asserts each line twice: that the limit is documented here, and that the
|
|
29
30
|
// command really does pass. A limits comment nothing checks drifts into fiction,
|
|
30
31
|
// which is what happened the first time — an earlier version of this list was
|
|
@@ -76,9 +77,10 @@
|
|
|
76
77
|
// Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
77
78
|
// stderr is shown to the agent as the reason. Fails open on anything it cannot
|
|
78
79
|
// parse — a crashed guard must never make the session unusable.
|
|
79
|
-
import {
|
|
80
|
+
import { realpathSync } from 'node:fs';
|
|
80
81
|
import { fileURLToPath } from 'node:url';
|
|
81
82
|
import { brakeIsOn } from '../scripts/stop-flag.mjs';
|
|
83
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
82
84
|
|
|
83
85
|
/** Branches that are shared by definition. */
|
|
84
86
|
const PROTECTED_BRANCH = /^(main|master|develop|development|trunk)$/;
|
|
@@ -803,12 +805,8 @@ export const inspect = (raw, brake, depth = 0) => {
|
|
|
803
805
|
};
|
|
804
806
|
|
|
805
807
|
function main() {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
809
|
-
} catch {
|
|
810
|
-
return 0;
|
|
811
|
-
}
|
|
808
|
+
const input = readHookInput();
|
|
809
|
+
if (input === null) return 0;
|
|
812
810
|
if (input.tool_name !== 'Bash') return 0;
|
|
813
811
|
const commandValue = input.tool_input?.command;
|
|
814
812
|
if (typeof commandValue !== 'string') return 0;
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
|
|
9
9
|
// codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
|
|
10
10
|
// projects do not carry that suite, and a downstream edit requires a local replacement test.
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
12
11
|
import { editFragments } from './lib/edit-input.mjs';
|
|
12
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
13
13
|
|
|
14
14
|
/** The only non-relative import the core may use: its schema/validation library. */
|
|
15
15
|
const ALLOWED_PACKAGES = ['zod'];
|
|
@@ -29,12 +29,8 @@ const BANNED = [
|
|
|
29
29
|
];
|
|
30
30
|
|
|
31
31
|
function main() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
35
|
-
} catch {
|
|
36
|
-
return 0; // unparseable payload: not ours to judge
|
|
37
|
-
}
|
|
32
|
+
const input = readHookInput();
|
|
33
|
+
if (input === null) return 0; // unparseable payload: not ours to judge
|
|
38
34
|
const fragments = editFragments(input);
|
|
39
35
|
const blocked = fragments.find(
|
|
40
36
|
({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
|