@akagilnc/pi-workflow-roles 0.1.3718 → 0.1.3733

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Seat-scoped host profile soul delivery (#644 / 拍 2).
3
+ *
4
+ * Hermes has no per-session systemPrompt channel; identity is the profile's
5
+ * SOUL.md. Each seat owns `profiles/<namePrefix><role>/`, and SOUL.md is a
6
+ * symlink to the packaged `souls/<role>.md` (package is the sole soul source).
7
+ */
8
+ import { constants } from "node:fs";
9
+ import { access, copyFile, lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
10
+ import { dirname, join, relative, resolve } from "node:path";
11
+
12
+ export type SeatProfileSoul = Readonly<{
13
+ /** Argv flag selecting the profile (hermes pre-argparse `-p`). */
14
+ flag: string;
15
+ /** Profile id = `${namePrefix}${role}` (e.g. `ak-judge`). */
16
+ namePrefix: string;
17
+ /** Profile directory root relative to the operator home (`.hermes/profiles`). */
18
+ profilesRootFromHome: readonly string[];
19
+ /** Soul filename inside the profile directory. */
20
+ soulFileName: string;
21
+ }>;
22
+
23
+ /** Profile id for one seat role. */
24
+ export function seatProfileName(spec: SeatProfileSoul, role: string): string {
25
+ return `${spec.namePrefix}${role}`;
26
+ }
27
+
28
+ /** Packaged soul path for one role (`souls/<role>.md`). */
29
+ export function packageRoleSoulPath(packageRoot: string, role: string): string {
30
+ return join(packageRoot, "souls", `${role}.md`);
31
+ }
32
+
33
+ async function pathExists(path: string): Promise<boolean> {
34
+ try {
35
+ await access(path, constants.F_OK);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Ensure the seat profile directory exists and SOUL.md is a symlink to the
44
+ * packaged role soul. On first create, copy credential surfaces from the host
45
+ * root (parent of the profiles root) so the profile can authenticate without
46
+ * touching the default profile in place. Returns the profile id for argv.
47
+ */
48
+ export async function ensureSeatProfileSoul(options: {
49
+ readonly spec: SeatProfileSoul;
50
+ readonly operatorHome: string;
51
+ readonly packageRoot: string;
52
+ readonly role: string;
53
+ }): Promise<string> {
54
+ const { spec, operatorHome, packageRoot, role } = options;
55
+ const profileName = seatProfileName(spec, role);
56
+ const soulTarget = resolve(packageRoleSoulPath(packageRoot, role));
57
+ if (!(await pathExists(soulTarget))) {
58
+ throw new Error(`packaged role soul missing: ${soulTarget}`);
59
+ }
60
+
61
+ const profilesRoot = join(operatorHome, ...spec.profilesRootFromHome);
62
+ const profileDir = join(profilesRoot, profileName);
63
+ const hostRoot = dirname(profilesRoot);
64
+ const soulPath = join(profileDir, spec.soulFileName);
65
+
66
+ if (!(await pathExists(profileDir))) {
67
+ await mkdir(profileDir, { recursive: true });
68
+ // First-create credential bootstrap only. Never rewrite an existing profile's
69
+ // auth/config; never write into the host root / default profile.
70
+ for (const name of ["auth.json", ".env", "config.yaml"] as const) {
71
+ const source = join(hostRoot, name);
72
+ if (!(await pathExists(source))) continue;
73
+ await copyFile(source, join(profileDir, name));
74
+ }
75
+ } else {
76
+ await mkdir(profileDir, { recursive: true });
77
+ }
78
+
79
+ const desiredLink = relative(profileDir, soulTarget);
80
+ let current: string | undefined;
81
+ try {
82
+ const st = await lstat(soulPath);
83
+ if (st.isSymbolicLink()) {
84
+ current = await readlink(soulPath);
85
+ }
86
+ } catch {
87
+ current = undefined;
88
+ }
89
+ if (current === desiredLink || current === soulTarget) {
90
+ return profileName;
91
+ }
92
+ if (await pathExists(soulPath) || current !== undefined) {
93
+ await unlink(soulPath);
94
+ }
95
+ await symlink(desiredLink, soulPath);
96
+ return profileName;
97
+ }
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
 
4
4
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { HostContext } from "./host-contracts.ts";
6
+ import { readableGateItem } from "./readable-gate-item.ts";
6
7
  import { Type } from "typebox";
7
8
 
8
9
  export const AUDITOR_DOSSIER_TOOL_NAME = "ak_get_run_dossier" as const;
@@ -63,24 +64,35 @@ export function gateSubmissionCandidatePath(runDirectory: string): string {
63
64
  }
64
65
 
65
66
  /**
66
- * Human-readable materials for gate-officer same-parent resume (#753 / #750).
67
- * Pointers only — no body embed (ADR 0079/0081); no duty/handbook lines (#755).
67
+ * Arguments of the latest assistant toolCall leaf — the in-flight 交卷 body.
68
+ * Transport assembly only: does not interpret field contents (#786).
69
+ */
70
+ export function readLatestSubmissionArguments(
71
+ context: GateSubmissionSessionContext,
72
+ ): unknown | undefined {
73
+ const leaf = readLatestToolCallLeaf(context);
74
+ if (!isRecord(leaf) || !isRecord(leaf.message) || !Array.isArray(leaf.message.content)) {
75
+ return undefined;
76
+ }
77
+ for (const part of leaf.message.content) {
78
+ if (isRecord(part) && part.type === "toolCall" && "arguments" in part) {
79
+ return part.arguments;
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ /**
86
+ * Verbatim parent-submission body for gate-officer same-parent resume (#786).
68
87
  * Three pairs share this face: countersign↔notary, judge↔auditor, worker↔inspector.
69
- * Code delivers the pointers; it does not judge whether the officer read them.
88
+ * Code relays bytes; it does not parse, judge, or rewrite the submission.
89
+ * Symmetric with bounce direction (officer receipt → parent via readableGateItem).
90
+ * No path/pointer substitute — argv 卷宗指针 stays on the code-summons face (ADR 0079).
70
91
  */
71
92
  export function buildGateOfficerReviewInstruction(input: {
72
- readonly sourceRunDirectory: string;
73
- readonly submissionCandidatePath?: string;
93
+ readonly submission: unknown;
74
94
  }): string {
75
- const lines = [
76
- "本轮父席交卷待审。",
77
- `来源 run:${input.sourceRunDirectory}`,
78
- ];
79
- const candidate = input.submissionCandidatePath?.trim();
80
- if (candidate !== undefined && candidate.length > 0) {
81
- lines.push(`交卷候选(冻结快照):${candidate}`);
82
- }
83
- return lines.join("\n");
95
+ return readableGateItem(input.submission);
84
96
  }
85
97
 
86
98
  /**
@@ -4,6 +4,7 @@ import type { HostContext } from "./host-contracts.ts";
4
4
  import {
5
5
  auditorRunDirectory,
6
6
  persistGateSubmissionCandidate,
7
+ readLatestSubmissionArguments,
7
8
  } from "./auditor-dossier-tool.ts";
8
9
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
9
10
  import { GatekeeperDecisionError } from "./submission-errors.ts";
@@ -89,6 +90,11 @@ export type GateOfficerSummon = (
89
90
  * conclusion (#753 / #756). Hosted as same-ticket resume instruction.
90
91
  */
91
92
  reask?: string,
93
+ /**
94
+ * In-flight parent 交卷 body (tool-call arguments). Production default relays
95
+ * it verbatim on same-parent officer resume (#786).
96
+ */
97
+ submission?: unknown,
92
98
  ) => Promise<PublicSummonResult>;
93
99
 
94
100
  export type RunGatekeeperOptions = {
@@ -106,6 +112,14 @@ export type RunGatekeeperOptions = {
106
112
  * activation path (#675); inject only in offline tracers.
107
113
  */
108
114
  readonly summonOfficer?: GateOfficerSummon;
115
+ /**
116
+ * Offline test injects forwarded through the production default summonGateOfficer
117
+ * path (same faces as public CLI). Production leaves these unset.
118
+ */
119
+ readonly home?: string;
120
+ readonly packageRoot?: string;
121
+ readonly roleTurnHost?: import("./host-contracts.ts").RoleTurnHost;
122
+ readonly createRunId?: () => string;
109
123
  };
110
124
 
111
125
  export type GatekeeperPassHostActions = {
@@ -299,18 +313,16 @@ export async function projectGatekeeperRun(
299
313
  },
300
314
  };
301
315
  }
302
- // Pointer-only summons need a resolvable leaf: Grok session.jsonl is header-only
303
- // (#617 DK-4); write the in-memory tool-call candidate as a run artifact first (#632).
304
- // Candidate path also rides same-parent officer resume as 人读材料 (#753 / #750).
305
- const submissionCandidatePath = persistGateSubmissionCandidate(
306
- runDirectory,
307
- options.context,
308
- );
316
+ // #632: Grok session.jsonl is header-only — freeze the in-memory tool-call leaf
317
+ // as a run artifact so dossier exploration still resolves. LLM→LLM resume does
318
+ // not ride this path: submission body goes verbatim on gateReviewInstruction (#786).
319
+ persistGateSubmissionCandidate(runDirectory, options.context);
320
+ const submission = readLatestSubmissionArguments(options.context);
309
321
  let summoned: PublicSummonResult;
310
322
  try {
311
323
  const summon =
312
324
  options.summonOfficer
313
- ?? (async (nextOfficer, sourceRunDirectory, officerSignal, reask) => {
325
+ ?? (async (nextOfficer, sourceRunDirectory, officerSignal, reask, nextSubmission) => {
314
326
  const { summonGateOfficer } = await import("./public-role-summons.ts");
315
327
  return summonGateOfficer({
316
328
  officer: nextOfficer,
@@ -318,12 +330,20 @@ export async function projectGatekeeperRun(
318
330
  cwd: options.context.cwd ?? process.cwd(),
319
331
  ...(officerSignal === undefined ? {} : { signal: officerSignal }),
320
332
  ...(reask === undefined ? {} : { reask }),
321
- ...(submissionCandidatePath === undefined
322
- ? {}
323
- : { submissionCandidatePath }),
333
+ ...(nextSubmission === undefined ? {} : { submission: nextSubmission }),
334
+ ...(options.home === undefined ? {} : { home: options.home }),
335
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
336
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
337
+ ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
324
338
  });
325
339
  });
326
- summoned = await summon(officer, runDirectory, options.signal, options.reask);
340
+ summoned = await summon(
341
+ officer,
342
+ runDirectory,
343
+ options.signal,
344
+ options.reask,
345
+ submission,
346
+ );
327
347
  } catch (error) {
328
348
  return {
329
349
  officer,
@@ -24,6 +24,7 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
24
24
  suffix: Object.freeze(["stdio"]),
25
25
  modelFlag: "--model",
26
26
  }),
27
+ modelPassing: "argv",
27
28
  boundResume: "session/load",
28
29
  sessionBindingFile: "grok-acp-session.json",
29
30
  childEnv: Object.freeze({
@@ -32,6 +33,31 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
32
33
  GROK_SUBAGENTS: "0",
33
34
  }),
34
35
  }),
36
+ /**
37
+ * Operator home `~/.hermes`, native session/load resume, `acp` subcommand.
38
+ * Model arrives as an ACP `session/set_model` RPC with modelId `provider:model`
39
+ * (seat table provider + model concatenated). Reasoning is the global
40
+ * `--reasoning` flag before `acp`. Soul is the seat profile SOUL.md symlink
41
+ * (`hermes -p ak-<role> …`); package `souls/<role>.md` is the sole source.
42
+ */
43
+ "hermes": Object.freeze({
44
+ binaryFromHome: Object.freeze([".local", "bin", "hermes"]),
45
+ argv: Object.freeze({
46
+ prefix: Object.freeze(["acp"]),
47
+ suffix: Object.freeze([]),
48
+ thinkingFlag: "--reasoning",
49
+ }),
50
+ modelPassing: "set_model",
51
+ boundResume: "session/load",
52
+ sessionBindingFile: "hermes-acp-session.json",
53
+ childEnv: Object.freeze({}),
54
+ seatProfileSoul: Object.freeze({
55
+ flag: "-p",
56
+ namePrefix: "ak-",
57
+ profilesRootFromHome: Object.freeze([".hermes", "profiles"]),
58
+ soulFileName: "SOUL.md",
59
+ }),
60
+ }),
35
61
  });
36
62
 
37
63
  export function lookupHostDescription(host: string): AcpHostDescription | undefined {
@@ -51,7 +51,7 @@ export type InspectorRunEnv = PostAdmissionEnv & {
51
51
  */
52
52
  reviewReask?: string;
53
53
  /**
54
- * #753/#750 same-parent re-summons: human-readable new-submission pointers.
54
+ * #786 same-parent re-summons: verbatim parent-submission body.
55
55
  * Rides summons.instruction when reviewReask is absent. Fresh mint keeps argv 卷宗指针.
56
56
  */
57
57
  gateReviewInstruction?: string;
@@ -99,7 +99,7 @@ export async function runPublicInspector(
99
99
  // No bare catch→fresh: lookup/resume failures surface; only true absence mints new.
100
100
  const projectRoot = parsed.project ?? env.cwd;
101
101
  // #747: parentRunPath is the pure 卷宗指针 path only — never reask/materials text.
102
- // #753: gate re-ask / new-submission pointers ride summons.instruction on resume.
102
+ // #753/#786: gate re-ask / verbatim submission body ride summons.instruction on resume.
103
103
  const parentRunPath = parentRunPathFromGatePointerInstruction(parsed.instruction);
104
104
  if (parentRunPath !== undefined) {
105
105
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
@@ -129,7 +129,7 @@ export async function runPublicInspector(
129
129
  if (resumed !== undefined) return resumed;
130
130
  // Reask without a prior same-parent run cannot deliver the plain-language ask
131
131
  // on a fresh mint without inventing a second prompt path — fail loud (#753).
132
- // gateReviewInstruction alone is resume-only; fresh mint keeps argv 卷宗指针.
132
+ // gateReviewInstruction (verbatim body) alone is resume-only; fresh mint keeps argv 卷宗指针.
133
133
  if (env.reviewReask !== undefined) {
134
134
  presentStructuralRejection(
135
135
  new CliUsageError(
@@ -70,7 +70,7 @@ export type InstructionSeatRunEnv = PostAdmissionEnv & {
70
70
  */
71
71
  reviewReask?: string;
72
72
  /**
73
- * #753/#750 same-parent re-summons: human-readable new-submission pointers.
73
+ * #786 same-parent re-summons: verbatim parent-submission body.
74
74
  * Rides summons.instruction when reviewReask is absent. Fresh mint keeps argv instruction.
75
75
  */
76
76
  gateReviewInstruction?: string;
@@ -294,7 +294,7 @@ export async function runPublicInstructionSeat(
294
294
  }
295
295
  }
296
296
 
297
- // #756: auditor reask / new-submission pointers ride summons.instruction on resume.
297
+ // #756/#786: auditor reask / verbatim submission body ride summons.instruction on resume.
298
298
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
299
299
  const summons: SameTicketSummonsMaterials = {
300
300
  ...(resumeInstruction === undefined
@@ -53,7 +53,7 @@ export type NotaryRunEnv = PostAdmissionEnv & {
53
53
  */
54
54
  reviewReask?: string;
55
55
  /**
56
- * #753/#750 same-parent re-summons: human-readable new-submission pointers.
56
+ * #786 same-parent re-summons: verbatim parent-submission body.
57
57
  * Rides summons.instruction when reviewReask is absent. Fresh mint ignores it.
58
58
  */
59
59
  gateReviewInstruction?: string;
@@ -120,7 +120,7 @@ export async function runPublicNotary(
120
120
  throw error;
121
121
  }
122
122
  // #747: officer resume key is this parent source-run path (not ticket number).
123
- // #753: gate re-ask and new-submission pointers share summons.instruction
123
+ // #753/#786: gate re-ask and verbatim submission body share summons.instruction
124
124
  // (reask wins when both present; no parallel stack).
125
125
  {
126
126
  const resumeInstruction = env.reviewReask ?? env.gateReviewInstruction;
@@ -148,7 +148,7 @@ export async function runPublicNotary(
148
148
  if (resumed !== undefined) return resumed;
149
149
  // Reask without a prior same-parent run cannot deliver the plain-language ask
150
150
  // on a fresh mint without inventing a second prompt path — fail loud (#753).
151
- // gateReviewInstruction alone is resume-only materials; fresh mint ignores it.
151
+ // gateReviewInstruction (verbatim body) alone is resume-only; fresh mint ignores it.
152
152
  if (env.reviewReask !== undefined) {
153
153
  presentStructuralRejection(
154
154
  new CliUsageError(
@@ -60,12 +60,18 @@ export type PublicSummonRequest = {
60
60
  */
61
61
  readonly reviewReask?: string;
62
62
  /**
63
- * #753/#750 same-parent re-summons: human-readable pointer materials for the
64
- * new parent submission (source run + optional gate-submission-candidate).
63
+ * #786 same-parent re-summons: verbatim parent-submission body for the gate officer.
65
64
  * Rides summons.instruction when reviewReask is absent. Fresh mint ignores it.
66
- * Never folded into argv / parent-run lookup keys.
65
+ * Never folded into argv / parent-run lookup keys (ADR 0079 卷宗指针 stays pure).
67
66
  */
68
67
  readonly gateReviewInstruction?: string;
68
+ /**
69
+ * Offline test inject — same face as public CLI env.roleTurnHost. Production
70
+ * summons leave this unset and use the seat-resolved host.
71
+ */
72
+ readonly roleTurnHost?: RoleTurnHost;
73
+ /** Offline test inject for deterministic run ids (same face as public CLI). */
74
+ readonly createRunId?: () => string;
69
75
  };
70
76
 
71
77
  export type PublicSummonResult = {
@@ -293,10 +299,13 @@ export async function summonPublicRole(
293
299
  ...(options.signal === undefined ? {} : { signal: options.signal }),
294
300
  // #753: reask rides the existing notary same-ticket resume summons.instruction.
295
301
  ...(options.reviewReask === undefined ? {} : { reviewReask: options.reviewReask }),
296
- // #753/#750: new-submission pointers on same-parent resume (not conclusion re-ask).
302
+ // #786: verbatim submission body on same-parent resume (not conclusion re-ask).
297
303
  ...(options.gateReviewInstruction === undefined
298
304
  ? {}
299
305
  : { gateReviewInstruction: options.gateReviewInstruction }),
306
+ // Offline test injects — same faces as public CLI env (production leaves unset).
307
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
308
+ ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
300
309
  };
301
310
  const captured = options.io === undefined ? createCapturingIo() : undefined;
302
311
  const io = options.io ?? captured!.io;
@@ -412,10 +421,14 @@ export async function summonGateOfficer(options: {
412
421
  */
413
422
  readonly reask?: string;
414
423
  /**
415
- * Path of this turn's gate-submission-candidate when written (#632 / #753).
416
- * Becomes human-readable resume materials when reask is absent.
424
+ * In-flight parent 交卷 body (tool-call arguments). Relayed verbatim on resume
425
+ * when reask is absent (#786). Never a path pointer substitute.
417
426
  */
418
- readonly submissionCandidatePath?: string;
427
+ readonly submission?: unknown;
428
+ /** Offline test inject — forwarded to summonPublicRole. */
429
+ readonly roleTurnHost?: RoleTurnHost;
430
+ /** Offline test inject — forwarded to summonPublicRole. */
431
+ readonly createRunId?: () => string;
419
432
  }): Promise<PublicSummonResult> {
420
433
  let home = options.home;
421
434
  if (home === undefined) {
@@ -423,16 +436,13 @@ export async function summonGateOfficer(options: {
423
436
  // Hard path resolve: fail loud — never fall through to packageMachineHome (#604 / #675).
424
437
  home = homeFromRunDirectory(options.sourceRunDirectory);
425
438
  }
426
- // Conclusion re-ask keeps sole ownership of reviewReask. New-submission materials
427
- // ride a separate field so first mint never fails the "reask requires prior" gate.
439
+ // Conclusion re-ask keeps sole ownership of reviewReask. New-submission body
440
+ // rides a separate field so first mint never fails the "reask requires prior" gate.
428
441
  let gateReviewInstruction: string | undefined;
429
- if (options.reask === undefined) {
442
+ if (options.reask === undefined && options.submission !== undefined) {
430
443
  const { buildGateOfficerReviewInstruction } = await import("./auditor-dossier-tool.ts");
431
444
  gateReviewInstruction = buildGateOfficerReviewInstruction({
432
- sourceRunDirectory: options.sourceRunDirectory,
433
- ...(options.submissionCandidatePath === undefined
434
- ? {}
435
- : { submissionCandidatePath: options.submissionCandidatePath }),
445
+ submission: options.submission,
436
446
  });
437
447
  }
438
448
  const common = {
@@ -445,6 +455,8 @@ export async function summonGateOfficer(options: {
445
455
  ...(gateReviewInstruction === undefined
446
456
  ? {}
447
457
  : { gateReviewInstruction }),
458
+ ...(options.roleTurnHost === undefined ? {} : { roleTurnHost: options.roleTurnHost }),
459
+ ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
448
460
  } as const;
449
461
  if (options.officer === "notary") {
450
462
  // #753: reask rides summonPublicRole → runPublicNotary summons.instruction