immune-brain 4.4.0 → 4.4.1

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.
@@ -116,6 +116,23 @@ batch state transition: the shared `startBatch` driver in
116
116
  `runtime/unattended/` owns every batch transition, and each child is still
117
117
  enrolled, assured, and settled by the Kernel under an ordinary TaskRecord.
118
118
 
119
+ A successful child Enrollment returns a `running` report with `handoff.role`
120
+ `executor`; the child remains `enrolled`. The Parent implements that TaskIntent
121
+ in the current foreground conversation, stages only its scoped changes, and
122
+ runs focused diagnostics before Kernel Assurance. Re-entering the batch before
123
+ implementation returns the same handoff without QA or another confirmation.
124
+ The runtime owns no model invocation and adds no persisted execution lifecycle.
125
+
126
+ After diagnostics pass, the Parent advances Kernel QA and dispatches any reserved
127
+ foreground Review once using the exact returned `agent_params`, then submits its
128
+ structured verdict. An open Review reservation stays with that Host invocation;
129
+ batch re-entry waits for it rather than redispatching. On Kernel `done`, stage
130
+ only that child's generated terminal audit evidence and call
131
+ `start_unattended_batch` with the same slug. The driver reconciles settlement,
132
+ adopts or creates the one scope-bound commit, then enrolls the next ready child
133
+ under the still-valid authorization. Ordinary Executor handoff and below-limit
134
+ QA repair do not park the batch; genuine budget/authority stops remain fail-closed.
135
+
119
136
  Batch execution never pushes a ref, opens or updates a pull request, resolves a
120
137
  user decision, or creates, switches, or deletes a Git worktree. Its sole Git
121
138
  effect is the batch branch `imm/<initiative-slug>` plus one scope-bounded commit
@@ -147,8 +164,24 @@ per completed child.
147
164
  and exactly one same-Host recovery action. Never recommend another Host,
148
165
  worktree, Direct Path, unmanaged implementation, or automatic retry.
149
166
  - After interruption, read a fresh projection and run only the pending obligation.
150
- A committed QA result is honored; an interrupted precommit QA run produces no
151
- approval. Do not rerun fresh QA simply because Review was interrupted.
167
+ `recovery` carries that task/run/record identity, affected acceptance/finding IDs
168
+ and one legal `next_action`; it is an observation, never execution readiness.
169
+ Own-claim technical rework goes to foreground Executor, not user authorization.
170
+ `review_preparation_failed` keeps the batch running/enrolled: repair the Review
171
+ evidence environment and resume `run_review`, retaining fresh QA without batch
172
+ re-confirmation.
173
+ Repair and verify the cause, then dispose each identified finding explicitly by
174
+ `resolve_finding` or evidence-bound `refute_finding`. Local green cannot close
175
+ findings or settle the task; obtain fresh Kernel QA and required Review.
176
+ Environment preparation/resolution/integrity failure is not an assertion finding.
177
+ Use its canonical descriptor reference/digest, phase/outcome, timing and byte
178
+ counts to diagnose; QA diagnostic metadata is capped at 16 KiB and contains no
179
+ stdout/stderr, argv, environment values or arbitrary executor error text.
180
+ A frozen child with `run_qa` pending returns to Parent; batch re-entry does not
181
+ blindly retry an unchanged failure. Fix the environment before a fresh attempt.
182
+ Scope/authority exceptions still use their existing native gates. A committed
183
+ QA result is honored; an interrupted precommit QA run produces no approval.
184
+ Do not rerun fresh QA simply because Review was interrupted.
152
185
  - Malformed or stale reviewer output is not a verdict. Keep the existing
153
186
  reservation only if the Host reports it valid; use its exact recovery action.
154
187
  Do not fabricate a pass or blindly redispatch Review.
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
6
6
  tests inside an approved directory or glob do not require a revision. Keep
7
7
  every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
8
8
 
9
+ On a batch `handoff.role=executor`, implement only its named enrolled child;
10
+ the batch stays `running` with that child `enrolled`. Return to the Parent after
11
+ scoped implementation, staging and focused diagnostics. Enrollment itself does
12
+ not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
+ Review handoff under the same still-valid batch authorization.
14
+
9
15
  Before handoff, run the permitted diagnostic checks and return commands and
10
16
  outcomes to the Parent as structured diagnostic evidence. The read-only Loop
11
17
  runtime action only constructs the dispatch envelope; it does not store
@@ -22,6 +22,7 @@ import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
22
22
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
23
23
  import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
24
24
 
25
+
25
26
  export type AssuranceRole = "qa" | "review";
