immune-brain 4.4.1 → 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 (25) hide show
  1. package/package.json +1 -1
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  4. package/plugins/immune-brain/dist/claude/mcp-server.mjs +675 -292
  5. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  6. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  7. package/plugins/immune-brain/dist/imm-loop.md +41 -2
  8. package/plugins/immune-brain/dist/imm-planner.md +65 -1
  9. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  10. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  11. package/plugins/immune-brain/runtime/github_issue_tracker.ts +321 -60
  12. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  13. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  14. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  15. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +35 -18
  17. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +194 -0
  18. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -5
  19. package/plugins/immune-brain/runtime/unattended/batch_state.ts +23 -4
  20. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  21. package/plugins/immune-brain/runtime/workspace_scope.ts +31 -4
  22. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  23. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  24. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  25. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
@@ -480,10 +480,15 @@ function ensureSecureDirectory(root: string, relativePath: string): string {
480
480
  return target.path;
481
481
  }
482
482
 
483
- export function readSecureProjectFile(
483
+ export function readSecureProjectFile(root: string, relativePath: string): string {
484
+ return readSecureProjectBytes(root, relativePath).toString("utf8");
485
+ }
486
+
487
+ /** Preserve byte identity while retaining the same secure filesystem checks. */
488
+ export function readSecureProjectBytes(
484
489
  root: string,
485
490
  relativePath: string,
486
- ): string {
491
+ ): Buffer {
487
492
  const candidate = safeCandidate(root, relativePath);
488
493
  assertNoSymlinkSegments(candidate.root, candidate.path);
489
494
  const before = pathStatOrNull(candidate.path);
@@ -498,7 +503,7 @@ export function readSecureProjectFile(
498
503
  const opened = fstatSync(fd);
499
504
  if (opened.dev !== before.dev || opened.ino !== before.ino)
500
505
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
501
- const content = readFileSync(fd, "utf8");
506
+ const content = readFileSync(fd);
502
507
  const after = lstatSync(candidate.path);
503
508
  if (after.dev !== opened.dev || after.ino !== opened.ino)
504
509
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "4.4.1" as const;
2
+ export const PLUGIN_VERSION = "4.5.0" as const;
@@ -34,6 +34,25 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
34
34
  excludes is reported as an advisory note against that boundary, never as blocking
35
35
  rework.
36
36
 
37
+ ## Invariant and Evidence Coverage
38
+
39
+ Judge whether the change actually closes the invariant its acceptance names,
40
+ not only the path the report mentions. A fix that covers one trigger but leaves
41
+ another trigger of the same invariant is a coverage gap in the same class, not a
42
+ new independent bug: say so and name the class. Check the negative and bound
43
+ behavior, not only the happy path. The six generalized classes worth checking
44
+ are: normalization precedes retrieval, validation precedes merge, the deadline
45
+ covers the whole lifecycle including cleanup, ranking tolerates missing
46
+ embeddings, the budget covers the complete serialized request, and each success,
47
+ failure, and timeout outcome carries its own duration evidence.
48
+
49
+ A required check that is skipped, that matches zero tests, or whose prerequisite
50
+ is absent does not prove the acceptance; do not accept it as evidence. Full
51
+ suite coverage reported at a coarse pass count is not per-invariant evidence.
52
+ Environment, preparation, and cleanup breakdowns are environment findings, not
53
+ assertion findings. Keep automated behavioral or geometry evidence separated
54
+ from a human quality judgment.
55
+
37
56
  Return exactly one JSON object with the fields required by the Loop review
38
57
  contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
39
58
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
@@ -20,4 +20,16 @@ Plan remains a literal-user decision.
20
20
  For elevated-risk work, check the latest referenced Spec's Design Conformance
21
21
  against implementation evidence. A local implementation mismatch is `rework`;
22
22
  return `rework` with bounded repair evidence. A structural or intended design
23
- change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation. If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
23
+ change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation.
24
+
25
+ Judge the recorded execution evidence, not the descriptor text. A required check
26
+ proves an acceptance invariant only when it actually executed and passed: an
27
+ absent dependency, a prerequisite preparation failure, a skipped required check,
28
+ or a check that matched zero tests is a failure, not a pass. Structural
29
+ "Enrollment ready" or "descriptor valid" is not execution evidence, and a
30
+ contract string proves nothing about provider behavior. Automated behavioral or
31
+ geometry evidence cannot stand in for a human motion-quality judgment, and vice
32
+ versa; keep the two claims separate. Environment, preparation, and cleanup
33
+ breakdowns are environment findings, not assertion findings: report them with
34
+ the descriptor reference and observed outcome rather than as a code defect.
35
+ If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
@@ -22,6 +22,7 @@ import { readGitHead } from "../kernel/pi_canary_prepare";
22
22
  import { readTaskIntent } from "../kernel/intent";
23
23
  import { localRunId, readAuditTaskPair, readTaskRecordRaw, readWorkspaceStateRaw, reconcileKernelAuthority } from "../kernel/storage";
24
24
  import { pathMatchesScope } from "../workspace_scope";
25
+ import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
25
26
  import { DEFAULT_DEADLINE_MS, projectBatchPlan } from "./batch_plan";
26
27
  import { batchReason, type BatchReasonKey } from "./batch_reasons";
27
28
  import { isTerminalBatchState, readBatchRunState, type BatchRunStateRecord } from "./batch_state";
@@ -44,6 +45,8 @@ export interface BatchPreflightOptions {
44
45
  }
45
46
 
46
47
  export interface BatchPreflightProjection {
48
+ /** Captured observations only; never serialized or used as capability. */
49
+ reconfirmation?: ReconfirmationSnapshot;
47
50
  initiative_slug: string;
48
51
  batch_branch: string;
49
52
  is_resuming: boolean;
@@ -291,8 +294,8 @@ function porcelainEntries(root: string): Array<{ code: string; path: string }> |
291
294
  // an unstaged modification (" M path") and silently mis-scoped the path.
292
295
  const statusProc = spawnSync(
293
296
  "git",
294
- ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"],
295
- { encoding: "utf8" },
297
+ ["-C", root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=none"],
298
+ { encoding: "utf8", env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
296
299
  );
297
300
  if (statusProc.status !== 0) return null;
298
301
  const entries: Array<{ code: string; path: string }> = [];
@@ -552,22 +555,26 @@ export async function projectBatchPreflight(
552
555
  });
553
556
  if (!planSurface.ok)
554
557
  return reject(planSurface.key, planSurface.detail);
558
+ let reconfirmation: ReconfirmationSnapshot | undefined;
559
+ if (activeRecord && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
560
+ try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
561
+ catch { return reject("plan_projection_failed", "batch plan reconfirmation is not eligible"); }
562
+ }
555
563
 
556
- return {
557
- ok: true,
558
- projection: {
559
- initiative_slug: initiativeSlug,
560
- batch_branch: batchBranch,
561
- is_resuming: isResuming,
562
- existing_batch: existingBatch,
563
- base_head: baseHead,
564
- budget: planSurface.surface.budget,
565
- plan_digest: planSurface.surface.plan_digest,
566
- recovery_children: planSurface.surface.recovery_children,
567
- risk_by_task: planSurface.surface.risk_by_task,
568
- excluded: planSurface.surface.excluded,
569
- },
564
+ const projection: BatchPreflightProjection = {
565
+ initiative_slug: initiativeSlug,
566
+ batch_branch: batchBranch,
567
+ is_resuming: isResuming,
568
+ existing_batch: existingBatch,
569
+ base_head: baseHead,
570
+ budget: planSurface.surface.budget,
571
+ plan_digest: planSurface.surface.plan_digest,
572
+ recovery_children: planSurface.surface.recovery_children,
573
+ risk_by_task: planSurface.surface.risk_by_task,
574
+ excluded: planSurface.surface.excluded,
570
575
  };
576
+ if (reconfirmation) Object.defineProperty(projection, "reconfirmation", { value: reconfirmation });
577
+ return { ok: true, projection };
571
578
  }
572
579
 
573
580
  /**
@@ -700,6 +707,11 @@ export async function authorizeBatch<HostRejection>(
700
707
  // ADR-0005 Decision 1: a resume of an intact, still-binding authorization
701
708
  // reuses it instead of opening a second native gate. Anything that no longer
702
709
  // binds falls through to the gate below, which names the reason.
710
+ const ownsUnpersistedHead = isResuming && existingBatch !== null && existingBatch.plan_digest === planDigest &&
711
+ expectedBatchHead(existingBatch) !== baseHead && ownUnpersistedBatchHead(root, existingBatch, baseHead);
712
+ if (isResuming && existingBatch && !existingBatch.commits.length && existingBatch.children[0]?.state === "settled" &&
713
+ expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead)
714
+ return { outcome: "rejected", rejection: batchRejection("head_moved", "first unpersisted commit provenance is invalid") };
703
715
  const reuseBlockers: string[] = [];
704
716
  if (isResuming && existingBatch) {
705
717
  if (isExistingExpired) reuseBlockers.push("batch_authorization_expired");
@@ -707,7 +719,7 @@ export async function authorizeBatch<HostRejection>(
707
719
  if (existingBatch.batch_state !== "running") reuseBlockers.push("batch_not_running");
708
720
  if (existingBatch.plan_digest !== planDigest) reuseBlockers.push("batch_plan_digest_changed");
709
721
  if (existingBatch.branch !== batchBranch) reuseBlockers.push("batch_branch_changed");
710
- if (expectedBatchHead(existingBatch) !== baseHead) reuseBlockers.push("batch_head_lineage_moved");
722
+ if (expectedBatchHead(existingBatch) !== baseHead && !ownsUnpersistedHead) reuseBlockers.push("batch_head_lineage_moved");
711
723
  }
712
724
  const reuseAuthorization = isResuming && reuseBlockers.length === 0;
713
725
  const expiresAt = reuseAuthorization ? existingBatch!.authorization_expires_at : budget.deadline_at;
@@ -753,7 +765,7 @@ export async function authorizeBatch<HostRejection>(
753
765
  if (drift.plan_digest !== planDigest)
754
766
  return { outcome: "rejected", rejection: batchRejection("plan_changed", drift.plan_digest) };
755
767
  if (drift.base_head === null) return { outcome: "rejected", rejection: batchRejection("repository_became_unreadable") };
756
- if (drift.base_head !== baseHead)
768
+ if (drift.base_head !== baseHead || (ownsUnpersistedHead && !ownUnpersistedBatchHead(root, existingBatch!, baseHead)))
757
769
  return { outcome: "rejected", rejection: batchRejection("head_moved", drift.base_head) };
758
770
 
759
771
  // The drift projection's own claim read happened before its plan read; this
@@ -778,6 +790,10 @@ export async function authorizeBatch<HostRejection>(
778
790
  if (Date.parse(expiresAt) <= Date.now())
779
791
  return { outcome: "rejected", rejection: batchRejection("confirmation_failed", "confirmed batch deadline expired before authorization issuance") };
780
792
 
793
+ if (projection.reconfirmation) {
794
+ try { projection.reconfirmation.assertUnchanged(); }
795
+ catch { return { outcome: "rejected", rejection: batchRejection("plan_changed") }; }
796
+ }
781
797
  const binding: BatchAuthorizationBinding = {
782
798
  batch_id: batchId,
783
799
  initiative_slug: initiativeSlug,
@@ -790,6 +806,7 @@ export async function authorizeBatch<HostRejection>(
790
806
  expires_at: expiresAt,
791
807
  nonce: options.nonce,
792
808
  };
809
+ if (projection.reconfirmation) retainReconfirmation(binding.nonce, binding.expires_at, projection.reconfirmation);
793
810
  return {
794
811
  outcome: "authorized",
795
812
  batch_id: batchId,
@@ -0,0 +1,194 @@
1
+ // Read-only eligibility for a first, zero-commit plan reconfirmation.
2
+ import { spawnSync } from "node:child_process";
3
+ import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
4
+ import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
5
+ import { projectAssurance } from "../kernel/assurance_projection";
6
+ import { localRunId, readAuditTaskPair, readSecureProjectBytes, readWorkspaceStateRaw } from "../kernel/storage";
7
+ import { readRunRowByTask, withKernelRead } from "../kernel/sqlite_store";
8
+ import { taskRevisionIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
9
+ import { projectTask } from "../kernel/completion";
10
+ import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
11
+ import type { BatchPlanChild } from "./types";
12
+
13
+ const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
14
+ const id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const batchAuthor = process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch";
16
+ const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17
+ function refuse(): never { throw new Error("plan_digest mismatch: batch plan reconfirmation is not eligible"); }
18
+ function matchesLocalProof(localJson: string | null, exported: object): boolean {
19
+ return localJson !== null && JSON.stringify(Object.entries(JSON.parse(localJson)).sort()) === JSON.stringify(Object.entries(exported).sort());
20
+ }
21
+ function git(root: string, args: string[]): string {
22
+ const r = spawnSync("git", ["-C", root, ...args], { encoding: "buffer", maxBuffer: 262144, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } });
23
+ if (r.status !== 0) refuse();
24
+ return decode(r.stdout);
25
+ }
26
+ // Recognize only the first commit/persist crash window; never query Git with
27
+ // an OID read from mutable evidence. The existing Git port performs final adoption.
28
+ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecord, head: string): boolean {
29
+ try {
30
+ if (!oid.test(head) || !oid.test(record.base_head) || !id.test(record.batch_id) || record.commits.length ||
31
+ record.children[0]?.state !== "settled" || record.children.some(c => c.commit !== null)) return false;
32
+ const task = record.children[0].task_id;
33
+ if (!id.test(task)) return false;
34
+ const evidence = JSON.parse(decode(readSecureProjectBytes(root, `.imm/state/batches/commits/${record.batch_id}-${task}.json`)));
35
+ if (evidence.contract !== "assurance_kernel/batch_commit_evidence/v1" || evidence.batch_id !== record.batch_id ||
36
+ evidence.task_id !== task || evidence.commit !== head || evidence.parent_head !== record.base_head) return false;
37
+ if (git(root, ["symbolic-ref", "--short", "HEAD"]).trim() !== record.branch) return false;
38
+ const parents = git(root, ["rev-list", "--parents", "-n", "1", head]).trim().split(/\s+/).slice(1);
39
+ if (parents.length !== 1 || parents[0] !== record.base_head) return false;
40
+ const metadata = git(root, ["show", "-s", "--format=%an%x00%s%x00%(trailers:key=Immune-Brain-Batch,valueonly)", head]).split("\0");
41
+ if (metadata[0] !== batchAuthor || !metadata[1]?.startsWith(`imm(${task}):`) || metadata[2]?.trim() !== record.batch_id) return false;
42
+ const run = localRunId(root, task);
43
+ if (!run || !id.test(run)) return false;
44
+ const pair = readAuditTaskPair(root, task, run);
45
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || pair.record.lifecycle !== "done" || pair.proof.terminal_lifecycle !== "done") return false;
46
+ const local = withKernelRead(root, db => readRunRowByTask(db, task));
47
+ if (!local || local.run_id !== run || local.state !== "done" || !matchesLocalProof(local.terminal_proof_json, pair.proof)) return false;
48
+ for (const file of ["task-record.json", "terminal-proof.json"]) {
49
+ const path = `.imm/audit/${task}/${run}/${file}`;
50
+ if (!readSecureProjectBytes(root, path).equals(Buffer.from(git(root, ["show", `${head}:${path}`])))) return false;
51
+ }
52
+ const paths = git(root, ["diff-tree", "--no-commit-id", "--name-only", "--no-renames", "-z", "-r", head]).split("\0").filter(Boolean);
53
+ if (!paths.length || paths.some(path => !path.startsWith(`.imm/audit/${task}/${run}/`) &&
54
+ !pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
55
+ const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
56
+ if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
57
+ return git(root, ["rev-parse", "HEAD"]).trim() === head;
58
+ } catch { return false; }
59
+ }
60
+
61
+ export interface ReconfirmationSnapshot {
62
+ root: string;
63
+ plan_digest: string;
64
+ stateBytes: Buffer;
65
+ /** Synchronous read-only check repeated under the state owner's lock. */
66
+ assertUnchanged(): void;
67
+ }
68
+
69
+ // Ephemeral captured observations, not authority: the runner independently
70
+ // validates the native capability. Entries expire and are discarded on application.
71
+ const confirmations = new Map<string, { snapshot: ReconfirmationSnapshot; expires: number }>();
72
+ export function retainReconfirmation(nonce: string, expires: string, snapshot: ReconfirmationSnapshot): void {
73
+ for (const [key, value] of confirmations) if (value.expires <= Date.now()) confirmations.delete(key);
74
+ if (confirmations.has(nonce)) refuse();
75
+ confirmations.set(nonce, { snapshot, expires: Date.parse(expires) });
76
+ }
77
+ export function takeReconfirmation(nonce: string): ReconfirmationSnapshot {
78
+ const found = confirmations.get(nonce); confirmations.delete(nonce);
79
+ if (!found || found.expires <= Date.now()) refuse();
80
+ return found.snapshot;
81
+ }
82
+
83
+ export async function captureBatchReconfirmation(root: string, record: BatchRunStateRecord, children: BatchPlanChild[]): Promise<ReconfirmationSnapshot> {
84
+ if (!id.test(record.batch_id) || !oid.test(record.base_head) || isTerminalBatchState(record.batch_state) || record.commits.length ||
85
+ record.children.some(c => c.commit !== null || c.state === "committed") || record.children.length !== children.length || !children.length) refuse();
86
+ const assertNoCommitEvidence = () => {
87
+ for (const child of record.children) {
88
+ if (!id.test(child.task_id)) refuse();
89
+ const path = `.imm/state/batches/commits/${record.batch_id}-${child.task_id}.json`;
90
+ let missing = false;
91
+ try { readSecureProjectBytes(root, path); }
92
+ catch (error) {
93
+ if (error instanceof Error && error.message === `source_missing: ${path}`) missing = true;
94
+ else refuse();
95
+ }
96
+ if (!missing) refuse();
97
+ }
98
+ };
99
+ assertNoCommitEvidence();
100
+ const statePath = `.imm/state/batches/${record.batch_id}.json`;
101
+ const stateBytes = readSecureProjectBytes(root, statePath);
102
+ if (JSON.stringify(JSON.parse(decode(stateBytes))) !== JSON.stringify(record)) refuse();
103
+ const files = new Map<string, Buffer>();
104
+ const capture = (path: string) => { const bytes = readSecureProjectBytes(root, path); files.set(path, bytes); return bytes; };
105
+ const oldChildren: DigestChild[] = [];
106
+ const changed: Array<{ task: string; run: string; identity: string; local: string }> = [];
107
+ let scope: string[] | null = null;
108
+ const workspace = readWorkspaceStateRaw(root);
109
+ if (workspace.state.current_working !== null) refuse();
110
+ for (const [index, child] of children.entries()) {
111
+ const previous = record.children[index]!;
112
+ if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
113
+ JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
114
+ const old = parseTaskIntentV1(JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
115
+ if (old.task_id !== child.task_id) refuse();
116
+ const oldHash = canonicalIntentHash(old);
117
+ 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 });
118
+ const currentBytes = capture(child.intent_path);
119
+ const current = parseTaskIntentV1(JSON.parse(decode(currentBytes)));
120
+ const currentHash = canonicalIntentHash(current);
121
+ const tracked = readTaskIntent(root, child.task_id, child.intent_path);
122
+ if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
123
+ if (currentHash === oldHash) continue;
124
+ if (scope !== null || previous.state === "pending" || previous.state === "skipped_blocked" || current.revision <= old.revision || current.goal !== old.goal || current.owner !== old.owner) refuse();
125
+ const run = localRunId(root, child.task_id);
126
+ if (!run || !id.test(run)) refuse();
127
+ const local = withKernelRead(root, db => readRunRowByTask(db, child.task_id));
128
+ if (!local || local.run_id !== run || local.state !== "done" || local.enrollment_event_id !== `enroll-${child.task_id}-${local.created_at}` ||
129
+ Date.parse(local.created_at) > Date.parse(record.updated_at)) refuse();
130
+ const pair = readAuditTaskPair(root, child.task_id, run);
131
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4") refuse();
132
+ const r = pair.record;
133
+ if (r.lifecycle !== "done" || r.intent_ref.content_hash !== currentHash || canonicalIntentHash(r.intent_snapshot) !== currentHash ||
134
+ r.git_base_head !== record.base_head || pair.proof.terminal_lifecycle !== "done") refuse();
135
+ const terminal = r.history.at(-1);
136
+ if (terminal?.type !== "complete" || terminal.from_state !== "active:frozen" || terminal.to_state !== "done:frozen" || terminal.id !== pair.proof.terminal_event_id) refuse();
137
+ const recordPath = `.imm/audit/${child.task_id}/${run}/task-record.json`;
138
+ const proofPath = `.imm/audit/${child.task_id}/${run}/terminal-proof.json`;
139
+ if (!capture(recordPath).equals(Buffer.from(local.record_json))) refuse();
140
+ const capturedProof = JSON.parse(decode(capture(proofPath)));
141
+ if (!matchesLocalProof(local.terminal_proof_json, capturedProof) || !matchesLocalProof(local.terminal_proof_json, pair.proof)) refuse();
142
+ const identity = taskRevisionIdentity(root, r.intent_snapshot.scope_hint, r.git_base_head, r.task_id);
143
+ const projection = await projectAssurance(root, child.task_id, (cwd, task) => {
144
+ if (task.contract !== "assurance_kernel/task_record/v4" || !task.git_base_head) refuse();
145
+ return taskRevisionIdentity(cwd, task.intent_snapshot.scope_hint, task.git_base_head, task.task_id);
146
+ });
147
+ if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
148
+ !projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash || projection.projection.diff_hash !== identity.diff_hash ||
149
+ !projection.projection.fresh_approval_kinds.includes("qa") ||
150
+ (projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
151
+ scope = r.intent_snapshot.scope_hint;
152
+ changed.push({ task: child.task_id, run, identity: JSON.stringify(identity), local: JSON.stringify(local) });
153
+ }
154
+ if (computeBatchPlanDigest(oldChildren) !== record.plan_digest || changed.length !== 1 || scope === null) refuse();
155
+ const confirmedScope = scope;
156
+ const owner = changed[0]!;
157
+ const status = () => git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"]);
158
+ const statusBytes = status();
159
+ const entries = statusBytes.split("\0").filter(Boolean);
160
+ // This bounded path refuses renames; staged regular edits/additions/deletions
161
+ // are enough for a revised child and cannot hide a second source path.
162
+ for (const entry of entries) {
163
+ const code = entry.slice(0, 2), path = entry.slice(3);
164
+ if (code[1] !== " " || !["A", "M", "D"].includes(code[0]!) ||
165
+ (!path.startsWith(`.imm/audit/${owner.task}/${owner.run}/`) &&
166
+ !confirmedScope.some(s => pathMatchesScope(path, s)))) refuse();
167
+ }
168
+ const flagsBytes = git(root, ["ls-files", "-v"]);
169
+ const flags = flagsBytes.split("\n").filter(Boolean);
170
+ if (flags.some(line => line[0] === "S" || line[0] !== line[0]?.toUpperCase())) refuse();
171
+ const head = () => git(root, ["rev-parse", "HEAD"]).trim();
172
+ const branch = () => git(root, ["symbolic-ref", "--short", "HEAD"]).trim();
173
+ if (head() !== record.base_head || branch() !== record.branch) refuse();
174
+ const assertDeliveryUnchanged = () => {
175
+ for (const c of changed) {
176
+ const pair = readAuditTaskPair(root, c.task, c.run);
177
+ if (!pair || pair.record.contract !== "assurance_kernel/task_record/v4" || !pair.record.git_base_head) refuse();
178
+ const local = withKernelRead(root, db => readRunRowByTask(db, c.task));
179
+ const diff = taskRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, c.task);
180
+ if (localRunId(root, c.task) !== c.run || JSON.stringify(local) !== c.local || JSON.stringify(diff) !== c.identity) refuse();
181
+ }
182
+ };
183
+ const assertUnchanged = () => {
184
+ assertNoCommitEvidence();
185
+ if (!readSecureProjectBytes(root, statePath).equals(stateBytes) || head() !== record.base_head || branch() !== record.branch || status() !== statusBytes || git(root, ["ls-files", "-v"]) !== flagsBytes ||
186
+ readWorkspaceStateRaw(root).revision !== workspace.revision) refuse();
187
+ for (const [path, bytes] of files) if (!readSecureProjectBytes(root, path).equals(bytes)) refuse();
188
+ assertDeliveryUnchanged();
189
+ };
190
+ assertUnchanged();
191
+ const plan_digest = computeBatchPlanDigest(oldChildren.map((old, index) => ({ ...old,
192
+ intent_revision: children[index]!.intent_revision!, intent_content_hash: children[index]!.intent_content_hash! })));
193
+ return { root, plan_digest, stateBytes, assertUnchanged };
194
+ }
@@ -23,6 +23,7 @@ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { find
23
23
  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 })) };
