immune-brain 3.6.9 → 4.0.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.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +33 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +41 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +11 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -7,11 +7,10 @@
7
7
  // evidence reservation.
8
8
 
9
9
  import { createHash, randomUUID } from "node:crypto";
10
- import type { FindingEvidence, TaskFinding } from "../kernel/types";
10
+ import type { FindingEvidence, ReviewAdvisoryFindingV1, TaskFinding } from "../kernel/types";
11
11
  import { anchorForEvidence } from "../kernel/refutation";
12
12
  import {
13
13
  findingsDigest,
14
- type FrozenRunner,
15
14
  type VerificationDescriptor,
16
15
  VerificationAbortedError,
17
16
  } from "./verification";
@@ -21,6 +20,7 @@ import { buildRoleDelegationPacket } from "../role_prompt_bridge";
21
20
  import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
22
21
  import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
23
22
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
23
+ import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
24
24
 
25
25
  export type AssuranceRole = "qa" | "review";
26
26
  export interface AssuranceCorrelation {
@@ -74,6 +74,42 @@ export function deriveGithubTerminalProjectionInput(
74
74
  };
75
75
  }
76
76
 
77
+ /**
78
+ * Post-settlement GitHub tracker projection, shared so a task settled under
79
+ * either Host produces the same opted-in terminal projection.
80
+ *
81
+ * Transport, never authority: the projection runs only for a fresh claimless
82
+ * done/stopped projection plus its exact terminal tombstone (`derive` returns
83
+ * null otherwise), a tracker failure is reported as a retryable failure rather
84
+ * than evidence or a blocker, and a failure never repeats the settling Kernel
85
+ * mutation.
86
+ */
87
+ export async function projectTerminalTrackerState(input: {
88
+ root: string;
89
+ task_id: string;
90
+ projection: AssuranceProjectionResult;
91
+ tombstone: TaskTombstone | null;
92
+ markTerminal: (root: string, projection: GithubTerminalProjectionInput) => Promise<GithubTrackerProjectionResult>;
93
+ }): Promise<GithubTrackerProjectionResult | undefined> {
94
+ if (input.projection.error) return undefined;
95
+ const terminal = deriveGithubTerminalProjectionInput(input.task_id, input.projection, input.tombstone);
96
+ if (!terminal) return undefined;
97
+ try {
98
+ return await input.markTerminal(input.root, terminal);
99
+ } catch {
100
+ return TRACKER_PROJECTION_FAILURE;
101
+ }
102
+ }
103
+
104
+ /** The one shape both Hosts report when post-settlement observation fails. */
105
+ export const TRACKER_PROJECTION_FAILURE: GithubTrackerProjectionResult = {
106
+ contract: "immune_brain/github_issue_tracker_result/v1",
107
+ operation: "mark-terminal",
108
+ status: "retryable_failure",
109
+ association_found: false,
110
+ message: "tracker observation failed after authoritative settlement",
111
+ };
112
+
77
113
  export interface AssuranceVerdict {
78
114
  contract: "assurance_kernel/assurance_verdict/v2";
79
115
  role: AssuranceRole;
@@ -91,8 +127,8 @@ export interface AssuranceVerdict {
91
127
  acceptance_id: string | null;
92
128
  summary: string;
93
129
  /**
94
- * Derived by `parseAssuranceVerdict` for Review-role rework findings. QA
95
- * findings and pass verdicts never carry it.
130
+ * Derived by `parseAssuranceVerdict` for every Review-role finding, the
131
+ * advisory notes of a pass verdict included. QA findings never carry it.
96
132
  */
97
133
  anchor?: string;
98
134
  evidence?: FindingEvidence;
@@ -124,6 +160,24 @@ export function reviewReworkFindings(verdict: AssuranceVerdict): TaskFinding[] {
124
160
  }));
125
161
  }
126
162
 
