@akagilnc/pi-workflow-roles 0.1.4889 → 0.1.4911

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.
@@ -47,12 +47,6 @@ export type RoleTurnRequestProjectionOptions = {
47
47
  invocationScopeId?: string;
48
48
  /** Station child role run (#840): omit automatic navigator attendance. */
49
49
  stationChild?: boolean;
50
- /**
51
- * Optional host-turn cwd override. Durable admitted projectRoot stays the
52
- * identity source; Reviewer fresh-copy sandboxes (explicit --lens, dual-lens
53
- * legs, resume) inject the ephemeral worktree here (#946 统一新副本).
54
- */
55
- cwd?: string;
56
50
  };
57
51
 
58
52
  export type AdmittedTurnInvocation = {
@@ -73,7 +67,7 @@ export function projectRoleTurnRequest(
73
67
  },
74
68
  options: RoleTurnRequestProjectionOptions,
75
69
  ): RoleTurnRequest {
76
- const cwd = options.cwd ?? admitted.projectRoot ?? admitted.repoRoot ?? admitted.cwd;
70
+ const cwd = admitted.projectRoot ?? admitted.repoRoot ?? admitted.cwd;
77
71
  if (cwd === undefined) throw new Error("admitted invocation missing working directory");
78
72
  if (admitted.principal === undefined) throw new Error("admitted invocation missing principal");
79
73
  // #617 DK-3: turn model is the live seat/env model only — never the admitted
@@ -11,9 +11,8 @@
11
11
  */
12
12
  import { execFile } from "node:child_process";
13
13
  import { existsSync } from "node:fs";
14
- import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
15
- import { tmpdir } from "node:os";
16
- import { join, relative, sep } from "node:path";
14
+ import { realpath } from "node:fs/promises";
15
+ import { join } from "node:path";
17
16
  import { promisify } from "node:util";
18
17
 
19
18
  import type { CliIo } from "./public-cli/cli-io.ts";
@@ -114,11 +113,6 @@ export type PublicSummonRequest = {
114
113
  * the host session — not a package-built advice ledger.
115
114
  */
116
115
  readonly resumeRunId?: string;
117
- /**
118
- * Ephemeral host-turn cwd override (#946 fresh-copy sandbox). Admission keeps
119
- * the public --project / cwd identity; the host turn runs under this path.
120
- */
121
- readonly executionCwd?: string;
122
116
  /**
123
117
  * Nested court station child (default true): omit Navigator auto-attendance
124
118
  * and use station-child resume. Ordinary public-equivalent legs (dual-lens
@@ -448,7 +442,6 @@ export async function summonPublicRole(
448
442
  ? {}
449
443
  : { correlationId: options.correlationId }),
450
444
  ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
451
- ...(options.executionCwd === undefined ? {} : { executionCwd: options.executionCwd }),
452
445
  },
453
446
  };
454
447
  } catch (error) {
@@ -565,159 +558,22 @@ function withReviewerLens(
565
558
  return [...argv.slice(0, separator), "--lens", lens, ...argv.slice(separator)];
566
559
  }
567
560
 
568
- /** Caller project may be a repo subdirectory; sandboxes keep that relative path. */
569
- async function resolveReviewerWorktreeRoots(projectRoot: string): Promise<{
570
- readonly callerProjectRoot: string;
571
- readonly sourceProjectRoot: string;
572
- readonly projectRelative: string;
573
- }> {
561
+ /** Git toplevel for Reviewer target status / seal checks (subdir-safe). */
562
+ async function resolveReviewerGitToplevel(projectRoot: string): Promise<string> {
574
563
  const callerProjectRoot = await realpath(projectRoot);
575
- const sourceProjectRoot = await realpath((await execFileAsync(
564
+ return await realpath((await execFileAsync(
576
565
  "git",
577
566
  ["rev-parse", "--show-toplevel"],
578
567
  { cwd: callerProjectRoot },
579
568
  )).stdout.trim());
580
- const callerRelative = relative(sourceProjectRoot, callerProjectRoot);
581
- const projectRelative =
582
- callerRelative === ""
583
- || callerRelative === "."
584
- || callerRelative.startsWith(`..${sep}`)
585
- || callerRelative === ".."
586
- ? ""
587
- : callerRelative;
588
- return { callerProjectRoot, sourceProjectRoot, projectRelative };
589
- }
590
-
591
- function reviewerSandboxPath(worktreeAxis: string, projectRelative: string): string {
592
- return projectRelative === "" ? worktreeAxis : join(worktreeAxis, projectRelative);
593
569
  }
594
570
 
595
- export type EphemeralReviewerWorktree = {
596
- readonly executionCwd: string;
597
- /** Always safe to call once; delete failure is diagnostic only (#946 10a). */
598
- readonly close: () => Promise<void>;
599
- };
600
-
601
571
  /**
602
- * Open one ephemeral detached worktree at the source tree's current HEAD.
603
- * Caller must close. Shared by explicit `--lens` and any Reviewer resume.
604
- * Dual-lens batch mints its own pair so both legs share one PRE_HEAD snapshot.
605
- */
606
- export async function openEphemeralReviewerWorktree(options: {
607
- readonly projectRoot: string;
608
- readonly onCleanupDiagnostic?: (diagnostic: string) => void;
609
- }): Promise<EphemeralReviewerWorktree> {
610
- const { sourceProjectRoot, projectRelative } = await resolveReviewerWorktreeRoots(
611
- options.projectRoot,
612
- );
613
- const { stdout: headStdout } = await execFileAsync(
614
- "git",
615
- ["rev-parse", "--verify", "HEAD^{commit}"],
616
- { cwd: sourceProjectRoot },
617
- );
618
- const targetCommit = headStdout.trim();
619
- const root = await mkdtemp(join(tmpdir(), "ak-reviewer-sandbox-"));
620
- const worktreeRoot = join(root, "work");
621
- let registered = false;
622
- const rollback = async (cause: unknown): Promise<never> => {
623
- // From registration onward, any pre-handle failure must remove the worktree
624
- // and root. Keep the original prep cause; cleanup failures are diagnostic
625
- // only (10a) — same face as close(), never silent (#946).
626
- const cleanupErrors: unknown[] = [];
627
- if (registered) {
628
- try {
629
- await execFileAsync("git", ["worktree", "remove", "--force", worktreeRoot], {
630
- cwd: sourceProjectRoot,
631
- });
632
- } catch (error) {
633
- cleanupErrors.push(error);
634
- }
635
- }
636
- try {
637
- await rm(root, { recursive: true, force: true });
638
- } catch (error) {
639
- cleanupErrors.push(error);
640
- }
641
- if (cleanupErrors.length > 0) {
642
- options.onCleanupDiagnostic?.([
643
- "reviewer worktree cleanup failed",
644
- ...cleanupErrors.map((error) =>
645
- error instanceof Error ? error.message : String(error)),
646
- ].join("\n"));
647
- }
648
- throw cause;
649
- };
650
- try {
651
- await execFileAsync("git", ["worktree", "add", "--detach", worktreeRoot, targetCommit], {
652
- cwd: sourceProjectRoot,
653
- });
654
- registered = true;
655
- // Preserve caller subdirectory even when absent from the pinned commit.
656
- if (projectRelative !== "") {
657
- await mkdir(reviewerSandboxPath(worktreeRoot, projectRelative), { recursive: true });
658
- }
659
- } catch (error) {
660
- await rollback(error);
661
- }
662
- let closed = false;
663
- return {
664
- executionCwd: reviewerSandboxPath(worktreeRoot, projectRelative),
665
- close: async () => {
666
- if (closed) return;
667
- closed = true;
668
- const cleanupErrors: unknown[] = [];
669
- try {
670
- await execFileAsync("git", ["worktree", "remove", worktreeRoot], {
671
- cwd: sourceProjectRoot,
672
- });
673
- } catch (error) {
674
- cleanupErrors.push(error);
675
- }
676
- try {
677
- await rm(root, { recursive: true, force: true });
678
- } catch (error) {
679
- cleanupErrors.push(error);
680
- }
681
- if (cleanupErrors.length > 0) {
682
- options.onCleanupDiagnostic?.([
683
- "reviewer worktree cleanup failed",
684
- ...cleanupErrors.map((error) =>
685
- error instanceof Error ? error.message : String(error)),
686
- ].join("\n"));
687
- }
688
- },
689
- };
690
- }
691
-
692
- /**
693
- * One ephemeral detached worktree at the source tree's current HEAD.
694
- * Always removed after `run` settles; delete failure is diagnostic only (#946 10a).
695
- */
696
- export async function withEphemeralReviewerWorktree<T>(options: {
697
- readonly projectRoot: string;
698
- readonly run: (executionCwd: string) => Promise<T>;
699
- readonly onCleanupDiagnostic?: (diagnostic: string) => void;
700
- }): Promise<T> {
701
- const sandbox = await openEphemeralReviewerWorktree({
702
- projectRoot: options.projectRoot,
703
- ...(options.onCleanupDiagnostic === undefined
704
- ? {}
705
- : { onCleanupDiagnostic: options.onCleanupDiagnostic }),
706
- });
707
- try {
708
- return await options.run(sandbox.executionCwd);
709
- } finally {
710
- await sandbox.close();
711
- }
712
- }
713
-
714
- /**
715
- * Default Reviewer call: two explicit single-axis public legs in independent
716
- * ephemeral detached worktrees, started as one parallel batch. Each leg reuses
717
- * the caller's public argv and only adds `--lens` (10a). Worktrees are stateless
718
- * execution sandboxes — created from the caller's current HEAD, always deleted
719
- * when the call ends (delete failure is diagnostic only). Lifecycle stays in
720
- * this shared summons seam, never in the role module (#946).
572
+ * Default Reviewer call: two explicit single-axis public legs on the caller's
573
+ * ticket worktree, started as one parallel batch (#997). Each leg reuses the
574
+ * caller's public argv and only adds `--lens` (10a). No seat-specific ephemeral
575
+ * copy — same shared tree as other seats. Lifecycle stays in this shared
576
+ * summons seam, never in the role module (#946 / ADR 0018).
721
577
  */
722
578
  export async function summonParallelReviewerLenses(options: {
723
579
  /** Public argv after the role token; must not already carry `--lens`. */
@@ -725,7 +581,7 @@ export async function summonParallelReviewerLenses(options: {
725
581
  /** Same cwd the single-axis public entry would receive for this call. */
726
582
  readonly cwd: string;
727
583
  readonly projectRoot: string;
728
- /** Typed --base from the public parse; used only for pre-worktree fail-closed check. */
584
+ /** Typed --base from the public parse; used only for pre-dispatch fail-closed check. */
729
585
  readonly baseRevision: string;
730
586
  readonly home: string;
731
587
  readonly agentDir?: string;
@@ -760,11 +616,9 @@ export async function summonParallelReviewerLenses(options: {
760
616
  return { completeness: failure, correctness: failure } as const;
761
617
  };
762
618
 
763
- // Worktree prep shares one root: git toplevel, never a subdir input.
764
- // Caller project may be a repo subdirectory; child sandboxes keep that relative path.
765
- // Every pre-dispatch target check failure keeps the existing dual-child batch surface.
619
+ // Pre-dispatch target checks use git toplevel (subdir-safe). Every failure
620
+ // keeps the existing dual-child batch surface.
766
621
  let sourceProjectRoot: string;
767
- let projectRelative: string;
768
622
  let targetCommit: string;
769
623
  const statusArgs = [
770
624
  "status",
@@ -775,9 +629,7 @@ export async function summonParallelReviewerLenses(options: {
775
629
  ":(top,exclude).claude/worktrees/**",
776
630
  ] as const;
777
631
  try {
778
- const roots = await resolveReviewerWorktreeRoots(options.projectRoot);
779
- sourceProjectRoot = roots.sourceProjectRoot;
780
- projectRelative = roots.projectRelative;
632
+ sourceProjectRoot = await resolveReviewerGitToplevel(options.projectRoot);
781
633
  const { stdout: statusStdout } = await execFileAsync("git", statusArgs, {
782
634
  cwd: sourceProjectRoot,
783
635
  });
@@ -805,81 +657,50 @@ export async function summonParallelReviewerLenses(options: {
805
657
  } catch (error) {
806
658
  return dualFailure(error);
807
659
  }
808
- const root = await mkdtemp(join(tmpdir(), "ak-reviewer-lenses-"));
809
- const completenessRoot = join(root, "completeness");
810
- const correctnessRoot = join(root, "correctness");
811
- const worktrees = [completenessRoot, correctnessRoot] as const;
812
- const created = new Set<string>();
813
- let results: { completeness: PublicSummonResult; correctness: PublicSummonResult };
814
- const creation = await Promise.allSettled(
815
- worktrees.map(async (path) => {
816
- await execFileAsync("git", ["worktree", "add", "--detach", path, targetCommit], {
817
- cwd: sourceProjectRoot,
818
- });
819
- // Git has registered the worktree — enter the rollback set before any further
820
- // prep (mkdir of caller subdirectory) so a later failure cannot leak the entry.
821
- created.add(path);
822
- // Preserve caller subdirectory even when it is not present in the pinned commit.
823
- if (projectRelative !== "") {
824
- await mkdir(reviewerSandboxPath(path, projectRelative), { recursive: true });
825
- }
826
- }),
827
- );
828
- const creationFailures = creation.flatMap((result) =>
829
- result.status === "rejected" ? [result.reason] : []);
830
- if (creationFailures.length > 0) {
831
- const failure = failedResult(new AggregateError(
832
- creationFailures,
833
- "parallel reviewer worktree creation failed",
834
- ));
835
- results = { completeness: failure, correctness: failure };
836
- } else {
837
- const summon = (
838
- lens: "completeness" | "correctness",
839
- worktreeAxis: string,
840
- ): Promise<PublicSummonResult> => {
841
- // Single-axis public entry as-is: caller's argv + only --lens, under the
842
- // same cwd the omitted-lens call used. Durable projectRoot admits from
843
- // that argv/cwd pair. Ephemeral worktree is executionCwd only (#946).
844
- const sandbox = reviewerSandboxPath(worktreeAxis, projectRelative);
845
- return summonPublicRole({
846
- role: "reviewer",
847
- argv: withReviewerLens(options.argv, lens),
848
- cwd: options.cwd,
849
- executionCwd: sandbox,
850
- // Ordinary single-axis public semantics — not a nested court station.
851
- stationChild: false,
852
- home: options.home,
853
- ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
854
- ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
855
- ...(options.model === undefined ? {} : { model: options.model }),
856
- ...(options.host === undefined ? {} : { host: options.host }),
857
- ...(options.engine === undefined ? {} : { engine: options.engine }),
858
- ...(options.engineModel === undefined ? {} : { engineModel: options.engineModel }),
859
- ...(options.signal === undefined ? {} : { signal: options.signal }),
860
- ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }),
861
- ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
862
- ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
863
- ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
864
- ...(options.principalAuthority === undefined
865
- ? {}
866
- : { principalAuthority: options.principalAuthority }),
867
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
868
- });
869
- };
870
- const settled = await Promise.allSettled([
871
- summon("completeness", completenessRoot),
872
- summon("correctness", correctnessRoot),
873
- ]);
874
- results = {
875
- completeness: settled[0].status === "fulfilled"
876
- ? settled[0].value
877
- : failedResult(settled[0].reason),
878
- correctness: settled[1].status === "fulfilled"
879
- ? settled[1].value
880
- : failedResult(settled[1].reason),
881
- };
882
- }
660
+
661
+ const summon = (
662
+ lens: "completeness" | "correctness",
663
+ ): Promise<PublicSummonResult> => {
664
+ // Single-axis public entry as-is: caller's argv + only --lens, under the
665
+ // same cwd the omitted-lens call used. Durable projectRoot admits from
666
+ // that argv/cwd pair; host turn runs on the ticket worktree (#997).
667
+ return summonPublicRole({
668
+ role: "reviewer",
669
+ argv: withReviewerLens(options.argv, lens),
670
+ cwd: options.cwd,
671
+ // Ordinary single-axis public semantics — not a nested court station.
672
+ stationChild: false,
673
+ home: options.home,
674
+ ...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
675
+ ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
676
+ ...(options.model === undefined ? {} : { model: options.model }),
677
+ ...(options.host === undefined ? {} : { host: options.host }),
678
+ ...(options.engine === undefined ? {} : { engine: options.engine }),
679
+ ...(options.engineModel === undefined ? {} : { engineModel: options.engineModel }),
680
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
681
+ ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }),
682
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
683
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
684
+ ...(options.hostAdapters === undefined ? {} : { hostAdapters: options.hostAdapters }),
685
+ ...(options.principalAuthority === undefined
686
+ ? {}
687
+ : { principalAuthority: options.principalAuthority }),
688
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
689
+ });
690
+ };
691
+ const settled = await Promise.allSettled([
692
+ summon("completeness"),
693
+ summon("correctness"),
694
+ ]);
695
+ let results = {
696
+ completeness: settled[0].status === "fulfilled"
697
+ ? settled[0].value
698
+ : failedResult(settled[0].reason),
699
+ correctness: settled[1].status === "fulfilled"
700
+ ? settled[1].value
701
+ : failedResult(settled[1].reason),
702
+ };
703
+
883
704
  let sealDiagnostic: string | undefined;
884
705
  try {
885
706
  const { stdout: headBeforeStdout } = await execFileAsync(
@@ -927,37 +748,6 @@ export async function summonParallelReviewerLenses(options: {
927
748
  correctness: withSealFailure(results.correctness),
928
749
  };
929
750
  }
930
- // Stateless worktrees: always remove every axis created for this call.
931
- // Delete failure is diagnostic only — do not flip child exit codes; leftover
932
- // tmp dirs are left to the OS and git worktree prune (#946).
933
- const cleanup = await Promise.allSettled(
934
- [...created].map((path) =>
935
- execFileAsync("git", ["worktree", "remove", path], { cwd: sourceProjectRoot })),
936
- );
937
- const cleanupFailures = cleanup.flatMap((result) =>
938
- result.status === "rejected" ? [result.reason] : []);
939
- if (cleanupFailures.length === 0) {
940
- const rootCleanup = await Promise.allSettled([rm(root, { recursive: true, force: true })]);
941
- cleanupFailures.push(...rootCleanup.flatMap((result) =>
942
- result.status === "rejected" ? [result.reason] : []));
943
- }
944
- if (cleanupFailures.length > 0) {
945
- const diagnostic = [
946
- "parallel reviewer worktree cleanup failed",
947
- ...cleanupFailures.map((failure) =>
948
- failure instanceof Error ? failure.message : String(failure)),
949
- ].join("\n");
950
- const withCleanupDiagnostic = (result: PublicSummonResult): PublicSummonResult => ({
951
- ...result,
952
- stderr: [result.stderr, diagnostic].filter(
953
- (text): text is string => typeof text === "string" && text !== "",
954
- ).join("\n"),
955
- });
956
- results = {
957
- completeness: withCleanupDiagnostic(results.completeness),
958
- correctness: withCleanupDiagnostic(results.correctness),
959
- };
960
- }
961
751
  return results;
962
752
  }
963
753
 
@@ -227,12 +227,15 @@ async function rewriteOfficerPointerFile(
227
227
  if (!isPlainObject(page)) return;
228
228
  // Only the typed direct-officer pointer shape — never arbitrary .pointer.json.
229
229
  if (page.kind !== "direct-officer-run-pointer") return;
230
+ const before = JSON.stringify(page);
230
231
  rewriteRunDirectoryPathFieldsAgainstRewrites(
231
232
  page,
232
233
  OFFICER_POINTER_FIELDS,
233
234
  rewrites,
234
235
  );
235
- await writeFile(path, `${JSON.stringify(page)}\n`, "utf8");
236
+ if (JSON.stringify(page) !== before) {
237
+ await writeFile(path, `${JSON.stringify(page)}\n`, "utf8");
238
+ }
236
239
  }
237
240
 
238
241
  async function rewriteSitianRecordsJsonl(
@@ -422,26 +425,21 @@ export async function rewriteRoleRunDurablePages(input: {
422
425
  string,
423
426
  unknown
424
427
  >;
428
+ const before = JSON.stringify(page);
425
429
  rewriteAdmittedRoleRunPage(page, rewrites);
426
- await writeFile(admittedPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
430
+ if (JSON.stringify(page) !== before) {
431
+ await writeFile(admittedPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
432
+ }
427
433
  }
428
434
 
429
435
  const invocationPath = join(pagesDirectory, "invocation.json");
430
436
  if (existsSync(invocationPath)) {
431
- const page = JSON.parse(await readFile(invocationPath, "utf8")) as Record<
432
- string,
433
- unknown
434
- >;
435
- rewriteRunDirectoryPathFieldsAgainstRewrites(
436
- page,
437
- INVOCATION_PAGE_FIELDS,
438
- rewrites,
439
- );
440
- await writeFile(
441
- invocationPath,
442
- `${JSON.stringify(page, null, 2)}\n`,
443
- "utf8",
444
- );
437
+ const page = JSON.parse(await readFile(invocationPath, "utf8")) as Record<string, unknown>;
438
+ const before = JSON.stringify(page);
439
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page, INVOCATION_PAGE_FIELDS, rewrites);
440
+ if (JSON.stringify(page) !== before) {
441
+ await writeFile(invocationPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
442
+ }
445
443
  }
446
444
 
447
445
  const statePath = join(pagesDirectory, "run-state.json");
@@ -450,6 +448,7 @@ export async function rewriteRoleRunDurablePages(input: {
450
448
  string,
451
449
  unknown
452
450
  >;
451
+ const before = JSON.stringify(page);
453
452
  rewriteRunDirectoryPathFieldsAgainstRewrites(
454
453
  page,
455
454
  RUN_STATE_PAGE_FIELDS,
@@ -466,7 +465,9 @@ export async function rewriteRoleRunDurablePages(input: {
466
465
  if (isPlainObject(page.currentCourt)) {
467
466
  rewriteSummonsMaterials(page.currentCourt.summons, rewrites);
468
467
  }
469
- await writeFile(statePath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
468
+ if (JSON.stringify(page) !== before) {
469
+ await writeFile(statePath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
470
+ }
470
471
  }
471
472
 
472
473
  await rewriteNestedMachinePathPages(pagesDirectory, rewrites);