@akagilnc/pi-workflow-roles 0.1.4528 → 0.1.4586

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.
@@ -9,15 +9,23 @@
9
9
  * (`reading 'dirname'`, `reading 'tryHomeFromAkRolesPath'`). Dynamic import
10
10
  * starts after the caller module has finished init, so those slots stay intact.
11
11
  */
12
+ import { execFile } from "node:child_process";
12
13
  import { existsSync } from "node:fs";
13
- import { join } from "node:path";
14
+ import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join, relative, sep } from "node:path";
17
+ import { promisify } from "node:util";
14
18
 
15
19
  import type { CliIo } from "./public-cli/cli-io.ts";
16
20
  import type { CredentialProviders, EffectiveSeat } from "./public-cli/config.ts";
17
21
  import type { PublicCallableRole } from "./public-cli/registry.ts";
18
- import type { RoleTurnHost } from "./host-contracts.ts";
19
- import type { HostSelectionFailure, NamedRoleTurnHostAdapter } from "./public-cli/role-turn-host-resolution.ts";
22
+ import type {
23
+ DurablePrincipalAuthority,
24
+ RoleTurnHost,
25
+ RoleTurnModelConfig,
26
+ } from "./host-contracts.ts";
20
27
  import type { TerminalResult } from "./public-cli/terminal.ts";
28
+ import type { HostSelectionFailure, NamedRoleTurnHostAdapter } from "./public-cli/role-turn-host-resolution.ts";
21
29
  import { pickEngineAxis } from "./package-resources/engine-material.ts";
22
30
 
23
31
  /** Env published by the parent activation so nested summons never re-derive root. */
@@ -33,7 +41,8 @@ export type PublicSummonRole =
33
41
  | "judge"
34
42
  | "doctor"
35
43
  | "diarist"
36
- | "countersign";
44
+ | "countersign"
45
+ | "reviewer";
37
46
 
