@akagilnc/pi-workflow-roles 0.1.2086 → 0.1.2091

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.
@@ -29,6 +29,13 @@ import {
29
29
  prepareComplianceDispatch,
30
30
  type AuditorParentAttemptBinding,
31
31
  } from "./compliance-transport.ts";
32
+ import { createEngineDetourToolDefinition } from "./engine-detour-tool.ts";
33
+ import { engineNameFromEnv } from "./engine-detour.ts";
34
+ import {
35
+ appendEngineSessionMaterial,
36
+ engineSessionMaterialFromOptions,
37
+ type EngineSessionMaterial,
38
+ } from "./package-resources/engine-material.ts";
32
39
  import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
33
40
  import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT, type NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
34
41
  import { REVIEWER_VERIFICATION_BOUNDARY } from "./reviewer-construction.ts";
@@ -41,13 +48,16 @@ import {
41
48
  } from "./upstream-error-testimony.ts";
42
49
 
43
50
  /** Package-owned system prompt for Reviewer Standards/Spec evidence children (private carrier). */
44
- function buildEvidenceChildSystemPrompt(): string {
45
- return [
51
+ function buildEvidenceChildSystemPrompt(
52
+ engineMaterial?: EngineSessionMaterial,
53
+ ): string {
54
+ const lines = [
46
55
  "Work only in the supplied workspace.",
47
56
  "Use the available evidence tools to investigate. Do not commit, push, or mutate remotes.",
48
57
  REVIEWER_VERIFICATION_BOUNDARY,
49
58
  "Return one substantive non-blank report.",
50
- ].join("\n");
59
+ ];
60
+ return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
51
61
  }
52
62
 
53
63
  // ── shared constants / types ──────────────────────────────────────────────
@@ -540,6 +550,11 @@ export type EvidenceChildExecuteOptions = Readonly<{
540
550
  signal?: AbortSignal;
541
551
  /** Parent directory for credential/config scratch. Defaults to os.tmpdir(). */
542
552
  credentialScratchParent?: string;
553
+ /**
554
+ * Package root for optional engine method-material resolution (#378).
555
+ * Required only when AK_ROLE_ENGINE is set and packaged notes should attach.
556
+ */
557
+ packageRoot?: string;
543
558
  }>;
544
559
 
545
560
  export async function executeEvidenceChild(
@@ -568,6 +583,32 @@ export async function executeEvidenceChild(
568
583
  } catch (error) {
569
584
  throw classifiedError(error, "provider");
570
585
  }
586
+ // #378: when labor engine is configured, legs get the same detour tool + material
587
+ // dual-path as the parent seat (ADR 0069 detour-rejoins-main-road).
588
+ const engineName = engineNameFromEnv();
589
+ const engineMaterial =
590
+ engineName === undefined
591
+ ? undefined
592
+ : options.packageRoot === undefined || options.packageRoot.trim() === ""
593
+ // Name-only when package root is unavailable (still a valid #376 path).
594
+ ? Object.freeze({ name: engineName })
595
+ : engineSessionMaterialFromOptions({
596
+ engine: engineName,
597
+ packageRoot: options.packageRoot,
598
+ });
599
+ // #378: detour failure is durable for this leg — later assistant reports cannot wash it.
600
+ let engineDetourFailure: Error | undefined;
601
+ const engineDetourTool =
602
+ engineName === undefined
603
+ ? undefined
604
+ : createEngineDetourToolDefinition({
605
+ engineName,
606
+ fail(error) {
607
+ const failure = error instanceof Error ? error : new Error(String(error));
608
+ engineDetourFailure = failure;
609
+ throw failure;
610
+ },
611
+ });
571
612
  // No tools allowlist — Pi defaults + unrestricted evidence surface (ADR 0064).
572
613
  // Single createAgentSession owner: in-process-session.ts.
573
614
  const { session, dispose } = await openInProcessAgentSession({
@@ -576,7 +617,10 @@ export async function executeEvidenceChild(
576
617
  model: inherited.model,
577
618
  thinkingLevel: context.thinkingLevel ?? "off",
578
619
  modelRuntime: inherited.runtime,
579
- systemPrompt: buildEvidenceChildSystemPrompt(),
620
+ systemPrompt: buildEvidenceChildSystemPrompt(engineMaterial),
621
+ ...(engineDetourTool === undefined
622
+ ? {}
623
+ : { customTools: [engineDetourTool] }),
580
624
  sessionManager: createRecordSession({
581
625
  cwd: workspace,
582
626
  kind: "evidence-children",
@@ -598,8 +642,18 @@ export async function executeEvidenceChild(
598
642
  try {
599
643
  await session.prompt(delivered);
600
644
  } catch (error) {
645
+ // Engine detour fail becomes isError toolResult and must outrank a later
646
+ // provider-shaped throw from the same prompt turn (#378).
647
+ if (engineDetourFailure !== undefined) {
648
+ throw classifiedError(engineDetourFailure, "child");
649
+ }
601
650
  throw classifiedError(error, "provider");
602
651
  }
652
+ // Launched-leg detour non-zero / empty stdout / spawn failure is infrastructure:
653
+ // reject the leg even when the model still emits a non-blank report afterward.
654
+ if (engineDetourFailure !== undefined) {
655
+ throw classifiedError(engineDetourFailure, "child");
656
+ }
603
657
  if (signal?.aborted) throw new Error("Evidence child was cancelled");
604
658
  const lastAssistant = [...session.messages]
605
659
  .reverse()
@@ -16,6 +16,7 @@ import {
16
16
  parsePersistentModelSpec,
17
17
  resolveEffectiveSeat,
18
18
  savePublicCliConfig,
19
+ isEngineAxisSeat,
19
20
  setPersistentSeatConfig,
20
21
  setPersistentSeatEngine,
21
22
  validatePublicCliConfigEngines,
@@ -418,7 +419,7 @@ function renderHelp(): string {
418
419
  "",
419
420
  "Role options: ak-role help <command>",
420
421
  "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",
422
+ "Persistent engine (judge|reviewer): ak-role config set-engine <seat> <name> | unset-engine <seat>",
422
423
  "Effective seats: ak-role roles",
423
424
  );
424
425
  return `${lines.join("\n")}\n`;
@@ -533,7 +534,7 @@ async function runConfigCommand(
533
534
  if (args[0] === "set-engine") {
534
535
  if (args.length !== 3) {
535
536
  throw new CliUsageError(
536
- "usage: ak-role config set-engine judge <name>",
537
+ "usage: ak-role config set-engine <judge|reviewer> <name>",
537
538
  );
538
539
  }
539
540
  const seat = args[1]!;
@@ -541,9 +542,9 @@ async function runConfigCommand(
541
542
  if (!isPublicConfigurableSeat(seat)) {
542
543
  throw new CliUsageError(`unknown configurable seat: ${seat}`);
543
544
  }
544
- if (seat !== "judge") {
545
+ if (!isEngineAxisSeat(seat)) {
545
546
  throw new CliUsageError(
546
- `engine axis is judge-only; refused seat ${seat}`,
547
+ `engine axis is judge+reviewer only; refused seat ${seat}`,
547
548
  );
548
549
  }
549
550
  requireLegalEngineName(name);
@@ -564,16 +565,16 @@ async function runConfigCommand(
564
565
  if (args[0] === "unset-engine") {
565
566
  if (args.length !== 2) {
566
567
  throw new CliUsageError(
567
- "usage: ak-role config unset-engine judge",
568
+ "usage: ak-role config unset-engine <judge|reviewer>",
568
569
  );
569
570
  }
570
571
  const seat = args[1]!;
571
572
  if (!isPublicConfigurableSeat(seat)) {
572
573
  throw new CliUsageError(`unknown configurable seat: ${seat}`);
573
574
  }
574
- if (seat !== "judge") {
575
+ if (!isEngineAxisSeat(seat)) {
575
576
  throw new CliUsageError(
576
- `engine axis is judge-only; refused seat ${seat}`,
577
+ `engine axis is judge+reviewer only; refused seat ${seat}`,
577
578
  );
578
579
  }
579
580
  let config = await loadAndValidateConfig(home, packageRoot);
@@ -606,17 +607,17 @@ export async function runAkRole(
606
607
  env = { ...env, packageRoot: await realpath(env.packageRoot) };
607
608
  const parsed = parseArgv(argv);
608
609
  // 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
+ // #356 / #378: engine axis is Judge+Reviewer (not resume / other seats).
610
611
  if (parsed.engine !== undefined) {
611
612
  requireLegalEngineName(parsed.engine);
612
613
  if (
613
614
  !parsed.help &&
614
615
  parsed.command !== undefined &&
615
616
  parsed.command !== "help" &&
616
- parsed.command !== "judge"
617
+ !isEngineAxisSeat(parsed.command)
617
618
  ) {
618
619
  throw new CliUsageError(
619
- `engine axis is judge-only; refused command ${parsed.command}`,
620
+ `engine axis is judge+reviewer only; refused command ${parsed.command}`,
620
621
  );
621
622
  }
622
623
  }
@@ -1046,6 +1047,7 @@ export async function runAkRole(
1046
1047
  : { correlationId: env.correlationId }),
1047
1048
  ...(env.piRunner === undefined ? {} : { piRunner: env.piRunner }),
1048
1049
  ...(seat.selection === undefined ? {} : { model: seat.selection }),
1050
+ ...(seat.engine === undefined ? {} : { engine: seat.engine }),
1049
1051
  ...(env.reviewerExtraPiArgs === undefined
1050
1052
  ? {}
1051
1053
  : { extraPiArgs: env.reviewerExtraPiArgs }),
@@ -116,8 +116,13 @@ export function setPersistentSeatConfig(
116
116
  };
117
117
  }
118
118
 
119
+ /** Seats that own the labor-engine axis (#356 Judge; #378 Reviewer). */
120
+ export function isEngineAxisSeat(seat: string): seat is "judge" | "reviewer" {
121
+ return seat === "judge" || seat === "reviewer";
122
+ }
123
+
119
124
  /**
120
- * Set or clear persistent engine on the Judge seat only (#356 MVP).
125
+ * Set or clear persistent engine on Judge or Reviewer (#356 / #378).
121
126
  * Engine-only seats are rejected — model triple remains required.
122
127
  */
123
128
  export function setPersistentSeatEngine(
@@ -125,8 +130,8 @@ export function setPersistentSeatEngine(
125
130
  seat: PublicConfigurableSeat,
126
131
  engine: string | undefined,
127
132
  ): PublicCliConfig {
128
- if (seat !== "judge") {
129
- throw new Error(`engine axis is judge-only; refused seat ${seat}`);
133
+ if (!isEngineAxisSeat(seat)) {
134
+ throw new Error(`engine axis is judge+reviewer only; refused seat ${seat}`);
130
135
  }
131
136
  const previous = config.seats[seat];
132
137
  if (previous === undefined) {
@@ -161,8 +166,8 @@ export function seatModelOnly(seat: PersistentSeatConfig): SeatModelConfig {
161
166
  }
162
167
 
163
168
  /**
164
- * Config-parse seam: engine axis is Judge-only; Judge engine names need only
165
- * path-safety syntax (no closed material catalog; #376 / ADR 0069).
169
+ * Config-parse seam: engine axis is Judge+Reviewer; engine names need only
170
+ * path-safety syntax (no closed material catalog; #376 / #378 / ADR 0069).
166
171
  * Call with packageRoot after load / before dispatch (#356).
167
172
  * Syntax authority = assertLegalEngineName (no injected duplicate).
168
173
  */
@@ -173,9 +178,9 @@ export function validatePublicCliConfigEngines(
173
178
  for (const seat of Object.keys(config.seats) as PublicConfigurableSeat[]) {
174
179
  const row = config.seats[seat];
175
180
  if (row?.engine === undefined) continue;
176
- if (seat !== "judge") {
181
+ if (!isEngineAxisSeat(seat)) {
177
182
  throw new Error(
178
- `config seat ${seat} engine is not allowed; engine axis is judge-only`,
183
+ `config seat ${seat} engine is not allowed; engine axis is judge+reviewer only`,
179
184
  );
180
185
  }
181
186
  try {
@@ -364,14 +369,14 @@ function attachEngineAxis(
364
369
  config: PublicCliConfig,
365
370
  invocation?: InvocationModelOverride,
366
371
  ): EffectiveSeat {
367
- // #356 MVP: engine axis is Judge-only. Other seats stay unconfigured.
368
- if (seat.seat !== "judge") {
372
+ // #356 / #378: engine axis is Judge+Reviewer. Other seats stay unconfigured.
373
+ if (!isEngineAxisSeat(seat.seat)) {
369
374
  return {
370
375
  ...seat,
371
376
  engineSource: "unconfigured",
372
377
  };
373
378
  }
374
- const persistentEngine = config.seats.judge?.engine;
379
+ const persistentEngine = config.seats[seat.seat]?.engine;
375
380
  if (invocation?.engine !== undefined) {
376
381
  return {
377
382
  ...seat,
@@ -1898,14 +1898,17 @@ export async function admitReviewerInvocation(
1898
1898
  /**
1899
1899
  * Build the Pi prompt transport for an admitted Reviewer request.
1900
1900
  * Semantic input is fixed base only — caller instruction stays provenance on disk.
1901
+ * Optional engine material follows the same dual-path coordinates as Judge (#376/#378).
1901
1902
  */
1902
1903
  export function buildReviewerTransportPrompt(
1903
1904
  admitted: AdmittedReviewerInvocation,
1905
+ engineMaterial?: EngineSessionMaterial,
1904
1906
  ): string {
1905
- return [
1907
+ const lines = [
1906
1908
  `Base revision for the fixed review target: ${admitted.baseRevision}`,
1907
1909
  "Use this exact revision as the fixed review point.",
1908
- ].join("\n");
1910
+ ];
1911
+ return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
1909
1912
  }
1910
1913
 
1911
1914
  /**
@@ -288,8 +288,8 @@ const GLOBAL_OPTIONS = [
288
288
  repeatable: false,
289
289
  form: "option",
290
290
  description: {
291
- en: "Optional Judge labor engine for this invocation (owner pool-directive name; packaged notes attached when present; judge-only).",
292
- zh: "本调用可选 Judge 劳动引擎(池令名字;有包内调法笔记则附卷;仅 Judge)。",
291
+ en: "Optional Judge/Reviewer labor engine for this invocation (owner pool-directive name; packaged notes attached when present; judge+reviewer only).",
292
+ zh: "本调用可选 Judge/Reviewer 劳动引擎(池令名字;有包内调法笔记则附卷;仅 Judge+Reviewer)。",
293
293
  },
294
294
  },
295
295
  {
@@ -7,6 +7,8 @@
7
7
  import { writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
 
10
+ import { AK_ROLE_ENGINE_ENV } from "../engine-detour.ts";
11
+ import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
10
12
  import {
11
13
  loadPackagedMethodSkillMaterial,
12
14
  resolvePackagedMethodSkillPath,
@@ -65,6 +67,7 @@ import {
65
67
  resolveControlledFailureResumeObservation,
66
68
  controlledFailureInputFromResolution,
67
69
  explicitInternalKnownFailureClassificationInput,
70
+ readEngineDetourInfrastructureFailure,
68
71
  settleFailureTerminalResult,
69
72
  trySettleReviewerTerminalResult,
70
73
  trySettleComplianceAuditIncompleteTerminalResult,
@@ -83,6 +86,8 @@ export type ReviewerRunEnv = {
83
86
  correlationId?: string;
84
87
  piRunner?: ExplicitInternalPiRunner;
85
88
  model?: SeatModelConfig;
89
+ /** Optional labor engine name (config→activation; session material + leg channel). */
90
+ engine?: string;
86
91
  credentials?: CredentialProviders;
87
92
  createRunId?: () => string;
88
93
  extraPiArgs?: readonly string[];
@@ -109,10 +114,14 @@ export function buildReviewerActivationExtraArgs(
109
114
  options: {
110
115
  packageRoot: string;
111
116
  model?: SeatModelConfig;
117
+ engine?: string;
112
118
  extraPiArgs?: readonly string[];
113
119
  },
114
120
  ): string[] {
115
- const prompt = buildReviewerTransportPrompt(admitted);
121
+ const prompt = buildReviewerTransportPrompt(
122
+ admitted,
123
+ engineSessionMaterialFromOptions(options),
124
+ );
116
125
  const skillPath = resolvePackagedMethodSkillPath(
117
126
  options.packageRoot,
118
127
  "code-review",
@@ -278,12 +287,17 @@ async function dispatchAdmittedReviewer(input: {
278
287
  extraArgs: string[];
279
288
  lease: RunWriterLease;
280
289
  methodMaterial: PackagedMethodSkillMaterial;
290
+ /**
291
+ * Mechanical engine provenance for initial Reviewer dispatch only.
292
+ * Explicit — never read from env.engine here, so resume cannot rewrite it.
293
+ */
294
+ effectiveEngine?: string;
281
295
  }): Promise<{
282
296
  exitCode: number;
283
297
  admitted: AdmittedReviewerInvocation;
284
298
  terminal?: TerminalResult;
285
299
  }> {
286
- const { admitted, env, io, extraArgs, lease, methodMaterial } = input;
300
+ const { admitted, env, io, extraArgs, lease, methodMaterial, effectiveEngine } = input;
287
301
  try {
288
302
  const missingCredential = missingCredentialPreDispatchFailure(
289
303
  env.model,
@@ -296,7 +310,7 @@ async function dispatchAdmittedReviewer(input: {
296
310
  io,
297
311
  );
298
312
  }
299
- await markRunRunning(admitted.runDirectory, env.model);
313
+ await markRunRunning(admitted.runDirectory, env.model, effectiveEngine);
300
314
  await clearTypedProviderHttpObservation(admitted.runDirectory);
301
315
  await clearReviewerDispatchRejection(admitted.runDirectory);
302
316
 
@@ -306,6 +320,14 @@ async function dispatchAdmittedReviewer(input: {
306
320
  PI_CODING_AGENT_DIR: env.agentDir,
307
321
  AK_ROLE_RUN_DIR: admitted.runDirectory,
308
322
  };
323
+ // Engine presence/name signal: registration gate + leg channel (no per-engine branch).
324
+ // Delete ambient inheritance first; own-key undefined mask survives process.env re-merge.
325
+ delete childEnv[AK_ROLE_ENGINE_ENV];
326
+ if (env.engine !== undefined && env.engine.trim() !== "") {
327
+ childEnv[AK_ROLE_ENGINE_ENV] = env.engine.trim();
328
+ } else {
329
+ childEnv[AK_ROLE_ENGINE_ENV] = undefined;
330
+ }
309
331
  const correlationId = admitted.correlationId ?? env.correlationId;
310
332
  if (correlationId !== undefined && correlationId.trim() !== "") {
311
333
  childEnv.AK_CORRELATION_ID = correlationId;
@@ -393,13 +415,27 @@ async function dispatchAdmittedReviewer(input: {
393
415
  };
394
416
  }
395
417
 
418
+ // Prefer engine-detour infrastructure failure already on the session principal
419
+ // over a later secondary knownFailure / provider-stop after abort (#357 T2 / #378).
420
+ const infrastructureFailure = await readEngineDetourInfrastructureFailure(
421
+ admitted.sessionFile,
422
+ );
396
423
  const credentialFailure = postRunMissingCredentialFailure(
397
424
  result,
398
425
  env.model,
399
426
  env.credentials,
400
427
  );
401
428
  const resolution = await resolveAuditedRunnerFailureResolution({
402
- runner: result.knownFailure,
429
+ runner:
430
+ infrastructureFailure === undefined
431
+ ? result.knownFailure
432
+ : {
433
+ cause: infrastructureFailure.cause,
434
+ diagnostic: infrastructureFailure.diagnostic,
435
+ ...(infrastructureFailure.identity === undefined
436
+ ? {}
437
+ : { identity: infrastructureFailure.identity }),
438
+ },
403
439
  sessionFile: admitted.sessionFile,
404
440
  credential: credentialFailure,
405
441
  runDirectory: admitted.runDirectory,
@@ -496,6 +532,7 @@ export async function runPublicReviewer(
496
532
  const extraArgs = buildReviewerActivationExtraArgs(admitted, {
497
533
  packageRoot: env.packageRoot,
498
534
  ...(env.model === undefined ? {} : { model: env.model }),
535
+ ...(env.engine === undefined ? {} : { engine: env.engine }),
499
536
  ...(env.extraPiArgs === undefined ? {} : { extraPiArgs: env.extraPiArgs }),
500
537
  });
501
538
 
@@ -509,6 +546,8 @@ export async function runPublicReviewer(
509
546
  extraArgs,
510
547
  lease,
511
548
  methodMaterial,
549
+ // #378: only initial Reviewer dispatch records mechanical engine provenance.
550
+ ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
512
551
  });
513
552
  }
514
553
 
@@ -35,6 +35,8 @@ type Dependencies = Readonly<{
35
35
  fault?(operation: ReviewerAgentFaultPoint): void;
36
36
  /** When set, child credential/config scratch is created under this parent so cleanup proofs stay process-local. */
37
37
  credentialScratchParent?: string;
38
+ /** Package root for optional engine method-material on legs (#378). */
39
+ packageRoot?: string;
38
40
  }>;
39
41
  function classify(error: unknown, signal?: AbortSignal): ReviewerFailureClassification { if (signal?.aborted) return "cancelled"; if (typeof error === "object" && error !== null && "reviewerFailure" in error) return (error as { reviewerFailure: ReviewerFailureClassification }).reviewerFailure; return "unknown"; }
40
42
  function failed(error: unknown, target: ReviewerTargetSnapshot, prompt: ReviewerPromptText, signal?: AbortSignal, retained?: string): ReviewerFailedLegRunResult {
@@ -76,6 +78,9 @@ export function createReviewerAgentRunner(dependencies: Dependencies = {}): Revi
76
78
  ...(dependencies.credentialScratchParent === undefined
77
79
  ? {}
78
80
  : { credentialScratchParent: dependencies.credentialScratchParent }),
81
+ ...(dependencies.packageRoot === undefined
82
+ ? {}
83
+ : { packageRoot: dependencies.packageRoot }),
79
84
  });
80
85
  const disposition = await workspaceOwner.dispose(workspace);
81
86
  return [leg.axis, Object.freeze({ status: "successful" as const, report: child.report, usage: child.usage, target: batch.target, prompt: child.prompt, workspaceDisposition: disposition })] as const;
@@ -5,6 +5,8 @@ import type { AcceptedReviewerLeg } from "./reviewer-dispatch.ts";
5
5
  export type ReviewerChildExecuteOptions = Readonly<{
6
6
  signal?: AbortSignal;
7
7
  credentialScratchParent?: string;
8
+ /** Package root for optional engine method-material on legs (#378). */
9
+ packageRoot?: string;
8
10
  }>;
9
11
 
10
12
  /**
@@ -591,7 +591,7 @@ export function createRoleRuntimeExtension(
591
591
  let pendingNavigatorSettlement: Promise<void> | undefined;
592
592
  let navigatorWorkContext: NavigatorWorkContext | undefined;
593
593
  const pendingInfrastructureToolCallIds = new Set<string>();
594
- // #357 T2: engine detour once-latch + registration (Judge+engine only).
594
+ // #357 T2 / #378: engine detour once-latch + registration (Judge|Reviewer+engine).
595
595
  let engineDetourRegistration: ReturnType<typeof registerEngineDetourTool> | undefined;
596
596
  // #288 primary-session thin adapter. The policy is the sole budget owner;
597
597
  // terminating-tool rejections and mechanical delivery requests share two turns.
@@ -1168,9 +1168,12 @@ export function createRoleRuntimeExtension(
1168
1168
  }
1169
1169
 
1170
1170
  await executeActivationStage(entry.role, activationStage(entry.role, runtime), { clock, writeTrace });
1171
- // #357 T2: Judge+engine activation registers the package detour tool once.
1172
- // Gate is env presence only — no per-engine execute branch; no judge-role spawn.
1173
- if (entry.role === "judge" && engineDetourRegistration === undefined) {
1171
+ // #357 T2 / #378: Judge|Reviewer+engine activation registers the package detour tool once.
1172
+ // Gate is env presence only — no per-engine execute branch; no role-module spawn.
1173
+ if (
1174
+ (entry.role === "judge" || entry.role === "reviewer") &&
1175
+ engineDetourRegistration === undefined
1176
+ ) {
1174
1177
  engineDetourRegistration = registerEngineDetourTool(pi, hostActions);
1175
1178
  if (!engineDetourRegistration.registered) {
1176
1179
  engineDetourRegistration = undefined;