@akagilnc/pi-workflow-roles 0.1.4239 → 0.1.4259

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.
@@ -5,6 +5,7 @@
5
5
  * Engine process failures stop through the host infrastructure-failure seam.
6
6
  * Caller AbortSignal cancellation propagates unchanged.
7
7
  */
8
+ import { basename } from "node:path";
8
9
  import { Type, type Static } from "typebox";
9
10
  import type { HostContext, HostToolDefinition, HostToolResult, RoleHost } from "./host-contracts.ts";
10
11
 
@@ -16,6 +17,18 @@ import {
16
17
  resolveEngineName,
17
18
  runEngineDetourOnce,
18
19
  } from "./engine-detour.ts";
20
+ import {
21
+ engineDetourStdoutByteLength,
22
+ reportEngineDetourCall,
23
+ } from "./engine-detour-usage.ts";
24
+
25
+ /** runDirectory leaf is `<runId>@<role>`; subject is optional. */
26
+ function basenameRunId(runDirectory: string): string | undefined {
27
+ const leaf = basename(runDirectory);
28
+ const at = leaf.indexOf("@");
29
+ if (at <= 0) return undefined;
30
+ return leaf.slice(0, at);
31
+ }
19
32
 
20
33
  // #836 r16 class 3: argv required/minItems/element-minLength stay — execute()
21
34
  // must obtain the first item as the executable and spawn it (below; #82-98).
