@akagilnc/pi-workflow-roles 0.1.2020 → 0.1.2033

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,12 +5,18 @@
5
5
  * Real producing onUpdate resets; final resolve/reject clears; timeout throws so
6
6
  * Pi settles the current call as an LLM-visible isError tool result. No retry,
7
7
  * role failure, process termination, signal abort, config, or Pi built-in coverage.
8
+ *
9
+ * #339: do not name-exempt whole terminating tools. Outer idle stays armed for
10
+ * pre/post-audit work. Only the real compliance-audit await suspends this single
11
+ * layer (ADR 0059 owns that interval); resume re-arms the same outer backstop.
8
12
  */
13
+ import { AsyncLocalStorage } from "node:async_hooks";
9
14
  import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
15
 
11
16
  import {
12
17
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
13
18
  createStreamIdleGuard,
19
+ type StreamIdleGuard,
14
20
  } from "./stream-idle-guard.ts";
15
21
  import { isProducingToolUpdate } from "./tool-execution-observation.ts";
16
22
 
@@ -19,6 +25,17 @@ export const PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_CODE = "AK_PACKAGE_OWNED_TOOL_IDLE_
19
25
 
20
26
  const WRAPPED = Symbol.for("ak.packageOwnedToolIdleWrapped");
21
27
 
