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
@@ -39,7 +39,7 @@ function probeHost(env = process.env, platform = process.platform, hostVersion)
39
39
  }
40
40
 
41
41
  // plugins/immune-brain/runtime/plugin_version.ts
42
- var PLUGIN_VERSION = "4.4.0";
42
+ var PLUGIN_VERSION = "4.5.0";
43
43
 
44
44
  // plugins/immune-brain/runtime/claude/interaction.ts
45
45
  import { createHash, randomUUID } from "node:crypto";
@@ -1362,6 +1362,88 @@ function reviewAdvisoryRecords(verdict) {
1362
1362
  evidence: finding.evidence ?? null
1363
1363
  }));
1364
1364
  }
1365
+ var QA_STAGES = ["resolution", "prepare", "check", "integrity"];
1366
+ var QA_OUTCOMES = [
1367
+ "running",
1368
+ "passed",
1369
+ "executable_or_cwd_unavailable",
1370
+ "process_launch_failed",
1371
+ "process_cleanup_failed",
1372
+ "protected_input_or_output_drift",
1373
+ "execution_metadata_limit_exceeded",
1374
+ "timeout",
1375
+ "output_limit",
1376
+ "nonzero_exit",
1377
+ "execution_failed",
1378
+ "command_identity_changed",
1379
+ "delivery_cleanup_failed",
1380
+ "delivery_unavailable"
1381
+ ];
1382
+ function qaDiagnosticMetadata(d) {
1383
+ if (typeof d.acceptance_id !== "string" || !d.acceptance_id || !/^acceptance\/\d{1,6}\/verification\/(command|environment\/prepare)$/.test(d.descriptor_ref) || !/^sha256:[a-f0-9]{64}$/.test(d.descriptor_digest) || !QA_STAGES.includes(d.stage) || !QA_OUTCOMES.includes(d.outcome) || !Number.isFinite(d.elapsed_ms) || d.elapsed_ms < 0 || [d.stdout_bytes, d.stderr_bytes].some((n) => n !== null && (!Number.isSafeInteger(n) || n < 0)) || d.exit_code !== null && !Number.isSafeInteger(d.exit_code))
1384
+ throw new Error("invalid QA diagnostic metadata");
1385
+ const safe = {
1386
+ acceptance_id: d.acceptance_id,
1387
+ descriptor_ref: d.descriptor_ref,
1388
+ descriptor_digest: d.descriptor_digest,
1389
+ stage: d.stage,
1390
+ outcome: d.outcome,
1391
+ elapsed_ms: d.elapsed_ms,
1392
+ exit_code: d.exit_code,
1393
+ stdout_bytes: d.stdout_bytes,
1394
+ stderr_bytes: d.stderr_bytes
1395
+ };
1396
+ if (Buffer.byteLength(JSON.stringify(safe)) > 16384)
1397
+ throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
1398
+ return safe;
1399
+ }
1400
+
1401
+ class QaPreparationError extends Error {
1402
+ stage;
1403
+ acceptance_ids;
1404
+ reason;
1405
+ diagnostics;
1406
+ constructor(stage, acceptance_ids, reason, diagnostics = []) {
1407
+ if (!QA_STAGES.includes(stage) || !QA_OUTCOMES.includes(reason))
1408
+ throw new Error("invalid QA preparation classification");
1409
+ const safe = diagnostics.map(qaDiagnosticMetadata);
1410
+ if (Buffer.byteLength(JSON.stringify(safe)) > 16384)
1411
+ throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
1412
+ super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.length}`);
1413
+ this.stage = stage;
1414
+ this.acceptance_ids = acceptance_ids;
1415
+ this.reason = reason;
1416
+ this.diagnostics = diagnostics;
1417
+ this.diagnostics = safe;
1418
+ this.name = "QaPreparationError";
1419
+ }
1420
+ }
1421
+ function deriveAssuranceRecovery(taskId, fresh, result, findings = []) {
1422
+ if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active")
1423
+ return null;
1424
+ const p = fresh.projection;
1425
+ const findingIds = [...p.blocking_finding_ids, ...p.unresolved_user_decision_ids, ...p.replan_required_ids];
1426
+ const scope = result.reason?.includes("task delivery contains paths outside the authorization envelope");
1427
+ const migration = result.reason?.includes("verification_contract_migration_required");
1428
+ const unstaged = result.reason?.includes("task delivery has unstaged or untracked changes");
1429
+ const reviewPreparation = result.state === "review_preparation_failed" && p.next_obligation === "run_review";
1430
+ const authority = scope || migration || p.next_obligation === "resolve_user_decision" || p.next_obligation === "revise_intent" || p.unresolved_user_decision_ids.length > 0 || p.replan_required_ids.length > 0 || p.authorization.state !== "none";
1431
+ const repair = unstaged || p.next_obligation === "resolve_findings" || p.blocking_finding_ids.length > 0;
1432
+ if (!authority && !repair && !result.environment_failure && !reviewPreparation)
1433
+ return null;
1434
+ const affected = findings.filter((f) => f.status === "open" && f.id && findingIds.includes(f.id) && f.acceptance_id).map((f) => f.acceptance_id);
1435
+ const category = authority ? "authorization" : repair ? "repair" : "environment";
1436
+ return {
1437
+ category,
1438
+ task_id: taskId,
1439
+ run_id: p.run_id ?? null,
1440
+ record_revision: p.record_revision,
1441
+ next_obligation: p.next_obligation,
1442
+ finding_ids: findingIds,
1443
+ 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)],
1444
+ next_action: scope ? "Reconcile the listed paths with the TaskIntent scope: unstage unrelated changes or submit the required native Intent revision." : migration ? "route Planner for the verification descriptor v2 Intent revision and native gate" : category === "authorization" ? p.authorization.blocked ? "inspect authority state" : p.next_obligation === "revise_intent" ? "route Planner for the required Intent revision and native gate" : "request_authorization" : unstaged ? "Stage only the scoped implementation changes, then call advance_assurance." : 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." : reviewPreparation ? "Repair the Review evidence environment, then call advance_assurance to resume run_review; retain fresh QA and do not re-confirm the batch." : "Repair the identified verification environment, then call advance_assurance; a local check is non-attesting."
1445
+ };
1446
+ }
1365
1447
  var QA_MIN_JOB_TIMEOUT_SECONDS = 15 * 60;
1366
1448
  var QA_MAX_JOB_TIMEOUT_SECONDS = 60 * 60;
1367
1449
  var QA_JOB_OVERHEAD_SECONDS = 2 * 60;
@@ -1673,6 +1755,36 @@ class AssuranceCoordinator {
1673
1755
  return this.sessionGeneration;
1674
1756
  }
1675
1757
  async advance(taskId, ctx, signal, onUpdate) {
1758
+ const checks = new Map;
1759
+ const result = await this.advanceOnce(taskId, ctx, signal, (update) => {
1760
+ const diagnostic = update.details.diagnostic;
1761
+ if (diagnostic) {
1762
+ checks.set(diagnostic.descriptor_ref, diagnostic);
1763
+ if (Buffer.byteLength(JSON.stringify([...checks.values()])) > 16384)
1764
+ throw new QaPreparationError("prepare", [diagnostic.acceptance_id], "execution_metadata_limit_exceeded", [{ ...diagnostic, stage: "prepare", outcome: "execution_metadata_limit_exceeded" }]);
1765
+ }
1766
+ onUpdate?.(update);
1767
+ });
1768
+ const diagnostics = result.diagnostics ?? [...checks.values()];
1769
+ return this.withRecovery(taskId, ctx, diagnostics.length ? { ...result, diagnostics } : result);
1770
+ }
1771
+ async withRecovery(taskId, ctx, enriched) {
1772
+ if (enriched.recovery_error || !["failed", "blocked", "rework", "review_preparation_failed"].includes(enriched.state))
1773
+ return enriched;
1774
+ try {
1775
+ const fresh = await this.ports.projectTask(ctx.cwd, taskId);
1776
+ if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active")
1777
+ return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
1778
+ const record = await this.ports.readTaskRecord(ctx.cwd, taskId);
1779
+ if (!record.record || record.revision !== fresh.projection.record_revision)
1780
+ return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
1781
+ const recovery = deriveAssuranceRecovery(taskId, fresh, enriched, record.record?.findings);
1782
+ return recovery ? { ...enriched, recovery } : enriched;
1783
+ } catch {
1784
+ return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
1785
+ }
1786
+ }
1787
+ async advanceOnce(taskId, ctx, signal, onUpdate) {
1676
1788
  if (this.isInvocationOpen(taskId))
1677
1789
  return { state: "blocked", reason: "an authority invocation is already open" };
1678
1790
  const active = this.active(taskId);
@@ -1682,7 +1794,7 @@ class AssuranceCoordinator {
1682
1794
  try {
1683
1795
  projection = await this.ports.projectTask(ctx.cwd, taskId);
1684
1796
  } catch (error) {
1685
- return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}` };
1797
+ return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}`, recovery_error: "fresh_kernel_projection_unavailable" };
1686
1798
  }
1687
1799
  const matches = reservationStillValid(reservation, projection, taskId);
1688
1800
  if (matches)
@@ -1690,6 +1802,8 @@ class AssuranceCoordinator {
1690
1802
  if (reservation)
1691
1803
  this.releaseReviewReservation(taskId, reservation);
1692
1804
  this.rejectedReviewOperations.delete(taskId);
1805
+ if (projection.error || projection.projection.lifecycle !== "done" && projection.projection.lifecycle !== "stopped" && (!projection.claim || projection.claim.task_id !== taskId))
1806
+ return { state: "blocked", reason: projection.error ?? "no active backend claim for this task", recovery_error: "fresh_kernel_projection_unavailable" };
1693
1807
  }
1694
1808
  const refreshed = this.active(taskId);
1695
1809
  if (refreshed?.state === "running")
@@ -1708,6 +1822,7 @@ class AssuranceCoordinator {
1708
1822
  let authorityBoundaryStarted = false;
1709
1823
  let boundaryBaseline = null;
1710
1824
  let reviewPreparationStarted = false;
1825
+ let initialProjectionAvailable = false;
1711
1826
  let qaJobBudgetExceeded = false;
1712
1827
  let phase = "preparing";
1713
1828
  const operationLive = () => this.sessionActive && this.sessionGeneration === operationGeneration && this.activeOperations.get(taskId) === operationId && !operationController.signal.aborted;
@@ -1744,15 +1859,16 @@ class AssuranceCoordinator {
1744
1859
  }
1745
1860
  ensureOperationLive();
1746
1861
  if (projection.error)
1747
- return { state: "blocked", reason: projection.error };
1862
+ return { state: "blocked", reason: projection.error, recovery_error: "fresh_kernel_projection_unavailable" };
1748
1863
  if (projection.projection.lifecycle === "done" || projection.projection.lifecycle === "stopped") {
1749
1864
  this.unknownOperations.delete(taskId);
1750
1865
  return { state: projection.projection.lifecycle === "done" ? "completed" : "stopped" };
1751
1866
  }
1752
1867
  if (!projection.claim)
1753
- return { state: "blocked", reason: "no active backend claim" };
1868
+ return { state: "blocked", reason: "no active backend claim", recovery_error: "fresh_kernel_projection_unavailable" };
1754
1869
  if (projection.claim.task_id !== taskId)
1755
- return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}` };
1870
+ return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}`, recovery_error: "fresh_kernel_projection_unavailable" };
1871
+ initialProjectionAvailable = true;
1756
1872
  if (projection.projection.lifecycle === "active")
1757
1873
  this.unknownOperations.delete(taskId);
1758
1874
  const parked = await this.ports.readTaskRecord(ctx.cwd, taskId);
@@ -1824,6 +1940,7 @@ class AssuranceCoordinator {
1824
1940
  operationController.abort(new Error(`deterministic QA exceeded its declared ${Math.ceil(qaJobTimeoutMs / 60000)} minute job budget`));
1825
1941
  }, qaJobTimeoutMs);
1826
1942
  try {
1943
+ phase = "verifying";
1827
1944
  qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, {
1828
1945
  signal: operationController.signal,
1829
1946
  onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
@@ -1831,7 +1948,8 @@ class AssuranceCoordinator {
1831
1948
  total: item.total,
1832
1949
  acceptance_id: item.acceptance_id,
1833
1950
  acceptance_phase: item.phase,
1834
- elapsed_ms: item.elapsed_ms
1951
+ elapsed_ms: item.elapsed_ms,
1952
+ ...item.diagnostic ? { diagnostic: qaDiagnosticMetadata(item.diagnostic) } : {}
1835
1953
  })
1836
1954
  });
1837
1955
  } finally {
@@ -1964,6 +2082,31 @@ class AssuranceCoordinator {
1964
2082
  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 });
1965
2083
  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 };
1966
2084
  } catch (error) {
2085
+ if (!initialProjectionAvailable && !aborted() && !(error instanceof VerificationAbortedError))
2086
+ return {
2087
+ state: "failed",
2088
+ operation: "qa",
2089
+ operation_id: operationId,
2090
+ reason: `${phase}: ${boundedAssuranceError(error)}`,
2091
+ recovery_error: "fresh_kernel_projection_unavailable"
2092
+ };
2093
+ if (error instanceof QaPreparationError && !authorityCommitted && !authorityBoundaryStarted && !aborted())
2094
+ return {
2095
+ state: "failed",
2096
+ operation: "qa",
2097
+ operation_id: operationId,
2098
+ reason: error.message,
2099
+ environment_failure: true,
2100
+ diagnostics: error.diagnostics.map(qaDiagnosticMetadata)
2101
+ };
2102
+ if (phase === "verifying" && !authorityCommitted && !authorityBoundaryStarted && !aborted() && !(error instanceof VerificationAbortedError))
2103
+ return {
2104
+ state: "failed",
2105
+ operation: "qa",
2106
+ operation_id: operationId,
2107
+ reason: "QA execution failed before attestation (execution_failed)",
2108
+ environment_failure: true
2109
+ };
1967
2110
  if (reviewPreparationStarted) {
1968
2111
  const reason = aborted() || error instanceof VerificationAbortedError ? `${phase}: host cancellation` : `${phase}: ${boundedAssuranceError(error)}`;
1969
2112
  return this.reviewPreparationFailed(taskId, operationId, reason);
@@ -1989,6 +2132,9 @@ class AssuranceCoordinator {
1989
2132
  }
1990
2133
  }
1991
2134
  async submitReview(taskId, ctx, verdictInput) {
2135
+ return this.withRecovery(taskId, ctx, await this.submitReviewOnce(taskId, ctx, verdictInput));
2136
+ }
2137
+ async submitReviewOnce(taskId, ctx, verdictInput) {
1992
2138
  const unknown = this.unknownOperations.get(taskId);
1993
2139
  if (unknown)
1994
2140
  return { state: "settlement_unknown", operation: unknown.operation, operation_id: unknown.operationId, reason: unknown.reason };
@@ -2004,12 +2150,12 @@ class AssuranceCoordinator {
2004
2150
  } catch (error) {
2005
2151
  const reason = boundedAssuranceError(error);
2006
2152
  this.releaseReviewReservation(taskId, reservation, reason);
2007
- return { state: "blocked", reason };
2153
+ return { state: "blocked", reason, recovery_error: "fresh_kernel_projection_unavailable" };
2008
2154
  }
2009
2155
  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) {
2010
2156
  const reason = fresh.error ?? "assurance snapshot changed before Review submission";
2011
2157
  this.releaseReviewReservation(taskId, reservation, reason);
2012
- return { state: "blocked", reason };
2158
+ return { state: "blocked", reason, ...fresh.error || !fresh.claim || fresh.claim.task_id !== taskId ? { recovery_error: "fresh_kernel_projection_unavailable" } : {} };
2013
2159
  }
2014
2160
  if (reservation.snapshot.review_revision) {
2015
2161
  try {
@@ -2177,11 +2323,13 @@ import {
2177
2323
  } from "node:fs";
2178
2324
  import { dirname as dirname3, join as join4, resolve as resolve2 } from "node:path";
2179
2325
  function git(root, args) {
2180
- const result = spawnSync("git", ["-C", root, ...args], {
2326
+ const diff = args[0] === "diff";
2327
+ const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
2328
+ const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
2181
2329
  encoding: "utf8",
2182
2330
  stdio: ["ignore", "pipe", "pipe"]
2183
2331
  });
2184
- return result.status === 0 ? result.stdout : null;
2332
+ return result.status === 0 || diff && result.status === 1 ? result.stdout : null;
2185
2333
  }
2186
2334
  function splitNull(value) {
2187
2335
  return value.split("\x00").filter((entry) => entry.length > 0);
@@ -2300,12 +2448,14 @@ var portablePathCollator = new Intl.Collator("und", {
2300
2448
  });
2301
2449
  var gitTaskSnapshotTestHook;
2302
2450
  function gitBytes(root, args) {
2303
- const result = spawnSync("git", ["-C", root, ...args], {
2451
+ const diff = args[0] === "diff";
2452
+ const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
2453
+ const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
2304
2454
  encoding: null,
2305
2455
  stdio: ["ignore", "pipe", "pipe"],
2306
2456
  maxBuffer: 8 * 1024 * 1024
2307
2457
  });
2308
- if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
2458
+ if (result.status !== 0 && !(diff && result.status === 1) || !Buffer.isBuffer(result.stdout)) {
2309
2459
  throw new Error(`Git task snapshot command failed: git ${args.join(" ")}`);
2310
2460
  }
2311
2461
  return result.stdout;
@@ -2595,6 +2745,38 @@ function taskRevisionIdentity(projectRoot, scopeHint, baseHead, taskId) {
2595
2745
  changed_paths: Object.keys(snapshot.changed_paths).sort(comparePaths)
2596
2746
  };
2597
2747
  }
2748
+ function taskCommitRevisionIdentity(projectRoot, scopeHint, baseHead, commit) {
2749
+ const requested = resolve2(projectRoot);
2750
+ if (lstatSync2(requested).isSymbolicLink())
2751
+ throw new Error("task commit root must be a real directory");
2752
+ const root = realpathSync3(requested), scope = assertCanonicalTaskScope(scopeHint);
2753
+ if (!GIT_OBJECT_ID.test(baseHead) || !GIT_OBJECT_ID.test(commit))
2754
+ throw new Error("invalid task commit identity");
2755
+ if (gitRequired(root, ["cat-file", "-t", baseHead], "unreadable task base") !== "commit" || gitRequired(root, ["cat-file", "-t", commit], "unreadable task commit") !== "commit" || git(root, ["merge-base", "--is-ancestor", baseHead, commit]) === null)
2756
+ throw new Error("invalid task commit ancestry");
2757
+ const paths = decodeNullPaths(gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", baseHead, commit, "--"]), "task commit paths").filter((path) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope)).sort(comparePaths);
2758
+ assertNoCaseFoldCollisions(paths, "task commit paths");
2759
+ const changed_paths = {};
2760
+ for (const path of paths) {
2761
+ const current = headEntry(root, commit, path), base = headEntry(root, baseHead, path);
2762
+ changed_paths[path] = {
2763
+ status: !base ? "added" : !current ? "deleted" : "modified",
2764
+ mode: current?.mode ?? null,
2765
+ oid: current?.oid ?? null,
2766
+ base_mode: base?.mode ?? null,
2767
+ base_oid: base?.oid ?? null
2768
+ };
2769
+ }
2770
+ const snapshot = {
2771
+ kind: "git-task-revision-v1",
2772
+ repository_root: root,
2773
+ base_head: baseHead,
2774
+ base_tree: gitRequired(root, ["rev-parse", `${baseHead}^{tree}`], "unreadable task base tree"),
2775
+ scope,
2776
+ changed_paths
2777
+ };
2778
+ return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: paths };
2779
+ }
2598
2780
  function isGitWorkspaceSnapshot(value) {
2599
2781
  return typeof value === "object" && value !== null && !Array.isArray(value) && value.kind === "git-workspace-v1" && typeof value.repository_root === "string" && typeof value.head === "string" && typeof value.dirty_files === "object" && value.dirty_files !== null;
2600
2782
  }
@@ -5582,6 +5764,9 @@ function ensureSecureDirectory(root, relativePath) {
5582
5764
  return target.path;
5583
5765
  }
5584
5766
  function readSecureProjectFile(root, relativePath) {
5767
+ return readSecureProjectBytes(root, relativePath).toString("utf8");
5768
+ }
5769
+ function readSecureProjectBytes(root, relativePath) {
5585
5770
  const candidate = safeCandidate(root, relativePath);
5586
5771
  assertNoSymlinkSegments(candidate.root, candidate.path);
5587
5772
  const before = pathStatOrNull(candidate.path);
@@ -5597,7 +5782,7 @@ function readSecureProjectFile(root, relativePath) {
5597
5782
  const opened = fstatSync4(fd);
5598
5783
  if (opened.dev !== before.dev || opened.ino !== before.ino)
5599
5784
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
5600
- const content = readFileSync8(fd, "utf8");
5785
+ const content = readFileSync8(fd);
5601
5786
  const after = lstatSync7(candidate.path);
5602
5787
  if (after.dev !== opened.dev || after.ino !== opened.ino)
5603
5788
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
@@ -7857,7 +8042,6 @@ function initializeEnrollmentGitBase(root, input, previous, base, signal) {
7857
8042
 
7858
8043
  // plugins/immune-brain/runtime/assurance/qa.ts
7859
8044
  import { createHash as createHash17 } from "node:crypto";
7860
-
7861
8045
  // plugins/immune-brain/runtime/assurance/delivery_workspace.ts
7862
8046
  import { execFileSync as execFileSync5 } from "node:child_process";
7863
8047
  import { createHash as createHash16 } from "node:crypto";
@@ -8117,19 +8301,6 @@ function deliveryTreeForSnapshot(snapshot, writeTree) {
8117
8301
  throw new Error("QA delivery identity does not match the frozen snapshot");
8118
8302
  return writeDeliveryTree(snapshot.root, captured);
8119
8303
  }
8120
-
8121
- class QaPreparationError extends Error {
8122
- stage;
8123
- acceptance_ids;
8124
- reason;
8125
- constructor(stage, acceptance_ids, reason) {
8126
- super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.join(",")}`);
8127
- this.stage = stage;
8128
- this.acceptance_ids = acceptance_ids;
8129
- this.reason = reason;
8130
- this.name = "QaPreparationError";
8131
- }
8132
- }
8133
8304
  async function runDeterministicQa(snapshot, descriptors, options = {}) {
8134
8305
  if (snapshot.role !== "qa")
8135
8306
  throw new Error("deterministic QA requires qa role");
@@ -8157,46 +8328,80 @@ async function runDeterministicQa(snapshot, descriptors, options = {}) {
8157
8328
  live();
8158
8329
  const ids = group.map((item) => item.id);
8159
8330
  const environment = group[0].descriptor.environment;
8160
- const delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
8161
- const home = mkdtempSync3(join9(tmpdir4(), "imm-qa-home-"));
8331
+ const diagnostics = (stage, outcome, affected = group, commandStage = "check", elapsed = 0, result) => affected.map((item) => ({
8332
+ acceptance_id: item.id,
8333
+ descriptor_ref: `acceptance/${item.index}/verification/${commandStage === "prepare" ? "environment/prepare" : "command"}`,
8334
+ descriptor_digest: `sha256:${createHash17("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
8335
+ stage,
8336
+ outcome,
8337
+ elapsed_ms: elapsed,
8338
+ exit_code: result?.exit_code ?? null,
8339
+ stdout_bytes: result ? Buffer.byteLength(result.stdout) : null,
8340
+ stderr_bytes: result ? Buffer.byteLength(result.stderr) : null
8341
+ }));
8342
+ const { delivery, home, deliveryPrefix } = (() => {
8343
+ let delivery, home;
8344
+ try {
8345
+ delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
8346
+ home = mkdtempSync3(join9(tmpdir4(), "imm-qa-home-"));
8347
+ return { delivery, home, deliveryPrefix: `${realpathSync10(delivery.root)}${sep6}` };
8348
+ } catch {
8349
+ let reason = "delivery_unavailable";
8350
+ try {
8351
+ try {
8352
+ delivery?.cleanup();
8353
+ } finally {
8354
+ if (home)
8355
+ removeTaskOwnedTree(home);
8356
+ }
8357
+ } catch {
8358
+ reason = "delivery_cleanup_failed";
8359
+ }
8360
+ throw new QaPreparationError("prepare", ids, reason, diagnostics("prepare", reason));
8361
+ }
8362
+ })();
8162
8363
  const clean = () => {
8163
8364
  if (options._materializeDeliveryWorkspace)
8164
8365
  return;
8165
8366
  try {
8166
8367
  assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths);
8167
8368
  } catch {
8168
- throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift");
8369
+ throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift"));
8169
8370
  }
8170
8371
  };
8171
8372
  const groupCommands = [];
8172
- const deliveryPrefix = `${realpathSync10(delivery.root)}${sep6}`;
8173
- const execute = async (command, stage) => {
8373
+ const execute = async (command, stage, affected = group) => {
8374
+ const started = performance.now();
8375
+ const fail = (errorStage, reason) => new QaPreparationError(errorStage, affected.map((item) => item.id), reason, diagnostics(errorStage, reason, affected, stage, Math.round(performance.now() - started)));
8174
8376
  live();
8175
8377
  clean();
8176
8378
  let frozen;
8177
8379
  try {
8178
8380
  frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(delivery.root, command, path);
8179
8381
  } catch {
8180
- throw new QaPreparationError("resolution", ids, "executable_or_cwd_unavailable");
8382
+ throw fail("resolution", "executable_or_cwd_unavailable");
8181
8383
  }
8182
8384
  let result;
8183
8385
  try {
8184
8386
  result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, home, path });
8185
8387
  } catch (error) {
8388
+ if (error instanceof VerificationAbortedError)
8389
+ throw error;
8186
8390
  if (error instanceof VerificationLaunchError)
8187
- throw new QaPreparationError("resolution", ids, "process_launch_failed");
8391
+ throw fail("resolution", "process_launch_failed");
8188
8392
  if (error instanceof VerificationCleanupError)
8189
- throw new QaPreparationError("resolution", ids, "process_cleanup_failed");
8190
- throw error;
8393
+ throw fail("resolution", "process_cleanup_failed");
8394
+ throw fail(stage, "execution_failed");
8191
8395
  }
8192
8396
  live();
8193
8397
  clean();
8194
8398
  groupCommands.push(frozen);
8195
8399
  if (frozen?.entry) {
8400
+ const identityDiagnostics = diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result);
8196
8401
  if (!command.executable.startsWith("./"))
8197
- allCommands.push(frozen);
8402
+ allCommands.push({ frozen, diagnostics: identityDiagnostics });
8198
8403
  else if (frozen.interpreter && !frozen.interpreter.path.startsWith(deliveryPrefix))
8199
- allCommands.push({ entry: frozen.interpreter, interpreter: null, interpreter_args: [] });
8404
+ allCommands.push({ frozen: { entry: frozen.interpreter, interpreter: null, interpreter_args: [] }, diagnostics: identityDiagnostics });
8200
8405
  }
8201
8406
  evidence.push({
8202
8407
  stage,
@@ -8209,29 +8414,31 @@ async function runDeterministicQa(snapshot, descriptors, options = {}) {
8209
8414
  output_limited: result.output_limited
8210
8415
  });
8211
8416
  if (Buffer.byteLength(JSON.stringify(evidence)) > 16384)
8212
- throw new QaPreparationError("prepare", ids, "execution_metadata_limit_exceeded");
8417
+ throw fail("prepare", "execution_metadata_limit_exceeded");
8213
8418
  return result;
8214
8419
  };
8215
8420
  try {
8216
8421
  clean();
8217
8422
  if (environment.prepare) {
8423
+ const prepareStarted = performance.now();
8218
8424
  const result = await execute(environment.prepare, "prepare");
8219
8425
  if (result.exit_code !== 0 || result.timed_out || result.output_limited)
8220
- throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit");
8426
+ throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result));
8221
8427
  }
8222
8428
  for (const item of group) {
8223
- const started = Date.now();
8224
- const progress = (phase) => options.onProgress?.({
8429
+ const started = performance.now();
8430
+ const progress = (phase, outcome, result) => options.onProgress?.({
8225
8431
  index: item.index + 1,
8226
8432
  total: snapshot.acceptance.length,
8227
8433
  acceptance_id: item.id,
8228
8434
  phase,
8229
- elapsed_ms: Date.now() - started
8435
+ elapsed_ms: Math.round(performance.now() - started),
8436
+ diagnostic: diagnostics("check", outcome, [item], "check", Math.round(performance.now() - started), result)[0]
8230
8437
  });
8231
- progress("running");
8232
- const result = await execute(item.descriptor.command, "check");
8438
+ progress("running", "running");
8439
+ const result = await execute(item.descriptor.command, "check", [item]);
8233
8440
  const failed = result.exit_code !== 0 || result.timed_out || result.output_limited;
8234
- progress(failed ? "failed" : "passed");
8441
+ progress(failed ? "failed" : "passed", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : failed ? "nonzero_exit" : "passed", result);
8235
8442
  if (failed)
8236
8443
  findings.push({
8237
8444
  id: qaFindingId(item.id, snapshotDigest(snapshot)),
@@ -8241,22 +8448,33 @@ async function runDeterministicQa(snapshot, descriptors, options = {}) {
8241
8448
  findings_digest: ""
8242
8449
  });
8243
8450
  }
8244
- for (const frozen of groupCommands) {
8245
- if (frozen?.entry)
8246
- assertCommandIdentity(frozen);
8451
+ try {
8452
+ for (const frozen of groupCommands)
8453
+ if (frozen?.entry)
8454
+ assertCommandIdentity(frozen);
8455
+ } catch {
8456
+ throw new QaPreparationError("integrity", ids, "command_identity_changed", diagnostics("integrity", "command_identity_changed"));
8247
8457
  }
8248
8458
  } finally {
8249
8459
  try {
8250
- delivery.cleanup();
8251
- } finally {
8252
- removeTaskOwnedTree(home);
8460
+ try {
8461
+ delivery.cleanup();
8462
+ } finally {
8463
+ removeTaskOwnedTree(home);
8464
+ }
8465
+ } catch {
8466
+ throw new QaPreparationError("integrity", ids, "delivery_cleanup_failed", diagnostics("integrity", "delivery_cleanup_failed"));
8253
8467
  }
8254
8468
  }
8255
8469
  }
8256
8470
  live();
8257
- for (const frozen of allCommands) {
8258
- if (frozen?.entry)
8259
- assertCommandIdentity(frozen);
8471
+ for (const entry of allCommands) {
8472
+ try {
8473
+ if (entry.frozen?.entry)
8474
+ assertCommandIdentity(entry.frozen);
8475
+ } catch {
8476
+ throw new QaPreparationError("integrity", entry.diagnostics.map((item) => item.acceptance_id), "command_identity_changed", entry.diagnostics);
8477
+ }
8260
8478
  }
8261
8479
  const base = {
8262
8480
  contract: "assurance_kernel/assurance_verdict/v2",
@@ -8296,6 +8514,11 @@ var BATCH_REASONS = Object.freeze({
8296
8514
  reason: (detail) => `an active workspace claim already exists for task: ${detail}`,
8297
8515
  recovery_action: "resolve or stop the active task before starting a batch in the current Host"
8298
8516
  },
8517
+ kernel_authority_conflict: {
8518
+ state: "rejected",
8519
+ reason: (detail) => `Kernel authority conflict: ${detail}`,
8520
+ recovery_action: "resolve the Kernel storage conflict before resuming in the current Host"
8521
+ },
8299
8522
  git_head_unreadable: {
8300
8523
  state: "rejected",
8301
8524
  reason: (detail) => detail,
@@ -8662,6 +8885,17 @@ function createGhTransport(binary = "gh") {
8662
8885
  return new Promise((complete) => {
8663
8886
  let stdout = Buffer.alloc(0);
8664
8887
  let stderr = Buffer.alloc(0);
8888
+ const remaining = options.budget === undefined ? GH_TIMEOUT_MS : Math.min(GH_TIMEOUT_MS, options.budget.deadline_ms - Date.now());
8889
+ if (options.budget && (options.budget.signal?.aborted || remaining <= 0)) {
8890
+ complete({
8891
+ exit_code: 1,
8892
+ stdout: "",
8893
+ stderr: options.budget.signal?.aborted ? "operation cancelled by the caller" : "publication deadline exceeded before the next remote call",
8894
+ timed_out: true,
8895
+ output_exceeded: false
8896
+ });
8897
+ return;
8898
+ }
8665
8899
  let timedOut = false;
8666
8900
  let outputExceeded = false;
8667
8901
  let timer;
@@ -8719,7 +8953,7 @@ function createGhTransport(binary = "gh") {
8719
8953
  timer = setTimeout(() => {
8720
8954
  timedOut = true;
8721
8955
  child.kill("SIGKILL");
8722
- }, GH_TIMEOUT_MS);
8956
+ }, remaining);
8723
8957
  child.once("close", (code) => {
8724
8958
  finish(code ?? 1);
8725
8959
  });
@@ -8823,6 +9057,33 @@ function initiativeLookup(issues, repositoryId, initiativeId) {
8823
9057
  const initiative = marker("initiative-id", initiativeId);
8824
9058
  return findIssue(issues, [initiative, KIND_INITIATIVE_MARKER], [marker("repo-id", repositoryId), initiative]);
8825
9059
  }
9060
+ function publishedIntentHashMarkers(body) {
9061
+ return [...body.matchAll(/<!-- immune-brain:intent-hash=([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) -->/g)].map((match) => match[1]);
9062
+ }
9063
+ function publishedIntentIdentity(body) {
9064
+ const hashes = publishedIntentHashMarkers(body);
9065
+ if (hashes.length === 0)
9066
+ return { kind: "absent" };
9067
+ if (hashes.length === 1)
9068
+ return { kind: "bound", hash: hashes[0] };
9069
+ return { kind: "ambiguous", hashes };
9070
+ }
9071
+ function publishedIntentMismatch(body, taskId, expected) {
9072
+ const identity = publishedIntentIdentity(body);
9073
+ if (identity.kind === "ambiguous")
9074
+ return `published Task ${taskId} carries ${identity.hashes.length} conflicting intent-hash markers; its published Intent identity is ambiguous`;
9075
+ if (identity.kind === "absent" || expected === undefined)
9076
+ return null;
9077
+ if (identity.hash !== expected)
9078
+ return `published Task ${taskId} was published from a different TaskIntent revision; the published identity no longer matches the Intent being published`;
9079
+ return null;
9080
+ }
9081
+ function sameTrackedBody(current, expected) {
9082
+ return stripPublishedIntentHash(current) === stripPublishedIntentHash(expected);
9083
+ }
9084
+ function stripPublishedIntentHash(body) {
9085
+ return body.replace(/<!-- immune-brain:intent-hash=[A-Za-z0-9][A-Za-z0-9._:-]{0,127} -->\n?/g, "");
9086
+ }
8826
9087
  function ownershipMarkerValue(body, name) {
8827
9088
  const values = [...body.matchAll(new RegExp(`<!-- immune-brain:${name}=([A-Za-z0-9][A-Za-z0-9._-]{0,127}) -->`, "g"))];
8828
9089
  return values.length === 1 ? values[0][1] : null;
@@ -9123,7 +9384,8 @@ function childBody(repository, operation, parent) {
9123
9384
  marker("repo-id", repository.id),
9124
9385
  marker("initiative-id", operation.initiative_id),
9125
9386
  marker("slice-id", operation.slice_id),
9126
- marker("task-id", operation.task_id)
9387
+ marker("task-id", operation.task_id),
9388
+ ...operation.intent_hash ? [marker("intent-hash", operation.intent_hash)] : []
9127
9389
  ].join(`
9128
9390
  `)}
9129
9391
 
@@ -9292,8 +9554,11 @@ async function upsertTask(root, gh, operation, source, pendingBinding = null, am
9292
9554
  return result(operation.op, "ambiguous_remote_state", `new pending Task ${operation.task_id} is unbound but Issue #${child.number} already exists with divergent content; bind it to amend`, found.issue);
9293
9555
  return updatePendingChild(root, gh, source, operation, child, undefined, blockers, approvedFinal, amendmentContext);
9294
9556
  }
9295
- if (found.issue.body !== body || found.issue.title !== title)
9557
+ if (!sameTrackedBody(found.issue.body, body) || found.issue.title !== title)
9296
9558
  return result(operation.op, "permanent_failure", "Task Issue already exists with a different title or Agent Brief; edit the GitHub source or retry the original projection before changing native relations", found.issue);
9559
+ const intentMismatch = publishedIntentMismatch(found.issue.body, operation.task_id, operation.intent_hash);
9560
+ if (intentMismatch)
9561
+ return result(operation.op, "ambiguous_remote_state", intentMismatch, found.issue);
9297
9562
  const observedLabelArgs = labelMutationArgs(child.labels, desiredTaskLabels(operation));
9298
9563
  if (observedLabelArgs.length) {
9299
9564
  const edited = await gh.run([
@@ -9329,8 +9594,11 @@ async function upsertTask(root, gh, operation, source, pendingBinding = null, am
9329
9594
  if ("contract" in finalSource)
9330
9595
  return finalSource;
9331
9596
  const finalChild = ownedTaskLookup(finalSource.issues, finalSource.repository.id, operation.task_id, operation.initiative_id, operation.slice_id);
9332
- if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || finalChild.issue.body !== body)
9597
+ if (finalChild.kind !== "found" || finalChild.issue.id !== child.id || finalChild.issue.title !== title || !sameTrackedBody(finalChild.issue.body, body))
9333
9598
  return result(operation.op, "ambiguous_remote_state", "Task Issue changed identity, title, or body during dependency publication", child);
9599
+ const finalIdentityMismatch = publishedIntentMismatch(finalChild.issue.body, operation.task_id, operation.intent_hash);
9600
+ if (finalIdentityMismatch)
9601
+ return result(operation.op, "ambiguous_remote_state", finalIdentityMismatch, child);
9334
9602
  const finalChildOwnership = await confirmTerminalOwnership(root, gh, operation.op, finalSource, finalChild.issue);
9335
9603
  if (!("owned" in finalChildOwnership))
9336
9604
  return finalChildOwnership;
@@ -9782,7 +10050,7 @@ async function revalidatePendingChildBeforeWrite(root, gh, childNumber, childTas
9782
10050
  import { existsSync as existsSync7, readdirSync as readdirSync6, readFileSync as readFileSync13 } from "node:fs";
9783
10051
  import { randomUUID as randomUUID7 } from "node:crypto";
9784
10052
  import { join as join12 } from "node:path";
9785
- import { spawnSync as spawnSync5 } from "node:child_process";
10053
+ import { spawnSync as spawnSync6 } from "node:child_process";
9786
10054
 
9787
10055
  // plugins/immune-brain/runtime/kernel/batch_authority.ts
9788
10056
  import { createHash as createHash18 } from "node:crypto";
@@ -10031,252 +10299,49 @@ function deriveChildEnrollment(root, registry, input) {
10031
10299
  };
10032
10300
  }
10033
10301
 
10034
- // plugins/immune-brain/runtime/unattended/batch_plan.ts
10035
- import { createHash as createHash19 } from "node:crypto";
10036
- var ID_PATTERN2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10037
- var DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1000;
10038
- var DEFAULT_QA_FAILURE_LIMIT = 2;
10039
- function specBindingReason(inspection) {
10040
- if (inspection.missing.length > 0)
10041
- return `spec_binding_incomplete: ${inspection.missing.join(", ")}`;
10042
- return inspection.code === "binding_ambiguous" ? "spec_binding_ambiguous" : "spec_binding_missing";
10043
- }
10044
- function compareIds(left, right) {
10045
- return left < right ? -1 : left > right ? 1 : 0;
10046
- }
10047
- function positiveSafeInteger(value, name) {
10048
- if (!Number.isSafeInteger(value) || value <= 0)
10049
- throw new Error(`${name} must be a positive safe integer`);
10050
- return value;
10051
- }
10052
- var ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
10053
- function timestamp(value, name) {
10302
+ // plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts
10303
+ import { spawnSync as spawnSync5 } from "node:child_process";
10304
+
10305
+ // plugins/immune-brain/runtime/unattended/batch_state.ts
10306
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, openSync as openSync6, closeSync as closeSync6, writeFileSync as writeFileSync6, renameSync as renameSync3, lstatSync as lstatSync9, constants as constants5, rmSync as rmSync6 } from "node:fs";
10307
+ import { randomUUID as randomUUID6 } from "node:crypto";
10308
+ import { dirname as dirname7, join as join11 } from "node:path";
10309
+ var BATCH_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10310
+ var CHILD_RUN_STATES = new Set([
10311
+ "pending",
10312
+ "enrolled",
10313
+ "settled",
10314
+ "committed",
10315
+ "needs_human",
10316
+ "skipped_blocked"
10317
+ ]);
10318
+ var BATCH_RUN_STATES = new Set([
10319
+ "prepared",
10320
+ "running",
10321
+ "needs_human",
10322
+ "completed",
10323
+ "budget_stopped",
10324
+ "failed",
10325
+ "rejected"
10326
+ ]);
10327
+ var ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10328
+ function isCanonicalTimestamp(value) {
10054
10329
  if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN.test(value))
10055
- throw new Error(`${name} must be an ISO timestamp`);
10330
+ return false;
10056
10331
  const milliseconds = Date.parse(value);
10057
- if (!Number.isFinite(milliseconds))
10058
- throw new Error(`${name} must be an ISO timestamp`);
10059
- return { milliseconds, iso: new Date(milliseconds).toISOString() };
10060
- }
10061
- function normalizeObservation(value, initiativeSlug) {
10062
- if (!value || value.contract !== "immune_brain/github_initiative_observation/v1")
10063
- throw new Error("tracker returned an invalid Initiative observation contract");
10064
- if (value.initiative_id !== initiativeSlug)
10065
- throw new Error("tracker returned an observation for another Initiative");
10066
- if (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0)
10067
- throw new Error("tracker returned an invalid Initiative Issue number");
10068
- if (!Array.isArray(value.tasks))
10069
- throw new Error("tracker returned an invalid Initiative Task list");
10070
- const taskIds = new Set;
10071
- const sliceIds = new Set;
10072
- const tasks = value.tasks.map((task, index) => {
10073
- if (!task || typeof task !== "object")
10074
- throw new Error(`tracker Task ${index} is invalid`);
10075
- if (!ID_PATTERN2.test(task.task_id))
10076
- throw new Error(`tracker Task ${index} has an invalid task_id`);
10077
- if (!ID_PATTERN2.test(task.slice_id))
10078
- throw new Error(`tracker Task ${index} has an invalid slice_id`);
10079
- if (!Number.isSafeInteger(task.issue_number) || task.issue_number <= 0)
10080
- throw new Error(`tracker Task ${task.task_id} has an invalid Issue number`);
10081
- if (!Array.isArray(task.blocked_by) || task.blocked_by.some((id) => typeof id !== "string" || !ID_PATTERN2.test(id)))
10082
- throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
10083
- if (taskIds.has(task.task_id))
10084
- throw new Error(`tracker returned duplicate Task ${task.task_id}`);
10085
- if (sliceIds.has(task.slice_id))
10086
- throw new Error(`tracker returned duplicate Slice ${task.slice_id}`);
10087
- taskIds.add(task.task_id);
10088
- sliceIds.add(task.slice_id);
10089
- const blockedBy = [...task.blocked_by].sort();
10090
- if (new Set(blockedBy).size !== blockedBy.length || blockedBy.includes(task.task_id))
10091
- throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
10092
- return { ...task, blocked_by: blockedBy };
10093
- });
10094
- for (const task of tasks) {
10095
- const unknown = task.blocked_by.find((id) => !taskIds.has(id));
10096
- if (unknown)
10097
- throw new Error(`tracker Task ${task.task_id} depends on unknown Task ${unknown}`);
10098
- }
10099
- return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) };
10332
+ return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === value;
10100
10333
  }
10101
- function dependencyOrder(observation) {
10102
- const remaining = new Map(observation.tasks.map((task) => [task.task_id, task]));
10103
- const done = new Set;
10104
- const order = [];
10105
- while (remaining.size) {
10106
- const ready = [...remaining.values()].filter((task) => task.blocked_by.every((id) => done.has(id))).sort((left, right) => compareIds(left.task_id, right.task_id));
10107
- if (!ready.length)
10108
- throw new Error("Initiative Task dependencies must form an acyclic graph");
10109
- for (const task of ready) {
10110
- remaining.delete(task.task_id);
10111
- done.add(task.task_id);
10112
- order.push(task);
10113
- }
10114
- }
10115
- return order;
10334
+ function validateBatchId(batchId) {
10335
+ if (typeof batchId !== "string" || !BATCH_ID_PATTERN.test(batchId))
10336
+ throw new Error("batch id is not a safe file identity");
10116
10337
  }
10117
- function dependencyClosures(order) {
10118
- const closures = new Map;
10119
- for (const task of order) {
10120
- const closure = new Set;
10121
- for (const dependency of task.blocked_by) {
10122
- closure.add(dependency);
10123
- for (const transitive of closures.get(dependency) ?? [])
10124
- closure.add(transitive);
10125
- }
10126
- closures.set(task.task_id, [...closure].sort());
10127
- }
10128
- return closures;
10338
+ function statePath(batchId) {
10339
+ validateBatchId(batchId);
10340
+ return join11(".imm", "state", "batches", `${batchId}.json`);
10129
10341
  }
10130
- function budget(input, enrollableCount, confirmationTime) {
10131
- const maxChildren = input.budget?.max_children === undefined ? positiveSafeInteger(enrollableCount, "budget.max_children") : positiveSafeInteger(input.budget.max_children, "budget.max_children");
10132
- if (maxChildren > enrollableCount)
10133
- throw new Error("budget.max_children exceeds the enrollable Child count");
10134
- const qaFailureLimit = input.budget?.qa_failure_limit === undefined ? DEFAULT_QA_FAILURE_LIMIT : positiveSafeInteger(input.budget.qa_failure_limit, "budget.qa_failure_limit");
10135
- const deadline = input.budget?.deadline_at === undefined ? new Date(confirmationTime.milliseconds + DEFAULT_DEADLINE_MS).toISOString() : timestamp(input.budget.deadline_at, "budget.deadline_at").iso;
10136
- if (Date.parse(deadline) <= confirmationTime.milliseconds)
10137
- throw new Error("budget.deadline_at must be later than confirmation_time");
10138
- return { max_children: maxChildren, deadline_at: deadline, qa_failure_limit: qaFailureLimit };
10139
- }
10140
- async function projectBatchPlan(root, initiativeSlug, input, readInitiative = observeGithubInitiative) {
10141
- if (!ID_PATTERN2.test(initiativeSlug))
10142
- throw new Error("initiative_slug is invalid");
10143
- if (!input || typeof input !== "object")
10144
- throw new Error("batch plan input is required");
10145
- const confirmationTime = timestamp(input.confirmation_time, "confirmation_time");
10146
- const trackerObservation = normalizeObservation(await readInitiative(root, initiativeSlug), initiativeSlug);
10147
- const order = dependencyOrder(trackerObservation);
10148
- const closures = dependencyClosures(order);
10149
- const children = [];
10150
- for (const task of order) {
10151
- const blockedBy = closures.get(task.task_id);
10152
- const child = { task_id: task.task_id, slice_id: task.slice_id, blocked_by: blockedBy };
10153
- const tombstone = readTaskTombstone(root, task.task_id);
10154
- const record = readTaskRecordRaw(root, task.task_id).record;
10155
- if (tombstone && record)
10156
- throw new Error(`Task ${task.task_id} has conflicting settled and active state`);
10157
- if (tombstone) {
10158
- children.push({ ...child, status: "already_settled", reason: null, intent_path: null, intent_revision: null, intent_content_hash: null });
10159
- continue;
10160
- }
10161
- if (record) {
10162
- children.push({ ...child, status: "already_owned", reason: null, intent_path: null, intent_revision: null, intent_content_hash: null });
10163
- continue;
10164
- }
10165
- const intentPath = `docs/plans/${task.task_id}.intent.json`;
10166
- try {
10167
- const read = observeTaskIntent(root, task.task_id, intentPath);
10168
- if (read.intent.risk === "critical") {
10169
- children.push({
10170
- ...child,
10171
- status: "needs_human",
10172
- reason: "critical",
10173
- intent_path: read.intent_ref.path,
10174
- intent_revision: read.intent_ref.revision,
10175
- intent_content_hash: read.content_hash
10176
- });
10177
- continue;
10178
- }
10179
- const binding = inspectSpecBinding(read.intent);
10180
- if (!binding.ok) {
10181
- children.push({
10182
- ...child,
10183
- status: "needs_human",
10184
- reason: specBindingReason(binding),
10185
- intent_path: read.intent_ref.path,
10186
- intent_revision: read.intent_ref.revision,
10187
- intent_content_hash: read.content_hash
10188
- });
10189
- continue;
10190
- }
10191
- children.push({
10192
- ...child,
10193
- status: "enrollable",
10194
- reason: null,
10195
- intent_path: read.intent_ref.path,
10196
- intent_revision: read.intent_ref.revision,
10197
- intent_content_hash: read.content_hash
10198
- });
10199
- } catch (error) {
10200
- if (!(error instanceof TaskIntentObservationError))
10201
- throw error;
10202
- children.push({ ...child, status: "needs_human", reason: "invalid_intent", intent_path: null, intent_revision: null, intent_content_hash: null });
10203
- }
10204
- }
10205
- const directDependencies = new Map(order.map((task) => [task.task_id, task.blocked_by]));
10206
- const childById = new Map(children.map((child) => [child.task_id, child]));
10207
- for (const child of children) {
10208
- if (child.status !== "enrollable")
10209
- continue;
10210
- if (directDependencies.get(child.task_id).some((id) => {
10211
- const status = childById.get(id).status;
10212
- return status === "already_owned" || status === "needs_human" || status === "blocked";
10213
- })) {
10214
- child.status = "blocked";
10215
- child.reason = "dependency_unavailable";
10216
- }
10217
- }
10218
- const enrollable = children.filter((child) => child.status === "enrollable").map((child) => ({
10219
- task_id: child.task_id,
10220
- intent_path: child.intent_path,
10221
- intent_revision: child.intent_revision,
10222
- intent_content_hash: child.intent_content_hash,
10223
- blocked_by: child.blocked_by.filter((id) => childById.get(id)?.status === "enrollable")
10224
- }));
10225
- if (!enrollable.length)
10226
- throw new Error("batch plan has no enrollable children; nothing to confirm");
10227
- const planDigest = `sha256:${createHash19("sha256").update(stableStringify(enrollable)).digest("hex")}`;
10228
- return {
10229
- contract: "assurance_kernel/batch_plan/v1",
10230
- initiative_slug: initiativeSlug,
10231
- confirmation_time: confirmationTime.iso,
10232
- tracker_observation: trackerObservation,
10233
- children,
10234
- enrollable,
10235
- plan_digest: planDigest,
10236
- budget: budget(input, enrollable.length, confirmationTime)
10237
- };
10238
- }
10239
-
10240
- // plugins/immune-brain/runtime/unattended/batch_state.ts
10241
- import { existsSync as existsSync6, mkdirSync as mkdirSync6, openSync as openSync6, closeSync as closeSync6, writeFileSync as writeFileSync6, renameSync as renameSync3, lstatSync as lstatSync9, constants as constants5, rmSync as rmSync6 } from "node:fs";
10242
- import { randomUUID as randomUUID6 } from "node:crypto";
10243
- import { dirname as dirname7, join as join11 } from "node:path";
10244
- var BATCH_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10245
- var CHILD_RUN_STATES = new Set([
10246
- "pending",
10247
- "enrolled",
10248
- "settled",
10249
- "committed",
10250
- "needs_human",
10251
- "skipped_blocked"
10252
- ]);
10253
- var BATCH_RUN_STATES = new Set([
10254
- "prepared",
10255
- "running",
10256
- "needs_human",
10257
- "completed",
10258
- "budget_stopped",
10259
- "failed",
10260
- "rejected"
10261
- ]);
10262
- var ISO_TIMESTAMP_PATTERN2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10263
- function isCanonicalTimestamp(value) {
10264
- if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN2.test(value))
10265
- return false;
10266
- const milliseconds = Date.parse(value);
10267
- return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === value;
10268
- }
10269
- function validateBatchId(batchId) {
10270
- if (typeof batchId !== "string" || !BATCH_ID_PATTERN.test(batchId))
10271
- throw new Error("batch id is not a safe file identity");
10272
- }
10273
- function statePath(batchId) {
10274
- validateBatchId(batchId);
10275
- return join11(".imm", "state", "batches", `${batchId}.json`);
10276
- }
10277
- function canonicalBytes(record) {
10278
- return `${JSON.stringify(record, null, 2)}
10279
- `;
10342
+ function canonicalBytes(record) {
10343
+ return `${JSON.stringify(record, null, 2)}
10344
+ `;
10280
10345
  }
10281
10346
  function validateRecordShape(value, batchId) {
10282
10347
  if (typeof value !== "object" || value === null)
@@ -10366,13 +10431,17 @@ function prepareBatchRunState(input) {
10366
10431
  };
10367
10432
  return prepared;
10368
10433
  }
10434
+ function parseBatchRunState(raw, batchId) {
10435
+ validateBatchId(batchId);
10436
+ const parsed = JSON.parse(raw);
10437
+ validateRecordShape(parsed, batchId);
10438
+ return parsed;
10439
+ }
10369
10440
  function readBatchRunState(root, batchId) {
10370
10441
  const path = statePath(batchId);
10371
10442
  if (!existsSync6(join11(root, path)))
10372
10443
  return null;
10373
- const parsed = JSON.parse(readSecureProjectFile(root, path));
10374
- validateRecordShape(parsed, batchId);
10375
- return parsed;
10444
+ return parseBatchRunState(readSecureProjectFile(root, path), batchId);
10376
10445
  }
10377
10446
  function ensureSecureDirectory2(root, relative) {
10378
10447
  const target = join11(root, relative);
@@ -10412,6 +10481,18 @@ function writeFileAtomically(root, relative, bytes) {
10412
10481
  }
10413
10482
  }
10414
10483
  }
10484
+ function replaceBatchRunState(root, expected, next, validate) {
10485
+ const path = statePath(next.batch_id);
10486
+ validateRecordShape(next, next.batch_id);
10487
+ return withKernelStoreLock(root, () => {
10488
+ if (!readSecureProjectBytes(root, path).equals(expected))
10489
+ throw new Error("batch state CAS mismatch");
10490
+ validate();
10491
+ const stored = { ...next, updated_at: new Date().toISOString() };
10492
+ writeFileAtomically(root, path, canonicalBytes(stored));
10493
+ return stored;
10494
+ });
10495
+ }
10415
10496
  function writeBatchRunState(root, record) {
10416
10497
  const path = statePath(record.batch_id);
10417
10498
  validateRecordShape(record, record.batch_id);
@@ -10459,6 +10540,428 @@ function isTerminalBatchState(state) {
10459
10540
  return state === "completed" || state === "budget_stopped" || state === "failed" || state === "rejected";
10460
10541
  }
10461
10542
 
10543
+ // plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts
10544
+ var oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
10545
+ var id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10546
+ var batchAuthor = process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch";
10547
+ var decode = (bytes) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
10548
+ function refuse() {
10549
+ throw new Error("plan_digest mismatch: batch plan reconfirmation is not eligible");
10550
+ }
10551
+ function matchesLocalProof(localJson, exported) {
10552
+ return localJson !== null && JSON.stringify(Object.entries(JSON.parse(localJson)).sort()) === JSON.stringify(Object.entries(exported).sort());
10553
+ }
10554
+ function git4(root, args) {
10555
+ const r = spawnSync5("git", ["-C", root, ...args], { encoding: "buffer", maxBuffer: 262144, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
10556
+ if (r.status !== 0)
10557
+ refuse();
10558
+ return decode(r.stdout);
10559
+ }
10560
+ function ownUnpersistedBatchHead(root, record, head) {
10561
+ try {
10562
+ if (!oid.test(head) || !oid.test(record.base_head) || !id.test(record.batch_id) || record.commits.length || record.children[0]?.state !== "settled" || record.children.some((c) => c.commit !== null))
10563
+ return false;
10564
+ const task = record.children[0].task_id;
10565
+ if (!id.test(task))
10566
+ return false;
10567
+ const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
10568
+ if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id || evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head)
10569
+ return false;
10570
+ if (git4(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch)
10571
+ return false;
10572
+ const parents = git4(root, ["rev-list", "--parents", "-n", "1", head]).trim().split(/\s+/).slice(1);
10573
+ if (parents.length !== 1 || parents[0] !== record.base_head)
10574
+ return false;
10575
+ const metadata = git4(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\x00");
10576
+ if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id)
10577
+ return false;
10578
+ const run = localRunId(root, task);
10579
+ if (!run || !id.test(run))
10580
+ return false;
10581
+ const pair = readAuditTaskPair(root, task, run);
10582
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done")
10583
+ return false;
10584
+ const local = withKernelRead(root, (db) => readRunRowByTask(db, task));
10585
+ if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof))
10586
+ return false;
10587
+ for (const file of ["task-record.json", "terminal-proof.json"]) {
10588
+ const path = `.imm/audit/${task}/${run}/${file}`;
10589
+ if (!readSecureProjectBytes(root, path).equals(Buffer.from(git4(root, ["show", `${head}:${path}`]))))
10590
+ return false;
10591
+ }
10592
+ const paths = git4(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\x00").filter(Boolean);
10593
+ if (!paths.length || paths.some((path) => !path.startsWith(`.imm/audit/${task}/${run}/`) && !pair.record.intent_snapshot.scope_hint.some((s) => pathMatchesScope(path, s))))
10594
+ return false;
10595
+ const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
10596
+ if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete)
10597
+ return false;
10598
+ return git4(root, ["rev-parse", "HEAD"]).trim() === head;
10599
+ } catch {
10600
+ return false;
10601
+ }
10602
+ }
10603
+ var confirmations = new Map;
10604
+ function retainReconfirmation(nonce, expires, snapshot) {
10605
+ for (const [key, value] of confirmations)
10606
+ if (value.expires <= Date.now())
10607
+ confirmations.delete(key);
10608
+ if (confirmations.has(nonce))
10609
+ refuse();
10610
+ confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
10611
+ }
10612
+ function takeReconfirmation(nonce) {
10613
+ const found = confirmations.get(nonce);
10614
+ confirmations.delete(nonce);
10615
+ if (!found || found.expires <= Date.now())
10616
+ refuse();
10617
+ return found.snapshot;
10618
+ }
10619
+ async function captureBatchReconfirmation(root, record, children) {
10620
+ if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length || record.children.some((c) => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length)
10621
+ refuse();
10622
+ const assertNoCommitEvidence = () => {
10623
+ for (const child of record.children) {
10624
+ if (!id.test(child.task_id))
10625
+ refuse();
10626
+ const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
10627
+ let missing = false;
10628
+ try {
10629
+ readSecureProjectBytes(root, path);
10630
+ } catch (error) {
10631
+ if (error instanceof Error && error.message === `source_missing: ${path}`)
10632
+ missing = true;
10633
+ else
10634
+ refuse();
10635
+ }
10636
+ if (!missing)
10637
+ refuse();
10638
+ }
10639
+ };
10640
+ assertNoCommitEvidence();
10641
+ const statePath = `.imm/state/batches/${record.batch_id}.json`;
10642
+ const stateBytes = readSecureProjectBytes(root, statePath);
10643
+ if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record))
10644
+ refuse();
10645
+ const files = new Map;
10646
+ const capture = (path) => {
10647
+ const bytes = readSecureProjectBytes(root, path);
10648
+ files.set(path, bytes);
10649
+ return bytes;
10650
+ };
10651
+ const oldChildren = [];
10652
+ const changed = [];
10653
+ let scope = null;
10654
+ const workspace = readWorkspaceStateRaw(root);
10655
+ if (workspace.state.current_working !== null)
10656
+ refuse();
10657
+ for (const [index, child] of children.entries()) {
10658
+ const previous = record.children[index];
10659
+ if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id || JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`)
10660
+ refuse();
10661
+ const old = parseTaskIntentV1(JSON.parse(git4(root, ["show", `${record.base_head}:${child.intent_path}`])));
10662
+ if (old.task_id !== child.task_id)
10663
+ refuse();
10664
+ const oldHash = canonicalIntentHash(old);
10665
+ oldChildren.push({ task_id: child.task_id, blocked_by: [...child.blocked_by], intent_path: child.intent_path, intent_revision: old.revision, intent_content_hash: oldHash });
10666
+ const currentBytes = capture(child.intent_path);
10667
+ const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
10668
+ const currentHash = canonicalIntentHash(current);
10669
+ const tracked = readTaskIntent(root, child.task_id, child.intent_path);
10670
+ if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision)
10671
+ refuse();
10672
+ if (currentHash === oldHash)
10673
+ continue;
10674
+ if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner)
10675
+ refuse();
10676
+ const run = localRunId(root, child.task_id);
10677
+ if (!run || !id.test(run))
10678
+ refuse();
10679
+ const local = withKernelRead(root, (db) => readRunRowByTask(db, child.task_id));
10680
+ if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` || Date.parse(local.created_at) > Date.parse(record.updated_at))
10681
+ refuse();
10682
+ const pair = readAuditTaskPair(root, child.task_id, run);
10683
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4")
10684
+ refuse();
10685
+ const r = pair.record;
10686
+ if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash || r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done")
10687
+ refuse();
10688
+ const terminal = r.history.at(-1);
10689
+ if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id)
10690
+ refuse();
10691
+ const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
10692
+ const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
10693
+ if (!capture(recordPath).equals(Buffer.from(local.record_json)))
10694
+ refuse();
10695
+ const capturedProof = JSON.parse(decode(capture(proofPath)));
10696
+ if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof))
10697
+ refuse();
10698
+ const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
10699
+ const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
10700
+ if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head)
10701
+ refuse();
10702
+ return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
10703
+ });
10704
+ if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" || !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash || !projection.projection.fresh_approval_kinds.includes("qa") || projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))
10705
+ refuse();
10706
+ scope = r.intent_snapshot.scope_hint;
10707
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
10708
+ }
10709
+ if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null)
10710
+ refuse();
10711
+ const confirmedScope = scope;
10712
+ const owner = changed[0];
10713
+ const status = () => git4(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
10714
+ const statusBytes = status();
10715
+ const entries = statusBytes.split("\x00").filter(Boolean);
10716
+ for (const entry of entries) {
10717
+ const code = entry.slice(0, 2), path = entry.slice(3);
10718
+ if (code[1] !== " " || !["A", "M", "D"].includes(code[0]) || !path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) && !confirmedScope.some((s) => pathMatchesScope(path, s)))
10719
+ refuse();
10720
+ }
10721
+ const flagsBytes = git4(root, ["ls-files", "-v"]);
10722
+ const flags = flagsBytes.split(`
10723
+ `).filter(Boolean);
10724
+ if (flags.some((line) => line[0] === "S" || line[0] !== line[0]?.toUpperCase()))
10725
+ refuse();
10726
+ const head = () => git4(root, ["rev-parse", "HEAD"]).trim();
10727
+ const branch = () => git4(root, ["symbolic-ref", "--short", "HEAD"]).trim();
10728
+ if (head() !== record.base_head || branch() !== record.branch)
10729
+ refuse();
10730
+ const assertDeliveryUnchanged = () => {
10731
+ for (const c of changed) {
10732
+ const pair = readAuditTaskPair(root, c.task, c.run);
10733
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head)
10734
+ refuse();
10735
+ const local = withKernelRead(root, (db) => readRunRowByTask(db, c.task));
10736
+ const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
10737
+ if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity)
10738
+ refuse();
10739
+ }
10740
+ };
10741
+ const assertUnchanged = () => {
10742
+ assertNoCommitEvidence();
10743
+ if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git4(root, ["ls-files", "-v"]) !== flagsBytes || readWorkspaceStateRaw(root).revision !== workspace.revision)
10744
+ refuse();
10745
+ for (const [path, bytes] of files)
10746
+ if (!readSecureProjectBytes(root, path).equals(bytes))
10747
+ refuse();
10748
+ assertDeliveryUnchanged();
10749
+ };
10750
+ assertUnchanged();
10751
+ const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({
10752
+ ...old,
10753
+ intent_revision: children[index].intent_revision,
10754
+ intent_content_hash: children[index].intent_content_hash
10755
+ })));
10756
+ return { root, plan_digest, stateBytes, assertUnchanged };
10757
+ }
10758
+
10759
+ // plugins/immune-brain/runtime/unattended/batch_plan.ts
10760
+ import { createHash as createHash19 } from "node:crypto";
10761
+ var ID_PATTERN2 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10762
+ var DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1000;
10763
+ var DEFAULT_QA_FAILURE_LIMIT = 2;
10764
+ function specBindingReason(inspection) {
10765
+ if (inspection.missing.length > 0)
10766
+ return `spec_binding_incomplete: ${inspection.missing.join(", ")}`;
10767
+ return inspection.code === "binding_ambiguous" ? "spec_binding_ambiguous" : "spec_binding_missing";
10768
+ }
10769
+ function compareIds(left, right) {
10770
+ return left < right ? -1 : left > right ? 1 : 0;
10771
+ }
10772
+ function positiveSafeInteger(value, name) {
10773
+ if (!Number.isSafeInteger(value) || value <= 0)
10774
+ throw new Error(`${name} must be a positive safe integer`);
10775
+ return value;
10776
+ }
10777
+ var ISO_TIMESTAMP_PATTERN2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
10778
+ function timestamp(value, name) {
10779
+ if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN2.test(value))
10780
+ throw new Error(`${name} must be an ISO timestamp`);
10781
+ const milliseconds = Date.parse(value);
10782
+ if (!Number.isFinite(milliseconds))
10783
+ throw new Error(`${name} must be an ISO timestamp`);
10784
+ return { milliseconds, iso: new Date(milliseconds).toISOString() };
10785
+ }
10786
+ function normalizeObservation(value, initiativeSlug) {
10787
+ if (!value || value.contract !== "immune_brain/github_initiative_observation/v1")
10788
+ throw new Error("tracker returned an invalid Initiative observation contract");
10789
+ if (value.initiative_id !== initiativeSlug)
10790
+ throw new Error("tracker returned an observation for another Initiative");
10791
+ if (!Number.isSafeInteger(value.issue_number) || value.issue_number <= 0)
10792
+ throw new Error("tracker returned an invalid Initiative Issue number");
10793
+ if (!Array.isArray(value.tasks))
10794
+ throw new Error("tracker returned an invalid Initiative Task list");
10795
+ const taskIds = new Set;
10796
+ const sliceIds = new Set;
10797
+ const tasks = value.tasks.map((task, index) => {
10798
+ if (!task || typeof task !== "object")
10799
+ throw new Error(`tracker Task ${index} is invalid`);
10800
+ if (!ID_PATTERN2.test(task.task_id))
10801
+ throw new Error(`tracker Task ${index} has an invalid task_id`);
10802
+ if (!ID_PATTERN2.test(task.slice_id))
10803
+ throw new Error(`tracker Task ${index} has an invalid slice_id`);
10804
+ if (!Number.isSafeInteger(task.issue_number) || task.issue_number <= 0)
10805
+ throw new Error(`tracker Task ${task.task_id} has an invalid Issue number`);
10806
+ if (!Array.isArray(task.blocked_by) || task.blocked_by.some((id) => typeof id !== "string" || !ID_PATTERN2.test(id)))
10807
+ throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
10808
+ if (taskIds.has(task.task_id))
10809
+ throw new Error(`tracker returned duplicate Task ${task.task_id}`);
10810
+ if (sliceIds.has(task.slice_id))
10811
+ throw new Error(`tracker returned duplicate Slice ${task.slice_id}`);
10812
+ taskIds.add(task.task_id);
10813
+ sliceIds.add(task.slice_id);
10814
+ const blockedBy = [...task.blocked_by].sort();
10815
+ if (new Set(blockedBy).size !== blockedBy.length || blockedBy.includes(task.task_id))
10816
+ throw new Error(`tracker Task ${task.task_id} has invalid blocked_by dependencies`);
10817
+ return { ...task, blocked_by: blockedBy };
10818
+ });
10819
+ for (const task of tasks) {
10820
+ const unknown = task.blocked_by.find((id) => !taskIds.has(id));
10821
+ if (unknown)
10822
+ throw new Error(`tracker Task ${task.task_id} depends on unknown Task ${unknown}`);
10823
+ }
10824
+ return { ...value, tasks: tasks.sort((left, right) => compareIds(left.task_id, right.task_id)) };
10825
+ }
10826
+ function dependencyOrder(observation) {
10827
+ const remaining = new Map(observation.tasks.map((task) => [task.task_id, task]));
10828
+ const done = new Set;
10829
+ const order = [];
10830
+ while (remaining.size) {
10831
+ const ready = [...remaining.values()].filter((task) => task.blocked_by.every((id) => done.has(id))).sort((left, right) => compareIds(left.task_id, right.task_id));
10832
+ if (!ready.length)
10833
+ throw new Error("Initiative Task dependencies must form an acyclic graph");
10834
+ for (const task of ready) {
10835
+ remaining.delete(task.task_id);
10836
+ done.add(task.task_id);
10837
+ order.push(task);
10838
+ }
10839
+ }
10840
+ return order;
10841
+ }
10842
+ function dependencyClosures(order) {
10843
+ const closures = new Map;
10844
+ for (const task of order) {
10845
+ const closure = new Set;
10846
+ for (const dependency of task.blocked_by) {
10847
+ closure.add(dependency);
10848
+ for (const transitive of closures.get(dependency) ?? [])
10849
+ closure.add(transitive);
10850
+ }
10851
+ closures.set(task.task_id, [...closure].sort());
10852
+ }
10853
+ return closures;
10854
+ }
10855
+ function budget(input, enrollableCount, confirmationTime) {
10856
+ const maxChildren = input.budget?.max_children === undefined ? positiveSafeInteger(enrollableCount, "budget.max_children") : positiveSafeInteger(input.budget.max_children, "budget.max_children");
10857
+ if (maxChildren > enrollableCount)
10858
+ throw new Error("budget.max_children exceeds the enrollable Child count");
10859
+ const qaFailureLimit = input.budget?.qa_failure_limit === undefined ? DEFAULT_QA_FAILURE_LIMIT : positiveSafeInteger(input.budget.qa_failure_limit, "budget.qa_failure_limit");
10860
+ const deadline = input.budget?.deadline_at === undefined ? new Date(confirmationTime.milliseconds + DEFAULT_DEADLINE_MS).toISOString() : timestamp(input.budget.deadline_at, "budget.deadline_at").iso;
10861
+ if (Date.parse(deadline) <= confirmationTime.milliseconds)
10862
+ throw new Error("budget.deadline_at must be later than confirmation_time");
10863
+ return { max_children: maxChildren, deadline_at: deadline, qa_failure_limit: qaFailureLimit };
10864
+ }
10865
+ async function projectBatchPlan(root, initiativeSlug, input, readInitiative = observeGithubInitiative) {
10866
+ if (!ID_PATTERN2.test(initiativeSlug))
10867
+ throw new Error("initiative_slug is invalid");
10868
+ if (!input || typeof input !== "object")
10869
+ throw new Error("batch plan input is required");
10870
+ const confirmationTime = timestamp(input.confirmation_time, "confirmation_time");
10871
+ const trackerObservation = normalizeObservation(await readInitiative(root, initiativeSlug), initiativeSlug);
10872
+ const order = dependencyOrder(trackerObservation);
10873
+ const closures = dependencyClosures(order);
10874
+ const children = [];
10875
+ for (const task of order) {
10876
+ const blockedBy = closures.get(task.task_id);
10877
+ const child = { task_id: task.task_id, slice_id: task.slice_id, blocked_by: blockedBy };
10878
+ const tombstone = readTaskTombstone(root, task.task_id);
10879
+ const record = readTaskRecordRaw(root, task.task_id).record;
10880
+ if (tombstone && record)
10881
+ throw new Error(`Task ${task.task_id} has conflicting settled and active state`);
10882
+ if (tombstone) {
10883
+ children.push({ ...child, status: "already_settled", reason: null, intent_path: null, intent_revision: null, intent_content_hash: null });
10884
+ continue;
10885
+ }
10886
+ if (record) {
10887
+ children.push({ ...child, status: "already_owned", reason: null, intent_path: null, intent_revision: null, intent_content_hash: null });
10888
+ continue;
10889
+ }
10890
+ const intentPath = `docs/plans/${task.task_id}.intent.json`;
10891
+ try {
10892
+ const read = observeTaskIntent(root, task.task_id, intentPath);
10893
+ if (read.intent.risk === "critical") {
10894
+ children.push({
10895
+ ...child,
10896
+ status: "needs_human",
10897
+ reason: "critical",
10898
+ intent_path: read.intent_ref.path,
10899
+ intent_revision: read.intent_ref.revision,
10900
+ intent_content_hash: read.content_hash
10901
+ });
10902
+ continue;
10903
+ }
10904
+ const binding = inspectSpecBinding(read.intent);
10905
+ if (!binding.ok) {
10906
+ children.push({
10907
+ ...child,
10908
+ status: "needs_human",
10909
+ reason: specBindingReason(binding),
10910
+ intent_path: read.intent_ref.path,
10911
+ intent_revision: read.intent_ref.revision,
10912
+ intent_content_hash: read.content_hash
10913
+ });
10914
+ continue;
10915
+ }
10916
+ children.push({
10917
+ ...child,
10918
+ status: "enrollable",
10919
+ reason: null,
10920
+ intent_path: read.intent_ref.path,
10921
+ intent_revision: read.intent_ref.revision,
10922
+ intent_content_hash: read.content_hash
10923
+ });
10924
+ } catch (error) {
10925
+ if (!(error instanceof TaskIntentObservationError))
10926
+ throw error;
10927
+ children.push({ ...child, status: "needs_human", reason: "invalid_intent", intent_path: null, intent_revision: null, intent_content_hash: null });
10928
+ }
10929
+ }
10930
+ const directDependencies = new Map(order.map((task) => [task.task_id, task.blocked_by]));
10931
+ const childById = new Map(children.map((child) => [child.task_id, child]));
10932
+ for (const child of children) {
10933
+ if (child.status !== "enrollable")
10934
+ continue;
10935
+ if (directDependencies.get(child.task_id).some((id) => {
10936
+ const status = childById.get(id).status;
10937
+ return status === "already_owned" || status === "needs_human" || status === "blocked";
10938
+ })) {
10939
+ child.status = "blocked";
10940
+ child.reason = "dependency_unavailable";
10941
+ }
10942
+ }
10943
+ const enrollable = children.filter((child) => child.status === "enrollable").map((child) => ({
10944
+ task_id: child.task_id,
10945
+ intent_path: child.intent_path,
10946
+ intent_revision: child.intent_revision,
10947
+ intent_content_hash: child.intent_content_hash,
10948
+ blocked_by: child.blocked_by.filter((id) => childById.get(id)?.status === "enrollable")
10949
+ }));
10950
+ if (!enrollable.length)
10951
+ throw new Error("batch plan has no enrollable children; nothing to confirm");
10952
+ const planDigest = `sha256:${createHash19("sha256").update(stableStringify(enrollable)).digest("hex")}`;
10953
+ return {
10954
+ contract: "assurance_kernel/batch_plan/v1",
10955
+ initiative_slug: initiativeSlug,
10956
+ confirmation_time: confirmationTime.iso,
10957
+ tracker_observation: trackerObservation,
10958
+ children,
10959
+ enrollable,
10960
+ plan_digest: planDigest,
10961
+ budget: budget(input, enrollable.length, confirmationTime)
10962
+ };
10963
+ }
10964
+
10462
10965
  // plugins/immune-brain/runtime/unattended/batch_preflight.ts
