claude-dev-env 7.6.1 → 7.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/e-code-review/reference/fix.md +4 -2
- package/.agents/skills/e-code-review/reference/loop.md +4 -2
- package/AGENTS.md +48 -71
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/advisor/reference/advisor-block.md +1 -1
- package/_shared/advisor/reference/astra-rung.md +2 -2
- package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
- package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
- package/bin/install-constants.mjs +3 -0
- package/bin/install-transaction.mjs +41 -44
- package/bin/install.cursor-rules.test.mjs +311 -3
- package/bin/install.mjs +156 -95
- package/bin/install.transaction.test.mjs +61 -1
- package/bin/pstack-model-preferences.codex.json +1 -0
- package/bin/pstack.mjs +28 -6
- package/bin/pstack.test.mjs +48 -0
- package/bin/select-pstack-models.test.mjs +424 -26
- package/bin/subagent-model-policy.test.mjs +436 -0
- package/docs/codex-compatibility.md +19 -5
- package/docs/subagent-model-routing.md +69 -0
- package/hooks/hooks.json +10 -0
- package/hooks/routing/subagent_model_routing.mjs +81 -0
- package/hooks/routing/subagent_model_routing.test.mjs +255 -0
- package/package.json +1 -1
- package/rules/pstack-models.md +22 -0
- package/rules/subagent-model-policy.json +85 -0
- package/scripts/resolve_advisor_model_route.mjs +52 -0
- package/scripts/select_pstack_models.mjs +203 -38
- package/scripts/subagent_model_policy.mjs +620 -0
- package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
- package/scripts/tests/test_codex_compat_materializer.py +15 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
+
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
defaultSubagentModelPolicyPath,
|
|
10
|
+
loadSubagentModelPolicy,
|
|
11
|
+
resolveSubagentModelRoute,
|
|
12
|
+
routeSubagentToolInput,
|
|
13
|
+
SubagentModelPolicyError,
|
|
14
|
+
validateSubagentModelPolicy,
|
|
15
|
+
} from '../scripts/subagent_model_policy.mjs';
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
const POLICY_SOURCE = defaultSubagentModelPolicyPath();
|
|
19
|
+
|
|
20
|
+
function route(request, options = {}) {
|
|
21
|
+
return resolveSubagentModelRoute(request, options);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function advisorRoute(request, options = {}) {
|
|
25
|
+
return route(request, {
|
|
26
|
+
trustedSessionMetadata: {
|
|
27
|
+
authorized: true,
|
|
28
|
+
registeredAgentType: 'session-advisor',
|
|
29
|
+
},
|
|
30
|
+
...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const replacements = [
|
|
35
|
+
[['Luna', 'low'], ['gpt-5.6-luna', 'high']],
|
|
36
|
+
[['Luna', 'medium'], ['gpt-5.6-luna', 'high']],
|
|
37
|
+
[['Sol', 'low'], ['gpt-5.6-luna', 'xhigh']],
|
|
38
|
+
[['Sol', 'medium'], ['gpt-5.6-luna', 'max']],
|
|
39
|
+
[['Terra', 'low'], ['gpt-5.6-luna', 'xhigh']],
|
|
40
|
+
[['Terra', 'medium'], ['gpt-5.6-luna', 'xhigh']],
|
|
41
|
+
[['Terra', 'high'], ['gpt-5.6-luna', 'xhigh']],
|
|
42
|
+
[['Terra', 'xhigh'], ['gpt-5.6-luna', 'max']],
|
|
43
|
+
[['Sol', 'high'], ['gpt-6-astra', 'low']],
|
|
44
|
+
[['Sol', 'xhigh'], ['gpt-6-astra', 'low']],
|
|
45
|
+
[['Sol', 'max'], ['gpt-6-astra', 'low']],
|
|
46
|
+
[['Terra', 'max'], ['gpt-6-astra', 'low']],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
test('the shipped policy parses and names native model ids', () => {
|
|
50
|
+
const policy = loadSubagentModelPolicy();
|
|
51
|
+
assert.equal(policy.modelByName.astra.id, 'gpt-6-astra');
|
|
52
|
+
assert.equal(policy.modelByName.sol.id, 'gpt-5.6-sol');
|
|
53
|
+
assert.equal(policy.modelByName.terra.id, 'gpt-5.6-terra');
|
|
54
|
+
assert.equal(policy.modelByName.luna.id, 'gpt-5.6-luna');
|
|
55
|
+
assert.deepEqual(policy.advisorDefault, { model: 'astra', effort: 'medium' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every approved pair returns its literal native pair', () => {
|
|
59
|
+
const expected = [
|
|
60
|
+
[['Luna', 'high'], ['gpt-5.6-luna', 'high']],
|
|
61
|
+
[['Luna', 'xhigh'], ['gpt-5.6-luna', 'xhigh']],
|
|
62
|
+
[['Luna', 'max'], ['gpt-5.6-luna', 'max']],
|
|
63
|
+
[['Astra', 'low'], ['gpt-6-astra', 'low']],
|
|
64
|
+
[['Astra', 'medium'], ['gpt-6-astra', 'medium']],
|
|
65
|
+
];
|
|
66
|
+
for (const [[model, effort], [expectedModel, expectedEffort]] of expected) {
|
|
67
|
+
const result = route({ model, reasoning_effort: effort });
|
|
68
|
+
assert.ok(['pass', 'remapped'].includes(result.status));
|
|
69
|
+
assert.deepEqual(result.selected, { model: expectedModel, effort: expectedEffort });
|
|
70
|
+
const nativeRoute = route({ model: expectedModel, reasoning_effort: expectedEffort });
|
|
71
|
+
assert.equal(nativeRoute.status, 'pass');
|
|
72
|
+
assert.deepEqual(nativeRoute.selected, { model: expectedModel, effort: expectedEffort });
|
|
73
|
+
}
|
|
74
|
+
const advisor = advisorRoute({
|
|
75
|
+
model: 'Astra',
|
|
76
|
+
reasoning_effort: 'high',
|
|
77
|
+
agent_type: 'session-advisor',
|
|
78
|
+
});
|
|
79
|
+
assert.equal(advisor.status, 'remapped');
|
|
80
|
+
assert.deepEqual(advisor.selected, { model: 'gpt-6-astra', effort: 'high' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('selector exclusions keep Sol Medium out while direct routing remaps', () => {
|
|
84
|
+
const policy = loadSubagentModelPolicy();
|
|
85
|
+
assert.equal(policy.selectorExclusions.has('sol/medium'), true);
|
|
86
|
+
const result = route({ model: 'Sol', reasoning_effort: 'medium' });
|
|
87
|
+
assert.equal(result.status, 'remapped');
|
|
88
|
+
assert.deepEqual(result.selected, { model: 'gpt-5.6-luna', effort: 'max' });
|
|
89
|
+
|
|
90
|
+
const rawPolicy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
91
|
+
rawPolicy.selectorExclusions = [{ model: 'missing', effort: 'medium' }];
|
|
92
|
+
assert.throws(
|
|
93
|
+
() => validateSubagentModelPolicy(rawPolicy),
|
|
94
|
+
/selectorExclusions entry has unknown model/,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('every automatic replacement returns its literal destination', () => {
|
|
99
|
+
for (const [[model, effort], [expectedModel, expectedEffort]] of replacements) {
|
|
100
|
+
const result = route({ model, reasoning_effort: effort });
|
|
101
|
+
assert.equal(result.status, 'remapped');
|
|
102
|
+
assert.deepEqual(result.selected, { model: expectedModel, effort: expectedEffort });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('advisor and worker Astra restrictions use role-specific replacements', () => {
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
advisorRoute({ model: 'Astra', reasoning_effort: 'xhigh', agent_type: 'session-advisor' }).selected,
|
|
109
|
+
{ model: 'gpt-6-astra', effort: 'medium' },
|
|
110
|
+
);
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
route({ model: 'Astra', reasoning_effort: 'max', agent_type: 'worker' }).selected,
|
|
113
|
+
{ model: 'gpt-6-astra', effort: 'low' },
|
|
114
|
+
);
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
advisorRoute({ model: 'Astra', reasoning_effort: 'high', agent_type: 'session-advisor' }).selected,
|
|
117
|
+
{ model: 'gpt-6-astra', effort: 'high' },
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('registered advisor roles satisfy advisory authorization', () => {
|
|
122
|
+
const result = advisorRoute({
|
|
123
|
+
model: 'Astra',
|
|
124
|
+
reasoning_effort: 'high',
|
|
125
|
+
agent_type: 'session-advisor',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'high' });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a policy from the first stack revision remains routable', () => {
|
|
132
|
+
const legacyPolicy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
133
|
+
delete legacyPolicy.roles.trustedAdvisor;
|
|
134
|
+
const result = advisorRoute(
|
|
135
|
+
{
|
|
136
|
+
model: 'Astra',
|
|
137
|
+
reasoning_effort: 'high',
|
|
138
|
+
agent_type: 'session-advisor',
|
|
139
|
+
},
|
|
140
|
+
{ policy: legacyPolicy },
|
|
141
|
+
);
|
|
142
|
+
assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'high' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('an advisor role claim cannot grant advisory authorization', () => {
|
|
146
|
+
const result = route({
|
|
147
|
+
model: 'Astra',
|
|
148
|
+
reasoning_effort: 'high',
|
|
149
|
+
agent_type: 'session-advisor',
|
|
150
|
+
});
|
|
151
|
+
assert.equal(result.status, 'blocked');
|
|
152
|
+
assert.equal(result.diagnostic, 'advisor role is not trusted');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('Astra Light resolves to Astra Low', () => {
|
|
156
|
+
const result = route({ model: 'Astra', reasoning_effort: 'Light' });
|
|
157
|
+
assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'low' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('missing advisor settings use the policy default', () => {
|
|
161
|
+
const result = advisorRoute({ agent_type: 'session-advisor' });
|
|
162
|
+
assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'medium' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the advisor bridge blocks malformed input', () => {
|
|
166
|
+
const bridgePath = join(PACKAGE_ROOT, 'scripts', 'resolve_advisor_model_route.mjs');
|
|
167
|
+
assert.throws(
|
|
168
|
+
() => execFileSync(process.execPath, [bridgePath], { encoding: 'utf8', input: '{' }),
|
|
169
|
+
error => {
|
|
170
|
+
assert.equal(error.status, 1);
|
|
171
|
+
assert.deepEqual(JSON.parse(error.stdout), {
|
|
172
|
+
status: 'blocked',
|
|
173
|
+
diagnostic: 'advisor route input is not valid JSON',
|
|
174
|
+
});
|
|
175
|
+
return true;
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('the advisor bridge routes Sol Medium to Luna Max', () => {
|
|
181
|
+
const bridgePath = join(PACKAGE_ROOT, 'scripts', 'resolve_advisor_model_route.mjs');
|
|
182
|
+
const bridgeExecution = spawnSync(
|
|
183
|
+
process.execPath,
|
|
184
|
+
[bridgePath],
|
|
185
|
+
{
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
input: JSON.stringify({ model: 'Sol', reasoning_effort: 'medium' }),
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
assert.equal(bridgeExecution.status, 0);
|
|
191
|
+
assert.equal(bridgeExecution.stderr, '');
|
|
192
|
+
const advisorDecision = JSON.parse(bridgeExecution.stdout);
|
|
193
|
+
assert.equal(advisorDecision.status, 'remapped');
|
|
194
|
+
assert.equal(advisorDecision.role, 'advisor');
|
|
195
|
+
assert.equal(advisorDecision.diagnostic, null);
|
|
196
|
+
assert.deepEqual(advisorDecision.selected, { model: 'gpt-5.6-luna', effort: 'max' });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('missing worker settings preserve parent inheritance', () => {
|
|
200
|
+
const input = { message: 'keep', agent_type: 'worker' };
|
|
201
|
+
const result = routeSubagentToolInput(input);
|
|
202
|
+
assert.equal(result.status, 'inherited');
|
|
203
|
+
assert.strictEqual(result.updatedInput, input);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('Sol without an effort stays blocked after its only approved pair is removed', () => {
|
|
207
|
+
const result = route(
|
|
208
|
+
{ model: 'Sol' },
|
|
209
|
+
{ allowMissingEffort: true },
|
|
210
|
+
);
|
|
211
|
+
assert.equal(result.status, 'blocked');
|
|
212
|
+
assert.equal(result.diagnostic, 'effort is required for this model');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('unknown input holds with a short diagnostic', () => {
|
|
216
|
+
assert.match(route({ model: 'Unknown', reasoning_effort: 'medium' }).diagnostic, /model is unknown/);
|
|
217
|
+
assert.match(route({ model: 'Terra', reasoning_effort: 'future' }).diagnostic, /effort is unknown/);
|
|
218
|
+
assert.match(route({ model: 'Sol', reasoning_effort: 'medium', agent_type: 'unregistered' }).diagnostic, /role is unknown/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('dual effort fields hold instead of leaving one stale', () => {
|
|
222
|
+
const result = route({ model: 'Luna', reasoning_effort: 'low', effort: 'low' });
|
|
223
|
+
assert.equal(result.status, 'blocked');
|
|
224
|
+
assert.match(result.diagnostic, /effort fields are ambiguous/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('native values with case or whitespace differences are canonicalized', () => {
|
|
228
|
+
const result = routeSubagentToolInput({
|
|
229
|
+
model: ' gpt-5.6-sol ',
|
|
230
|
+
reasoning_effort: 'Medium',
|
|
231
|
+
message: 'keep',
|
|
232
|
+
});
|
|
233
|
+
assert.equal(result.status, 'remapped');
|
|
234
|
+
assert.deepEqual(result.updatedInput, {
|
|
235
|
+
model: 'gpt-5.6-luna',
|
|
236
|
+
reasoning_effort: 'max',
|
|
237
|
+
message: 'keep',
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('unavailable replacements hold the spawn', () => {
|
|
242
|
+
const result = route(
|
|
243
|
+
{ model: 'Sol', reasoning_effort: 'medium' },
|
|
244
|
+
{ availableModelIds: ['gpt-5.6-sol'] },
|
|
245
|
+
);
|
|
246
|
+
assert.equal(result.status, 'blocked');
|
|
247
|
+
assert.match(result.diagnostic, /replacement model is unavailable/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('the tool route copies every non-routing field', () => {
|
|
251
|
+
const input = {
|
|
252
|
+
assignment: 'preserve',
|
|
253
|
+
message: 'do the work',
|
|
254
|
+
child_permissions: { shell: 'ask' },
|
|
255
|
+
output_contract: ['summary'],
|
|
256
|
+
model: 'Terra',
|
|
257
|
+
reasoning_effort: 'Medium',
|
|
258
|
+
};
|
|
259
|
+
const result = routeSubagentToolInput(input);
|
|
260
|
+
assert.deepEqual(result.updatedInput, {
|
|
261
|
+
assignment: 'preserve',
|
|
262
|
+
message: 'do the work',
|
|
263
|
+
child_permissions: { shell: 'ask' },
|
|
264
|
+
output_contract: ['summary'],
|
|
265
|
+
model: 'gpt-5.6-luna',
|
|
266
|
+
reasoning_effort: 'xhigh',
|
|
267
|
+
});
|
|
268
|
+
assert.deepEqual(input, {
|
|
269
|
+
assignment: 'preserve',
|
|
270
|
+
message: 'do the work',
|
|
271
|
+
child_permissions: { shell: 'ask' },
|
|
272
|
+
output_contract: ['summary'],
|
|
273
|
+
model: 'Terra',
|
|
274
|
+
reasoning_effort: 'Medium',
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('a second route pass is idempotent', () => {
|
|
279
|
+
const first = routeSubagentToolInput(
|
|
280
|
+
{ model: 'Sol', reasoning_effort: 'medium', message: 'keep' },
|
|
281
|
+
{},
|
|
282
|
+
);
|
|
283
|
+
const second = routeSubagentToolInput(first.updatedInput);
|
|
284
|
+
assert.equal(first.status, 'remapped');
|
|
285
|
+
assert.equal(second.status, 'pass');
|
|
286
|
+
assert.deepEqual(second.updatedInput, first.updatedInput);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('a temporary policy copy changes the next route without source edits', () => {
|
|
290
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), 'subagent-policy-'));
|
|
291
|
+
try {
|
|
292
|
+
const temporaryPolicy = join(temporaryRoot, 'policy.json');
|
|
293
|
+
const sourceText = readFileSync(POLICY_SOURCE, 'utf8');
|
|
294
|
+
const policy = JSON.parse(sourceText);
|
|
295
|
+
writeFileSync(temporaryPolicy, sourceText);
|
|
296
|
+
assert.deepEqual(route(
|
|
297
|
+
{ model: 'Sol', reasoning_effort: 'medium' },
|
|
298
|
+
{ policyPath: temporaryPolicy },
|
|
299
|
+
).selected, { model: 'gpt-5.6-luna', effort: 'max' });
|
|
300
|
+
policy.replacements = policy.replacements.map(replacement => (
|
|
301
|
+
replacement.requested.model === 'sol' && replacement.requested.effort === 'medium'
|
|
302
|
+
? { ...replacement, selected: { model: 'luna', effort: 'xhigh' } }
|
|
303
|
+
: replacement
|
|
304
|
+
));
|
|
305
|
+
writeFileSync(temporaryPolicy, JSON.stringify(policy));
|
|
306
|
+
const updatedRoute = route(
|
|
307
|
+
{ model: 'Sol', reasoning_effort: 'medium' },
|
|
308
|
+
{ policyPath: temporaryPolicy },
|
|
309
|
+
);
|
|
310
|
+
assert.deepEqual(updatedRoute.selected, { model: 'gpt-5.6-luna', effort: 'xhigh' });
|
|
311
|
+
assert.equal(readFileSync(POLICY_SOURCE, 'utf8'), sourceText);
|
|
312
|
+
} finally {
|
|
313
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('malformed policy data fails validation', () => {
|
|
318
|
+
assert.throws(
|
|
319
|
+
() => validateSubagentModelPolicy({ schemaVersion: 1 }),
|
|
320
|
+
error => error instanceof SubagentModelPolicyError && /models must be an object/.test(error.message),
|
|
321
|
+
);
|
|
322
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
323
|
+
assert.throws(
|
|
324
|
+
() => validateSubagentModelPolicy({
|
|
325
|
+
...policy,
|
|
326
|
+
replacements: [
|
|
327
|
+
{
|
|
328
|
+
requested: { model: 'terra', effort: 'medium' },
|
|
329
|
+
selected: { model: 'terra', effort: 'medium' },
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
}),
|
|
333
|
+
/replacement target is not approved/,
|
|
334
|
+
);
|
|
335
|
+
const blocked = resolveSubagentModelRoute(
|
|
336
|
+
{ model: 'Terra', reasoning_effort: 'medium' },
|
|
337
|
+
{ policy: { schemaVersion: 1 } },
|
|
338
|
+
);
|
|
339
|
+
assert.equal(blocked.status, 'blocked');
|
|
340
|
+
assert.match(blocked.diagnostic, /models must be an object/);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('a copied policy cannot forge the validated-policy marker', () => {
|
|
344
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
345
|
+
const validatedPolicy = validateSubagentModelPolicy(policy);
|
|
346
|
+
const copiedPolicy = { ...validatedPolicy, models: {} };
|
|
347
|
+
const result = resolveSubagentModelRoute(
|
|
348
|
+
{ model: 'Terra', reasoning_effort: 'medium' },
|
|
349
|
+
{ policy: copiedPolicy },
|
|
350
|
+
);
|
|
351
|
+
assert.equal(result.status, 'blocked');
|
|
352
|
+
assert.match(result.diagnostic, /models must not be empty/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('normalized effort aliases resolve from the validated policy', () => {
|
|
356
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
357
|
+
policy.efforts.aliases = { Light: 'LOW' };
|
|
358
|
+
const validatedPolicy = validateSubagentModelPolicy(policy);
|
|
359
|
+
const result = resolveSubagentModelRoute(
|
|
360
|
+
{ model: 'Astra', reasoning_effort: 'Light' },
|
|
361
|
+
{ policy: validatedPolicy },
|
|
362
|
+
);
|
|
363
|
+
assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'low' });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('inheritance aliases cannot shadow model aliases', () => {
|
|
367
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
368
|
+
policy.inheritanceAliases = ['astra'];
|
|
369
|
+
assert.throws(
|
|
370
|
+
() => validateSubagentModelPolicy(policy),
|
|
371
|
+
/inheritance alias conflicts with a model alias/,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('model names cannot collide after normalization', () => {
|
|
376
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
377
|
+
policy.models.Astra = { id: 'gpt-6-astra-copy', aliases: [] };
|
|
378
|
+
assert.throws(
|
|
379
|
+
() => validateSubagentModelPolicy(policy),
|
|
380
|
+
/model name is ambiguous: astra/,
|
|
381
|
+
);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('combined role routes must end at an approved terminal pair', () => {
|
|
385
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
386
|
+
const solMediumReplacement = policy.replacements.find(replacement => (
|
|
387
|
+
replacement.requested.model === 'sol'
|
|
388
|
+
&& replacement.requested.effort === 'medium'
|
|
389
|
+
));
|
|
390
|
+
solMediumReplacement.selected = { model: 'astra', effort: 'low' };
|
|
391
|
+
policy.roleReplacements.push({
|
|
392
|
+
role: 'worker',
|
|
393
|
+
requested: { model: 'astra', effort: 'low' },
|
|
394
|
+
selected: { model: 'luna', effort: 'high' },
|
|
395
|
+
});
|
|
396
|
+
assert.throws(
|
|
397
|
+
() => validateSubagentModelPolicy(policy),
|
|
398
|
+
/replacement destination is not terminal/,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('replacement cycles fail policy validation', () => {
|
|
403
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
404
|
+
policy.replacements.push(
|
|
405
|
+
{
|
|
406
|
+
requested: { model: 'luna', effort: 'high' },
|
|
407
|
+
selected: { model: 'luna', effort: 'xhigh' },
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
requested: { model: 'luna', effort: 'xhigh' },
|
|
411
|
+
selected: { model: 'luna', effort: 'high' },
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
assert.throws(
|
|
415
|
+
() => validateSubagentModelPolicy(policy),
|
|
416
|
+
/replacement cycle includes/,
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('base replacement targets must be approved for every role', () => {
|
|
421
|
+
const policy = JSON.parse(readFileSync(POLICY_SOURCE, 'utf8'));
|
|
422
|
+
policy.replacements = policy.replacements.map(replacement => (
|
|
423
|
+
replacement.requested.model === 'terra' && replacement.requested.effort === 'medium'
|
|
424
|
+
? { ...replacement, selected: { model: 'astra', effort: 'high' } }
|
|
425
|
+
: replacement
|
|
426
|
+
));
|
|
427
|
+
assert.throws(
|
|
428
|
+
() => validateSubagentModelPolicy(policy),
|
|
429
|
+
/replacement target is not approved: astra\/high/,
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('policy aliases stay in the policy file', () => {
|
|
434
|
+
const source = readFileSync(join(PACKAGE_ROOT, 'scripts', 'subagent_model_policy.mjs'), 'utf8');
|
|
435
|
+
assert.doesNotMatch(source, /gpt-5\.6-(?:luna|sol|terra)|gpt-6-astra/);
|
|
436
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Codex compatibility entry point
|
|
2
2
|
|
|
3
|
-
`codex-compat` is an optional, explicit bridge from this package's Claude-oriented source tree to Codex-compatible records. The
|
|
3
|
+
`codex-compat` is an optional, explicit bridge from this package's Claude-oriented source tree to Codex-compatible records. The package installer also registers the supported native subagent routing hook in the selected Codex home.
|
|
4
4
|
|
|
5
5
|
## Materialization
|
|
6
6
|
|
|
@@ -22,6 +22,20 @@ A missing or unreadable source root is an error, and the run changes nothing. An
|
|
|
22
22
|
|
|
23
23
|
Run `codex-compat bridge --surface <name> --payload '<json-object>'`. The bridge exposes the Python translation logic directly. `TaskCreate` and `TaskUpdate` map to `update_plan`; spawn, message, wait, and stop map to multi-agent surfaces. `ScheduleWakeup` is explicitly unsupported and requires manual review.
|
|
24
24
|
|
|
25
|
+
## Automatic model routing
|
|
26
|
+
|
|
27
|
+
The package installer seeds `subagent-model-policy.json` in the agents-home
|
|
28
|
+
`rules/` directory when the file is absent. It registers one `PreToolUse` group
|
|
29
|
+
for `multi_agent_v1__spawn_agent` in `$CODEX_HOME/hooks.json`. Existing Codex
|
|
30
|
+
hook groups stay in place. Claude-only hook groups do not enter the Codex file.
|
|
31
|
+
|
|
32
|
+
Edit the installed policy file to change the next routing decision. The resolver
|
|
33
|
+
loads that path for each invocation.
|
|
34
|
+
|
|
35
|
+
The default `.claude` target uses `CODEX_HOME`. A named profile or another
|
|
36
|
+
target uses `<managed-root>/.codex`, so launch that profile with the same
|
|
37
|
+
`CODEX_HOME` value.
|
|
38
|
+
|
|
25
39
|
## Per-message context injection (manual wiring)
|
|
26
40
|
|
|
27
41
|
Codex reads the same `hookSpecificOutput.additionalContext` shape as Claude,
|
|
@@ -46,9 +60,8 @@ Add this by hand to `$CODEX_HOME/hooks.json` (merge it into an existing file):
|
|
|
46
60
|
}
|
|
47
61
|
```
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
- The installer skips Codex `hooks.json` for now. Wire it by hand.
|
|
63
|
+
The package installer does not register this optional context hook. Add it by
|
|
64
|
+
hand when the host should receive that context.
|
|
52
65
|
|
|
53
66
|
## Luna fast-mode guard
|
|
54
67
|
|
|
@@ -71,4 +84,5 @@ The guard requires exact `fast` for Luna spawns through `Agent` and `Task`. Nati
|
|
|
71
84
|
|
|
72
85
|
## Roots and safety
|
|
73
86
|
|
|
74
|
-
Both roots are caller-supplied. The tool
|
|
87
|
+
Both materializer roots are caller-supplied. The tool writes only inside those
|
|
88
|
+
roots. No personal paths or secrets are embedded in the package.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Automatic child model choices
|
|
2
|
+
|
|
3
|
+
The package routes native subagent requests before the child starts. It keeps
|
|
4
|
+
approved pairs, aliases, replacements, selector exclusions, and the advisor
|
|
5
|
+
default in one file.
|
|
6
|
+
|
|
7
|
+
The source policy is
|
|
8
|
+
`packages/claude-dev-env/rules/subagent-model-policy.json`.
|
|
9
|
+
|
|
10
|
+
After install, the editable file is
|
|
11
|
+
`<agents-home>/rules/subagent-model-policy.json`.
|
|
12
|
+
|
|
13
|
+
The installer creates this file only when it is absent. Later installs keep the
|
|
14
|
+
file and its edits. The main profile uses the `.agents` directory beside
|
|
15
|
+
`.claude`. A named profile uses its matching `<profile>.agents` directory.
|
|
16
|
+
|
|
17
|
+
To change a route, edit the `selected` pair for one `requested` pair. For
|
|
18
|
+
example, change the `terra` and `medium` entry in `replacements` to:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"requested": {
|
|
23
|
+
"model": "terra",
|
|
24
|
+
"effort": "medium"
|
|
25
|
+
},
|
|
26
|
+
"selected": {
|
|
27
|
+
"model": "luna",
|
|
28
|
+
"effort": "max"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The next routing call reads the file again. The resolver source stays unchanged.
|
|
34
|
+
|
|
35
|
+
Selector exclusions keep a pair out of panels. They do not change direct route
|
|
36
|
+
decisions. The shipped policy keeps Sol Medium out of panels and routes direct
|
|
37
|
+
requests for Sol Medium to Luna Max through `replacements`. Sol Medium is absent
|
|
38
|
+
from `approvedPairs`. The selector drops the requested pair before routing it,
|
|
39
|
+
so its Luna replacement does not enter the panel through that preference.
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
"selectorExclusions": [
|
|
43
|
+
{"model": "sol", "effort": "medium"}
|
|
44
|
+
]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The matching replacement is:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"requested": {"model": "sol", "effort": "medium"},
|
|
52
|
+
"selected": {"model": "luna", "effort": "max"}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Unknown values, invalid policy data, and unavailable replacement models stop the
|
|
57
|
+
spawn with a short diagnostic. Parent inheritance stays unchanged.
|
|
58
|
+
|
|
59
|
+
The direct spawn hook and advisor bridge use the same shared resolver. A Sol
|
|
60
|
+
Medium remap changes only the model and effort fields in the hook's tool input.
|
|
61
|
+
The hook returns an allow decision with the updated input and emits no remap
|
|
62
|
+
notice. Luna High, Xhigh, and Max and Astra Low and Medium keep their routes.
|
|
63
|
+
Trusted advisors also keep Astra High. A worker request for Sol without an
|
|
64
|
+
effort stops because Sol has no approved pair to supply a default effort.
|
|
65
|
+
|
|
66
|
+
The hook blocks advisor role claims because the current Codex hook input has no
|
|
67
|
+
host-provided role binding. The Python advisor bridge passes trusted session
|
|
68
|
+
metadata to the shared resolver. The native advisor hook path stays pending
|
|
69
|
+
until the host supplies that binding.
|
package/hooks/hooks.json
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "multi_agent_v1__spawn_agent",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/routing/subagent_model_routing.mjs\"",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
5
15
|
{
|
|
6
16
|
"matcher": "Write|Edit|MultiEdit|apply_patch",
|
|
7
17
|
"hooks": [
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { routeSubagentToolInput } from '../../scripts/subagent_model_policy.mjs';
|
|
5
|
+
|
|
6
|
+
const targetToolName = 'multi_agent_v1__spawn_agent';
|
|
7
|
+
const hookEventName = 'PreToolUse';
|
|
8
|
+
const defaultBlockingDiagnostic = 'subagent model routing blocked';
|
|
9
|
+
|
|
10
|
+
export function buildSubagentModelRoutingResponse(hookPayload, routingOptions = {}) {
|
|
11
|
+
if (!isObject(hookPayload)) return buildBlockedResponse('hook input must be an object');
|
|
12
|
+
if (!isTargetSubagentTool(hookPayload)) return {};
|
|
13
|
+
|
|
14
|
+
const routeOptions = buildRouteOptions(hookPayload, routingOptions);
|
|
15
|
+
const routingDecision = routeToolInput(hookPayload.tool_input, routeOptions);
|
|
16
|
+
if (routingDecision.status === 'blocked') {
|
|
17
|
+
return buildBlockedResponse(routingDecision.diagnostic);
|
|
18
|
+
}
|
|
19
|
+
if (routingDecision.status !== 'remapped') return {};
|
|
20
|
+
|
|
21
|
+
return buildAllowResponse(routingDecision.updatedInput);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function main(serializedInput = readFileSync(0, 'utf8')) {
|
|
25
|
+
let hookPayload;
|
|
26
|
+
try {
|
|
27
|
+
hookPayload = JSON.parse(serializedInput);
|
|
28
|
+
} catch {
|
|
29
|
+
hookPayload = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hookResponse = hookPayload === null
|
|
33
|
+
? buildBlockedResponse('hook input is not valid JSON')
|
|
34
|
+
: buildSubagentModelRoutingResponse(hookPayload);
|
|
35
|
+
process.stdout.write(JSON.stringify(hookResponse));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildRouteOptions(hookPayload, routingOptions) {
|
|
39
|
+
const routeOptions = { ...routingOptions };
|
|
40
|
+
const availableModelIds = routeOptions.availableModelIds
|
|
41
|
+
?? hookPayload.availableModelIds
|
|
42
|
+
?? hookPayload.available_model_ids;
|
|
43
|
+
if (availableModelIds !== undefined) routeOptions.availableModelIds = availableModelIds;
|
|
44
|
+
return routeOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function routeToolInput(toolInput, routeOptions) {
|
|
48
|
+
return routeSubagentToolInput(toolInput, routeOptions);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isTargetSubagentTool(hookPayload) {
|
|
52
|
+
return hookPayload.tool_name === targetToolName;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildAllowResponse(updatedInput) {
|
|
56
|
+
return {
|
|
57
|
+
hookSpecificOutput: {
|
|
58
|
+
hookEventName,
|
|
59
|
+
permissionDecision: 'allow',
|
|
60
|
+
updatedInput,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildBlockedResponse(diagnostic) {
|
|
66
|
+
return {
|
|
67
|
+
hookSpecificOutput: {
|
|
68
|
+
hookEventName,
|
|
69
|
+
permissionDecision: 'deny',
|
|
70
|
+
permissionDecisionReason: diagnostic || defaultBlockingDiagnostic,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isObject(candidate) {
|
|
76
|
+
return candidate !== null && typeof candidate === 'object' && !Array.isArray(candidate);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
80
|
+
main();
|
|
81
|
+
}
|