immune-brain 4.4.0 → 4.5.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
  5. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
  6. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
  7. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1039 -465
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  11. package/plugins/immune-brain/dist/imm-loop.md +76 -4
  12. package/plugins/immune-brain/dist/imm-planner.md +65 -1
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
  17. package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
  18. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +20 -8
  19. package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
  20. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  21. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  22. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  23. package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
  24. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  25. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
  26. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +73 -26
  27. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
  28. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
  29. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +126 -151
  30. package/plugins/immune-brain/runtime/unattended/batch_state.ts +34 -4
  31. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  32. package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
  33. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  34. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  35. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  36. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
@@ -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
  },