@evoclock/pi-agentic-driver 0.6.0 → 0.8.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.
@@ -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