28
+ type PackageOwnedToolIdleSuspension = {
29
+ suspend(): void;
30
+ resume(): void;
31
+ };
32
+
33
+ /**
34
+ * Active outer package-owned execute idle, if any. Nested tool executes install
35
+ * their own store; compliance audit only suspends the store visible at await time.
36
+ */
37
+ const packageOwnedToolIdleScope = new AsyncLocalStorage<PackageOwnedToolIdleSuspension>();
38
+
22
39
  export class PackageOwnedToolIdleTimeoutError extends Error {
23
40
  readonly code = PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_CODE;
24
41
  readonly idleTimeoutMs = PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_MS;
@@ -52,6 +69,24 @@ function isPackageOwnedToolActivityUpdate(partialResult: unknown): boolean {
52
69
  return true;
53
70
  }
54
71
 
72
+ /**
73
+ * #339: suspend the active package-owned execute idle for one real compliance
74
+ * audit await. Single-layer only — production has one runComplianceAudit seam.
75
+ * No-op outside a wrapped package-owned execute. No second timeout or retry.
76
+ */
77
+ export async function withPackageOwnedToolIdleSuspended<T>(
78
+ run: () => Promise<T>,
79
+ ): Promise<T> {
80
+ const scope = packageOwnedToolIdleScope.getStore();
81
+ if (scope === undefined) return run();
82
+ scope.suspend();
83
+ try {
84
+ return await run();
85
+ } finally {
86
+ scope.resume();
87
+ }
88
+ }
89
+
55
90
  /**
56
91
  * Single shared execute wrapper for package-owned tool definitions.
57
92
  * Idempotent: wrapping twice returns the same protected definition.
@@ -72,7 +107,8 @@ export function wrapPackageOwnedToolDefinition<T extends PackageOwnedToolLike>(t
72
107
  const onUpdate = args[3] as ((partialResult: unknown) => void) | undefined;
73
108
  return new Promise((resolve, reject) => {
74
109
  let settled = false;
75
- const idle = createStreamIdleGuard({
110
+ let suspended = false;
111
+ let idle: StreamIdleGuard = createStreamIdleGuard({
76
112
  idleTimeoutMs: PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_MS,
77
113
  });
78
114
 
@@ -88,6 +124,25 @@ export function wrapPackageOwnedToolDefinition<T extends PackageOwnedToolLike>(t
88
124
  };
89
125
  idle.signal.addEventListener("abort", onIdle, { once: true });
90
126
 
127
+ const suspension: PackageOwnedToolIdleSuspension = {
128
+ suspend(): void {
129
+ if (settled || suspended) return;
130
+ suspended = true;
131
+ // ADR 0059 owns the audit interval — drop this layer until audit returns.
132
+ idle.signal.removeEventListener("abort", onIdle);
133
+ idle.dispose();
134
+ },
135
+ resume(): void {
136
+ if (settled || !suspended) return;
137
+ suspended = false;
138
+ // Fresh single-layer silence window for post-audit work (e.g. cleanup).
139
+ idle = createStreamIdleGuard({
140
+ idleTimeoutMs: PACKAGE_OWNED_TOOL_IDLE_TIMEOUT_MS,
141
+ });
142
+ idle.signal.addEventListener("abort", onIdle, { once: true });
143
+ },
144
+ };
145
+
91
146
  const guardedOnUpdate = onUpdate === undefined
92
147
  ? undefined
93
148
  : (partialResult: unknown) => {
@@ -101,12 +156,14 @@ export function wrapPackageOwnedToolDefinition<T extends PackageOwnedToolLike>(t
101
156
  callArgs[2] = signal;
102
157
  callArgs[3] = guardedOnUpdate;
103
158
 
104
- void Promise.resolve()
105
- .then(() => originalExecute(...callArgs))
106
- .then(
107
- (result) => settle(() => resolve(result)),
108
- (error: unknown) => settle(() => reject(error)),
109
- );
159
+ void packageOwnedToolIdleScope.run(suspension, async () => {
160
+ try {
161
+ const result = await originalExecute(...callArgs);
162
+ settle(() => resolve(result));
163
+ } catch (error) {
164
+ settle(() => reject(error));
165
+ }
166
+ });
110
167
  });
111
168
  };
112
169
  (wrappedExecute as { [WRAPPED]?: boolean })[WRAPPED] = true;
@@ -10,7 +10,7 @@ import {
10
10
  formatModelSpec,
11
11
  loadCredentialProviders,
12
12
  loadPublicCliConfig,
13
- parseModelSpec,
13
+ parsePersistentModelSpec,
14
14
  resolveEffectiveSeat,
15
15
  savePublicCliConfig,
16
16
  setPersistentSeatConfig,
@@ -402,7 +402,7 @@ async function runConfigCommand(
402
402
  if (!isPublicConfigurableSeat(seat)) {
403
403
  throw new CliUsageError(`unknown configurable seat: ${seat}`);
404
404
  }
405
- config = setPersistentSeatConfig(config, seat, parseModelSpec(spec));
405
+ config = setPersistentSeatConfig(config, seat, parsePersistentModelSpec(spec));
406
406
  }
407
407
  await savePublicCliConfig(config, home);
408
408
  io.stdout(renderConfig(config));
@@ -24,6 +24,7 @@ import {
24
24
  type AdmittedCoderInvocation,
25
25
  } from "./invocation.ts";
26
26
  import {
27
+ buildSeatModelCliArgs,
27
28
  type CredentialProviders,
28
29
  type SeatModelConfig,
29
30
  } from "./config.ts";
@@ -84,17 +85,6 @@ export type CoderRunEnv = {
84
85
  timeoutMs?: number;
85
86
  };
86
87
 
87
- function buildModelArgs(model: SeatModelConfig | undefined): string[] {
88
- if (model === undefined) return [];
89
- return [
90
- "--provider",
91
- model.provider,
92
- "--model",
93
- model.model,
94
- "--thinking",
95
- model.thinking,
96
- ];
97
- }
98
88
 
99
89
  /**
100
90
  * Build Internal activation extra-args for an admitted Coder run.
@@ -136,7 +126,7 @@ export function buildCoderActivationExtraArgs(
136
126
  admitted.taskPath,
137
127
  "--mode",
138
128
  "json",
139
- ...buildModelArgs(options.model),
129
+ ...buildSeatModelCliArgs(options.model),
140
130
  prompt,
141
131
  ];
142
132
  }
@@ -179,7 +169,7 @@ export function buildCoderResumeActivationExtraArgs(
179
169
  admitted.taskPath,
180
170
  "--mode",
181
171
  "json",
182
- ...buildModelArgs(options.model),
172
+ ...buildSeatModelCliArgs(options.model),
183
173
  RESUME_TRANSPORT_ENVELOPE,
184
174
  ];
185
175
  }
@@ -304,7 +294,7 @@ async function dispatchAdmittedCoder(input: {
304
294
  io,
305
295
  );
306
296
  }
307
- await markRunRunning(admitted.runDirectory);
297
+ await markRunRunning(admitted.runDirectory, env.model);
308
298
  await clearTypedProviderHttpObservation(admitted.runDirectory);
309
299
 
310
300
  const childEnv: NodeJS.ProcessEnv = {
@@ -434,6 +424,7 @@ export async function runPublicCoder(
434
424
  attachmentPaths: parsed.attachmentPaths,
435
425
  ...(parsed.project === undefined ? {} : { project: parsed.project }),
436
426
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
427
+ ...(env.model === undefined ? {} : { model: env.model }),
437
428
  });
438
429
  } catch (error) {
439
430
  if (error instanceof CliUsageError) {
@@ -20,6 +20,7 @@ import {
20
20
  type ParseCollectorArgvResult,
21
21
  } from "./invocation.ts";
22
22
  import {
23
+ buildSeatModelCliArgs,
23
24
  type CredentialProviders,
24
25
  type SeatModelConfig,
25
26
  } from "./config.ts";
@@ -69,17 +70,6 @@ export type CollectorRunEnv = {
69
70
  timeoutMs?: number;
70
71
  };
71
72
 
72
- function buildModelArgs(model: SeatModelConfig | undefined): string[] {
73
- if (model === undefined) return [];
74
- return [
75
- "--provider",
76
- model.provider,
77
- "--model",
78
- model.model,
79
- "--thinking",
80
- model.thinking,
81
- ];
82
- }
83
73
 
84
74
  /**
85
75
  * Build Internal activation extra-args for an admitted Collector run.
@@ -114,7 +104,7 @@ export function buildCollectorActivationExtraArgs(
114
104
  : ["--ak-collector-request-manifest", admitted.requestManifestPath]),
115
105
  "--mode",
116
106
  "json",
117
- ...buildModelArgs(options.model),
107
+ ...buildSeatModelCliArgs(options.model),
118
108
  prompt,
119
109
  ];
120
110
  }
@@ -202,7 +192,7 @@ async function dispatchAdmittedCollector(input: {
202
192
  io,
203
193
  );
204
194
  }
205
- await markRunRunning(admitted.runDirectory);
195
+ await markRunRunning(admitted.runDirectory, env.model);
206
196
  await clearTypedProviderHttpObservation(admitted.runDirectory);
207
197
 
208
198
  const childEnv: NodeJS.ProcessEnv = {
@@ -340,6 +330,7 @@ export async function runPublicCollector(
340
330
  ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
341
331
  ...(parsed.requestManifestPath === undefined ? {} : { requestManifestPath: parsed.requestManifestPath }),
342
332
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
333
+ ...(env.model === undefined ? {} : { model: env.model }),
343
334
  });
344
335
  } catch (error) {
345
336
  if (error instanceof CliUsageError) {
@@ -108,12 +108,18 @@ export function parseModelSpec(
108
108
  const thinkingSplit = trimmed.lastIndexOf(":");
109
109
  let modelPart = trimmed;
110
110
  let thinking: PublicThinkingLevel | undefined = fallbackThinking;
111
- if (thinkingSplit > 0) {
111
+ // #346: no colon → bare provider/model is legal. Colon present (any index,
112
+ // including 0) → suffix must be a typed PublicThinkingLevel (empty/unknown
113
+ // stay format rejects; never swallow ":…" into the model name).
114
+ if (thinkingSplit !== -1) {
112
115
  const maybeThinking = trimmed.slice(thinkingSplit + 1);
113
- if (THINKING_LEVELS.has(maybeThinking as PublicThinkingLevel)) {
114
- thinking = maybeThinking as PublicThinkingLevel;
115
- modelPart = trimmed.slice(0, thinkingSplit);
116
+ if (!THINKING_LEVELS.has(maybeThinking as PublicThinkingLevel)) {
117
+ throw new Error(
118
+ `model specification must be provider/model[:thinking], got ${spec}`,
119
+ );
116
120
  }
121
+ thinking = maybeThinking as PublicThinkingLevel;
122
+ modelPart = trimmed.slice(0, thinkingSplit);
117
123
  }
118
124
  const slash = modelPart.indexOf("/");
119
125
  if (slash <= 0 || slash === modelPart.length - 1) {
@@ -123,16 +129,48 @@ export function parseModelSpec(
123
129
  }
124
130
  const provider = modelPart.slice(0, slash);
125
131
  const model = modelPart.slice(slash + 1);
126
- if (!thinking) {
132
+ // #346: bare provider/model is legal on invocation — do not invent thinking.
133
+ // Persistent config set still requires thinking via parsePersistentModelSpec.
134
+ return thinking === undefined
135
+ ? { provider, model }
136
+ : { provider, model, thinking };
137
+ }
138
+
139
+ /** Persistent seat config keeps the three-part provider/model:thinking grammar. */
140
+ export function parsePersistentModelSpec(spec: string): SeatModelConfig & {
141
+ thinking: PublicThinkingLevel;
142
+ } {
143
+ const parsed = parseModelSpec(spec);
144
+ if (parsed.thinking === undefined) {
127
145
  throw new Error(
128
146
  `model specification requires a thinking level (provider/model:thinking), got ${spec}`,
129
147
  );
130
148
  }
131
- return { provider, model, thinking };
149
+ return {
150
+ provider: parsed.provider,
151
+ model: parsed.model,
152
+ thinking: parsed.thinking,
153
+ };
132
154
  }
133
155
 
134
156
  export function formatModelSpec(selection: SeatModelConfig): string {
135
- return `${selection.provider}/${selection.model}:${selection.thinking}`;
157
+ const base = `${selection.provider}/${selection.model}`;
158
+ return selection.thinking === undefined ? base : `${base}:${selection.thinking}`;
159
+ }
160
+
161
+ /**
162
+ * Single source: seat model selection → Pi argv at the public CLI execution seam.
163
+ * Bare provider/model omits --thinking so pi/model defaults apply; suffix passes through.
164
+ */
165
+ export function buildSeatModelCliArgs(model: SeatModelConfig | undefined): string[] {
166
+ if (model === undefined) return [];
167
+ return [
168
+ "--provider",
169
+ model.provider,
170
+ "--model",
171
+ model.model,
172
+ ...(model.thinking === undefined ? [] : ["--thinking", model.thinking]),
173
+ ];
136
174
  }
137
175
 
138
176
  function parsePublicCliConfig(value: unknown): PublicCliConfig {
@@ -20,6 +20,7 @@ import {
20
20
  type ParseDoctorArgvResult,
21
21
  } from "./invocation.ts";
22
22
  import {
23
+ buildSeatModelCliArgs,
23
24
  type CredentialProviders,
24
25
  type SeatModelConfig,
25
26
  } from "./config.ts";
@@ -70,17 +71,6 @@ export type DoctorRunEnv = {
70
71
  timeoutMs?: number;
71
72
  };
72
73
 
73
- function buildModelArgs(model: SeatModelConfig | undefined): string[] {
74
- if (model === undefined) return [];
75
- return [
76
- "--provider",
77
- model.provider,
78
- "--model",
79
- model.model,
80
- "--thinking",
81
- model.thinking,
82
- ];
83
- }
84
74
 
85
75
  /**
86
76
  * Build Internal activation extra-args for an admitted Doctor run.
@@ -111,7 +101,7 @@ export function buildDoctorActivationExtraArgs(
111
101
  admitted.caseRunsPath,
112
102
  "--mode",
113
103
  "json",
114
- ...buildModelArgs(options.model),
104
+ ...buildSeatModelCliArgs(options.model),
115
105
  prompt,
116
106
  ];
117
107
  }
@@ -183,7 +173,7 @@ async function dispatchAdmittedDoctor(input: {
183
173
  io,
184
174
  );
185
175
  }
186
- await markRunRunning(admitted.runDirectory);
176
+ await markRunRunning(admitted.runDirectory, env.model);
187
177
  await clearTypedProviderHttpObservation(admitted.runDirectory);
188
178
 
189
179
  const childEnv: NodeJS.ProcessEnv = {
@@ -319,6 +309,7 @@ export async function runPublicDoctor(
319
309
  ...(parsed.project === undefined ? {} : { project: parsed.project }),
320
310
  ...(parsed.runs === undefined ? {} : { runs: parsed.runs }),
321
311
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
312
+ ...(env.model === undefined ? {} : { model: env.model }),
322
313
  });
323
314
  } catch (error) {
324
315
  if (error instanceof CliUsageError) {
@@ -26,6 +26,7 @@ import {
26
26
  type AdmittedFixerInvocation,
27
27
  } from "./invocation.ts";
28
28
  import {
29
+ buildSeatModelCliArgs,
29
30
  type CredentialProviders,
30
31
  type SeatModelConfig,
31
32
  } from "./config.ts";
@@ -87,17 +88,6 @@ export type FixerRunEnv = {
87
88
  timeoutMs?: number;
88
89
  };
89
90
 
90
- function buildModelArgs(model: SeatModelConfig | undefined): string[] {
91
- if (model === undefined) return [];
92
- return [
93
- "--provider",
94
- model.provider,
95
- "--model",
96
- model.model,
97
- "--thinking",
98
- model.thinking,
99
- ];
100
- }
101
91
 
102
92
  /**
103
93
  * Build Internal activation extra-args for an admitted Fixer run.
@@ -145,7 +135,7 @@ export function buildFixerActivationExtraArgs(
145
135
  ...prerequisiteArgs,
146
136
  "--mode",
147
137
  "json",
148
- ...buildModelArgs(options.model),
138
+ ...buildSeatModelCliArgs(options.model),
149
139
  prompt,
150
140
  ];
151
141
  }
@@ -194,7 +184,7 @@ export function buildFixerResumeActivationExtraArgs(
194
184
  ...prerequisiteArgs,
195
185
  "--mode",
196
186
  "json",
197
- ...buildModelArgs(options.model),
187
+ ...buildSeatModelCliArgs(options.model),
198
188
  RESUME_TRANSPORT_ENVELOPE,
199
189
  ];
200
190
  }
@@ -303,7 +293,7 @@ async function dispatchAdmittedFixer(input: {
303
293
  io,
304
294
  );
305
295
  }
306
- await markRunRunning(admitted.runDirectory);
296
+ await markRunRunning(admitted.runDirectory, env.model);
307
297
  await clearTypedProviderHttpObservation(admitted.runDirectory);
308
298
 
309
299
  const childEnv: NodeJS.ProcessEnv = {
@@ -461,6 +451,7 @@ export async function runPublicFixer(
461
451
  : { prerequisitesPath: parsed.prerequisitesPath }),
462
452
  ...(parsed.project === undefined ? {} : { project: parsed.project }),
463
453
  ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
454
+ ...(env.model === undefined ? {} : { model: env.model }),
464
455
  });
465
456
  } catch (error) {
466
457
  if (error instanceof CliUsageError) {
@@ -172,14 +172,39 @@ type RoleInvocationLedgerSource = Pick<
172
172
  "runId" | "bookKey" | "projectRoot" | "runDirectory" | "sessionDirectory" | "sessionFile" | "correlationId" | "ticketNumber"
173
173
  >;
174
174
 
175
+ /**
176
+ * Effective provider/model selection recorded on the invocation identity page.
177
+ * thinking is present only when the caller/seat supplied it — bare model omits it.
178
+ */
179
+ export type InvocationEffectiveModel = {
180
+ readonly provider: string;
181
+ readonly model: string;
182
+ readonly thinking?: string;
183
+ };
184
+
185
+ /** Project effective model onto ledger fields; absent thinking stays absent. */
186
+ function effectiveModelLedgerFields(
187
+ model: InvocationEffectiveModel | undefined,
188
+ ): Record<string, string> {
189
+ if (model === undefined) return {};
190
+ return {
191
+ provider: model.provider,
192
+ model: model.model,
193
+ ...(model.thinking === undefined ? {} : { thinking: model.thinking }),
194
+ };
195
+ }
196
+
175
197
  /**
176
198
  * Persist one `invocation.json` identity page for the public run.
177
199
  * Admission is the sole source for every field; this is the only identity
178
200
  * projection and callers never provide an independent ledger shape.
201
+ * When an effective model is known at admission, provider/model (and thinking
202
+ * only when supplied) are written onto the same page.
179
203
  */
180
204
  async function writeRoleInvocationLedger(
181
205
  source: RoleInvocationLedgerSource,
182
206
  role: AdmittedRoleInvocation["role"],
207
+ effectiveModel?: InvocationEffectiveModel,
183
208
  ): Promise<void> {
184
209
  const identity = {
185
210
  role,
@@ -191,6 +216,7 @@ async function writeRoleInvocationLedger(
191
216
  sessionFile: source.sessionFile,
192
217
  ...(source.correlationId === undefined ? {} : { correlationId: source.correlationId }),
193
218
  ...(source.ticketNumber === undefined ? {} : { ticketNumber: source.ticketNumber }),
219
+ ...effectiveModelLedgerFields(effectiveModel),
194
220
  };
195
221
  await writeFile(
196
222
  join(source.runDirectory, "invocation.json"),
@@ -199,6 +225,37 @@ async function writeRoleInvocationLedger(
199
225
  );
200
226
  }
201
227
 
228
+ /**
229
+ * Merge the effective launch model onto the existing invocation identity page
230
+ * (resume / temporary override path — same field shape as admission write).
231
+ * Bare model clears any prior thinking key so absence stays honest.
232
+ */
233
+ export async function recordEffectiveInvocationModel(
234
+ runDirectory: string,
235
+ model: InvocationEffectiveModel,
236
+ ): Promise<void> {
237
+ const ledgerPath = join(runDirectory, "invocation.json");
238
+ const current = JSON.parse(await readFile(ledgerPath, "utf8")) as Record<
239
+ string,
240
+ unknown
241
+ >;
242
+ const next: Record<string, unknown> = {
243
+ ...current,
244
+ provider: model.provider,
245
+ model: model.model,
246
+ };
247
+ if (model.thinking === undefined) {
248
+ delete next.thinking;
249
+ } else {
250
+ next.thinking = model.thinking;
251
+ }
252
+ await writeFile(
253
+ ledgerPath,
254
+ `${JSON.stringify(next, null, 2)}\n`,
255
+ "utf8",
256
+ );
257
+ }
258
+
202
259
  /** Merge observed launch-time fields into the single existing invocation.json identity page. */
203
260
  async function mergeInvocationIdentityPage(
204
261
  runDirectory: string,
@@ -686,6 +743,8 @@ export type AdmitJudgeInvocationOptions = {
686
743
  project?: string;
687
744
  /** Injectable clock/id for tests. */
688
745
  createRunId?: () => string;
746
+ /** Effective model for this invocation — written onto invocation.json. */
747
+ model?: InvocationEffectiveModel;
689
748
  };
690
749
 
691
750
  /**
@@ -736,7 +795,7 @@ export async function admitJudgeInvocation(
736
795
  };
737
796
  const admittedRequestPath = join(runDirectory, "admitted-request.json");
738
797
  await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
739
- await writeRoleInvocationLedger(admitted, admitted.role);
798
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
740
799
 
741
800
  return {
742
801
  role: "judge",
@@ -811,6 +870,8 @@ export type AdmitCoderInvocationOptions = {
811
870
  attachmentPaths: readonly string[];
812
871
  project?: string;
813
872
  createRunId?: () => string;
873
+ /** Effective model for this invocation — written onto invocation.json. */
874
+ model?: InvocationEffectiveModel;
814
875
  };
815
876
 
816
877
  /**
@@ -874,7 +935,7 @@ export async function admitCoderInvocation(
874
935
  };
875
936
  const admittedRequestPath = join(runDirectory, "admitted-request.json");
876
937
  await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
877
- await writeRoleInvocationLedger(admitted, admitted.role);
938
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
878
939
 
879
940
  return {
880
941
  role: "coder",
@@ -923,6 +984,8 @@ export type AdmitFixerInvocationOptions = {
923
984
  prerequisitesPath?: string;
924
985
  project?: string;
925
986
  createRunId?: () => string;
987
+ /** Effective model for this invocation — written onto invocation.json. */
988
+ model?: InvocationEffectiveModel;
926
989
  };
927
990
 
928
991
  /**
@@ -1026,7 +1089,7 @@ export async function admitFixerInvocation(
1026
1089
  };
1027
1090
  const admittedRequestPath = join(runDirectory, "admitted-request.json");
1028
1091
  await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
1029
- await writeRoleInvocationLedger(admitted, admitted.role);
1092
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
1030
1093
 
1031
1094
  return {
1032
1095
  role: "fixer",
@@ -1188,6 +1251,8 @@ export type AdmitCollectorInvocationOptions = {
1188
1251
  /** Optional public request configuration; copied into the admitted run. */
1189
1252
  requestManifestPath?: string;
1190
1253
  createRunId?: () => string;
1254
+ /** Effective model for this invocation — written onto invocation.json. */
1255
+ model?: InvocationEffectiveModel;
1191
1256
  };
1192
1257
 
1193
1258
  /**
@@ -1286,7 +1351,7 @@ export async function admitCollectorInvocation(
1286
1351
  `${JSON.stringify(admitted, null, 2)}\n`,
1287
1352
  "utf8",
1288
1353
  );
1289
- await writeRoleInvocationLedger(admitted, admitted.role);
1354
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
1290
1355
 
1291
1356
  return {
1292
1357
  role: "collector",
@@ -1440,6 +1505,8 @@ export type AdmitDoctorInvocationOptions = {
1440
1505
  attachmentPaths?: readonly string[];
1441
1506
  project?: string;
1442
1507
  createRunId?: () => string;
1508
+ /** Effective model for this invocation — written onto invocation.json. */
1509
+ model?: InvocationEffectiveModel;
1443
1510
  };
1444
1511
 
1445
1512
  /**
@@ -1619,7 +1686,7 @@ export async function admitDoctorInvocation(
1619
1686
  `${JSON.stringify(admitted, null, 2)}\n`,
1620
1687
  "utf8",
1621
1688
  );
1622
- await writeRoleInvocationLedger(admitted, admitted.role);
1689
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
1623
1690
 
1624
1691
  return {
1625
1692
  role: "doctor",
@@ -1730,6 +1797,8 @@ export type AdmitReviewerInvocationOptions = {
1730
1797
  authorityRefs?: readonly string[];
1731
1798
  project?: string;
1732
1799
  createRunId?: () => string;
1800
+ /** Effective model for this invocation — written onto invocation.json. */
1801
+ model?: InvocationEffectiveModel;
1733
1802
  };
