@akagilnc/pi-workflow-roles 0.1.2043 → 0.1.2052

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.
@@ -5,6 +5,9 @@ import { realpath } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
 
8
+ import {
9
+ assertLegalEngineName,
10
+ } from "../package-resources/engine-material.ts";
8
11
  import {
9
12
  effectiveSeatConfigurations,
10
13
  formatModelSpec,
@@ -14,6 +17,8 @@ import {
14
17
  resolveEffectiveSeat,
15
18
  savePublicCliConfig,
16
19
  setPersistentSeatConfig,
20
+ setPersistentSeatEngine,
21
+ validatePublicCliConfigEngines,
17
22
  type CredentialProviders,
18
23
  type EffectiveSeat,
19
24
  type InvocationModelOverride,
@@ -94,7 +99,8 @@ export const PUBLIC_GLOBAL_OPTIONS: readonly PublicOptionDefinition[] =
94
99
  type TakenPublicGlobalFlag =
95
100
  | { flag: "help"; consume: 1 }
96
101
  | { flag: "model"; consume: 1 | 2; value: string | undefined }
97
- | { flag: "thinking"; consume: 1 | 2; raw: string | undefined };
102
+ | { flag: "thinking"; consume: 1 | 2; raw: string | undefined }
103
+ | { flag: "engine"; consume: 1 | 2; value: string | undefined };
98
104
 
99
105
  /**
100
106
  * If `argv[index]` is a public global flag, describe its span and payload.
@@ -127,6 +133,13 @@ function takePublicGlobalFlag(
127
133
  raw: taken.value,
128
134
  };
129
135
  }
136
+ if (taken.def.id === "engine") {
137
+ return {
138
+ flag: "engine",
139
+ consume: consumed as 1 | 2,
140
+ value: taken.value,
141
+ };
142
+ }
130
143
  return undefined;
131
144
  }
132
145
 
@@ -217,6 +230,7 @@ type ParsedGlobal = {
217
230
  args: string[];
218
231
  model?: string;
219
232
  thinking?: PublicThinkingLevel;
233
+ engine?: string;
220
234
  help: boolean;
221
235
  };
222
236
 
@@ -231,6 +245,7 @@ function parseArgv(argv: readonly string[]): ParsedGlobal {
231
245
  const args = [...argv];
232
246
  let model: string | undefined;
233
247
  let thinking: PublicThinkingLevel | undefined;
248
+ let engine: string | undefined;
234
249
  let help = false;
235
250
  const positional: string[] = [];
236
251
  const globalOptions = createTypedOptionConsumer(PUBLIC_GLOBAL_OPTIONS);
@@ -259,12 +274,24 @@ function parseArgv(argv: readonly string[]): ParsedGlobal {
259
274
  args.splice(0, taken.consume);
260
275
  continue;
261
276
  }
262
- if (taken.raw === undefined) {
263
- throw new CliUsageError("--thinking requires a value");
277
+ if (taken.flag === "thinking") {
278
+ if (taken.raw === undefined) {
279
+ throw new CliUsageError("--thinking requires a value");
280
+ }
281
+ thinking = parseThinking(taken.raw);
282
+ args.splice(0, taken.consume);
283
+ continue;
284
+ }
285
+ if (taken.flag === "engine") {
286
+ if (taken.value === undefined) {
287
+ throw new CliUsageError("--engine requires a value");
288
+ }
289
+ engine = taken.value;
290
+ args.splice(0, taken.consume);
291
+ continue;
264
292
  }
265
- thinking = parseThinking(taken.raw);
266
- args.splice(0, taken.consume);
267
- continue;
293
+ // Exhaustive for known global flags; unknown id is a table bug.
294
+ throw new CliUsageError(`unhandled global option: ${String((taken as { flag: string }).flag)}`);
268
295
  }
269
296
  // Subcommands may own additional flags later; unknown dashed tokens stay
270
297
  // positional here (same as pre-unification parseArgv).
@@ -277,18 +304,54 @@ function parseArgv(argv: readonly string[]): ParsedGlobal {
277
304
  args: rest,
278
305
  ...(model === undefined ? {} : { model }),
279
306
  ...(thinking === undefined ? {} : { thinking }),
307
+ ...(engine === undefined ? {} : { engine }),
280
308
  help,
281
309
  };
282
310
  }
283
311
 
284
312
  function invocationFromParsed(parsed: ParsedGlobal): InvocationModelOverride | undefined {
285
- if (parsed.model === undefined && parsed.thinking === undefined) return undefined;
313
+ if (
314
+ parsed.model === undefined &&
315
+ parsed.thinking === undefined &&
316
+ parsed.engine === undefined
317
+ ) {
318
+ return undefined;
319
+ }
286
320
  return {
287
321
  ...(parsed.model === undefined ? {} : { model: parsed.model }),
288
322
  ...(parsed.thinking === undefined ? {} : { thinking: parsed.thinking }),
323
+ ...(parsed.engine === undefined ? {} : { engine: parsed.engine }),
289
324
  };
290
325
  }
291
326
 
327
+ function requireLegalEngineName(packageRoot: string, name: string): string {
328
+ try {
329
+ return assertLegalEngineName(packageRoot, name);
330
+ } catch (error) {
331
+ throw new CliUsageError(
332
+ error instanceof Error ? error.message : String(error),
333
+ { cause: error },
334
+ );
335
+ }
336
+ }
337
+
338
+ function loadAndValidateConfig(
339
+ home: string,
340
+ packageRoot: string,
341
+ ): Promise<PublicCliConfig> {
342
+ return loadPublicCliConfig(home).then((config) => {
343
+ try {
344
+ validatePublicCliConfigEngines(config, packageRoot);
345
+ } catch (error) {
346
+ throw new CliUsageError(
347
+ error instanceof Error ? error.message : String(error),
348
+ { cause: error },
349
+ );
350
+ }
351
+ return config;
352
+ });
353
+ }
354
+
292
355
  /** Typed facts used by help presentation — not layout. */
293
356
  export function helpDocument() {
294
357
  return {
@@ -355,6 +418,7 @@ function renderHelp(): string {
355
418
  "",
356
419
  "Role options: ak-role help <command>",
357
420
  "Persistent config: ak-role config set <seat> <provider/model:thinking>",
421
+ "Persistent engine (judge only): ak-role config set-engine judge <name> | unset-engine judge",
358
422
  "Effective seats: ak-role roles",
359
423
  );
360
424
  return `${lines.join("\n")}\n`;
@@ -398,7 +462,7 @@ function renderRoles(seats: readonly EffectiveSeat[]): string {
398
462
  }
399
463
 
400
464
  function renderConfig(config: PublicCliConfig): string {
401
- const lines: string[] = ["seat\tmodel"];
465
+ const lines: string[] = ["seat\tmodel\tengine"];
402
466
  const keys = Object.keys(config.seats) as (keyof typeof config.seats)[];
403
467
  if (keys.length === 0) {
404
468
  lines.push("(empty)");
@@ -406,7 +470,8 @@ function renderConfig(config: PublicCliConfig): string {
406
470
  for (const seat of keys.sort()) {
407
471
  const selection = config.seats[seat];
408
472
  if (selection === undefined) continue;
409
- lines.push(`${seat}\t${formatModelSpec(selection)}`);
473
+ const engine = selection.engine === undefined ? "-" : selection.engine;
474
+ lines.push(`${seat}\t${formatModelSpec(selection)}\t${engine}`);
410
475
  }
411
476
  }
412
477
  return `${lines.join("\n")}\n`;
@@ -415,10 +480,11 @@ function renderConfig(config: PublicCliConfig): string {
415
480
  async function runConfigCommand(
416
481
  args: readonly string[],
417
482
  home: string,
483
+ packageRoot: string,
418
484
  io: CliIo,
419
485
  ): Promise<number> {
420
486
  if (args.length === 0 || args[0] === "get" || args[0] === "list" || args[0] === "show") {
421
- const config = await loadPublicCliConfig(home);
487
+ const config = await loadAndValidateConfig(home, packageRoot);
422
488
  if (args[0] === "get" && args[1] !== undefined) {
423
489
  if (!isPublicConfigurableSeat(args[1])) {
424
490
  throw new CliUsageError(`unknown configurable seat: ${args[1]}`);
@@ -427,7 +493,9 @@ async function runConfigCommand(
427
493
  if (selection === undefined) {
428
494
  io.stdout(`${args[1]}\t(unconfigured)\n`);
429
495
  } else {
430
- io.stdout(`${args[1]}\t${formatModelSpec(selection)}\n`);
496
+ const engine =
497
+ selection.engine === undefined ? "-" : selection.engine;
498
+ io.stdout(`${args[1]}\t${formatModelSpec(selection)}\t${engine}\n`);
431
499
  }
432
500
  return 0;
433
501
  }
@@ -448,7 +516,7 @@ async function runConfigCommand(
448
516
  "config set requires seat/spec pairs: ak-role config set <seat> <spec> [<seat> <spec> ...]",
449
517
  );
450
518
  }
451
- let config = await loadPublicCliConfig(home);
519
+ let config = await loadAndValidateConfig(home, packageRoot);
452
520
  for (let i = 0; i < pairs.length; i += 2) {
453
521
  const seat = pairs[i]!;
454
522
  const spec = pairs[i + 1]!;
@@ -462,6 +530,66 @@ async function runConfigCommand(
462
530
  return 0;
463
531
  }
464
532
 
533
+ if (args[0] === "set-engine") {
534
+ if (args.length !== 3) {
535
+ throw new CliUsageError(
536
+ "usage: ak-role config set-engine judge <name>",
537
+ );
538
+ }
539
+ const seat = args[1]!;
540
+ const name = args[2]!;
541
+ if (!isPublicConfigurableSeat(seat)) {
542
+ throw new CliUsageError(`unknown configurable seat: ${seat}`);
543
+ }
544
+ if (seat !== "judge") {
545
+ throw new CliUsageError(
546
+ `engine axis is judge-only; refused seat ${seat}`,
547
+ );
548
+ }
549
+ requireLegalEngineName(packageRoot, name);
550
+ let config = await loadAndValidateConfig(home, packageRoot);
551
+ try {
552
+ config = setPersistentSeatEngine(config, seat, name);
553
+ } catch (error) {
554
+ throw new CliUsageError(
555
+ error instanceof Error ? error.message : String(error),
556
+ { cause: error },
557
+ );
558
+ }
559
+ await savePublicCliConfig(config, home);
560
+ io.stdout(renderConfig(config));
561
+ return 0;
562
+ }
563
+
564
+ if (args[0] === "unset-engine") {
565
+ if (args.length !== 2) {
566
+ throw new CliUsageError(
567
+ "usage: ak-role config unset-engine judge",
568
+ );
569
+ }
570
+ const seat = args[1]!;
571
+ if (!isPublicConfigurableSeat(seat)) {
572
+ throw new CliUsageError(`unknown configurable seat: ${seat}`);
573
+ }
574
+ if (seat !== "judge") {
575
+ throw new CliUsageError(
576
+ `engine axis is judge-only; refused seat ${seat}`,
577
+ );
578
+ }
579
+ let config = await loadAndValidateConfig(home, packageRoot);
580
+ try {
581
+ config = setPersistentSeatEngine(config, seat, undefined);
582
+ } catch (error) {
583
+ throw new CliUsageError(
584
+ error instanceof Error ? error.message : String(error),
585
+ { cause: error },
586
+ );
587
+ }
588
+ await savePublicCliConfig(config, home);
589
+ io.stdout(renderConfig(config));
590
+ return 0;
591
+ }
592
+
465
593
  throw new CliUsageError(`unknown config subcommand: ${args[0]}`);
466
594
  }
467
595
 
@@ -477,6 +605,21 @@ export async function runAkRole(
477
605
  // runtime entry, activation argv, or invocation provenance is derived.
478
606
  env = { ...env, packageRoot: await realpath(env.packageRoot) };
479
607
  const parsed = parseArgv(argv);
608
+ // Invocation --engine rejects at the call-request seam (not role submission).
609
+ // #356 MVP: engine axis is Judge-only (not resume / non-Judge seats).
610
+ if (parsed.engine !== undefined) {
611
+ requireLegalEngineName(env.packageRoot, parsed.engine);
612
+ if (
613
+ !parsed.help &&
614
+ parsed.command !== undefined &&
615
+ parsed.command !== "help" &&
616
+ parsed.command !== "judge"
617
+ ) {
618
+ throw new CliUsageError(
619
+ `engine axis is judge-only; refused command ${parsed.command}`,
620
+ );
621
+ }
622
+ }
480
623
 
481
624
  if (
482
625
  parsed.help ||
@@ -501,7 +644,7 @@ export async function runAkRole(
501
644
  if (parsed.args.length > 0) {
502
645
  throw new CliUsageError("roles takes no arguments");
503
646
  }
504
- const config = await loadPublicCliConfig(home);
647
+ const config = await loadAndValidateConfig(home, env.packageRoot);
505
648
  const credentials =
506
649
  env.credentials ??
507
650
  (await loadCredentialProviders(resolveAgentDir(env, home)));
@@ -515,7 +658,9 @@ export async function runAkRole(
515
658
  }
516
659
 
517
660
  if (parsed.command === "config") {
518
- return { exitCode: await runConfigCommand(parsed.args, home, io) };
661
+ return {
662
+ exitCode: await runConfigCommand(parsed.args, home, env.packageRoot, io),
663
+ };
519
664
  }
520
665
 
521
666
  // Resume reopens an exact Role run after a typed HTTP 429 (#108/#109).
@@ -523,7 +668,7 @@ export async function runAkRole(
523
668
  if (parsed.command === "resume") {
524
669
  const agentDir = resolveAgentDir(env, home);
525
670
  const cwd = env.cwd ?? process.cwd();
526
- const config = await loadPublicCliConfig(home);
671
+ const config = await loadAndValidateConfig(home, env.packageRoot);
527
672
  const credentials =
528
673
  env.credentials ?? (await loadCredentialProviders(agentDir));
529
674
  const resumeRunId = parsed.args[0];
@@ -706,7 +851,7 @@ export async function runAkRole(
706
851
  if (parsed.command === "judge") {
707
852
  const agentDir = resolveAgentDir(env, home);
708
853
  const cwd = env.cwd ?? process.cwd();
709
- const config = await loadPublicCliConfig(home);
854
+ const config = await loadAndValidateConfig(home, env.packageRoot);
710
855
  const credentials =
711
856
  env.credentials ?? (await loadCredentialProviders(agentDir));
712
857
  const seat = resolveEffectiveSeat(
@@ -728,6 +873,7 @@ export async function runAkRole(
728
873
  : { correlationId: env.correlationId }),
729
874
  ...(env.piRunner === undefined ? {} : { piRunner: env.piRunner }),
730
875
  ...(seat.selection === undefined ? {} : { model: seat.selection }),
876
+ ...(seat.engine === undefined ? {} : { engine: seat.engine }),
731
877
  ...(env.judgeExtraPiArgs === undefined
732
878
  ? {}
733
879
  : { extraPiArgs: env.judgeExtraPiArgs }),
@@ -749,7 +895,7 @@ export async function runAkRole(
749
895
  if (parsed.command === "coder") {
750
896
  const agentDir = resolveAgentDir(env, home);
751
897
  const cwd = env.cwd ?? process.cwd();
752
- const config = await loadPublicCliConfig(home);
898
+ const config = await loadAndValidateConfig(home, env.packageRoot);
753
899
  const credentials =
754
900
  env.credentials ?? (await loadCredentialProviders(agentDir));
755
901
  const seat = resolveEffectiveSeat(
@@ -792,7 +938,7 @@ export async function runAkRole(
792
938
  if (parsed.command === "fixer") {
793
939
  const agentDir = resolveAgentDir(env, home);
794
940
  const cwd = env.cwd ?? process.cwd();
795
- const config = await loadPublicCliConfig(home);
941
+ const config = await loadAndValidateConfig(home, env.packageRoot);
796
942
  const credentials =
797
943
  env.credentials ?? (await loadCredentialProviders(agentDir));
798
944
  const seat = resolveEffectiveSeat(
@@ -835,7 +981,7 @@ export async function runAkRole(
835
981
  if (parsed.command === "collector") {
836
982
  const agentDir = resolveAgentDir(env, home);
837
983
  const cwd = env.cwd ?? process.cwd();
838
- const config = await loadPublicCliConfig(home);
984
+ const config = await loadAndValidateConfig(home, env.packageRoot);
839
985
  const credentials =
840
986
  env.credentials ?? (await loadCredentialProviders(agentDir));
841
987
  const seat = resolveEffectiveSeat(
@@ -878,7 +1024,7 @@ export async function runAkRole(
878
1024
  if (parsed.command === "reviewer") {
879
1025
  const agentDir = resolveAgentDir(env, home);
880
1026
  const cwd = env.cwd ?? process.cwd();
881
- const config = await loadPublicCliConfig(home);
1027
+ const config = await loadAndValidateConfig(home, env.packageRoot);
882
1028
  const credentials =
883
1029
  env.credentials ?? (await loadCredentialProviders(agentDir));
884
1030
  const seat = resolveEffectiveSeat(
@@ -921,7 +1067,7 @@ export async function runAkRole(
921
1067
  if (parsed.command === "doctor") {
922
1068
  const agentDir = resolveAgentDir(env, home);
923
1069
  const cwd = env.cwd ?? process.cwd();
924
- const config = await loadPublicCliConfig(home);
1070
+ const config = await loadAndValidateConfig(home, env.packageRoot);
925
1071
  const credentials =
926
1072
  env.credentials ?? (await loadCredentialProviders(agentDir));
927
1073
  const seat = resolveEffectiveSeat(
@@ -964,7 +1110,7 @@ export async function runAkRole(
964
1110
  if (parsed.command === "merger") {
965
1111
  const agentDir = resolveAgentDir(env, home);
966
1112
  const cwd = env.cwd ?? process.cwd();
967
- const config = await loadPublicCliConfig(home);
1113
+ const config = await loadAndValidateConfig(home, env.packageRoot);
968
1114
  const credentials =
969
1115
  env.credentials ?? (await loadCredentialProviders(agentDir));
970
1116
  const seat = resolveEffectiveSeat(
@@ -5,6 +5,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
 
8
+ import { assertLegalEngineName } from "../package-resources/engine-material.ts";
8
9
  import {
9
10
  AUTOMATIC_NAVIGATOR_SEAT,
10
11
  PUBLIC_CALLABLE_ROLES,
@@ -22,23 +23,36 @@ export type CredentialProviders = {
22
23
 
23
24
  export type SeatModelConfig = ModelRef;
24
25
 
26
+ /** Persistent seat row: required model triple + optional engine axis (#356). */
27
+ export type PersistentSeatConfig = SeatModelConfig & {
28
+ engine?: string;
29
+ };
30
+
25
31
  export type PublicCliConfig = {
26
- seats: Partial<Record<PublicConfigurableSeat, SeatModelConfig>>;
32
+ seats: Partial<Record<PublicConfigurableSeat, PersistentSeatConfig>>;
27
33
  };
28
34
 
29
35
  export type EffectiveSource = "persistent" | "startup" | "invocation" | "unconfigured";
30
36
 
37
+ /** Engine axis source is independent of model source (#356). */
38
+ export type EngineSource = "invocation" | "persistent" | "unconfigured";
39
+
31
40
  export type EffectiveSeat = {
32
41
  seat: PublicConfigurableSeat;
33
42
  /** True when the seat is automatic Navigator attendance rather than caller-selected. */
34
43
  automatic: boolean;
35
44
  source: EffectiveSource;
36
45
  selection?: SeatModelConfig;
46
+ /** Selected engine name when configured; undefined = no engine (default path). */
47
+ engine?: string;
48
+ engineSource: EngineSource;
37
49
  };
38
50
 
39
51
  export type InvocationModelOverride = {
40
52
  model?: string;
41
53
  thinking?: PublicThinkingLevel;
54
+ /** Optional engine override for this invocation only (#356). */
55
+ engine?: string;
42
56
  };
43
57
 
44
58
  const THINKING_LEVELS = new Set<PublicThinkingLevel>([
@@ -89,14 +103,91 @@ export function setPersistentSeatConfig(
89
103
  seat: PublicConfigurableSeat,
90
104
  selection: SeatModelConfig,
91
105
  ): PublicCliConfig {
106
+ const previous = config.seats[seat];
92
107
  return {
93
108
  seats: {
94
109
  ...config.seats,
95
- [seat]: { ...selection },
110
+ [seat]: {
111
+ ...selection,
112
+ // Model rewrite preserves a previously configured engine axis.
113
+ ...(previous?.engine === undefined ? {} : { engine: previous.engine }),
114
+ },
96
115
  },
97
116
  };
98
117
  }
99
118
 
119
+ /**
120
+ * Set or clear persistent engine on the Judge seat only (#356 MVP).
121
+ * Engine-only seats are rejected — model triple remains required.
122
+ */
123
+ export function setPersistentSeatEngine(
124
+ config: PublicCliConfig,
125
+ seat: PublicConfigurableSeat,
126
+ engine: string | undefined,
127
+ ): PublicCliConfig {
128
+ if (seat !== "judge") {
129
+ throw new Error(`engine axis is judge-only; refused seat ${seat}`);
130
+ }
131
+ const previous = config.seats[seat];
132
+ if (previous === undefined) {
133
+ throw new Error(
134
+ `config seat ${seat} has no persistent model; set provider/model:thinking before engine`,
135
+ );
136
+ }
137
+ if (engine === undefined) {
138
+ const { engine: _dropped, ...modelOnly } = previous;
139
+ return {
140
+ seats: {
141
+ ...config.seats,
142
+ [seat]: modelOnly,
143
+ },
144
+ };
145
+ }
146
+ // Engine-name legality is owned solely by assertLegalEngineName
147
+ // (call-request + config-parse seams). Setter is pure seat mutation.
148
+ return {
149
+ seats: {
150
+ ...config.seats,
151
+ [seat]: { ...previous, engine },
152
+ },
153
+ };
154
+ }
155
+
156
+ /** Strip optional engine so activation model argv never sees the engine axis. */
157
+ export function seatModelOnly(seat: PersistentSeatConfig): SeatModelConfig {
158
+ return seat.thinking === undefined
159
+ ? { provider: seat.provider, model: seat.model }
160
+ : { provider: seat.provider, model: seat.model, thinking: seat.thinking };
161
+ }
162
+
163
+ /**
164
+ * Config-parse seam: engine axis is Judge-only; Judge engine names must exist
165
+ * in package materials. Call with packageRoot after load / before dispatch (#356).
166
+ * Legality authority = assertLegalEngineName (no injected duplicate).
167
+ */
168
+ export function validatePublicCliConfigEngines(
169
+ config: PublicCliConfig,
170
+ packageRoot: string,
171
+ ): void {
172
+ for (const seat of Object.keys(config.seats) as PublicConfigurableSeat[]) {
173
+ const row = config.seats[seat];
174
+ if (row?.engine === undefined) continue;
175
+ if (seat !== "judge") {
176
+ throw new Error(
177
+ `config seat ${seat} engine is not allowed; engine axis is judge-only`,
178
+ );
179
+ }
180
+ try {
181
+ assertLegalEngineName(packageRoot, row.engine);
182
+ } catch (error) {
183
+ throw new Error(
184
+ `config seat ${seat} engine is unknown: ${row.engine}`,
185
+ { cause: error },
186
+ );
187
+ }
188
+ }
189
+ }
190
+
100
191
  export function parseModelSpec(
101
192
  spec: string,
102
193
  fallbackThinking?: PublicThinkingLevel,
@@ -200,7 +291,7 @@ function parsePublicCliConfig(value: unknown): PublicCliConfig {
200
291
  return { seats };
201
292
  }
202
293
 
203
- function parseSeatModelConfig(value: unknown, seat: string): SeatModelConfig {
294
+ function parseSeatModelConfig(value: unknown, seat: string): PersistentSeatConfig {
204
295
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
205
296
  throw new Error(`config seat ${seat} must be an object`);
206
297
  }
@@ -217,11 +308,20 @@ function parseSeatModelConfig(value: unknown, seat: string): SeatModelConfig {
217
308
  ) {
218
309
  throw new Error(`config seat ${seat} requires a valid thinking level`);
219
310
  }
220
- return {
311
+ const parsed: PersistentSeatConfig = {
221
312
  provider: raw.provider,
222
313
  model: raw.model,
223
314
  thinking: raw.thinking as PublicThinkingLevel,
224
315
  };
316
+ if (raw.engine !== undefined) {
317
+ // Shape only: engine must be a string field. Name legality is deferred to
318
+ // validatePublicCliConfigEngines → assertLegalEngineName (single authority).
319
+ if (typeof raw.engine !== "string") {
320
+ throw new Error(`config seat ${seat} engine must be a string`);
321
+ }
322
+ parsed.engine = raw.engine;
323
+ }
324
+ return parsed;
225
325
  }
226
326
 
227
327
  export function providerConfigured(
@@ -258,6 +358,39 @@ function pickStartupCandidate(
258
358
  return undefined;
259
359
  }
260
360
 
361
+ function attachEngineAxis(
362
+ seat: EffectiveSeat,
363
+ config: PublicCliConfig,
364
+ invocation?: InvocationModelOverride,
365
+ ): EffectiveSeat {
366
+ // #356 MVP: engine axis is Judge-only. Other seats stay unconfigured.
367
+ if (seat.seat !== "judge") {
368
+ return {
369
+ ...seat,
370
+ engineSource: "unconfigured",
371
+ };
372
+ }
373
+ const persistentEngine = config.seats.judge?.engine;
374
+ if (invocation?.engine !== undefined) {
375
+ return {
376
+ ...seat,
377
+ engine: invocation.engine,
378
+ engineSource: "invocation",
379
+ };
380
+ }
381
+ if (persistentEngine !== undefined) {
382
+ return {
383
+ ...seat,
384
+ engine: persistentEngine,
385
+ engineSource: "persistent",
386
+ };
387
+ }
388
+ return {
389
+ ...seat,
390
+ engineSource: "unconfigured",
391
+ };
392
+ }
393
+
261
394
  function resolveBaseSeat(
262
395
  config: PublicCliConfig,
263
396
  seat: PublicConfigurableSeat,
@@ -270,7 +403,8 @@ function resolveBaseSeat(
270
403
  seat,
271
404
  automatic,
272
405
  source: "persistent",
273
- selection: { ...persistent },
406
+ selection: seatModelOnly(persistent),
407
+ engineSource: "unconfigured",
274
408
  };
275
409
  }
276
410
  const startup = pickStartupCandidate(seat, credentials);
@@ -280,9 +414,10 @@ function resolveBaseSeat(
280
414
  automatic,
281
415
  source: "startup",
282
416
  selection: startup,
417
+ engineSource: "unconfigured",
283
418
  };
284
419
  }
285
- return { seat, automatic, source: "unconfigured" };
420
+ return { seat, automatic, source: "unconfigured", engineSource: "unconfigured" };
286
421
  }
287
422
 
288
423
  export function resolveEffectiveSeat(
@@ -292,36 +427,46 @@ export function resolveEffectiveSeat(
292
427
  invocation?: InvocationModelOverride,
293
428
  ): EffectiveSeat {
294
429
  const automatic = seat === AUTOMATIC_NAVIGATOR_SEAT;
295
- const hasInvocation =
430
+ const hasModelInvocation =
296
431
  invocation !== undefined &&
297
432
  (invocation.model !== undefined || invocation.thinking !== undefined);
298
- if (!hasInvocation || invocation === undefined) {
299
- return resolveBaseSeat(config, seat, credentials);
300
- }
301
433
 
302
- if (invocation.model !== undefined) {
434
+ let modelSeat: EffectiveSeat;
435
+ if (!hasModelInvocation || invocation === undefined) {
436
+ modelSeat = resolveBaseSeat(config, seat, credentials);
437
+ } else if (invocation.model !== undefined) {
303
438
  const spec =
304
439
  invocation.model.includes(":") || invocation.thinking === undefined
305
440
  ? invocation.model
306
441
  : `${invocation.model}:${invocation.thinking}`;
307
- return {
442
+ modelSeat = {
308
443
  seat,
309
444
  automatic,
310
445
  source: "invocation",
311
446
  selection: parseModelSpec(spec),
447
+ engineSource: "unconfigured",
312
448
  };
449
+ } else {
450
+ const base = resolveBaseSeat(config, seat, credentials);
451
+ if (base.selection === undefined || invocation.thinking === undefined) {
452
+ modelSeat = {
453
+ seat,
454
+ automatic,
455
+ source: "unconfigured",
456
+ engineSource: "unconfigured",
457
+ };
458
+ } else {
459
+ modelSeat = {
460
+ seat,
461
+ automatic,
462
+ source: "invocation",
463
+ selection: { ...base.selection, thinking: invocation.thinking },
464
+ engineSource: "unconfigured",
465
+ };
466
+ }
313
467
  }
314
468
 
315
- const base = resolveBaseSeat(config, seat, credentials);
316
- if (base.selection === undefined || invocation.thinking === undefined) {
317
- return { seat, automatic, source: "unconfigured" };
318
- }
319
- return {
320
- seat,
321
- automatic,
322
- source: "invocation",
323
- selection: { ...base.selection, thinking: invocation.thinking },
324
- };
469
+ return attachEngineAxis(modelSeat, config, invocation);
325
470
  }
326
471
 
327
472
  export function effectiveSeatConfigurations(