@@ -32,7 +45,14 @@ const engineDetourArgsSchema = Type.Object(
32
45
 
33
46
  type EngineDetourArgs = Static<typeof engineDetourArgsSchema>;
34
47
 
35
- type EngineDetourContext = Pick<HostContext, "cwd" | "mode" | "abort">;
48
+ type EngineDetourContext = Pick<HostContext, "cwd" | "mode" | "abort"> & {
49
+ sessionManager?: Pick<HostContext["sessionManager"], "getSessionFile">;
50
+ runDirectory?: string;
51
+ /** Public-invocation scope from the shared Host envelope (#537). */
52
+ invocationScopeId?: string;
53
+ /** Selected host from the shared Host envelope (#537 / ADR 0082). */
54
+ host?: string;
55
+ };
36
56
 
37
57
  export type EngineDetourHostActions = {
38
58
  failInfrastructure(
@@ -58,6 +78,12 @@ function isCallerCancellation(
58
78
  return false;
59
79
  }
60
80
 
81
+ function asError(error: unknown, fallback: string): Error {
82
+ return error instanceof Error
83
+ ? error
84
+ : new Error(String(error).trim() || fallback);
85
+ }
86
+
61
87
  /**
62
88
  * Build one detour tool definition for a configured engine name.
63
89
  * `fail` owns host abort (parent) vs throw (evidence child) for tool misuse only.
@@ -100,6 +126,68 @@ export function createEngineDetourToolDefinition(input: {
100
126
  );
101
127
  }
102
128
 
129
+ const sessionParent = ctx.sessionManager?.getSessionFile?.();
130
+ const startedAt = Date.now();
131
+ const runDirectory = typeof ctx.runDirectory === "string" && ctx.runDirectory.length > 0
132
+ ? ctx.runDirectory
133
+ : undefined;
134
+ const runId = runDirectory === undefined ? undefined : basenameRunId(runDirectory);
135
+ // Public-invocation scope + selected host from Host envelope only — never
136
+ // courtAttemptId, never sidecar file, never pre-spawn invocation.json I/O.
137
+ const invocationScopeId =
138
+ typeof ctx.invocationScopeId === "string" && ctx.invocationScopeId.trim() !== ""
139
+ ? ctx.invocationScopeId.trim()
140
+ : undefined;
141
+ const host =
142
+ typeof ctx.host === "string" && ctx.host.trim() !== ""
143
+ ? ctx.host.trim()
144
+ : undefined;
145
+
146
+ const recordCall = (observed: {
147
+ code?: number;
148
+ stdoutByteLength?: number;
149
+ }): void => {
150
+ if (typeof sessionParent !== "string" || sessionParent.length === 0) return;
151
+ reportEngineDetourCall({
152
+ toolCallId,
153
+ durationMs: Math.max(0, Date.now() - startedAt),
154
+ cwd: ctx.cwd,
155
+ sessionParent,
156
+ ...(runId === undefined ? {} : { runId }),
157
+ ...(invocationScopeId === undefined ? {} : { invocationScopeId }),
158
+ ...(host === undefined ? {} : { host }),
159
+ ...(observed.code === undefined ? {} : { code: observed.code }),
160
+ ...(observed.stdoutByteLength === undefined
161
+ ? {}
162
+ : { stdoutByteLength: observed.stdoutByteLength }),
163
+ });
164
+ };
165
+
166
+ /** Preserve engine cause; if ledger write also fails, keep both (失败诚实). */
167
+ const failAfterLedger = (
168
+ engineCause: Error,
169
+ observed: { code?: number; stdoutByteLength?: number },
170
+ aggregateMessage: string,
171
+ ): never => {
172
+ try {
173
+ recordCall(observed);
174
+ } catch (recordError) {
175
+ input.fail(
176
+ new AggregateError(
177
+ [
178
+ engineCause,
179
+ asError(recordError, "engine detour usage ledger write failed"),
180
+ ],
181
+ aggregateMessage,
182
+ { cause: engineCause },
183
+ ),
184
+ toolCallId,
185
+ ctx,
186
+ );
187
+ }
188
+ input.fail(engineCause, toolCallId, ctx);
189
+ };
190
+
103
191
  let result: Awaited<ReturnType<typeof runEngineDetourOnce>>;
104
192
  try {
105
193
  result = await runEngineDetourOnce({
@@ -109,20 +197,30 @@ export function createEngineDetourToolDefinition(input: {
109
197
  });
110
198
  } catch (error) {
111
199
  if (isCallerCancellation(error, signal)) throw error;
112
- const cause = error instanceof Error
113
- ? error
114
- : new Error(String(error).trim() || "劳务引擎 spawn 失败");
115
- input.fail(cause, toolCallId, ctx);
200
+ // Spawn path: duration only — code/stdout bytes absent (not forged 0).
201
+ return failAfterLedger(
202
+ asError(error, "劳务引擎 spawn 失败"),
203
+ {},
204
+ "engine detour spawn and usage ledger both failed",
205
+ );
116
206
  }
117
207
 
208
+ const stdoutByteLength = engineDetourStdoutByteLength(result.stdout);
209
+ const observed = { code: result.code, stdoutByteLength };
210
+
211
+ // Classify closed-child failure before ledger write so a sitian failure
212
+ // cannot erase nonzero/empty engine facts.
118
213
  if (isEngineDetourFailure(result)) {
119
- input.fail(
214
+ return failAfterLedger(
120
215
  new Error(engineDetourFailureDiagnostic(result)),
121
- toolCallId,
122
- ctx,
216
+ observed,
217
+ "engine detour child-close and usage ledger both failed",
123
218
  );
124
219
  }
125
220
 
221
+ recordCall(observed);
222
+
223
+ // Usage ledger lives in sitian + decisiveFacts only (#537) — not tool details.
126
224
  return {
127
225
  content: [{ type: "text" as const, text: result.stdout }],
128
226
  details: {
@@ -0,0 +1,373 @@
1
+ /**
2
+ * #537 typed ak_engine_detour tool usage ledger.
3
+ *
4
+ * Observes only the package detour tool path. Bash/CLI ordinary path
5
+ * (resources/engine-dispatch.md ordinary path) stays outside this ledger — a
6
+ * permanent blind spot that must never be read as "the seat did not use an engine".
7
+ *
8
+ * Runtime owns these facts beside role payloads (ADR 0042); they land in
9
+ * TerminalResult.decisiveFacts and are written live via sitian (ADR 0077).
10
+ * No gate, no required field, no index bytes (ADR 0049 / 0057).
11
+ *
12
+ * Invocation scope is one public ak-role call (#537), owned by the shared Host
13
+ * execution envelope (RoleTurnRequest / HostContext). Auto-resume attempts inside
14
+ * that call share the same scope; only an explicit new public call (including
15
+ * `ak-role resume`) mints a new one. Never courtAttemptId, never Pi session
16
+ * toolResult join keys, and never a detour-owned sidecar file.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ import { readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+
22
+ import { ENGINE_DETOUR_TOOL_NAME } from "./engine-detour.ts";
23
+ import {
24
+ readSitianRecords,
25
+ resolveSitianRecordPath,
26
+ sitianReport,
27
+ type RecordPointer,
28
+ } from "./sitian-facade.ts";
29
+
30
+ /** Sitian event kind for one detour-tool call (volume under session/). */
31
+ export const ENGINE_DETOUR_CALL_KIND = "engine-detour-call" as const;
32
+
33
+ /** decisiveFacts key — name states detour-tool scope, not full engine usage. */
34
+ export const ENGINE_DETOUR_TOOL_USAGE_FACT_KEY = "engineDetourToolUsage" as const;
35
+
36
+ /**
37
+ * Run-relative sitian volume path for one detour call.
38
+ * Openable once the run directory is known (resume.command / top-level runId);
39
+ * contains no runId bytes itself (#108 + #537 AC8).
40
+ */
41
+ export const ENGINE_DETOUR_CALL_RECORD_FILE_RELATIVE =
42
+ `session/${ENGINE_DETOUR_CALL_KIND}/records.jsonl` as const;
43
+
44
+ /** stdout UTF-8 byte length (ticket-frozen metric; empty stdout is real 0). */
45
+ export function engineDetourStdoutByteLength(stdout: string): number {
46
+ return Buffer.byteLength(stdout, "utf8");
47
+ }
48
+
49
+ /** One observed ak_engine_detour call. code/stdoutByteLength absent on spawn failure. */
50
+ export type EngineDetourCallFact = {
51
+ readonly toolCallId: string;
52
+ readonly durationMs: number;
53
+ readonly code?: number;
54
+ readonly stdoutByteLength?: number;
55
+ readonly recordPointer: Readonly<RecordPointer>;
56
+ };
57
+
58
+ /**
59
+ * Per-invocation aggregate for the detour tool only.
60
+ * callCount 0 is lawful when engine is mounted (ADR 0071 soft rule).
61
+ */
62
+ export type EngineDetourToolUsageFact = {
63
+ readonly callCount: number;
64
+ readonly calls: readonly EngineDetourCallFact[];
65
+ };
66
+
67
+ export type ReportEngineDetourCallInput = {
68
+ readonly toolCallId: string;
69
+ readonly durationMs: number;
70
+ /** Present only when the child closed (not spawn failure). */
71
+ readonly code?: number;
72
+ /** Present only when the child closed; empty stdout → 0. */
73
+ readonly stdoutByteLength?: number;
74
+ readonly cwd: string;
75
+ readonly sessionParent: string;
76
+ readonly home?: string;
77
+ /** Real selected host (ADR 0077 / 0082). Never invent "pi". */
78
+ readonly host?: string;
79
+ readonly runId?: string;
80
+ /** Public-invocation scope id from the shared Host envelope. */
81
+ readonly invocationScopeId?: string;
82
+ };
83
+
84
+ /**
85
+ * Deterministic sitian identity: invocation scope + toolCallId.
86
+ * Must not embed runId — public Terminal decisiveFacts re-expose identity (#108).
87
+ */
88
+ export function engineDetourCallIdentity(input: {
89
+ readonly toolCallId: string;
90
+ readonly invocationScopeId?: string;
91
+ }): string {
92
+ const scope = input.invocationScopeId ?? "";
93
+ return `engine-detour-call:${scope}:${input.toolCallId}`;
94
+ }
95
+
96
+ /** Live sitian write for one detour call. Returns the call fact with pointer. */
97
+ export function reportEngineDetourCall(
98
+ input: ReportEngineDetourCallInput,
99
+ ): EngineDetourCallFact {
100
+ const payload: Record<string, unknown> = {
101
+ tool: ENGINE_DETOUR_TOOL_NAME,
102
+ toolCallId: input.toolCallId,
103
+ durationMs: input.durationMs,
104
+ };
105
+ if (input.code !== undefined) payload.code = input.code;
106
+ if (input.stdoutByteLength !== undefined) {
107
+ payload.stdoutByteLength = input.stdoutByteLength;
108
+ }
109
+ if (input.invocationScopeId !== undefined) {
110
+ payload.invocationScopeId = input.invocationScopeId;
111
+ }
112
+ if (input.runId !== undefined) payload.runId = input.runId;
113
+
114
+ // SitianSubject object form requires runId; invocation scope rides payload always.
115
+ const subject =
116
+ input.runId === undefined
117
+ ? undefined
118
+ : {
119
+ runId: input.runId,
120
+ ...(input.invocationScopeId === undefined
121
+ ? {}
122
+ : { invocationScopeId: input.invocationScopeId }),
123
+ };
124
+
125
+ const pointer = sitianReport({
126
+ level: "event",
127
+ kind: ENGINE_DETOUR_CALL_KIND,
128
+ identity: engineDetourCallIdentity({
129
+ toolCallId: input.toolCallId,
130
+ ...(input.invocationScopeId === undefined
131
+ ? {}
132
+ : { invocationScopeId: input.invocationScopeId }),
133
+ }),
134
+ cwd: input.cwd,
135
+ sessionParent: input.sessionParent,
136
+ source: "engine-detour-tool",
137
+ payload,
138
+ raw: {
139
+ sessionFile: input.sessionParent,
140
+ entryId: input.toolCallId,
141
+ },
142
+ ...(input.home === undefined ? {} : { home: input.home }),
143
+ ...(input.host === undefined ? {} : { host: input.host }),
144
+ ...(subject === undefined ? {} : { subject }),
145
+ });
146
+
147
+ return {
148
+ toolCallId: input.toolCallId,
149
+ durationMs: input.durationMs,
150
+ ...(input.code === undefined ? {} : { code: input.code }),
151
+ ...(input.stdoutByteLength === undefined
152
+ ? {}
153
+ : { stdoutByteLength: input.stdoutByteLength }),
154
+ recordPointer: pointer,
155
+ };
156
+ }
157
+
158
+ function isRecord(value: unknown): value is Record<string, unknown> {
159
+ return typeof value === "object" && value !== null && !Array.isArray(value);
160
+ }
161
+
162
+ function callFactFromSitianPayload(
163
+ payload: unknown,
164
+ pointer: RecordPointer,
165
+ ): EngineDetourCallFact | undefined {
166
+ if (!isRecord(payload)) return undefined;
167
+ if (payload.tool !== ENGINE_DETOUR_TOOL_NAME) return undefined;
168
+ if (typeof payload.toolCallId !== "string" || payload.toolCallId.length === 0) {
169
+ return undefined;
170
+ }
171
+ if (typeof payload.durationMs !== "number" || !Number.isFinite(payload.durationMs)) {
172
+ return undefined;
173
+ }
174
+ return {
175
+ toolCallId: payload.toolCallId,
176
+ durationMs: payload.durationMs,
177
+ ...(typeof payload.code === "number" ? { code: payload.code } : {}),
178
+ ...(typeof payload.stdoutByteLength === "number"
179
+ ? { stdoutByteLength: payload.stdoutByteLength }
180
+ : {}),
181
+ recordPointer: pointer,
182
+ };
183
+ }
184
+
185
+ function invocationScopeIdOfRecord(record: {
186
+ readonly subject?: unknown;
187
+ readonly payload?: unknown;
188
+ }): string | undefined {
189
+ if (isRecord(record.subject)) {
190
+ const fromSubject = record.subject.invocationScopeId;
191
+ if (typeof fromSubject === "string" && fromSubject.length > 0) return fromSubject;
192
+ }
193
+ if (isRecord(record.payload)) {
194
+ const fromPayload = record.payload.invocationScopeId;
195
+ if (typeof fromPayload === "string" && fromPayload.length > 0) return fromPayload;
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ /**
201
+ * Read this-invocation detour-tool usage from sitian volume.
202
+ * When `invocationScopeId` is provided, only records bound to that public call count
203
+ * (host-neutral boundary — not session toolResult join keys, not courtAttemptId).
204
+ * When engineMounted is false, returns undefined (field absent).
205
+ * When engineMounted is true and no calls, returns callCount 0.
206
+ */
207
+ export async function readEngineDetourToolUsage(input: {
208
+ readonly sessionParent: string;
209
+ readonly engineMounted: boolean;
210
+ readonly invocationScopeId?: string;
211
+ readonly home?: string;
212
+ readonly cwd?: string;
213
+ }): Promise<EngineDetourToolUsageFact | undefined> {
214
+ if (!input.engineMounted) return undefined;
215
+
216
+ const { recordFile } = resolveSitianRecordPath({
217
+ level: "event",
218
+ kind: ENGINE_DETOUR_CALL_KIND,
219
+ sessionParent: input.sessionParent,
220
+ ...(input.home === undefined ? {} : { home: input.home }),
221
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
222
+ });
223
+
224
+ const { records } = await readSitianRecords(recordFile);
225
+ const calls: EngineDetourCallFact[] = [];
226
+ for (const record of records) {
227
+ if (record.kind !== ENGINE_DETOUR_CALL_KIND) continue;
228
+ const boundScope = invocationScopeIdOfRecord(record);
229
+ if (input.invocationScopeId !== undefined && input.invocationScopeId.length > 0) {
230
+ if (boundScope !== input.invocationScopeId) continue;
231
+ } else if (boundScope !== undefined) {
232
+ // Unscoped settlement must not pull invocation-bound rows across resume.
233
+ continue;
234
+ }
235
+ const pointer: RecordPointer = {
236
+ identity: record.identity,
237
+ recordFile,
238
+ kind: record.kind,
239
+ level: record.level,
240
+ };
241
+ const fact = callFactFromSitianPayload(record.payload, pointer);
242
+ if (fact === undefined) continue;
243
+ calls.push(fact);
244
+ }
245
+
246
+ return { callCount: calls.length, calls };
247
+ }
248
+
249
+ /**
250
+ * Project usage onto the public Terminal face.
251
+ * Non-resumable: keep absolute recordFile (runId already public via top-level runId).
252
+ * Resumable: keep an openable run-relative recordFile and identity with no runId
253
+ * bytes (#108 single disclosure + #537 AC8 reopen).
254
+ */
255
+ export function projectEngineDetourToolUsageForPublicTerminal(
256
+ usage: EngineDetourToolUsageFact,
257
+ options: { readonly discloseRecordFile: boolean },
258
+ ): EngineDetourToolUsageFact {
259
+ if (options.discloseRecordFile) return usage;
260
+ return {
261
+ callCount: usage.callCount,
262
+ calls: usage.calls.map((call) => ({
263
+ toolCallId: call.toolCallId,
264
+ durationMs: call.durationMs,
265
+ ...(call.code === undefined ? {} : { code: call.code }),
266
+ ...(call.stdoutByteLength === undefined
267
+ ? {}
268
+ : { stdoutByteLength: call.stdoutByteLength }),
269
+ recordPointer: {
270
+ identity: call.recordPointer.identity,
271
+ kind: call.recordPointer.kind,
272
+ level: call.recordPointer.level,
273
+ recordFile: ENGINE_DETOUR_CALL_RECORD_FILE_RELATIVE,
274
+ },
275
+ })),
276
+ };
277
+ }
278
+
279
+ /** One shared invocation.json reader (ENOENT → undefined; other errors propagate). */
280
+ function readInvocationRecord(
281
+ runDirectory: string,
282
+ ): Record<string, unknown> | undefined {
283
+ try {
284
+ const raw = JSON.parse(
285
+ readFileSync(join(runDirectory, "invocation.json"), "utf8"),
286
+ ) as unknown;
287
+ return isRecord(raw) ? raw : undefined;
288
+ } catch (error) {
289
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ /** True when invocation.json carries a non-empty engine axis. */
295
+ export async function readInvocationEngineMounted(
296
+ runDirectory: string,
297
+ ): Promise<boolean> {
298
+ const raw = readInvocationRecord(runDirectory);
299
+ if (raw === undefined) return false;
300
+ return typeof raw.engine === "string" && raw.engine.trim() !== "";
301
+ }
302
+
303
+ /**
304
+ * Selected host from the admission invocation page — for post-admission
305
+ * host-transition classification only. In-turn tools take host from the shared
306
+ * Host envelope (RoleTurnRequest / HostContext), never this reader.
307
+ * Undefined when absent — callers must not invent "pi".
308
+ */
309
+ export function readInvocationSelectedHost(runDirectory: string): string | undefined {
310
+ const raw = readInvocationRecord(runDirectory);
311
+ if (raw === undefined) return undefined;
312
+ return typeof raw.host === "string" && raw.host.trim() !== ""
313
+ ? raw.host.trim()
314
+ : undefined;
315
+ }
316
+
317
+ /** Merge usage into decisiveFacts without touching role payloads. */
318
+ export function withEngineDetourToolUsageFact<
319
+ T extends { readonly decisiveFacts?: Readonly<Record<string, unknown>> },
320
+ >(
321
+ outcome: T,
322
+ usage: EngineDetourToolUsageFact | undefined,
323
+ ): T {
324
+ if (usage === undefined) return outcome;
325
+ const prior = isRecord(outcome.decisiveFacts) ? outcome.decisiveFacts : {};
326
+ return {
327
+ ...outcome,
328
+ decisiveFacts: {
329
+ ...prior,
330
+ [ENGINE_DETOUR_TOOL_USAGE_FACT_KEY]: usage,
331
+ },
332
+ };
333
+ }
334
+
335
+ /** runDirectory owning a session directory (.../runs/<id>@role/session). */
336
+ export function runDirectoryFromSessionDirectory(sessionDirectory: string): string {
337
+ return dirname(sessionDirectory);
338
+ }
339
+
340
+ /** session.jsonl under a session directory. */
341
+ export function sessionFileFromSessionDirectory(sessionDirectory: string): string {
342
+ return join(sessionDirectory, "session.jsonl");
343
+ }
344
+
345
+ /**
346
+ * Mint one public-invocation scope id when an engine is mounted.
347
+ * Call once at the public-entry boundary — never inside the auto-resume loop.
348
+ * The id lives on RoleTurnRequest / HostContext only (no detour sidecar file).
349
+ */
350
+ export function mintEngineDetourInvocationScope(input: {
351
+ readonly effectiveEngine?: string;
352
+ }): string | undefined {
353
+ const engine = input.effectiveEngine?.trim();
354
+ if (engine === undefined || engine.length === 0) return undefined;
355
+ return randomUUID();
356
+ }
357
+
358
+ /** Attach a minted scope onto a turn request (shared Host envelope field). */
359
+ export function withEngineDetourInvocationScope<T extends object>(
360
+ request: T & { readonly invocationScopeId?: string },
361
+ invocationScopeId: string | undefined,
362
+ ): T & { readonly invocationScopeId?: string } {
363
+ if (invocationScopeId === undefined || invocationScopeId.length === 0) {
364
+ return request;
365
+ }
366
+ if (
367
+ typeof request.invocationScopeId === "string" &&
368
+ request.invocationScopeId.length > 0
369
+ ) {
370
+ return request;
371
+ }
372
+ return { ...request, invocationScopeId };
373
+ }
@@ -194,6 +194,17 @@ export type RoleTurnRequest = {
194
194
  * and resume-with-message set this; bare resume without an open court omits it.
195
195
  */
196
196
  readonly courtAttemptId?: string;
197
+ /**
198
+ * Public-invocation scope (#537): one ak-role call. Auto-resume reuses it;
199
+ * explicit resume mints a new one. Owned by this shared Host envelope — not
200
+ * courtAttemptId and not a detour sidecar file.
201
+ */
202
+ readonly invocationScopeId?: string;
203
+ /**
204
+ * Selected host axis for this turn (#537 / ADR 0082). Projected from the
205
+ * public-entry seat resolution — never re-read from invocation.json by tools.
206
+ */
207
+ readonly host?: string;
197
208
  /** Station child role run (#840): omit automatic navigator attendance. */
198
209
  readonly stationChild?: boolean;
199
210
  };
@@ -246,7 +257,7 @@ export interface DurablePrincipalAuthority {
246
257
  type HostSessionManager = { getLeafEntry(): HostSessionEntry | undefined; getLeafId(): string | null | undefined; getEntries(): Iterable<HostSessionEntry>; getSessionDir(): string; getSessionFile(): string | undefined; getHeader?(): { readonly type: string; readonly id?: string } | null; setSessionFile?(path: string): void; appendCustomEntry?(customType: string, data?: unknown): unknown; };
247
258
 
248
259
  /** Context supplied by a host for one activation and its interceptable events. */
249
- export type HostContext = { cwd: string; mode: string; model: { readonly provider: string } | undefined; sessionManager: HostSessionManager; /** Per-turn admitted run directory (#879); never process-global env. */ runDirectory?: string; /** Per-turn court attempt (#879); never process-global env. */ courtAttemptId?: string; signal?: AbortSignal | undefined; ui?: { notify?(message: string, type?: "info" | "warning" | "error"): void }; transcript?(): string; abort(): void; };
260
+ export type HostContext = { cwd: string; mode: string; model: { readonly provider: string } | undefined; sessionManager: HostSessionManager; /** Per-turn admitted run directory (#879); never process-global env. */ runDirectory?: string; /** Per-turn court attempt (#879); never process-global env. */ courtAttemptId?: string; /** Public-invocation scope (#537); never process-global env. */ invocationScopeId?: string; /** Selected host axis (#537 / ADR 0082); never process-global env invent. */ host?: string; signal?: AbortSignal | undefined; ui?: { notify?(message: string, type?: "info" | "warning" | "error"): void }; transcript?(): string; abort(): void; };
250
261
 
251
262
  /** Per-turn run directory; adapters must project any child-process identity. */
252
263
  export function runDirectoryFromHostContext(context: HostContext): string | undefined {
package/src/pi/adapter.ts CHANGED
@@ -52,6 +52,12 @@ function projectPiContext(context: ExtensionContext, transcriptFromContext?: (co
52
52
  ...(typeof process.env.AK_ROLE_COURT_ATTEMPT === "string" && process.env.AK_ROLE_COURT_ATTEMPT.trim() !== ""
53
53
  ? { courtAttemptId: process.env.AK_ROLE_COURT_ATTEMPT }
54
54
  : {}),
55
+ ...(typeof process.env.AK_ROLE_INVOCATION_SCOPE === "string" && process.env.AK_ROLE_INVOCATION_SCOPE.trim() !== ""
56
+ ? { invocationScopeId: process.env.AK_ROLE_INVOCATION_SCOPE }
57
+ : {}),
58
+ ...(typeof process.env.AK_ROLE_HOST === "string" && process.env.AK_ROLE_HOST.trim() !== ""
59
+ ? { host: process.env.AK_ROLE_HOST.trim() }
60
+ : {}),
55
61
  sessionManager: {
56
62
  getLeafEntry: () => context.sessionManager.getLeafEntry(),
57
63
  getLeafId: () => context.sessionManager.getLeafId(),
@@ -447,6 +447,12 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
447
447
  };
448
448
  if (request.courtAttemptId === undefined) delete env.AK_ROLE_COURT_ATTEMPT;
449
449
  else env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
450
+ // Public-invocation scope (#537): omit must not inherit a parent env value.
451
+ if (request.invocationScopeId === undefined) delete env.AK_ROLE_INVOCATION_SCOPE;
452
+ else env.AK_ROLE_INVOCATION_SCOPE = request.invocationScopeId;
453
+ // Selected host axis (#537 / ADR 0082): omit must not inherit a parent env value.
454
+ if (request.host === undefined || request.host.trim() === "") delete env.AK_ROLE_HOST;
455
+ else env.AK_ROLE_HOST = request.host.trim();
450
456
  applyEngineChildEnv(env, request.engine);
451
457
  // Nested auditor dossier tool binds the parent run pointer when published.
452
458
  if (