@evoclock/pi-agentic-driver 0.6.0 → 0.7.0

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 CHANGED
@@ -1,4 +1,4 @@
1
- # pi-agentic-driver v0.6.0
1
+ # pi-agentic-driver v0.7.0
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
@@ -7,7 +7,7 @@
7
7
  <p align="center">
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
9
9
  <a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
10
- <img src="https://img.shields.io/badge/version-0.6.0-blue?style=flat" alt="Version 0.6.0"/>
10
+ <img src="https://img.shields.io/badge/version-0.7.0-blue?style=flat" alt="Version 0.7.0"/>
11
11
  <img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
12
12
  <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
13
13
  <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
@@ -345,18 +345,72 @@ Small models on your own desk are worth taking seriously as reviewers.
345
345
 
346
346
  </details>
347
347
 
348
+ <details>
349
+ <summary><strong>autonomous journeys, overnight operation</strong> <em>(released, 0.7.0)</em></summary>
350
+
351
+ Dispatch a worker journey that runs end to end while you are away. The
352
+ journey proceeds through steps without pausing for confirmation, replaces
353
+ agents that get stuck, and records everything in one report you read when
354
+ you come back.
355
+
356
+ Autonomy is entered by your words alone. Say "run these overnight end to
357
+ end" or "don't wait for me" and the journey runs in autonomous mode. Say
358
+ "do these two things" and it runs in the normal mode, pausing for you
359
+ between steps. The mode is recorded in the report either way.
360
+
361
+ The journeys run work you dispatch in natural language: work that is real
362
+ and authorized but not yet formally placed on a kanban board. A task board
363
+ integration is planned, and once it ships, planned journeys will execute
364
+ board-managed work with the same containment and safety guarantees.
365
+
366
+ ### The cast
367
+
368
+ You name the agents for the journey, and that list becomes the standing
369
+ permission. If the implementer gets stuck at 3am, a replacement spawns
370
+ automatically: same role, same model, both in the list you authorized.
371
+ If a task needs an agent you didn't name, the attempt is recorded and that
372
+ thread stops. Nothing outside your list can appear while you sleep.
373
+
374
+ The cast is frozen at dispatch and never grows. It can only shrink in
375
+ practice (roles finishing their work), never widen.
376
+
377
+ ### When agents get stuck
378
+
379
+ A stuck agent doesn't block the journey. The journey detects the stall,
380
+ spawns a replacement from the cast, and the replacement starts with a
381
+ mandatory gap analysis: read the task spec, inspect the repository, consult
382
+ the journey history, and state what remains before resuming. The replacement
383
+ must show that the remaining work is smaller than what its predecessor left.
384
+ If it can't, the journey stops that role and continues with the others.
385
+
386
+ ### The morning report
387
+
388
+ One block tells you what happened: the terminal state, each step's outcome,
389
+ every replacement with its gap analysis and progress judgment, and any
390
+ denials. Every claim is tied to a receipt or a report excerpt. No prose
391
+ narrative to reconstruct; you read the report and know exactly where things
392
+ stand.
393
+
394
+ </details>
395
+
396
+ <details>
397
+ <summary><strong>attended-authority guard</strong> <em>(released, 0.7.0)</em></summary>
398
+
399
+ The safety net between an agent and your shell. When a model tries to
400
+ delete, overwrite, or push, the guard stops it and asks you. Safe commands
401
+ pass through untouched. If you deny, you get a clear reason and the session
402
+ continues, and the agent does not retry behind your back. In headless runs
403
+ where no human can confirm, destructive commands are refused rather than
404
+ silently allowed.
405
+
406
+ </details>
407
+
348
408
  **Under development in this theme:**
349
409
 
350
410
  - **native macOS container proof.** The native Apple Container runtime has
351
411
  passed a fixed local isolation qualification: read-only repository mount,
352
412
  no network, automatic removal. The native Pi adapter is not yet part of the
353
413
  released package.
354
- - **attended-authority guard.** The safety net between an agent and your
355
- shell. When a model tries to delete, overwrite, or push, the guard stops it
356
- and asks you. Safe commands pass through untouched. If you deny, you get a
357
- clear reason and the session continues, and the agent does not retry behind
358
- your back. In headless runs where no human can confirm, destructive
359
- commands are refused rather than silently allowed.
360
414
 
361
415
  ## Session continuity
362
416
 
@@ -436,7 +490,7 @@ pi install npm:@evoclock/pi-agentic-driver
436
490
  Or from Git at a pinned tag:
437
491
 
438
492
  ```sh
439
- pi install git:github.com/evoclock/pi-agentic-driver@v0.6.0
493
+ pi install git:github.com/evoclock/pi-agentic-driver@v0.7.0
440
494
  ```
441
495
 
442
496
  Released extensions load standalone; neither requires the other.
