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.
- package/AGENTS.md +26 -0
- package/CHANGELOG.md +43 -2
- package/README.md +52 -8
- package/bin/cli-run.mjs +22 -7
- package/bin/cli.js +4 -4
- package/docs/audit-brief.md +32 -0
- package/docs/part-1-beginner.md +1 -1
- package/docs/part-2-intermediate.md +1 -1
- package/llms.txt +27 -0
- package/package.json +28 -4
- package/src/README.md +1 -1
- package/src/catalog.js +9 -1
- package/src/detect.js +28 -9
- package/src/install.js +206 -5
- package/templates/README.md +1 -1
- package/templates/agents/README.md +3 -1
- package/templates/agents/agy/README.md +2 -2
- package/templates/agents/agy/done-verifier.md +35 -0
- package/templates/agents/agy/finding-verifier.md +6 -0
- package/templates/agents/agy/reader.md +22 -0
- package/templates/agents/claude-code/README.md +7 -5
- package/templates/agents/claude-code/builder.md +6 -1
- package/templates/agents/claude-code/code-reviewer.md +9 -2
- package/templates/agents/claude-code/done-verifier.md +44 -0
- package/templates/agents/claude-code/finding-verifier.md +9 -2
- package/templates/agents/claude-code/reader.md +26 -0
- package/templates/agents/snippets/claude-code.md +9 -4
- package/templates/agents/snippets/route-gate.mjs +151 -0
- package/templates/agents/snippets/route-metrics.mjs +356 -0
- package/templates/agents/snippets/settings.hooks.snippet.json +70 -0
- package/templates/agents/snippets/subagent-context.mjs +76 -0
- package/templates/beginner/ORCHESTRATOR.md +4 -3
- package/templates/common/TASK_BUNDLE.md +2 -2
- package/templates/common/protocols/build-protocol.md +2 -2
- package/templates/intermediate/ROUTING.md +9 -10
- 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
|
-
|
|
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
|
-
|
|
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}}
|