@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.
- package/README.md +3 -2
- package/README.zh-CN.md +4 -3
- package/dist/acp-host/production-host.js +854 -111
- package/dist/headless-host/production-host.js +850 -107
- package/dist/migrate-book-topology.js +5 -5
- package/dist/public-cli/invocation.js +10 -4
- package/dist/public-cli/main.js +733 -239
- package/dist/public-cli/option-definitions.js +5 -5
- package/dist/public-cli/post-admission.js +23 -8
- package/dist/public-cli/reviewer-run.js +339 -0
- package/dist/public-cli/terminal.js +9 -0
- package/dist/public-cli/turn-request.js +1 -1
- package/dist/public-role-summons.js +348 -16
- package/package.json +1 -1
- package/src/host-contracts.ts +0 -1
- package/src/public-cli/invocation.ts +19 -9
- package/src/public-cli/option-definitions.ts +5 -5
- package/src/public-cli/post-admission.ts +39 -9
- package/src/public-cli/reviewer-run.ts +238 -53
- package/src/public-cli/settlement.ts +0 -1
- package/src/public-cli/terminal.ts +27 -0
- package/src/public-cli/turn-request.ts +7 -1
- package/src/public-role-summons.ts +510 -17
- package/src/reviewer-role.ts +1 -2
- package/src/role-runtime.ts +2 -2
|
@@ -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 {
|
|
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 {
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
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";
|
package/src/reviewer-role.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
package/src/role-runtime.ts
CHANGED
|
@@ -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: "
|
|
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
|
|
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>;
|