@akagilnc/pi-workflow-roles 0.1.3545 → 0.1.3552

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.
@@ -1,12 +1,8 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { copyFile, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, relative as pathRelative } from "node:path";
1
+ import { spawn } from "node:child_process";
4
2
  import { createInterface } from "node:readline";
5
- import { promisify } from "node:util";
6
3
 
7
4
  import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
8
5
  import { renderAgentStartMaterials } from "../agent-start-materials.ts";
9
- import { installGrokPreToolUseDeny } from "./bash-seatbelt.ts";
10
6
 
11
7
  /** ACP v1 surface used by the Grok adapter. Protocol details stay in this module. */
12
8
  export interface GrokAcpConnection {
@@ -17,13 +13,6 @@ export interface GrokAcpConnection {
17
13
  close(): Promise<void>;
18
14
  }
19
15
 
20
- export type GrokControlledInspection = Readonly<{
21
- /** Active configuration whose source is neither Grok builtin nor AK injection. */
22
- privateActive: readonly string[];
23
- /** Active AK-owned configuration observed by the same first-party inspect call. */
24
- akActive: readonly string[];
25
- }>;
26
-
27
16
  /** The shared envelope, prepared before session/new (systemPrompt delivery) and
28
17
  * able to observe the host's real builtin tool surface once it arrives post-session. */
29
18
  export type GrokPreparedTurn = Readonly<{
@@ -63,11 +52,6 @@ export function renderGrokSystemPromptOverride(authority: {
63
52
  return renderAgentStartMaterials(authority.body, authority.materials);
64
53
  }
65
54
 
66
- export type GrokCapabilityDeclaration = Readonly<{
67
- nativeToolNarrowing: false;
68
- preToolUseDeny: boolean;
69
- }>;
70
-
71
55
  export type GrokSessionIdentityAuthority = Readonly<{
72
56
  load(principal: RoleTurnRequest["principal"]): Promise<string | undefined>;
73
57
  bind(principal: RoleTurnRequest["principal"], sessionId: string): Promise<void>;
@@ -78,9 +62,7 @@ export type GrokSessionIdentityAuthority = Readonly<{
78
62
  export type GrokRoleTurnHostConfig = Readonly<{
79
63
  sessionIdentity: GrokSessionIdentityAuthority;
80
64
  connect(request: RoleTurnRequest): Promise<GrokAcpConnection>;
81
- inspect(request: RoleTurnRequest): Promise<GrokControlledInspection>;
82
65
  prepare(request: RoleTurnRequest): Promise<GrokPreparedTurn>;
83
- recordCapabilities(request: RoleTurnRequest, declaration: GrokCapabilityDeclaration): void | Promise<void>;
84
66
  }>;
85
67
 
86
68
  function failure(cause: "activation" | "session" | "output", name: string, code: string, details?: Readonly<Record<string, unknown>>): RoleTurnResult {
@@ -223,21 +205,13 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
223
205
  return {
224
206
  executeTurn(request) {
225
207
  const execution = serial.then(async (): Promise<RoleTurnResult> => {
226
- const inspected = await config.inspect(request);
227
- if (inspected.privateActive.length !== 0) {
228
- return failure("activation", "UncontrolledGrokSession", "private-config-active", {
229
- privateActive: [...inspected.privateActive],
230
- });
231
- }
232
208
  const continuation = request.continuation;
233
209
  const prepared = await config.prepare(request);
234
210
  let connection: GrokAcpConnection | undefined;
235
211
  let sessionId: string | undefined;
236
212
  let accepted = false;
237
213
  try {
238
- // AK injection proof is prepared MCP composition (envelope), not inspect.akActive.
239
- // Inspect only classifies first-party already-active sources; external packageRoot
240
- // materials reach session/new via prepare, not via Grok-native inspect paths.
214
+ // AK injection proof is prepared MCP composition (envelope).
241
215
  if (prepared.mcpServers.length === 0) {
242
216
  return failure("activation", "UncontrolledGrokSession", "ak-config-missing");
243
217
  }
@@ -249,20 +223,6 @@ export function createGrokRoleTurnHost(config: GrokRoleTurnHostConfig): RoleTurn
249
223
  const initializeMeta = initialized._meta as {
250
224
  modelState?: { availableModels?: unknown };
251
225
  } | undefined;
252
- const hookMeta = initialized._meta as { "x.ai/hooks"?: { blockingEvents?: unknown; decisions?: unknown } } | undefined;
253
- const hookCapability = hookMeta?.["x.ai/hooks"];
254
- const canDeny = Array.isArray(hookCapability?.blockingEvents)
255
- && hookCapability.blockingEvents.includes("pre_tool_use")
256
- && Array.isArray(hookCapability.decisions)
257
- && hookCapability.decisions.includes("deny");
258
- // ADR 0008: seatbelt hangs only on the activated Fixer. Capability alone
259
- // is not an installed belt, and review seats (ADR 0064) must stay unnarrowed.
260
- let preToolUseDeny = false;
261
- if (canDeny && request.activation.role === "fixer") {
262
- await installGrokPreToolUseDeny(request.home);
263
- preToolUseDeny = true;
264
- }
265
- await config.recordCapabilities(request, { nativeToolNarrowing: false, preToolUseDeny });
266
226
  const modelState = initializeMeta?.modelState;
267
227
  const availableModels = Array.isArray(modelState?.availableModels) ? modelState.availableModels : undefined;
268
228
  if (request.model !== undefined && availableModels !== undefined && !availableModels.some((entry) =>
@@ -436,325 +396,11 @@ const PRIVATE_COMPAT_ENV = Object.fromEntries(
436
396
  [`GROK_${vendor}_${kind}_ENABLED`, "false"] as const)),
437
397
  );
438
398
 
439
- type InspectItem = {
440
- readonly name?: unknown;
441
- readonly path?: unknown;
442
- readonly disabled?: unknown;
443
- readonly enabled?: unknown;
444
- readonly compatibilityStatus?: unknown;
445
- readonly source?: { readonly type?: unknown; readonly path?: unknown };
446
- };
447
-
448
- export type GrokInspectionClassificationOptions = Readonly<{
449
- /**
450
- * Calling-repo projectInstructions whose path is carried by HEAD and whose
451
- * working-tree bytes match that blob (#521 repo-instructions-are-shared-material).
452
- * These are shared repo material — not privateActive, not AK package injection.
453
- */
454
- readonly headMatchedProjectInstructionPaths?: ReadonlySet<string>;
455
- }>;
456
-
457
- const execFileAsync = promisify(execFile);
458
-
459
- function errnoCode(error: unknown): string | undefined {
460
- if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
461
- const code = (error as { code: unknown }).code;
462
- return typeof code === "string" ? code : undefined;
463
- }
464
-
465
- async function realpathIfPresent(path: string): Promise<string | undefined> {
466
- try {
467
- return await realpath(path);
468
- } catch (error) {
469
- if (errnoCode(error) === "ENOENT") return undefined;
470
- throw error;
471
- }
472
- }
473
-
474
- /**
475
- * Typed worktree readability: ENOENT → absent; permission and other IO stay loud.
476
- * Does not consult Git diagnostics.
477
- */
478
- async function worktreeFilePresence(path: string): Promise<"absent" | "present"> {
479
- try {
480
- const handle = await open(path, "r");
481
- await handle.close();
482
- return "present";
483
- } catch (error) {
484
- if (errnoCode(error) === "ENOENT") return "absent";
485
- throw error;
486
- }
487
- }
488
-
489
- /**
490
- * Map a worktree-relative path to the unique HEAD tree path it names.
491
- * Exact match first; otherwise a single case-insensitive hit in the same
492
- * directory (Grok may report `Claude.md` while HEAD stores `CLAUDE.md`).
493
- * Path identity keeps the inspect leaf name (final symlink not followed).
494
- * Git/IO failures propagate; only "not in HEAD" returns undefined.
495
- */
496
- async function resolveHeadTreePath(topLevel: string, relativePath: string): Promise<string | undefined> {
497
- const { stdout: exactOut } = await execFileAsync(
498
- "git",
499
- ["ls-tree", "--name-only", "HEAD", "--", relativePath],
500
- { cwd: topLevel, encoding: "utf8" },
501
- );
502
- const exactHits = exactOut.split("\n").map((name) => name.trim()).filter((name) => name !== "");
503
- if (exactHits.includes(relativePath)) return relativePath;
504
-
505
- const parent = dirname(relativePath);
506
- const leaf = basename(relativePath);
507
- // Path absence is an empty structured ls-tree result (exit 0), never stderr prose.
508
- if (parent !== ".") {
509
- const { stdout: parentOut } = await execFileAsync(
510
- "git",
511
- ["ls-tree", "--name-only", "HEAD", "--", parent],
512
- { cwd: topLevel, encoding: "utf8" },
513
- );
514
- const parentHits = parentOut.split("\n").map((name) => name.trim()).filter((name) => name !== "");
515
- if (!parentHits.includes(parent)) return undefined;
516
- }
517
-
518
- // Parent confirmed present (or root): list children. Any failure stays loud infrastructure.
519
- const { stdout: listing } = parent === "."
520
- ? await execFileAsync("git", ["ls-tree", "--name-only", "HEAD"], {
521
- cwd: topLevel,
522
- encoding: "utf8",
523
- })
524
- : await execFileAsync("git", ["ls-tree", "--name-only", `HEAD:${parent}`], {
525
- cwd: topLevel,
526
- encoding: "utf8",
527
- });
528
- const needle = leaf.toLowerCase();
529
- const hits = listing
530
- .split("\n")
531
- .map((name) => name.trim())
532
- .filter((name) => name !== "" && basename(name).toLowerCase() === needle)
533
- .map((name) => (parent === "." ? basename(name) : join(parent, basename(name))));
534
- return hits.length === 1 ? hits[0] : undefined;
535
- }
536
-
537
- /**
538
- * True when inspect-reported path is carried by calling-repo HEAD and the bytes
539
- * a host reads through that path match the HEAD blob
540
- * (#521 repo-instructions-are-shared-material).
541
- *
542
- * Expected negatives (return false): empty/outside path, HEAD does not carry
543
- * the path, worktree absent, or worktree bytes ≠ HEAD blob.
544
- * Infrastructure (throw with cause): git unavailable, unexpected git/repo
545
- * failure, permission or other IO on realpath/hash.
546
- */
547
- export async function isHeadMatchedProjectInstruction(
548
- repositoryCwd: string,
549
- absolutePath: string,
550
- ): Promise<boolean> {
551
- if (absolutePath === "" || absolutePath.includes("\0")) return false;
552
-
553
- const { stdout: topLevelOut } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
554
- cwd: repositoryCwd,
555
- encoding: "utf8",
556
- });
557
- // Prove HEAD is readable before path negatives — corrupt/missing HEAD stays loud.
558
- await execFileAsync("git", ["rev-parse", "--verify", "HEAD"], {
559
- cwd: repositoryCwd,
560
- encoding: "utf8",
561
- });
562
- const topLevel = await realpath(topLevelOut.trim());
563
-
564
- // Keep final leaf identity (do not realpath through a final-component symlink).
565
- const parent = await realpathIfPresent(dirname(absolutePath));
566
- if (parent === undefined) return false;
567
- const leaf = basename(absolutePath);
568
- if (leaf === "" || leaf === "." || leaf === "..") return false;
569
- const candidate = join(parent, leaf);
570
- const relative = pathRelative(topLevel, candidate);
571
- if (relative === "" || relative.startsWith("..") || isAbsolute(relative) || relative.includes("\0")) {
572
- return false;
573
- }
574
-
575
- const headRel = await resolveHeadTreePath(topLevel, relative);
576
- if (headRel === undefined) return false;
577
- const headFile = join(topLevel, headRel);
578
-
579
- const { stdout: headBlobOut } = await execFileAsync(
580
- "git",
581
- ["rev-parse", "--verify", `HEAD:${headRel}`],
582
- { cwd: topLevel, encoding: "utf8" },
583
- );
584
- const headBlob = headBlobOut.trim();
585
-
586
- // Prefer inspect-reported path bytes (hash-object follows symlink content).
587
- // Worktree absence is typed FS ENOENT; permission and other IO stay loud.
588
- // When exact casing is absent, fall back to the unique HEAD-cased path.
589
- let hashTarget = candidate;
590
- const candidatePresence = await worktreeFilePresence(candidate);
591
- if (candidatePresence === "absent") {
592
- if (candidate === headFile) return false;
593
- const headPresence = await worktreeFilePresence(headFile);
594
- if (headPresence === "absent") return false;
595
- hashTarget = headFile;
596
- }
597
- const { stdout } = await execFileAsync("git", ["hash-object", "--", hashTarget], {
598
- cwd: topLevel,
599
- encoding: "utf8",
600
- });
601
- return headBlob === stdout.trim();
602
- }
603
-
604
- function inspectItemPath(value: InspectItem): string {
605
- if (typeof value.source?.path === "string") return value.source.path;
606
- if (typeof value.path === "string") return value.path;
607
- return "";
608
- }
609
-
610
- function isInspectItemActive(value: InspectItem): boolean {
611
- return value.disabled !== true && value.enabled !== false && value.compatibilityStatus !== "disabled";
612
- }
613
-
614
- /** Collect active projectInstruction paths for HEAD-blob provenance resolution. */
615
- export function listActiveProjectInstructionPaths(document: Readonly<Record<string, unknown>>): readonly string[] {
616
- const items = document.projectInstructions;
617
- if (!Array.isArray(items)) return [];
618
- const paths: string[] = [];
619
- for (const value of items as InspectItem[]) {
620
- if (!isInspectItemActive(value)) continue;
621
- const path = inspectItemPath(value);
622
- if (path !== "") paths.push(path);
623
- }
624
- return paths;
625
- }
626
-
627
- /** Classify first-party inspect JSON by provenance; wording and item counts are irrelevant. */
628
- export function classifyGrokInspection(
629
- document: Readonly<Record<string, unknown>>,
630
- packageRoot: string,
631
- options: GrokInspectionClassificationOptions = {},
632
- ): GrokControlledInspection {
633
- const privateActive = new Set<string>();
634
- const akActive = new Set<string>();
635
- const headMatched = options.headMatchedProjectInstructionPaths ?? new Set<string>();
636
- const externalCompat = document.externalCompat as { cells?: unknown } | undefined;
637
- if (Array.isArray(externalCompat?.cells)) {
638
- for (const cell of externalCompat.cells as Array<{ vendor?: unknown; surface?: unknown; enabled?: unknown }>) {
639
- if (cell.enabled !== true) continue;
640
- privateActive.add(`externalCompat:${String(cell.vendor)}:${String(cell.surface)}`);
641
- }
642
- }
643
- for (const section of ["skills", "agents", "plugins", "mcpServers", "hooks", "projectInstructions"] as const) {
644
- const items = document[section];
645
- if (!Array.isArray(items)) continue;
646
- for (const value of items as InspectItem[]) {
647
- if (!isInspectItemActive(value)) continue;
648
- const sourceType = value.source?.type;
649
- const path = inspectItemPath(value);
650
- const identity = `${section}:${typeof value.name === "string" ? value.name : path}`;
651
- if (sourceType === "builtin" || sourceType === "bundled") continue;
652
- if (path === packageRoot || path.startsWith(`${packageRoot}/`)) akActive.add(identity);
653
- else if (section === "projectInstructions" && headMatched.has(path)) continue;
654
- else privateActive.add(identity);
655
- }
656
- }
657
- return { privateActive: [...privateActive].sort(), akActive: [...akActive].sort() };
658
- }
659
-
660
- /** AK Fixer PreToolUse seatbelt files written under controlled GROK_HOME/hooks. */
661
- export const AK_BASH_SEATBELT_HOOK_FILES = ["ak-bash-seatbelt.json", "ak-bash-seatbelt.mjs"] as const;
662
-
663
- function isMissingPathError(error: unknown): boolean {
664
- return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
665
- }
666
-
667
- /** Refuse symlink roots so copy/rm never follow a redirected controlled home (#594 F4). */
668
- export async function assertControlledGrokHomeIsRealDirectory(controlledHome: string): Promise<void> {
669
- let st;
670
- try {
671
- st = await lstat(controlledHome);
672
- } catch (error) {
673
- if (isMissingPathError(error)) return;
674
- throw error;
675
- }
676
- if (st.isSymbolicLink()) {
677
- throw new Error(`controlled grok home must not be a symlink: ${controlledHome}`);
678
- }
679
- if (!st.isDirectory()) {
680
- throw new Error(`controlled grok home must be a real directory: ${controlledHome}`);
681
- }
682
- }
683
-
684
- /** Refuse a symlink credential destination before copy or scrub (#594 F4). */
685
- export async function assertControlledGrokAuthIsNotSymlink(authPath: string): Promise<void> {
686
- let st;
687
- try {
688
- st = await lstat(authPath);
689
- } catch (error) {
690
- if (isMissingPathError(error)) return;
691
- throw error;
692
- }
693
- if (st.isSymbolicLink()) {
694
- throw new Error(`controlled grok auth must not be a symlink: ${authPath}`);
695
- }
696
- }
697
-
698
- /** Remove AK seatbelt hook residue while leaving sessions/ intact (#594 F1). */
699
- export async function scrubAkBashSeatbeltHooks(controlledHome: string): Promise<void> {
700
- const hooksDir = join(controlledHome, "hooks");
701
- for (const name of AK_BASH_SEATBELT_HOOK_FILES) {
702
- await rm(join(hooksDir, name), { force: true });
703
- }
704
- }
705
-
706
- /**
707
- * Copy only Grok's authentication authority into an otherwise isolated home.
708
- * Refuses symlink home/auth destinations (no follow). Scrubs crash-window residual
709
- * auth.json and AK seatbelt hooks before the copy so the next inspect cannot see
710
- * either residue (#594 F1/F3/F4).
711
- */
712
- export async function prepareControlledGrokHome(sourceHome: string, controlledHome: string): Promise<void> {
713
- await assertControlledGrokHomeIsRealDirectory(controlledHome);
714
- await mkdir(controlledHome, { recursive: true, mode: 0o700 });
715
- await assertControlledGrokHomeIsRealDirectory(controlledHome);
716
- const authPath = join(controlledHome, "auth.json");
717
- await assertControlledGrokAuthIsNotSymlink(authPath);
718
- // Crash-window residue: prior auth.json may still sit in the retained ledger.
719
- await rm(authPath, { force: true });
720
- await scrubAkBashSeatbeltHooks(controlledHome);
721
- await copyFile(join(sourceHome, ".grok", "auth.json"), authPath);
722
- }
723
-
724
- /** First-party structured inspection under the exact environment used by ACP. */
725
- export async function inspectControlledGrok(options: {
726
- readonly binary: string;
727
- readonly cwd: string;
728
- readonly env: NodeJS.ProcessEnv;
729
- readonly packageRoot: string;
730
- }): Promise<GrokControlledInspection> {
731
- const { stdout } = await execFileAsync(options.binary, ["inspect", "--json"], {
732
- cwd: options.cwd,
733
- env: options.env,
734
- encoding: "utf8",
735
- });
736
- const document: unknown = JSON.parse(stdout);
737
- if (typeof document !== "object" || document === null || Array.isArray(document)) {
738
- throw new Error("Grok structured inspection did not return an object");
739
- }
740
- const record = document as Readonly<Record<string, unknown>>;
741
- const headMatchedProjectInstructionPaths = new Set<string>();
742
- for (const path of listActiveProjectInstructionPaths(record)) {
743
- if (path === options.packageRoot || path.startsWith(`${options.packageRoot}/`)) continue;
744
- if (await isHeadMatchedProjectInstruction(options.cwd, path)) {
745
- headMatchedProjectInstructionPaths.add(path);
746
- }
747
- }
748
- return classifyGrokInspection(record, options.packageRoot, { headMatchedProjectInstructionPaths });
749
- }
750
-
751
- /** Exact child environment shared by inspect and ACP agent processes. */
752
- export function controlledGrokChildEnv(base: NodeJS.ProcessEnv, grokHome: string): NodeJS.ProcessEnv {
399
+ /** Child environment shared by ACP agent processes. */
400
+ export function controlledGrokChildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
753
401
  return {
754
402
  ...base,
755
403
  ...PRIVATE_COMPAT_ENV,
756
- HOME: grokHome,
757
- GROK_HOME: grokHome,
758
404
  GROK_MEMORY: "0",
759
405
  GROK_SUBAGENTS: "0",
760
406
  };
@@ -3,7 +3,7 @@
3
3
  * Closed host discriminators only; unknown previous/live hosts never inject.
4
4
  */
5
5
  import { access, readdir } from "node:fs/promises";
6
- import { join } from "node:path";
6
+ import { dirname, join } from "node:path";
7
7
 
8
8
  import type { RoleTurnHostTransition } from "./host-contracts.ts";
9
9
 
@@ -29,42 +29,33 @@ async function listPiNativeRecordPaths(sessionFile: string): Promise<string[]> {
29
29
  }
30
30
  }
31
31
 
32
- /** Native Grok updates.jsonl paths under runDirectory/grok-home/sessions, sorted. */
33
- export async function listGrokNativeRecordPaths(runDirectory: string): Promise<string[]> {
34
- const grokSessionsDir = join(runDirectory, "grok-home", "sessions");
35
- let encodedCwds;
32
+ /**
33
+ * Present sitian records.jsonl paths under sessionParent topology (#717).
34
+ * resolveSitianRecordPathInLedger writes dirname(sessionParent)/<category>/records.jsonl
35
+ * when sessionParent is inside ledger home — never session.jsonl itself.
36
+ */
37
+ async function listSitianRecordPaths(sessionParent: string): Promise<string[]> {
38
+ const sessionRoot = dirname(sessionParent);
39
+ let entries;
36
40
  try {
37
- encodedCwds = await readdir(grokSessionsDir, { withFileTypes: true });
41
+ entries = await readdir(sessionRoot, { withFileTypes: true });
38
42
  } catch (error) {
39
43
  if (isEnoent(error)) return [];
40
44
  throw error;
41
45
  }
42
- const updatesPaths: string[] = [];
43
- for (const cwdEntry of encodedCwds) {
44
- if (!cwdEntry.isDirectory()) continue;
45
- let sessionDirs;
46
- try {
47
- sessionDirs = await readdir(join(grokSessionsDir, cwdEntry.name), { withFileTypes: true });
48
- } catch (error) {
49
- if (isEnoent(error)) continue;
50
- throw error;
51
- }
52
- for (const sessEntry of sessionDirs) {
53
- if (!sessEntry.isDirectory()) continue;
54
- updatesPaths.push(join(grokSessionsDir, cwdEntry.name, sessEntry.name, "updates.jsonl"));
55
- }
56
- }
57
- updatesPaths.sort();
58
- const present: string[] = [];
59
- for (const updatesFile of updatesPaths) {
46
+ const recordPaths: string[] = [];
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory()) continue;
49
+ const recordFile = join(sessionRoot, entry.name, "records.jsonl");
60
50
  try {
61
- await access(updatesFile);
62
- present.push(updatesFile);
51
+ await access(recordFile);
52
+ recordPaths.push(recordFile);
63
53
  } catch (error) {
64
54
  if (!isEnoent(error)) throw error;
65
55
  }
66
56
  }
67
- return present;
57
+ recordPaths.sort();
58
+ return recordPaths;
68
59
  }
69
60
 
70
61
  /**
@@ -72,13 +63,13 @@ export async function listGrokNativeRecordPaths(runDirectory: string): Promise<s
72
63
  * Unknown host names → undefined (no inject). Empty native volume still
73
64
  * yields a typed switch (empty path list).
74
65
  *
75
- * Grok native home lives under the live run that wrote it (#617 DK-4 / #637
76
- * same-run resume). No cross-run previousRunDirectory override.
66
+ * Pi previous → Pi session.jsonl path.
67
+ * Grok previous → sitian records on the live run (#717). Grok CLI journals
68
+ * stay in the operator grok home; they are not copied here.
77
69
  */
78
70
  export async function projectHostTransitionPriorNative(input: {
79
71
  readonly previousHost: string;
80
72
  readonly liveHost: string;
81
- readonly runDirectory: string;
82
73
  readonly piSessionFile: string;
83
74
  }): Promise<RoleTurnHostTransition | undefined> {
84
75
  if (input.previousHost === input.liveHost) return undefined;
@@ -92,8 +83,8 @@ export async function projectHostTransitionPriorNative(input: {
92
83
  priorNativePaths: paths,
93
84
  };
94
85
  }
95
- // previousHost === "grok-build": DK-7 path handoff only — do not read bytes.
96
- const paths = await listGrokNativeRecordPaths(input.runDirectory);
86
+ // previousHost === "grok-build": sitian path handoff only — do not read bytes.
87
+ const paths = await listSitianRecordPaths(input.piSessionFile);
97
88
  return {
98
89
  previousHost: "grok-build",
99
90
  priorNativePaths: paths,
@@ -294,6 +294,11 @@ export type CliEnv = {
294
294
  /** Override Notary role-run timeout (tests). */
295
295
  notaryTimeoutMs?: number;
296
296
  createRunId?: () => string;
297
+ /**
298
+ * #724: set by the `new` support verb before role dispatch; seat runners
299
+ * skip same-ticket auto-resume. Not a public flag — the verb is the choice.
300
+ */
301
+ freshSummons?: true;
297
302
  };
298
303
 
299
304
  /** Compose the Pi turn host for one role dispatch (sole public-cli → pi contact). */
@@ -437,6 +442,7 @@ function createRoleEnvironment(
437
442
  ...(options.config?.autoResumeLimit === undefined
438
443
  ? {}
439
444
  : { autoResumeLimit: options.config.autoResumeLimit }),
445
+ ...(env.freshSummons === true ? { freshSummons: true as const } : {}),
440
446
  };
441
447
  }
442
448
 
@@ -1084,12 +1090,14 @@ export async function runAkRole(
1084
1090
  packageRoot: await realpath(env.packageRoot),
1085
1091
  principalAuthority: env.principalAuthority ?? piDurablePrincipalAuthority,
1086
1092
  };
1087
- const parsed = parseArgv(argv);
1088
- // Host/engine axes: callable roles + resume (#617 DK-3: resume shares seat axes).
1093
+ let parsed = parseArgv(argv);
1094
+ // Host/engine axes: callable roles + resume + new (#617 DK-3; #724 new shares seat axes).
1089
1095
  // Support commands (roles/config/…) still refuse both flags.
1090
1096
  const acceptsSeatAxes =
1091
1097
  parsed.command !== undefined &&
1092
- (isPublicCallableRole(parsed.command) || parsed.command === "resume");
1098
+ (isPublicCallableRole(parsed.command) ||
1099
+ parsed.command === "resume" ||
1100
+ parsed.command === "new");
1093
1101
  if (
1094
1102
  parsed.host !== undefined &&
1095
1103
  !parsed.help &&
@@ -1160,6 +1168,24 @@ export async function runAkRole(
1160
1168
  };
1161
1169
  }
1162
1170
 
1171
+ // #724 explicit fresh summons: `ak-role new <role> …` — same role argv, always mint.
1172
+ // Rewrites onto the role command with freshSummons; auto-resume and resume stay intact.
1173
+ if (parsed.command === "new") {
1174
+ const role = parsed.args[0];
1175
+ if (role === undefined) {
1176
+ throw new CliUsageError("usage: ak-role new <role> …");
1177
+ }
1178
+ if (!isPublicCallableRole(role)) {
1179
+ throw new CliUsageError(`usage: ak-role new <role> …; unknown role: ${role}`);
1180
+ }
1181
+ parsed = {
1182
+ ...parsed,
1183
+ command: role,
1184
+ args: parsed.args.slice(1),
1185
+ };
1186
+ env = { ...env, freshSummons: true };
1187
+ }
1188
+
1163
1189
  // Resume reopens an exact Role run (#416): caller decides; session principal
1164
1190
  // must still exist. Seat and dispatch follow the durable admitted role.
1165
1191
  // #471: unique parser owns {runId, message?}; five role paths only consume it.
@@ -1202,7 +1228,10 @@ export async function runAkRole(
1202
1228
  return cliResultFromRoleRun(result);
1203
1229
  }
1204
1230
 
1205
- if (isPublicCliSupportCommand(parsed.command)) {
1231
+ if (
1232
+ parsed.command !== undefined &&
1233
+ isPublicCliSupportCommand(parsed.command)
1234
+ ) {
1206
1235
  throw new CliUsageError(`unhandled support command: ${parsed.command}`);
1207
1236
  }
1208
1237
 
@@ -122,6 +122,7 @@ export async function runPublicCountersign(
122
122
  projectRoot,
123
123
  role: "countersign",
124
124
  ticketNumber: probedTicketNumber,
125
+ freshSummons: env.freshSummons,
125
126
  summons,
126
127
  resume: (runId, materials) =>
127
128
  runPublicCountersignResume(
@@ -109,6 +109,7 @@ export async function runPublicInspector(
109
109
  projectRoot,
110
110
  role: "inspector",
111
111
  ticketNumber: probedTicketNumber,
112
+ freshSummons: env.freshSummons,
112
113
  summons,
113
114
  resume: (runId, materials) =>
114
115
  runPublicInspectorResume(
@@ -119,6 +119,7 @@ export async function runPublicNotary(
119
119
  projectRoot,
120
120
  role: "notary",
121
121
  ticketNumber,
122
+ freshSummons: env.freshSummons,
122
123
  summons,
123
124
  resume: (runId, materials) =>
124
125
  runPublicNotaryResume(
@@ -1271,6 +1271,17 @@ const SUPPORT_COMMAND_HELP = {
1271
1271
  "ak-role --engine agy resume 01abc…",
1272
1272
  ],
1273
1273
  },
1274
+ new: {
1275
+ command: "new",
1276
+ summary:
1277
+ "Explicit fresh summons: same role options as a direct call, but always mint a new run (skip same-ticket auto-resume). Caller chooses new vs resume; no overflow judgment.",
1278
+ usage: ["ak-role new <role> [options] …"],
1279
+ examples: [
1280
+ 'ak-role new countersign --attach ./ticket.md "裁:本票 #708 是否足以开工。"',
1281
+ "ak-role new notary --source-run 01abc…@countersign",
1282
+ "ak-role --host grok-build new inspector --attach ./change.patch",
1283
+ ],
1284
+ },
1274
1285
  } as const satisfies Record<string, PublicCommandHelpFacts>;
1275
1286
 
1276
1287
  /**
@@ -110,6 +110,11 @@ export type PostAdmissionEnv = {
110
110
  sessionAppender: SessionCustomEntryAppender;
111
111
  autoResumeLimit?: number;
112
112
  createRunId?: () => string;
113
+ /**
114
+ * #724 explicit fresh summons (`ak-role new <role>`): skip same-ticket auto-resume
115
+ * and mint a new run. Absent on ordinary role commands and on `ak-role resume`.
116
+ */
117
+ freshSummons?: true;
113
118
  };
114
119
 
115
120
  /**
@@ -294,7 +299,6 @@ export async function dispatchPostAdmissionTurn<
294
299
  ? await projectHostTransitionPriorNative({
295
300
  previousHost,
296
301
  liveHost,
297
- runDirectory: admitted.runDirectory,
298
302
  piSessionFile: principalCoordinates.sessionFile,
299
303
  })
300
304
  : undefined;
@@ -27,6 +27,7 @@ export const PUBLIC_CLI_SUPPORT_COMMANDS = [
27
27
  "config",
28
28
  "help",
29
29
  "resume",
30
+ "new",
30
31
  ] as const;
31
32
 
32
33
  export type PublicCliSupportCommand = (typeof PUBLIC_CLI_SUPPORT_COMMANDS)[number];