model-orchestrator 0.1.14 → 0.1.16

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 (36) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +43 -2
  3. package/README.md +52 -8
  4. package/bin/cli-run.mjs +22 -7
  5. package/bin/cli.js +4 -4
  6. package/docs/audit-brief.md +32 -0
  7. package/docs/part-1-beginner.md +1 -1
  8. package/docs/part-2-intermediate.md +1 -1
  9. package/llms.txt +27 -0
  10. package/package.json +28 -4
  11. package/src/README.md +1 -1
  12. package/src/catalog.js +9 -1
  13. package/src/detect.js +28 -9
  14. package/src/install.js +206 -5
  15. package/templates/README.md +1 -1
  16. package/templates/agents/README.md +3 -1
  17. package/templates/agents/agy/README.md +2 -2
  18. package/templates/agents/agy/done-verifier.md +35 -0
  19. package/templates/agents/agy/finding-verifier.md +6 -0
  20. package/templates/agents/agy/reader.md +22 -0
  21. package/templates/agents/claude-code/README.md +7 -5
  22. package/templates/agents/claude-code/builder.md +6 -1
  23. package/templates/agents/claude-code/code-reviewer.md +9 -2
  24. package/templates/agents/claude-code/done-verifier.md +44 -0
  25. package/templates/agents/claude-code/finding-verifier.md +9 -2
  26. package/templates/agents/claude-code/reader.md +26 -0
  27. package/templates/agents/snippets/claude-code.md +9 -4
  28. package/templates/agents/snippets/route-gate.mjs +151 -0
  29. package/templates/agents/snippets/route-metrics.mjs +356 -0
  30. package/templates/agents/snippets/settings.hooks.snippet.json +70 -0
  31. package/templates/agents/snippets/subagent-context.mjs +76 -0
  32. package/templates/beginner/ORCHESTRATOR.md +4 -3
  33. package/templates/common/TASK_BUNDLE.md +2 -2
  34. package/templates/common/protocols/build-protocol.md +2 -2
  35. package/templates/intermediate/ROUTING.md +9 -10
  36. package/templates/intermediate/TIERS.md +2 -0
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // route-gate.mjs: UserPromptSubmit hook for {{PRIMARY_NAME}}.
3
+ //
4
+ // Reads the route-gate table out of {{RULES_FILE_REL}} and injects it as
5
+ // additionalContext on every turn, so the routing table is read at runtime
6
+ // from the one place it is generated (the rules file), never a second
7
+ // hand-typed copy that can drift from it.
8
+ //
9
+ // Fail-open by design: a miss here is a stray context string, not a gate.
10
+ // This script always exits 0, never blocks on stdin past a short bound,
11
+ // reads at most 64 KB of the rules file through a fixed-size buffer (never
12
+ // a full read of an arbitrarily large or non-regular file), and never
13
+ // executes anything it reads. See docs/audit-brief.md for the threat model.
14
+ import { statSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
15
+ import { join, isAbsolute } from 'node:path';
16
+
17
+ // Rendered at install time from the level and directory the user chose.
18
+ // Never hardcoded: a level 1 install points this at ORCHESTRATOR.md, level
19
+ // 2+ at ROUTING.md, and a --dir outside the project resolves to an absolute
20
+ // path instead of a relative one.
21
+ const RULES_FILE_REL = {{RULES_FILE_REL_JSON}};
22
+
23
+ const MAX_READ = 64 * 1024; // bounded read: this is a rules file, not a log
24
+ const MAX_CONTEXT = 4000; // bounded injection: a table, not the whole file
25
+ const STDIN_DRAIN_MS = 250; // hard cap: never let an open, never-closed stdin pipe hold this hook open
26
+ const START = '<!-- route-gate:start -->';
27
+ const END = '<!-- route-gate:end -->';
28
+
29
+ function fallback(reason) {
30
+ return 'route-gate: ' + reason + '. Pick the lane before acting: read ' + RULES_FILE_REL + ' yourself.';
31
+ }
32
+
33
+ function resolveRulesPath() {
34
+ if (isAbsolute(RULES_FILE_REL)) return RULES_FILE_REL;
35
+ const projectDir = process.env.CLAUDE_PROJECT_DIR;
36
+ if (!projectDir) return null;
37
+ // Resolve through whatever part of the project dir already exists, so a
38
+ // symlinked project folder still resolves to the real path the rules file
39
+ // was written under.
40
+ let root = projectDir;
41
+ try {
42
+ root = realpathSync(projectDir);
43
+ } catch {
44
+ /* keep the unresolved value; the read below reports the real failure */
45
+ }
46
+ return join(root, RULES_FILE_REL);
47
+ }
48
+
49
+ // Bounded, regular-file-only read. statSync (not lstatSync) follows a
50
+ // symlink to its target and reports what the target actually is, so a
51
+ // symlinked rules file still reads; a FIFO, socket, device or directory at
52
+ // the resolved path is refused before any open/read call touches it. That
53
+ // check matters: opening a FIFO for reading blocks until a writer opens the
54
+ // other end, and a plain readFileSync on any of these can hang or, for a
55
+ // huge or sparse regular file, allocate far more than this hook needs. The
56
+ // fixed-size buffer plus a single bounded readSync call means the on-disk
57
+ // size of the file never determines how much this hook reads or how long it
58
+ // takes.
59
+ function readBounded(path) {
60
+ let st;
61
+ try {
62
+ st = statSync(path);
63
+ } catch (e) {
64
+ throw Object.assign(new Error('could not stat ' + path + ' (' + ((e && e.code) || e) + ')'), { code: e && e.code });
65
+ }
66
+ if (!st.isFile()) throw new Error(path + ' is not a regular file');
67
+ const buf = Buffer.alloc(MAX_READ);
68
+ let fd;
69
+ try {
70
+ fd = openSync(path, 'r');
71
+ const bytesRead = readSync(fd, buf, 0, MAX_READ, 0);
72
+ return buf.toString('utf8', 0, bytesRead);
73
+ } finally {
74
+ if (fd !== undefined) closeSync(fd);
75
+ }
76
+ }
77
+
78
+ function computeContext() {
79
+ const path = resolveRulesPath();
80
+ if (!path) return fallback('CLAUDE_PROJECT_DIR is not set, so ' + RULES_FILE_REL + ' could not be located');
81
+
82
+ let text;
83
+ try {
84
+ text = readBounded(path);
85
+ } catch (e) {
86
+ return fallback((e && e.message) || String(e));
87
+ }
88
+
89
+ const s = text.indexOf(START);
90
+ const e = s === -1 ? -1 : text.indexOf(END, s);
91
+ if (s === -1 || e === -1) return fallback(path + ' has no route-gate block');
92
+
93
+ return text.slice(s, e + END.length).slice(0, MAX_CONTEXT);
94
+ }
95
+
96
+ // Drain stdin without ever blocking on it. A bare `readFileSync(0)` waits
97
+ // for stdin to reach EOF, so a caller that pipes into this hook and never
98
+ // closes its end of the pipe (or a bare TTY with no redirection at all)
99
+ // left the process running indefinitely. This races the real 'end' event
100
+ // against a hard timeout instead: whichever settles first wins, and the
101
+ // timer is unref'd so it can never itself be the reason the process stays
102
+ // alive past a normal exit.
103
+ function drainStdin(timeoutMs) {
104
+ return new Promise((resolve) => {
105
+ let settled = false;
106
+ const finish = () => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ try {
111
+ process.stdin.removeAllListeners('data');
112
+ process.stdin.removeAllListeners('end');
113
+ process.stdin.removeAllListeners('error');
114
+ process.stdin.pause();
115
+ } catch {
116
+ /* stdin may already be gone; nothing left to clean up */
117
+ }
118
+ resolve();
119
+ };
120
+ const timer = setTimeout(finish, timeoutMs);
121
+ if (timer.unref) timer.unref();
122
+ try {
123
+ process.stdin.on('data', () => {});
124
+ process.stdin.on('end', finish);
125
+ process.stdin.on('error', finish);
126
+ process.stdin.resume();
127
+ } catch {
128
+ finish();
129
+ }
130
+ });
131
+ }
132
+
133
+ let additionalContext;
134
+ try {
135
+ additionalContext = computeContext();
136
+ } catch (err) {
137
+ additionalContext = fallback('route-gate.mjs failed unexpectedly (' + ((err && err.message) || err) + ')');
138
+ }
139
+
140
+ drainStdin(STDIN_DRAIN_MS).then(() => {
141
+ const payload = JSON.stringify({
142
+ hookSpecificOutput: {
143
+ hookEventName: 'UserPromptSubmit',
144
+ additionalContext
145
+ }
146
+ });
147
+ // Exit only after the write's callback fires, so a buffered write to a
148
+ // pipe (the common case on Windows, and possible anywhere output exceeds
149
+ // one write's worth) is not truncated by an exit that races ahead of it.
150
+ process.stdout.write(payload, () => process.exit(0));
151
+ });
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ // route-metrics.mjs: routing telemetry hook for {{PRIMARY_NAME}}.
3
+ //
4
+ // Answers "is my agent actually routing and delegating?" by turning five
5
+ // hook events into one JSON line each, appended to
6
+ // ~/.ai-orchestrator/route-metrics.jsonl (the same directory bin/cli-run.mjs
7
+ // already logs to, and the same os.homedir() resolution it uses):
8
+ //
9
+ // UserPromptSubmit -> {event:"turn"}
10
+ // PreToolUse (matcher Agent|Task) -> {event:"dispatch", subagent_type, background}
11
+ // SubagentStart -> {event:"start", agent_type}
12
+ // SubagentStop -> {event:"end", agent_type?, duration_s?}
13
+ // Stop -> {event:"route", lane}, parsed from the LAST
14
+ // <!-- route: <lane> | <why> --> marker in
15
+ // last_assistant_message (documented source;
16
+ // the transcript can lag, so that is never read)
17
+ //
18
+ // Pure telemetry, fail-open by design: this script prints NOTHING to stdout
19
+ // (stdout on UserPromptSubmit/SubagentStart becomes model context) and always
20
+ // exits 0, whether or not a line was written. A miss here is a missing log
21
+ // line, never a blocked turn.
22
+ //
23
+ // The durable log holds no provider-supplied string: prompt text, tool
24
+ // descriptions, and the "why" half of the route marker are never read into a
25
+ // field, only the named, charset-bounded values below. See docs/audit-brief.md.
26
+ //
27
+ // Second entry point: `node route-metrics.mjs --summary [--since <ISO date>]`
28
+ // prints a plain-text report from the log and exits 0 without touching stdin.
29
+ import { createHash } from 'node:crypto';
30
+ import {
31
+ existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, unlinkSync, renameSync,
32
+ statSync, readdirSync
33
+ } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ import { homedir } from 'node:os';
36
+
37
+ const HOME_DIR = join(homedir(), '.ai-orchestrator');
38
+ const LOG_FILE = join(HOME_DIR, 'route-metrics.jsonl');
39
+ const STATE_DIR = join(HOME_DIR, 'route-metrics.state');
40
+
41
+ const STDIN_MAX_BYTES = 8 * 1024 * 1024; // size cap: a giant or runaway payload is truncated, not parsed
42
+ const STDIN_DRAIN_MS = 1000; // hard cap: never let an open, never-closed stdin pipe hold this hook open
43
+ const LOG_ROTATE_BYTES = 5 * 1024 * 1024; // rotate to .1 above this size
44
+ const STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // prune state files older than 24h
45
+ const TOKEN_CHARSET = /[^A-Za-z0-9_.+-]/g; // session_id, subagent_type, agent_type, lane tokens
46
+ const TOKEN_MAX_LEN = 64;
47
+ const SESSION_ID_MAX_LEN = 128;
48
+
49
+ // Strip anything outside the allowed charset and cap length, so no field in
50
+ // the durable log can carry an arbitrary provider- or model-supplied string
51
+ // (a newline, a control character, shell metacharacters, or just length).
52
+ function sanitize(raw, maxLen) {
53
+ if (typeof raw !== 'string' || raw.length === 0) return '';
54
+ return raw.replace(TOKEN_CHARSET, '').slice(0, maxLen);
55
+ }
56
+
57
+ function stateKeyFor(agentId) {
58
+ if (typeof agentId !== 'string' || agentId.length === 0) return null;
59
+ return createHash('sha256').update(agentId).digest('hex');
60
+ }
61
+
62
+ // Best-effort housekeeping: a leaked state file (a SubagentStop that never
63
+ // arrived) should not accumulate forever. Run on SubagentStart only, since
64
+ // that is the one event guaranteed to fire at least as often as starts happen.
65
+ function pruneOldState() {
66
+ let names;
67
+ try {
68
+ names = readdirSync(STATE_DIR);
69
+ } catch {
70
+ return; // no state dir yet: nothing to prune
71
+ }
72
+ const cutoff = Date.now() - STATE_MAX_AGE_MS;
73
+ for (const name of names) {
74
+ const p = join(STATE_DIR, name);
75
+ try {
76
+ if (statSync(p).mtimeMs < cutoff) unlinkSync(p);
77
+ } catch {
78
+ /* a race with another process touching the same file is not an error here */
79
+ }
80
+ }
81
+ }
82
+
83
+ function recordStart(agentId, agentType) {
84
+ const key = stateKeyFor(agentId);
85
+ if (!key) return;
86
+ try {
87
+ mkdirSync(STATE_DIR, { recursive: true });
88
+ writeFileSync(join(STATE_DIR, key + '.json'), JSON.stringify({ ts: Date.now(), agent_type: agentType }));
89
+ } catch {
90
+ /* telemetry never blocks the run */
91
+ }
92
+ }
93
+
94
+ // Reads and deletes the state file for this agent_id. Returns {agentType,
95
+ // durationS}, either possibly null: no agent_id and no state file both mean
96
+ // "none", which the caller reflects by omitting the field entirely.
97
+ function consumeStart(agentId) {
98
+ const key = stateKeyFor(agentId);
99
+ if (!key) return { agentType: null, durationS: null };
100
+ const p = join(STATE_DIR, key + '.json');
101
+ let agentType = null;
102
+ let durationS = null;
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
105
+ if (parsed && typeof parsed.ts === 'number') durationS = Math.max(0, (Date.now() - parsed.ts) / 1000);
106
+ if (parsed && typeof parsed.agent_type === 'string' && parsed.agent_type) agentType = parsed.agent_type;
107
+ } catch {
108
+ /* no state file, or it was unreadable: none, not an error */
109
+ }
110
+ try {
111
+ unlinkSync(p);
112
+ } catch {
113
+ /* already gone */
114
+ }
115
+ return { agentType, durationS };
116
+ }
117
+
118
+ function appendLog(record) {
119
+ try {
120
+ mkdirSync(HOME_DIR, { recursive: true });
121
+ let size = 0;
122
+ try {
123
+ size = statSync(LOG_FILE).size;
124
+ } catch {
125
+ /* file does not exist yet: size stays 0 */
126
+ }
127
+ if (size > LOG_ROTATE_BYTES) {
128
+ try {
129
+ renameSync(LOG_FILE, LOG_FILE + '.1');
130
+ } catch {
131
+ /* a concurrent rotation losing this race is not worth failing over */
132
+ }
133
+ }
134
+ appendFileSync(LOG_FILE, JSON.stringify(record) + '\n');
135
+ } catch {
136
+ /* telemetry never blocks the run */
137
+ }
138
+ }
139
+
140
+ // Parses the LAST <!-- route: <lane> | <why> --> marker out of text. The
141
+ // "why" half is captured only to be discarded: it is never read into a
142
+ // variable that reaches the log. Returns an array of lane tokens (split on
143
+ // "+", the documented way to log more than one lane from a single marker),
144
+ // or ["missing"] when there is no marker at all.
145
+ export function extractLane(text) {
146
+ if (typeof text !== 'string' || text.length === 0) return ['missing'];
147
+ const re = /<!--\s*route:\s*([^|>]*)\|[^>]*-->/g;
148
+ let match;
149
+ let last = null;
150
+ while ((match = re.exec(text)) !== null) last = match;
151
+ if (!last) return ['missing'];
152
+ // A token carrying any character outside the charset is logged as
153
+ // "invalid", never stripped into a plausible-looking lane: stripping
154
+ // `main","evil":"1` would log a lane named "mainevil1" that nobody chose.
155
+ const parts = last[1]
156
+ .split('+')
157
+ .map((s) => s.trim())
158
+ .filter(Boolean)
159
+ .map((t) => (t.length > TOKEN_MAX_LEN || /[^A-Za-z0-9_.-]/.test(t) ? 'invalid' : t));
160
+ return parts.length ? parts : ['missing'];
161
+ }
162
+
163
+ // Turns one parsed hook-input object into a log record, or null when the
164
+ // event is not one this hook measures (or PreToolUse fired for a tool other
165
+ // than Agent/Task, which the settings matcher should already have excluded;
166
+ // this is a defensive second check, not the primary gate).
167
+ export function buildRecord(input, now = () => new Date().toISOString()) {
168
+ if (!input || typeof input !== 'object') return null;
169
+ const sessionId = sanitize(input.session_id, SESSION_ID_MAX_LEN) || 'unknown';
170
+ const ts = now();
171
+ const base = { ts, v: 1 };
172
+
173
+ switch (input.hook_event_name) {
174
+ case 'UserPromptSubmit':
175
+ return { ...base, event: 'turn', session_id: sessionId };
176
+
177
+ case 'PreToolUse': {
178
+ if (input.tool_name !== 'Agent' && input.tool_name !== 'Task') return null;
179
+ const toolInput = (input.tool_input && typeof input.tool_input === 'object') ? input.tool_input : {};
180
+ const subagentType = sanitize(toolInput.subagent_type, TOKEN_MAX_LEN) || 'general-purpose';
181
+ const background = toolInput.run_in_background === true;
182
+ return { ...base, event: 'dispatch', session_id: sessionId, subagent_type: subagentType, background };
183
+ }
184
+
185
+ case 'SubagentStart': {
186
+ pruneOldState();
187
+ const agentType = sanitize(input.agent_type, TOKEN_MAX_LEN) || 'unknown';
188
+ recordStart(input.agent_id, agentType);
189
+ return { ...base, event: 'start', session_id: sessionId, agent_type: agentType };
190
+ }
191
+
192
+ case 'SubagentStop': {
193
+ const { agentType, durationS } = consumeStart(input.agent_id);
194
+ const record = { ...base, event: 'end', session_id: sessionId };
195
+ if (agentType) record.agent_type = agentType;
196
+ if (durationS !== null) record.duration_s = durationS;
197
+ return record;
198
+ }
199
+
200
+ case 'Stop':
201
+ return { ...base, event: 'route', session_id: sessionId, lane: extractLane(input.last_assistant_message) };
202
+
203
+ default:
204
+ return null;
205
+ }
206
+ }
207
+
208
+ // Drain stdin without ever blocking on it, bounded by BOTH time and size. A
209
+ // bare `readFileSync(0)` waits for EOF, so a caller that pipes in and never
210
+ // closes its end left the process running indefinitely (the same class of
211
+ // bug route-gate.mjs and subagent-context.mjs already fix). The size cap is
212
+ // this hook's own addition: hook input is normally small, so a payload past
213
+ // the cap is treated as truncated and parsed as nothing, never partially.
214
+ function drainStdinBounded(timeoutMs, maxBytes) {
215
+ return new Promise((resolve) => {
216
+ let settled = false;
217
+ let bytes = 0;
218
+ let truncated = false;
219
+ const chunks = [];
220
+ const finish = () => {
221
+ if (settled) return;
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ try {
225
+ process.stdin.removeAllListeners('data');
226
+ process.stdin.removeAllListeners('end');
227
+ process.stdin.removeAllListeners('error');
228
+ process.stdin.pause();
229
+ } catch {
230
+ /* stdin may already be gone */
231
+ }
232
+ resolve({ data: truncated ? null : Buffer.concat(chunks).toString('utf8'), truncated });
233
+ };
234
+ const timer = setTimeout(finish, timeoutMs);
235
+ if (timer.unref) timer.unref();
236
+ try {
237
+ process.stdin.on('data', (chunk) => {
238
+ if (truncated) return;
239
+ bytes += chunk.length;
240
+ if (bytes > maxBytes) {
241
+ truncated = true;
242
+ return finish();
243
+ }
244
+ chunks.push(chunk);
245
+ });
246
+ process.stdin.on('end', finish);
247
+ process.stdin.on('error', finish);
248
+ process.stdin.resume();
249
+ } catch {
250
+ finish();
251
+ }
252
+ });
253
+ }
254
+
255
+ async function runHook() {
256
+ const { data } = await drainStdinBounded(STDIN_DRAIN_MS, STDIN_MAX_BYTES);
257
+ if (data) {
258
+ let input;
259
+ try {
260
+ input = JSON.parse(data);
261
+ } catch {
262
+ input = null; // invalid JSON: log nothing
263
+ }
264
+ if (input) {
265
+ try {
266
+ const record = buildRecord(input);
267
+ if (record) appendLog(record);
268
+ } catch {
269
+ /* telemetry never blocks or fails the run */
270
+ }
271
+ }
272
+ }
273
+ process.exit(0); // fail-open, always: a miss here is a missing log line, never a blocked turn
274
+ }
275
+
276
+ // ---- --summary: a plain-text report, no stdin involved ----
277
+
278
+ function parseLines(text) {
279
+ const records = [];
280
+ for (const line of text.split('\n')) {
281
+ const trimmed = line.trim();
282
+ if (!trimmed) continue;
283
+ try {
284
+ records.push(JSON.parse(trimmed));
285
+ } catch {
286
+ /* one bad line (a torn write, a rotation race) does not sink the report */
287
+ }
288
+ }
289
+ return records;
290
+ }
291
+
292
+ function formatNumber(n) {
293
+ return Number.isInteger(n) ? String(n) : n.toFixed(2);
294
+ }
295
+
296
+ function runSummary(args) {
297
+ if (!existsSync(LOG_FILE)) {
298
+ console.log('route-metrics: no data yet (' + LOG_FILE + ' does not exist).');
299
+ return process.exit(0);
300
+ }
301
+ const sinceIdx = args.indexOf('--since');
302
+ const since = sinceIdx !== -1 ? Date.parse(args[sinceIdx + 1]) : NaN;
303
+ let records = parseLines(readFileSync(LOG_FILE, 'utf8'));
304
+ if (!Number.isNaN(since)) records = records.filter((r) => Date.parse(r.ts) >= since);
305
+
306
+ const turns = records.filter((r) => r.event === 'turn').length;
307
+ const routes = records.filter((r) => r.event === 'route');
308
+ const covered = routes.filter((r) => !(Array.isArray(r.lane) && r.lane.length === 1 && r.lane[0] === 'missing')).length;
309
+ const coveragePct = turns > 0 ? (covered / turns) * 100 : null;
310
+
311
+ const laneCounts = new Map();
312
+ for (const r of routes) {
313
+ for (const lane of Array.isArray(r.lane) ? r.lane : []) laneCounts.set(lane, (laneCounts.get(lane) || 0) + 1);
314
+ }
315
+
316
+ const dispatches = records.filter((r) => r.event === 'dispatch');
317
+ const dispatchCounts = new Map();
318
+ for (const r of dispatches) dispatchCounts.set(r.subagent_type, (dispatchCounts.get(r.subagent_type) || 0) + 1);
319
+
320
+ const starts = records.filter((r) => r.event === 'start').length;
321
+ const noMatchingStart = Math.max(0, dispatches.length - starts);
322
+
323
+ const ends = records.filter((r) => r.event === 'end' && r.agent_type && typeof r.duration_s === 'number');
324
+ const durationsByType = new Map();
325
+ for (const r of ends) {
326
+ if (!durationsByType.has(r.agent_type)) durationsByType.set(r.agent_type, []);
327
+ durationsByType.get(r.agent_type).push(r.duration_s);
328
+ }
329
+
330
+ const lines = [];
331
+ lines.push('route-metrics summary' + (Number.isNaN(since) ? '' : ' since ' + args[sinceIdx + 1]));
332
+ lines.push('turns: ' + turns);
333
+ lines.push('route-marker coverage: ' + (coveragePct === null ? 'no turns yet' : formatNumber(coveragePct) + '%') + ' (' + covered + '/' + turns + ')');
334
+ lines.push('lanes by count:');
335
+ if (laneCounts.size === 0) lines.push(' (none)');
336
+ for (const [lane, count] of [...laneCounts.entries()].sort((a, b) => b[1] - a[1])) lines.push(' ' + lane + ': ' + count);
337
+ lines.push('dispatches by subagent_type:');
338
+ if (dispatchCounts.size === 0) lines.push(' (none)');
339
+ for (const [type, count] of [...dispatchCounts.entries()].sort((a, b) => b[1] - a[1])) lines.push(' ' + type + ': ' + count);
340
+ lines.push('dispatches with no matching start: ' + noMatchingStart + ' (a hook or guard blocked them before launch)');
341
+ lines.push('duration by agent_type (mean / max, seconds):');
342
+ if (durationsByType.size === 0) lines.push(' (none)');
343
+ for (const [type, durs] of durationsByType) {
344
+ const mean = durs.reduce((a, b) => a + b, 0) / durs.length;
345
+ lines.push(' ' + type + ': ' + formatNumber(mean) + ' / ' + formatNumber(Math.max(...durs)));
346
+ }
347
+ console.log(lines.join('\n'));
348
+ process.exit(0);
349
+ }
350
+
351
+ const args = process.argv.slice(2);
352
+ if (args.includes('--summary')) {
353
+ runSummary(args);
354
+ } else {
355
+ runHook();
356
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node",
9
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-gate.mjs"]
10
+ },
11
+ {
12
+ "type": "command",
13
+ "command": "node",
14
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-metrics.mjs"]
15
+ }
16
+ ]
17
+ }
18
+ ],
19
+ "PreToolUse": [
20
+ {
21
+ "matcher": "Agent|Task",
22
+ "hooks": [
23
+ {
24
+ "type": "command",
25
+ "command": "node",
26
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-metrics.mjs"]
27
+ }
28
+ ]
29
+ }
30
+ ],
31
+ "SubagentStart": [
32
+ {
33
+ "hooks": [
34
+ {
35
+ "type": "command",
36
+ "command": "node",
37
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/subagent-context.mjs"]
38
+ },
39
+ {
40
+ "type": "command",
41
+ "command": "node",
42
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-metrics.mjs"]
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "SubagentStop": [
48
+ {
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "node",
53
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-metrics.mjs"]
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "Stop": [
59
+ {
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "node",
64
+ "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/route-metrics.mjs"]
65
+ }
66
+ ]
67
+ }
68
+ ]
69
+ }
70
+ }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // subagent-context.mjs: SubagentStart hook for {{PRIMARY_NAME}}.
3
+ //
4
+ // A Claude Code subagent loads this project's CLAUDE.md hierarchy at start
5
+ // (code.claude.com/docs/en/sub-agents), so it already has the standing
6
+ // rules. What it does not have is this task's scope, and it can be tempted
7
+ // to route further work itself or to mark its own output verified. This
8
+ // hook injects a short, static reminder of where the rest lives and what
9
+ // "delegate" means.
10
+ //
11
+ // Fail-open by design: a miss here is a stray context string, not a gate.
12
+ // This script always exits 0, never executes anything it reads, and never
13
+ // blocks on stdin past a short bound (see drainStdin below).
14
+ import { isAbsolute } from 'node:path';
15
+
16
+ // Rendered at install time so a --dir outside the project still names an
17
+ // honest path rather than a hardcoded one.
18
+ const RULES_FILE_REL = {{RULES_FILE_REL_JSON}};
19
+ const TASK_BUNDLE_REL = {{TASK_BUNDLE_REL_JSON}};
20
+ const STDIN_DRAIN_MS = 250; // hard cap: never let an open, never-closed stdin pipe hold this hook open
21
+
22
+ const additionalContext = [
23
+ 'SUBAGENT CONTEXT (model-orchestrator).',
24
+ 'Routing rules: ' + RULES_FILE_REL + (isAbsolute(RULES_FILE_REL) ? '.' : ' (relative to the project root).'),
25
+ 'Task bundle format: ' + TASK_BUNDLE_REL + '.',
26
+ 'Report contract: say what you did, what you did NOT do, and what you could not verify. "Unverified" is acceptable; a confident guess is not. Stop at the bound your brief set, and never claim work you cannot show.',
27
+ 'You are a delegate: do not route further work to another subagent yourself, and do not mark your own output as the final verification of it.'
28
+ ].join(' ');
29
+
30
+ // Drain stdin without ever blocking on it. A bare `readFileSync(0)` waits
31
+ // for stdin to reach EOF, so a caller that pipes into this hook and never
32
+ // closes its end of the pipe left the process running indefinitely. This
33
+ // races the real 'end' event against a hard timeout instead: whichever
34
+ // settles first wins, and the timer is unref'd so it can never itself be
35
+ // the reason the process stays alive past a normal exit.
36
+ function drainStdin(timeoutMs) {
37
+ return new Promise((resolve) => {
38
+ let settled = false;
39
+ const finish = () => {
40
+ if (settled) return;
41
+ settled = true;
42
+ clearTimeout(timer);
43
+ try {
44
+ process.stdin.removeAllListeners('data');
45
+ process.stdin.removeAllListeners('end');
46
+ process.stdin.removeAllListeners('error');
47
+ process.stdin.pause();
48
+ } catch {
49
+ /* stdin may already be gone; nothing left to clean up */
50
+ }
51
+ resolve();
52
+ };
53
+ const timer = setTimeout(finish, timeoutMs);
54
+ if (timer.unref) timer.unref();
55
+ try {
56
+ process.stdin.on('data', () => {});
57
+ process.stdin.on('end', finish);
58
+ process.stdin.on('error', finish);
59
+ process.stdin.resume();
60
+ } catch {
61
+ finish();
62
+ }
63
+ });
64
+ }
65
+
66
+ drainStdin(STDIN_DRAIN_MS).then(() => {
67
+ const payload = JSON.stringify({
68
+ hookSpecificOutput: {
69
+ hookEventName: 'SubagentStart',
70
+ additionalContext
71
+ }
72
+ });
73
+ // Exit only after the write's callback fires, so a buffered write to a
74
+ // pipe is not truncated by an exit that races ahead of it.
75
+ process.stdout.write(payload, () => process.exit(0));
76
+ });
@@ -20,10 +20,10 @@ Robustness first, cost second. Split tiers because the split produces better wor
20
20
  2. **Needs live data?** trends, current docs, pricing, recent events → standard tier with tools; freshness comes from tools, not from a bigger model.
21
21
  3. **Reviewing without changing?** → standard tier, read-only, findings ranked by severity. Escalate to deep only for security-critical review.
22
22
  4. **Ambiguous, strategic, or expensive to get wrong?** "design my…", "figure out…", unknown cause → deep tier. Then hand the plan down.
23
- 5. **Everything else that changes files or executes a known plan** → you build it directly, at standard tier. The main build is never handed off whole; bounded sub-parts (a bulk pass, a wide search, a long audit loop) can go to cheaper tiers.
23
+ {{DECISION_RULE5_L1}}
24
24
 
25
25
  Modifiers:
26
- - **Plan big, execute small.** The expensive tier steers, the cheaper tier does the volume. Never make the fast tier design anything; never make the deep tier grind out bulk output.
26
+ - **Plan big, execute small.** The expensive tier steers, the cheaper tier does the volume. Never make the fast tier design anything; never make the deep tier grind out bulk output.{{INLINE_THRESHOLD_NOTE}}
27
27
  - **Never silently retry at the same tier after a failure.** Escalate one tier, or consult the deep tier once, and say which you did. If two consults do not unstick it, stop and tell the human.
28
28
  - **De-escalate.** If a request sounds deep but is a lookup or a small edit, route down. Default down, escalate on evidence.
29
29
 
@@ -36,7 +36,7 @@ Cap: two deep-tier consults per build. The full procedure is `protocols/build-pr
36
36
 
37
37
  ## Delegating inside one agent
38
38
 
39
- Subagents, a fresh chat, a second window: each one holds none of these rules. Every hand-off carries a `TASK_BUNDLE.md` brief: purpose, task class, granted scope, capabilities, denied actions, conventions it does not have, report contract, exit parameters. Absence is denial.
39
+ {{DELEGATE_RULES_NOTE}} Every hand-off carries a `TASK_BUNDLE.md` brief: purpose, task class, granted scope, capabilities, denied actions, conventions it does not have, report contract, exit parameters. Absence is denial.
40
40
 
41
41
  ## Numbers and logic go through a tool, never your head
42
42
 
@@ -53,3 +53,4 @@ Anything durable is searched for before it is written, its folder index is corre
53
53
  ## When you outgrow this
54
54
 
55
55
  You will know: you keep wanting a second model family to read your diff, a $0 lane for bulk, or a live-data lane your primary does not have. That is level 2. Re-run the installer with `--level 2`.
56
+ {{ROUTE_GATE_SECTION}}