create-agent-rig 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/package.json +1 -1
  3. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
  4. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +117 -80
  5. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +84 -16
  6. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +23 -3
  7. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +65 -7
  8. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +2 -2
  9. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +2 -2
  10. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +109 -0
  11. package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
  12. package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
  13. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
  14. package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
  15. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
  16. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
  17. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
  18. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
  19. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +41 -19
  20. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
  21. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +640 -59
  22. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
  23. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
  24. package/templates/agent-os/universal/.claude/settings.json +1 -1
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +117 -80
  26. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +84 -16
  27. package/templates/agent-os/universal/.codex/hooks.json +1 -1
  28. package/templates/agent-os/universal/.rig/revalidation.json +10 -0
  29. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
  30. package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
  31. package/templates/agent-os/universal/layers.json +5 -0
  32. package/templates/hash-history.json +104 -29
  33. package/templates/release-ledger.json +3 -1
@@ -35,7 +35,7 @@ blockers.
35
35
  flow, and this gate with it, begins when the project has a remote
36
36
  (`workflow.md`, "PR flow"). The refusal exists because two rounds were once counted ahead of a
37
37
  commit pre-commit then refused, so the counter and the fan-out's verdicts
38
- named a head that never shipped (AR-141) — pinned in the generator's
38
+ named a head that never shipped — pinned in the generator's
39
39
  `test/template/gate-rounds.test.ts` — absent in a generated rig — ›
40
40
  "refuses to count a round on a dirty tree, and counts nothing".
41
41
 
@@ -57,26 +57,90 @@ blockers.
57
57
  confidently-wrong reviews. Everything below is scoped to this diff.
58
58
 
59
59
  Then, on the fetched ref, ask whether the branch is still the branch the run
60
- took up (AR-134):
60
+ took up. **Two paths, and you state which one — the command infers neither.**
61
+ A branch that is a queue item's take-up:
61
62
 
62
63
  ```sh
63
64
  node .claude/scripts/revalidate.mjs --point BEFORE_PR --ticket <item-id> --base origin/<default>
64
65
  ```
65
66
 
66
- It compares two sources and names each one that moved: the item's `updatedAt`
67
- against the take-up snapshot `next` recorded (`task:updatedAt`), and what the
68
- default branch changed since this branch forked, on the paths the branch
69
- touches or a `check-premises` record in this run cited (`main:<path>`). It
67
+ Owner-directed work or a hotfix that has **no item** the case step 4 below
68
+ already tells you to declare to the reviewers:
69
+
70
+ ```sh
71
+ node .claude/scripts/revalidate.mjs --point BEFORE_PR --owner-directed --base origin/<default>
72
+ ```
73
+
74
+ 🔴 **The second path is not a lighter checkpoint, and it is never a skip.**
75
+ It runs the same `main:<path>` comparison and holds on the same exit 2; what
76
+ it drops is the claim comparison, because work with no item has no claim to
77
+ compare. It resolves no queue config, so it reaches no tracker and needs no
78
+ credentials. Passing both flags, or neither, is exit 1.
79
+
80
+ **Four refusals** — exit 1, nothing journalled — keep it from being the way
81
+ around a claim or revalidation failure. It is refused when this run carries
82
+ an **unresolved revalidation hold** (what the ticketed path writes when it
83
+ holds or answers `UNVERIFIABLE`), when this run **declares a take-up**, when
84
+ the branch **touches a tracked `.rig/claims/*.json`** in any direction —
85
+ added, modified, removed or renamed — and at `BEFORE_CLOSE`. So re-running a
86
+ held ticketed call in this mode does not get past it: resolve the hold with
87
+ `outcome` instead.
88
+
89
+ ⚠ **What those refusals do not cover**, because a governance mode is trusted
90
+ exactly as far as it is described:
91
+
92
+ - **With no `RIG_RUN_DIR` there is no run state to read**, so the hold and
93
+ take-up refusals cannot fire — and nothing is journalled. The command says
94
+ so on stdout and in `evidence.runState`; it is not evidence that neither
95
+ exists. An attended gate run is exactly this shape, so read that line.
96
+ - The claim refusal reads the **branch diff**, so a claim record already on
97
+ the default branch, or written and not committed, is not seen.
98
+ - **`--base` is the sole authority for the verdict here**, the claim
99
+ comparison that would otherwise survive a wrong base being absent. Pass
100
+ the fetched `origin/<default>`, not a local copy and not `HEAD`.
101
+ - Nothing can prove an item does not exist. The rest is your word, recorded
102
+ as such, with `ticket: null` and no invented id.
103
+
104
+ Pinned in the generator's `test/template/owner-directed-revalidation.test.ts`
105
+ (absent in a generated rig) › "refuses when this run carries an unresolved
106
+ revalidation hold", › "refuses when the declared run already carries a
107
+ take-up", › "refuses when the branch RENAMES a claim record — the case
108
+ --diff-filter=AM could not see" and › "says out loud that an undeclared run
109
+ checked neither the hold nor the take-up".
110
+
111
+ **Exit 2 here is a HOLD with the same shape as the ticketed one**, and the
112
+ same two-step remedy: re-read the default branch on each named path, then
113
+ record what the re-read concluded — `node .claude/scripts/revalidate.mjs
114
+ outcome --point BEFORE_PR --owner-directed --action-changed <true | false>
115
+ --note '…'` — and come back through step 0. The owner-directed detection
116
+ carries no ticket, so `--owner-directed` is how the outcome addresses it;
117
+ the ticketed `--ticket <item-id>` form below cannot match it and is refused.
118
+
119
+ The ticketed path runs the existing revalidation chain against the tracked, versioned
120
+ `.rig/claims/<item-id>.json`: the content-blind `scope` fingerprint set is
121
+ authoritative here, while `takeUps` / `updatedAt` remain evidence only. It
122
+ also names what the default branch changed since this branch forked on paths
123
+ the branch touches or a `check-premises` record cited (`main:<path>`). It
70
124
  journals one `revalidation` event at `point: BEFORE_PR`; **exit code 2 is a HOLD**, with one blocker per named source: re-read the item, or the default
