claude-dev-env 7.6.0 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.agents/skills/session-continuity/README.md +19 -9
  2. package/AGENTS.md +48 -71
  3. package/_shared/advisor/advisor-protocol.md +2 -2
  4. package/_shared/advisor/reference/advisor-block.md +1 -1
  5. package/_shared/advisor/reference/astra-rung.md +2 -2
  6. package/_shared/advisor/scripts/codex_astra_advisor.py +122 -23
  7. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +76 -9
  8. package/_shared/advisor/scripts/tests/test_advisor_route_constants.py +51 -0
  9. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +52 -2
  10. package/bin/install-constants.mjs +3 -0
  11. package/bin/install-session-continuity.mjs +73 -30
  12. package/bin/install-session-continuity.test.mjs +52 -1
  13. package/bin/install-transaction.mjs +41 -44
  14. package/bin/install.cursor-rules.test.mjs +311 -3
  15. package/bin/install.mjs +226 -95
  16. package/bin/install.test.mjs +57 -0
  17. package/bin/install.transaction.test.mjs +61 -1
  18. package/bin/pstack-model-preferences.codex.json +1 -0
  19. package/bin/pstack.mjs +28 -6
  20. package/bin/pstack.test.mjs +48 -0
  21. package/bin/select-pstack-models.test.mjs +375 -26
  22. package/bin/subagent-model-policy.test.mjs +399 -0
  23. package/docs/codex-compatibility.md +19 -5
  24. package/docs/subagent-model-routing.md +50 -0
  25. package/hooks/hooks.json +10 -0
  26. package/hooks/routing/subagent_model_routing.mjs +81 -0
  27. package/hooks/routing/subagent_model_routing.test.mjs +218 -0
  28. package/package.json +1 -1
  29. package/rules/pstack-models.md +22 -0
  30. package/rules/subagent-model-policy.json +85 -0
  31. package/scripts/resolve_advisor_model_route.mjs +52 -0
  32. package/scripts/select_pstack_models.mjs +203 -38
  33. package/scripts/subagent_model_policy.mjs +620 -0
  34. package/scripts/tests/resolve_advisor_model_route.test.mjs +51 -0
  35. package/scripts/tests/test_codex_compat_materializer.py +15 -0
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "host": "codex",
3
+ "defaultEffort": "medium",
3
4
  "modelsByRole": {
4
5
  "feature, refactoring": ["gpt-5.6-sol"],
5
6
  "bug-fix": ["gpt-5.6-sol"],
package/bin/pstack.mjs CHANGED
@@ -67,6 +67,9 @@ function adapterFiles(root) {
67
67
  ...['common.md', ...hosts.map(host => `host-${host}.md`), 'create-skill.md'].map(name => [name, join(adapters, name)]),
68
68
  ...['pstack-host-mapping.md', 'pstack-models.md'].map(name => [name, join(root, 'rules', name)]),
69
69
  ['select_pstack_models.mjs', join(root, 'scripts', 'select_pstack_models.mjs')],
70
+ ['subagent_model_policy.mjs', join(root, 'scripts', 'subagent_model_policy.mjs')],
71
+ ['resolve_advisor_model_route.mjs', join(root, 'scripts', 'resolve_advisor_model_route.mjs')],
72
+ ['subagent-model-policy.json', join(root, 'rules', 'subagent-model-policy.json')],
70
73
  ].map(([name, path]) => [name, readFileSync(path, 'utf8')]));
71
74
  }
72
75
 