10463
10966
  var INITIATIVE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10464
10967
  function batchRejection(key, detail = "") {
@@ -10509,7 +11012,12 @@ function findExistingActiveBatch(root, initiativeSlug) {
10509
11012
  }
10510
11013
  if (isTerminalBatchState(candidate.batch_state))
10511
11014
  continue;
10512
- return { corrupt: false, record: candidate };
11015
+ try {
11016
+ const record = readBatchRunState(root, file.slice(0, -5));
11017
+ if (record)
11018
+ return { corrupt: false, record };
11019
+ } catch {}
11020
+ return { corrupt: true, path: file };
10513
11021
  }
10514
11022
  return null;
10515
11023
  }
@@ -10559,7 +11067,7 @@ function isOwnBatchClaim(root, existingBatch, taskId, batchBranch) {
10559
11067
  const currentTaskId = workspaceOwner || (claim?.lifecycle_status === "active" ? claim?.task_id : null);
10560
11068
  if (currentTaskId !== taskId || !claim)
10561
11069
  return false;
10562
- const branch = spawnSync5("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
11070
+ const branch = spawnSync6("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim();
10563
11071
  if (branch !== batchBranch)
10564
11072
  return false;
10565
11073
  const childInBatch = existingBatch.children.find((c) => c.task_id === taskId);
@@ -10616,7 +11124,7 @@ function authorizedScopeOf(root, taskId, state) {
10616
11124
  return scope;
10617
11125
  }
10618
11126
  function porcelainEntries(root) {
10619
- const statusProc = spawnSync5("git", ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { encoding: "utf8" });
11127
+ const statusProc = spawnSync6("git", ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"], { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
10620
11128
  if (statusProc.status !== 0)
10621
11129
  return null;
10622
11130
  const entries = [];
@@ -10635,7 +11143,9 @@ async function projectPlanSurface(input) {
10635
11143
  const riskByTask = new Map;
10636
11144
  let budget;
10637
11145
  if (isResuming && existingBatch) {
10638
- budget = existingBatch.budget;
11146
+ budget = { ...existingBatch.budget };
11147
+ if (Date.parse(budget.deadline_at) <= Date.parse(now))
11148
+ budget.deadline_at = new Date(Date.parse(now) + DEFAULT_DEADLINE_MS).toISOString();
10639
11149
  try {
10640
11150
  recoveryChildren = existingBatch.children.map((c) => {
10641
11151
  const intentPath = `docs/plans/${c.task_id}.intent.json`;
@@ -10738,6 +11248,11 @@ async function projectBatchPreflight(options) {
10738
11248
  const isResuming = activeRecord !== null;
10739
11249
  const batchBranch = `imm/${initiativeSlug}`;
10740
11250
  const activeTaskId = readActiveClaimTaskId(root);
11251
+ if (activeTaskId) {
11252
+ const authority = reconcileKernelAuthority(root, activeTaskId);
11253
+ if (authority.state === "authority_conflict")
11254
+ return reject("kernel_authority_conflict", authority.diagnostic ?? "unverifiable workspace authority");
11255
+ }
10741
11256
  const ownClaim = isResuming && activeTaskId !== null && isOwnBatchClaim(root, activeRecord, activeTaskId, batchBranch);
10742
11257
  if (activeTaskId && !ownClaim)
10743
11258
  return reject("claim_already_active", activeTaskId);
@@ -10747,7 +11262,7 @@ async function projectBatchPreflight(options) {
10747
11262
  } catch (err) {
10748
11263
  return reject("git_head_unreadable", err instanceof Error ? err.message : String(err));
10749
11264
  }
10750
- const branchExists = spawnSync5("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
11265
+ const branchExists = spawnSync6("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${batchBranch}`]);
10751
11266
  if (branchExists.status === 0 && !existingBatch)
10752
11267
  return reject("branch_already_exists", batchBranch);
10753
11268
  const statusEntries = porcelainEntries(root);
@@ -10796,21 +11311,29 @@ async function projectBatchPreflight(options) {
10796
11311
  });
10797
11312
  if (!planSurface.ok)
10798
11313
  return reject(planSurface.key, planSurface.detail);
10799
- return {
10800
- ok: true,
10801
- projection: {
10802
- initiative_slug: initiativeSlug,
10803
- batch_branch: batchBranch,
10804
- is_resuming: isResuming,
10805
- existing_batch: existingBatch,
10806
- base_head: baseHead,
10807
- budget: planSurface.surface.budget,
10808
- plan_digest: planSurface.surface.plan_digest,
10809
- recovery_children: planSurface.surface.recovery_children,
10810
- risk_by_task: planSurface.surface.risk_by_task,
10811
- excluded: planSurface.surface.excluded
11314
+ let reconfirmation;
11315
+ if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
11316
+ try {
11317
+ reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children);
11318
+ } catch {
11319
+ return reject("plan_projection_failed", "batch plan reconfirmation is not eligible");
10812
11320
  }
11321
+ }
11322
+ const projection = {
11323
+ initiative_slug: initiativeSlug,
11324
+ batch_branch: batchBranch,
11325
+ is_resuming: isResuming,
11326
+ existing_batch: existingBatch,
11327
+ base_head: baseHead,
11328
+ budget: planSurface.surface.budget,
11329
+ plan_digest: planSurface.surface.plan_digest,
11330
+ recovery_children: planSurface.surface.recovery_children,
11331
+ risk_by_task: planSurface.surface.risk_by_task,
11332
+ excluded: planSurface.surface.excluded
10813
11333
  };
11334
+ if (reconfirmation)
11335
+ Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
11336
+ return { ok: true, projection };
10814
11337
  }
10815
11338
  async function projectBatchDrift(options) {
10816
11339
  const { root, initiative_slug: initiativeSlug, readInitiative } = options;
@@ -10851,21 +11374,29 @@ async function authorizeBatch(options) {
10851
11374
  } = projection;
10852
11375
  const existingBatch = projection.existing_batch;
10853
11376
  const isExistingExpired = isResuming && Date.parse(existingBatch.authorization_expires_at) <= Date.now();
10854
- const expiresAt = isResuming && !isExistingExpired && existingBatch.batch_state === "running" ? existingBatch.authorization_expires_at : budget.deadline_at;
11377
+ const branchBefore = spawnSync6("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
11378
+ if (branchBefore.status !== 0)
11379
+ return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
11380
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest && expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
11381
+ if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" && expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
11382
+ return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
10855
11383
  const reuseBlockers = [];
10856
11384
  if (isResuming && existingBatch) {
10857
11385
  if (isExistingExpired)
10858
11386
  reuseBlockers.push("batch_authorization_expired");
11387
+ if (Date.parse(existingBatch.budget.deadline_at) <= Date.now())
11388
+ reuseBlockers.push("batch_budget_expired");
10859
11389
  if (existingBatch.batch_state !== "running")
10860
11390
  reuseBlockers.push("batch_not_running");
10861
11391
  if (existingBatch.plan_digest !== planDigest)
10862
11392
  reuseBlockers.push("batch_plan_digest_changed");
10863
11393
  if (existingBatch.branch !== batchBranch)
10864
11394
  reuseBlockers.push("batch_branch_changed");
10865
- if (expectedBatchHead(existingBatch) !== baseHead)
11395
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
10866
11396
  reuseBlockers.push("batch_head_lineage_moved");
10867
11397
  }
10868
11398
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
11399
+ const expiresAt = reuseAuthorization ? existingBatch.authorization_expires_at : budget.deadline_at;
10869
11400
  const batchId = isResuming && existingBatch ? existingBatch.batch_id : `batch-${initiativeSlug}-${randomUUID7()}`;
10870
11401
  const facts = {
10871
11402
  initiative_slug: initiativeSlug,
@@ -10903,12 +11434,29 @@ async function authorizeBatch(options) {
10903
11434
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
10904
11435
  if (drift.base_head === null)
10905
11436
  return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
10906
- if (drift.base_head !== baseHead)
11437
+ if (drift.base_head !== baseHead || ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch, baseHead))
10907
11438
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
10908
11439
  const finalClaimTaskId = readActiveClaimTaskId(root);
10909
11440
  const finalOwnClaim = isResuming && finalClaimTaskId !== null && isOwnBatchClaim(root, existingBatch, finalClaimTaskId, batchBranch);
10910
11441
  if (finalClaimTaskId && !finalOwnClaim)
10911
11442
  return { outcome: "rejected", rejection: batchRejection("claim_appeared_during_confirmation", finalClaimTaskId) };
11443
+ if (isResuming) {
11444
+ const current = findExistingActiveBatch(root, initiativeSlug);
11445
+ if (!current || current.corrupt || JSON.stringify(current.record) !== JSON.stringify(existingBatch))
11446
+ return { outcome: "rejected", rejection: batchRejection("plan_changed") };
11447
+ }
11448
+ const branchAfter = spawnSync6("git", ["-C", root, "branch", "--show-current"], { encoding: "utf8" });
11449
+ if (branchAfter.status !== 0 || branchAfter.stdout !== branchBefore.stdout)
11450
+ return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "Git branch moved after native confirmation") };
11451
+ if (Date.parse(expiresAt) <= Date.now())
11452
+ return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
11453
+ if (projection.reconfirmation) {
11454
+ try {
11455
+ projection.reconfirmation.assertUnchanged();
11456
+ } catch {
11457
+ return { outcome: "rejected", rejection: batchRejection("plan_changed") };
11458
+ }
11459
+ }
10912
11460
  const binding = {
10913
11461
  batch_id: batchId,
10914
11462
  initiative_slug: initiativeSlug,
@@ -10921,6 +11469,8 @@ async function authorizeBatch(options) {
10921
11469
  expires_at: expiresAt,
10922
11470
  nonce: options.nonce
10923
11471
  };
11472
+ if (projection.reconfirmation)
11473
+ retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
10924
11474
  return {
10925
11475
  outcome: "authorized",
10926
11476
  batch_id: batchId,
@@ -10932,12 +11482,12 @@ async function authorizeBatch(options) {
10932
11482
  }
10933
11483
 
10934
11484
  // plugins/immune-brain/runtime/unattended/batch_runner.ts
10935
- import { spawnSync as spawnSync7 } from "node:child_process";
11485
+ import { spawnSync as spawnSync8 } from "node:child_process";
10936
11486
  import { existsSync as existsSync9 } from "node:fs";
10937
11487
  import { join as join14 } from "node:path";
10938
11488
 
10939
11489
  // plugins/immune-brain/runtime/unattended/batch_git.ts
10940
- import { spawnSync as spawnSync6 } from "node:child_process";
11490
+ import { spawnSync as spawnSync7 } from "node:child_process";
10941
11491
  import { createHash as createHash20, randomUUID as randomUUID8 } from "node:crypto";
10942
11492
  import {
10943
11493
  constants as constants6,
@@ -10962,7 +11512,7 @@ var DEFAULT_GIT_ENV = {
10962
11512
  function runBatchGitPreflight(input) {
10963
11513
  const { root, initiative_slug: initiativeSlug, base_head: baseHead } = input;
10964
11514
  const branch = `imm/${initiativeSlug}`;
10965
- const toplevelResult = spawnSync6("git", ["-C", root, "rev-parse", "--show-toplevel"], {
11515
+ const toplevelResult = spawnSync7("git", ["-C", root, "rev-parse", "--show-toplevel"], {
10966
11516
  encoding: "utf8",
10967
11517
  stdio: ["ignore", "pipe", "pipe"]
10968
11518
  });
@@ -11000,7 +11550,7 @@ function runBatchGitPreflight(input) {
11000
11550
  message: `unsupported index flags (assume-unchanged/skip-worktree) detected: ${flaggedPreflight.join(", ")}`
11001
11551
  };
11002
11552
  }
11003
- const statusResult = spawnSync6("git", ["-C", root, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], {
11553
+ const statusResult = spawnSync7("git", ["-C", root, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], {
11004
11554
  encoding: "utf8",
11005
11555
  stdio: ["ignore", "pipe", "pipe"]
11006
11556
  });
@@ -11018,7 +11568,7 @@ function runBatchGitPreflight(input) {
11018
11568
  message: "working tree is dirty before batch preflight"
11019
11569
  };
11020
11570
  }
11021
- const headResult = spawnSync6("git", ["-C", root, "rev-parse", "--verify", "HEAD^{commit}"], {
11571
+ const headResult = spawnSync7("git", ["-C", root, "rev-parse", "--verify", "HEAD^{commit}"], {
11022
11572
  encoding: "utf8",
11023
11573
  stdio: ["ignore", "pipe", "pipe"]
11024
11574
  });
@@ -11029,11 +11579,11 @@ function runBatchGitPreflight(input) {
11029
11579
  message: "HEAD is uncommitted or not a valid commit"
11030
11580
  };
11031
11581
  }
11032
- const branchCheck = spawnSync6("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: ["ignore", "ignore", "ignore"] });
11582
+ const branchCheck = spawnSync7("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: ["ignore", "ignore", "ignore"] });
11033
11583
  if (branchCheck.status === 0) {
11034
11584
  const settled = findSettledBatchRecord(root, initiativeSlug);
11035
- const currentBranch = spawnSync6("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], { encoding: "utf8" });
11036
- if (findExistingActiveBatch(root, initiativeSlug) === null && settled?.branch === branch && currentBranch.status === 0 && currentBranch.stdout.trim() === branch && headResult.stdout.trim() === baseHead && spawnSync6("git", ["-C", root, "merge-base", "--is-ancestor", expectedBatchHead(settled), baseHead]).status === 0)
11585
+ const currentBranch = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], { encoding: "utf8" });
11586
+ if (findExistingActiveBatch(root, initiativeSlug) === null && settled?.branch === branch && currentBranch.status === 0 && currentBranch.stdout.trim() === branch && headResult.stdout.trim() === baseHead && spawnSync7("git", ["-C", root, "merge-base", "--is-ancestor", expectedBatchHead(settled), baseHead]).status === 0)
11037
11587
  return { ok: true, branch };
11038
11588
  return {
11039
11589
  ok: false,
@@ -11041,25 +11591,25 @@ function runBatchGitPreflight(input) {
11041
11591
  message: `branch refs/heads/${branch} already exists`
11042
11592
  };
11043
11593
  }
11044
- const originalBranchResult = spawnSync6("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11594
+ const originalBranchResult = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11045
11595
  encoding: "utf8",
11046
11596
  stdio: ["ignore", "pipe", "pipe"]
11047
11597
  });
11048
11598
  const originalBranch = originalBranchResult.stdout.trim();
11049
- const checkoutResult = spawnSync6("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", "-b", branch, baseHead], {
11599
+ const checkoutResult = spawnSync7("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", "-b", branch, baseHead], {
11050
11600
  encoding: "utf8",
11051
11601
  stdio: ["ignore", "pipe", "pipe"],
11052
11602
  env: DEFAULT_GIT_ENV
11053
11603
  });
11054
11604
  if (checkoutResult.status !== 0) {
11055
11605
  const stderr = checkoutResult.stderr?.trim() || "";
11056
- const branchExists = stderr.includes("already exists") || spawnSync6("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
11057
- const currentBranchCheck = spawnSync6("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11606
+ const branchExists = stderr.includes("already exists") || spawnSync7("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
11607
+ const currentBranchCheck = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11058
11608
  encoding: "utf8",
11059
11609
  stdio: ["ignore", "pipe", "pipe"]
11060
11610
  }).stdout.trim();
11061
11611
  if (currentBranchCheck === branch && originalBranch && originalBranch !== branch) {
11062
- spawnSync6("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", originalBranch], {
11612
+ spawnSync7("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", originalBranch], {
11063
11613
  stdio: ["ignore", "ignore", "ignore"]
11064
11614
  });
11065
11615
  }
@@ -11082,7 +11632,7 @@ function hasBoundaryWhitespace(path) {
11082
11632
  return path.split("/").some((segment) => segment.trim() !== segment || segment.length === 0);
11083
11633
  }
11084
11634
  function getUnsupportedIndexFlags(root) {
11085
- const result = spawnSync6("git", ["-C", root, "ls-files", "-v", "-z", "--"], {
11635
+ const result = spawnSync7("git", ["-C", root, "ls-files", "-v", "-z", "--"], {
11086
11636
  encoding: "utf8",
11087
11637
  stdio: ["ignore", "pipe", "pipe"]
11088
11638
  });
@@ -11116,13 +11666,13 @@ function isPathAllowedForChild(path, taskId, scopeHint) {
11116
11666
  });
11117
11667
  }
11118
11668
  function getChangedProjectPaths(root) {
11119
- const tracked = spawnSync6("git", ["-C", root, "diff-index", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
11669
+ const tracked = spawnSync7("git", ["-C", root, "diff-index", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
11120
11670
  encoding: "utf8",
11121
11671
  stdio: ["ignore", "pipe", "pipe"]
11122
11672
  });
11123
11673
  if (tracked.status !== 0)
11124
11674
  throw new Error("failed to inspect tracked diff vs HEAD");
11125
- const untracked = spawnSync6("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
11675
+ const untracked = spawnSync7("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
11126
11676
  encoding: "utf8",
11127
11677
  stdio: ["ignore", "pipe", "pipe"]
11128
11678
  });
@@ -11132,7 +11682,7 @@ function getChangedProjectPaths(root) {
11132
11682
  return [...new Set([...splitZ(tracked.stdout), ...splitZ(untracked.stdout)])];
11133
11683
  }
11134
11684
  function getStagedProjectPaths(root) {
11135
- const staged = spawnSync6("git", ["-C", root, "diff-index", "--cached", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
11685
+ const staged = spawnSync7("git", ["-C", root, "diff-index", "--cached", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
11136
11686
  encoding: "utf8",
11137
11687
  stdio: ["ignore", "pipe", "pipe"]
11138
11688
  });
@@ -11141,13 +11691,13 @@ function getStagedProjectPaths(root) {
11141
11691
  return staged.stdout.split("\x00").filter((p) => p.length > 0);
11142
11692
  }
11143
11693
  function getUnstagedProjectPaths(root) {
11144
- const diffFiles = spawnSync6("git", ["-C", root, "diff-files", "--name-only", "-z", "--ignore-submodules=none", "--"], {
11694
+ const diffFiles = spawnSync7("git", ["-C", root, "diff-files", "--name-only", "-z", "--ignore-submodules=none", "--"], {
11145
11695
  encoding: "utf8",
11146
11696
  stdio: ["ignore", "pipe", "pipe"]
11147
11697
  });
11148
11698
  if (diffFiles.status !== 0)
11149
11699
  throw new Error("failed to inspect unstaged tracked changes");
11150
- const untracked = spawnSync6("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
11700
+ const untracked = spawnSync7("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
11151
11701
  encoding: "utf8",
11152
11702
  stdio: ["ignore", "pipe", "pipe"]
11153
11703
  });
@@ -11157,7 +11707,7 @@ function getUnstagedProjectPaths(root) {
11157
11707
  return [...new Set([...splitZ(diffFiles.stdout), ...splitZ(untracked.stdout)])];
11158
11708
  }
11159
11709
  function getCommittedDeltaPaths(root) {
11160
- const delta = spawnSync6("git", ["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", "HEAD~1", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11710
+ const delta = spawnSync7("git", ["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", "HEAD~1", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11161
11711
  if (delta.status !== 0)
11162
11712
  throw new Error("failed to inspect committed tree delta");
11163
11713
  return delta.stdout.split("\x00").filter((p) => p.length > 0);
@@ -11263,7 +11813,7 @@ async function commitBatchChild(input) {
11263
11813
  const goal = typeof intentSnapshot.goal === "string" ? intentSnapshot.goal : "";
11264
11814
  const scopeHint = Array.isArray(intentSnapshot.scope_hint) ? intentSnapshot.scope_hint.filter((s) => typeof s === "string") : [];
11265
11815
  if (expectedBranch !== undefined) {
11266
- const currentBranchResult = spawnSync6("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11816
+ const currentBranchResult = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11267
11817
  encoding: "utf8",
11268
11818
  stdio: ["ignore", "pipe", "pipe"]
11269
11819
  });
@@ -11272,7 +11822,7 @@ async function commitBatchChild(input) {
11272
11822
  throw new Error(`batch_head_lineage_broken: current branch ${currentBranch} does not match expected branch ${expectedBranch}`);
11273
11823
  }
11274
11824
  }
11275
- const currentHeadResult = spawnSync6("git", ["-C", root, "rev-parse", "HEAD"], {
11825
+ const currentHeadResult = spawnSync7("git", ["-C", root, "rev-parse", "HEAD"], {
11276
11826
  encoding: "utf8",
11277
11827
  stdio: ["ignore", "pipe", "pipe"]
11278
11828
  });
@@ -11291,7 +11841,7 @@ async function commitBatchChild(input) {
11291
11841
  }
11292
11842
  const pathsToStage = getUnstagedProjectPaths(root);
11293
11843
  if (pathsToStage.length > 0) {
11294
- const addResult = spawnSync6("git", ["-C", root, "--literal-pathspecs", "add", "-A", "--", ...pathsToStage], {
11844
+ const addResult = spawnSync7("git", ["-C", root, "--literal-pathspecs", "add", "-A", "--", ...pathsToStage], {
11295
11845
  encoding: "utf8",
11296
11846
  stdio: ["ignore", "pipe", "pipe"]
11297
11847
  });
@@ -11306,7 +11856,7 @@ async function commitBatchChild(input) {
11306
11856
  const staged = getStagedProjectPaths(root);
11307
11857
  const stagedOutside = staged.filter((p) => !isPathAllowedForChild(p, taskId, scopeHint));
11308
11858
  if (stagedOutside.length > 0) {
11309
- spawnSync6("git", ["-C", root, "reset", "--quiet"], { stdio: ["ignore", "ignore", "ignore"] });
11859
+ spawnSync7("git", ["-C", root, "reset", "--quiet"], { stdio: ["ignore", "ignore", "ignore"] });
11310
11860
  throw new Error("dirty_outside_scope");
11311
11861
  }
11312
11862
  const record = auditPair.record;
@@ -11324,7 +11874,7 @@ async function commitBatchChild(input) {
11324
11874
 
11325
11875
  Immune-Brain-Batch: ${batchId}
11326
11876
  `;
11327
- const commitResult = spawnSync6("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "commit", "--no-verify", "-F", "-"], {
11877
+ const commitResult = spawnSync7("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "commit", "--no-verify", "-F", "-"], {
11328
11878
  input: commitMessage,
11329
11879
  encoding: "utf8",
11330
11880
  stdio: ["pipe", "pipe", "pipe"],
@@ -11333,7 +11883,7 @@ Immune-Brain-Batch: ${batchId}
11333
11883
  if (commitResult.status !== 0) {
11334
11884
  throw new Error(`commit failed for child ${taskId}: ${commitResult.stderr?.trim() || "git commit failed"}`);
11335
11885
  }
11336
- const newHeadResult = spawnSync6("git", ["-C", root, "rev-parse", "HEAD"], {
11886
+ const newHeadResult = spawnSync7("git", ["-C", root, "rev-parse", "HEAD"], {
11337
11887
  encoding: "utf8",
11338
11888
  stdio: ["ignore", "pipe", "pipe"]
11339
11889
  });
@@ -11341,7 +11891,7 @@ Immune-Brain-Batch: ${batchId}
11341
11891
  if (newHeadResult.status !== 0 || !newHead || newHead === expectedHead) {
11342
11892
  throw new Error("commit_failed");
11343
11893
  }
11344
- const parentsResult = spawnSync6("git", ["-C", root, "rev-parse", "HEAD^@"], {
11894
+ const parentsResult = spawnSync7("git", ["-C", root, "rev-parse", "HEAD^@"], {
11345
11895
  encoding: "utf8",
11346
11896
  stdio: ["ignore", "pipe", "pipe"]
11347
11897
  });
@@ -11374,7 +11924,7 @@ async function lookupBatchCommit(input) {
11374
11924
  let candidates = [];
11375
11925
  let evidenceBacked = false;
11376
11926
  if (evidence && evidence.commit) {
11377
- const direct = spawnSync6("git", ["-C", root, "log", "-n", "1", `--format=${FORMAT}`, evidence.commit, "--"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11927
+ const direct = spawnSync7("git", ["-C", root, "log", "-n", "1", `--format=${FORMAT}`, evidence.commit, "--"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11378
11928
  if (direct.status !== 0) {
11379
11929
  throw new Error(`failed to inspect batch commit for ${taskId}: ${direct.stderr?.trim() || "git log failed"}`);
11380
11930
  }
@@ -11387,7 +11937,7 @@ async function lookupBatchCommit(input) {
11387
11937
  }
11388
11938
  }
11389
11939
  if (!candidates.length) {
11390
- const result = spawnSync6("git", [
11940
+ const result = spawnSync7("git", [
11391
11941
  "-C",
11392
11942
  root,
11393
11943
  "log",
@@ -11424,7 +11974,7 @@ async function lookupBatchCommit(input) {
11424
11974
  throw new Error(`batch_head_lineage_broken: adopted commit author ${match.authorName} does not match batch authority ${DEFAULT_GIT_ENV.GIT_AUTHOR_NAME}`);
11425
11975
  }
11426
11976
  if (expectedBranch !== undefined) {
11427
- const currentBranchResult = spawnSync6("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11977
+ const currentBranchResult = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11428
11978
  encoding: "utf8",
11429
11979
  stdio: ["ignore", "pipe", "pipe"]
11430
11980
  });
@@ -11434,7 +11984,7 @@ async function lookupBatchCommit(input) {
11434
11984
  }
11435
11985
  }
11436
11986
  if (expectedHead !== undefined) {
11437
- const currentHeadResult = spawnSync6("git", ["-C", root, "rev-parse", "HEAD"], {
11987
+ const currentHeadResult = spawnSync7("git", ["-C", root, "rev-parse", "HEAD"], {
11438
11988
  encoding: "utf8",
11439
11989
  stdio: ["ignore", "pipe", "pipe"]
11440
11990
  });
@@ -11442,7 +11992,7 @@ async function lookupBatchCommit(input) {
11442
11992
  if (currentHead !== commit) {
11443
11993
  throw new Error(`batch_head_lineage_broken: current HEAD ${currentHead} diverged from adopted commit ${commit}`);
11444
11994
  }
11445
- const parentsResult = spawnSync6("git", ["-C", root, "rev-parse", `${commit}^@`], {
11995
+ const parentsResult = spawnSync7("git", ["-C", root, "rev-parse", `${commit}^@`], {
11446
11996
  encoding: "utf8",
11447
11997
  stdio: ["ignore", "pipe", "pipe"]
11448
11998
  });
@@ -11460,7 +12010,7 @@ async function lookupBatchCommit(input) {
11460
12010
  throw new Error(`batch_head_lineage_broken: adopted commit task lifecycle is not done: ${lifecycle}`);
11461
12011
  }
11462
12012
  const scopeHint = Array.isArray(auditPair.record.intent_snapshot.scope_hint) ? auditPair.record.intent_snapshot.scope_hint.filter((s) => typeof s === "string") : [];
11463
- const deltaResult = spawnSync6("git", ["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", expectedHead, commit], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
12013
+ const deltaResult = spawnSync7("git", ["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", expectedHead, commit], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11464
12014
  if (deltaResult.status !== 0) {
11465
12015
  throw new Error("batch_head_lineage_broken: failed to inspect adopted commit delta");
11466
12016
  }
@@ -11474,6 +12024,14 @@ async function lookupBatchCommit(input) {
11474
12024
  }
11475
12025
 
11476
12026
  // plugins/immune-brain/runtime/unattended/batch_runner.ts
12027
+ function batchQaFailureFacts(record) {
12028
+ const failed = record.history.filter((event) => event.type === "request_rework" && event.authority?.authority_kind === "qa");
12029
+ return {
12030
+ qa_failure_count: failed.length,
12031
+ last_qa_failure_at: failed.at(-1)?.at ?? null,
12032
+ recovery_findings: record.findings.filter((f) => f.status === "open").map((f) => ({ id: f.id, kind: f.kind, status: f.status, acceptance_id: f.acceptance_id }))
12033
+ };
12034
+ }
11477
12035
  function dependentsOf(record, taskId) {
11478
12036
  return record.children.filter((child) => child.blocked_by.includes(taskId));
11479
12037
  }
@@ -11533,8 +12091,23 @@ function reportFor(record, reason, nextAction) {
11533
12091
  created_at: record.updated_at
11534
12092
  };
11535
12093
  }
11536
- function finalize(root, record, reason, nextAction) {
11537
- const report = reportFor(record, reason, nextAction);
12094
+ function executorHandoff(record, taskId, fresh, failure = {}) {
12095
+ const recovery = deriveAssuranceRecovery(taskId, fresh, failure, fresh.recovery_findings);
12096
+ return {
12097
+ ...reportFor(record, null, recovery?.next_action ?? (fresh.projection.artifact_state === "frozen" ? "The child is frozen with pending QA. Call advance_assurance once; follow its diagnostics before retrying, then submit any reserved Review before batch continuation." : "Route the enrolled child to foreground Executor, implement and stage its scoped work, then call Kernel advance_assurance. Submit any reserved Review verdict before continuing start_unattended_batch with the same Initiative.")),
12098
+ ...recovery ? { recovery } : {},
12099
+ ...failure.diagnostics?.length ? { diagnostics: failure.diagnostics } : {},
12100
+ handoff: recovery?.category === "authorization" ? undefined : {
12101
+ role: "executor",
12102
+ task_id: taskId,
12103
+ run_id: fresh.projection.run_id ?? null,
12104
+ record_revision: fresh.projection.record_revision,
12105
+ next_obligation: fresh.projection.next_obligation
12106
+ }
12107
+ };
12108
+ }
12109
+ function finalize(root, record, reason, nextAction, details = {}) {
12110
+ const report = { ...reportFor(record, reason, nextAction), ...details };
11538
12111
  if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
11539
12112
  writeBatchRunReport(root, report);
11540
12113
  }
@@ -11557,14 +12130,14 @@ function externalHeadDriftMessage(root, record) {
11557
12130
  if (!existsSync9(join14(root, ".git")))
11558
12131
  return null;
11559
12132
  const head = record.commits.length ? record.commits[record.commits.length - 1] : record.base_head;
11560
- const headCheck = spawnSync7("git", ["-C", root, "rev-parse", "HEAD"], {
12133
+ const headCheck = spawnSync8("git", ["-C", root, "rev-parse", "HEAD"], {
11561
12134
  encoding: "utf8",
11562
12135
  stdio: ["ignore", "pipe", "pipe"]
11563
12136
  });
11564
12137
  if (headCheck.status === 0 && headCheck.stdout.trim() && headCheck.stdout.trim() !== head) {
11565
12138
  return `batch_head_lineage_broken: current HEAD ${headCheck.stdout.trim()} does not match expected batch head ${head}`;
11566
12139
  }
11567
- const branchCheck = spawnSync7("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
12140
+ const branchCheck = spawnSync8("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
11568
12141
  encoding: "utf8",
11569
12142
  stdio: ["ignore", "pipe", "pipe"]
11570
12143
  });
@@ -11575,6 +12148,11 @@ function externalHeadDriftMessage(root, record) {
11575
12148
  return null;
11576
12149
  }
11577
12150
  async function validatePersistedRun(input, record) {
12151
+ if (!record.commits.length && record.children[0]?.state === "settled" && existsSync9(join14(input.root, ".git"))) {
12152
+ const head = spawnSync8("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
12153
+ if (head.status !== 0 || head.stdout.trim() !== record.base_head && !ownUnpersistedBatchHead(input.root, record, head.stdout.trim()))
12154
+ throw new Error("first unpersisted batch commit provenance is invalid");
12155
+ }
11578
12156
  const plan = input.registry.children(input.capability);
11579
12157
  if (record.plan_digest !== computeBatchPlanDigest(plan) || record.children.length !== plan.length || record.children.some((child, index) => {
11580
12158
  const expected = plan[index];
@@ -11596,7 +12174,7 @@ async function validatePersistedRun(input, record) {
11596
12174
  });
11597
12175
  if (!evidence || evidence.commit !== child.commit) {
11598
12176
  if (evidence === null && typeof child.commit === "string" && child.commit.length > 0 && existsSync9(join14(input.root, ".git"))) {
11599
- const reach = spawnSync7("git", ["-C", input.root, "merge-base", "--is-ancestor", child.commit, "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
12177
+ const reach = spawnSync8("git", ["-C", input.root, "merge-base", "--is-ancestor", child.commit, "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
11600
12178
  if (reach.status !== 0) {
11601
12179
  throw new Error(`batch_head_lineage_broken: recorded commit ${child.commit} for ${child.task_id} is no longer reachable from HEAD`);
11602
12180
  }
@@ -11634,6 +12212,41 @@ function validateRunAuthorization(input, existing) {
11634
12212
  });
11635
12213
  if (computeBatchPlanDigest(children) !== authorized.plan_digest || input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
11636
12214
  throw new Error("batch run input does not match the authorized plan or base_head");
12215
+ return authorized;
12216
+ }
12217
+ function applyPlanReconfirmation(input, existing) {
12218
+ if (existing.plan_digest === input.plan_digest)
12219
+ return existing;
12220
+ const next = {
12221
+ ...existing,
12222
+ plan_digest: input.plan_digest,
12223
+ confirmation_time: input.confirmation_time,
12224
+ authorization_expires_at: input.authorization_expires_at,
12225
+ budget: input.budget
12226
+ };
12227
+ if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head || input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
12228
+ throw new Error("plan_digest mismatch: reconfirmation binding changed");
12229
+ const validate = () => {
12230
+ const plan = input.registry.children(input.capability);
12231
+ if (existing.children.length !== plan.length || plan.some((child, index) => {
12232
+ const old = existing.children[index], nextChild = input.children[index];
12233
+ return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id || JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
12234
+ }))
12235
+ throw new Error("plan_digest mismatch: reconfirmation topology changed");
12236
+ const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
12237
+ if (binding.branch !== existing.branch)
12238
+ throw new Error("plan_digest mismatch: reconfirmation branch changed");
12239
+ prepareBatchRunState(input);
12240
+ return input.registry.inspect(input.capability, binding);
12241
+ };
12242
+ const authority = validate();
12243
+ const captured = takeReconfirmation(authority.nonce);
12244
+ if (captured.root !== input.root || captured.plan_digest !== input.plan_digest)
12245
+ throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
12246
+ return replaceBatchRunState(input.root, captured.stateBytes, next, () => {
12247
+ captured.assertUnchanged();
12248
+ validate();
12249
+ });
11637
12250
  }
11638
12251
  async function startBatch(input) {
11639
12252
  try {
@@ -11664,7 +12277,9 @@ async function startBatchLocked(input) {
11664
12277
  return reportFor({ ...rejected, batch_state: "rejected" }, "batch plan is empty", "Provide a non-empty enrollable child plan.");
11665
12278
  }
11666
12279
  let existing = readBatchRunState(input.root, input.batch_id);
12280
+ const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
11667
12281
  if (existing) {
12282
+ existing = applyPlanReconfirmation(input, existing);
11668
12283
  try {
11669
12284
  await validatePersistedRun(input, existing);
11670
12285
  } catch (error) {
@@ -11686,10 +12301,11 @@ async function startBatchLocked(input) {
11686
12301
  const nextConfirmation = Date.parse(input.confirmation_time);
11687
12302
  const nextExpiry = Date.parse(input.authorization_expires_at);
11688
12303
  const freshAuthorization = Number.isFinite(nextConfirmation) && nextConfirmation > priorConfirmation && Number.isFinite(nextExpiry) && nextExpiry > Date.now();
11689
- if (!freshAuthorization) {
12304
+ if (!freshAuthorization && !reconfirmed) {
11690
12305
  return finalize(input.root, existing, "the parked batch requires a fresh literal-user confirmation", "Resolve the parked child, then re-confirm the batch to continue.");
11691
12306
  }
11692
- validateRunAuthorization(input, existing);
12307
+ if (!reconfirmed)
12308
+ validateRunAuthorization(input, existing);
11693
12309
  prepareBatchRunState({ ...input, now: input.now });
11694
12310
  const remapped = await Promise.all(existing.children.map(async (child) => {
11695
12311
  if (child.state !== "needs_human")
@@ -11724,7 +12340,6 @@ async function startBatchLocked(input) {
11724
12340
  authorization_expires_at: input.authorization_expires_at,
11725
12341
  budget: input.budget,
11726
12342
  batch_state: "running",
11727
- consecutive_qa_failures: 0,
11728
12343
  children: resumedChildren
11729
12344
  });
11730
12345
  }
@@ -11882,67 +12497,8 @@ async function startBatchLocked(input) {
11882
12497
  break;
11883
12498
  }
11884
12499
  }
11885
- let childTerminal = false;
11886
- while (!childTerminal) {
11887
- const terminal = await input.kernel.advanceTask(input.root, child.task_id);
11888
- if (terminal.state === "completed") {
11889
- childTerminal = true;
11890
- record.consecutive_qa_failures = 0;
11891
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c);
11892
- persist();
11893
- try {
11894
- const planChild = input.children.find((c) => c.task_id === child.task_id);
11895
- const intentPath = planChild?.intent_path ?? undefined;
11896
- const { commit } = input.git?.commitChild ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath) : input.kernel.commitChild ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath) : await commitBatchChild({
11897
- root: input.root,
11898
- taskId: child.task_id,
11899
- batchId: input.batch_id,
11900
- expectedHead: head,
11901
- branch: record.branch,
11902
- intentPath
11903
- });
11904
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "committed", commit } : c);
11905
- record.commits.push(commit);
11906
- head = commit;
11907
- persist();
11908
- } catch (error) {
11909
- const message = error instanceof Error ? error.message : String(error);
11910
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c);
11911
- skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
11912
- record.batch_state = "failed";
11913
- persist();
11914
- break;
11915
- }
11916
- } else if (terminal.state === "stopped") {
11917
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason: "Kernel reported the child stopped" } : c);
11918
- childTerminal = true;
11919
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
11920
- record.batch_state = "needs_human";
11921
- persist();
11922
- } else if (terminal.state === "rework" && terminal.operation === "qa") {
11923
- record.consecutive_qa_failures += 1;
11924
- if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
11925
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason: `QA failure limit reached: ${terminal.summary}` } : c);
11926
- record.batch_state = "needs_human";
11927
- childTerminal = true;
11928
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
11929
- persist();
11930
- } else {
11931
- persist();
11932
- }
11933
- } else if (terminal.state === "failed" || terminal.state === "blocked" || terminal.state === "rework") {
11934
- const reason = terminal.state === "rework" ? terminal.summary : terminal.reason;
11935
- childTerminal = true;
11936
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c);
11937
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
11938
- record.batch_state = "needs_human";
11939
- persist();
11940
- } else if (terminal.state === "review_ready") {
11941
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "enrolled", reason: `review reservation ${terminal.operation_id} open` } : c);
11942
- persist();
11943
- return reportFor(record, `child ${child.task_id} holds an open Review reservation`, "Submit the reserved foreground Review verdict, then call startBatch again to continue.");
11944
- }
11945
- }
12500
+ const enrolled = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
12501
+ return executorHandoff(record, child.task_id, enrolled);
11946
12502
  }
11947
12503
  return finalize(input.root, record, stopReasonFor(record), "");
11948
12504
  }
@@ -11964,8 +12520,9 @@ function stopReasonFor(record) {
11964
12520
  }
11965
12521
  async function resumeBatch(input, projection) {
11966
12522
  let existing = readBatchRunState(input.root, input.batch_id);
11967
- if (!existing)
12523
+ if (!existing || existing.batch_state === "needs_human")
11968
12524
  return startBatch(input);
12525
+ existing = applyPlanReconfirmation(input, existing);
11969
12526
  try {
11970
12527
  await validatePersistedRun(input, existing);
11971
12528
  } catch (error) {
@@ -11980,8 +12537,6 @@ async function resumeBatch(input, projection) {
11980
12537
  }
11981
12538
  if (isTerminalBatchState(existing.batch_state))
11982
12539
  return replayTerminal(input.root, existing);
11983
- if (existing.batch_state === "needs_human")
11984
- return startBatch(input);
11985
12540
  const driven = existing.children.find((child) => child.state === "enrolled" || child.state === "settled");
11986
12541
  if (driven) {
11987
12542
  if (driven.state === "enrolled") {
@@ -12001,11 +12556,30 @@ async function resumeBatch(input, projection) {
12001
12556
  });
12002
12557
  return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
12003
12558
  }
12559
+ if (holdsClaim) {
12560
+ const failures = Math.max(existing.consecutive_qa_failures, fresh.qa_failure_count ?? 0);
12561
+ if (failures >= existing.budget.qa_failure_limit && fresh.last_qa_failure_at && Date.parse(fresh.last_qa_failure_at) > Date.parse(existing.confirmation_time)) {
12562
+ const reason = "QA failure limit reached; repair and settle the own child through Kernel before resuming the batch";
12563
+ skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
12564
+ existing = writeBatchRunState(input.root, {
12565
+ ...existing,
12566
+ batch_state: "needs_human",
12567
+ consecutive_qa_failures: failures,
12568
+ children: existing.children.map((c) => c.task_id === driven.task_id ? { ...c, state: "needs_human", reason } : c)
12569
+ });
12570
+ return finalize(input.root, existing, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.", { recovery: deriveAssuranceRecovery(driven.task_id, fresh, {}, fresh.recovery_findings) ?? undefined });
12571
+ }
12572
+ if (failures !== existing.consecutive_qa_failures)
12573
+ existing = writeBatchRunState(input.root, { ...existing, consecutive_qa_failures: failures });
12574
+ if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen")
12575
+ return executorHandoff(existing, driven.task_id, fresh);
12576
+ }
12004
12577
  if (!holdsClaim) {
12005
12578
  const settledRemotely = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready === true;
12006
12579
  if (settledRemotely) {
12007
12580
  existing = writeBatchRunState(input.root, {
12008
12581
  ...existing,
12582
+ consecutive_qa_failures: 0,
12009
12583
  children: existing.children.map((c) => c.task_id === driven.task_id ? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" } : c)
12010
12584
  });
12011
12585
  } else {
@@ -12087,6 +12661,8 @@ async function driveInterruptedChild(input, child) {
12087
12661
  intentPath
12088
12662
  });
12089
12663
  };
12664
+ if (existing && !record.commits.length && existsSync9(join14(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
12665
+ throw new Error("first unpersisted batch commit provenance is invalid");
12090
12666
  const adopted = existing ?? await doCommit().catch((error) => {
12091
12667
  const message = error instanceof Error ? error.message : String(error);
12092
12668
  record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c);
@@ -12108,30 +12684,8 @@ async function driveInterruptedChild(input, child) {
12108
12684
  record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c);
12109
12685
  record.consecutive_qa_failures = 0;
12110
12686
  persist();
12111
- try {
12112
- const planChild = input.children.find((c) => c.task_id === child.task_id);
12113
- const intentPath = planChild?.intent_path ?? undefined;
12114
- const { commit } = input.git?.commitChild ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath) : input.kernel.commitChild ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath) : await commitBatchChild({
12115
- root: input.root,
12116
- taskId: child.task_id,
12117
- batchId: input.batch_id,
12118
- expectedHead: head,
12119
- branch: record.branch,
12120
- intentPath
12121
- });
12122
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "committed", commit } : c);
12123
- record.commits.push(commit);
12124
- head = commit;
12125
- persist();
12126
- child = { ...child, state: "committed", commit };
12127
- } catch (error) {
12128
- const message = error instanceof Error ? error.message : String(error);
12129
- record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c);
12130
- skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
12131
- record.batch_state = "failed";
12132
- persist();
12133
- return finalize(input.root, record, message, "");
12134
- }
12687
+ child = { ...child, state: "settled", reason: null };
12688
+ continue;
12135
12689
  } else if (terminal.state === "review_ready") {
12136
12690
  record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, reason: `review reservation ${terminal.operation_id} open` } : c);
12137
12691
  persist();
@@ -12139,16 +12693,22 @@ async function driveInterruptedChild(input, child) {
12139
12693
  } else if (terminal.state === "rework" && terminal.operation === "qa") {
12140
12694
  record.consecutive_qa_failures += 1;
12141
12695
  if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
12142
- const reason = `QA failure limit reached: ${terminal.summary}`;
12696
+ const reason = "QA failure limit reached";
12143
12697
  record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c);
12144
12698
  record.batch_state = "needs_human";
12145
12699
  skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
12146
12700
  persist();
12147
- return finalize(input.root, record, reason, "");
12701
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
12702
+ return finalize(input.root, record, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.", { recovery: deriveAssuranceRecovery(child.task_id, fresh, terminal, fresh.recovery_findings) ?? undefined, diagnostics: terminal.diagnostics });
12148
12703
  }
12149
12704
  persist();
12705
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
12706
+ return executorHandoff(record, child.task_id, fresh, terminal);
12707
+ } else if (terminal.state === "rework" || terminal.environment_failure || terminal.state === "review_preparation_failed" || terminal.recovery?.category === "repair") {
12708
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
12709
+ return executorHandoff(record, child.task_id, fresh, terminal);
12150
12710
  } else {
12151
- const reason = terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.state === "rework" ? terminal.summary : terminal.reason;
12711
+ const reason = terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.reason;
12152
12712
  record.children = record.children.map((c) => c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c);
12153
12713
  record.batch_state = "needs_human";
12154
12714
  skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
@@ -12855,7 +13415,7 @@ class ClaudeRuntime {
12855
13415
  throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
12856
13416
  if (!this.requestConfirmation)
12857
13417
  throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
12858
- const now = new Date().toISOString();
13418
+ let now = new Date().toISOString();
12859
13419
  const preflight = await projectBatchPreflight({
12860
13420
  root: this.cwd,
12861
13421
  initiative_slug: initiativeSlug,
@@ -12969,6 +13529,9 @@ class ClaudeRuntime {
12969
13529
  const { binding, batch_id: batchId, expires_at: expiresAt } = authorization;
12970
13530
  if (meta.signal?.aborted)
12971
13531
  return batchReason("cancelled_before_execution");
13532
+ now = new Date().toISOString();
13533
+ if (Date.parse(expiresAt) <= Date.parse(now))
13534
+ return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
12972
13535
  const capability = this.batchRegistry.issue(binding, recoveryChildren, now);
12973
13536
  const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
12974
13537
  const kernelPort = {
@@ -13041,19 +13604,30 @@ class ClaudeRuntime {
13041
13604
  },
13042
13605
  advanceTask: async (root, taskId) => {
13043
13606
  const result = await this.coordinator.advance(taskId, { cwd: root });
13607
+ const facts = { diagnostics: result.diagnostics, environment_failure: result.environment_failure, recovery: result.recovery };
13044
13608
  if (result.state === "completed")
13045
- return { state: "completed" };
13609
+ return { state: "completed", ...facts };
13046
13610
  if (result.state === "stopped")
13047
- return { state: "stopped" };
13611
+ return { state: "stopped", ...facts };
13048
13612
  if (result.state === "rework")
13049
- return { state: "rework", operation: result.operation, summary: result.summary };
13613
+ return { state: "rework", operation: result.operation, summary: result.summary, ...facts };
13050
13614
  if (result.state === "review_ready")
13051
- return { state: "review_ready", operation_id: result.operation_id };
13615
+ return { state: "review_ready", operation_id: result.operation_id, ...facts };
13616
+ if (result.state === "review_preparation_failed")
13617
+ return { state: result.state, operation: result.operation, operation_id: result.operation_id, reason: result.reason, ...facts };
13052
13618
  if (result.state === "blocked")
13053
- return { state: "blocked", reason: result.reason };
13054
- return { state: "failed", reason: result.reason ?? "advance failed" };
13619
+ return { state: "blocked", reason: result.reason, ...facts };
13620
+ return { state: "failed", reason: result.reason ?? "advance failed", ...facts };
13621
+ },
13622
+ projectTask: async (root, taskId) => {
13623
+ const fresh = await projectAssurance(root, taskId, diffSnapshotOf);
13624
+ if (!fresh.claim || fresh.projection.lifecycle !== "active" || fresh.error !== null)
13625
+ return fresh;
13626
+ const { record, revision } = readTaskRecordRaw(root, taskId);
13627
+ if (!record || revision !== fresh.projection.record_revision)
13628
+ throw new Error(`Kernel recovery projection changed for ${taskId}`);
13629
+ return { ...fresh, ...batchQaFailureFacts(record) };
13055
13630
  },
13056
- projectTask: async (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
13057
13631
  ownsTaskClaim: (taskId) => {
13058
13632
  return registry.isChildConsumed(capability, taskId);
13059
13633
  },