@akagilnc/pi-workflow-roles 0.1.2052 → 0.1.2058

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.
@@ -16380,18 +16380,21 @@ async function writeRoleInvocationLedger(source, role, effectiveModel) {
16380
16380
  "utf8"
16381
16381
  );
16382
16382
  }
16383
- async function recordEffectiveInvocationModel(runDirectory, model) {
16383
+ async function recordEffectiveInvocationModel(runDirectory, model, engine) {
16384
16384
  const ledgerPath = join6(runDirectory, "invocation.json");
16385
16385
  const current = JSON.parse(await readFile4(ledgerPath, "utf8"));
16386
- const next = {
16387
- ...current,
16388
- provider: model.provider,
16389
- model: model.model
16390
- };
16391
- if (model.thinking === void 0) {
16392
- delete next.thinking;
16393
- } else {
16394
- next.thinking = model.thinking;
16386
+ const next = { ...current };
16387
+ if (model !== void 0) {
16388
+ next.provider = model.provider;
16389
+ next.model = model.model;
16390
+ if (model.thinking === void 0) {
16391
+ delete next.thinking;
16392
+ } else {
16393
+ next.thinking = model.thinking;
16394
+ }
16395
+ }
16396
+ if (engine !== void 0) {
16397
+ next.engine = engine;
16395
16398
  }
16396
16399
  await writeFile2(
16397
16400
  ledgerPath,
@@ -18629,9 +18632,13 @@ async function markRunAdmitted(admitted) {
18629
18632
  ...admitted.role === "coder" || admitted.role === "fixer" ? { phase: admitted.phase } : {}
18630
18633
  });
18631
18634
  }
18632
- async function markRunRunning(runDirectory, effectiveModel) {
18633
- if (effectiveModel !== void 0) {
18634
- await recordEffectiveInvocationModel(runDirectory, effectiveModel);
18635
+ async function markRunRunning(runDirectory, effectiveModel, effectiveEngine) {
18636
+ if (effectiveModel !== void 0 || effectiveEngine !== void 0) {
18637
+ await recordEffectiveInvocationModel(
18638
+ runDirectory,
18639
+ effectiveModel,
18640
+ effectiveEngine
18641
+ );
18635
18642
  }
18636
18643
  const current = await readRoleRunState(runDirectory);
18637
18644
  if (current === void 0) {
@@ -19410,6 +19417,16 @@ var init_collector_ledger = __esm({
19410
19417
  }
19411
19418
  });
19412
19419
 
19420
+ // src/engine-detour.ts
19421
+ var ENGINE_DETOUR_TOOL_NAME, AK_ROLE_ENGINE_ENV;
19422
+ var init_engine_detour = __esm({
19423
+ "src/engine-detour.ts"() {
19424
+ "use strict";
19425
+ ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour";
19426
+ AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE";
19427
+ }
19428
+ });
19429
+
19413
19430
  // src/work-subject-identity.ts
19414
19431
  import { resolve as resolve6 } from "node:path";
19415
19432
  function issueRoot(value) {
@@ -20563,34 +20580,46 @@ function boundErroredToolCandidate(entries, resultIndex, message, toolName) {
20563
20580
  const diagnostic = toolResultText(message);
20564
20581
  return bound === void 0 || diagnostic === "" ? void 0 : { candidate: bound.candidate, diagnostic, callIndex: bound.callIndex };
20565
20582
  }
20566
- function extractCollectorInfrastructureFailure(entries) {
20583
+ function extractInfrastructureToolFailure(entries, spec) {
20567
20584
  for (let i = entries.length - 1; i >= 0; i -= 1) {
20568
20585
  const entry = entries[i];
20569
20586
  if (entry?.type !== "message") continue;
20570
20587
  const message = entry.message;
20571
20588
  if (message?.role !== "toolResult") continue;
20572
20589
  if (message.isError !== true) continue;
20573
- if (typeof message.toolName !== "string" || !COLLECTOR_INFRASTRUCTURE_TOOLS.has(message.toolName)) {
20590
+ if (typeof message.toolName !== "string" || !spec.matchTool(message.toolName)) {
20574
20591
  continue;
20575
20592
  }
20576
20593
  const diagnostic = toolResultText(message);
20577
20594
  if (diagnostic.length === 0) continue;
20578
20595
  return {
20579
- cause: "activation",
20596
+ cause: spec.cause,
20580
20597
  diagnostic,
20581
- identity: { name: "CollectorInfrastructureError" }
20598
+ identity: { name: spec.identityName }
20582
20599
  };
20583
20600
  }
20584
20601
  return void 0;
20585
20602
  }
20586
- async function readCollectorInfrastructureFailure(sessionFile) {
20603
+ async function readInfrastructureToolFailure(sessionFile, spec) {
20587
20604
  try {
20588
20605
  const entries = await readBoundSessionEntries(sessionFile);
20589
- return extractCollectorInfrastructureFailure(entries);
20606
+ return extractInfrastructureToolFailure(entries, spec);
20590
20607
  } catch {
20591
20608
  return void 0;
20592
20609
  }
20593
20610
  }
20611
+ async function readCollectorInfrastructureFailure(sessionFile) {
20612
+ return readInfrastructureToolFailure(
20613
+ sessionFile,
20614
+ COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC
20615
+ );
20616
+ }
20617
+ async function readEngineDetourInfrastructureFailure(sessionFile) {
20618
+ return readInfrastructureToolFailure(
20619
+ sessionFile,
20620
+ ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC
20621
+ );
20622
+ }
20594
20623
  function assertCollectorReceiptMatchesAdmitted(receipt, admitted) {
20595
20624
  if (receipt.repository !== admitted.repository.canonical) {
20596
20625
  throw collectorReceiptBindingFailure(
@@ -22386,7 +22415,7 @@ function presentFailureTerminal(terminal, io) {
22386
22415
  }));
22387
22416
  }
22388
22417
  }
22389
- var CONCISE_DIAGNOSTIC_MAX_CHARS, COLLECTOR_INFRASTRUCTURE_TOOLS;
22418
+ var CONCISE_DIAGNOSTIC_MAX_CHARS, COLLECTOR_INFRASTRUCTURE_TOOLS, COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC, ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC;
22390
22419
  var init_settlement = __esm({
22391
22420
  "src/public-cli/settlement.ts"() {
22392
22421
  "use strict";
@@ -22399,6 +22428,7 @@ var init_settlement = __esm({
22399
22428
  init_run_lifecycle();
22400
22429
  init_compliance_transport();
22401
22430
  init_collector_ledger();
22431
+ init_engine_detour();
22402
22432
  init_judge_output();
22403
22433
  init_collector_output();
22404
22434
  init_worker_output();
@@ -22419,6 +22449,16 @@ var init_settlement = __esm({
22419
22449
  COLLECTOR_REQUEST_TOOL,
22420
22450
  COLLECTOR_WAIT_TOOL
22421
22451
  ]);
22452
+ COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC = {
22453
+ matchTool: (toolName) => COLLECTOR_INFRASTRUCTURE_TOOLS.has(toolName),
22454
+ cause: "activation",
22455
+ identityName: "CollectorInfrastructureError"
22456
+ };
22457
+ ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC = {
22458
+ matchTool: (toolName) => toolName === ENGINE_DETOUR_TOOL_NAME,
22459
+ cause: "output",
22460
+ identityName: "EngineDetourInfrastructureError"
22461
+ };
22422
22462
  }
22423
22463
  });
22424
22464
 
@@ -23742,7 +23782,7 @@ async function presentControlledFailure6(admitted, failureInput, io) {
23742
23782
  };
23743
23783
  }
23744
23784
  async function dispatchAdmittedJudge(input) {
23745
- const { admitted, env, io, extraArgs, lease } = input;
23785
+ const { admitted, env, io, extraArgs, lease, effectiveEngine } = input;
23746
23786
  try {
23747
23787
  const missingCredential = missingCredentialPreDispatchFailure(
23748
23788
  env.model,
@@ -23755,7 +23795,11 @@ async function dispatchAdmittedJudge(input) {
23755
23795
  io
23756
23796
  );
23757
23797
  }
23758
- await markRunRunning(admitted.runDirectory, env.model);
23798
+ await markRunRunning(
23799
+ admitted.runDirectory,
23800
+ env.model,
23801
+ effectiveEngine
23802
+ );
23759
23803
  await clearTypedProviderHttpObservation(admitted.runDirectory);
23760
23804
  const childEnv = {
23761
23805
  ...process.env,
@@ -23765,6 +23809,12 @@ async function dispatchAdmittedJudge(input) {
23765
23809
  // and role-runtime can record typed provider HTTP observations.
23766
23810
  AK_ROLE_RUN_DIR: admitted.runDirectory
23767
23811
  };
23812
+ delete childEnv[AK_ROLE_ENGINE_ENV];
23813
+ if (env.engine !== void 0 && env.engine.trim() !== "") {
23814
+ childEnv[AK_ROLE_ENGINE_ENV] = env.engine.trim();
23815
+ } else {
23816
+ childEnv[AK_ROLE_ENGINE_ENV] = void 0;
23817
+ }
23768
23818
  const correlationId = admitted.correlationId ?? env.correlationId;
23769
23819
  if (correlationId !== void 0 && correlationId.trim() !== "") {
23770
23820
  childEnv.AK_CORRELATION_ID = correlationId;
@@ -23839,13 +23889,20 @@ async function dispatchAdmittedJudge(input) {
23839
23889
  terminal: auditIncomplete
23840
23890
  };
23841
23891
  }
23892
+ const infrastructureFailure = await readEngineDetourInfrastructureFailure(
23893
+ admitted.sessionFile
23894
+ );
23842
23895
  const credentialFailure = postRunMissingCredentialFailure(
23843
23896
  result2,
23844
23897
  env.model,
23845
23898
  env.credentials
23846
23899
  );
23847
23900
  const resolution = await resolveAuditedRunnerFailureResolution({
23848
- runner: result2.knownFailure,
23901
+ runner: result2.knownFailure ?? (infrastructureFailure === void 0 ? void 0 : {
23902
+ cause: infrastructureFailure.cause,
23903
+ diagnostic: infrastructureFailure.diagnostic,
23904
+ ...infrastructureFailure.identity === void 0 ? {} : { identity: infrastructureFailure.identity }
23905
+ }),
23849
23906
  sessionFile: admitted.sessionFile,
23850
23907
  credential: credentialFailure,
23851
23908
  runDirectory: admitted.runDirectory
@@ -23909,7 +23966,9 @@ async function runPublicJudge(argv, env, io, parseJudgeArgv2) {
23909
23966
  },
23910
23967
  io,
23911
23968
  extraArgs,
23912
- lease
23969
+ lease,
23970
+ // #358: only initial Judge dispatch records mechanical engine provenance.
23971
+ ...env.engine === void 0 ? {} : { effectiveEngine: env.engine }
23913
23972
  });
23914
23973
  }
23915
23974
  async function runPublicResume(argv, env, io) {
@@ -23967,6 +24026,7 @@ async function runPublicResume(argv, env, io) {
23967
24026
  var init_judge_run = __esm({
23968
24027
  "src/public-cli/judge-run.ts"() {
23969
24028
  "use strict";
24029
+ init_engine_detour();
23970
24030
  init_engine_material();
23971
24031
  init_explicit_internal();
23972
24032
  init_cli_errors();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.2052",
3
+ "version": "0.1.2058",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,3 +29,7 @@ kimi --yolo --output-format text -p "YOUR_LABOR_PROMPT"
29
29
 
30
30
  Prefer `kimi --help` on the host over any remembered flag set. Do not wrap this
31
31
  engine behind `ak-role` flags.
32
+
33
+ When the package detour tool is available, start exactly one subprocess through
34
+ it with argv assembled from this material and the local CLI; return the stdout
35
+ labor content to the same session for the existing typed submission path.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Package-owned engine detour tool (#357 T2).
3
+ * Registered by shared role-runtime when Judge + engine activation signal is present.
4
+ * Forbidden in judge-role.ts (lifecycle ban — no spawn in role modules).
5
+ */
6
+ import type {
7
+ AgentToolResult,
8
+ ExtensionAPI,
9
+ ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Type, type Static } from "typebox";
12
+
13
+ import {
14
+ ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC,
15
+ ENGINE_DETOUR_TOOL_NAME,
16
+ engineDetourFailureDiagnostic,
17
+ engineNameFromEnv,
18
+ isEngineDetourFailure,
19
+ runEngineDetourOnce,
20
+ } from "./engine-detour.ts";
21
+
22
+ const engineDetourArgsSchema = Type.Object(
23
+ {
24
+ argv: Type.Array(Type.String({ minLength: 1 }), {
25
+ minItems: 1,
26
+ description:
27
+ "Executable argv for one engine subprocess. First element is the command (PATH lookup); remaining elements are arguments. Assemble from engine method material — do not invent package flags.",
28
+ }),
29
+ },
30
+ { additionalProperties: false },
31
+ );
32
+
33
+ type EngineDetourArgs = Static<typeof engineDetourArgsSchema>;
34
+
35
+ export type EngineDetourHostActions = {
36
+ failInfrastructure(
37
+ error: unknown,
38
+ ctx: ExtensionContext,
39
+ toolCallId?: string,
40
+ ): never;
41
+ };
42
+
43
+ export type EngineDetourToolRegistration = {
44
+ /** True when the tool definition was installed on this ExtensionAPI. */
45
+ readonly registered: boolean;
46
+ };
47
+
48
+ /**
49
+ * Register the engine-generic detour tool once for this process when Judge has
50
+ * an engine activation signal. Returns whether registration occurred.
51
+ * Once-latch is activation-scoped via the returned reset handle.
52
+ */
53
+ export function registerEngineDetourTool(
54
+ pi: ExtensionAPI,
55
+ hostActions: EngineDetourHostActions,
56
+ ): EngineDetourToolRegistration & { resetLatch(): void } {
57
+ const engineName = engineNameFromEnv();
58
+ if (engineName === undefined) {
59
+ return {
60
+ registered: false,
61
+ resetLatch() {
62
+ /* no-op when unregistered */
63
+ },
64
+ };
65
+ }
66
+
67
+ let used = false;
68
+
69
+ pi.registerTool({
70
+ name: ENGINE_DETOUR_TOOL_NAME,
71
+ label: "Engine Detour",
72
+ description:
73
+ `Run one labor-engine subprocess (engine=${engineName}) and return its stdout to this session. Call at most once per activation. Assemble argv from the engine method material path delivered in the session prompt.`,
74
+ promptSnippet: "Run the configured labor engine once and return its stdout",
75
+ promptGuidelines: [
76
+ `Use ${ENGINE_DETOUR_TOOL_NAME} exactly once when engine method material is present.`,
77
+ "Pass argv assembled from the material and the host CLI — first element is the executable name on PATH.",
78
+ "On success, use the returned stdout as labor content for the existing typed submission tool.",
79
+ ],
80
+ parameters: engineDetourArgsSchema,
81
+ async execute(
82
+ toolCallId,
83
+ params,
84
+ signal,
85
+ _onUpdate,
86
+ ctx,
87
+ ): Promise<AgentToolResult<unknown>> {
88
+ if (used) {
89
+ hostActions.failInfrastructure(
90
+ new Error(ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC),
91
+ ctx,
92
+ toolCallId,
93
+ );
94
+ }
95
+ used = true;
96
+
97
+ const args = params as EngineDetourArgs;
98
+ const argv = Array.isArray(args.argv) ? args.argv : [];
99
+ if (argv.length === 0 || argv.some((part) => typeof part !== "string" || part.length === 0)) {
100
+ hostActions.failInfrastructure(
101
+ new Error("engine detour argv must be a non-empty string array"),
102
+ ctx,
103
+ toolCallId,
104
+ );
105
+ }
106
+
107
+ let result: Awaited<ReturnType<typeof runEngineDetourOnce>>;
108
+ try {
109
+ result = await runEngineDetourOnce({
110
+ argv,
111
+ cwd: ctx.cwd,
112
+ ...(signal === undefined ? {} : { signal }),
113
+ });
114
+ } catch (error) {
115
+ hostActions.failInfrastructure(error, ctx, toolCallId);
116
+ }
117
+
118
+ if (isEngineDetourFailure(result)) {
119
+ hostActions.failInfrastructure(
120
+ new Error(engineDetourFailureDiagnostic(result)),
121
+ ctx,
122
+ toolCallId,
123
+ );
124
+ }
125
+
126
+ return {
127
+ content: [{ type: "text" as const, text: result.stdout }],
128
+ details: {
129
+ tool: ENGINE_DETOUR_TOOL_NAME,
130
+ code: result.code,
131
+ },
132
+ };
133
+ },
134
+ });
135
+
136
+ return {
137
+ registered: true,
138
+ resetLatch() {
139
+ used = false;
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Engine-generic one-shot subprocess detour (#357 T2 / ADR 0069).
3
+ * Spawn once; no retry, hang surface, or per-engine branch.
4
+ * Material body is LLM data — this module only executes argv the model assembled.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+
8
+ /** Package-owned detour tool name (settlement whitelist + session principal). */
9
+ export const ENGINE_DETOUR_TOOL_NAME = "ak_engine_detour" as const;
10
+
11
+ /** Env presence/name signal injected by public Judge run (registration gate only). */
12
+ export const AK_ROLE_ENGINE_ENV = "AK_ROLE_ENGINE" as const;
13
+
14
+ export const ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC =
15
+ "engine detour produced empty stdout" as const;
16
+
17
+ export const ENGINE_DETOUR_ALREADY_USED_DIAGNOSTIC =
18
+ "engine detour already used in this activation" as const;
19
+
20
+ export type EngineDetourResult = Readonly<{
21
+ code: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ }>;
25
+
26
+ export type EngineDetourRunInput = Readonly<{
27
+ argv: readonly string[];
28
+ cwd: string;
29
+ env?: NodeJS.ProcessEnv;
30
+ signal?: AbortSignal;
31
+ }>;
32
+
33
+ /**
34
+ * Run one engine subprocess. First argv element is the executable (PATH lookup).
35
+ * stdio: ignore stdin, pipe stdout+stderr. No shell, no retry, no hang timer.
36
+ */
37
+ export async function runEngineDetourOnce(
38
+ input: EngineDetourRunInput,
39
+ ): Promise<EngineDetourResult> {
40
+ if (input.argv.length === 0) {
41
+ throw new Error("engine detour argv must be non-empty");
42
+ }
43
+ const command = input.argv[0]!;
44
+ const args = input.argv.slice(1);
45
+ return await new Promise<EngineDetourResult>((resolve, reject) => {
46
+ let settled = false;
47
+ const child = spawn(command, args, {
48
+ cwd: input.cwd,
49
+ env: input.env ?? process.env,
50
+ stdio: ["ignore", "pipe", "pipe"],
51
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
52
+ });
53
+ let stdout = "";
54
+ let stderr = "";
55
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
56
+ stdout += chunk;
57
+ });
58
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
59
+ stderr += chunk;
60
+ });
61
+ const fail = (error: unknown): void => {
62
+ if (settled) return;
63
+ settled = true;
64
+ reject(error instanceof Error ? error : new Error(String(error)));
65
+ };
66
+ child.on("error", (error) => fail(error));
67
+ child.on("close", (code) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ resolve({ code: code ?? 1, stdout, stderr });
71
+ });
72
+ });
73
+ }
74
+
75
+ /** Failure predicate: nonzero exit OR stdout trim-empty (including whitespace-only). */
76
+ export function isEngineDetourFailure(result: {
77
+ code: number;
78
+ stdout: string;
79
+ }): boolean {
80
+ return result.code !== 0 || result.stdout.trim() === "";
81
+ }
82
+
83
+ /**
84
+ * Diagnostic string for shared settlement / Terminal Error Artifact.
85
+ * Prefer engine stderr 原样; whitespace-only/empty stderr is absent → stable fallback.
86
+ */
87
+ export function engineDetourFailureDiagnostic(result: {
88
+ stderr: string;
89
+ code: number;
90
+ stdout: string;
91
+ }): string {
92
+ if (result.stderr.trim().length > 0) return result.stderr;
93
+ if (result.stdout.trim() === "") return ENGINE_DETOUR_EMPTY_STDOUT_DIAGNOSTIC;
94
+ return `engine detour exited with code ${result.code}`;
95
+ }
96
+
97
+ /** Non-empty trimmed engine name from process.env, else undefined. */
98
+ export function engineNameFromEnv(): string | undefined {
99
+ const raw = process.env[AK_ROLE_ENGINE_ENV];
100
+ if (typeof raw !== "string") return undefined;
101
+ const trimmed = raw.trim();
102
+ return trimmed === "" ? undefined : trimmed;
103
+ }
@@ -239,28 +239,35 @@ async function writeRoleInvocationLedger(
239
239
  }
240
240
 
241
241
  /**
242
- * Merge the effective launch model onto the existing invocation identity page
243
- * (resume / temporary override path — same field shape as admission write).
242
+ * Merge the effective launch model (and optional initial engine) onto the
243
+ * existing invocation identity page (resume / temporary override path — same
244
+ * field shape as admission write).
244
245
  * Bare model clears any prior thinking key so absence stays honest.
246
+ * Engine is write-if-present only: undefined leaves any existing key untouched
247
+ * (resume model merge must not erase initial mechanical provenance).
245
248
  */
246
249
  export async function recordEffectiveInvocationModel(
247
250
  runDirectory: string,
248
- model: InvocationEffectiveModel,
251
+ model?: InvocationEffectiveModel,
252
+ engine?: string,
249
253
  ): Promise<void> {
250
254
  const ledgerPath = join(runDirectory, "invocation.json");
251
255
  const current = JSON.parse(await readFile(ledgerPath, "utf8")) as Record<
252
256
  string,
253
257
  unknown
254
258
  >;
255
- const next: Record<string, unknown> = {
256
- ...current,
257
- provider: model.provider,
258
- model: model.model,
259
- };
260
- if (model.thinking === undefined) {
261
- delete next.thinking;
262
- } else {
263
- next.thinking = model.thinking;
259
+ const next: Record<string, unknown> = { ...current };
260
+ if (model !== undefined) {
261
+ next.provider = model.provider;
262
+ next.model = model.model;
263
+ if (model.thinking === undefined) {
264
+ delete next.thinking;
265
+ } else {
266
+ next.thinking = model.thinking;
267
+ }
268
+ }
269
+ if (engine !== undefined) {
270
+ next.engine = engine;
264
271
  }
265
272
  await writeFile(
266
273
  ledgerPath,
@@ -6,6 +6,7 @@
6
6
  import { writeFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
 
9
+ import { AK_ROLE_ENGINE_ENV } from "../engine-detour.ts";
9
10
  import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
10
11
  import {
11
12
  runExplicitInternalActivation,
@@ -58,6 +59,7 @@ import {
58
59
  resolveControlledFailureResumeObservation,
59
60
  controlledFailureInputFromResolution,
60
61
  explicitInternalKnownFailureClassificationInput,
62
+ readEngineDetourInfrastructureFailure,
61
63
  settleJudgeFailureTerminalResult,
62
64
  trySettleJudgeTerminalResult,
63
65
  trySettleComplianceAuditIncompleteTerminalResult,
@@ -254,12 +256,17 @@ async function dispatchAdmittedJudge(input: {
254
256
  io: CliIo;
255
257
  extraArgs: string[];
256
258
  lease: RunWriterLease;
259
+ /**
260
+ * Mechanical engine provenance for initial Judge dispatch only.
261
+ * Explicit — never read from env.engine here, so resume cannot rewrite it.
262
+ */
263
+ effectiveEngine?: string;
257
264
  }): Promise<{
258
265
  exitCode: number;
259
266
  admitted: AdmittedJudgeInvocation;
260
267
  terminal?: TerminalResult;
261
268
  }> {
262
- const { admitted, env, io, extraArgs, lease } = input;
269
+ const { admitted, env, io, extraArgs, lease, effectiveEngine } = input;
263
270
  try {
264
271
  // Fail closed at the public credential seam before model dispatch: missing
265
272
  // selected-provider auth must not be washed by ambient keys or zero-exit runs.
@@ -274,7 +281,11 @@ async function dispatchAdmittedJudge(input: {
274
281
  io,
275
282
  );
276
283
  }
277
- await markRunRunning(admitted.runDirectory, env.model);
284
+ await markRunRunning(
285
+ admitted.runDirectory,
286
+ env.model,
287
+ effectiveEngine,
288
+ );
278
289
  // Attempt-scoped observation: drop any prior dispatch's 429 evidence so only
279
290
  // the current initial/resume attempt can qualify v1 resume.
280
291
  await clearTypedProviderHttpObservation(admitted.runDirectory);
@@ -287,6 +298,14 @@ async function dispatchAdmittedJudge(input: {
287
298
  // and role-runtime can record typed provider HTTP observations.
288
299
  AK_ROLE_RUN_DIR: admitted.runDirectory,
289
300
  };
301
+ // Engine presence/name signal: registration gate + label only (no per-engine branch).
302
+ // Delete ambient inheritance first; own-key undefined mask survives process.env re-merge.
303
+ delete childEnv[AK_ROLE_ENGINE_ENV];
304
+ if (env.engine !== undefined && env.engine.trim() !== "") {
305
+ childEnv[AK_ROLE_ENGINE_ENV] = env.engine.trim();
306
+ } else {
307
+ childEnv[AK_ROLE_ENGINE_ENV] = undefined;
308
+ }
290
309
  const correlationId = admitted.correlationId ?? env.correlationId;
291
310
  if (correlationId !== undefined && correlationId.trim() !== "") {
292
311
  childEnv.AK_CORRELATION_ID = correlationId;
@@ -368,6 +387,11 @@ async function dispatchAdmittedJudge(input: {
368
387
  };
369
388
  }
370
389
 
390
+ // Prefer engine-detour infrastructure failure already on the session principal
391
+ // over a later secondary provider-stop after abort (#357 T2 / collector-isomorphic).
392
+ const infrastructureFailure = await readEngineDetourInfrastructureFailure(
393
+ admitted.sessionFile,
394
+ );
371
395
  // Production-owned typed cause channel — never inferred from stderr wording.
372
396
  const credentialFailure = postRunMissingCredentialFailure(
373
397
  result,
@@ -375,7 +399,17 @@ async function dispatchAdmittedJudge(input: {
375
399
  env.credentials,
376
400
  );
377
401
  const resolution = await resolveAuditedRunnerFailureResolution({
378
- runner: result.knownFailure,
402
+ runner:
403
+ result.knownFailure ??
404
+ (infrastructureFailure === undefined
405
+ ? undefined
406
+ : {
407
+ cause: infrastructureFailure.cause,
408
+ diagnostic: infrastructureFailure.diagnostic,
409
+ ...(infrastructureFailure.identity === undefined
410
+ ? {}
411
+ : { identity: infrastructureFailure.identity }),
412
+ }),
379
413
  sessionFile: admitted.sessionFile,
380
414
  credential: credentialFailure,
381
415
  runDirectory: admitted.runDirectory,
@@ -459,6 +493,8 @@ export async function runPublicJudge(
459
493
  io,
460
494
  extraArgs,
461
495
  lease,
496
+ // #358: only initial Judge dispatch records mechanical engine provenance.
497
+ ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
462
498
  });
463
499
  }
464
500
 
@@ -230,15 +230,23 @@ export async function markRunAdmitted(
230
230
 
231
231
  /**
232
232
  * Shared dispatch execution seam: record the effective launch model (initial or
233
- * resume override) onto invocation.json when known, then transition to running.
233
+ * resume override) and optional initial effective engine onto invocation.json
234
+ * when known, then transition to running.
234
235
  * Role runners must not coordinate lifecycle ledger writes themselves.
236
+ * Engine is write-if-present only — callers that omit it (resume / non-Judge)
237
+ * never touch the engine key.
235
238
  */
236
239
  export async function markRunRunning(
237
240
  runDirectory: string,
238
241
  effectiveModel?: InvocationEffectiveModel,
242
+ effectiveEngine?: string,
239
243
  ): Promise<void> {
240
- if (effectiveModel !== undefined) {
241
- await recordEffectiveInvocationModel(runDirectory, effectiveModel);
244
+ if (effectiveModel !== undefined || effectiveEngine !== undefined) {
245
+ await recordEffectiveInvocationModel(
246
+ runDirectory,
247
+ effectiveModel,
248
+ effectiveEngine,
249
+ );
242
250
  }
243
251
  const current = await readRoleRunState(runDirectory);
244
252
  if (current === undefined) {
@@ -33,6 +33,7 @@ import {
33
33
  COLLECTOR_REQUEST_TOOL,
34
34
  COLLECTOR_WAIT_TOOL,
35
35
  } from "../collector-ledger.ts";
36
+ import { ENGINE_DETOUR_TOOL_NAME } from "../engine-detour.ts";
36
37
  import {
37
38
  JUDGE_OUTPUT_TOOL_NAME,
38
39
  type JudgeVerdict,
@@ -1348,13 +1349,33 @@ const COLLECTOR_INFRASTRUCTURE_TOOLS = new Set<string>([
1348
1349
  COLLECTOR_WAIT_TOOL,
1349
1350
  ]);
1350
1351
 
1352
+ /** Shared match/cause/identity knobs for session-principal infrastructure failures. */
1353
+ type InfrastructureFailureSpec = Readonly<{
1354
+ matchTool: (toolName: string) => boolean;
1355
+ cause: ControlledFailureCause;
1356
+ identityName: string;
1357
+ }>;
1358
+
1359
+ const COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC: InfrastructureFailureSpec = {
1360
+ matchTool: (toolName) => COLLECTOR_INFRASTRUCTURE_TOOLS.has(toolName),
1361
+ cause: "activation",
1362
+ identityName: "CollectorInfrastructureError",
1363
+ };
1364
+
1365
+ const ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC: InfrastructureFailureSpec = {
1366
+ matchTool: (toolName) => toolName === ENGINE_DETOUR_TOOL_NAME,
1367
+ cause: "output",
1368
+ identityName: "EngineDetourInfrastructureError",
1369
+ };
1370
+
1351
1371
  /**
1352
- * Prefer a real Collector infrastructure tool failure already on the session
1353
- * principal over a later secondary provider-stop (failure-honesty).
1354
- * Observe/request/wait host failures keep their diagnostic identity (e.g. HTTP 404).
1372
+ * Prefer a real infrastructure tool failure already on the session principal
1373
+ * over a later secondary provider-stop (failure-honesty).
1374
+ * Tool match + cause + identity are call-site parameters — one extraction body.
1355
1375
  */
1356
- export function extractCollectorInfrastructureFailure(
1376
+ function extractInfrastructureToolFailure(
1357
1377
  entries: readonly SessionEntry[],
1378
+ spec: InfrastructureFailureSpec,
1358
1379
  ): ControlledFailure | undefined {
1359
1380
  for (let i = entries.length - 1; i >= 0; i -= 1) {
1360
1381
  const entry = entries[i];
@@ -1364,33 +1385,82 @@ export function extractCollectorInfrastructureFailure(
1364
1385
  if (message.isError !== true) continue;
1365
1386
  if (
1366
1387
  typeof message.toolName !== "string" ||
1367
- !COLLECTOR_INFRASTRUCTURE_TOOLS.has(message.toolName)
1388
+ !spec.matchTool(message.toolName)
1368
1389
  ) {
1369
1390
  continue;
1370
1391
  }
1371
1392
  const diagnostic = toolResultText(message);
1372
1393
  if (diagnostic.length === 0) continue;
1373
1394
  return {
1374
- cause: "activation",
1395
+ cause: spec.cause,
1375
1396
  diagnostic,
1376
- identity: { name: "CollectorInfrastructureError" },
1397
+ identity: { name: spec.identityName },
1377
1398
  };
1378
1399
  }
1379
1400
  return undefined;
1380
1401
  }
1381
1402
 
1382
- /** Read the bound session principal for a Collector infrastructure tool failure. */
1383
- export async function readCollectorInfrastructureFailure(
1403
+ /** Read the bound session principal for a parameterized infrastructure tool failure. */
1404
+ async function readInfrastructureToolFailure(
1384
1405
  sessionFile: string,
1406
+ spec: InfrastructureFailureSpec,
1385
1407
  ): Promise<ControlledFailure | undefined> {
1386
1408
  try {
1387
1409
  const entries = await readBoundSessionEntries(sessionFile);
1388
- return extractCollectorInfrastructureFailure(entries);
1410
+ return extractInfrastructureToolFailure(entries, spec);
1389
1411
  } catch {
1390
1412
  return undefined;
1391
1413
  }
1392
1414
  }
1393
1415
 
1416
+ /**
1417
+ * Prefer a real Collector infrastructure tool failure already on the session
1418
+ * principal over a later secondary provider-stop (failure-honesty).
1419
+ * Observe/request/wait host failures keep their diagnostic identity (e.g. HTTP 404).
1420
+ */
1421
+ export function extractCollectorInfrastructureFailure(
1422
+ entries: readonly SessionEntry[],
1423
+ ): ControlledFailure | undefined {
1424
+ return extractInfrastructureToolFailure(
1425
+ entries,
1426
+ COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC,
1427
+ );
1428
+ }
1429
+
1430
+ /** Read the bound session principal for a Collector infrastructure tool failure. */
1431
+ export async function readCollectorInfrastructureFailure(
1432
+ sessionFile: string,
1433
+ ): Promise<ControlledFailure | undefined> {
1434
+ return readInfrastructureToolFailure(
1435
+ sessionFile,
1436
+ COLLECTOR_INFRASTRUCTURE_FAILURE_SPEC,
1437
+ );
1438
+ }
1439
+
1440
+ /**
1441
+ * Prefer a real engine-detour infrastructure tool failure already on the session
1442
+ * principal over a later secondary provider-stop (failure-honesty / #357 T2).
1443
+ * Cause stays `output` — labor leg failed before accepted typed Receipt.
1444
+ */
1445
+ export function extractEngineDetourInfrastructureFailure(
1446
+ entries: readonly SessionEntry[],
1447
+ ): ControlledFailure | undefined {
1448
+ return extractInfrastructureToolFailure(
1449
+ entries,
1450
+ ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1451
+ );
1452
+ }
1453
+
1454
+ /** Read the bound session principal for an engine-detour infrastructure failure. */
1455
+ export async function readEngineDetourInfrastructureFailure(
1456
+ sessionFile: string,
1457
+ ): Promise<ControlledFailure | undefined> {
1458
+ return readInfrastructureToolFailure(
1459
+ sessionFile,
1460
+ ENGINE_DETOUR_INFRASTRUCTURE_FAILURE_SPEC,
1461
+ );
1462
+ }
1463
+
1394
1464
  /**
1395
1465
  * Compare a validated receipt with the admitted Collector invocation identity.
1396
1466
  * Throws a typed output failure when any identity field mismatches.
@@ -21,6 +21,7 @@ import {
21
21
  writeToolExecutionObservationRecord,
22
22
  type ToolExecutionObservationWriter,
23
23
  } from "./tool-execution-observation.ts";
24
+ import { registerEngineDetourTool } from "./engine-detour-tool.ts";
24
25
  import { installPackageOwnedToolRegistration } from "./package-owned-tool-idle.ts";
25
26
  import { installWorkerGitHooks } from "./worker-submission-gates.ts";
26
27
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
@@ -284,6 +285,7 @@ export {
284
285
  type JudgeVerdict,
285
286
  type SoulAuditResult,
286
287
  } from "./judge-role.ts";
288
+ export { ENGINE_DETOUR_TOOL_NAME, AK_ROLE_ENGINE_ENV } from "./engine-detour.ts";
287
289
  export {
288
290
  AGENT_TOOL_NAME,
289
291
  REVIEWER_OUTPUT_TOOL_NAME,
@@ -590,6 +592,8 @@ export function createRoleRuntimeExtension(
590
592
  let pendingNavigatorSettlement: Promise<void> | undefined;
591
593
  let navigatorWorkContext: NavigatorWorkContext | undefined;
592
594
  const pendingInfrastructureToolCallIds = new Set<string>();
595
+ // #357 T2: engine detour once-latch + registration (Judge+engine only).
596
+ let engineDetourRegistration: ReturnType<typeof registerEngineDetourTool> | undefined;
593
597
  // #288 primary-session thin adapter. The policy is the sole budget owner;
594
598
  // terminating-tool rejections and mechanical delivery requests share two turns.
595
599
  let receiptDelivery = createReceiptDeliveryPolicy();
@@ -1053,6 +1057,7 @@ export function createRoleRuntimeExtension(
1053
1057
  pendingNavigatorPresentation = undefined;
1054
1058
  pendingNavigatorSettlement = undefined;
1055
1059
  pendingInfrastructureToolCallIds.clear();
1060
+ engineDetourRegistration?.resetLatch();
1056
1061
  navigatorWorkContext = undefined;
1057
1062
  // #351: OAuth keepalive is orthogonal to --ak-role; start before role early-return
1058
1063
  // so role-less sessions (and reload after shutdown stop) still keep tokens alive.
@@ -1164,6 +1169,14 @@ export function createRoleRuntimeExtension(
1164
1169
  }
1165
1170
 
1166
1171
  await executeActivationStage(entry.role, activationStage(entry.role, runtime), { clock, writeTrace });
1172
+ // #357 T2: Judge+engine activation registers the package detour tool once.
1173
+ // Gate is env presence only — no per-engine execute branch; no judge-role spawn.
1174
+ if (entry.role === "judge" && engineDetourRegistration === undefined) {
1175
+ engineDetourRegistration = registerEngineDetourTool(pi, hostActions);
1176
+ if (!engineDetourRegistration.registered) {
1177
+ engineDetourRegistration = undefined;
1178
+ }
1179
+ }
1167
1180
  // Worker gates ②④ + ① baseline: envelope arms the worktree after role install.
1168
1181
  // Parent session feeds #216 createRecordSession so baseline/bounce survive resume.
1169
1182
  if (entry.role === "coder" || entry.role === "fixer") {