@@ -141,7 +144,7 @@ export function prepareRelease(checkout, stage, finalRoot, lock, adapters) {
141
144
  const fileDigests = Object.fromEntries(filesUnder(stage).map(path => [relative(stage, path), digest(readFileSync(path))]));
142
145
  const managedRoot = dirname(dirname(dirname(finalRoot)));
143
146
  const agentsRoot = basename(managedRoot) === '.claude' ? join(dirname(managedRoot), '.agents') : managedRoot + '.agents';
144
- const release = { preferencesDirectory: join(agentsRoot, 'rules'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
147
+ const release = { preferencesDirectory: join(agentsRoot, 'rules'), policyPath: join(agentsRoot, 'rules', 'subagent-model-policy.json'), selectorPath: join(finalRoot, 'compat', 'select_pstack_models.mjs'), schemaVersion: 1, commit: lock.commit, adapterVersion: lock.adapterVersion, skills, agents, fileDigests, verification: 'filesystem-only' };
145
148
  writeFileSync(join(stage, 'release.json'), json(release));
146
149
  return release;
147
150
  }
@@ -171,7 +174,7 @@ export function verifyRelease(root) {
171
174
  function layout(options) {
172
175
  const root = resolve(options.root ?? (options.project ? join(options.project, '.claude') : process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')));
173
176
  const agents = basename(root) === '.claude' ? join(dirname(root), '.agents') : root + '.agents';
174
- return { root, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
177
+ return { root, agents, store: join(root, 'pstack'), skillHomes: [join(root, 'skills'), join(agents, 'skills')] };
175
178
  }
176
179
 
177
180
  function loadState(store) {
@@ -224,6 +227,19 @@ function publish(releaseRoot, release, homes, prior) {
224
227
  return { links, restore };
225
228
  }
226
229
 
230
+ function seedPolicy(releaseRoot, release, agentsRoot) {
231
+ const policyPath = join(agentsRoot, 'rules', 'subagent-model-policy.json');
232
+ if (typeof release.policyPath !== 'string') throw new Error('Invalid release policy path');
233
+ if (resolve(release.policyPath) !== resolve(policyPath)) throw new Error('Invalid release policy path');
234
+ mkdirSync(dirname(policyPath), { recursive: true });
235
+ if (entryExists(policyPath)) {
236
+ if (!lstatSync(policyPath).isFile()) throw new Error(`Keep existing unmanaged policy path: ${policyPath}`);
237
+ return () => {};
238
+ }
239
+ writeFileSync(policyPath, readFileSync(join(releaseRoot, 'compat', 'subagent-model-policy.json')), { flag: 'wx' });
240
+ return () => { if (entryExists(policyPath)) unlinkSync(policyPath); };
241
+ }
242
+
227
243
  function liveLeases(store) {
228
244
  const root = join(store, 'sessions');
229
245
  if (!existsSync(root)) return false;
@@ -247,7 +263,7 @@ function readCentralLock(temporaryRoot) {
247
263
  }
248
264
 
249
265
  export function installPstack(options = {}, dependencies = {}) {
250
- const { store, skillHomes } = layout(options);
266
+ const { agents, store, skillHomes } = layout(options);
251
267
  mkdirSync(store, { recursive: true });
252
268
  const lockDirectory = join(store, '.install-lock');
253
269
  mkdirSync(lockDirectory);
@@ -291,12 +307,18 @@ export function installPstack(options = {}, dependencies = {}) {
291
307
  renameSync(stage, releaseRoot);
292
308
  }
293
309
  const release = verifyRelease(releaseRoot);
294
- const publication = publish(releaseRoot, release, skillHomes, prior);
295
- const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
310
+ const policyPublication = seedPolicy(releaseRoot, release, agents);
311
+ let publication;
296
312
  try {
313
+ publication = publish(releaseRoot, release, skillHomes, prior);
314
+ const state = { release: id, commit: lock.commit, adapterVersion: lock.adapterVersion, adapterDigest, checkedAt, lock, links: publication.links };
297
315
  writeFileSync(join(store, 'installed.next.json'), json(state));
298
316
  renameSync(join(store, 'installed.next.json'), join(store, 'installed.json'));
299
- } catch (error) { publication.restore(); throw error; }
317
+ } catch (error) {
318
+ publication?.restore();
319
+ policyPublication();
320
+ throw error;
321
+ }
300
322
  return finish({ ...verifyInstallation(options), status: prior?.release === id ? 'unchanged' : 'installed' });
301
323
  } catch (error) {
302
324
  if (!options.strict) {
@@ -46,6 +46,26 @@ for (const host of ['claude', 'codex', 'cursor']) {
46
46
  const release = join(f.store, 'releases', result.release);
47
47
  const releaseManifest = JSON.parse(readFileSync(join(release, 'release.json'), 'utf8'));
48
48
  assert.equal(releaseManifest.skills.find(skill => skill.component === 'pstack').name, 'pstack:poteto-mode');
49
+ assert.equal(
50
+ releaseManifest.policyPath,
51
+ join(f.options.project, '.agents', 'rules', 'subagent-model-policy.json'),
52
+ );
53
+ assert.equal(
54
+ readFileSync(join(release, 'compat', 'subagent_model_policy.mjs'), 'utf8'),
55
+ readFileSync(join(packageRoot, 'scripts', 'subagent_model_policy.mjs'), 'utf8'),
56
+ );
57
+ assert.equal(
58
+ readFileSync(join(release, 'compat', 'subagent-model-policy.json'), 'utf8'),
59
+ readFileSync(join(packageRoot, 'rules', 'subagent-model-policy.json'), 'utf8'),
60
+ );
61
+ assert.equal(
62
+ readFileSync(join(release, 'compat', 'resolve_advisor_model_route.mjs'), 'utf8'),
63
+ readFileSync(join(packageRoot, 'scripts', 'resolve_advisor_model_route.mjs'), 'utf8'),
64
+ );
65
+ assert.equal(
66
+ readFileSync(join(f.options.project, '.agents', 'rules', 'subagent-model-policy.json'), 'utf8'),
67
+ readFileSync(join(packageRoot, 'rules', 'subagent-model-policy.json'), 'utf8'),
68
+ );
49
69
  const expected = ['cde-create-skill', 'cursor-team-kit-control-cli', 'cursor-team-kit-control-ui', 'cursor-team-kit-deslop', 'pstack'];
50
70
  for (const home of ['.claude', '.agents']) {
51
71
  const skillsHome = join(f.options.project, home, 'skills');
@@ -85,6 +105,34 @@ test('unchanged install avoids another upstream fetch', t => {
85
105
  assert.deepEqual(second.links, first.links);
86
106
  });
87
107
 
108
+ test('policy edits survive a pstack update', t => {
109
+ const f = fixture(t);
110
+ installPstack(f.options, f.dependencies);
111
+ const policyPath = join(f.options.project, '.agents', 'rules', 'subagent-model-policy.json');
112
+ const editedPolicy = '{"schemaVersion":1,"edited":true}\n';
113
+ writeFileSync(policyPath, editedPolicy);
114
+ installPstack({ ...f.options, lock: { ...baseLock, commit: 'b'.repeat(40) } }, f.dependencies);
115
+ assert.equal(readFileSync(policyPath, 'utf8'), editedPolicy);
116
+ });
117
+
118
+ test('a release cannot redirect the installed policy path', t => {
119
+ const f = fixture(t);
120
+ const first = installPstack(f.options, f.dependencies);
121
+ const releaseRoot = join(f.store, 'releases', first.release);
122
+ const releaseManifestPath = join(releaseRoot, 'release.json');
123
+ const releaseManifest = JSON.parse(readFileSync(releaseManifestPath, 'utf8'));
124
+ const outsidePolicyPath = join(f.temporary, 'outside-policy.json');
125
+ releaseManifest.policyPath = outsidePolicyPath;
126
+ writeFileSync(releaseManifestPath, JSON.stringify(releaseManifest));
127
+
128
+ assert.throws(
129
+ () => installPstack({ ...f.options, strict: true }, f.dependencies),
130
+ /Invalid release policy path/,
131
+ );
132
+ assert.equal(existsSync(outsidePolicyPath), false);
133
+ assert.equal(verifyInstallation(f.options).release, first.release);
134
+ });
135
+
88
136
  test('changed, added, and removed skills converge while old releases remain readable', t => {
89
137
  const f = fixture(t);
90
138
  put(join(f.checkout, 'pstack', 'skills', 'old', 'SKILL.md'), '---\nname: old\ndescription: Old skill.\n---\nOld body.\n');
@@ -1,8 +1,9 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
+ import { defaultSubagentModelPolicyPath } from '../scripts/subagent_model_policy.mjs';
6
7
  import {
7
8
  allPstackRoleRequirements,
8
9
  selectPstackDelegation,
@@ -29,14 +30,27 @@ const ALL_PSTACK_ROLES = [
29
30
  'interrogate reviewers',
30
31
  ];
31
32
 
32
- function writeHostPreferences(preferencesDirectory, host, modelsByRole) {
33
+ function writeHostPreferences(preferencesDirectory, host, modelsByRole, defaultEffort) {
33
34
  mkdirSync(preferencesDirectory, { recursive: true });
35
+ const preferences = { host, modelsByRole };
36
+ if (defaultEffort !== undefined) preferences.defaultEffort = defaultEffort;
34
37
  writeFileSync(
35
38
  join(preferencesDirectory, 'pstack-model-preferences.' + host + '.json'),
36
- JSON.stringify({ host, modelsByRole }),
39
+ JSON.stringify(preferences),
37
40
  );
38
41
  }
39
42
 
43
+ function writePolicyWithModels(preferencesDirectory, modelEntries) {
44
+ const policy = JSON.parse(readFileSync(defaultSubagentModelPolicyPath(), 'utf8'));
45
+ for (const [name, id] of Object.entries(modelEntries)) {
46
+ policy.models[name] = { id, aliases: [] };
47
+ policy.approvedPairs.push({ model: name, effort: 'medium' });
48
+ }
49
+ const policyPath = join(preferencesDirectory, 'subagent-model-policy.json');
50
+ writeFileSync(policyPath, JSON.stringify(policy));
51
+ return policyPath;
52
+ }
53
+
40
54
  function selectWithPreferences(preferencesDirectory, overrides) {
41
55
  return selectPstackDelegation({
42
56
  host: 'codex',
@@ -49,6 +63,7 @@ function selectWithPreferences(preferencesDirectory, overrides) {
49
63
  isAllowed: false,
50
64
  hasMaterialCapabilityLoss: false,
51
65
  },
66
+ requestedEffort: 'medium',
52
67
  preferencesDirectory,
53
68
  ...overrides,
54
69
  });
@@ -60,7 +75,7 @@ test('portable policy represents every pstack role without model ids', () => {
60
75
  assert.doesNotMatch(serializedRequirements, /gpt-|claude-|grok-/i);
61
76
  });
62
77
 
63
- test('Codex preferences select every available model in a diverse panel', () => {
78
+ test('Codex preferences skip an excluded pair in a panel', () => {
64
79
  const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-codex-'));
65
80
  try {
66
81
  writeHostPreferences(preferencesDirectory, 'codex', {
@@ -69,18 +84,26 @@ test('Codex preferences select every available model in a diverse panel', () =>
69
84
  const selection = selectWithPreferences(preferencesDirectory, {
70
85
  role: 'how critics',
71
86
  delegationIndex: 1,
72
- availableModelIds: ['gpt-5.6-sol', 'gpt-6-astra', 'gpt-5.6-terra'],
87
+ availableModelIds: [
88
+ 'gpt-5.6-sol',
89
+ 'gpt-6-astra',
90
+ 'gpt-5.6-terra',
91
+ 'gpt-5.6-luna',
92
+ ],
73
93
  });
74
94
  assert.equal(selection.canDelegate, true);
75
95
  assert.equal(selection.selectedHost, 'codex');
76
96
  assert.equal(selection.selectionSource, 'host-preference');
77
97
  assert.deepEqual(selection.panel.selectedModelIds, [
78
- 'gpt-5.6-terra',
79
- 'gpt-5.6-sol',
98
+ 'gpt-5.6-luna',
80
99
  'gpt-6-astra',
100
+ 'gpt-5.6-luna',
81
101
  ]);
82
- assert.equal(selection.panel.isCrossModelDiverse, true);
83
- assert.deepEqual(selection.nativeSpawnArguments, { model: 'gpt-5.6-sol' });
102
+ assert.equal(selection.panel.isCrossModelDiverse, false);
103
+ assert.deepEqual(selection.nativeSpawnArguments, {
104
+ model: 'gpt-6-astra',
105
+ reasoning_effort: 'medium',
106
+ });
84
107
  assert.equal(selection.omitNativeModelArgument, false);
85
108
  assert.equal(selection.availabilityValidated, true);
86
109
  } finally {
@@ -111,21 +134,23 @@ test('a foreign host reads only its own saved preferences', () => {
111
134
  }
112
135
  });
113
136
 
114
- test('an unavailable preferred model uses a confirmed host alternative', () => {
137
+ test('an unavailable preferred model stops before a confirmed host alternative', () => {
115
138
  const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-alternative-'));
116
139
  try {
117
140
  writeHostPreferences(preferencesDirectory, 'codex', {
118
- 'feature, refactoring': ['gpt-5.6-sol'],
141
+ 'feature, refactoring': ['gpt-5.6-terra'],
142
+ });
143
+ const policyPath = writePolicyWithModels(preferencesDirectory, {
144
+ current: 'confirmed-current-model',
119
145
  });
120
146
  const selection = selectWithPreferences(preferencesDirectory, {
121
147
  availableModelIds: ['confirmed-current-model'],
122
148
  confirmedSuitableModelIds: ['confirmed-current-model'],
149
+ policyPath,
123
150
  });
124
- assert.equal(selection.canDelegate, true);
125
- assert.equal(selection.selectionSource, 'confirmed-host-alternative');
126
- assert.deepEqual(selection.nativeSpawnArguments, {
127
- model: 'confirmed-current-model',
128
- });
151
+ assert.equal(selection.canDelegate, false);
152
+ assert.match(selection.failure, /model-routing-failed: replacement model is unavailable/);
153
+ assert.deepEqual(selection.nativeSpawnArguments, {});
129
154
  } finally {
130
155
  rmSync(preferencesDirectory, { recursive: true, force: true });
131
156
  }
@@ -143,7 +168,10 @@ test('the arena cross-judge pool selects one model other than the parent', () =>
143
168
  availableModelIds: ['gpt-5.6-sol', 'gpt-6-astra'],
144
169
  });
145
170
  assert.equal(selection.panel.agentCount, 1);
146
- assert.deepEqual(selection.nativeSpawnArguments, { model: 'gpt-6-astra' });
171
+ assert.deepEqual(selection.nativeSpawnArguments, {
172
+ model: 'gpt-6-astra',
173
+ reasoning_effort: 'medium',
174
+ });
147
175
  } finally {
148
176
  rmSync(preferencesDirectory, { recursive: true, force: true });
149
177
  }
@@ -154,6 +182,9 @@ test('the arena cross-judge prefers a real alternative over parent inheritance',
154
182
  writeHostPreferences(preferencesDirectory, 'codex', {
155
183
  'arena cross-judge pool': ['auto', 'confirmed-other-model'],
156
184
  });
185
+ const policyPath = writePolicyWithModels(preferencesDirectory, {
186
+ other: 'confirmed-other-model',
187
+ });
157
188
  const selection = selectWithPreferences(preferencesDirectory, {
158
189
  role: 'arena cross-judge pool',
159
190
  parentModelId: 'current-parent-model',
@@ -162,9 +193,11 @@ test('the arena cross-judge prefers a real alternative over parent inheritance',
162
193
  isAllowed: true,
163
194
  hasMaterialCapabilityLoss: false,
164
195
  },
196
+ policyPath,
165
197
  });
166
198
  assert.deepEqual(selection.nativeSpawnArguments, {
167
199
  model: 'confirmed-other-model',
200
+ reasoning_effort: 'medium',
168
201
  });
169
202
  assert.equal(selection.omitNativeModelArgument, false);
170
203
  } finally {
@@ -196,13 +229,16 @@ test('a same-model swarm reports its actual panel composition', () => {
196
229
  'gpt-5.6-luna',
197
230
  ]);
198
231
  assert.equal(selection.panel.isCrossModelDiverse, false);
199
- assert.deepEqual(selection.nativeSpawnArguments, { model: 'gpt-5.6-luna' });
232
+ assert.deepEqual(selection.nativeSpawnArguments, {
233
+ model: 'gpt-5.6-luna',
234
+ reasoning_effort: 'high',
235
+ });
200
236
  } finally {
201
237
  rmSync(preferencesDirectory, { recursive: true, force: true });
202
238
  }
203
239
  });
204
240
 
205
- test('a diversity-required panel fails without enough distinct real models', () => {
241
+ test('a diversity-required panel fails without enough distinct models', () => {
206
242
  const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-diversity-'));
207
243
  try {
208
244
  writeHostPreferences(preferencesDirectory, 'codex', {
@@ -214,7 +250,7 @@ test('a diversity-required panel fails without enough distinct real models', ()
214
250
  });
215
251
  assert.equal(selection.canDelegate, false);
216
252
  assert.equal(selection.requiresUserChoice, true);
217
- assert.equal(selection.failure, 'distinct-models-unavailable');
253
+ assert.match(selection.failure, /model-routing-failed:/);
218
254
  assert.equal(selection.panel.isCrossModelDiverse, false);
219
255
  assert.deepEqual(selection.nativeSpawnArguments, {});
220
256
  } finally {
@@ -244,6 +280,63 @@ test('parent inheritance omits the native model argument', () => {
244
280
  }
245
281
  });
246
282
 
283
+ test('parent inheritance rejects an explicit preference effort', () => {
284
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-parent-effort-'));
285
+ try {
286
+ writeHostPreferences(preferencesDirectory, 'codex', {
287
+ 'feature, refactoring': [{ model: 'auto', effort: 'medium' }],
288
+ });
289
+ const selection = selectWithPreferences(preferencesDirectory, {
290
+ availableModelIds: [],
291
+ parentFallback: {
292
+ isAllowed: true,
293
+ hasMaterialCapabilityLoss: false,
294
+ },
295
+ });
296
+ assert.equal(selection.canDelegate, false);
297
+ assert.equal(selection.failure, 'model-routing-failed: parent inheritance cannot set an effort');
298
+ assert.deepEqual(selection.panel.selectedModelIds, []);
299
+ assert.deepEqual(selection.nativeSpawnArguments, {});
300
+ } finally {
301
+ rmSync(preferencesDirectory, { recursive: true, force: true });
302
+ }
303
+ });
304
+
305
+ test('selector rejects policy-defined inheritance aliases in model inventories', () => {
306
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-inventory-alias-'));
307
+ try {
308
+ assert.throws(
309
+ () => selectWithPreferences(preferencesDirectory, {
310
+ availableModelIds: ['auto'],
311
+ confirmedSuitableModelIds: ['auto'],
312
+ parentFallback: {
313
+ isAllowed: true,
314
+ hasMaterialCapabilityLoss: false,
315
+ },
316
+ }),
317
+ /confirmed suitable model ids must contain native model ids/,
318
+ );
319
+ const policy = JSON.parse(readFileSync(defaultSubagentModelPolicyPath(), 'utf8'));
320
+ policy.inheritanceAliases = ['custom-parent'];
321
+ const policyPath = join(preferencesDirectory, 'subagent-model-policy.json');
322
+ writeFileSync(policyPath, JSON.stringify(policy));
323
+ assert.throws(
324
+ () => selectWithPreferences(preferencesDirectory, {
325
+ availableModelIds: ['custom-parent'],
326
+ confirmedSuitableModelIds: [],
327
+ parentFallback: {
328
+ isAllowed: true,
329
+ hasMaterialCapabilityLoss: false,
330
+ },
331
+ policyPath,
332
+ }),
333
+ /available model ids must contain native model ids/,
334
+ );
335
+ } finally {
336
+ rmSync(preferencesDirectory, { recursive: true, force: true });
337
+ }
338
+ });
339
+
247
340
  test('material capability loss requires a user choice before inheritance', () => {
248
341
  const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-parent-loss-'));
249
342
  try {
@@ -354,7 +447,7 @@ test('an optional-diversity review panel reports repeated models', () => {
354
447
  const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-review-panel-'));
355
448
  try {
356
449
  writeHostPreferences(preferencesDirectory, 'codex', {
357
- 'how critics': ['gpt-5.6-sol'],
450
+ 'how critics': ['gpt-5.6-luna'],
358
451
  });
359
452
  const selection = selectWithPreferences(preferencesDirectory, {
360
453
  role: 'how critics',
@@ -363,16 +456,272 @@ test('an optional-diversity review panel reports repeated models', () => {
363
456
  agentCount: 3,
364
457
  requiresDistinctModels: false,
365
458
  },
366
- availableModelIds: ['gpt-5.6-sol'],
459
+ availableModelIds: ['gpt-5.6-luna'],
367
460
  });
368
461
  assert.equal(selection.canDelegate, true);
369
462
  assert.deepEqual(selection.panel.selectedModelIds, [
370
- 'gpt-5.6-sol',
371
- 'gpt-5.6-sol',
372
- 'gpt-5.6-sol',
463
+ 'gpt-5.6-luna',
464
+ 'gpt-5.6-luna',
465
+ 'gpt-5.6-luna',
373
466
  ]);
374
467
  assert.equal(selection.panel.isCrossModelDiverse, false);
375
468
  } finally {
376
469
  rmSync(preferencesDirectory, { recursive: true, force: true });
377
470
  }
378
- });
471
+ });
472
+
473
+ test('a model-only preference still goes through policy validation', () => {
474
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-model-only-'));
475
+ try {
476
+ writeHostPreferences(preferencesDirectory, 'codex', {
477
+ 'feature, refactoring': ['unknown-model'],
478
+ });
479
+ const selection = selectWithPreferences(preferencesDirectory, {
480
+ availableModelIds: ['unknown-model'],
481
+ });
482
+ assert.equal(selection.canDelegate, false);
483
+ assert.match(selection.failure, /model-routing-failed: model is unknown/);
484
+ assert.deepEqual(selection.nativeSpawnArguments, {});
485
+ } finally {
486
+ rmSync(preferencesDirectory, { recursive: true, force: true });
487
+ }
488
+ });
489
+
490
+ test('a model-only Terra preference uses the host effort before routing', () => {
491
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-terra-default-'));
492
+ try {
493
+ writeHostPreferences(preferencesDirectory, 'codex', {
494
+ 'feature, refactoring': ['Terra'],
495
+ });
496
+ const selection = selectWithPreferences(preferencesDirectory, {
497
+ requestedEffort: 'medium',
498
+ availableModelIds: ['gpt-5.6-terra', 'gpt-5.6-luna'],
499
+ });
500
+ assert.equal(selection.canDelegate, true);
501
+ assert.deepEqual(selection.nativeSpawnArguments, {
502
+ model: 'gpt-5.6-luna',
503
+ reasoning_effort: 'xhigh',
504
+ });
505
+ } finally {
506
+ rmSync(preferencesDirectory, { recursive: true, force: true });
507
+ }
508
+ });
509
+
510
+ test('a profile default effort supplies a missing model-only effort', () => {
511
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-profile-default-'));
512
+ try {
513
+ writeHostPreferences(preferencesDirectory, 'codex', {
514
+ 'feature, refactoring': ['Terra'],
515
+ }, 'medium');
516
+ const selection = selectWithPreferences(preferencesDirectory, {
517
+ requestedEffort: undefined,
518
+ availableModelIds: ['gpt-5.6-terra', 'gpt-5.6-luna'],
519
+ });
520
+ assert.deepEqual(selection.nativeSpawnArguments, {
521
+ model: 'gpt-5.6-luna',
522
+ reasoning_effort: 'xhigh',
523
+ });
524
+ } finally {
525
+ rmSync(preferencesDirectory, { recursive: true, force: true });
526
+ }
527
+ });
528
+
529
+ test('a model-only worker Astra preference uses the host effort before routing', () => {
530
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-astra-default-'));
531
+ try {
532
+ writeHostPreferences(preferencesDirectory, 'codex', {
533
+ 'feature, refactoring': ['Astra'],
534
+ });
535
+ const selection = selectWithPreferences(preferencesDirectory, {
536
+ requestedEffort: 'high',
537
+ availableModelIds: ['gpt-6-astra'],
538
+ });
539
+ assert.equal(selection.canDelegate, true);
540
+ assert.deepEqual(selection.nativeSpawnArguments, {
541
+ model: 'gpt-6-astra',
542
+ reasoning_effort: 'low',
543
+ });
544
+ } finally {
545
+ rmSync(preferencesDirectory, { recursive: true, force: true });
546
+ }
547
+ });
548
+
549
+ test('a model-only preference holds when the host supplies no usable effort', () => {
550
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-missing-effort-'));
551
+ try {
552
+ writeHostPreferences(preferencesDirectory, 'codex', {
553
+ 'feature, refactoring': ['Terra'],
554
+ });
555
+ const selection = selectWithPreferences(preferencesDirectory, {
556
+ requestedEffort: undefined,
557
+ availableModelIds: ['gpt-5.6-terra'],
558
+ });
559
+ assert.equal(selection.canDelegate, false);
560
+ assert.match(selection.failure, /effort is required for this model/);
561
+ } finally {
562
+ rmSync(preferencesDirectory, { recursive: true, force: true });
563
+ }
564
+ });
565
+
566
+ test('non-Codex inheritance keeps its ordered parent fallback', () => {
567
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-parent-'));
568
+ try {
569
+ writeHostPreferences(preferencesDirectory, 'claude', {
570
+ 'feature, refactoring': ['auto', 'opus'],
571
+ });
572
+ const selection = selectPstackDelegation({
573
+ host: 'claude',
574
+ inventoryHost: 'claude',
575
+ role: 'feature, refactoring',
576
+ delegationIndex: 0,
577
+ availableModelIds: ['opus'],
578
+ confirmedSuitableModelIds: [],
579
+ parentFallback: {
580
+ isAllowed: true,
581
+ hasMaterialCapabilityLoss: false,
582
+ },
583
+ preferencesDirectory,
584
+ });
585
+ assert.equal(selection.canDelegate, true);
586
+ assert.equal(selection.omitNativeModelArgument, true);
587
+ assert.equal(selection.selectionSource, 'parent-inheritance');
588
+ assert.deepEqual(selection.panel.selectedModelIds, [null]);
589
+ } finally {
590
+ rmSync(preferencesDirectory, { recursive: true, force: true });
591
+ }
592
+ });
593
+
594
+ test('selector routes preference pairs before panel dedupe', () => {
595
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-'));
596
+ try {
597
+ writeHostPreferences(preferencesDirectory, 'codex', {
598
+ 'feature, refactoring': [
599
+ { model: 'gpt-5.6-terra', effort: 'medium' },
600
+ { model: 'gpt-5.6-luna', effort: 'medium' },
601
+ ],
602
+ });
603
+ const selection = selectWithPreferences(preferencesDirectory, {
604
+ panel: { agentCount: 2, requiresDistinctModels: false },
605
+ availableModelIds: ['gpt-5.6-terra', 'gpt-5.6-luna'],
606
+ });
607
+ assert.equal(selection.canDelegate, true);
608
+ assert.deepEqual(selection.panel.selectedModelIds, [
609
+ 'gpt-5.6-luna',
610
+ 'gpt-5.6-luna',
611
+ ]);
612
+ assert.deepEqual(selection.panel.selectedModelPairs, [
613
+ { model: 'gpt-5.6-luna', effort: 'xhigh' },
614
+ { model: 'gpt-5.6-luna', effort: 'xhigh' },
615
+ ]);
616
+ assert.equal(selection.panel.isCrossModelDiverse, false);
617
+ assert.deepEqual(selection.nativeSpawnArguments, {
618
+ model: 'gpt-5.6-luna',
619
+ reasoning_effort: 'xhigh',
620
+ });
621
+ } finally {
622
+ rmSync(preferencesDirectory, { recursive: true, force: true });
623
+ }
624
+ });
625
+
626
+ test('panel diversity uses selected models after routing', () => {
627
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-panel-'));
628
+ try {
629
+ writeHostPreferences(preferencesDirectory, 'codex', {
630
+ 'arena runners': [
631
+ { model: 'gpt-5.6-terra', effort: 'medium' },
632
+ { model: 'gpt-5.6-sol', effort: 'medium' },
633
+ { model: 'gpt-6-astra', effort: 'medium' },
634
+ ],
635
+ });
636
+ const selection = selectWithPreferences(preferencesDirectory, {
637
+ role: 'arena runners',
638
+ availableModelIds: [
639
+ 'gpt-5.6-terra',
640
+ 'gpt-5.6-sol',
641
+ 'gpt-6-astra',
642
+ 'gpt-5.6-luna',
643
+ ],
644
+ });
645
+ assert.equal(selection.canDelegate, false);
646
+ assert.equal(selection.failure, 'distinct-models-unavailable');
647
+ assert.deepEqual(selection.panel.selectedModelIds, [
648
+ 'gpt-5.6-luna',
649
+ 'gpt-6-astra',
650
+ ]);
651
+ assert.equal(selection.panel.isCrossModelDiverse, false);
652
+ assert.deepEqual(selection.routingDiagnostics, []);
653
+ } finally {
654
+ rmSync(preferencesDirectory, { recursive: true, force: true });
655
+ }
656
+ });
657
+
658
+ test('selector keeps other Sol remaps as contenders', () => {
659
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-sol-remap-'));
660
+ try {
661
+ writeHostPreferences(preferencesDirectory, 'codex', {
662
+ 'feature, refactoring': [{ model: 'gpt-5.6-sol', effort: 'high' }],
663
+ });
664
+ const selection = selectWithPreferences(preferencesDirectory, {
665
+ availableModelIds: ['gpt-5.6-sol', 'gpt-6-astra'],
666
+ });
667
+ assert.equal(selection.canDelegate, true);
668
+ assert.deepEqual(selection.panel.selectedModelIds, ['gpt-6-astra']);
669
+ assert.deepEqual(selection.panel.selectedModelPairs, [
670
+ { model: 'gpt-6-astra', effort: 'low' },
671
+ ]);
672
+ assert.deepEqual(selection.routingDiagnostics, []);
673
+ } finally {
674
+ rmSync(preferencesDirectory, { recursive: true, force: true });
675
+ }
676
+ });
677
+
678
+ test('unavailable routed models hold selection', () => {
679
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-unavailable-'));
680
+ try {
681
+ writeHostPreferences(preferencesDirectory, 'codex', {
682
+ 'feature, refactoring': [{ model: 'gpt-5.6-terra', effort: 'medium' }],
683
+ });
684
+ const selection = selectWithPreferences(preferencesDirectory, {
685
+ availableModelIds: ['gpt-5.6-terra'],
686
+ });
687
+ assert.equal(selection.canDelegate, false);
688
+ assert.equal(selection.requiresUserChoice, true);
689
+ assert.match(selection.failure, /model-routing-failed: replacement model is unavailable/);
690
+ assert.deepEqual(selection.nativeSpawnArguments, {});
691
+ } finally {
692
+ rmSync(preferencesDirectory, { recursive: true, force: true });
693
+ }
694
+ });
695
+
696
+ test('malformed routing policy holds selection with a diagnostic', () => {
697
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-routing-policy-'));
698
+ try {
699
+ writeHostPreferences(preferencesDirectory, 'codex', {
700
+ 'feature, refactoring': [{ model: 'gpt-5.6-terra', effort: 'medium' }],
701
+ });
702
+ const policyPath = join(preferencesDirectory, 'subagent-model-policy.json');
703
+ writeFileSync(policyPath, '{"schemaVersion":1}\n');
704
+ const selection = selectWithPreferences(preferencesDirectory, { policyPath });
705
+ assert.equal(selection.canDelegate, false);
706
+ assert.match(selection.failure, /model-routing-failed: models must be an object/);
707
+ assert.deepEqual(selection.nativeSpawnArguments, {});
708
+ } finally {
709
+ rmSync(preferencesDirectory, { recursive: true, force: true });
710
+ }
711
+ });
712
+
713
+ test('an explicit missing policy path holds selection', () => {
714
+ const preferencesDirectory = mkdtempSync(join(tmpdir(), 'pstack-missing-policy-'));
715
+ try {
716
+ writeHostPreferences(preferencesDirectory, 'codex', {
717
+ 'feature, refactoring': [{ model: 'Terra', effort: 'medium' }],
718
+ });
719
+ const selection = selectWithPreferences(preferencesDirectory, {
720
+ policyPath: join(preferencesDirectory, 'missing-policy.json'),
721
+ });
722
+ assert.equal(selection.canDelegate, false);
723
+ assert.match(selection.failure, /policy file is missing/);
724
+ } finally {
725
+ rmSync(preferencesDirectory, { recursive: true, force: true });
726
+ }
727
+ });