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.
Files changed (38) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/templates/agent-os/init/AGENTS.md +4 -2
  5. package/templates/agent-os/init/CLAUDE.md +4 -2
  6. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +46 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
  9. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
  10. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
  11. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
  13. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
  14. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
  15. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  16. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  17. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
  18. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  19. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  20. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  21. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  22. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  25. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  26. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
  27. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  28. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  29. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
  31. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  32. package/templates/agent-os/universal/.codex/hooks.json +16 -16
  33. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  34. package/templates/agent-os/universal/layers.json +1 -0
  35. package/templates/hash-history.json +158 -43
  36. package/templates/release-ledger.json +3 -1
  37. package/templates/skeleton/aws-serverless/gitignore +2 -0
  38. 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`, the queue adapters, the router, the gate sweep, the rules,
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.0",
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. **Four runtime paths need a `.gitignore` line each**, and `init` cannot add
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. Add all four:
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. **Four runtime paths need a `.gitignore` line each**, and `init` cannot add
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. Add all four:
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 `.claude/hooks/`,
107
- `.claude/settings.json`, `.claude/queue.json`, `.claude/scripts/queue/`, the
108
- router, the gate sweep, `.claude/rules/` or `CLAUDE.md` is refused unless its
109
- path starts with an allowed prefix; with no flag the guard does nothing. An
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 project key from .claude/queue.json;
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 hook may point to upstream generator
65
- tests that are absent locally **only while the hook is unchanged downstream**
66
- and its hook header identifies those tests as absent locally. If that hook is
67
- edited downstream or appears as changed in the current diff, the exception
68
- expires and the local test is yours; then an absent pointer is item 2 again.
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 { readFileSync } from 'node:fs';
15
+ import { readHookInput } from './lib/hook-input.mjs';
16
16
 
17
17
  function main() {
18
- let input;
19
- try {
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
- let input;
170
- try {
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 `test/template/guard-hardening.test.ts`
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 { readFileSync, realpathSync } from 'node:fs';
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
- let input;
807
- try {
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
- let input;
33
- try {
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,