26
27
  export interface AssuranceCorrelation {
27
28
  record_revision: string;
@@ -38,7 +39,8 @@ export interface TaskTombstone {
38
39
  terminal_event_id: string;
39
40
  }
40
41
  export interface TaskRecordRead {
41
- record?: { contract?: string; findings: Array<{ kind: string; status: string }> } | null;
42
+ revision?: string;
43
+ record?: { contract?: string; findings: Array<{ kind: string; status: string; id?: string; acceptance_id?: string | null }> } | null;
42
44
  }
43
45
  export interface TaskIntentRead {
44
46
  /**
@@ -211,12 +213,102 @@ export interface SnapshotDescriptor {
211
213
  root: string;
212
214
  }
213
215
 
214
- export interface QaVerificationProgress {
216
+ export type QaPreparationReason = "executable_or_cwd_unavailable" | "process_launch_failed" | "process_cleanup_failed"
217
+ | "protected_input_or_output_drift" | "execution_metadata_limit_exceeded" | "timeout" | "output_limit" | "nonzero_exit"
218
+ | "execution_failed" | "command_identity_changed" | "delivery_cleanup_failed" | "delivery_unavailable";
219
+ export interface QaCheckDiagnostic {
220
+ acceptance_id: string;
221
+ descriptor_ref: string;
222
+ descriptor_digest: string;
223
+ stage: "resolution" | "prepare" | "check" | "integrity";
224
+ outcome: "running" | "passed" | QaPreparationReason;
225
+ elapsed_ms: number;
226
+ exit_code: number | null;
227
+ stdout_bytes: number | null;
228
+ stderr_bytes: number | null;
229
+ }
230
+
231
+ const QA_STAGES = ["resolution", "prepare", "check", "integrity"];
232
+ const QA_OUTCOMES = ["running", "passed", "executable_or_cwd_unavailable", "process_launch_failed", "process_cleanup_failed",
233
+ "protected_input_or_output_drift", "execution_metadata_limit_exceeded", "timeout", "output_limit", "nonzero_exit", "execution_failed",
234
+ "command_identity_changed", "delivery_cleanup_failed", "delivery_unavailable"];
235
+ /** The Tool boundary never spreads arbitrary executor fields. */
236
+ export function qaDiagnosticMetadata(d: QaCheckDiagnostic): QaCheckDiagnostic {
237
+ if (typeof d.acceptance_id !== "string" || !d.acceptance_id
238
+ || !/^acceptance\/\d{1,6}\/verification\/(command|environment\/prepare)$/.test(d.descriptor_ref)
239
+ || !/^sha256:[a-f0-9]{64}$/.test(d.descriptor_digest) || !QA_STAGES.includes(d.stage) || !QA_OUTCOMES.includes(d.outcome)
240
+ || !Number.isFinite(d.elapsed_ms) || d.elapsed_ms < 0
241
+ || [d.stdout_bytes, d.stderr_bytes].some(n => n !== null && (!Number.isSafeInteger(n) || n < 0))
242
+ || (d.exit_code !== null && !Number.isSafeInteger(d.exit_code))) throw new Error("invalid QA diagnostic metadata");
243
+ const safe = { acceptance_id: d.acceptance_id, descriptor_ref: d.descriptor_ref, descriptor_digest: d.descriptor_digest,
244
+ stage: d.stage, outcome: d.outcome, elapsed_ms: d.elapsed_ms, exit_code: d.exit_code, stdout_bytes: d.stdout_bytes, stderr_bytes: d.stderr_bytes };
245
+ if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
246
+ return safe;
247
+ }
248
+
249
+ export interface QaVerificationProgressInput {
215
250
  index: number;
216
251
  total: number;
217
252
  acceptance_id: string;
218
253
  phase: "running" | "passed" | "failed";
219
254
  elapsed_ms: number;
255
+ diagnostic?: QaCheckDiagnostic;
256
+ }
257
+
258
+ export class QaPreparationError extends Error {
259
+ constructor(public readonly stage: QaCheckDiagnostic["stage"], public readonly acceptance_ids: string[], public readonly reason: QaPreparationReason,
260
+ public readonly diagnostics: QaCheckDiagnostic[] = []) {
261
+ if (!QA_STAGES.includes(stage) || !QA_OUTCOMES.includes(reason)) throw new Error("invalid QA preparation classification");
262
+ const safe = diagnostics.map(qaDiagnosticMetadata);
263
+ if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
264
+ super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.length}`);
265
+ this.diagnostics = safe;
266
+ this.name = "QaPreparationError";
267
+ }
268
+ }
269
+
270
+ export type QaVerificationProgress = QaVerificationProgressInput;
271
+
272
+ export interface AssuranceRecovery {
273
+ category: "repair" | "environment" | "authorization";
274
+ task_id: string;
275
+ run_id: string | null;
276
+ record_revision: string;
277
+ next_obligation: AssuranceProjectionResult["projection"]["next_obligation"];
278
+ acceptance_ids: string[];
279
+ finding_ids: string[];
280
+ next_action: string;
281
+ }
282
+
283
+ /** Observation only; fresh Kernel facts remain the sole owner of the obligation. */
284
+ export function deriveAssuranceRecovery(taskId: string, fresh: AssuranceProjectionResult,
285
+ result: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[]; reason?: string },
286
+ findings: NonNullable<TaskRecordRead["record"]>["findings"] = []): AssuranceRecovery | null {
287
+ if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active") return null;
288
+ const p = fresh.projection;
289
+ const findingIds = [...p.blocking_finding_ids, ...p.unresolved_user_decision_ids, ...p.replan_required_ids];
290
+ const scope = result.reason?.includes("task delivery contains paths outside the authorization envelope");
291
+ const migration = result.reason?.includes("verification_contract_migration_required");
292
+ const unstaged = result.reason?.includes("task delivery has unstaged or untracked changes");
293
+ const reviewPreparation = result.state === "review_preparation_failed" && p.next_obligation === "run_review";
294
+ const authority = scope || migration || p.next_obligation === "resolve_user_decision" || p.next_obligation === "revise_intent"
295
+ || p.unresolved_user_decision_ids.length > 0 || p.replan_required_ids.length > 0 || p.authorization.state !== "none";
296
+ const repair = unstaged || p.next_obligation === "resolve_findings" || p.blocking_finding_ids.length > 0;
297
+ if (!authority && !repair && !result.environment_failure && !reviewPreparation) return null;
298
+ const affected = findings.filter(f => f.status === "open" && f.id && findingIds.includes(f.id) && f.acceptance_id).map(f => f.acceptance_id!);
299
+ const category = authority ? "authorization" : repair ? "repair" : "environment";
300
+ return { category, task_id: taskId, run_id: p.run_id ?? null, record_revision: p.record_revision,
301
+ next_obligation: p.next_obligation, finding_ids: findingIds,
302
+ acceptance_ids: [...new Set(affected.length ? affected : result.diagnostics?.length ? result.diagnostics.map(d => d.acceptance_id) : reviewPreparation ? p.fresh_acceptance_ids : p.missing_acceptance_ids)],
303
+ next_action: scope ? "Reconcile the listed paths with the TaskIntent scope: unstage unrelated changes or submit the required native Intent revision."
304
+ : migration ? "route Planner for the verification descriptor v2 Intent revision and native gate"
305
+ : category === "authorization"
306
+ ? p.authorization.blocked ? "inspect authority state" : p.next_obligation === "revise_intent" ? "route Planner for the required Intent revision and native gate" : "request_authorization"
307
+ : unstaged ? "Stage only the scoped implementation changes, then call advance_assurance."
308
+ : category === "repair" ? "Route foreground Executor to repair and verify the identified findings; dispose each by resolve_finding or evidence-bound refute_finding, then run fresh advance_assurance."
309
+ : reviewPreparation ? "Repair the Review evidence environment, then call advance_assurance to resume run_review; retain fresh QA and do not re-confirm the batch."
310
+ : "Repair the identified verification environment, then call advance_assurance; a local check is non-attesting.",
311
+ };
220
312
  }
221
313
 
222
314
  export interface ForegroundToolUpdate {
@@ -224,7 +316,13 @@ export interface ForegroundToolUpdate {
224
316
  details: Record<string, unknown>;
225
317
  }
226
318
 
227
- export type AssuranceAdvanceResult =
319
+ type AssuranceRecoveryFields = {
320
+ diagnostics?: QaCheckDiagnostic[];
321
+ environment_failure?: boolean;
322
+ recovery?: AssuranceRecovery;
323
+ recovery_error?: "fresh_kernel_projection_unavailable";
324
+ };
325
+ export type AssuranceAdvanceResult = AssuranceRecoveryFields & (
228
326
  | { state: "review_ready"; operation: "review"; operation_id: string; snapshot_digest: string; review_bundle_digest: string; agent_params: unknown }
229
327
  | { state: "rework"; operation: "qa"; operation_id: string; summary: string }
230
328
  | { state: "cancelled"; operation: "qa" | "review"; operation_id: string; reason: string }
@@ -238,14 +336,14 @@ export type AssuranceAdvanceResult =
238
336
  | { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
239
337
  | { state: "blocked"; reason: string; code?: "verdict_invalid" }
240
338
  | { state: "completed" }
241
- | { state: "stopped" };
339
+ | { state: "stopped" });
242
340
 
243
- export type AssuranceSubmitReviewResult =
341
+ export type AssuranceSubmitReviewResult = AssuranceRecoveryFields & (
244
342
  | { state: "rework"; operation: "review"; operation_id: string; summary: string }
245
343
  | { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string }
246
344
  | { state: "completed" }
247
345
  | { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
248
- | { state: "blocked"; reason: string; code?: "verdict_invalid" };
346
+ | { state: "blocked"; reason: string; code?: "verdict_invalid" });
249
347
 
250
348
  export type ActiveAssuranceState =
251
349
  | { state: "running"; operation: "qa"; operation_id: string; deadline_seconds: number }
@@ -663,6 +761,36 @@ export class AssuranceCoordinator {
663
761
  sessionGenerationValue(): number { return this.sessionGeneration; }
664
762
 
665
763
  async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
764
+ const checks = new Map<string, QaCheckDiagnostic>();
765
+ const result = await this.advanceOnce(taskId, ctx, signal, update => {
766
+ const diagnostic = update.details.diagnostic as QaCheckDiagnostic | undefined;
767
+ if (diagnostic) {
768
+ checks.set(diagnostic.descriptor_ref, diagnostic);
769
+ if (Buffer.byteLength(JSON.stringify([...checks.values()])) > 16_384)
770
+ throw new QaPreparationError("prepare", [diagnostic.acceptance_id], "execution_metadata_limit_exceeded",
771
+ [{ ...diagnostic, stage: "prepare", outcome: "execution_metadata_limit_exceeded" }]);
772
+ }
773
+ onUpdate?.(update);
774
+ });
775
+ const diagnostics = result.diagnostics ?? [...checks.values()];
776
+ return this.withRecovery(taskId, ctx, diagnostics.length ? { ...result, diagnostics } : result);
777
+ }
778
+
779
+ private async withRecovery<T extends AssuranceAdvanceResult | AssuranceSubmitReviewResult>(taskId: string, ctx: HostContext, enriched: T): Promise<T> {
780
+ if (enriched.recovery_error || !["failed", "blocked", "rework", "review_preparation_failed"].includes(enriched.state)) return enriched;
781
+ try {
782
+ const fresh = await this.ports.projectTask(ctx.cwd, taskId);
783
+ if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active")
784
+ return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
785
+ const record = await this.ports.readTaskRecord(ctx.cwd, taskId);
786
+ if (!record.record || record.revision !== fresh.projection.record_revision)
787
+ return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
788
+ const recovery = deriveAssuranceRecovery(taskId, fresh, enriched, record.record?.findings);
789
+ return recovery ? { ...enriched, recovery } : enriched;
790
+ } catch { return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" }; }
791
+ }
792
+
793
+ private async advanceOnce(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
666
794
  if (this.isInvocationOpen(taskId)) return { state: "blocked", reason: "an authority invocation is already open" };
667
795
  const active = this.active(taskId);
668
796
  if (active?.state === "review_ready") {
@@ -671,12 +799,14 @@ export class AssuranceCoordinator {
671
799
  try {
672
800
  projection = await this.ports.projectTask(ctx.cwd, taskId);
673
801
  } catch (error) {
674
- return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}` };
802
+ return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}`, recovery_error: "fresh_kernel_projection_unavailable" };
675
803
  }
676
804
  const matches = reservationStillValid(reservation, projection, taskId);
677
805
  if (matches) return this.reviewReadyResult(taskId);
678
806
  if (reservation) this.releaseReviewReservation(taskId, reservation);
679
807
  this.rejectedReviewOperations.delete(taskId);
808
+ if (projection.error || (projection.projection.lifecycle !== "done" && projection.projection.lifecycle !== "stopped" && (!projection.claim || projection.claim.task_id !== taskId)))
809
+ return { state: "blocked", reason: projection.error ?? "no active backend claim for this task", recovery_error: "fresh_kernel_projection_unavailable" };
680
810
  }
681
811
  const refreshed = this.active(taskId);
682
812
  if (refreshed?.state === "running") return { state: "blocked", reason: `assurance operation ${refreshed.operation_id} is already running` };
@@ -698,6 +828,7 @@ export class AssuranceCoordinator {
698
828
  // nothing instead of being reported as an unknown settlement.
699
829
  let boundaryBaseline: string | null = null;
700
830
  let reviewPreparationStarted = false;
831
+ let initialProjectionAvailable = false;
701
832
  // Set only by the QA job-budget deadline, which is not a host cancellation.
702
833
  let qaJobBudgetExceeded = false;
703
834
  let phase = "preparing";
@@ -738,13 +869,14 @@ export class AssuranceCoordinator {
738
869
  projection = await this.ports.projectTask(ctx.cwd, taskId);
739
870
  }
740
871
  ensureOperationLive();
741
- if (projection.error) return { state: "blocked", reason: projection.error };
872
+ if (projection.error) return { state: "blocked", reason: projection.error, recovery_error: "fresh_kernel_projection_unavailable" };
742
873
  if (projection.projection.lifecycle === "done" || projection.projection.lifecycle === "stopped") {
743
874
  this.unknownOperations.delete(taskId);
744
875
  return { state: projection.projection.lifecycle === "done" ? "completed" : "stopped" };
745
876
  }
746
- if (!projection.claim) return { state: "blocked", reason: "no active backend claim" };
747
- if (projection.claim.task_id !== taskId) return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}` };
877
+ if (!projection.claim) return { state: "blocked", reason: "no active backend claim", recovery_error: "fresh_kernel_projection_unavailable" };
878
+ if (projection.claim.task_id !== taskId) return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}`, recovery_error: "fresh_kernel_projection_unavailable" };
879
+ initialProjectionAvailable = true;
748
880
  // A closed Kernel projection, not the lost host reply, decides what
749
881
  // remains. Committed QA/Review is never replayed from local memory.
750
882
  if (projection.projection.lifecycle === "active") this.unknownOperations.delete(taskId);
@@ -822,6 +954,7 @@ export class AssuranceCoordinator {
822
954
  operationController.abort(new Error(`deterministic QA exceeded its declared ${Math.ceil(qaJobTimeoutMs / 60_000)} minute job budget`));
823
955
  }, qaJobTimeoutMs);
824
956
  try {
957
+ phase = "verifying";
825
958
  qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, {
826
959
  signal: operationController.signal,
827
960
  onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
@@ -830,6 +963,7 @@ export class AssuranceCoordinator {
830
963
  acceptance_id: item.acceptance_id,
831
964
  acceptance_phase: item.phase,
832
965
  elapsed_ms: item.elapsed_ms,
966
+ ...(item.diagnostic ? { diagnostic: qaDiagnosticMetadata(item.diagnostic) } : {}),
833
967
  }),
834
968
  });
835
969
  } finally {
@@ -950,6 +1084,16 @@ export class AssuranceCoordinator {
950
1084
  progress("review_ready", "QA passed; invoke the reserved foreground Agent, then call submit_review", { snapshot_digest: snapshotDigest(review.snapshot), review_bundle_digest: review.snapshot.review_bundle_digest ?? "", agent_params: hostReservation.dispatch });
951
1085
  return { state: "review_ready", operation: "review", operation_id: operationId, snapshot_digest: snapshotDigest(review.snapshot), review_bundle_digest: review.snapshot.review_bundle_digest ?? "", agent_params: hostReservation.dispatch };
952
1086
  } catch (error) {
1087
+ // Recovery decoration must not bypass the initial read retry policy.
1088
+ if (!initialProjectionAvailable && !aborted() && !(error instanceof VerificationAbortedError))
1089
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}`,
1090
+ recovery_error: "fresh_kernel_projection_unavailable" };
1091
+ if (error instanceof QaPreparationError && !authorityCommitted && !authorityBoundaryStarted && !aborted())
1092
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: error.message,
1093
+ environment_failure: true, diagnostics: error.diagnostics.map(qaDiagnosticMetadata) };
1094
+ if (phase === "verifying" && !authorityCommitted && !authorityBoundaryStarted && !aborted() && !(error instanceof VerificationAbortedError))
1095
+ return { state: "failed", operation: "qa", operation_id: operationId,
1096
+ reason: "QA execution failed before attestation (execution_failed)", environment_failure: true };
953
1097
  if (reviewPreparationStarted) {
954
1098
  const reason = aborted() || error instanceof VerificationAbortedError
955
1099
  ? `${phase}: host cancellation`
@@ -980,6 +1124,10 @@ export class AssuranceCoordinator {
980
1124
  }
981
1125
 
982
1126
  async submitReview(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
1127
+ return this.withRecovery(taskId, ctx, await this.submitReviewOnce(taskId, ctx, verdictInput));
1128
+ }
1129
+
1130
+ private async submitReviewOnce(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
983
1131
  const unknown = this.unknownOperations.get(taskId);
984
1132
  if (unknown) return { state: "settlement_unknown", operation: unknown.operation, operation_id: unknown.operationId, reason: unknown.reason };
985
1133
  const rejected = this.rejectedReviewOperations.get(taskId);
@@ -992,12 +1140,12 @@ export class AssuranceCoordinator {
992
1140
  } catch (error) {
993
1141
  const reason = boundedAssuranceError(error);
994
1142
  this.releaseReviewReservation(taskId, reservation, reason);
995
- return { state: "blocked", reason };
1143
+ return { state: "blocked", reason, recovery_error: "fresh_kernel_projection_unavailable" };
996
1144
  }
997
1145
  if (fresh.error || !fresh.claim || fresh.claim.task_id !== taskId || compareReservationSnapshot(reservation.snapshot, fresh.projection).length > 0 || fresh.projection.lifecycle !== reservation.snapshot.lifecycle || fresh.projection.artifact_state !== reservation.snapshot.artifact_state) {
998
1146
  const reason = fresh.error ?? "assurance snapshot changed before Review submission";
999
1147
  this.releaseReviewReservation(taskId, reservation, reason);
1000
- return { state: "blocked", reason };
1148
+ return { state: "blocked", reason, ...(fresh.error || !fresh.claim || fresh.claim.task_id !== taskId ? { recovery_error: "fresh_kernel_projection_unavailable" as const } : {}) };
1001
1149
  }
1002
1150
  if (reservation.snapshot.review_revision) {
1003
1151
  try {
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { snapshotDigest, type SnapshotDescriptor, type AssuranceVerdict } from "./coordinator";
2
+ import { snapshotDigest, QaPreparationError, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
3
+ export { QaPreparationError, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
3
4
  import { assertDeliveryClean, materializeDeliveryWorkspace, removeTaskOwnedTree, writeDeliveryTree } from "./delivery_workspace";
4
5
  import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand } from "./verification";
5
6
  import { mkdtempSync, realpathSync } from "node:fs";
@@ -9,14 +10,6 @@ import { qaFindingId } from "./qa_findings";
9
10
  import { captureGitTaskRevisionSnapshot } from "../workspace_scope";
10
11
  import { readTaskRecord } from "../kernel/storage";
11
12
 
12
- export interface QaVerificationProgressInput {
13
- index: number;
14
- total: number;
15
- acceptance_id: string;
16
- phase: "running" | "passed" | "failed";
17
- elapsed_ms: number;
18
- }
19
-
20
13
  function deliveryTreeForSnapshot(
21
14
  snapshot: SnapshotDescriptor,
22
15
  writeTree: typeof writeDeliveryTree,
@@ -37,13 +30,6 @@ function deliveryTreeForSnapshot(
37
30
  return writeDeliveryTree(snapshot.root, captured);
38
31
  }
39
32
 
40
- export class QaPreparationError extends Error {
41
- constructor(public readonly stage: "resolution" | "prepare" | "integrity", public readonly acceptance_ids: string[], public readonly reason: string) {
42
- super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.join(",")}`);
43
- this.name = "QaPreparationError";
44
- }
45
- }
46
-
47
33
  export async function runDeterministicQa(
48
34
  snapshot: SnapshotDescriptor,
49
35
  descriptors: Map<string, VerificationDescriptor>,
@@ -74,38 +60,60 @@ export async function runDeterministicQa(
74
60
  const path = verificationPath();
75
61
  const findings: NonNullable<AssuranceVerdict["findings"]> = [];
76
62
  const evidence: unknown[] = [];
77
- const allCommands: FrozenCommand[] = [];
63
+ const allCommands: Array<{ frozen: FrozenCommand; diagnostics: QaCheckDiagnostic[] }> = [];
78
64
  for (const [environmentKey, group] of groups) {
79
65
  live();
80
66
  const ids = group.map(item => item.id);
81
67
  const environment = group[0]!.descriptor.environment;
82
- const delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
83
- const home = mkdtempSync(join(tmpdir(), "imm-qa-home-"));
68
+ const diagnostics = (stage: QaCheckDiagnostic["stage"], outcome: QaCheckDiagnostic["outcome"], affected = group,
69
+ commandStage: "prepare" | "check" = "check", elapsed = 0, result?: { exit_code: number; stdout: string; stderr: string }): QaCheckDiagnostic[] =>
70
+ affected.map(item => ({ acceptance_id: item.id,
71
+ descriptor_ref: `acceptance/${item.index}/verification/${commandStage === "prepare" ? "environment/prepare" : "command"}`,
72
+ descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
73
+ stage, outcome, elapsed_ms: elapsed, exit_code: result?.exit_code ?? null,
74
+ stdout_bytes: result ? Buffer.byteLength(result.stdout) : null, stderr_bytes: result ? Buffer.byteLength(result.stderr) : null }));
75
+ const { delivery, home, deliveryPrefix } = (() => {
76
+ let delivery: ReturnType<typeof materializeDeliveryWorkspace> | undefined, home: string | undefined;
77
+ try {
78
+ delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
79
+ home = mkdtempSync(join(tmpdir(), "imm-qa-home-"));
80
+ return { delivery, home, deliveryPrefix: `${realpathSync(delivery.root)}${sep}` };
81
+ } catch {
82
+ let reason: QaPreparationReason = "delivery_unavailable";
83
+ try { try { delivery?.cleanup(); } finally { if (home) removeTaskOwnedTree(home); } }
84
+ catch { reason = "delivery_cleanup_failed"; }
85
+ throw new QaPreparationError("prepare", ids, reason, diagnostics("prepare", reason));
86
+ }
87
+ })();
84
88
  const clean = () => {
85
89
  if (options._materializeDeliveryWorkspace) return;
86
90
  try { assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths); }
87
- catch { throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift"); }
91
+ catch { throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift")); }
88
92
  };
89
93
  const groupCommands: FrozenCommand[] = [];
90
- const deliveryPrefix = `${realpathSync(delivery.root)}${sep}`;
91
- const execute = async (command: VerificationCommand, stage: "prepare" | "check") => {
94
+ const execute = async (command: VerificationCommand, stage: "prepare" | "check", affected = group) => {
95
+ const started = performance.now();
96
+ const fail = (errorStage: QaCheckDiagnostic["stage"], reason: QaPreparationReason) =>
97
+ new QaPreparationError(errorStage, affected.map(item => item.id), reason, diagnostics(errorStage, reason, affected, stage, Math.round(performance.now() - started)));
92
98
  live(); clean();
93
99
  let frozen: FrozenCommand;
94
100
  try { frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(delivery.root, command, path); }
95
- catch { throw new QaPreparationError("resolution", ids, "executable_or_cwd_unavailable"); }
101
+ catch { throw fail("resolution", "executable_or_cwd_unavailable"); }
96
102
  let result;
97
103
  try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, home, path }); }
98
104
  catch (error) {
99
- if (error instanceof VerificationLaunchError) throw new QaPreparationError("resolution", ids, "process_launch_failed");
100
- if (error instanceof VerificationCleanupError) throw new QaPreparationError("resolution", ids, "process_cleanup_failed");
101
- throw error;
105
+ if (error instanceof VerificationAbortedError) throw error;
106
+ if (error instanceof VerificationLaunchError) throw fail("resolution", "process_launch_failed");
107
+ if (error instanceof VerificationCleanupError) throw fail("resolution", "process_cleanup_failed");
108
+ throw fail(stage, "execution_failed");
102
109
  }
103
110
  live(); clean();
104
111
  groupCommands.push(frozen);
105
112
  if (frozen?.entry) {
106
- if (!command.executable.startsWith("./")) allCommands.push(frozen);
113
+ const identityDiagnostics = diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result);
114
+ if (!command.executable.startsWith("./")) allCommands.push({ frozen, diagnostics: identityDiagnostics });
107
115
  else if (frozen.interpreter && !frozen.interpreter.path.startsWith(deliveryPrefix))
108
- allCommands.push({ entry: frozen.interpreter, interpreter: null, interpreter_args: [] });
116
+ allCommands.push({ frozen: { entry: frozen.interpreter, interpreter: null, interpreter_args: [] }, diagnostics: identityDiagnostics });
109
117
  }
110
118
  // Paths and process output are not persisted. Identity hashes describe
111
119
  // observed entries, not a claim about every transitive dependency.
@@ -114,38 +122,45 @@ export async function runDeterministicQa(
114
122
  entry: frozen?.entry?.content_hash ?? null, interpreter: frozen?.interpreter?.content_hash ?? null,
115
123
  exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited });
116
124
  if (Buffer.byteLength(JSON.stringify(evidence)) > 16_384)
117
- throw new QaPreparationError("prepare", ids, "execution_metadata_limit_exceeded");
125
+ throw fail("prepare", "execution_metadata_limit_exceeded");
118
126
  return result;
119
127
  };
120
128
  try {
121
129
  clean();
122
130
  if (environment.prepare) {
131
+ const prepareStarted = performance.now();
123
132
  const result = await execute(environment.prepare, "prepare");
124
133
  if (result.exit_code !== 0 || result.timed_out || result.output_limited)
125
- throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit");
134
+ throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit",
135
+ diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result));
126
136
  }
127
137
  for (const item of group) {
128
- const started = Date.now();
129
- const progress = (phase: QaVerificationProgressInput["phase"]) => options.onProgress?.({
130
- index: item.index + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase, elapsed_ms: Date.now() - started,
138
+ const started = performance.now();
139
+ const progress = (phase: QaVerificationProgressInput["phase"], outcome: QaCheckDiagnostic["outcome"], result?: { exit_code: number; stdout: string; stderr: string }) => options.onProgress?.({
140
+ index: item.index + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase, elapsed_ms: Math.round(performance.now() - started),
141
+ diagnostic: diagnostics("check", outcome, [item], "check", Math.round(performance.now() - started), result)[0],
131
142
  });
132
- progress("running");
133
- const result = await execute(item.descriptor.command, "check");
143
+ progress("running", "running");
144
+ const result = await execute(item.descriptor.command, "check", [item]);
134
145
  const failed = result.exit_code !== 0 || result.timed_out || result.output_limited;
135
- progress(failed ? "failed" : "passed");
146
+ progress(failed ? "failed" : "passed", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : failed ? "nonzero_exit" : "passed", result);
136
147
  if (failed) findings.push({ id: qaFindingId(item.id, snapshotDigest(snapshot)), kind: "blocking", acceptance_id: item.id,
137
148
  summary: `verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""}${result.output_limited ? ", output limit" : ""}) stdout=${Buffer.byteLength(result.stdout)}B stderr=${Buffer.byteLength(result.stderr)}B`, findings_digest: "" });
138
149
  }
139
150
  // Recheck while project entries still exist; cleanup removes them.
140
- for (const frozen of groupCommands) {
141
- if (frozen?.entry) assertCommandIdentity(frozen);
142
- }
143
- } finally { try { delivery.cleanup(); } finally { removeTaskOwnedTree(home); } }
151
+ try {
152
+ for (const frozen of groupCommands) if (frozen?.entry) assertCommandIdentity(frozen);
153
+ } catch { throw new QaPreparationError("integrity", ids, "command_identity_changed", diagnostics("integrity", "command_identity_changed")); }
154
+ } finally {
155
+ try { try { delivery.cleanup(); } finally { removeTaskOwnedTree(home); } }
156
+ catch { throw new QaPreparationError("integrity", ids, "delivery_cleanup_failed", diagnostics("integrity", "delivery_cleanup_failed")); }
157
+ }
144
158
  }
145
159
  live();
146
160
  // Host entries survive materialization cleanup and must still match before approval.
147
- for (const frozen of allCommands) {
148
- if (frozen?.entry) assertCommandIdentity(frozen);
161
+ for (const entry of allCommands) {
162
+ try { if (entry.frozen?.entry) assertCommandIdentity(entry.frozen); }
163
+ catch { throw new QaPreparationError("integrity", entry.diagnostics.map(item => item.acceptance_id), "command_identity_changed", entry.diagnostics); }
149
164
  }
150
165
  const base = { contract: "assurance_kernel/assurance_verdict/v2" as const, role: "qa" as const,
151
166
  task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot) };
@@ -62,6 +62,7 @@ import {
62
62
  } from "../unattended/batch_preflight";
63
63
  import {
64
64
  startBatch,
65
+ batchQaFailureFacts,
65
66
  type BatchRunnerKernelPort,
66
67
  type BatchRunReport,
67
68
  } from "../unattended/batch_runner";
@@ -1001,7 +1002,7 @@ export class ClaudeRuntime {
1001
1002
  // availability, working-tree cleanliness against the authorized scope,
1002
1003
  // recovery children, plan digest, and base HEAD are one shared projection,
1003
1004
  // so neither Host re-implements a batch decision.
1004
- const now = new Date().toISOString();
1005
+ let now = new Date().toISOString();
1005
1006
  const preflight = await projectBatchPreflight({
1006
1007
  root: this.cwd,
1007
1008
  initiative_slug: initiativeSlug,
@@ -1126,6 +1127,9 @@ export class ClaudeRuntime {
1126
1127
  // review-1: verify cancellation signal right before authority issuance and startBatch
1127
1128
  if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
1128
1129
 
1130
+ now = new Date().toISOString();
1131
+ if (Date.parse(expiresAt) <= Date.parse(now))
1132
+ return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
1129
1133
  const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
1130
1134
 
1131
1135
  const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
@@ -1209,14 +1213,22 @@ export class ClaudeRuntime {
1209
1213
  },
1210
1214
  advanceTask: async (root, taskId) => {
1211
1215
  const result = await this.coordinator.advance(taskId, { cwd: root });
1212
- if (result.state === "completed") return { state: "completed" };
1213
- if (result.state === "stopped") return { state: "stopped" };
1214
- if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary };
1215
- if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id };
1216
- if (result.state === "blocked") return { state: "blocked", reason: result.reason };
1217
- return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed" };
1216
+ const facts = { diagnostics: result.diagnostics, environment_failure: result.environment_failure, recovery: result.recovery };
1217
+ if (result.state === "completed") return { state: "completed", ...facts };
1218
+ if (result.state === "stopped") return { state: "stopped", ...facts };
1219
+ if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary, ...facts };
1220
+ if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id, ...facts };
1221
+ if (result.state === "review_preparation_failed") return { state: result.state, operation: result.operation, operation_id: result.operation_id, reason: result.reason, ...facts };
1222
+ if (result.state === "blocked") return { state: "blocked", reason: result.reason, ...facts };
1223
+ return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed", ...facts };
1224
+ },
1225
+ projectTask: async (root, taskId) => {
1226
+ const fresh = await projectAssurance(root, taskId, diffSnapshotOf);
1227
+ if (!fresh.claim || fresh.projection.lifecycle !== "active" || fresh.error !== null) return fresh;
1228
+ const { record, revision } = readTaskRecordRaw(root, taskId);
1229
+ if (!record || revision !== fresh.projection.record_revision) throw new Error(`Kernel recovery projection changed for ${taskId}`);
1230
+ return { ...fresh, ...batchQaFailureFacts(record) };
1218
1231
  },
1219
- projectTask: async (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
1220
1232
  ownsTaskClaim: (taskId) => {
1221
1233
  return registry.isChildConsumed(capability, taskId);
1222
1234
  },
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "4.4.0" as const;
2
+ export const PLUGIN_VERSION = "4.4.1" as const;
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
6
6
  tests inside an approved directory or glob do not require a revision. Keep
7
7
  every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
8
8
 
9
+ On a batch `handoff.role=executor`, implement only its named enrolled child;
10
+ the batch stays `running` with that child `enrolled`. Return to the Parent after
11
+ scoped implementation, staging and focused diagnostics. Enrollment itself does
12
+ not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
+ Review handoff under the same still-valid batch authorization.
14
+
9
15
  Before handoff, run the permitted diagnostic checks and return commands and
10
16
  outcomes to the Parent as structured diagnostic evidence. The read-only Loop
11
17
  runtime action only constructs the dispatch envelope; it does not store
@@ -19,7 +19,7 @@ import type {
19
19
  } from "./types";
20
20
 
21
21
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
22
- const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
22
+ export const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
23
23
  const DEFAULT_QA_FAILURE_LIMIT = 2;
24
24
 
25
25
  /**