immune-brain 3.6.9 → 4.0.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 (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +34 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Plan parsing, normalization, signatures, and validation.
2
+ * Plan parsing, normalization, and validation.
3
3
  */
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { resolve, relative, sep } from "node:path";
6
- import { createHash } from "node:crypto";
7
6
  import process from "node:process";
8
- import { stableStringify } from "./canonical_json";
9
7
 
10
8
  // ── errors ───────────────────────────────────────────────────────────
11
9
 
@@ -48,18 +46,18 @@ const ROADMAP_SLICE_REQUIRED_FIELDS: Array<[key: string, label: string]> = [
48
46
 
49
47
  // ── plan parsing helpers ─────────────────────────────────────────────
50
48
 
51
- export function hasPlaceholder(value: string): boolean {
49
+ function hasPlaceholder(value: string): boolean {
52
50
  const text = value || "";
53
51
  return Boolean(
54
52
  PLACEHOLDER_RE.test(text) || text.includes("<") || text.includes(">"),
55
53
  );
56
54
  }
57
55
 
58
- export function isActionStepResult(value: string): boolean {
56
+ function isActionStepResult(value: string): boolean {
59
57
  return ACTION_RESULT_RE.test((value || "").trim());
60
58
  }
61
59
 
62
- export function parseDependsOn(rawValue: string): number[] {
60
+ function parseDependsOn(rawValue: string): number[] {
63
61
  const value = rawValue.trim();
64
62
  if (value.toLowerCase() === "none") return [];
65
63
  const deps: number[] = [];
@@ -81,7 +79,7 @@ export function parseDependsOn(rawValue: string): number[] {
81
79
  return deps;
82
80
  }
83
81
 
84
- export function parseStepScope(rawValue: string): string[] {
82
+ function parseStepScope(rawValue: string): string[] {
85
83
  const codeSpans = [...rawValue.matchAll(/`([^`\n]+)`/g)].map((match) =>
86
84
  match[1].trim(),
87
85
  );
@@ -110,7 +108,7 @@ export function parseStepScope(rawValue: string): string[] {
110
108
  return paths;
111
109
  }
112
110
 
113
- export function parseBrainstormManifestItems(rawValue: string): string[] {
111
+ function parseBrainstormManifestItems(rawValue: string): string[] {
114
112
  return rawValue
115
113
  ? rawValue
116
114
  .split(/[;,]/)
@@ -119,7 +117,7 @@ export function parseBrainstormManifestItems(rawValue: string): string[] {
119
117
  : [];
120
118
  }
121
119
 
122
- export function parseBrainstormTraceRow(
120
+ function parseBrainstormTraceRow(
123
121
  stripped: string,
124
122
  ): Record<string, string> | null {
125
123
  if (!stripped.startsWith("|") || !stripped.endsWith("|")) return null;
@@ -137,7 +135,7 @@ export function parseBrainstormTraceRow(
137
135
  return { item, status, target, reason };
138
136
  }
139
137
 
140
- export function parseDiscoveryCache(
138
+ function parseDiscoveryCache(
141
139
  rawValue: string,
142
140
  ): Array<{ path: string; reason: string }> {
143
141
  const value = (rawValue || "").trim();
@@ -157,7 +155,7 @@ export function parseDiscoveryCache(
157
155
  return entries;
158
156
  }
159
157
 
160
- export function validateParallelProbeShape(
158
+ function validateParallelProbeShape(
161
159
  probe: Record<string, unknown>,
162
160
  ): Record<string, unknown> {
163
161
  if (typeof probe !== "object" || probe === null) {
@@ -179,7 +177,7 @@ export function validateParallelProbeShape(
179
177
  return probe;
180
178
  }
181
179
 
182
- export function parseParallelProbeEntry(
180
+ function parseParallelProbeEntry(
183
181
  rawEntry: string,
184
182
  ): Record<string, unknown> {
185
183
  const probe: Record<string, unknown> = {};
@@ -205,7 +203,7 @@ export function parseParallelProbeEntry(
205
203
  return validateParallelProbeShape(probe);
206
204
  }
207
205
 
208
- export function parseParallelProbes(
206
+ function parseParallelProbes(
209
207
  rawValue: string,
210
208
  ): Array<Record<string, unknown>> {
211
209
  const value = (rawValue || "").trim();
@@ -237,7 +235,7 @@ function parseSpecReference(rawValue: string): string {
237
235
  return (rawValue || "").trim().replace(/^`|`$/g, "");
238
236
  }
239
237
 
240
- export interface ParsedSpecDesign {
238
+ interface ParsedSpecDesign {
241
239
  path: string;
242
240
  exists: boolean;
243
241
  design_risk: string | null;
@@ -340,7 +338,7 @@ function parseReferencedSpec(
340
338
 
341
339
  // ── plan parsing ─────────────────────────────────────────────────────
342
340
 
343
- export interface ParsedStep {
341
+ interface ParsedStep {
344
342
  number: number;
345
343
  result: string | null;
346
344
  verification: string | null;
@@ -353,7 +351,7 @@ export interface ParsedStep {
353
351
  agent_hint: string | null;
354
352
  }
355
353
 
356
- export interface ParsedRoadmapPhase {
354
+ interface ParsedRoadmapPhase {
357
355
  title: string;
358
356
  acceptance_criteria_present: boolean;
359
357
  acceptance_criteria: string[];
@@ -361,14 +359,14 @@ export interface ParsedRoadmapPhase {
361
359
  promotion_criteria: string[];
362
360
  }
363
361
 
364
- export interface PlanValidationWarning {
362
+ interface PlanValidationWarning {
365
363
  code: string;
366
364
  message: string;
367
365
  phase?: string;
368
366
  field?: string;
369
367
  }
370
368
 
371
- export interface ParsedPlan {
369
+ interface ParsedPlan {
372
370
  path: string;
373
371
  summary: string | null;
374
372
  task: Record<string, string>;
@@ -378,7 +376,7 @@ export interface ParsedPlan {
378
376
  steps: ParsedStep[];
379
377
  }
380
378
 
381
- export function parsePlan(path: string): ParsedPlan {
379
+ function parsePlan(path: string): ParsedPlan {
382
380
  const lines = readFileSync(path, "utf-8").split("\n");
383
381
  let summary: string | null = null;
384
382
  const steps: ParsedStep[] = [];
@@ -533,9 +531,7 @@ export function parsePlan(path: string): ParsedPlan {
533
531
  };
534
532
  }
535
533
 
536
- // ── plan signature ───────────────────────────────────────────────────
537
-
538
- export interface NormalizedStep {
534
+ interface NormalizedStep {
539
535
  number: number;
540
536
  step_id: string;
541
537
  result: string;
@@ -548,17 +544,16 @@ export interface NormalizedStep {
548
544
  test_scenarios?: string[] | null;
549
545
  }
550
546
 
551
- export interface NormalizedPlan {
547
+ interface NormalizedPlan {
552
548
  plan_path: string;
553
549
  summary: string;
554
550
  task: Record<string, string>;
555
551
  steps: NormalizedStep[];
556
552
  }
557
553
 
558
- export type WorkflowProfile = "standard" | "strict";
559
- export type CompounderPolicy = "optional" | "required";
554
+ type WorkflowProfile = "standard" | "strict";
560
555
 
561
- export function workflowProfileForTask(
556
+ function workflowProfileForTask(
562
557
  task: Record<string, string> | null | undefined,
563
558
  ): WorkflowProfile {
564
559
  return task?.workflow_profile?.trim().toLowerCase() === "standard"
@@ -566,40 +561,7 @@ export function workflowProfileForTask(
566
561
  : "strict";
567
562
  }
568
563
 
569
- export function compounderPolicyForTask(
570
- task: Record<string, string> | null | undefined,
571
- ): CompounderPolicy {
572
- if (task?.compounder?.trim().toLowerCase() === "optional") return "optional";
573
- return "required";
574
- }
575
-
576
- export function buildSignaturePayload(normalizedPlan: NormalizedPlan): string {
577
- // Keep cross-runtime parity: the legacy Plan signature predates Scope.
578
- const payload = {
579
- summary: normalizedPlan.summary,
580
- task: normalizedPlan.task,
581
- steps: normalizedPlan.steps.map((step) => ({
582
- number: step.number,
583
- step_id: step.step_id,
584
- result: step.result,
585
- verification: step.verification,
586
- test_scenarios: step.test_scenarios,
587
- discovery_cache: step.discovery_cache,
588
- parallel_probes: step.parallel_probes,
589
- depends_on: step.depends_on,
590
- agent_hint: step.agent_hint,
591
- })),
592
- };
593
- return stableStringify(payload);
594
- }
595
-
596
- export function buildPlanSignature(normalizedPlan: NormalizedPlan): string {
597
- return createHash("sha256")
598
- .update(buildSignaturePayload(normalizedPlan))
599
- .digest("hex");
600
- }
601
-
602
- export function normalizePlan(
564
+ function normalizePlan(
603
565
  plan: ParsedPlan,
604
566
  projectRoot?: string,
605
567
  ): NormalizedPlan {
@@ -623,7 +585,7 @@ export function normalizePlan(
623
585
  };
624
586
  }
625
587
 
626
- export function normalizePlanPath(
588
+ function normalizePlanPath(
627
589
  planPath: string,
628
590
  projectRoot?: string,
629
591
  ): string {
@@ -845,7 +807,7 @@ function validatePlanContract(task: Record<string, string>): string[] {
845
807
  return errors;
846
808
  }
847
809
 
848
- export function validatePlan(plan: ParsedPlan): {
810
+ function validatePlan(plan: ParsedPlan): {
849
811
  errors: string[];
850
812
  warnings: PlanValidationWarning[];
851
813
  } {
@@ -954,7 +916,7 @@ export function validatePlan(plan: ParsedPlan): {
954
916
  return { errors, warnings };
955
917
  }
956
918
 
957
- export interface OriginCoverageProjection {
919
+ interface OriginCoverageProjection {
958
920
  applicable: boolean;
959
921
  declared_items: number;
960
922
  mapped_items: number;
@@ -964,7 +926,7 @@ export interface OriginCoverageProjection {
964
926
  complete: boolean;
965
927
  }
966
928
 
967
- export interface PlanValidationProjection extends NormalizedPlan {
929
+ interface PlanValidationProjection extends NormalizedPlan {
968
930
  warnings: PlanValidationWarning[];
969
931
  origin_coverage: OriginCoverageProjection;
970
932
  }
@@ -975,7 +937,7 @@ const REASON_REQUIRED_STATUSES = new Set([
975
937
  "deferred",
976
938
  ]);
977
939
 
978
- export function deriveOriginCoverage(
940
+ function deriveOriginCoverage(
979
941
  plan: ParsedPlan,
980
942
  ): OriginCoverageProjection {
981
943
  const declared = [
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.6.9" as const;
2
+ export const PLUGIN_VERSION = "4.0.0" as const;
@@ -38,6 +38,8 @@ boundary name). Do not invent fields, and never send an anchor yourself: the
38
38
  Kernel derives it as the sha256 of the canonical `{violated.kind,
39
39
  violated.ref, caller_chain}`, so an identical claim keeps one stable identity
40
40
  across review rounds while a different call chain is a different claim. A
41
- passing review has no findings. If the
41
+ passing review carries no blocking findings; non-blocking notes may ride along as
42
+ `kind: "advisory"` findings, and every finding, advisory included, needs the
43
+ same machine-checkable evidence. If the
42
44
  checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only
43
45
  a literal user may invoke `--approve-successor`.
@@ -1,10 +1,10 @@
1
1
  # Internal role: executor
2
2
 
3
3
  You are the Immune-Brain Executor role inside Loop. Implement exactly the
4
- enrolled TaskIntent acceptance and `scope_hint` (or one accepted
5
- same-boundary follow-up) in the current Parent conversation. Use workspace
6
- tools only for the supplied target and keep every edit inside the
7
- authoritative Scope. Do not discover or load a Pi Skill.
4
+ enrolled TaskIntent acceptance and `scope_hint` envelope (or one accepted
5
+ same-boundary follow-up) in the current Parent conversation. New helpers or
6
+ tests inside an approved directory or glob do not require a revision. Keep
7
+ every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
8
8
 
9
9
  Before handoff, run the permitted diagnostic checks and return commands and
10
10
  outcomes to the Parent as structured diagnostic evidence. The read-only Loop
@@ -0,0 +1,58 @@
1
+ // Staging a TaskIntent sidecar is one operation on both Hosts: capture the
2
+ // sidecar bytes together with its Git index entry, and restore both. Claude used
3
+ // to stop at `git update-index`; Pi re-read the bytes and the index and failed
4
+ // closed on any mismatch. The stricter branch is now the only one, so a restore
5
+ // that silently left either side inconsistent cannot pass as success.
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ export interface StagedIntentSnapshot {
12
+ /** Repository-relative path of the staged sidecar. */
13
+ path: string;
14
+ bytes: Buffer;
15
+ index_state: Buffer;
16
+ }
17
+
18
+ /** Capture the sidecar bytes and the exact index entry the restore must reproduce. */
19
+ export function captureStagedIntent(root: string, relativePath: string): StagedIntentSnapshot {
20
+ return {
21
+ path: relativePath,
22
+ bytes: readFileSync(join(root, relativePath)),
23
+ index_state: execFileSync("git", ["ls-files", "--stage", "-z", "--", relativePath], {
24
+ cwd: root,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ }),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Restore the sidecar and its index entry, then verify both. A restore that
32
+ * leaves either the bytes or the index different from the snapshot throws.
33
+ */
34
+ export function restoreStagedIntent(root: string, snapshot: StagedIntentSnapshot): void {
35
+ writeFileSync(join(root, snapshot.path), snapshot.bytes);
36
+ execFileSync("git", ["update-index", "--force-remove", "--", snapshot.path], {
37
+ cwd: root,
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ });
40
+ if (snapshot.index_state.length > 0) {
41
+ execFileSync("git", ["update-index", "-z", "--index-info"], {
42
+ cwd: root,
43
+ input: snapshot.index_state,
44
+ stdio: ["pipe", "ignore", "pipe"],
45
+ });
46
+ }
47
+ const restoredBytes = readFileSync(join(root, snapshot.path));
48
+ if (!restoredBytes.equals(snapshot.bytes)) {
49
+ throw new Error("failed to restore prior intent bytes");
50
+ }
51
+ const restoredIndex = execFileSync("git", ["ls-files", "--stage", "-z", "--", snapshot.path], {
52
+ cwd: root,
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ });
55
+ if (!restoredIndex.equals(snapshot.index_state)) {
56
+ throw new Error("failed to restore prior intent git index entry");
57
+ }
58
+ }
@@ -3,7 +3,7 @@
3
3
  // Invariant: The runtime never creates, switches, or deletes a Git worktree,
4
4
  // never pushes a ref, and never opens or updates a pull request.
5
5
  import { spawnSync } from "node:child_process";
6
- import { randomUUID } from "node:crypto";
6
+ import { createHash, randomUUID } from "node:crypto";
7
7
  import {
8
8
  constants,
9
9
  closeSync,
@@ -11,15 +11,15 @@ import {
11
11
  lstatSync,
12
12
  mkdirSync,
13
13
  openSync,
14
- readFileSync,
15
14
  realpathSync,
16
15
  renameSync,
17
16
  rmSync,
18
17
  writeFileSync,
19
18
  } from "node:fs";
20
19
  import { dirname, join } from "node:path";
21
- import { readAuditTaskPair, readSecureProjectFile } from "../kernel/storage";
22
- import { pathMatchesScope } from "../workspace_scope";
20
+ import { localRunId, readAuditTaskPair, readSecureProjectFile } from "../kernel/storage";
21
+ import { captureGitTaskRevisionSnapshot, pathMatchesScope } from "../workspace_scope";
22
+ import { expectedBatchHead, findExistingActiveBatch, findSettledBatchRecord } from "./batch_preflight";
23
23
 
24
24
  export type BatchGitPreflightRejectReason =
25
25
  | "batch_branch_exists"
@@ -66,7 +66,8 @@ const DEFAULT_GIT_ENV = {
66
66
 
67
67
  /**
68
68
  * Preflight before any child is enrolled: requires clean working tree,
69
- * committed HEAD, and absence of refs/heads/imm/<initiative-slug>.
69
+ * committed HEAD, and an absent batch branch or the current branch of a settled
70
+ * run whose commit lineage is preserved.
70
71
  * Verifies root is the top-level repository root (Finding 5).
71
72
  * Explicitly passes --untracked-files=all so repo config cannot bypass dirty check (Finding 2).
72
73
  * Disables Git hooks during checkout (Finding 2).
@@ -163,6 +164,17 @@ export function runBatchGitPreflight(input: {
163
164
  { stdio: ["ignore", "ignore", "ignore"] },
164
165
  );
165
166
  if (branchCheck.status === 0) {
167
+ const settled = findSettledBatchRecord(root, initiativeSlug);
168
+ const currentBranch = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], { encoding: "utf8" });
169
+ // Reuse only this initiative's current, settled branch. Never move or reset
170
+ // a ref; the new authorization binds the current HEAD, including newly
171
+ // committed candidate intents, and the old run's evidence stays intact.
172
+ if (
173
+ findExistingActiveBatch(root, initiativeSlug) === null &&
174
+ settled?.branch === branch && currentBranch.status === 0 && currentBranch.stdout.trim() === branch &&
175
+ headResult.stdout.trim() === baseHead &&
176
+ spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", expectedBatchHead(settled), baseHead]).status === 0
177
+ ) return { ok: true, branch };
166
178
  return {
167
179
  ok: false,
168
180
  reason: "batch_branch_exists",
@@ -442,7 +454,11 @@ export async function commitBatchChild(input: {
442
454
  const { root, taskId, batchId, expectedHead, branch: expectedBranch } = input;
443
455
 
444
456
  // 1. Verify Kernel reports that child done via immutable terminal audit pair (Finding 2)
445
- const auditPair = readAuditTaskPair(root, taskId);
457
+ // The committed local run owns this task's evidence: another worktree's run
458
+ // directory for the same task is not this batch's audit pair. Settlement
459
+ // clears the active run, so the lookup includes terminal runs.
460
+ const localRun = localRunId(root, taskId);
461
+ const auditPair = readAuditTaskPair(root, taskId, localRun ?? undefined);
446
462
  if (!auditPair) {
447
463
  throw new Error(`cannot commit child ${taskId}: task is not settled done (audit pair missing)`);
448
464
  }
@@ -532,6 +548,19 @@ export async function commitBatchChild(input: {
532
548
  spawnSync("git", ["-C", root, "reset", "--quiet"], { stdio: ["ignore", "ignore", "ignore"] });
533
549
  throw new Error("dirty_outside_scope");
534
550
  }
551
+ const record = auditPair.record as {
552
+ contract?: string;
553
+ git_base_head?: string;
554
+ attestations?: Array<{ kind: string; diff_hash: string }>;
555
+ };
556
+ const qa = [...(record.attestations ?? [])].reverse().find((item) => item.kind === "qa");
557
+ if (record.contract === "assurance_kernel/task_record/v4" && qa?.diff_hash) {
558
+ if (!record.git_base_head) throw new Error("batch commit requires a TaskRecord v4 git_base_head");
559
+ const captured = captureGitTaskRevisionSnapshot(root, scopeHint, record.git_base_head, taskId);
560
+ const digest = `sha256:${createHash("sha256").update(JSON.stringify(captured)).digest("hex")}`;
561
+ if (digest !== qa.diff_hash)
562
+ throw new Error("batch commit drifted from the reviewed delivery identity");
563
+ }
535
564
 
536
565
  // 6. Commit with structured message, Immune-Brain-Batch trailer, and all hooks disabled (Finding 1)
537
566
  const goalFirstLine = goal.trim().split(/\r?\n/)[0]?.trim() || taskId;
@@ -730,7 +759,8 @@ export async function lookupBatchCommit(input: {
730
759
  }
731
760
 
732
761
  // Verify terminal audit exists and is settled done
733
- const auditPair = readAuditTaskPair(root, taskId);
762
+ const adoptedRun = localRunId(root, taskId);
763
+ const auditPair = readAuditTaskPair(root, taskId, adoptedRun ?? undefined);
734
764
  if (!auditPair) {
735
765
  throw new Error(`batch_head_lineage_broken: adopted commit lacks terminal audit pair for ${taskId}`);
736
766
  }
@@ -7,10 +7,12 @@ import {
7
7
  TaskIntentObservationError,
8
8
  } from "../kernel/intent";
9
9
  import { readTaskRecordRaw } from "../kernel/storage";
10
+ import { inspectSpecBinding, type SpecBindingInspection } from "../kernel/spec_binding";
10
11
  import type {
11
12
  BatchPlan,
12
13
  BatchPlanBudget,
13
14
  BatchPlanChild,
15
+ BatchPlanChildReason,
14
16
  BatchPlanDigestChild,
15
17
  InitiativeObservationReader,
16
18
  ProjectBatchPlanInput,
@@ -20,6 +22,21 @@ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
20
22
  const DEFAULT_DEADLINE_MS = 8 * 60 * 60 * 1_000;
21
23
  const DEFAULT_QA_FAILURE_LIMIT = 2;
22
24
 
25
+ /**
26
+ * Enrollment refuses a child whose scope_hint cannot name its bound Spec pair,
27
+ * so the plan excludes the same children here, before a confirmation offers
28
+ * them and before an Executor writes their implementation. The reason carries a
29
+ * stable code plus every path the intent must add. Advisory only: this mints,
30
+ * alters, and consumes no authority.
31
+ */
32
+ function specBindingReason(inspection: Extract<SpecBindingInspection, { ok: false }>): BatchPlanChildReason {
33
+ // One shared branch renders the concrete paths of every refusal that carries
34
+ // them — a `binding_missing` whose declared halves never paired included.
35
+ // `binding_incomplete` always carries paths, so it has no entry of its own.
36
+ if (inspection.missing.length > 0) return `spec_binding_incomplete: ${inspection.missing.join(", ")}`;
37
+ return inspection.code === "binding_ambiguous" ? "spec_binding_ambiguous" : "spec_binding_missing";
38
+ }
39
+
23
40
  function compareIds(left: string, right: string): number {
24
41
  return left < right ? -1 : left > right ? 1 : 0;
25
42
  }
@@ -153,10 +170,33 @@ export async function projectBatchPlan(
153
170
  const intentPath = `docs/plans/${task.task_id}.intent.json`;
154
171
  try {
155
172
  const read = observeTaskIntent(root, task.task_id, intentPath);
173
+ if (read.intent.risk === "critical") {
174
+ children.push({
175
+ ...child,
176
+ status: "needs_human",
177
+ reason: "critical",
178
+ intent_path: read.intent_ref.path,
179
+ intent_revision: read.intent_ref.revision,
180
+ intent_content_hash: read.content_hash,
181
+ });
182
+ continue;
183
+ }
184
+ const binding = inspectSpecBinding(read.intent);
185
+ if (!binding.ok) {
186
+ children.push({
187
+ ...child,
188
+ status: "needs_human",
189
+ reason: specBindingReason(binding),
190
+ intent_path: read.intent_ref.path,
191
+ intent_revision: read.intent_ref.revision,
192
+ intent_content_hash: read.content_hash,
193
+ });
194
+ continue;
195
+ }
156
196
  children.push({
157
197
  ...child,
158
- status: read.intent.risk === "critical" ? "needs_human" : "enrollable",
159
- reason: read.intent.risk === "critical" ? "critical" : null,
198
+ status: "enrollable",
199
+ reason: null,
160
200
  intent_path: read.intent_ref.path,
161
201
  intent_revision: read.intent_ref.revision,
162
202
  intent_content_hash: read.content_hash,