24
24
  }
25
25
  import type { BatchPlanChild } from "./types";
26
+ import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
26
27
  import {
27
28
  type BatchRunnerGitPort,
28
29
  type BatchGitPreflightResult,
@@ -38,6 +39,7 @@ import {
38
39
  prepareBatchRunState,
39
40
  readBatchRunState,
40
41
  writeBatchRunState,
42
+ replaceBatchRunState,
41
43
  writeBatchRunReport,
42
44
  } from "./batch_state";
43
45
 
@@ -322,6 +324,11 @@ function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): st
322
324
  }
323
325
 
324
326
  async function validatePersistedRun(input: StartBatchInput, record: BatchRunStateRecord): Promise<void> {
327
+ if (!record.commits.length && record.children[0]?.state === "settled" && existsSync(join(input.root, ".git"))) {
328
+ const head = spawnSync("git", ["-C", input.root, "rev-parse", "HEAD"], { encoding: "utf8" });
329
+ if (head.status !== 0 || (head.stdout.trim() !== record.base_head && !ownUnpersistedBatchHead(input.root, record, head.stdout.trim())))
330
+ throw new Error("first unpersisted batch commit provenance is invalid");
331
+ }
325
332
  const plan = input.registry.children(input.capability);
326
333
  if (record.plan_digest !== computeBatchPlanDigest(plan) ||
327
334
  record.children.length !== plan.length || record.children.some((child, index) => {
@@ -384,7 +391,7 @@ function replayTerminal(
384
391
  );
385
392
  }
386
393
 
387
- function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): void {
394
+ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
388
395
  const authorized = input.kernel.validateBatchAuthorization({
389
396
  registry: input.registry,
390
397
  capability: input.capability,
@@ -409,6 +416,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
409
416
  if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
410
417
  input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
411
418
  throw new Error("batch run input does not match the authorized plan or base_head");
419
+ return authorized;
420
+ }
421
+
422
+ function applyPlanReconfirmation(input: StartBatchInput, existing: BatchRunStateRecord): BatchRunStateRecord {
423
+ if (existing.plan_digest === input.plan_digest) return existing;
424
+ const next = { ...existing, plan_digest: input.plan_digest, confirmation_time: input.confirmation_time,
425
+ authorization_expires_at: input.authorization_expires_at, budget: input.budget };
426
+ if (!existing.branch || input.batch_id !== existing.batch_id || input.initiative_slug !== existing.initiative_slug || input.base_head !== existing.base_head ||
427
+ input.budget.max_children !== existing.budget.max_children || input.budget.qa_failure_limit !== existing.budget.qa_failure_limit)
428
+ throw new Error("plan_digest mismatch: reconfirmation binding changed");
429
+ const validate = () => {
430
+ const plan = input.registry.children(input.capability);
431
+ if (existing.children.length !== plan.length || plan.some((child, index) => {
432
+ const old = existing.children[index]!, nextChild = input.children[index];
433
+ return !nextChild || old.task_id !== child.task_id || old.slice_id !== nextChild.slice_id ||
434
+ JSON.stringify(old.blocked_by) !== JSON.stringify(child.blocked_by) || JSON.stringify(nextChild.blocked_by) !== JSON.stringify(child.blocked_by);
435
+ })) throw new Error("plan_digest mismatch: reconfirmation topology changed");
436
+ const { issued_at: _issuedAt, ...binding } = validateRunAuthorization(input, { ...existing, plan_digest: input.plan_digest });
437
+ if (binding.branch !== existing.branch) throw new Error("plan_digest mismatch: reconfirmation branch changed");
438
+ prepareBatchRunState(input);
439
+ return input.registry.inspect(input.capability, binding);
440
+ };
441
+ const authority = validate();
442
+ const captured = takeReconfirmation(authority.nonce);
443
+ if (captured.root !== input.root || captured.plan_digest !== input.plan_digest) throw new Error("plan_digest mismatch: captured reconfirmation does not match run input");
444
+ return replaceBatchRunState(input.root, captured.stateBytes, next, () => { captured.assertUnchanged(); validate(); });
412
445
  }
413
446
 
414
447
  export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
@@ -460,7 +493,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
460
493
  }
461
494
 
462
495
  let existing = readBatchRunState(input.root, input.batch_id);
496
+ const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
463
497
  if (existing) {
498
+ existing = applyPlanReconfirmation(input, existing);
464
499
  try {
465
500
  await validatePersistedRun(input, existing);
466
501
  } catch (error) {
@@ -487,7 +522,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
487
522
  nextConfirmation > priorConfirmation &&
488
523
  Number.isFinite(nextExpiry) &&
489
524
  nextExpiry > Date.now();
490
- if (!freshAuthorization) {
525
+ if (!freshAuthorization && !reconfirmed) {
491
526
  return finalize(
492
527
  input.root,
493
528
  existing,
@@ -496,7 +531,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
496
531
  );
497
532
  }
498
533
 
499
- validateRunAuthorization(input, existing);
534
+ if (!reconfirmed) validateRunAuthorization(input, existing);
500
535
  prepareBatchRunState({ ...input, now: input.now });
501
536
  const remapped = await Promise.all(
502
537
  existing.children.map(async (child) => {
@@ -863,7 +898,8 @@ export async function resumeBatch(
863
898
  projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
864
899
  ): Promise<BatchRunReport> {
865
900
  let existing = readBatchRunState(input.root, input.batch_id);
866
- if (!existing) return startBatch(input);
901
+ if (!existing || existing.batch_state === "needs_human") return startBatch(input);
902
+ existing = applyPlanReconfirmation(input, existing);
867
903
  try {
868
904
  await validatePersistedRun(input, existing);
869
905
  } catch (error) {
@@ -877,7 +913,6 @@ export async function resumeBatch(
877
913
  }
878
914
  if (isTerminalBatchState(existing.batch_state))
879
915
  return replayTerminal(input.root, existing);
880
- if (existing.batch_state === "needs_human") return startBatch(input);
881
916
 
882
917
  // An enrolled-but-unsettled child must reach its own Kernel terminal
883
918
  // settlement before anything else, even under an expired authorization:
@@ -1103,6 +1138,8 @@ async function driveInterruptedChild(
1103
1138
  intentPath,
1104
1139
  });
1105
1140
  };
1141
+ if (existing && !record.commits.length && existsSync(join(input.root, ".git")) && !ownUnpersistedBatchHead(input.root, record, existing.commit))
1142
+ throw new Error("first unpersisted batch commit provenance is invalid");
1106
1143
  const adopted =
1107
1144
  existing ??
1108
1145
  (await doCommit().catch((error: unknown) => {
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { dirname, join } from "node:path";
7
- import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
7
+ import { readSecureProjectFile, readSecureProjectBytes, withKernelStoreLock } from "../kernel/storage";
8
8
  import type { BatchPlanChild } from "./types";
9
9
 
10
10
  export type BatchRunState =
@@ -243,12 +243,18 @@ export function prepareBatchRunState(input: {
243
243
  return prepared;
244
244
  }
245
245
 
246
+ /** Validate a captured snapshot without reading or writing filesystem state. */
247
+ export function parseBatchRunState(raw: string, batchId: string): BatchRunStateRecord {
248
+ validateBatchId(batchId);
249
+ const parsed: unknown = JSON.parse(raw);
250
+ validateRecordShape(parsed, batchId);
251
+ return parsed;
252
+ }
253
+
246
254
  export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
247
255
  const path = statePath(batchId);
248
256
  if (!existsSync(join(root, path))) return null;
249
- const parsed: unknown = JSON.parse(readSecureProjectFile(root, path));
250
- validateRecordShape(parsed, batchId);
251
- return parsed;
257
+ return parseBatchRunState(readSecureProjectFile(root, path), batchId);
252
258
  }
253
259
 
254
260
  function ensureSecureDirectory(root: string, relative: string): string {
@@ -287,6 +293,19 @@ function writeFileAtomically(root: string, relative: string, bytes: string): voi
287
293
  }
288
294
  }
289
295
 
296
+ /** Replace an existing authorization only after a locked expected-byte check. */
297
+ export function replaceBatchRunState(root: string, expected: Buffer, next: BatchRunStateRecord, validate: () => void): BatchRunStateRecord {
298
+ const path = statePath(next.batch_id);
299
+ validateRecordShape(next, next.batch_id);
300
+ return withKernelStoreLock(root, () => {
301
+ if (!readSecureProjectBytes(root, path).equals(expected)) throw new Error("batch state CAS mismatch");
302
+ validate();
303
+ const stored = { ...next, updated_at: new Date().toISOString() };
304
+ writeFileAtomically(root, path, canonicalBytes(stored));
305
+ return stored;
306
+ });
307
+ }
308
+
290
309
  export function writeBatchRunState(
291
310
  root: string,
292
311
  record: BatchRunStateRecord,
@@ -176,6 +176,15 @@ async function runKernelCli(args: string[], root: string): Promise<{
176
176
  // status --json, and the explicit audit command. All other kernel
177
177
  // subcommands (readiness, journal, migrate) are retired.
178
178
  const sub = args[0] ?? "";
179
+ // The canonical author shape is discoverable before any error probing: a
180
+ // caller who does not know the destination/stdin contract reads it here
181
+ // instead of discovering it through a failed call.
182
+ if (sub === "intent" && (args[1] === "--help" || args[1] === "help" || args[1] === undefined))
183
+ return {
184
+ stdout: `${intentAuthorHelp()}\n`,
185
+ stderr: "",
186
+ returncode: 0,
187
+ };
179
188
  // The explicit claimless storage-layout migration is reachable here: a
180
189
  // worktree still on the retired file store has no other entry point, and
181
190
  // every mutating subcommand stays fail-closed behind it.
@@ -224,6 +233,51 @@ async function runCli(command: string, args: string[], root: string): Promise<{
224
233
  };
225
234
  }
226
235
 
236
+ /**
237
+ * The canonical TaskIntent author contract, printed by `imm-kernel intent` and
238
+ * `imm-kernel intent --help`. It states destination, stdin, and the strict v2
239
+ * descriptor shape before a caller has to discover them by failing a call.
240
+ */
241
+ export function intentAuthorHelp(): string {
242
+ return [
243
+ "imm-kernel intent author <destination> --stdin --json",
244
+ " destination: docs/plans/<task-id>.intent.json (created exclusively; never overwritten)",
245
+ " stdin: the complete candidate TaskIntent JSON on file descriptor 0",
246
+ " stdout: { valid, enrollment_ready, intent_ref, git_base_initialization_required, ... }",
247
+ "",
248
+ "imm-kernel intent validate <path> --json",
249
+ "",
250
+ "Candidate shape (assurance_kernel/task_intent/v1):",
251
+ JSON.stringify(
252
+ {
253
+ contract: "assurance_kernel/task_intent/v1",
254
+ task_id: "<task-id>",
255
+ owner: "user",
256
+ goal: "<one closable outcome>",
257
+ risk: "routine|material|critical",
258
+ revision: 1,
259
+ scope_hint: ["<every file the slice touches>"],
260
+ acceptance: [
261
+ {
262
+ id: "<ID>",
263
+ assertion: "<observable invariant>",
264
+ verification: JSON.stringify({
265
+ contract: "assurance_kernel/verification_descriptor/v2",
266
+ command: { executable: "bun", argv: ["test", "tests/<focused>.test.ts"], cwd: ".", timeout_ms: 180000, max_output_bytes: 196608 },
267
+ environment: { prepare: null, writable_paths: [] },
268
+ }),
269
+ },
270
+ ],
271
+ },
272
+ null,
273
+ 2,
274
+ ),
275
+ "",
276
+ "Unknown fields are rejected; the descriptor parser is strict v2 and never infers a runner.",
277
+ "A v1 descriptor is historical-only and needs an explicit Intent revision before execution.",
278
+ ].join("\n");
279
+ }
280
+
227
281
  async function main(argv: string[]): Promise<number> {
228
282
  const mode = argv[0];
229
283
  const root = process.cwd();