71
125
  branch on that path, record what the re-read concluded —
72
126
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_PR --ticket <item-id> --action-changed <true | false> --note '…'`
73
- — and come back through step 0. A hold with no outcome is counted by the
74
- report as a re-read the run skipped. Exit 0 with
75
- `unverifiable` means the task side could not be compared no take-up
76
- snapshot in this run, or no marker — and is stated in the evidence, not read
77
- as a pass. Exit 1 is the command refusing (unknown point, no ticket, a base
78
- that is not a revision): fix the call. Its limits are its own header's; the
79
- cited-path set is a labelled assumption, not a recorded fact.
127
+ — and come back through step 0. **That `--ticket` form is this path's, not
128
+ both paths'** the owner-directed detection carries no ticket for it to
129
+ name, and its own `--owner-directed` outcome is written out above. A hold
130
+ with no outcome, in either mode, is counted by the report as a re-read the
131
+ run skipped. A missing, untracked, unreadable or
132
+ unsupported claim is `UNVERIFIABLE`, exits 2, and stops automatic progress;
133
+ so is a tracker whose adapter the command cannot READ, which means the
134
+ question was never put rather than that the claim record is unreadable.
135
+ Neither is ever read as a pass. Exit 1 is the command refusing (unknown
136
+ point, neither mode or both, a base that is not a revision, or a queue config
137
+ that does not resolve — plus, on the owner-directed path, the four refusals
138
+ above): fix the call or the config — the message says which. Its limits are its own header's; the
139
+ cited-path set is a labelled assumption, not a recorded fact. Pinned in the
140
+ generator's `test/template/revalidate.test.ts` (absent in a generated rig) ›
141
+ "continues when only updatedAt moved and still reports the marker evidence"
142
+ and `test/template/content-blind-revalidation.test.ts` › "refuses a deleted
143
+ tracked claim in a fresh run without take-up markers".
80
144
  2. **Route the diff before you spend on it.** This gate always ran its most
81
145
  expensive path, so a typo fix in a README bought the same fan-out as a
82
146
  rewrite of the storage layer. The dispatcher decides which lane the change
@@ -175,9 +239,13 @@ blockers.
175
239
  **Whatever you launch, pass it the text of the queue item this branch
176
240
  implements.** A reviewer given only a diff cannot check the change against
177
241
  what was asked: a cold context has no way to know, and reconstructing it from
178
- the PR description would mean trusting the run under review. If there is no
179
- item — owner-directed work, a hotfix — say so when launching, and the
180
- reviewer skips that check openly instead of guessing at it.
242
+ the PR description would mean trusting the run under review. When there is
243
+ no item — owner-directed work, a hotfix — launch every reviewer with the
244
+ words **`no item owner-directed`** instead. That skips the item-contract
245
+ check, openly, and **nothing else**: the checks, the routing, the security,
246
+ code and prose/governance reviews, the coverage check and the DoD all still
247
+ run. It is one check narrower than a ticketed fan-out, not a cheaper gate,
248
+ and the same words are what step 1's `--owner-directed` call records.
181
249
 
182
250
  🔴 **The triggers below are lane-independent and may only ADD.** They read
183
251
  *what the code does*; the router reads *paths*, and a path cannot say that a
@@ -12,13 +12,33 @@
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 { readHookInput } from './lib/hook-input.mjs';
15
+ import { readHookInput, refusalText, shellCommandOf } from './lib/hook-input.mjs';
16
+ import { SHELL_TOOLS } from '../scripts/lib/shell-tools.mjs';
16
17
 
17
18
  function main() {
18
19
  const input = readHookInput();
19
20
  if (input === null) return 0;
20
- if (input.tool_name !== 'Bash') return 0;
21
- const raw = String(input.tool_input?.command ?? '');
21
+ // One list, not a literal: this hook was launched for every shell surface
22
+ // and then excused itself from all but Bash, so the pre-commit gate stayed
23
+ // bypassable on the other one.
24
+ if (!SHELL_TOOLS.includes(input.tool_name)) return 0;
25
+ // Three outcomes, from the one shared contract (RP-80). `String(argv)` used
26
+ // to stand here, and it did not merely fail to read an array — it produced a
27
+ // plausible-looking string: `["git","commit","--no-verify"]` became
28
+ // `git,commit,--no-verify`, where the tokeniser below never splits on a comma,
29
+ // so the one flag this hook exists to refuse was silently absent. A guard that
30
+ // converts what it cannot read into something it can is worse than one that
31
+ // refuses, because it reports a check it did not perform.
32
+ const parsed = shellCommandOf(input);
33
+ if (parsed.kind === 'unreadable') {
34
+ process.stderr.write(`${refusalText(parsed)}\n`);
35
+ return 2;
36
+ }
37
+ // Positive test, not a list of the others: a member added later must not
38
+ // reach `raw.replace(…)` below with `undefined`, which throws, exits 1, and
39
+ // is read as allow.
40
+ if (parsed.kind !== 'string') return 0;
41
+ const raw = parsed.command;
22
42
 
23
43
  // Strip quoted segments first: a commit message that merely MENTIONS a
24
44
  // forbidden flag is prose, not a bypass. Only unquoted flags count.
@@ -74,13 +74,48 @@
74
74
  // written without thinking — not an adversary, and circumventing it is itself a
75
75
  // Never-tier violation. The layers behind it are review and CI.
76
76
  //
77
+ // ── Which SURFACES it sees, and what that does not promise (RP-65) ───────────
78
+ //
79
+ // This runs for every tool named in `.claude/scripts/lib/shell-tools.mjs`, not
80
+ // for `Bash` alone. It used to be wired under `Bash` only, and the measurement
81
+ // that changed it is in that file: the same `--no-verify` command was blocked
82
+ // through one tool and ran through the other, in one session.
83
+ //
84
+ // ⚠ Widening the matcher makes the same RULES run on both surfaces. It does not
85
+ // make the PARSING identical: the tokeniser above is POSIX, and PowerShell's
86
+ // quoting, escaping and separators are its own, so a command whose danger is
87
+ // visible only after PowerShell-specific parsing can read differently here.
88
+ //
89
+ // ⚠ And the coarse checks are narrower than "coarse" suggests. The rules
90
+ // match a command NAME — `git`, `gh`, `rm` — so they refuse the operation
91
+ // only when the operation is spelled that way.
92
+ // Measured with the brake armed: `gh pr merge …` is refused on both surfaces,
93
+ // while `gh.exe pr merge …`, `Start-Process gh -ArgumentList …` and
94
+ // `Remove-Item -Recurse -Force C:\` are all allowed. The first of those is
95
+ // allowed under `Bash` too, so this is a rule-set bound rather than anything
96
+ // the widened matcher introduced — but it is a bound, and an earlier draft of
97
+ // this block claimed the opposite. This gap is why the file keeps a name that
98
+ // says `bash`: a rename would promise a parity the parser does not have.
99
+ //
77
100
  // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
78
- // stderr is shown to the agent as the reason. Fails open on anything it cannot
79
- // parse — a crashed guard must never make the session unusable.
101
+ // stderr is shown to the agent as the reason.
102
+ //
103
+ // Two different things happen to input this guard cannot act on, and collapsing
104
+ // them into one sentence is the mistake `.claude/rules/invariants.md`
105
+ // ("Refusing to inspect is a third outcome") says costs a credential either way:
106
+ // - NOTHING TO JUDGE -> allow. An unparseable payload, no `tool_input`, no
107
+ // `command`, an empty one, a tool this guard does not answer for, or a crash
108
+ // inside `inspect` — a guard that has nothing to look at, or that broke, must
109
+ // never make the session unusable.
110
+ // - HANDED SOMETHING IT CANNOT READ -> block. A `command` that is present in a
111
+ // shape this guard does not accept is refused, naming the shape expected,
112
+ // because allowing it would report a check that never ran.
113
+ // The split is decided in one place for both shell guards, `lib/hook-input.mjs`.
80
114
  import { realpathSync } from 'node:fs';
81
115
  import { fileURLToPath } from 'node:url';
82
116
  import { brakeIsOn } from '../scripts/stop-flag.mjs';
83
- import { readHookInput } from './lib/hook-input.mjs';
117
+ import { SHELL_TOOLS } from '../scripts/lib/shell-tools.mjs';
118
+ import { readHookInput, refusalText, shellCommandOf } from './lib/hook-input.mjs';
84
119
 
85
120
  /** Branches that are shared by definition. */
86
121
  const PROTECTED_BRANCH = /^(main|master|develop|development|trunk)$/;
@@ -807,10 +842,33 @@ export const inspect = (raw, brake, depth = 0) => {
807
842
  function main() {
808
843
  const input = readHookInput();
809
844
  if (input === null) return 0;
810
- if (input.tool_name !== 'Bash') return 0;
811
- const commandValue = input.tool_input?.command;
812
- if (typeof commandValue !== 'string') return 0;
813
- const raw = commandValue;
845
+ // The ONE list decides which surfaces this guard answers for. Comparing a
846
+ // literal here is what made the widened matcher in `settings.json` cosmetic:
847
+ // the hook was launched for every shell tool and then excused itself from all
848
+ // but one, so the Never tier and the kill switch stayed bypassable on the
849
+ // other. Two spellings of one fact, and the one that ran was the wrong one.
850
+ if (!SHELL_TOOLS.includes(input.tool_name)) return 0;
851
+ // Three outcomes, decided in one shared place (RP-80): absent → allow, a
852
+ // string → inspect, present-in-a-shape-this-cannot-read → REFUSE. The last
853
+ // one used to be an allow, and what that cost is measured rather than
854
+ // asserted: on `master` at `254b25c8`, with the kill switch armed, a
855
+ // `command` spelled as an array of argv words returned 0 here before
856
+ // `brakeIsOn()` was ever consulted. Pinned in hook-command-shape.test.ts
857
+ // (absent in a generated rig) › "refuses an unreadable command through %s
858
+ // while the kill switch is armed". A rule that can be stepped over by
859
+ // restating the same command in another container is not a rule.
860
+ const command = shellCommandOf(input);
861
+ if (command.kind === 'unreadable') {
862
+ process.stderr.write(`${refusalText(command)}\n`);
863
+ return 2;
864
+ }
865
+ // Every member except `string` leaves nothing to inspect. Stated as one
866
+ // POSITIVE test rather than a list of the others, so a member added later
867
+ // cannot fall through to `raw.trim()` — which sits outside the try below,
868
+ // where a throw exits 1 and the harness reads that as allow. That is the
869
+ // fail-open this change removes, re-entering by another door.
870
+ if (command.kind !== 'string') return 0;
871
+ const raw = command.command;
814
872
  if (!raw.trim()) return 0;
815
873
 
816
874
  try {
@@ -6,8 +6,8 @@
6
6
  // Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
7
7
  // stderr is shown to the agent as the reason.
8
8
  // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
- // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
- // projects do not carry that suite, and a downstream edit requires a local replacement test.
9
+ // codex.test.ts (absent in a generated rig) › "$guard blocks with a neutral,
10
+ // actionable size-limit refusal" a downstream edit requires a local replacement test.
11
11
  import { editFragments } from './lib/edit-input.mjs';
12
12
  import { readHookInput } from './lib/hook-input.mjs';
13
13
 
@@ -6,8 +6,8 @@
6
6
  // Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
7
7
  // stderr is shown to the agent as the reason.
8
8
  // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
- // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
- // projects do not carry that suite, and a downstream edit requires a local replacement test.
9
+ // codex.test.ts (absent in a generated rig) › "$guard blocks with a neutral,
10
+ // actionable size-limit refusal" a downstream edit requires a local replacement test.
11
11
  import { editFragments } from './lib/edit-input.mjs';
12
12
  import { readHookInput } from './lib/hook-input.mjs';
13
13
 
@@ -53,3 +53,112 @@ export const readHookInput = () => {
53
53
  return null;
54
54
  }
55
55
  };
56
+
57
+ /**
58
+ * ── The shape of a shell command, for the Never-tier SHELL guards (RP-80) ───
59
+ *
60
+ * One place, for the two guards that read `tool_input.command` on a shell tool —
61
+ * `guard-bash` and `block-no-verify`. It is deliberately NOT a repository-wide
62
+ * ruling on the word `command`: `guard-secret-file` reads an `apply_patch`
63
+ * `command` that is a **list of strings**, which is exactly the shape this
64
+ * module classifies as unreadable, and it is right to — the reasoning is in
65
+ * `docs/decisions/codex-adapter.md`. Routing a third guard through here without
66
+ * checking which shape its tool actually sends would start refusing input
67
+ * another guard exists to read.
68
+ *
69
+ * `.claude/rules/invariants.md` ("Refusing to inspect is a third outcome, not a
70
+ * match and not an error") draws the line these guards need:
71
+ *
72
+ * - **absent** — nothing to judge, so the guard fails OPEN. A `Write` with no
73
+ * content and a shell tool with no `command` are the same case.
74
+ * - **`string`** — the guard inspects it as it always did.
75
+ * - **present and unreadable** — the guard was handed something and can tell
76
+ * that it cannot read it. That is a REFUSAL: block, name the shape expected,
77
+ * and say to resend in that shape.
78
+ *
79
+ * 🔴 Getting that last line backwards costs the whole rule set. Measured on
80
+ * `master` at `254b25c8`: a payload whose `command` was `["gh","pr","merge","1"]`
81
+ * exited 0 from `guard-bash` **with the kill switch armed** — the brake was
82
+ * never consulted, because the guard had already excused itself. `block-no-verify`
83
+ * had the same hole by another road: `String(argv)` comma-joins, and its
84
+ * tokeniser never splits on a comma, so `--no-verify` became invisible rather
85
+ * than unreadable.
86
+ *
87
+ * 🔴 The remedy is carried as a FIELD beside the reason, not inferred from the
88
+ * reason's wording by whoever prints it — `invariants.md` again. A remedy
89
+ * chosen by pattern-matching a sentence is wrong the day somebody rewords the
90
+ * sentence, in every copy at once. And it is deliberately NOT "split the change
91
+ * and retry": that advice belongs to a crossed BOUND, where a smaller input
92
+ * really does fit. Nothing about splitting changes a container's shape, so
93
+ * offering it here would turn a refusal into a loop.
94
+ *
95
+ * Bounded work, because a fail-open guard is a total bypass if any input can
96
+ * make it spin or throw: a fixed number of `typeof` tests and one lookup. The
97
+ * offending value is NEVER serialised into the message — only its shape word —
98
+ * so an enormous or cyclic `command` costs nothing and leaks nothing.
99
+ *
100
+ * Limits, stated rather than implied:
101
+ * - It reads `tool_input.command`. A surface that names its command field
102
+ * differently is not seen at all, and that is the same blind spot the whole
103
+ * list in `.claude/scripts/lib/shell-tools.mjs` has: harness → guard is
104
+ * guarded by nobody.
105
+ * - `absent` covers `null` as well as `undefined`, on both `tool_input` and
106
+ * `command`. A key explicitly set to null carries no command to read, and
107
+ * refusing it would fire on payloads that mean "no command".
108
+ * - An empty or whitespace-only string is READABLE and allowed. It is a
109
+ * command that does nothing, not a shape the guard failed to parse.
110
+ * Pinned in hook-command-shape.test.ts (absent in a generated rig) ›
111
+ * "refuses an unreadable command through %s while the kill switch is armed"
112
+ * and › "allows an ABSENT command through %s".
113
+ */
114
+
115
+ /** The shape word for a value, bounded: never the value itself. */
116
+ const shapeOf = (value) => {
117
+ if (Array.isArray(value)) return 'an array';
118
+ const type = typeof value;
119
+ return type === 'object' ? 'an object' : `a ${type}`;
120
+ };
121
+
122
+ /**
123
+ * What a shell guard was handed, as one of three outcomes.
124
+ *
125
+ * @returns {{kind:'absent'}
126
+ * |{kind:'string', command:string}
127
+ * |{kind:'unreadable', reason:string, remedy:string}}
128
+ */
129
+ export const shellCommandOf = (input) => {
130
+ const toolInput = input?.tool_input;
131
+ if (toolInput === undefined || toolInput === null) return { kind: 'absent' };
132
+ if (typeof toolInput !== 'object' || Array.isArray(toolInput)) {
133
+ return unreadable('tool_input', shapeOf(toolInput), 'an object');
134
+ }
135
+ const command = toolInput.command;
136
+ if (command === undefined || command === null) return { kind: 'absent' };
137
+ if (typeof command !== 'string') {
138
+ return unreadable('tool_input.command', shapeOf(command), 'a string');
139
+ }
140
+ return { kind: 'string', command };
141
+ };
142
+
143
+ const unreadable = (field, got, expected) => ({
144
+ kind: 'unreadable',
145
+ reason:
146
+ `BLOCKED — ${field} is present as ${got}, and this guard reads ${expected}. ` +
147
+ 'An input it cannot read is refused, never allowed: the Never-tier rules and ' +
148
+ 'the kill switch are decided by reading the command, so allowing what was ' +
149
+ 'not read would report a check that did not happen ' +
150
+ '(.claude/rules/invariants.md, "Refusing to inspect is a third outcome").',
151
+ remedy: `Resend the call with ${field} as ${expected}.`,
152
+ });
153
+
154
+ /**
155
+ * One refusal, one wording — so two guards cannot drift apart in what they say.
156
+ *
157
+ * It answers for the `unreadable` member only. Handed anything else it returns
158
+ * the empty string rather than `"undefined undefined"`: a guard that printed
159
+ * that would be reporting a refusal it cannot explain, and the caller could not
160
+ * act on it. The call sites below never do this today; the guard is here so a
161
+ * later one cannot introduce it silently.
162
+ */
163
+ export const refusalText = (refusal) =>
164
+ refusal?.kind === 'unreadable' ? `${refusal.reason} ${refusal.remedy}` : '';
@@ -223,6 +223,25 @@ an artifact may cite the generator's upstream tests, which are absent locally,
223
223
  only when the pointer says they are absent and `.claude/.rig-manifest.json`
224
224
  proves the current artifact's hash matches the installed manifest.
225
225
 
226
+ **In the generator, part of that is mechanical.** A citation there is a
227
+ test-file name followed by `›` and a quoted test name, and the generator's
228
+ `test/template/evidence-pointers.test.ts` (absent in a generated rig) resolves
229
+ the citations it reads: › "names a test file this repository still has" and ›
230
+ "quotes a test name that file still declares" go red when a target is renamed
231
+ or a test retitled, and › "says so when the test it names is one a generated
232
+ project never receives" goes red when a pointer into the generator's suite
233
+ carries no word that the reader does not have it. The wording that satisfies
234
+ the last one is `(absent in a generated rig)` beside the pointer, or the
235
+ one-line disclosure nine scripts here open with — a phrase naming the generator
236
+ is not enough, because it tells the reader where the test lives and nothing
237
+ about whether they have it.
238
+
239
+ ⚠ **"the citations it reads" is not all of them**, and the test's own header
240
+ states where the edges are: it looks only at the tree that ships into a rig,
241
+ and it reads a citation's names from the line the file is named on and the two
242
+ after it. A green run means no citation it read has gone dead — not that every
243
+ pointer in the generator was verified.
244
+
226
245
  A manifest-backed upgrade remains an inherited, generator-owned artifact even
227
246
  though the upgrade diff changes its bytes. The exception applies **only while the
228
247
  manifest hash matches**. A hash mismatch, missing manifest, or no evidence ends