create-agent-rig 0.8.0 → 0.9.0

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 (52) hide show
  1. package/CHANGELOG.md +105 -1
  2. package/README.md +92 -3
  3. package/package.json +4 -3
  4. package/packages/cli/dist/commands/memory.js +123 -0
  5. package/packages/cli/dist/commands/setup.js +45 -0
  6. package/packages/cli/dist/index.js +107 -3
  7. package/packages/cli/dist/lib/subsystems.js +269 -0
  8. package/packages/cli/dist/lib/version.js +15 -0
  9. package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
  10. package/packages/cli/dist/policy/core/coverage.js +253 -0
  11. package/packages/cli/dist/policy/core/decision-record.js +130 -44
  12. package/packages/cli/dist/policy/core/declaration.js +58 -17
  13. package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
  14. package/packages/cli/dist/policy/core/probe.js +442 -0
  15. package/packages/cli/dist/policy/core/validation.js +194 -1
  16. package/packages/cli/dist/policy/core/vocabulary.js +70 -3
  17. package/packages/cli/dist/policy/harness/claude.js +9 -1
  18. package/packages/cli/dist/policy/harness/codex.js +48 -1
  19. package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
  20. package/packages/cli/dist/policy/index.js +9 -2
  21. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
  22. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
  23. package/templates/agent-os/subagent-routing.json +32 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
  25. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
  26. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
  27. package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
  28. package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
  29. package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
  30. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
  31. package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
  32. package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
  33. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
  34. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
  35. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
  36. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
  37. package/templates/agent-os/universal/.claude/settings.json +16 -0
  38. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
  39. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
  40. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
  41. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
  42. package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
  43. package/templates/agent-os/universal/.codex/config.toml +3 -0
  44. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
  45. package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
  46. package/templates/agent-os/universal/layers.json +4 -0
  47. package/templates/hash-history.json +8 -4
  48. package/templates/release-ledger.json +2 -1
  49. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
  50. package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
  51. package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
  52. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
