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.
- package/CHANGELOG.md +105 -1
- package/README.md +92 -3
- package/package.json +4 -3
- package/packages/cli/dist/commands/memory.js +123 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/index.js +107 -3
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +8 -4
- package/templates/release-ledger.json +2 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- 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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
};
|