163
+ /**
164
+ * The advisory counterpart of `reviewReworkFindings`: a pass verdict may carry
165
+ * non-blocking Review notes, and both Hosts record them on the review
166
+ * attestation itself so settlement can never separate the two.
167
+ */
168
+ export function reviewAdvisoryRecords(verdict: AssuranceVerdict): ReviewAdvisoryFindingV1[] {
169
+ if (verdict.decision !== "pass") throw new Error("advisory findings require a pass verdict");
170
+ return (verdict.findings ?? [])
171
+ .filter((finding) => finding.kind === "advisory")
172
+ .map((finding) => ({
173
+ id: finding.id,
174
+ acceptance_id: finding.acceptance_id,
175
+ summary: finding.summary,
176
+ anchor: finding.anchor ?? null,
177
+ evidence: finding.evidence ?? null,
178
+ }));
179
+ }
180
+
127
181
  export interface ReviewRevisionIdentity {
128
182
  contract: "assurance_kernel/review_revision_identity/v1";
129
183
  base_head: string;
@@ -135,6 +189,8 @@ export interface ReviewRevisionIdentity {
135
189
  export interface SnapshotDescriptor {
136
190
  contract: "assurance_kernel/assurance_snapshot/v2";
137
191
  task_id: string;
192
+ /** The exact run this snapshot was captured from; null when none is committed. */
193
+ run_id: string | null;
138
194
  role: AssuranceRole;
139
195
  record_revision: string;
140
196
  workspace_revision: string;
@@ -201,13 +257,11 @@ export interface AssuranceCoordinatorPorts {
201
257
  projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
202
258
  readTaskRecord(root: string, taskId: string): Promise<TaskRecordRead>;
203
259
  readTaskIntent(root: string, taskId: string): Promise<TaskIntentRead>;
204
- frozenRunner(): Promise<FrozenRunner>;
205
260
  buildAssurance(
206
261
  root: string,
207
262
  taskId: string,
208
263
  role: AssuranceRole,
209
264
  projection: AssuranceProjectionResult,
210
- runner: FrozenRunner,
211
265
  ): Promise<{
212
266
  snapshot: SnapshotDescriptor;
213
267
  descriptors: Map<string, VerificationDescriptor>;
@@ -227,7 +281,6 @@ export interface AssuranceCoordinatorPorts {
227
281
  runQa(
228
282
  snapshot: SnapshotDescriptor,
229
283
  descriptors: Map<string, VerificationDescriptor>,
230
- runner: FrozenRunner,
231
284
  options: { signal?: AbortSignal; onProgress?: (progress: QaVerificationProgress) => void },
232
285
  ): Promise<AssuranceVerdict>;
233
286
  writeReviewEvidence(input: { snapshot: SnapshotDescriptor; evidence: ReviewBundle | ReviewManifestV5 }): { path: string; remove(): void };
@@ -272,13 +325,19 @@ export const REVIEW_TIMING_PROFILES: Readonly<Record<ReviewWorkload, ReviewTimin
272
325
  heavy: { softDeadlineSeconds: 20 * 60, stopThresholdSeconds: 60 * 60 },
273
326
  };
274
327
 
275
- function declaredQaJobTimeoutMs(descriptors: Iterable<Pick<VerificationDescriptor, "timeout_ms">>): number {
328
+ function declaredQaJobTimeoutMs(descriptors: Iterable<VerificationDescriptor>): number {
276
329
  let declaredMs = 0;
277
- for (const descriptor of descriptors) declaredMs += descriptor.timeout_ms;
330
+ const prepared = new Set<string>();
331
+ for (const descriptor of descriptors) {
332
+ declaredMs += descriptor.command.timeout_ms;
333
+ const key = JSON.stringify(descriptor.environment);
334
+ if (!prepared.has(key)) declaredMs += descriptor.environment.prepare?.timeout_ms ?? 0;
335
+ prepared.add(key);
336
+ }
278
337
  return Math.max(QA_MIN_JOB_TIMEOUT_SECONDS * 1000, declaredMs + QA_JOB_OVERHEAD_SECONDS * 1000);
279
338
  }
280
339
 
281
- export function deriveQaJobTimeoutMs(descriptors: Iterable<Pick<VerificationDescriptor, "timeout_ms">>): number {
340
+ export function deriveQaJobTimeoutMs(descriptors: Iterable<VerificationDescriptor>): number {
282
341
  const derivedMs = declaredQaJobTimeoutMs(descriptors);
283
342
  if (derivedMs > QA_MAX_JOB_TIMEOUT_SECONDS * 1000)
284
343
  throw new Error(`declared QA budget ${Math.ceil(derivedMs / 60_000)} minutes exceeds the maximum of 60 minutes`);
@@ -348,6 +407,7 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
348
407
  "Reserve the final turn for exactly one strict JSON verdict. Reply with ONLY that object, without markdown fences or commentary.",
349
408
  `Every rework finding must carry machine-checkable provenance: evidence.trigger (the concrete inputs or state that reach the defect), a non-empty evidence.caller_chain (ordered repository paths or symbols), and evidence.violated {kind: "acceptance"|"security_boundary", ref}. The anchor is derived from that evidence; a finding without it is rejected and the correction must be resubmitted.`,
350
409
  `PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"}}`,
410
+ `A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
351
411
  `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
352
412
  `REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
353
413
  ].join("\n");
@@ -423,12 +483,31 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
423
483
  if (!approval || approval.kind !== expectedKind || approval.authority_role !== expectedRole || typeof approval.summary !== "string" || !approval.summary.trim()) throw new Error("pass verdict approval is invalid");
424
484
  const unknownApproval = Object.keys(approval).find((key) => !["kind", "authority_role", "summary"].includes(key));
425
485
  if (unknownApproval) throw new Error(`pass verdict approval has unknown field: ${unknownApproval}`);
426
- if (raw.findings !== undefined) throw new Error("pass verdict must omit findings");
427
- return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "pass", approval: { kind: expectedKind, authority_role: expectedRole, summary: approval.summary } };
486
+ const passFindings = raw.findings === undefined ? [] : parseVerdictFindings(raw.findings as unknown[], snapshot);
487
+ if (passFindings.some((finding) => finding.kind !== "advisory")) throw new Error("pass verdict findings must all be advisory");
488
+ return {
489
+ contract: "assurance_kernel/assurance_verdict/v2",
490
+ role: snapshot.role,
491
+ task_id: snapshot.task_id,
492
+ snapshot_digest: snapshotDigest(snapshot),
493
+ decision: "pass",
494
+ approval: { kind: expectedKind, authority_role: expectedRole, summary: approval.summary },
495
+ ...(passFindings.length > 0 ? { findings: passFindings } : {}),
496
+ };
428
497
  }
429
498
  if (!Array.isArray(raw.findings) || raw.findings.length === 0) throw new Error("rework verdict findings are invalid");
430
499
  if (raw.approval !== undefined && raw.approval !== null) throw new Error("rework verdict must omit approval");
431
- const findings = raw.findings.map((item, index) => {
500
+ const findings = parseVerdictFindings(raw.findings, snapshot);
501
+ return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
502
+ }
503
+
504
+ /**
505
+ * Parse one verdict's findings. Review findings carry evidence and derive their
506
+ * anchor here so a capability minted for one anchor set cannot be spent on
507
+ * another; QA findings stay identifier-only.
508
+ */
509
+ function parseVerdictFindings(rawFindings: unknown[], snapshot: SnapshotDescriptor) {
510
+ return rawFindings.map((item, index) => {
432
511
  const finding = item as Record<string, unknown>;
433
512
  const allowedFindingKeys =
434
513
  snapshot.role === "review"
@@ -458,7 +537,57 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
458
537
  findings_digest: findingsDigest([normalized]),
459
538
  };
460
539
  });
461
- return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
540
+ }
541
+
542
+ /**
543
+ * The five snapshot fields one Review reservation binds. A reservation is only
544
+ * reusable while all five still match the live projection; each call site adds
545
+ * its own conditions on top of this comparison.
546
+ */
547
+ export interface ReservationSnapshot {
548
+ record_revision: string;
549
+ workspace_revision: string;
550
+ intent_revision: number;
551
+ intent_content_hash: string;
552
+ diff_hash: string;
553
+ }
554
+
555
+ /**
556
+ * Field names where the reserved snapshot differs from the live projection;
557
+ * an empty result is an exact match on all five. BR-DEC-06 keeps the five
558
+ * fields distinct, so this compares each one instead of a merged digest.
559
+ */
560
+ export function compareReservationSnapshot(
561
+ snapshot: ReservationSnapshot,
562
+ current: ReservationSnapshot,
563
+ ): string[] {
564
+ const fields: Array<keyof ReservationSnapshot> = [
565
+ "record_revision",
566
+ "workspace_revision",
567
+ "intent_revision",
568
+ "intent_content_hash",
569
+ "diff_hash",
570
+ ];
571
+ return fields.filter((field) => snapshot[field] !== current[field]);
572
+ }
573
+
574
+ /**
575
+ * `advance()`'s complete Review-ready predicate: claim identity, an active task
576
+ * still awaiting Review, and an exactly matching reservation snapshot. The
577
+ * release-on-mismatch cleanup that follows stays with the caller, because it is
578
+ * a side effect rather than part of this pure decision.
579
+ */
580
+ export function reservationStillValid(
581
+ reservation: { snapshot: ReservationSnapshot } | undefined,
582
+ projection: AssuranceProjectionResult,
583
+ taskId: string,
584
+ ): boolean {
585
+ if (projection.error) return false;
586
+ if (projection.claim?.task_id !== taskId) return false;
587
+ const current = projection.projection;
588
+ if (current.lifecycle !== "active" || current.next_obligation !== "run_review") return false;
589
+ if (!reservation) return false;
590
+ return compareReservationSnapshot(reservation.snapshot, current).length === 0;
462
591
  }
463
592
 
464
593
  interface ReviewReservation {
@@ -544,17 +673,7 @@ export class AssuranceCoordinator {
544
673
  } catch (error) {
545
674
  return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}` };
546
675
  }
547
- const current = projection.projection;
548
- const matches = !projection.error
549
- && projection.claim?.task_id === taskId
550
- && current.lifecycle === "active"
551
- && current.next_obligation === "run_review"
552
- && reservation !== undefined
553
- && reservation.snapshot.record_revision === current.record_revision
554
- && reservation.snapshot.workspace_revision === current.workspace_revision
555
- && reservation.snapshot.intent_revision === current.intent_revision
556
- && reservation.snapshot.intent_content_hash === current.intent_content_hash
557
- && reservation.snapshot.diff_hash === current.diff_hash;
676
+ const matches = reservationStillValid(reservation, projection, taskId);
558
677
  if (matches) return this.reviewReadyResult(taskId);
559
678
  if (reservation) this.releaseReviewReservation(taskId, reservation);
560
679
  this.rejectedReviewOperations.delete(taskId);
@@ -579,6 +698,8 @@ export class AssuranceCoordinator {
579
698
  // nothing instead of being reported as an unknown settlement.
580
699
  let boundaryBaseline: string | null = null;
581
700
  let reviewPreparationStarted = false;
701
+ // Set only by the QA job-budget deadline, which is not a host cancellation.
702
+ let qaJobBudgetExceeded = false;
582
703
  let phase = "preparing";
583
704
  const operationLive = () => this.sessionActive
584
705
  && this.sessionGeneration === operationGeneration
@@ -592,6 +713,14 @@ export class AssuranceCoordinator {
592
713
  onUpdate?.({ content: [{ type: "text", text: summary }], details: { state: "running", operation: "qa", operation_id: operationId, stage, ...details } });
593
714
  };
594
715
  const aborted = () => operationController.signal.aborted;
716
+ // A declared job budget past the ceiling is refused here, before any
717
+ // preparation command runs or any snapshot is captured.
718
+ const qaBudgetFailure = () => ({
719
+ state: "failed" as const,
720
+ operation: "qa" as const,
721
+ operation_id: operationId,
722
+ reason: `${phase}: deterministic QA exceeded its declared job budget before settlement`,
723
+ });
595
724
  try {
596
725
  ensureOperationLive();
597
726
  progress(phase, `Preparing deterministic QA for ${taskId}`);
@@ -621,7 +750,7 @@ export class AssuranceCoordinator {
621
750
  if (projection.projection.lifecycle === "active") this.unknownOperations.delete(taskId);
622
751
  const parked = await this.ports.readTaskRecord(ctx.cwd, taskId);
623
752
  ensureOperationLive();
624
- if (parked.record?.findings.some((finding) => finding.kind === "replan_required" && finding.status === "open")) return { state: "blocked", reason: "review rework limit reached; a durable replan is required" };
753
+ if (parked.record?.findings.some((finding) => finding.kind === "replan_required" && finding.status === "open")) return { state: "blocked", reason: "review requires a durable replan; the task is paused for a user decision" };
625
754
  if (projection.projection.artifact_state === "active") {
626
755
  if (projection.projection.next_obligation !== "submit_assurance")
627
756
  return { state: "blocked", reason: `Kernel requires ${projection.projection.next_obligation}` };
@@ -657,7 +786,6 @@ export class AssuranceCoordinator {
657
786
  return { state: "blocked", reason: `Kernel requires ${projection.projection.next_obligation}` };
658
787
  const qaAlreadySettled = projection.projection.next_obligation === "run_review";
659
788
  if (qaAlreadySettled) reviewPreparationStarted = true;
660
- const runner = await this.ports.frozenRunner();
661
789
  ensureOperationLive();
662
790
  // Prove the immutable Review revision before any descriptor runs. A task
663
791
  // that cannot publish its revision must stop with zero QA attestation
@@ -682,18 +810,31 @@ export class AssuranceCoordinator {
682
810
  progress("capturing_snapshot", "Capturing the immutable QA snapshot");
683
811
  await this.ports.qaBeforeProjection?.();
684
812
  ensureOperationLive();
685
- const assurance = await this.ports.buildAssurance(ctx.cwd, taskId, "qa", projection, runner);
813
+ const assurance = await this.ports.buildAssurance(ctx.cwd, taskId, "qa", projection);
686
814
  ensureOperationLive();
687
- qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, runner, {
688
- signal: operationController.signal,
689
- onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
690
- current: item.index,
691
- total: item.total,
692
- acceptance_id: item.acceptance_id,
693
- acceptance_phase: item.phase,
694
- elapsed_ms: item.elapsed_ms,
695
- }),
696
- });
815
+ // Preparation counts against the same ceiling as the checks, so the
816
+ // aggregate clock starts before the first prepared group runs. A host
817
+ // may only tighten the derived budget, never raise it past the ceiling.
818
+ const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
819
+ const qaJobTimeoutMs = Math.min(declaredQaJobMs, this.ports.qaJobTimeoutMs ?? declaredQaJobMs);
820
+ const qaJobDeadline = setTimeout(() => {
821
+ qaJobBudgetExceeded = true;
822
+ operationController.abort(new Error(`deterministic QA exceeded its declared ${Math.ceil(qaJobTimeoutMs / 60_000)} minute job budget`));
823
+ }, qaJobTimeoutMs);
824
+ try {
825
+ qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, {
826
+ signal: operationController.signal,
827
+ onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
828
+ current: item.index,
829
+ total: item.total,
830
+ acceptance_id: item.acceptance_id,
831
+ acceptance_phase: item.phase,
832
+ elapsed_ms: item.elapsed_ms,
833
+ }),
834
+ });
835
+ } finally {
836
+ clearTimeout(qaJobDeadline);
837
+ }
697
838
  ensureOperationLive();
698
839
  const invocation = this.openInvocation(taskId);
699
840
  try {
@@ -722,6 +863,7 @@ export class AssuranceCoordinator {
722
863
  authorityBoundaryStarted = false;
723
864
  }
724
865
  } catch (error) {
866
+ if (qaJobBudgetExceeded) return qaBudgetFailure();
725
867
  if (!authorityCommitted && (aborted() || error instanceof VerificationAbortedError)) return this.cancelled("qa", operationId, "host cancellation before QA authority commit");
726
868
  return authorityCommitted
727
869
  ? this.unknownAfterCommit(taskId, "qa", operationId, boundedAssuranceError(error))
@@ -764,7 +906,7 @@ export class AssuranceCoordinator {
764
906
  let review: Awaited<ReturnType<AssuranceCoordinatorPorts["buildAssurance"]>>;
765
907
  let evidence: { path: string; remove(): void };
766
908
  try {
767
- review = await this.ports.buildAssurance(ctx.cwd, taskId, "review", fresh, runner);
909
+ review = await this.ports.buildAssurance(ctx.cwd, taskId, "review", fresh);
768
910
  const payload = review.reviewManifest ?? review.reviewBundle;
769
911
  if (!payload) throw new Error("review evidence is missing after QA settlement");
770
912
  evidence = this.ports.writeReviewEvidence({ snapshot: review.snapshot, evidence: payload });
@@ -826,6 +968,7 @@ export class AssuranceCoordinator {
826
968
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
827
969
  return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
828
970
  }
971
+ if (qaJobBudgetExceeded) return qaBudgetFailure();
829
972
  if (aborted() || error instanceof VerificationAbortedError) return this.cancelled("qa", operationId, `${phase}: host cancellation`);
830
973
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
831
974
  } finally {
@@ -851,7 +994,7 @@ export class AssuranceCoordinator {
851
994
  this.releaseReviewReservation(taskId, reservation, reason);
852
995
  return { state: "blocked", reason };
853
996
  }
854
- if (fresh.error || !fresh.claim || fresh.claim.task_id !== taskId || fresh.projection.record_revision !== reservation.snapshot.record_revision || fresh.projection.workspace_revision !== reservation.snapshot.workspace_revision || fresh.projection.intent_revision !== reservation.snapshot.intent_revision || fresh.projection.intent_content_hash !== reservation.snapshot.intent_content_hash || fresh.projection.diff_hash !== reservation.snapshot.diff_hash || fresh.projection.lifecycle !== reservation.snapshot.lifecycle || fresh.projection.artifact_state !== reservation.snapshot.artifact_state) {
997
+ 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) {
855
998
  const reason = fresh.error ?? "assurance snapshot changed before Review submission";
856
999
  this.releaseReviewReservation(taskId, reservation, reason);
857
1000
  return { state: "blocked", reason };
@@ -0,0 +1,240 @@
1
+ // Disposable QA materialization of one immutable delivery tree.
2
+ // Never binds the user's index, refs, or node_modules.
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
9
+
10
+ import type { GitTaskRevisionSnapshot } from "../workspace_scope";
11
+
12
+ export class DeliveryWorkspaceError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "DeliveryWorkspaceError";
16
+ }
17
+ }
18
+
19
+ export interface DeliveryWorkspace {
20
+ root: string;
21
+ tree: string;
22
+ seal: string;
23
+ cleanup: () => void;
24
+ }
25
+
26
+ export function removeTaskOwnedTree(root: string): void {
27
+ const makeDirectoriesWritable = (path: string): void => {
28
+ let stat;
29
+ try { stat = lstatSync(path); }
30
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
31
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
32
+ chmodSync(path, (stat.mode & 0o7777) | 0o700);
33
+ for (const name of readdirSync(path)) makeDirectoriesWritable(join(path, name));
34
+ };
35
+ makeDirectoriesWritable(root);
36
+ rmSync(root, { recursive: true, force: true });
37
+ }
38
+
39
+ const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
40
+
41
+ function isolatedGitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
42
+ const env = { ...process.env };
43
+ delete env.GIT_DIR;
44
+ delete env.GIT_WORK_TREE;
45
+ delete env.GIT_INDEX_FILE;
46
+ delete env.GIT_OBJECT_DIRECTORY;
47
+ delete env.GIT_ALTERNATE_OBJECT_DIRECTORIES;
48
+ return {
49
+ ...env,
50
+ GIT_CONFIG_NOSYSTEM: "1",
51
+ GIT_TERMINAL_PROMPT: "0",
52
+ GIT_OPTIONAL_LOCKS: "0",
53
+ ...extra,
54
+ };
55
+ }
56
+
57
+ function git(cwd: string, args: string[], extra: Record<string, string> = {}): string {
58
+ return execFileSync("git", args, {
59
+ cwd,
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ env: isolatedGitEnv(extra),
63
+ }).trim();
64
+ }
65
+
66
+ function assertTree(tree: string): void {
67
+ if (!GIT_OBJECT_ID.test(tree)) throw new DeliveryWorkspaceError("delivery tree has invalid identity");
68
+ }
69
+
70
+ function assertInside(root: string, candidate: string, label: string): void {
71
+ const rel = relative(root, candidate);
72
+ if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel))
73
+ throw new DeliveryWorkspaceError(`delivery ${label} escapes materialization: ${rel}`);
74
+ }
75
+
76
+ function resolvedSymlinkTarget(path: string): string {
77
+ const target = readlinkSync(path);
78
+ let current = isAbsolute(target) ? parse(target).root : parse(path).root;
79
+ let pending = (isAbsolute(target)
80
+ ? target.split(sep)
81
+ : [...relative(current, dirname(path)).split(sep), ...target.split(sep)]
82
+ ).filter(Boolean);
83
+ let links = 0;
84
+ while (pending.length) {
85
+ const part = pending.shift()!;
86
+ if (part === ".") continue;
87
+ if (part === "..") { current = dirname(current); continue; }
88
+ const next = join(current, part);
89
+ try {
90
+ if (!lstatSync(next).isSymbolicLink()) { current = next; continue; }
91
+ if (++links > 40) throw new DeliveryWorkspaceError("delivery symlink chain is too deep");
92
+ const nested = readlinkSync(next);
93
+ if (isAbsolute(nested)) current = parse(nested).root;
94
+ pending = [...nested.split(sep).filter(Boolean), ...pending];
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
97
+ current = next;
98
+ }
99
+ }
100
+ return current;
101
+ }
102
+
103
+ function assertNoEscapingSymlinks(root: string, dir = root): void {
104
+ const realRoot = realpathSync(root);
105
+ for (const name of readdirSync(dir)) {
106
+ const path = join(dir, name);
107
+ const stat = lstatSync(path);
108
+ if (stat.isSymbolicLink()) {
109
+ assertInside(realRoot, resolvedSymlinkTarget(path), `symlink ${relative(root, path)}`);
110
+ } else if (stat.isDirectory()) {
111
+ assertNoEscapingSymlinks(root, path);
112
+ }
113
+ }
114
+ }
115
+
116
+
117
+ export function writeDeliveryTree(sourceRoot: string, snapshot: GitTaskRevisionSnapshot): string {
118
+ const indexDirectory = mkdtempSync(join(tmpdir(), "imm-delivery-index-"));
119
+ try {
120
+ git(sourceRoot, ["read-tree", snapshot.base_tree], {
121
+ GIT_INDEX_FILE: join(indexDirectory, "index"),
122
+ GIT_DIR: join(sourceRoot, ".git"),
123
+ });
124
+ for (const [path, entry] of Object.entries(snapshot.changed_paths)) {
125
+ if (entry.oid && entry.mode) {
126
+ git(sourceRoot, ["update-index", "--add", "--cacheinfo", `${entry.mode},${entry.oid},${path}`], {
127
+ GIT_INDEX_FILE: join(indexDirectory, "index"),
128
+ GIT_DIR: join(sourceRoot, ".git"),
129
+ });
130
+ } else {
131
+ git(sourceRoot, ["update-index", "--force-remove", "--", path], {
132
+ GIT_INDEX_FILE: join(indexDirectory, "index"),
133
+ GIT_DIR: join(sourceRoot, ".git"),
134
+ });
135
+ }
136
+ }
137
+ const next = git(sourceRoot, ["write-tree"], {
138
+ GIT_INDEX_FILE: join(indexDirectory, "index"),
139
+ GIT_DIR: join(sourceRoot, ".git"),
140
+ });
141
+ assertTree(next);
142
+ return next;
143
+ } finally {
144
+ rmSync(indexDirectory, { recursive: true, force: true });
145
+ }
146
+ }
147
+
148
+ // The seal lives in host memory, outside the project. Compare bytes directly:
149
+ // project commands can rewrite their disposable index or porcelain state.
150
+ function workspaceFiles(
151
+ root: string,
152
+ dir = root,
153
+ result: Record<string, string> = Object.create(null) as Record<string, string>,
154
+ ): Record<string, string> {
155
+ if (dir === root) result["."] = `${lstatSync(root).mode & 0o7777}:directory`;
156
+ for (const name of readdirSync(dir).sort()) {
157
+ const path = join(dir, name), rel = relative(root, path);
158
+ const stat = lstatSync(path);
159
+ if (stat.isSymbolicLink()) result[rel] = `link:${stat.mode & 0o7777}:${readlinkSync(path)}`;
160
+ else if (stat.isDirectory()) { result[rel] = `${stat.mode & 0o7777}:directory`; workspaceFiles(root, path, result); }
161
+ else if (stat.isFile()) result[rel] = `${stat.mode & 0o7777}:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
162
+ else throw new DeliveryWorkspaceError("delivery contains an unsupported filesystem entry");
163
+ }
164
+ return result;
165
+ }
166
+
167
+ function workspaceSeal(root: string): string {
168
+ return JSON.stringify(workspaceFiles(root));
169
+ }
170
+
171
+ export function assertDeliveryClean(root: string, tree: string, seal?: string, writablePaths: string[] = []): void {
172
+ assertTree(tree);
173
+ if (seal === undefined) {
174
+ if (git(root, ["status", "--porcelain", "--untracked-files=all", "--ignored=matching"]))
175
+ throw new DeliveryWorkspaceError("delivery workspace was contaminated");
176
+ if (git(root, ["rev-parse", "HEAD^{tree}"]) !== tree)
177
+ throw new DeliveryWorkspaceError("delivery workspace tree drifted from the frozen identity");
178
+ return;
179
+ }
180
+ if (seal === "[]") {
181
+ for (const name of readdirSync(root)) {
182
+ const stat = lstatSync(join(root, name));
183
+ if (!stat.isDirectory() || name !== ".git") throw new DeliveryWorkspaceError("delivery workspace was contaminated");
184
+ }
185
+ return;
186
+ }
187
+ const expected = JSON.parse(seal) as Record<string, string>;
188
+ for (const path of writablePaths) {
189
+ if (path === "." || path === ".git" || path.startsWith(".git/") || isAbsolute(path) || path.split("/").includes("..")
190
+ || Object.keys(expected).some(p => p === path || p.startsWith(`${path}/`)))
191
+ throw new DeliveryWorkspaceError("delivery writable path overlaps protected inputs");
192
+ }
193
+ const current = workspaceFiles(root);
194
+ for (const [path, bytes] of Object.entries(expected))
195
+ if (current[path] !== bytes) throw new DeliveryWorkspaceError("delivery protected input was contaminated");
196
+ for (const [path, bytes] of Object.entries(current)) {
197
+ if (Object.hasOwn(expected, path)) continue;
198
+ const permitted = writablePaths.some(p => path === p || path.startsWith(`${p}/`)
199
+ || (bytes.endsWith(":directory") && p.startsWith(`${path}/`)));
200
+ if (!permitted) throw new DeliveryWorkspaceError("delivery undeclared output was contaminated");
201
+ if (bytes.startsWith("link:")) {
202
+ assertInside(realpathSync(root), resolvedSymlinkTarget(join(root, path)), "generated symlink");
203
+ }
204
+ }
205
+ }
206
+
207
+
208
+ export function materializeDeliveryWorkspace(
209
+ sourceRoot: string,
210
+ tree: string,
211
+ ): DeliveryWorkspace {
212
+ assertTree(tree);
213
+ const dest = mkdtempSync(join(tmpdir(), "imm-delivery-"));
214
+ const cleanup = () => removeTaskOwnedTree(dest);
215
+ try {
216
+ mkdirSync(dest, { recursive: true });
217
+ const commit = git(sourceRoot, ["commit-tree", tree, "-m", `delivery ${tree}`], {
218
+ GIT_DIR: join(sourceRoot, ".git"),
219
+ GIT_AUTHOR_NAME: "Immune-Brain Assurance",
220
+ GIT_AUTHOR_EMAIL: "assurance@immune-brain.local",
221
+ GIT_AUTHOR_DATE: "1970-01-01T00:00:00 +0000",
222
+ GIT_COMMITTER_NAME: "Immune-Brain Assurance",
223
+ GIT_COMMITTER_EMAIL: "assurance@immune-brain.local",
224
+ GIT_COMMITTER_DATE: "1970-01-01T00:00:00 +0000",
225
+ });
226
+ if (!GIT_OBJECT_ID.test(commit)) throw new DeliveryWorkspaceError("delivery commit write failed");
227
+ git(dest, ["init", "-q"]);
228
+ git(dest, ["fetch", "--depth=1", `file://${resolve(sourceRoot)}`, `${commit}:refs/heads/delivery`]);
229
+ git(dest, ["checkout", "-q", "delivery"]);
230
+ const got = git(dest, ["rev-parse", "HEAD^{tree}"]);
231
+ if (got !== tree) throw new DeliveryWorkspaceError("delivery materialization does not match the frozen tree");
232
+ assertNoEscapingSymlinks(dest);
233
+ const seal = workspaceSeal(dest);
234
+ assertDeliveryClean(dest, tree, seal);
235
+ return { root: dest, tree, seal, cleanup };
236
+ } catch (error) {
237
+ cleanup();
238
+ throw error;
239
+ }
240
+ }