@@ -0,0 +1,234 @@
1
+ // PreToolUse hook (Claude Code's Agent tool): a subagent whose project
2
+ // definition pins its model is never dispatched with a call-site `model`.
3
+ //
4
+ // Why it exists. Claude Code takes a subagent's model from the per-invocation
5
+ // `model` parameter FIRST, and only then from the definition's `model:` line
6
+ // (Claude Code's sub-agents documentation, "model resolution"). A pin is
7
+ // therefore only as strong as every call site: the generator's
8
+ // docs/capability-evidence.json (absent in a generated rig) records a pinned
9
+ // agent that ran the call-site model instead — mechanism `subagent-model-pin`,
10
+ // surface `Agent tool call-site model, without guard-subagent-model`. Which
11
+ // model a role reads with is the routing policy's decision
12
+ // (docs/decisions/subagent-routing.md), not one dispatch's.
13
+ //
14
+ // What "pinned" means here: `<project>/.claude/agents/<subagent_type>.md` is a
15
+ // regular file whose leading frontmatter carries a `model:` line with a value
16
+ // other than `inherit`. The project root is CLAUDE_PROJECT_DIR, else the working
17
+ // directory. One leading byte-order mark is not content and is skipped.
18
+ //
19
+ // The three outcomes (.claude/rules/invariants.md):
20
+ // - allow — nothing it can read, another event, no call-site model, or an
21
+ // ad-hoc subagent: no `subagent_type`, a built-in, a name that is not a plain
22
+ // file name (`plugin:agent`, `../x`), no such file, a path that is not a
23
+ // regular file (a directory, a FIFO — opened without waiting, never read), or
24
+ // no pin in it;
25
+ // - block — a call-site model for a pinned agent;
26
+ // - refuse to inspect — `model` or `subagent_type` PRESENT in a shape other
27
+ // than a string (resend it as one), or a frontmatter that does not close
28
+ // within MAX_HEAD_BYTES (a bound crossed).
29
+ // An error opening or reading the agent file fails open, like every guard here.
30
+ //
31
+ // Bounded work: one non-blocking open, one fstat, one read of at most
32
+ // MAX_HEAD_BYTES + 1 bytes, one frontmatter match, and one pass over its lines
33
+ // with a prefix test per line. Nothing recurses or rescans.
34
+ //
35
+ // Limits, stated so nobody relies on cover that is not here:
36
+ // - project agents only. A user-level or plugin agent that pins a model is not
37
+ // a role of this project, and its call-site model is allowed;
38
+ // - the file is found by the dispatched name, so a project agent whose
39
+ // frontmatter `name` differs from its file name is not matched;
40
+ // - `model:` is read as a frontmatter line, not through a YAML parser, so a pin
41
+ // spelled another YAML way (a quoted key) is not seen.
42
+ // Pinned in the generator's test/template/subagent-routing-hooks.test.ts
43
+ // (absent in a generated rig) › "blocks a call-site model on a project agent
44
+ // that pins one, and says to re-dispatch without it", › "never resolves %s to a
45
+ // project agent file, so the call is allowed", › "refuses an agent file whose
46
+ // frontmatter does not close within the read bound, and names the bound", ›
47
+ // "allows a call-site model without waiting when the agent path is not a regular
48
+ // file", › "reads a pin in an agent file that starts with a byte-order mark", ›
49
+ // "echoes a model pinned in the agent file bounded and escaped" (the bound) and
50
+ // › "escapes a control byte inside the echoed part of both model names".
51
+ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from 'node:fs';
52
+ import path from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+ import { readHookInput } from './lib/hook-input.mjs';
55
+
56
+ /** How much of an agent file is read to find its frontmatter. */
57
+ export const MAX_HEAD_BYTES = 64 * 1024;
58
+
59
+ /** How much of a model name a refusal repeats, from either side of the comparison. */
60
+ const MAX_ECHOED_MODEL = 64;
61
+
62
+ /** A name that can only ever be a file directly under `.claude/agents/`. */
63
+ const AGENT_FILE_NAME = /^[A-Za-z0-9_-]{1,128}$/;
64
+
65
+ /**
66
+ * Open without blocking: a FIFO at the agent path would otherwise hold the open
67
+ * until a writer appears. Windows has no such flag and no such file there.
68
+ */
69
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
70
+
71
+ const ALLOW = Object.freeze({ outcome: 'allow' });
72
+
73
+ /** The shape word for a value, bounded: never the value itself. */
74
+ const shapeOf = (value) => {
75
+ if (Array.isArray(value)) return 'an array';
76
+ const type = typeof value;
77
+ return type === 'object' ? 'an object' : `a ${type}`;
78
+ };
79
+
80
+ /**
81
+ * A model name as a refusal may print it: at most MAX_ECHOED_MODEL characters,
82
+ * through JSON.stringify — which escapes C0 control bytes such as ESC, and
83
+ * leaves other non-ASCII characters (C1 controls, bidi overrides) as they are.
84
+ */
85
+ const echoed = (model) => JSON.stringify(model.slice(0, MAX_ECHOED_MODEL));
86
+
87
+ const unreadable = (field, value, expected) => ({
88
+ outcome: 'refuse',
89
+ message:
90
+ `BLOCKED — ${field} is present as ${shapeOf(value)}, and this guard reads ${expected}. ` +
91
+ 'An input it cannot read is refused, never allowed: whether this dispatch overrides a ' +
92
+ 'pinned model is decided by reading it (.claude/rules/invariants.md, "Refusing to ' +
93
+ `inspect is a third outcome"). Resend the Agent call with ${field} as ${expected}.`,
94
+ });
95
+
96
+ /**
97
+ * The first MAX_HEAD_BYTES of a regular file, and whether it went on past them;
98
+ * null when the path is absent, is not a regular file, or cannot be read.
99
+ */
100
+ function readHead(file) {
101
+ let fd;
102
+ try {
103
+ fd = openSync(file, OPEN_FLAGS);
104
+ if (!fstatSync(fd).isFile()) return null;
105
+ const buffer = Buffer.alloc(MAX_HEAD_BYTES + 1);
106
+ let filled = 0;
107
+ while (filled < buffer.length) {
108
+ const read = readSync(fd, buffer, filled, buffer.length - filled, null);
109
+ if (read === 0) break;
110
+ filled += read;
111
+ }
112
+ return {
113
+ text: buffer.subarray(0, Math.min(filled, MAX_HEAD_BYTES)).toString('utf8'),
114
+ cut: filled > MAX_HEAD_BYTES,
115
+ };
116
+ } catch {
117
+ return null;
118
+ } finally {
119
+ if (fd !== undefined) {
120
+ try {
121
+ closeSync(fd);
122
+ } catch {
123
+ // nothing left to release
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * `{ kind: 'pinned', model }`, `{ kind: 'none' }`, or `{ kind: 'unbounded' }`
131
+ * when a frontmatter opens and does not close inside the bytes that were read.
132
+ */
133
+ function pinOf({ text: raw, cut }) {
134
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
135
+ if (!/^---\r?\n/.test(text)) return { kind: 'none' };
136
+ // A head that was cut may end in the middle of a line, so only a closer
137
+ // followed by a newline counts there; an uncut head may end at the closer.
138
+ const closed = (
139
+ cut
140
+ ? /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/
141
+ : /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
142
+ ).exec(text);
143
+ if (!closed) return cut ? { kind: 'unbounded' } : { kind: 'none' };
144
+ for (const line of closed[1].split(/\r?\n/)) {
145
+ if (!line.startsWith('model:')) continue;
146
+ const value = line.slice('model:'.length).trim();
147
+ const quoted =
148
+ value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0];
149
+ const model = quoted ? value.slice(1, -1) : value;
150
+ return model !== '' && model !== 'inherit' ? { kind: 'pinned', model } : { kind: 'none' };
151
+ }
152
+ return { kind: 'none' };
153
+ }
154
+
155
+ /** The verdict for one hook payload, judged against the project at `root`. */
156
+ export function judge(input, root) {
157
+ if (input === null || typeof input !== 'object' || input.hook_event_name !== 'PreToolUse') {
158
+ return ALLOW;
159
+ }
160
+ const toolInput = input.tool_input;
161
+ if (toolInput === undefined || toolInput === null) return ALLOW;
162
+ if (typeof toolInput !== 'object' || Array.isArray(toolInput)) {
163
+ return unreadable('tool_input', toolInput, 'an object');
164
+ }
165
+
166
+ const model = toolInput.model;
167
+ if (model === undefined || model === null) return ALLOW;
168
+ if (typeof model !== 'string') return unreadable('tool_input.model', model, 'a string');
169
+ if (model.trim() === '') return ALLOW;
170
+
171
+ const name = toolInput.subagent_type;
172
+ if (name === undefined || name === null) return ALLOW;
173
+ if (typeof name !== 'string') return unreadable('tool_input.subagent_type', name, 'a string');
174
+ if (!AGENT_FILE_NAME.test(name)) return ALLOW;
175
+
176
+ const relative = `.claude/agents/${name}.md`;
177
+ const head = readHead(path.join(root, '.claude', 'agents', `${name}.md`));
178
+ if (head === null) return ALLOW;
179
+ const pin = pinOf(head);
180
+ if (pin.kind === 'unbounded') {
181
+ return {
182
+ outcome: 'refuse',
183
+ message:
184
+ `BLOCKED — ${relative} opens a frontmatter that does not close within the first ` +
185
+ `${MAX_HEAD_BYTES} bytes, the limit this guard reads, so it cannot tell whether ` +
186
+ `\`${name}\` pins its model. Close the frontmatter near the top of the file, or ` +
187
+ 're-dispatch without `model`.',
188
+ };
189
+ }
190
+ if (pin.kind !== 'pinned') return ALLOW;
191
+ return {
192
+ outcome: 'block',
193
+ message:
194
+ `BLOCKED — \`${name}\` pins its model in ${relative} (${echoed(pin.model)}), and this ` +
195
+ `dispatch passes model ${echoed(model)}, which Claude Code would run instead.\n` +
196
+ 'Re-dispatch without `model`: the agent definition decides which model a role runs on. ' +
197
+ "Changing a role's model is a policy change — make it in the agent definition, in a " +
198
+ 'reviewed change, never in one call (docs/decisions/subagent-routing.md).',
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Whether this file is being run as a script rather than imported — the realpath
204
+ * on both sides, as `inject-rules.mjs` explains, so a symlinked checkout still runs.
205
+ */
206
+ function invokedDirectly() {
207
+ if (!process.argv[1]) return false;
208
+ const real = (p) => {
209
+ try {
210
+ return realpathSync(p);
211
+ } catch {
212
+ return p;
213
+ }
214
+ };
215
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
216
+ }
217
+
218
+ function main() {
219
+ let verdict;
220
+ try {
221
+ const input = readHookInput();
222
+ if (input === null) return 0;
223
+ verdict = judge(input, process.env.CLAUDE_PROJECT_DIR || process.cwd());
224
+ } catch {
225
+ return 0;
226
+ }
227
+ if (verdict.outcome === 'allow') return 0;
228
+ process.stderr.write(`${verdict.message}\n`);
229
+ return 2;
230
+ }
231
+
232
+ if (invokedDirectly()) {
233
+ process.exit(main());
234
+ }
@@ -49,9 +49,73 @@ const MAX_PATCH_SECTIONS = 128;
49
49
  const MAX_MULTI_EDITS = 256;
50
50
  const MAX_PATCH_PATH_COMPONENTS = 512;
51
51
 
52
+ /**
53
+ * The surfaces this normaliser answers for. A tool outside the set is one the
54
+ * hook does not understand, and the refusal below must not reach it: a guard
55
+ * that blocks payloads it was never asked about is a guard that gets deleted.
56
+ */
57
+ const EDIT_TOOL_NAMES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
58
+
59
+ /**
60
+ * The one refusal for a container this file cannot read, on every surface it
61
+ * owns. `apply_patch` keeps its own wording because the shape it expects is a
62
+ * different shape — a patch string — and a remedy naming an object would be
63
+ * advice its caller cannot act on.
64
+ */
65
+ const unreadableToolInput = (toolName) =>
66
+ toolName === 'apply_patch'
67
+ ? {
68
+ filePath: '',
69
+ fragment: '',
70
+ inspectionRefusal:
71
+ 'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
72
+ 'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
73
+ 'nothing about this patch is vouched for.',
74
+ remedy: 'Send the command as a patch string, or a list of strings.',
75
+ appliesToAll: true,
76
+ }
77
+ : {
78
+ filePath: '',
79
+ fragment: '',
80
+ inspectionRefusal:
81
+ 'the tool_input arrived in a shape this guard cannot read — it is an object ' +
82
+ 'carrying the edit fields, and nothing else. Nothing was inspected, so nothing ' +
83
+ 'about this edit is vouched for.',
84
+ remedy: 'Send the tool_input as an object carrying the edit fields.',
85
+ appliesToAll: true,
86
+ };
87
+
52
88
  export function editFragments(input) {
53
89
  const toolName = input?.tool_name;
54
- const toolInput = input?.tool_input ?? {};
90
+ const rawToolInput = input?.tool_input;
91
+ // 🔴 **"I could not look" is not "there was nothing to look at" — and this
92
+ // file used to answer both ways depending on which arm you reached.**
93
+ // `?? {}` substitutes only for null/undefined, so a `tool_input` PRESENT as a
94
+ // string, a number or an array flowed on as that value: every field read came
95
+ // back `undefined`, and the Write/Edit/NotebookEdit arms returned a fragment
96
+ // with an empty path and empty text — which every consuming guard reads as a
97
+ // clean edit. Measured on master `6589db36`: a `tool_input` that is a string
98
+ // carrying a credential exited 0 through Write, Edit, MultiEdit and
99
+ // NotebookEdit, while the SAME shape through `apply_patch` exited 2. The
100
+ // divergence was between arms of this one file, which is why the contract
101
+ // belongs here rather than in each guard (RP-85, applying RP-80's ruling).
102
+ //
103
+ // ⚠ **ABSENT stays fail-open, and the difference is the whole rule.**
104
+ // `.claude/rules/invariants.md`: a field that is simply absent is the case the
105
+ // guard has nothing to judge — it must allow, exactly as a `Write` carrying no
106
+ // content does. A field PRESENT in a shape the guard does not accept is the
107
+ // case where it was handed something and could tell it could not read it,
108
+ // which is the one thing reporting is for. Getting these backwards costs a
109
+ // credential in either direction.
110
+ if (
111
+ EDIT_TOOL_NAMES.has(toolName) &&
112
+ rawToolInput !== null &&
113
+ rawToolInput !== undefined &&
114
+ (typeof rawToolInput !== 'object' || Array.isArray(rawToolInput))
115
+ ) {
116
+ return [unreadableToolInput(toolName)];
117
+ }
118
+ const toolInput = rawToolInput ?? {};
55
119
  if (toolName === 'Write' || toolName === 'Edit') {
56
120
  return [
57
121
  {
@@ -107,21 +171,10 @@ export function editFragments(input) {
107
171
  // they exited 1 with a stack trace, which neither harness treats as blocking, so
108
172
  // a crash here was an ALLOW. A `tool_input` that is not an object is the same
109
173
  // case as a `command` whose container this guard cannot read: detected, not
110
- // readable, refused.
111
- if (typeof toolInput !== 'object' || toolInput === null || Array.isArray(toolInput)) {
112
- return [
113
- {
114
- filePath: '',
115
- fragment: '',
116
- inspectionRefusal:
117
- 'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
118
- 'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
119
- 'nothing about this patch is vouched for.',
120
- remedy: 'Send the command as a patch string, or a list of strings.',
121
- appliesToAll: true,
122
- },
123
- ];
124
- }
174
+ // readable, refused. That check used to sit HERE, guarding this one arm; it is
175
+ // now the first thing `editFragments` does, for every surface in
176
+ // `EDIT_TOOL_NAMES`, so `toolInput` is an object by the time this line runs and
177
+ // `in` cannot throw (RP-85). One mechanism, one implementation.
125
178
  if (!('command' in toolInput)) return [];
126
179
  if (
127
180
  typeof rawCommand !== 'string' &&
@@ -140,22 +193,12 @@ export function editFragments(input) {
140
193
  // "a crashed guard that blocks everything gets deleted within the hour" —
141
194
  // and this branch is neither. Two opposite answers to one question, ten
142
195
  // lines apart, was the real defect.
143
- return [
144
- {
145
- filePath: '',
146
- fragment: '',
147
- inspectionRefusal:
148
- 'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
149
- 'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
150
- 'nothing about this patch is vouched for.',
151
- // 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
152
- // `/shape/i.test(reason)` in six copies — correct only by coincidence of
153
- // wording, so rewording the reason silently restored the retry loop this
154
- // remedy exists to replace. One field, one place.
155
- remedy: 'Send the command as a patch string, or a list of strings.',
156
- appliesToAll: true,
157
- },
158
- ];
196
+ // 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
197
+ // `/shape/i.test(reason)` in six copies — correct only by coincidence of
198
+ // wording, so rewording the reason silently restored the retry loop this
199
+ // remedy exists to replace. One field, one place — and since RP-85 that
200
+ // place is `unreadableToolInput`, shared with the four edit surfaces.
201
+ return [unreadableToolInput(toolName)];
159
202
  }
160
203
  const command = typeof rawCommand === 'string' ? rawCommand : rawCommand.join('\n');
161
204
  if (command.length > MAX_PATCH_CHARACTERS) {
@@ -0,0 +1,120 @@
1
+ // SessionStart hook (Claude Code): says out loud when this session's
2
+ // environment may keep the pinned subagent routing from holding.
3
+ //
4
+ // The pins live in each agent definition (`model:`, `effort:`) and in the
5
+ // shipped settings (`env.CLAUDE_CODE_SUBAGENT_MODEL`). Four conditions are
6
+ // invisible from inside the rulebook, and each is reported:
7
+ // - CLAUDE_CODE_SUBAGENT_MODEL_FORCE is set: Claude Code applies one model to
8
+ // every subagent and ignores each definition's `model:` (its 2.1.257
9
+ // changelog);
10
+ // - CLAUDE_CODE_EFFORT_LEVEL is set: its level replaced a definition's
11
+ // `effort:` in a live run — the generator's docs/capability-evidence.json
12
+ // (absent in a generated rig), mechanism `subagent-effort-pin`, surface
13
+ // `environment: CLAUDE_CODE_EFFORT_LEVEL=low`;
14
+ // - Claude Code is older than MINIMUM_CLAUDE_CODE_VERSION: until 2.1.251,
15
+ // CLAUDE_CODE_SUBAGENT_MODEL overrode an agent definition's `model:` (its
16
+ // 2.1.251 changelog), so the shipped unnamed default replaced every model pin;
17
+ // - the version cannot be read: then whether the pins hold cannot be confirmed.
18
+ //
19
+ // The version is read from AI_AGENT, which Claude Code sets for its subprocesses
20
+ // (its 2.1.120 changelog) and which a SessionStart hook's environment was
21
+ // observed to carry (the same evidence file, mechanism
22
+ // `claude-code-version-signal`). Its shape,
23
+ // `claude-code_<major>-<minor>-<patch>_<role>`, is observed and not documented —
24
+ // so a value this hook cannot parse is reported as an unknown version, never
25
+ // taken for a supported one.
26
+ //
27
+ // It warns and never blocks: exit 0 always, the warning on stdout, which Claude
28
+ // Code adds to the session's context at SessionStart. An empty variable counts
29
+ // as unset. The rationale is docs/decisions/subagent-routing.md.
30
+ //
31
+ // Pinned in the generator's test/template/subagent-routing-hooks.test.ts
32
+ // (absent in a generated rig) › "warns when %s is set, and never blocks the
33
+ // session", › "warns on a Claude Code older than 2.1.251 and names the minimum"
34
+ // and › "warns that it could not determine the version when %s".
35
+ import { realpathSync } from 'node:fs';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { readHookInput } from './lib/hook-input.mjs';
38
+
39
+ /** The first Claude Code release in which an agent definition's `model:` outranks the env default. */
40
+ export const MINIMUM_CLAUDE_CODE_VERSION = '2.1.251';
41
+
42
+ const PIN_REPLACING_VARIABLES = [
43
+ [
44
+ 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
45
+ "replaces the `model:` every agent definition pins with one model for all subagents",
46
+ ],
47
+ ['CLAUDE_CODE_EFFORT_LEVEL', "replaces the `effort:` every agent definition pins with its own level"],
48
+ ];
49
+
50
+ const VERSION = /^claude-code_(\d{1,6})-(\d{1,6})-(\d{1,6})(?:_|$)/;
51
+
52
+ const numbers = (version) => version.split('.').map(Number);
53
+
54
+ const olderThan = (found, minimum) => {
55
+ for (let index = 0; index < minimum.length; index += 1) {
56
+ if (found[index] !== minimum[index]) return found[index] < minimum[index];
57
+ }
58
+ return false;
59
+ };
60
+
61
+ /** Every routing problem this environment has, as sentences; empty when there is none. */
62
+ export function routingProblems(env) {
63
+ const problems = [];
64
+ for (const [variable, effect] of PIN_REPLACING_VARIABLES) {
65
+ const value = env[variable];
66
+ if (typeof value === 'string' && value !== '') {
67
+ problems.push(`${variable} is set: it ${effect}. Unset it for those pins to hold.`);
68
+ }
69
+ }
70
+ const agent = typeof env.AI_AGENT === 'string' ? VERSION.exec(env.AI_AGENT) : null;
71
+ if (agent === null) {
72
+ problems.push(
73
+ `could not determine the Claude Code version from AI_AGENT, so it cannot be confirmed ` +
74
+ `that the model pins hold: they need Claude Code ${MINIMUM_CLAUDE_CODE_VERSION} or later.`,
75
+ );
76
+ } else {
77
+ const found = [Number(agent[1]), Number(agent[2]), Number(agent[3])];
78
+ if (olderThan(found, numbers(MINIMUM_CLAUDE_CODE_VERSION))) {
79
+ problems.push(
80
+ `Claude Code ${found.join('.')} is older than ${MINIMUM_CLAUDE_CODE_VERSION}: before it, ` +
81
+ "CLAUDE_CODE_SUBAGENT_MODEL overrides every agent definition's `model:`. Upgrade Claude Code.",
82
+ );
83
+ }
84
+ }
85
+ return problems;
86
+ }
87
+
88
+ function invokedDirectly() {
89
+ if (!process.argv[1]) return false;
90
+ const real = (p) => {
91
+ try {
92
+ return realpathSync(p);
93
+ } catch {
94
+ return p;
95
+ }
96
+ };
97
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
98
+ }
99
+
100
+ function main() {
101
+ try {
102
+ const input = readHookInput();
103
+ if (input === null || input.hook_event_name !== 'SessionStart') return 0;
104
+ const problems = routingProblems(process.env);
105
+ if (problems.length === 0) return 0;
106
+ process.stdout.write(
107
+ '[agent-os] WARNING — the pinned subagent routing may not hold in this session:\n' +
108
+ problems.map((problem) => `- ${problem}`).join('\n') +
109
+ '\nA gate may run on a model or effort the routing policy did not choose ' +
110
+ '(docs/decisions/subagent-routing.md).\n',
111
+ );
112
+ } catch {
113
+ // a broken warning must never make the session unusable
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ if (invokedDirectly()) {
119
+ process.exit(main());
120
+ }
@@ -38,6 +38,11 @@ subagent with a fresh context, and why the `pr-ship` gate fans reviewers out
38
38
  instead of self-checking. This isolation is load-bearing, not ceremony — do
39
39
  not "optimise" it away by reviewing in the authoring session.
40
40
 
41
+ A reviewer whose definition pins its model is never dispatched with a call-site
42
+ `model`: which model reads a change is the routing policy's decision, not the
43
+ dispatching session's, and `guard-subagent-model` refuses the override
44
+ (`docs/decisions/subagent-routing.md`).
45
+
41
46
  ## PR flow
42
47
 
43
48
  This applies **once the project has a remote and CI checks** — a freshly
@@ -26,10 +26,10 @@ import { fileURLToPath } from 'node:url';
26
26
  // cycle, because preflight is the only scripted brake check and had no test.
27
27
  import { brakeIsOn } from './stop-flag.mjs';
28
28
  import { readRevalidationContract } from './lib/claim-records.mjs';
29
+ import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
29
30
 
30
31
  /** The items this script cannot check: judgement, or a call worth more than it saves. */
31
32
  export const UNCHECKED = [
32
- 'the queue is reachable through its adapter (`node .claude/scripts/queue/index.mjs next`)',
33
33
  'no stray worktree from a dead session that this run might mistake for its own',
34
34
  'a budget is declared for this run, and it is written down somewhere the run can re-read',
35
35
  ];
@@ -136,6 +136,30 @@ export const checkDetectionContract = (projectRoot) => {
136
136
  }
137
137
  };
138
138
 
139
+ // See test/template/preflight-queue.test.ts (absent in a generated rig) ›
140
+ // "reads exactly one adapter listing without selecting, claiming, or writing queue and run files".
141
+ export const checkQueue = async (projectRoot) => {
142
+ try {
143
+ const configPath = join(projectRoot, '.claude', 'queue.json');
144
+ const config = loadConfig(configPath, { strictRead: true });
145
+ const adapterName = config.adapter ?? 'plan-md';
146
+ const adapter = await resolveAdapter(adapterName);
147
+ await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
148
+ return { ok: true, detail: `queue readable through ${adapterName}` };
149
+ } catch (error) {
150
+ const diagnostic = Array.from(String(error?.message ?? error), (character) => {
151
+ const code = character.codePointAt(0);
152
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f)
153
+ ? `\\u${code.toString(16).padStart(4, '0')}`
154
+ : character;
155
+ }).join('');
156
+ return {
157
+ ok: false,
158
+ detail: `could not read queue: ${diagnostic}`,
159
+ };
160
+ }
161
+ };
162
+
139
163
  /** The last deploy must have concluded successfully — never start on a broken runtime. */
140
164
  export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
141
165
  try {
@@ -157,8 +181,7 @@ export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
157
181
 
158
182
  /**
159
183
  * STOP on any hard failure; CAUTION on anything that is not a clean pass; GO only
160
- * when every scripted item genuinely passed. The three unscripted items are still
161
- * the reader's.
184
+ * when every scripted item genuinely passed. Unscripted checks remain the reader's.
162
185
  *
163
186
  * `stale` and `unknown` both give CAUTION but are never merged into one word:
164
187
  * "I looked and it is stale" is actionable, "I could not look" is not, and neither
@@ -216,6 +239,7 @@ if (invokedDirectly()) {
216
239
  killSwitch: checkKillSwitch(),
217
240
  runDirNotExported: checkRunDirNotExported(),
218
241
  detectionContract: checkDetectionContract(projectRoot),
242
+ queue: await checkQueue(projectRoot),
219
243
  defaultBranchFresh: checkDefaultBranchFresh(),
220
244
  lastDeploy: checkLastDeploy(),
221
245
  };
@@ -25,9 +25,26 @@
25
25
  * and the failure is bounded and in the generous direction. What was worth fixing is
26
26
  * the crash it came with — a fixed temp filename made the losers of that race fail
27
27
  * with `ENOENT` on rename, reporting "could not run" for a condition nothing named.
28
+ * Pinned by the generator's `test/template/concurrent-sessions.test.ts`
29
+ * (absent in a generated rig) › "eight concurrent recordGateRound calls all exit 0
30
+ * and leave one parseable counter between one and eight".
31
+ *
32
+ * ⚠ **And a second crash, Windows-only, measured on the same race (RP-120):** a
33
+ * rename over a file another process holds open is refused there with `EPERM`, for
34
+ * exactly as long as the handle is open — a reader's `readFileSync` is enough. Eight
35
+ * racing callers lost one to it in four rounds of thirty, and each loser left its
36
+ * temp file behind. So the rename is retried within a fixed budget
37
+ * (`RENAME_BUDGET_MS`: the pauses between attempts never exceed it; the rename
38
+ * calls themselves add their own wall time), and a loser that still cannot rename
39
+ * removes its temp file and reports the code and the file rather than a bare
40
+ * `EPERM`. That is a bounded retry, not a lock: the count can still lose an
41
+ * increment, and nothing waits on a holder past the budget. Pinned by the
42
+ * generator's `test/template/gate-rounds.test.ts` (absent in a generated rig) ›
43
+ * "retries the rename while another process holds the counter open, and still counts the round"
44
+ * and › "gives up past its budget, removes its temp file, keeps the old count, and names the code and the file".
28
45
  */
29
46
 
30
- import { renameSync, readFileSync, writeFileSync } from 'node:fs';
47
+ import { renameSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
48
  import { join } from 'node:path';
32
49
 
33
50
  import { mainCheckoutRoot } from './checkout.mjs';
@@ -154,7 +171,58 @@ export const recordGateRound = ({ branch, projectRoot, roundsPath } = {}) => {
154
171
  const updated = Object.assign(Object.create(null), rounds, { [key]: next });
155
172
  const temp = `${file}.${process.pid}.tmp`;
156
173
  writeFileSync(temp, `${JSON.stringify(updated, null, 2)}\n`);
157
- renameSync(temp, file);
174
+ replaceWithRetry(temp, file);
158
175
 
159
176
  return { rounds: next };
160
177
  };
178
+
179
+ // How many times the rename is tried, and how long each retry waits.
180
+ const RENAME_ATTEMPTS = 20;
181
+ const RENAME_BACKOFF_MS = 10;
182
+ /** The whole budget a caller can spend pausing on a held-open counter. */
183
+ export const RENAME_BUDGET_MS = RENAME_ATTEMPTS * RENAME_BACKOFF_MS;
184
+
185
+ const RETRIED_CODES = new Set(['EPERM', 'EBUSY']);
186
+
187
+ // A synchronous pause: this module is synchronous end to end (the CLI counts a round
188
+ // and exits), and `Atomics.wait` on a throwaway buffer is the one sleep that shape
189
+ // allows without a spin.
190
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
191
+
192
+ /**
193
+ * Rename `temp` over `file`, retrying a Windows-style refusal within the budget.
194
+ *
195
+ * Every attempt past the last, and every failure of another kind, ends the same way:
196
+ * the temp file is removed so the counter's directory holds nothing but the counter,
197
+ * and the error names the file and the code — the CLI prints that as "could not run",
198
+ * which pr-ship reads as a command failure to retry, not as an exhausted cap.
199
+ */
200
+ const replaceWithRetry = (temp, file) => {
201
+ for (let attempt = 1; ; attempt += 1) {
202
+ try {
203
+ renameSync(temp, file);
204
+ return;
205
+ } catch (error) {
206
+ const code = error?.code ?? 'unknown error';
207
+ if (RETRIED_CODES.has(code) && attempt < RENAME_ATTEMPTS) {
208
+ pause(RENAME_BACKOFF_MS);
209
+ continue;
210
+ }
211
+ // The cleanup must not displace the diagnosis: a temp file that cannot be
212
+ // removed (held open too, on Windows) is reported beside the rename
213
+ // failure, and the rename failure stays the error the caller sees.
214
+ let cleanup = 'and the temp file was removed';
215
+ try {
216
+ rmSync(temp, { force: true });
217
+ } catch (removal) {
218
+ cleanup = `but the temp file ${temp} could not be removed (${removal?.code ?? 'unknown error'})`;
219
+ }
220
+ throw new Error(
221
+ `${file} could not be replaced after ${attempt} attempt${attempt === 1 ? '' : 's'} ` +
222
+ `(${code}): another process may be holding it open. The round was NOT counted ` +
223
+ `${cleanup}; run the command again.`,
224
+ { cause: error },
225
+ );
226
+ }
227
+ }
228
+ };