@@ -450,7 +504,7 @@ extensions you want with the object form in your Pi settings:
450
504
  {
451
505
  "packages": [
452
506
  {
453
- "source": "npm:@evoclock/pi-agentic-driver@0.6.0",
507
+ "source": "npm:@evoclock/pi-agentic-driver@0.7.0",
454
508
  "extensions": [
455
509
  "extensions/aidr.ts",
456
510
  "extensions/code-phage.js"
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ export default async function attendedAuthorityGuard(pi) {
5
+ const module = await import(new URL("../scripts/enforcement/attended_authority_guard.js", import.meta.url).href);
6
+ return module.registerAttendedAuthorityGuard(pi);
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -34,6 +34,8 @@
34
34
  "governance"
35
35
  ],
36
36
  "files": [
37
+ "extensions/attended-authority-guard.ts",
38
+ "scripts/enforcement/attended_authority_guard.js",
37
39
  "extensions/code-phage.js",
38
40
  "extensions/herdr-communication.ts",
39
41
  "extensions/herdr-dispatch.ts",
@@ -0,0 +1,223 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Attended-authority destructive-command guard for Pi.
5
+ //
6
+ // Ported observable behavior (private reference: scripts/enforcement/
7
+ // pi_attended_authority.js + cc_destructive_command_guard_hook.py):
8
+ // - Intercept tool calls before execution via the `tool_call` event.
9
+ // - Destructive shell/Git operations require native confirmation.
10
+ // - Safe reads/builds/tests pass through untouched.
11
+ // - An explicit denial returns a clear reason and the session continues;
12
+ // no hidden retry and no automatic re-ask.
13
+ // - Headless / no-confirmation contexts refuse destructive operations
14
+ // fail-closed.
15
+ // - No bypasses, no model-supplied authority, no silent escalation. The
16
+ // model cannot mark a call safe; classification is structural only.
17
+
18
+ import { isNativeTuiContext } from "./native_tui_context.js";
19
+
20
+ export const GUARD_SCHEMA = "pi-agentic-driver.attended-authority.v1";
21
+
22
+ // Tool names that can mutate the workspace.
23
+ const MUTATING_TOOLS = new Set(["bash", "write", "edit"]);
24
+
25
+ // Read-only / build / test commands that always pass through.
26
+ const SAFE_COMMAND_PREFIXES = [
27
+ "cat", "ls", "head", "tail", "grep", "rg", "find", "sed -n", "awk",
28
+ "wc", "file", "stat", "which", "echo", "pwd", "date", "env",
29
+ "node --check", "node --test", "npm test", "npm run",
30
+ "python3 -m pytest", "pytest", "make", "cmake",
31
+ "git status", "git log", "git diff", "git show", "git branch",
32
+ "git remote", "git rev-parse", "git blame", "git describe",
33
+ "git config --get", "git ls-files",
34
+ ];
35
+
36
+ // Arbitrary-code interpreter execution: any code payload can perform a
37
+ // destructive operation, so these forms are classified fail-closed.
38
+ const DESTRUCTIVE_SHELL_PATTERNS = [
39
+ { pattern: /\brm\b[^|;&]*\s(-[a-z]*[rf][a-z]*\s|--recursive|--force)/, kind: "recursive or forced file deletion" },
40
+ { pattern: /\bsudo\s+rm\b/, kind: "privileged file deletion" },
41
+ { pattern: /\brmdir\b|\bunlink\b/, kind: "file or directory deletion" },
42
+ { pattern: /\bmkfs\b|\bshred\b|\bdd\b\s+if=/, kind: "irreversible disk or file operation" },
43
+ { pattern: /\btruncate\s+-s\s*0\b/, kind: "file truncation" },
44
+ { pattern: /\bkill\b\s+-9\b|\bpkill\b/, kind: "forced process termination" },
45
+ { pattern: /\bchmod\s+-R\b|\bchown\s+-R\b/, kind: "recursive permission change" },
46
+ { pattern: /\bnode\s+(-e|--eval)\b/, kind: "arbitrary JavaScript execution via node -e" },
47
+ { pattern: /\bpython3?\s+-c\b/, kind: "arbitrary Python execution via python -c" },
48
+ { pattern: /\bnpx\b/, kind: "arbitrary package execution via npx" },
49
+ ];
50
+
51
+ // Scope note: plain `rm file` (no -r/-f) is intentionally NOT classified
52
+ // destructive here; the guard targets recursive/forced deletion and
53
+ // irreversible operations. Any `git push`, forced or not, is treated
54
+ // conservatively as destructive because pushes publish history to remotes.
55
+
56
+ // Destructive Git operations.
57
+ const DESTRUCTIVE_GIT_PATTERNS = [
58
+ { pattern: /\bgit\s+push\b[^|;&]*(--force|-f\b)/, kind: "forced Git push" },
59
+ { pattern: /\bgit\s+push\b/, kind: "Git push" },
60
+ { pattern: /\bgit\s+reset\s+--hard\b/, kind: "hard Git reset" },
61
+ { pattern: /\bgit\s+reset\b/, kind: "Git reset" },
62
+ { pattern: /\bgit\s+clean\b/, kind: "Git clean" },
63
+ { pattern: /\bgit\s+checkout\s+--\s/, kind: "Git working-tree discard" },
64
+ { pattern: /\bgit\s+restore\b/, kind: "Git working-tree discard" },
65
+ { pattern: /\bgit\s+stash\s+(drop|clear|pop)\b/, kind: "Git stash mutation" },
66
+ { pattern: /\bgit\s+stash\b/, kind: "Git stash mutation" },
67
+ { pattern: /\bgit\s+rebase\b/, kind: "Git history rewrite" },
68
+ { pattern: /\bgit\s+filter-(branch|repo)\b/, kind: "Git history rewrite" },
69
+ { pattern: /\bgit\s+commit\b[^|;&]*--amend\b/, kind: "Git history rewrite" },
70
+ { pattern: /\bgit\s+branch\s+(-D|-d)\b/, kind: "protected branch deletion" },
71
+ { pattern: /\bgit\s+tag\s+-d\b/, kind: "protected tag deletion" },
72
+ { pattern: /\bgit\s+cherry-pick\b|\bgit\s+revert\b/, kind: "Git history mutation" },
73
+ ];
74
+
75
+ // Paths whose deletion or overwrite is always treated as destructive.
76
+ const PROTECTED_PATHS = [
77
+ ".env", ".ssh", ".gnupg", "node_modules", ".git",
78
+ "package-lock.json", "pnpm-lock.yaml", "Cargo.lock", "poetry.lock",
79
+ ];
80
+
81
+ // Quote-aware tokenization: normalizes shell quoting so `rm '-rf'`,
82
+ // `git "push"`, and split/embedded quoting such as `r'm' '-rf'` still
83
+ // expose their destructive tokens to the patterns. Escapes are also
84
+ // stripped (`\rm` -> `rm`) so an escaped destructive token cannot dodge
85
+ // classification. Normalization is only used for the destructive
86
+ // patterns, never to widen the safe-prefix list.
87
+ function unquoteTokens(text) {
88
+ return String(text ?? "")
89
+ .split(/[\s\n]+/)
90
+ .map((token) => token
91
+ .replace(/^"/, "").replace(/"$/, "")
92
+ .replace(/^'/, "").replace(/'$/, "")
93
+ .replace(/["']/g, "")
94
+ .replace(/\\(.)/g, "$1"))
95
+ .join(" ");
96
+ }
97
+
98
+ export function classifyBashCommand(command) {
99
+ const text = String(command ?? "").trim();
100
+ if (!text) return { destructive: false };
101
+ // A safe prefix wins only when the whole command is that single simple
102
+ // command (no chaining, redirection, or command substitution), so a
103
+ // destructive payload cannot hide behind a safe-looking prefix.
104
+ const compound = /[;&|>`]|\$\(|\n/.test(text);
105
+ const redirection = /(^|\s)(>{1,2}|<)/.test(text);
106
+ // Structural destructive verdicts win over safe-looking prefixes, so a
107
+ // destructive form such as `git branch -D` cannot hide behind a safe
108
+ // prefix such as `git branch`.
109
+ for (const { pattern, kind } of DESTRUCTIVE_SHELL_PATTERNS) {
110
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
111
+ }
112
+ for (const { pattern, kind } of DESTRUCTIVE_GIT_PATTERNS) {
113
+ if (pattern.test(text) || pattern.test(unquoteTokens(text))) return { destructive: true, kind };
114
+ }
115
+ if (!compound && !redirection) {
116
+ for (const prefix of SAFE_COMMAND_PREFIXES) {
117
+ if (text === prefix || text.startsWith(`${prefix} `) || text.startsWith(`${prefix}\t`)) {
118
+ return { destructive: false };
119
+ }
120
+ }
121
+ }
122
+ // Output redirection overwrites an existing file in place.
123
+ const redirect = text.match(/(?:^|\s)>{1,2}\s*([^\s;&|]+)\s*$/);
124
+ if (redirect) {
125
+ const target = redirect[1].replace(/^["']|["']$/g, "");
126
+ return {
127
+ destructive: true,
128
+ kind: `file overwrite via redirection to ${target}`,
129
+ };
130
+ }
131
+ // Unrecognized non-safe commands are not destructive by default; the
132
+ // guard only intercepts structurally destructive operations.
133
+ return { destructive: false };
134
+ }
135
+
136
+ function touchesProtectedPath(path) {
137
+ const normalized = String(path ?? "").replace(/\\/g, "/");
138
+ return PROTECTED_PATHS.some((entry) =>
139
+ normalized === entry
140
+ || normalized.endsWith(`/${entry}`)
141
+ || normalized.includes(`/${entry}/`)
142
+ || normalized.startsWith(`./${entry}`),
143
+ );
144
+ }
145
+
146
+ // Existing-file overwrite detection is injected so tests stay filesystem-free.
147
+ export function classifyWriteCall(toolName, input, existsSync) {
148
+ if (toolName !== "write" && toolName !== "edit") {
149
+ return { destructive: false };
150
+ }
151
+ const path = input?.path ?? input?.file_path ?? "";
152
+ if (touchesProtectedPath(path)) {
153
+ return { destructive: true, kind: `protected path write to ${path}` };
154
+ }
155
+ if (toolName === "write" && typeof existsSync === "function" && path && existsSync(path)) {
156
+ return { destructive: true, kind: `overwrite of existing file ${path}` };
157
+ }
158
+ return { destructive: false };
159
+ }
160
+
161
+ export function confirmationBody(toolName, input, kind) {
162
+ return [
163
+ `Attended-authority guard: ${kind}.`,
164
+ `Tool: ${toolName}`,
165
+ toolName === "bash" ? `Command: ${input?.command}` : `Path: ${input?.path ?? input?.file_path ?? ""}`,
166
+ "",
167
+ "Allow this destructive operation?",
168
+ ].join("\n");
169
+ }
170
+
171
+ export function denialReason(toolName, kind, context) {
172
+ if (!isNativeTuiContext(context)) {
173
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) refused fail-closed: no native confirmation surface in this headless context`;
174
+ }
175
+ return `${GUARD_SCHEMA}: destructive ${toolName} operation (${kind}) denied by user; the session continues and this call is not retried`;
176
+ }
177
+
178
+ /**
179
+ * Core guard for one tool call. Returns undefined to allow the call, or
180
+ * `{ block: true, reason }` to refuse it. Confirmation is native-only:
181
+ * `ctx.ui.confirm` in an interactive TUI. Headless contexts never confirm.
182
+ * Model-supplied fields on the event can never grant authority.
183
+ */
184
+ export async function guardToolCall(event, ctx, options = {}) {
185
+ const toolName = String(event?.toolName ?? "").toLowerCase();
186
+ if (!MUTATING_TOOLS.has(toolName)) return undefined;
187
+ const input = event?.input && typeof event.input === "object" ? event.input : {};
188
+
189
+ const bash = toolName === "bash"
190
+ ? classifyBashCommand(input.command)
191
+ : { destructive: false };
192
+ const write = toolName === "bash"
193
+ ? { destructive: false }
194
+ : classifyWriteCall(toolName, input, options.existsSync);
195
+ const verdict = bash.destructive ? bash : write;
196
+ if (!verdict.destructive) return undefined;
197
+
198
+ const context = options.context ?? ctx;
199
+ if (!isNativeTuiContext(context)) {
200
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
201
+ }
202
+ const confirmed = await context.ui.confirm(
203
+ "Destructive operation",
204
+ confirmationBody(toolName, input, verdict.kind),
205
+ );
206
+ if (confirmed === true) return undefined;
207
+ return { block: true, reason: denialReason(toolName, verdict.kind, context) };
208
+ }
209
+
210
+ /**
211
+ * Register the guard on a Pi host instance. Duplicate registration on the
212
+ * same instance is ignored.
213
+ */
214
+ const REGISTERED = new WeakSet();
215
+
216
+ export function registerAttendedAuthorityGuard(pi, options = {}) {
217
+ if (!pi || REGISTERED.has(pi)) return undefined;
218
+ REGISTERED.add(pi);
219
+ pi.on?.("tool_call", (event, ctx) => guardToolCall(event, ctx, options));
220
+ return { registered: true, schema: GUARD_SCHEMA };
221
+ }
222
+
223
+ export default guardToolCall;
@@ -20,7 +20,9 @@ import { isNativeTuiContext } from "./native_tui_context.js";
20
20
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
21
21
  export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
22
22
  export const WORKER_DISPATCH_MODES = Object.freeze(["continuous", "turn-by-turn"]);
23
+ export const WORKER_DISPATCH_AUTONOMY_MODES = Object.freeze(["confirmed-default", "autonomous"]);
23
24
  export const DEFAULT_MODE = "continuous";
25
+ export const DEFAULT_AUTONOMY = "confirmed-default";
24
26
  const DEFAULT_JOURNEY_STEPS = 50;
25
27
  const MAX_JOURNEY_STEPS = 200;
26
28
  const MAX_REPORT_BYTES = 32 * 1024;
@@ -39,9 +41,22 @@ export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
39
41
  action: { type: "string", enum: ["dispatch", "pulse"] },
40
42
  role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
41
43
  mode: { type: "string", enum: WORKER_DISPATCH_MODES },
44
+ autonomy: { type: "string", enum: WORKER_DISPATCH_AUTONOMY_MODES },
42
45
  maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
43
46
  stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
44
47
  model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
48
+ cast: {
49
+ type: "object",
50
+ properties: {
51
+ roles: { type: "array", items: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" }, maxItems: 8 },
52
+ models: {
53
+ type: "object",
54
+ patternProperties: { "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$": { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 } },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ additionalProperties: false,
59
+ },
45
60
  },
46
61
  required: ["action", "role"],
47
62
  allOf: [
@@ -83,6 +98,53 @@ function failure(action, error) {
83
98
  };
84
99
  }
85
100
 
101
+ function frozenCastEntries(source) {
102
+ if (Array.isArray(source)) {
103
+ return source.map((entry) => ({
104
+ role: entry?.role,
105
+ models: Array.isArray(entry?.models) ? entry.models : [entry?.model],
106
+ }));
107
+ }
108
+ if (source && typeof source === "object" && Array.isArray(source.roles)) {
109
+ return source.roles.map((role) => ({
110
+ role,
111
+ models: Array.isArray(source.models?.[role])
112
+ ? source.models[role]
113
+ : [source.models?.[role]],
114
+ }));
115
+ }
116
+ return [];
117
+ }
118
+
119
+ function materializeFrozenCast(params, options) {
120
+ const source = params.cast ?? options.cast;
121
+ const entries = source === undefined
122
+ ? [{ role: params.role, models: [params.model ?? options.model] }]
123
+ : frozenCastEntries(source);
124
+ const roles = [];
125
+ const models = {};
126
+ for (const entry of entries) {
127
+ if (typeof entry.role !== "string" || roles.includes(entry.role)) continue;
128
+ roles.push(entry.role);
129
+ models[entry.role] = Object.freeze(
130
+ entry.models.filter((model, index, values) => typeof model === "string" && values.indexOf(model) === index),
131
+ );
132
+ }
133
+ return Object.freeze({ roles: Object.freeze(roles), models: Object.freeze(models) });
134
+ }
135
+
136
+ function checkFrozenCast(cast, role, model) {
137
+ const roleAuthorized = Array.isArray(cast?.roles) && cast.roles.includes(role);
138
+ const modelSet = roleAuthorized ? cast.models?.[role] : undefined;
139
+ const modelAuthorized = roleAuthorized && Array.isArray(modelSet) && modelSet.some((candidate) => candidate === model);
140
+ return Object.freeze({
141
+ authorized: modelAuthorized,
142
+ role,
143
+ model,
144
+ reason: modelAuthorized ? "in-cast" : "outside-cast",
145
+ });
146
+ }
147
+
86
148
  // Worker pulse: liveness, current state, and dispatch eligibility, observed
87
149
  // through the existing non-authorizing get seam. Grants no authority.
88
150
  export async function workerPulse(role, context, options = {}, signal) {
@@ -146,6 +208,8 @@ function journeyReceipt(journey) {
146
208
  const body = [
147
209
  "[WORKER_JOURNEY_REPORT_BEGIN]",
148
210
  `mode: ${journey.mode}`,
211
+ `autonomy: ${journey.autonomy}`,
212
+ ...(journey.cast ? [`cast: ${JSON.stringify(journey.cast)}`] : []),
149
213
  `role: ${journey.role}`,
150
214
  `steps: ${journey.steps.length}`,
151
215
  `status: ${journey.status}`,
@@ -167,6 +231,7 @@ function journeyReceipt(journey) {
167
231
  // dispatch purposes and ends the journey explicitly as worker-unresponsive.
168
232
  export async function runWorkerJourney(params, context, options = {}, signal) {
169
233
  const mode = params.mode ?? DEFAULT_MODE;
234
+ const autonomy = params.autonomy ?? DEFAULT_AUTONOMY;
170
235
  const maxSteps = params.maxSteps ?? DEFAULT_JOURNEY_STEPS;
171
236
  if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > MAX_JOURNEY_STEPS) {
172
237
  return failure("dispatch", dispatchError("max-steps-invalid",
@@ -176,15 +241,18 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
176
241
  const stepPrompt = params.stepPrompt;
177
242
  const taskStore = options.taskStore;
178
243
  const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
179
- const journey = { mode, role, steps: [], status: "failed", code: null, handoff: null };
244
+ const journey = { mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
180
245
  const dispatched = new Set();
181
246
  const communicationOptions = options.communication ?? options;
182
-
247
+ const replacementRole = options.replacementRole ?? role;
248
+ const replacementModel = options.replacementModel ?? options.model ?? params.model;
183
249
  const finish = (status) => ({
184
250
  schema: WORKER_DISPATCH_SCHEMA,
185
251
  ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
186
252
  action: "dispatch",
187
253
  mode,
254
+ autonomy,
255
+ ...(journey.cast ? { cast: journey.cast } : {}),
188
256
  role,
189
257
  status,
190
258
  steps: journey.steps,
@@ -209,28 +277,51 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
209
277
  journey.handoff = { attempted: false, reason: "replacement spawn is not available in this context" };
210
278
  return finish("worker-unresponsive");
211
279
  }
212
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
213
- journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
214
- return finish("worker-unresponsive");
215
- }
216
- let confirmed;
217
- try {
218
- confirmed = await context.ui.confirm("Spin up replacement worker", [
219
- `Agent session for role ${role} became unresponsive (${reason}).`,
220
- "Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
221
- "The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
222
- ].join("\n"));
223
- } catch (error) {
224
- journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
280
+ const replacementRole = options.replacementRole ?? role;
281
+ const replacementModel = options.replacementModel ?? options.model ?? params.model;
282
+ if (autonomy === "autonomous" && castCheck.authorized !== true) {
283
+ journey.handoff = {
284
+ attempted: true,
285
+ ok: false,
286
+ spawned: false,
287
+ role: replacementRole,
288
+ model: replacementModel,
289
+ reason: "outside-cast",
290
+ castCheck,
291
+ };
225
292
  return finish("worker-unresponsive");
226
293
  }
227
- if (confirmed !== true) {
228
- journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
229
- return finish("worker-unresponsive");
294
+ if (autonomy !== "autonomous") {
295
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
296
+ journey.handoff = { attempted: false, reason: "native TUI confirmation unavailable for replacement spawn" };
297
+ return finish("worker-unresponsive");
298
+ }
299
+ let confirmed;
300
+ try {
301
+ confirmed = await context.ui.confirm("Spin up replacement worker", [
302
+ `Agent session for role ${replacementRole} became unresponsive (${reason}).`,
303
+ "Spin up one replacement worker through the guarded herdr-lifecycle spawn boundary?",
304
+ "The replacement resumes the same pending task sequence; existing task cards are reused, never duplicated.",
305
+ ].join("\n"));
306
+ } catch (error) {
307
+ journey.handoff = { attempted: false, reason: `confirmation failed: ${error.message}` };
308
+ return finish("worker-unresponsive");
309
+ }
310
+ if (confirmed !== true) {
311
+ journey.handoff = { attempted: false, reason: "native confirmation was not granted for the replacement spawn" };
312
+ return finish("worker-unresponsive");
313
+ }
230
314
  }
231
315
  let spawned;
232
316
  try {
233
- spawned = await spawnReplacement({ role, repository: options.repository, model: options.model, context, signal });
317
+ spawned = await spawnReplacement({
318
+ role: replacementRole,
319
+ repository: options.repository,
320
+ model: replacementModel,
321
+ context,
322
+ signal,
323
+ castCheck,
324
+ });
234
325
  } catch (error) {
235
326
  journey.handoff = { attempted: true, ok: false, error: String(error?.message || error).slice(0, 256) };
236
327
  return finish("worker-unresponsive");
@@ -238,9 +329,10 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
238
329
  journey.handoff = {
239
330
  attempted: true,
240
331
  ok: spawned?.ok === true,
241
- role: spawned?.role ?? role,
332
+ role: spawned?.role ?? replacementRole,
242
333
  repository: spawned?.repository,
243
334
  modelArgv: spawned?.modelArgv,
335
+ castCheck,
244
336
  nonAuthorizing: true,
245
337
  };
246
338
  return finish("worker-unresponsive");
@@ -250,6 +342,34 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
250
342
  if (mode !== "continuous" && mode !== "turn-by-turn") {
251
343
  return failure("dispatch", dispatchError("mode-invalid", "dispatch mode must be continuous or turn-by-turn", "denied"));
252
344
  }
345
+ if (!WORKER_DISPATCH_AUTONOMY_MODES.includes(autonomy)) {
346
+ return failure("dispatch", dispatchError("autonomy-invalid", "autonomy must be confirmed-default or autonomous", "denied"));
347
+ }
348
+ if (params.cast) {
349
+ const castRoles = Array.isArray(params.cast)
350
+ ? params.cast.map((entry) => entry?.role).filter((role) => typeof role === "string")
351
+ : Array.isArray(params.cast.roles) ? params.cast.roles : [];
352
+ if (!castRoles.length) {
353
+ return failure("dispatch", dispatchError("cast-invalid", "the cast must include at least one role", "denied"));
354
+ }
355
+ for (const castRole of castRoles) {
356
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(castRole) || castRole.length > 64) {
357
+ return failure("dispatch", dispatchError("cast-invalid", `invalid cast role name: ${castRole}`, "denied"));
358
+ }
359
+ }
360
+ const castModels = Array.isArray(params.cast)
361
+ ? Object.fromEntries(params.cast.map((entry) => [entry?.role, entry?.model]).filter(([role]) => typeof role === "string"))
362
+ : params.cast.models ?? {};
363
+ for (const [castRoleName, castModel] of Object.entries(castModels)) {
364
+ if (typeof castModel === "string" && !/^[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._-]{0,127})*$/.test(castModel)) {
365
+ return failure("dispatch", dispatchError("cast-invalid", `invalid cast model for ${castRoleName}: ${castModel}`, "denied"));
366
+ }
367
+ }
368
+ }
369
+ if (autonomy === "autonomous") journey.cast = materializeFrozenCast(params, options);
370
+ const castCheck = autonomy === "autonomous"
371
+ ? checkFrozenCast(journey.cast, replacementRole, replacementModel)
372
+ : null;
253
373
  if (!taskStore || typeof taskStore.list !== "function") {
254
374
  return failure("dispatch", dispatchError("task-store-invalid", "a read-only task store is required", "denied"));
255
375
  }
@@ -304,29 +424,32 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
304
424
  return finish("waiting-approval");
305
425
  }
306
426
 
307
- // Consequential dispatch requires native confirmation, once per step.
308
- if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
309
- journey.status = "failed";
310
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
311
- return finish("failed");
312
- }
313
- let confirmed;
314
- try {
315
- confirmed = await context.ui.confirm("Dispatch task to worker", [
316
- `Dispatch one bounded step to role ${role}?`,
317
- `Task: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`,
318
- `Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
319
- "One prompt exchange, no retries; the worker returns one marked report.",
320
- ].join("\n"));
321
- } catch (error) {
322
- journey.status = "failed";
323
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
324
- return finish("failed");
325
- }
326
- if (confirmed !== true) {
327
- journey.status = "cancelled";
328
- journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
329
- return finish("cancelled");
427
+ // Consequential dispatch requires native confirmation, once per step,
428
+ // unless the initial dispatch explicitly authorized autonomous progression.
429
+ if (autonomy !== "autonomous") {
430
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
431
+ journey.status = "failed";
432
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: "native TUI confirmation unavailable" });
433
+ return finish("failed");
434
+ }
435
+ let confirmed;
436
+ try {
437
+ confirmed = await context.ui.confirm("Dispatch task to worker", [
438
+ `Dispatch one bounded step to role ${role}?`,
439
+ `Task: ${task.id}${task.subject ? ` ${task.subject}` : ""}`,
440
+ `Mode: ${mode} (step ${stepIndex} of at most ${maxSteps})`,
441
+ "One prompt exchange, no retries; the worker returns one marked report.",
442
+ ].join("\n"));
443
+ } catch (error) {
444
+ journey.status = "failed";
445
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: `confirmation failed: ${error.message}` });
446
+ return finish("failed");
447
+ }
448
+ if (confirmed !== true) {
449
+ journey.status = "cancelled";
450
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "cancelled", error: "native confirmation was not granted" });
451
+ return finish("cancelled");
452
+ }
330
453
  }
331
454
 
332
455
  // One prompt exchange. Any failure is terminal for the journey; there is
@@ -338,8 +461,50 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
338
461
  signal,
339
462
  );
340
463
  if (exchange.ok !== true) {
341
- const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "process_timeout";
464
+ const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "prompt_delivery_unknown" || exchange.code === "process_timeout";
342
465
  if (unresponsive) {
466
+ if (autonomy === "autonomous" && castCheck?.authorized) {
467
+ // Autonomous replacement spawn: in-cast roles are replaced
468
+ // automatically through the guarded seam. The replacement's first
469
+ // prompt includes the mandatory gap-analysis instruction.
470
+ const replacementPrompt = `${stepPrompt}\n\nMANDATORY GAP-ANALYSIS PHASE: you are a replacement agent. Before resuming implementation work: (1) read the task spec; (2) inspect the repository state (code, tests, working tree) — not what prior reports claim; (3) consult the journey history for prior step reports, handoffs, and progress judgments; (4) produce a gap analysis: remaining work and the next concrete sub-step you will execute. You may not resume implementation until this phase is complete.`;
471
+ const replacement = await executeHerdrCommunication(
472
+ { action: "prompt", role, prompt: replacementPrompt, timeoutMs: 120000 },
473
+ context,
474
+ communicationOptions,
475
+ signal,
476
+ );
477
+ const gapAnalysis = replacement.ok === true
478
+ ? String(replacement.report ?? "").slice(0, 512)
479
+ : undefined;
480
+ const previousScope = journey.steps.filter(s => s.gapAnalysis).at(-1)?.gapAnalysis;
481
+ const progressCredited = replacement.ok === true && (!previousScope || gapAnalysis !== previousScope);
482
+ journey.steps.push({
483
+ step: journey.steps.length + 1,
484
+ taskId: task.id,
485
+ status: replacement.ok === true ? "replaced" : "worker-unresponsive",
486
+ error: replacement.ok === true ? undefined : String(replacement.code),
487
+ gapAnalysis,
488
+ progressCredited,
489
+ handoff: {
490
+ timestamp: new Date().toISOString(),
491
+ role,
492
+ triggerCode: exchange.code,
493
+ gapOutcome: gapAnalysis ? "produced" : "failed",
494
+ progressJudgment: progressCredited ? "credited" : "not-credited",
495
+ },
496
+ });
497
+ if (!progressCredited) {
498
+ journey.status = "worker-unresponsive-exhausted";
499
+ return finish("worker-unresponsive-exhausted");
500
+ }
501
+ // The replacement demonstrated progress: continue the journey with
502
+ // the next task instead of finishing. The replacement's exchange
503
+ // outcome determines whether the current task is retried or skipped.
504
+ if (replacement.ok !== true) {
505
+ return finish("worker-unresponsive");
506
+ }
507
+ }
343
508
  return handoffToReplacement(`exchange ended with ${exchange.code}`, task.id);
344
509
  }
345
510
  journey.status = exchange.code === "role_blocked" ? "role-blocked" : "failed";
@@ -131,6 +131,7 @@ function errorResult(operation, error) {
131
131
  code: known.code,
132
132
  reason: known.message,
133
133
  ...(known.diagnostic ? { diagnostic: known.diagnostic } : {}),
134
+ ...(known.deliveryState ? { deliveryState: known.deliveryState } : {}),
134
135
  nonAuthorizing: true,
135
136
  authorityCreated: false,
136
137
  };
@@ -676,7 +677,7 @@ async function invokeHerdr(action, params, context, options = {}, signal) {
676
677
  timeoutMs: processTimeout,
677
678
  maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
678
679
  });
679
- } catch {
680
+ } catch (error) {
680
681
  return { internalFailure: "spawn_error" };
681
682
  }
682
683
  raw = await awaitBounded(pending, processTimeout, signal);
@@ -992,15 +993,15 @@ function allMarkerOccurrences(text, standaloneOnly = false, additionalPair = und
992
993
  return occurrences.sort((left, right) => left.index - right.index || left.end - right.end);
993
994
  }
994
995
 
995
- function promptContractRange(text, marker, occurrenceIndex) {
996
+ function promptContractRange(text, marker, occurrenceIndex, { boundEnd = false } = {}) {
996
997
  const anchor = text.lastIndexOf(REPORT_CONTRACT_LINE, occurrenceIndex);
997
998
  if (anchor < 0) return undefined;
998
999
  const markerStart = anchor + REPORT_CONTRACT_LINE.length;
999
1000
  const open = text.indexOf(marker.open, markerStart);
1000
1001
  if (open < 0 || open > occurrenceIndex) return undefined;
1001
1002
  const close = text.indexOf(marker.close, open + marker.open.length);
1002
- if (close < occurrenceIndex) return undefined;
1003
1003
  const end = close + marker.close.length;
1004
+ if (boundEnd && end > occurrenceIndex) return undefined;
1004
1005
  if (Buffer.byteLength(text.slice(anchor, end), "utf8") > MAX_PROMPT_CONTRACT_ECHO_BYTES) return undefined;
1005
1006
  if (!/^\s*$/.test(text.slice(markerStart, open)) || !/^\s*$/.test(text.slice(open + marker.open.length, close))) return undefined;
1006
1007
  const otherMarkers = [...new Set(Object.values(REPORT_MARKERS)
@@ -1053,16 +1054,11 @@ function extractLatestReport(text, role) {
1053
1054
  }
1054
1055
  const prior = relevant.at(-3);
1055
1056
  if (prior?.marker === marker.open) {
1056
- // `recent-unwrapped` can retain one older unmatched opening before the
1057
- // newer pair. Ignore that prefix only when it is preceded by terminal
1058
- // history; an opening at the window boundary remains fail-closed so a
1059
- // nested/duplicate opening cannot be reclassified as stale history.
1060
- const prefixOpenCount = relevant.slice(0, -2).filter((item) => item.marker === marker.open).length;
1061
- const historicalPrefix = prefixOpenCount === 1
1062
- && text.slice(0, prior.index).trim().length > 0;
1063
- if (!historicalPrefix) {
1064
- throw communicationError("report_duplicate_open", "the latest role report contains a duplicate or nested opening marker");
1065
- }
1057
+ // A duplicate or nested opening before the latest pair is never
1058
+ // reclassified as stale history: stale unmatched opens are surfaced as
1059
+ // bounded evidence in the prompt flow only, and a plain read stays
1060
+ // fail-closed.
1061
+ throw communicationError("report_duplicate_open", "the latest role report contains a duplicate or nested opening marker");
1066
1062
  }
1067
1063
  const rawBody = text.slice(open.end, close.index);
1068
1064
  const nestedMarkers = allMarkerOccurrences(rawBody, false, marker)
@@ -1083,6 +1079,76 @@ function extractLatestReport(text, role) {
1083
1079
  return body;
1084
1080
  }
1085
1081
 
1082
+ function lineStarts(text) {
1083
+ const starts = [0];
1084
+ for (let index = 0; index < text.length; index += 1) {
1085
+ if (text[index] === "\n") starts.push(index + 1);
1086
+ }
1087
+ return starts;
1088
+ }
1089
+
1090
+ function provenancedReportSegment(pre, post, sentPrompt, role) {
1091
+ const marker = reportMarkersForRole(role);
1092
+ const preLines = pre.split("\n");
1093
+ const starts = lineStarts(post);
1094
+ const candidates = [];
1095
+ let from = 0;
1096
+ while (from <= post.length) {
1097
+ const start = post.indexOf(sentPrompt, from);
1098
+ if (start < 0) break;
1099
+ from = start + 1;
1100
+ if (!starts.includes(start)) continue;
1101
+ const range = marker && promptContractRange(post, marker, start + sentPrompt.length, { boundEnd: true });
1102
+ if (!range || range.end !== start + sentPrompt.length) continue;
1103
+ const prefix = post.slice(0, start);
1104
+ const prefixLines = prefix.endsWith("\n") ? prefix.slice(0, -1).split("\n") : prefix.split("\n");
1105
+ const shared = Math.min(prefixLines.length, preLines.length);
1106
+ if (shared < 1) continue;
1107
+ const preFirstLine = preLines[0];
1108
+ const prefixContainsPreSnapshot = prefixLines.some((line) => line === preFirstLine);
1109
+ const preTailMatchesPrefix = preLines.slice(-shared).every((line, index) => line === prefixLines[prefixLines.length - shared + index]);
1110
+ if (prefixContainsPreSnapshot || preTailMatchesPrefix) {
1111
+ candidates.push({ start, end: range.end, aligned: shared });
1112
+ }
1113
+ }
1114
+ if (!candidates.length) throw communicationError("report_scope_unavailable", "the prompted exchange could not be proven from terminal history");
1115
+ const max = Math.max(...candidates.map((candidate) => candidate.aligned));
1116
+ const winners = candidates.filter((candidate) => candidate.aligned === max);
1117
+ if (winners.length !== 1) throw communicationError("report_scope_unavailable", "the prompted exchange boundary is ambiguous");
1118
+ return winners[0];
1119
+ }
1120
+
1121
+ function extractReportFromSegment(text, role, fromIndex = 0, fullText = text) {
1122
+ const marker = reportMarkersForRole(role);
1123
+ const segment = text.slice(fromIndex);
1124
+ const relevant = allMarkerOccurrences(segment, true, marker)
1125
+ .filter((item) => (item.marker === marker.open || item.marker === marker.close)
1126
+ && !isPromptContractMarker(fullText, { index: item.index + fromIndex, end: item.end + fromIndex }, marker));
1127
+ if (!relevant.length) throw communicationError("report_missing", "no complete role-specific report was observed");
1128
+ const close = relevant.at(-1);
1129
+ if (close.marker === marker.open) {
1130
+ throw communicationError("report_truncated", "the latest role report has no closing marker");
1131
+ }
1132
+ const open = relevant.at(-2);
1133
+ if (!open || open.marker !== marker.open) {
1134
+ throw communicationError("report_reversed", "the latest role report has no matching opening marker");
1135
+ }
1136
+ const rawBody = segment.slice(open.end, close.index);
1137
+ const nestedMarkers = allMarkerOccurrences(rawBody, false, marker)
1138
+ .filter((item) => !isPromptContractMarker(fullText, { index: open.end + item.index, end: open.end + item.end }, marker));
1139
+ if (nestedMarkers.length) {
1140
+ throw communicationError("report_nested", "the latest role report contains a nested report marker");
1141
+ }
1142
+ const body = removePromptContractEchoes(rawBody, marker)
1143
+ .replace(/^[ \t]*\r?\n/, "")
1144
+ .replace(/\r?\n[ \t]*$/, "");
1145
+ if (!body.trim()) throw communicationError("report_empty", "the latest role report is empty");
1146
+ if (Buffer.byteLength(body, "utf8") > MAX_REPORT_BYTES) {
1147
+ throw communicationError("report_oversized", "the latest role report exceeds the bounded report size");
1148
+ }
1149
+ return body;
1150
+ }
1151
+
1086
1152
  export function extractLatestHerdrReport(text, role) {
1087
1153
  requireRole(role);
1088
1154
  if (typeof text !== "string") throw communicationError("report_missing", "report history is not text");
@@ -1119,7 +1185,36 @@ export async function executeHerdrCommunication(params, context, options = {}, s
1119
1185
  // change before it accepts settlement. A separate wait command can race
1120
1186
  // and match the role's pre-existing idle state, reading the empty marker
1121
1187
  // template before the new response exists.
1122
- const prompted = await invokeHerdr(operation, request, context, options, signal);
1188
+ let pre;
1189
+ try {
1190
+ pre = readText(await invokeHerdr("read", { action: "read", role }, context, options, signal));
1191
+ } catch {
1192
+ throw communicationError("report_scope_unavailable", "the pre-prompt terminal snapshot is unavailable");
1193
+ }
1194
+ const revalidated = await invokeHerdr("get", { action: "get", role }, context, options, signal);
1195
+ publicAgentObservation(extractAgent(revalidated, "agent_info"), role, repositories, { requirePromptable: true });
1196
+ let prompted;
1197
+ try {
1198
+ prompted = await invokeHerdr(operation, request, context, options, signal);
1199
+ } catch (error) {
1200
+ if (!(error instanceof HerdrCommunicationError) || error.code !== "prompt_stalled") throw error;
1201
+ try {
1202
+ const recovered = await invokeHerdr("get", { action: "get", role }, context, options, signal);
1203
+ const recovery = publicAgentObservation(extractAgent(recovered, "agent_info"), role, repositories);
1204
+ const recoveredSeq = stateChangeSeq(recovered);
1205
+ const initialSeq = stateChangeSeq(current);
1206
+ if (recovery.status === "idle" && initialSeq !== undefined && recoveredSeq !== undefined && recoveredSeq !== initialSeq) throw error;
1207
+ const unknown = communicationError("prompt_delivery_unknown", "prompt delivery is unknown; the adapter did not retry, and only the caller may issue a new explicit prompt");
1208
+ unknown.deliveryState = "unknown";
1209
+ throw unknown;
1210
+ } catch (recoveryError) {
1211
+ if (recoveryError instanceof HerdrCommunicationError && ["prompt_stalled", "prompt_delivery_unknown"].includes(recoveryError.code)) throw recoveryError;
1212
+ const unknown = communicationError("prompt_delivery_unknown", "prompt delivery is unknown; the adapter did not retry, and only the caller may issue a new explicit prompt");
1213
+ unknown.deliveryState = "unknown";
1214
+ unknown.diagnostic = boundedFailureDiagnostic(recoveryError?.message);
1215
+ throw unknown;
1216
+ }
1217
+ }
1123
1218
  const observation = publicAgentObservation(extractAgent(prompted, "agent_prompted"), role, repositories);
1124
1219
  const waitedStatus = observation.status;
1125
1220
  if (!WAIT_STATUSES.has(waitedStatus)) {
@@ -1129,7 +1224,10 @@ export async function executeHerdrCommunication(params, context, options = {}, s
1129
1224
  return errorResult(operation, communicationError("role_blocked", "the prompted role reached blocked state", "blocked"));
1130
1225
  }
1131
1226
  const rawReport = await invokeHerdr("read", { action: "read", role }, context, options, signal);
1132
- const report = extractLatestHerdrReport(readText(rawReport), role);
1227
+ const post = readText(rawReport);
1228
+ const sentPrompt = promptWithReportRequirement(role, request.prompt);
1229
+ const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
1230
+ const report = extractReportFromSegment(post, role, boundary.end);
1133
1231
  return successResult(operation, {
1134
1232
  status: "complete",
1135
1233
  role,
@@ -2587,6 +2587,14 @@ if ! gc_cache_growth_sample "\$session" /root/.npm /root/.cache /var/cache >/dev
2587
2587
  gc_log_event "\$session" "sweep" "GC-FSW-003" "sampler" "cache-growth sampling failed" "error" >/dev/null 2>&1 || true
2588
2588
  fi
2589
2589
  if [ ! -f "\$session/kill" ]; then gc_session_end "\$session" || :; fi
2590
+ # Session outcome (compliance-gap harness): the job's outcome marker, bounded
2591
+ # and redacted, appended to the containment log before the envelope. The job
2592
+ # may write /tmp/session/result; a missing file means the job ended without
2593
+ # an outcome.
2594
+ if [ -f "/tmp/session/result" ]; then
2595
+ outcome=\$(head -c 256 "/tmp/session/result" | tr '\r\n' ' ' | sed 's/[[:cntrl:]]//g')
2596
+ gc_log_event "\$session" "supervisor" "session-outcome" "outcome" "\$outcome" >/dev/null 2>&1 || :
2597
+ fi
2590
2598
  # Denial-evidence transport (design section 5): framed base64 envelope on the
2591
2599
  # console channel, UTF-8, LF-only, fixed key order; pty-safe alphabet.
2592
2600
  if [ -f "\$session/containment.log.jsonl" ]; then