1734
1803
 
1735
1804
  /**
@@ -1794,7 +1863,7 @@ export async function admitReviewerInvocation(
1794
1863
  `${JSON.stringify(admitted, null, 2)}\n`,
1795
1864
  "utf8",
1796
1865
  );
1797
- await writeRoleInvocationLedger(admitted, admitted.role);
1866
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
1798
1867
 
1799
1868
  return {
1800
1869
  role: "reviewer",
@@ -1945,6 +2014,8 @@ export type AdmitMergerInvocationOptions = {
1945
2014
  createRunId?: () => string;
1946
2015
  /** Test seam; production binds createProductionMergerGitState(projectRoot). */
1947
2016
  gitState?: MergerGitState;
2017
+ /** Effective model for this invocation — written onto invocation.json. */
2018
+ model?: InvocationEffectiveModel;
1948
2019
  };
1949
2020
 
1950
2021
  /**
@@ -2052,7 +2123,7 @@ export async function admitMergerInvocation(
2052
2123
  `${JSON.stringify(admitted, null, 2)}\n`,
2053
2124
  "utf8",
2054
2125
  );
2055
- await writeRoleInvocationLedger(admitted, admitted.role);
2126
+ await writeRoleInvocationLedger(admitted, admitted.role, options.model);
2056
2127
 
2057
2128
  return {
2058
2129
  role: "merger",