38
47
  export type PublicSummonRequest = {
39
48
  readonly role: PublicSummonRole;
@@ -45,7 +54,16 @@ export type PublicSummonRequest = {
45
54
  readonly packageRoot?: string;
46
55
  readonly io?: CliIo;
47
56
  readonly credentials?: CredentialProviders;
57
+ /** Parent turn's effective seat axes for same-seat child legs. */
58
+ readonly model?: RoleTurnModelConfig;
59
+ readonly host?: string;
60
+ readonly engine?: string;
61
+ readonly engineModel?: string;
48
62
  readonly agentDir?: string;
63
+ /** Parent durable-principal authority; defaults to the Pi adapter. */
64
+ readonly principalAuthority?: DurablePrincipalAuthority;
65
+ /** Parent role-run timeout projected onto the child turn request. */
66
+ readonly timeoutMs?: number;
49
67
  /**
50
68
  * Optional Pi argv forwarded to the role-turn host (same face as public CLI
51
69
  * seat extraPiArgs). Callers pass explicitly — no process.env test protocol.
@@ -85,8 +103,22 @@ export type PublicSummonRequest = {
85
103
  readonly boundTicketNumber?: number;
86
104
  /** Caller correlation id for nested leg ledger (ADR 0010 / #924). */
87
105
  readonly correlationId?: string;
106
+ /**
107
+ * Ephemeral host-turn cwd override (#946 fresh-copy sandbox). Admission keeps
108
+ * the public --project / cwd identity; the host turn runs under this path.
109
+ */
110
+ readonly executionCwd?: string;
111
+ /**
112
+ * Nested court station child (default true): omit Navigator auto-attendance
113
+ * and use station-child resume. Ordinary public-equivalent legs (dual-lens
114
+ * Reviewer axes) pass false so behavior matches a top-level single-axis call
115
+ * (#946 / ADR 0082).
116
+ */
117
+ readonly stationChild?: boolean;
88
118
  };
89
119
 
120
+ const execFileAsync = promisify(execFile);
121
+
90
122
  export type PublicSummonResult = {
91
123
  readonly exitCode: number;
92
124
  readonly terminal?: TerminalResult;
@@ -194,7 +226,7 @@ function hostSelectionFailureFromUnknown(error: unknown): HostSelectionFailure |
194
226
 
195
227
  type SummonEnvOk = {
196
228
  readonly home: string;
197
- readonly principalAuthority: import("./host-contracts.ts").DurablePrincipalAuthority;
229
+ readonly principalAuthority: DurablePrincipalAuthority;
198
230
  readonly agentDir: string;
199
231
  readonly sessionAppender: typeof import("./pi/role-turn-host.ts").appendPiSessionCustomEntry;
200
232
  readonly packageRoot: string;
@@ -205,6 +237,7 @@ type SummonEnvOk = {
205
237
  readonly engine?: string;
206
238
  readonly engineModel?: string;
207
239
  readonly host?: string;
240
+ readonly timeoutMs?: number;
208
241
  };
209
242
 
210
243
  /** #178: missing model is ok:false (typed fact), not a thrown message. */
@@ -224,6 +257,10 @@ async function createSummonEnv(
224
257
  readonly extraPiArgs?: readonly string[];
225
258
  readonly roleTurnHost?: RoleTurnHost;
226
259
  readonly hostAdapters?: readonly NamedRoleTurnHostAdapter[];
260
+ readonly principalAuthority?: DurablePrincipalAuthority;
261
+ readonly timeoutMs?: number;
262
+ /** Parent env model already host-facing — never project a second time. */
263
+ readonly hostFacingModel?: RoleTurnModelConfig;
227
264
  },
228
265
  /** #178: after host selection, before missing-model — structural argv parse once. */
229
266
  afterHost?: () => void,
@@ -239,7 +276,7 @@ async function createSummonEnv(
239
276
  import("./public-cli/role-turn-host-resolution.ts"),
240
277
  import("./public-cli/config.ts"),
241
278
  ]);
242
- const principalAuthority = piDurablePrincipalAuthority;
279
+ const principalAuthority = options.principalAuthority ?? piDurablePrincipalAuthority;
243
280
  // Host first → argv (afterHost) → missing-model → provider projection (#617/#178/#840).
244
281
  const roleTurnHost = resolveRoleTurnHost(
245
282
  {
@@ -249,6 +286,7 @@ async function createSummonEnv(
249
286
  ...(options.extraPiArgs === undefined || options.extraPiArgs.length === 0
250
287
  ? {}
251
288
  : { extraPiArgs: options.extraPiArgs }),
289
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
252
290
  },
253
291
  { role: options.role, seat: options.seat, principalAuthority },
254
292
  );
@@ -261,15 +299,18 @@ async function createSummonEnv(
261
299
  };
262
300
  }
263
301
  const hostName = seatWithModel.host ?? "pi";
264
- const { loadHostProvidersTable, projectHostFacingProvider } = await import(
265
- "./public-cli/host-providers.ts"
266
- );
267
- const hostFacingSelection = projectHostFacingProvider(
268
- seatWithModel.selection,
269
- hostName,
270
- loadHostProvidersTable(options.home),
271
- options.home,
272
- );
302
+ let hostFacingSelection: RoleTurnModelConfig | undefined = options.hostFacingModel;
303
+ if (hostFacingSelection === undefined) {
304
+ const { loadHostProvidersTable, projectHostFacingProvider } = await import(
305
+ "./public-cli/host-providers.ts"
306
+ );
307
+ hostFacingSelection = projectHostFacingProvider(
308
+ seatWithModel.selection,
309
+ hostName,
310
+ loadHostProvidersTable(options.home),
311
+ options.home,
312
+ );
313
+ }
273
314
  return {
274
315
  ok: true,
275
316
  env: {
@@ -284,6 +325,7 @@ async function createSummonEnv(
284
325
  ...(hostFacingSelection === undefined ? {} : { model: hostFacingSelection }),
285
326
  ...projectSeatEngine(seatWithModel),
286
327
  ...projectSeatHost(seatWithModel),
328
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
287
329
  },
288
330
  };
289
331
  }
@@ -310,7 +352,29 @@ export async function summonPublicRole(
310
352
  options.credentials ?? (await loadCredentialProviders(agentDir));
311
353
  const config = await loadPublicCliConfig(home);
312
354
  // Nested summons: officer seat only (flag>seat>default pi). #178 order below.
313
- const seat = resolveEffectiveSeat(config, options.role, credentials);
355
+ const resolvedSeat = resolveEffectiveSeat(config, options.role, credentials);
356
+ const inheritedSelection = options.model === undefined
357
+ ? undefined
358
+ : {
359
+ provider: options.model.provider,
360
+ model: options.model.model,
361
+ ...(options.model.thinking === undefined
362
+ ? {}
363
+ : { thinking: options.model.thinking as import("./public-cli/registry.ts").PublicThinkingLevel }),
364
+ };
365
+ const seat: EffectiveSeat = {
366
+ ...resolvedSeat,
367
+ ...(inheritedSelection === undefined
368
+ ? {}
369
+ : { selection: inheritedSelection, source: "invocation" as const }),
370
+ ...(options.host === undefined
371
+ ? {}
372
+ : { host: options.host, hostSource: "invocation" as const }),
373
+ ...(options.engine === undefined
374
+ ? {}
375
+ : { engine: options.engine, engineSource: "invocation" as const }),
376
+ ...(options.engineModel === undefined ? {} : { engineModel: options.engineModel }),
377
+ };
314
378
  const captured = options.io === undefined ? createCapturingIo() : undefined;
315
379
  const io = options.io ?? captured!.io;
316
380
 
@@ -325,6 +389,12 @@ export async function summonPublicRole(
325
389
  ...(options.extraPiArgs === undefined ? {} : { extraPiArgs: options.extraPiArgs }),
326
390
  ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
327
391
  ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
392
+ ...(options.principalAuthority === undefined
393
+ ? {}
394
+ : { principalAuthority: options.principalAuthority }),
395
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
396
+ // Parent model is already host-facing; child must not project again.
397
+ ...(options.model === undefined ? {} : { hostFacingModel: options.model }),
328
398
  } as const;
329
399
 
330
400
  type Prepared =
@@ -341,7 +411,9 @@ export async function summonPublicRole(
341
411
  ok: true,
342
412
  env: {
343
413
  ...built.env,
344
- stationChild: true,
414
+ // Nested court stations keep station-child semantics; dual-lens
415
+ // ordinary Reviewer axes opt out (#946 / ADR 0082).
416
+ ...(options.stationChild === false ? {} : { stationChild: true }),
345
417
  // Forward composition-root adapters so nested court stations (e.g.
346
418
  // countersign → diarist) select the same faux/production table (#924).
347
419
  ...(options.hostAdapters === undefined
@@ -362,6 +434,7 @@ export async function summonPublicRole(
362
434
  ? {}
363
435
  : { correlationId: options.correlationId }),
364
436
  ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
437
+ ...(options.executionCwd === undefined ? {} : { executionCwd: options.executionCwd }),
365
438
  },
366
439
  };
367
440
  } catch (error) {
@@ -503,6 +576,17 @@ export async function summonPublicRole(
503
576
  result = stepped.ok;
504
577
  break;
505
578
  }
579
+ case "reviewer": {
580
+ const [{ runPublicReviewer }, { parseReviewerArgv }] = await Promise.all([
581
+ import("./public-cli/reviewer-run.ts"),
582
+ import("./public-cli/invocation.ts"),
583
+ ]);
584
+ const stepped = await runPrepared(parseReviewerArgv, (env, once) =>
585
+ runPublicReviewer(options.argv, env as never, io, once));
586
+ if ("fail" in stepped) return stepped.fail;
587
+ result = stepped.ok;
588
+ break;
589
+ }
506
590
  }
507
591
 
508
592
  const stderr = captured?.stderrText();
@@ -518,6 +602,415 @@ export async function summonPublicRole(
518
602
  };
519
603
  }
520
604
 
605
+ /**
606
+ * Inject `--lens` into a public Reviewer argv that has none. Keeps every other
607
+ * token (including `--project` and `--`) exactly as the single-axis entry saw it.
608
+ */
609
+ function withReviewerLens(
610
+ argv: readonly string[],
611
+ lens: "completeness" | "correctness",
612
+ ): string[] {
613
+ const separator = argv.indexOf("--");
614
+ if (separator === -1) return [...argv, "--lens", lens];
615
+ return [...argv.slice(0, separator), "--lens", lens, ...argv.slice(separator)];
616
+ }
617
+
618
+ /** Caller project may be a repo subdirectory; sandboxes keep that relative path. */
619
+ async function resolveReviewerWorktreeRoots(projectRoot: string): Promise<{
620
+ readonly callerProjectRoot: string;
621
+ readonly sourceProjectRoot: string;
622
+ readonly projectRelative: string;
623
+ }> {
624
+ const callerProjectRoot = await realpath(projectRoot);
625
+ const sourceProjectRoot = await realpath((await execFileAsync(
626
+ "git",
627
+ ["rev-parse", "--show-toplevel"],
628
+ { cwd: callerProjectRoot },
629
+ )).stdout.trim());
630
+ const callerRelative = relative(sourceProjectRoot, callerProjectRoot);
631
+ const projectRelative =
632
+ callerRelative === ""
633
+ || callerRelative === "."
634
+ || callerRelative.startsWith(`..${sep}`)
635
+ || callerRelative === ".."
636
+ ? ""
637
+ : callerRelative;
638
+ return { callerProjectRoot, sourceProjectRoot, projectRelative };
639
+ }
640
+
641
+ function reviewerSandboxPath(worktreeAxis: string, projectRelative: string): string {
642
+ return projectRelative === "" ? worktreeAxis : join(worktreeAxis, projectRelative);
643
+ }
644
+
645
+ export type EphemeralReviewerWorktree = {
646
+ readonly executionCwd: string;
647
+ /** Always safe to call once; delete failure is diagnostic only (#946 10a). */
648
+ readonly close: () => Promise<void>;
649
+ };
650
+
651
+ /**
652
+ * Open one ephemeral detached worktree at the source tree's current HEAD.
653
+ * Caller must close. Shared by explicit `--lens` and any Reviewer resume.
654
+ * Dual-lens batch mints its own pair so both legs share one PRE_HEAD snapshot.
655
+ */
656
+ export async function openEphemeralReviewerWorktree(options: {
657
+ readonly projectRoot: string;
658
+ readonly onCleanupDiagnostic?: (diagnostic: string) => void;
659
+ }): Promise<EphemeralReviewerWorktree> {
660
+ const { sourceProjectRoot, projectRelative } = await resolveReviewerWorktreeRoots(
661
+ options.projectRoot,
662
+ );
663
+ const { stdout: headStdout } = await execFileAsync(
664
+ "git",
665
+ ["rev-parse", "--verify", "HEAD^{commit}"],
666
+ { cwd: sourceProjectRoot },
667
+ );
668
+ const targetCommit = headStdout.trim();
669
+ const root = await mkdtemp(join(tmpdir(), "ak-reviewer-sandbox-"));
670
+ const worktreeRoot = join(root, "work");
671
+ let registered = false;
672
+ const rollback = async (cause: unknown): Promise<never> => {
673
+ // From registration onward, any pre-handle failure must remove the worktree
674
+ // and root. Keep the original prep cause; cleanup failures are diagnostic
675
+ // only (10a) — same face as close(), never silent (#946).
676
+ const cleanupErrors: unknown[] = [];
677
+ if (registered) {
678
+ try {
679
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreeRoot], {
680
+ cwd: sourceProjectRoot,
681
+ });
682
+ } catch (error) {
683
+ cleanupErrors.push(error);
684
+ }
685
+ }
686
+ try {
687
+ await rm(root, { recursive: true, force: true });
688
+ } catch (error) {
689
+ cleanupErrors.push(error);
690
+ }
691
+ if (cleanupErrors.length > 0) {
692
+ options.onCleanupDiagnostic?.([
693
+ "reviewer worktree cleanup failed",
694
+ ...cleanupErrors.map((error) =>
695
+ error instanceof Error ? error.message : String(error)),
696
+ ].join("\n"));
697
+ }
698
+ throw cause;
699
+ };
700
+ try {
701
+ await execFileAsync("git", ["worktree", "add", "--detach", worktreeRoot, targetCommit], {
702
+ cwd: sourceProjectRoot,
703
+ });
704
+ registered = true;
705
+ // Preserve caller subdirectory even when absent from the pinned commit.
706
+ if (projectRelative !== "") {
707
+ await mkdir(reviewerSandboxPath(worktreeRoot, projectRelative), { recursive: true });
708
+ }
709
+ } catch (error) {
710
+ await rollback(error);
711
+ }
712
+ let closed = false;
713
+ return {
714
+ executionCwd: reviewerSandboxPath(worktreeRoot, projectRelative),
715
+ close: async () => {
716
+ if (closed) return;
717
+ closed = true;
718
+ const cleanupErrors: unknown[] = [];
719
+ try {
720
+ await execFileAsync("git", ["worktree", "remove", worktreeRoot], {
721
+ cwd: sourceProjectRoot,
722
+ });
723
+ } catch (error) {
724
+ cleanupErrors.push(error);
725
+ }
726
+ try {
727
+ await rm(root, { recursive: true, force: true });
728
+ } catch (error) {
729
+ cleanupErrors.push(error);
730
+ }
731
+ if (cleanupErrors.length > 0) {
732
+ options.onCleanupDiagnostic?.([
733
+ "reviewer worktree cleanup failed",
734
+ ...cleanupErrors.map((error) =>
735
+ error instanceof Error ? error.message : String(error)),
736
+ ].join("\n"));
737
+ }
738
+ },
739
+ };
740
+ }
741
+
742
+ /**
743
+ * One ephemeral detached worktree at the source tree's current HEAD.
744
+ * Always removed after `run` settles; delete failure is diagnostic only (#946 10a).
745
+ */
746
+ export async function withEphemeralReviewerWorktree<T>(options: {
747
+ readonly projectRoot: string;
748
+ readonly run: (executionCwd: string) => Promise<T>;
749
+ readonly onCleanupDiagnostic?: (diagnostic: string) => void;
750
+ }): Promise<T> {
751
+ const sandbox = await openEphemeralReviewerWorktree({
752
+ projectRoot: options.projectRoot,
753
+ ...(options.onCleanupDiagnostic === undefined
754
+ ? {}
755
+ : { onCleanupDiagnostic: options.onCleanupDiagnostic }),
756
+ });
757
+ try {
758
+ return await options.run(sandbox.executionCwd);
759
+ } finally {
760
+ await sandbox.close();
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Default Reviewer call: two explicit single-axis public legs in independent
766
+ * ephemeral detached worktrees, started as one parallel batch. Each leg reuses
767
+ * the caller's public argv and only adds `--lens` (10a). Worktrees are stateless
768
+ * execution sandboxes — created from the caller's current HEAD, always deleted
769
+ * when the call ends (delete failure is diagnostic only). Lifecycle stays in
770
+ * this shared summons seam, never in the role module (#946).
771
+ */
772
+ export async function summonParallelReviewerLenses(options: {
773
+ /** Public argv after the role token; must not already carry `--lens`. */
774
+ readonly argv: readonly string[];
775
+ /** Same cwd the single-axis public entry would receive for this call. */
776
+ readonly cwd: string;
777
+ readonly projectRoot: string;
778
+ /** Typed --base from the public parse; used only for pre-worktree fail-closed check. */
779
+ readonly baseRevision: string;
780
+ readonly home: string;
781
+ readonly agentDir?: string;
782
+ readonly credentials?: CredentialProviders;
783
+ readonly model?: RoleTurnModelConfig;
784
+ readonly host?: string;
785
+ readonly engine?: string;
786
+ readonly engineModel?: string;
787
+ readonly packageRoot?: string;
788
+ readonly signal?: AbortSignal;
789
+ readonly correlationId?: string;
790
+ readonly roleTurnHost?: RoleTurnHost;
791
+ readonly hostAdapters?: readonly NamedRoleTurnHostAdapter[];
792
+ readonly principalAuthority?: DurablePrincipalAuthority;
793
+ readonly timeoutMs?: number;
794
+ }): Promise<{
795
+ readonly completeness: PublicSummonResult;
796
+ readonly correctness: PublicSummonResult;
797
+ }> {
798
+ const describeFailure = (error: unknown): string => {
799
+ if (error instanceof AggregateError) {
800
+ return [error.message, ...error.errors.map(describeFailure)].join("\n");
801
+ }
802
+ return error instanceof Error ? error.message : String(error);
803
+ };
804
+ const failedResult = (error: unknown): PublicSummonResult => ({
805
+ exitCode: 1,
806
+ stderr: describeFailure(error),
807
+ });
808
+ const dualFailure = (error: unknown) => {
809
+ const failure = failedResult(error);
810
+ return { completeness: failure, correctness: failure } as const;
811
+ };
812
+
813
+ // Worktree prep shares one root: git toplevel, never a subdir input.
814
+ // Caller project may be a repo subdirectory; child sandboxes keep that relative path.
815
+ // Every pre-dispatch target check failure keeps the existing dual-child batch surface.
816
+ let sourceProjectRoot: string;
817
+ let projectRelative: string;
818
+ let targetCommit: string;
819
+ const statusArgs = [
820
+ "status",
821
+ "--porcelain=v1",
822
+ "--untracked-files=all",
823
+ "--",
824
+ ":/",
825
+ ":(top,exclude).claude/worktrees/**",
826
+ ] as const;
827
+ try {
828
+ const roots = await resolveReviewerWorktreeRoots(options.projectRoot);
829
+ sourceProjectRoot = roots.sourceProjectRoot;
830
+ projectRelative = roots.projectRelative;
831
+ const { stdout: statusStdout } = await execFileAsync("git", statusArgs, {
832
+ cwd: sourceProjectRoot,
833
+ });
834
+ if (statusStdout !== "") {
835
+ const diagnostic = [
836
+ "Reviewer target status gate failed:",
837
+ "git status --porcelain=v1 --untracked-files=all -- :/ ':(top,exclude).claude/worktrees/**'",
838
+ statusStdout,
839
+ ].join("\n");
840
+ return dualFailure(new Error(diagnostic));
841
+ }
842
+ const { stdout: targetStdout } = await execFileAsync(
843
+ "git",
844
+ ["rev-parse", "--verify", "HEAD^{commit}"],
845
+ { cwd: sourceProjectRoot },
846
+ );
847
+ targetCommit = targetStdout.trim();
848
+ // Typed base from the public parse — covers --base value and --base=value alike.
849
+ // Child legs still carry the original argv token unchanged (10a).
850
+ await execFileAsync(
851
+ "git",
852
+ ["rev-parse", "--verify", `${options.baseRevision}^{commit}`],
853
+ { cwd: sourceProjectRoot },
854
+ );
855
+ } catch (error) {
856
+ return dualFailure(error);
857
+ }
858
+ const root = await mkdtemp(join(tmpdir(), "ak-reviewer-lenses-"));
859
+ const completenessRoot = join(root, "completeness");
860
+ const correctnessRoot = join(root, "correctness");
861
+ const worktrees = [completenessRoot, correctnessRoot] as const;
862
+ const created = new Set<string>();
863
+ let results: { completeness: PublicSummonResult; correctness: PublicSummonResult };
864
+ const creation = await Promise.allSettled(
865
+ worktrees.map(async (path) => {
866
+ await execFileAsync("git", ["worktree", "add", "--detach", path, targetCommit], {
867
+ cwd: sourceProjectRoot,
868
+ });
869
+ // Git has registered the worktree — enter the rollback set before any further
870
+ // prep (mkdir of caller subdirectory) so a later failure cannot leak the entry.
871
+ created.add(path);
872
+ // Preserve caller subdirectory even when it is not present in the pinned commit.
873
+ if (projectRelative !== "") {
874
+ await mkdir(reviewerSandboxPath(path, projectRelative), { recursive: true });
875
+ }
876
+ }),
877
+ );
878
+ const creationFailures = creation.flatMap((result) =>
879
+ result.status === "rejected" ? [result.reason] : []);
880
+ if (creationFailures.length > 0) {
881
+ const failure = failedResult(new AggregateError(
882
+ creationFailures,
883
+ "parallel reviewer worktree creation failed",
884
+ ));
885
+ results = { completeness: failure, correctness: failure };
886
+ } else {
887
+ const summon = (
888
+ lens: "completeness" | "correctness",
889
+ worktreeAxis: string,
890
+ ): Promise<PublicSummonResult> => {
891
+ // Single-axis public entry as-is: caller's argv + only --lens, under the
892
+ // same cwd the omitted-lens call used. Durable projectRoot admits from
893
+ // that argv/cwd pair. Ephemeral worktree is executionCwd only (#946).
894
+ const sandbox = reviewerSandboxPath(worktreeAxis, projectRelative);
895
+ return summonPublicRole({
896
+ role: "reviewer",
897
+ argv: withReviewerLens(options.argv, lens),
898
+ cwd: options.cwd,
899
+ executionCwd: sandbox,
900
+ // Ordinary single-axis public semantics — not a nested court station.
901
+ stationChild: false,
902
+ home: options.home,
903
+ ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
904
+ ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
905
+ ...(options.model === undefined ? {} : { model: options.model }),
906
+ ...(options.host === undefined ? {} : { host: options.host }),
907
+ ...(options.engine === undefined ? {} : { engine: options.engine }),
908
+ ...(options.engineModel === undefined ? {} : { engineModel: options.engineModel }),
909
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
910
+ ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }),
911
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
912
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
913
+ ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
914
+ ...(options.principalAuthority === undefined
915
+ ? {}
916
+ : { principalAuthority: options.principalAuthority }),
917
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
918
+ });
919
+ };
920
+ const settled = await Promise.allSettled([
921
+ summon("completeness", completenessRoot),
922
+ summon("correctness", correctnessRoot),
923
+ ]);
924
+ results = {
925
+ completeness: settled[0].status === "fulfilled"
926
+ ? settled[0].value
927
+ : failedResult(settled[0].reason),
928
+ correctness: settled[1].status === "fulfilled"
929
+ ? settled[1].value
930
+ : failedResult(settled[1].reason),
931
+ };
932
+ }
933
+ let sealDiagnostic: string | undefined;
934
+ try {
935
+ const { stdout: headBeforeStdout } = await execFileAsync(
936
+ "git",
937
+ ["rev-parse", "--verify", "HEAD^{commit}"],
938
+ { cwd: sourceProjectRoot },
939
+ );
940
+ const { stdout: sealedStatusStdout } = await execFileAsync("git", statusArgs, {
941
+ cwd: sourceProjectRoot,
942
+ });
943
+ const { stdout: headAfterStdout } = await execFileAsync(
944
+ "git",
945
+ ["rev-parse", "--verify", "HEAD^{commit}"],
946
+ { cwd: sourceProjectRoot },
947
+ );
948
+ const headBefore = headBeforeStdout.trim();
949
+ const headAfter = headAfterStdout.trim();
950
+ if (
951
+ headBefore !== targetCommit
952
+ || headAfter !== targetCommit
953
+ || sealedStatusStdout !== ""
954
+ ) {
955
+ sealDiagnostic = [
956
+ "Reviewer target final seal failed:",
957
+ `HEAD before status => ${headBefore}`,
958
+ `HEAD after status => ${headAfter}`,
959
+ `expected PRE_HEAD ${targetCommit}`,
960
+ "git status --porcelain=v1 --untracked-files=all -- :/ ':(top,exclude).claude/worktrees/**'",
961
+ sealedStatusStdout,
962
+ ].join("\n");
963
+ }
964
+ } catch (error) {
965
+ sealDiagnostic = `Reviewer target final seal failed:\n${describeFailure(error)}`;
966
+ }
967
+ if (sealDiagnostic !== undefined) {
968
+ const withSealFailure = (result: PublicSummonResult): PublicSummonResult => ({
969
+ ...result,
970
+ exitCode: 1,
971
+ stderr: [result.stderr, sealDiagnostic].filter(
972
+ (text): text is string => typeof text === "string" && text !== "",
973
+ ).join("\n"),
974
+ });
975
+ results = {
976
+ completeness: withSealFailure(results.completeness),
977
+ correctness: withSealFailure(results.correctness),
978
+ };
979
+ }
980
+ // Stateless worktrees: always remove every axis created for this call.
981
+ // Delete failure is diagnostic only — do not flip child exit codes; leftover
982
+ // tmp dirs are left to the OS and git worktree prune (#946).
983
+ const cleanup = await Promise.allSettled(
984
+ [...created].map((path) =>
985
+ execFileAsync("git", ["worktree", "remove", path], { cwd: sourceProjectRoot })),
986
+ );
987
+ const cleanupFailures = cleanup.flatMap((result) =>
988
+ result.status === "rejected" ? [result.reason] : []);
989
+ if (cleanupFailures.length === 0) {
990
+ const rootCleanup = await Promise.allSettled([rm(root, { recursive: true, force: true })]);
991
+ cleanupFailures.push(...rootCleanup.flatMap((result) =>
992
+ result.status === "rejected" ? [result.reason] : []));
993
+ }
994
+ if (cleanupFailures.length > 0) {
995
+ const diagnostic = [
996
+ "parallel reviewer worktree cleanup failed",
997
+ ...cleanupFailures.map((failure) =>
998
+ failure instanceof Error ? failure.message : String(failure)),
999
+ ].join("\n");
1000
+ const withCleanupDiagnostic = (result: PublicSummonResult): PublicSummonResult => ({
1001
+ ...result,
1002
+ stderr: [result.stderr, diagnostic].filter(
1003
+ (text): text is string => typeof text === "string" && text !== "",
1004
+ ).join("\n"),
1005
+ });
1006
+ results = {
1007
+ completeness: withCleanupDiagnostic(results.completeness),
1008
+ correctness: withCleanupDiagnostic(results.correctness),
1009
+ };
1010
+ }
1011
+ return results;
1012
+ }
1013
+
521
1014
  /** Gate officer summons: notary/auditor via --source-run; inspector via pointer instruction. */
522
1015
  export async function summonGateOfficer(options: {
523
1016
  readonly officer: "inspector" | "notary" | "auditor";
@@ -13,7 +13,6 @@ export type { ReviewerIntent };
13
13
  /** Frozen admitted inputs the behavior layer may consume — no flag surface. */
14
14
  export type ReviewerAdmittedInputs = Readonly<{
15
15
  baseRevision: string;
16
- /** Caller-selected single lens; required, no default. */
17
16
  lens: "completeness" | "correctness";
18
17
  authorityRefs?: readonly string[];
19
18
  /** Typed #176 ticketNumber from admitted invocation (Spec self-fetch primary). */
@@ -32,7 +31,7 @@ const reviewerAmendmentsSchema = Type.Object({
32
31
  }, {
33
32
  additionalProperties: true,
34
33
  description:
35
- "所选 lens 的 amendments 承载 candidates、逐条处置与 verdict 的完整报告(非仅 verdict 行);未选 lens 可省略。hard-stop/usage error 为 refused 时,已产出报告仍照录进所选 lens 字段。形状指引,非 schema 闸。",
34
+ "已运行 lens 的 amendments 承载 candidates、逐条处置与 verdict 的完整报告(非仅 verdict 行);显式单轴时另一轴可省略。hard-stop/usage error 为 refused 时,已产出报告仍照录对应 lens 字段。形状指引,非 schema 闸。",
36
35
  });
37
36
  // #836 r16 class 1: diagnostic is LLM/human-read narrative content — no code
38
37
  // branches on its length (src/reviewer-role.ts consumer: reviewer content is
@@ -156,7 +156,7 @@ const REVIEWER_TRANSPORT_FLAGS = Object.freeze([
156
156
  Object.freeze({
157
157
  name: "ak-review-lens",
158
158
  definition: Object.freeze({
159
- description: "Caller-selected single review lens: completeness or correctness",
159
+ description: "Single review lens: completeness or correctness",
160
160
  type: "string" as const,
161
161
  }),
162
162
  }),
@@ -441,7 +441,7 @@ type ActivationRuntime = {
441
441
  };
442
442
  /** Envelope decodes Reviewer transport flags inside the activation stage. */
443
443
  decodeReviewerAdmitted(): ReviewerAdmittedInputs;
444
- /** Envelope stores live parent activation for agent_start prompt assembly. */
444
+ /** Envelope stores live Reviewer activation for agent_start prompt assembly. */
445
445
  bindReviewerParent(activation: ReviewerActivation): void;
446
446
  collector: {
447
447
  activate(context: HostContext, event: { reason: string }): Promise<void>;