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
@@ -12,7 +12,18 @@ import {
12
12
  computeBatchPlanDigest,
13
13
  } from "../kernel/batch_authority";
14
14
  import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
15
+ import type { TaskRecord } from "../kernel/types";
16
+ import { deriveAssuranceRecovery, type AssuranceRecovery, type TaskRecordRead } from "../assurance/coordinator";
17
+ import type { QaCheckDiagnostic } from "../assurance/qa";
18
+
19
+ /** Failed atomic QA attempts observed through Kernel history, including Parent-driven QA. */
20
+ export function batchQaFailureFacts(record: Pick<TaskRecord, "history"> & { findings: NonNullable<TaskRecordRead["record"]>["findings"] }) {
21
+ const failed = record.history.filter((event) => event.type === "request_rework" && event.authority?.authority_kind === "qa");
22
+ return { qa_failure_count: failed.length, last_qa_failure_at: failed.at(-1)?.at ?? null,
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
+ }
15
25
  import type { BatchPlanChild } from "./types";
26
+ import { ownUnpersistedBatchHead, takeReconfirmation } from "./batch_reconfirmation";
16
27
  import {
17
28
  type BatchRunnerGitPort,
18
29
  type BatchGitPreflightResult,
@@ -28,6 +39,7 @@ import {
28
39
  prepareBatchRunState,
29
40
  readBatchRunState,
30
41
  writeBatchRunState,
42
+ replaceBatchRunState,
31
43
  writeBatchRunReport,
32
44
  } from "./batch_state";
33
45
 
@@ -75,7 +87,7 @@ export interface BatchRunnerKernelPort {
75
87
  * persistence. Uses the real AssuranceProjectionResult contract; batch
76
88
  * ownership of the claim is verified separately through the authoritative
77
89
  * batch registry, because the Kernel claim carries no batch id. */
78
- projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult>;
90
+ projectTask(root: string, taskId: string): Promise<AssuranceProjectionResult & { qa_failure_count?: number; last_qa_failure_at?: string | null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] }>;
79
91
  /** Authoritative batch ownership check: true when the task's Kernel claim
80
92
  * is held under this batch's derived capability (consumed child slot). */
81
93
  ownsTaskClaim(taskId: string): boolean;
@@ -92,13 +104,14 @@ export interface BatchRunnerKernelPort {
92
104
  }): ValidatedBatchAuthorization;
93
105
  }
94
106
 
95
- export type BatchChildAdvanceResult =
107
+ export type BatchChildAdvanceResult = { diagnostics?: QaCheckDiagnostic[]; environment_failure?: boolean; recovery?: AssuranceRecovery } & (
96
108
  | { state: "completed" }
97
109
  | { state: "stopped" }
98
110
  | { state: "failed"; reason: string }
99
111
  | { state: "rework"; operation: "qa" | "review"; summary: string }
100
112
  | { state: "blocked"; reason: string }
101
- | { state: "review_ready"; operation_id: string };
113
+ | { state: "review_ready"; operation_id: string }
114
+ | { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string });
102
115
 
103
116
  export interface StartBatchInput {
104
117
  root: string;
@@ -166,13 +179,13 @@ function isAuthorizationExpiryError(error: unknown): boolean {
166
179
  return error instanceof BatchAuthorizationExpiryError;
167
180
  }
168
181
 
169
- function requireFreshProjection(
170
- result: AssuranceProjectionResult,
182
+ function requireFreshProjection<T extends AssuranceProjectionResult>(
183
+ result: T,
171
184
  taskId: string,
172
- ): AssuranceProjectionResult & { error: null } {
185
+ ): T & { error: null } {
173
186
  if (result.error !== null)
174
187
  throw new Error(`cannot reconcile Kernel projection for ${taskId}: ${result.error}`);
175
- return result as AssuranceProjectionResult & { error: null };
188
+ return result as T & { error: null };
176
189
  }
177
190
 
178
191
  /** Next pending child whose direct dependents are all committed. */
@@ -216,6 +229,29 @@ function reportFor(
216
229
  };
217
230
  }
218
231
 
232
+ /** Derived observation only: Parent must re-read Kernel ownership before execution. */
233
+ function executorHandoff(
234
+ record: BatchRunStateRecord,
235
+ taskId: string,
236
+ fresh: AssuranceProjectionResult & { error: null; recovery_findings?: NonNullable<TaskRecordRead["record"]>["findings"] },
237
+ failure: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[] } = {},
238
+ ): BatchRunReport {
239
+ const recovery = deriveAssuranceRecovery(taskId, fresh, failure, fresh.recovery_findings);
240
+ return {
241
+ ...reportFor(record, null, recovery?.next_action ?? (fresh.projection.artifact_state === "frozen"
242
+ ? "The child is frozen with pending QA. Call advance_assurance once; follow its diagnostics before retrying, then submit any reserved Review before batch continuation."
243
+ : "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.")),
244
+ ...(recovery ? { recovery } : {}),
245
+ ...(failure.diagnostics?.length ? { diagnostics: failure.diagnostics } : {}),
246
+ handoff: recovery?.category === "authorization" ? undefined : {
247
+ role: "executor", task_id: taskId,
248
+ run_id: fresh.projection.run_id ?? null,
249
+ record_revision: fresh.projection.record_revision,
250
+ next_obligation: fresh.projection.next_obligation,
251
+ },
252
+ };
253
+ }
254
+
219
255
  /** review-3(6th round): persist the single run report when the batch
220
256
  * stops — including a needs_human park, which ends the current run even
221
257
  * though the batch remains resumable after a human decision. */
@@ -224,8 +260,9 @@ function finalize(
224
260
  record: BatchRunStateRecord,
225
261
  reason: string | null,
226
262
  nextAction: string,
263
+ details: Pick<BatchRunReport, "recovery" | "diagnostics"> = {},
227
264
  ): BatchRunReport {
228
- const report = reportFor(record, reason, nextAction);
265
+ const report = { ...reportFor(record, reason, nextAction), ...details };
229
266
  if (isTerminalBatchState(record.batch_state) || record.batch_state === "needs_human") {
230
267
  writeBatchRunReport(root, report);
231
268
  }
@@ -287,6 +324,11 @@ function externalHeadDriftMessage(root: string, record: BatchRunStateRecord): st
287
324
  }
288
325
 
289
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
+ }
290
332
  const plan = input.registry.children(input.capability);
291
333
  if (record.plan_digest !== computeBatchPlanDigest(plan) ||
292
334
  record.children.length !== plan.length || record.children.some((child, index) => {
@@ -349,7 +391,7 @@ function replayTerminal(
349
391
  );
350
392
  }
351
393
 
352
- function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): void {
394
+ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStateRecord | null): ValidatedBatchAuthorization {
353
395
  const authorized = input.kernel.validateBatchAuthorization({
354
396
  registry: input.registry,
355
397
  capability: input.capability,
@@ -374,6 +416,32 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
374
416
  if (computeBatchPlanDigest(children) !== authorized.plan_digest ||
375
417
  input.plan_digest !== authorized.plan_digest || input.base_head !== authorized.base_head)
376
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(); });
377
445
  }
378
446
 
379
447
  export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
@@ -425,7 +493,9 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
425
493
  }
426
494
 
427
495
  let existing = readBatchRunState(input.root, input.batch_id);
496
+ const reconfirmed = existing !== null && existing.plan_digest !== input.plan_digest;
428
497
  if (existing) {
498
+ existing = applyPlanReconfirmation(input, existing);
429
499
  try {
430
500
  await validatePersistedRun(input, existing);
431
501
  } catch (error) {
@@ -452,7 +522,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
452
522
  nextConfirmation > priorConfirmation &&
453
523
  Number.isFinite(nextExpiry) &&
454
524
  nextExpiry > Date.now();
455
- if (!freshAuthorization) {
525
+ if (!freshAuthorization && !reconfirmed) {
456
526
  return finalize(
457
527
  input.root,
458
528
  existing,
@@ -461,7 +531,7 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
461
531
  );
462
532
  }
463
533
 
464
- validateRunAuthorization(input, existing);
534
+ if (!reconfirmed) validateRunAuthorization(input, existing);
465
535
  prepareBatchRunState({ ...input, now: input.now });
466
536
  const remapped = await Promise.all(
467
537
  existing.children.map(async (child) => {
@@ -525,7 +595,6 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
525
595
  authorization_expires_at: input.authorization_expires_at,
526
596
  budget: input.budget,
527
597
  batch_state: "running",
528
- consecutive_qa_failures: 0,
529
598
  children: resumedChildren,
530
599
  });
531
600
  }
@@ -786,105 +855,11 @@ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport>
786
855
  break;
787
856
  }
788
857
  }
789
- // Drive the child through the Kernel obligation surface only; rework
790
- // below the limit retries the same child in this inner loop.
791
- let childTerminal = false;
792
- while (!childTerminal) {
793
- const terminal = await input.kernel.advanceTask(input.root, child.task_id);
794
- if (terminal.state === "completed") {
795
- childTerminal = true;
796
- // review-6: a successful settlement resets the consecutive-QA-failure
797
- // counter so separated failures do not park later children.
798
- record.consecutive_qa_failures = 0;
799
- record.children = record.children.map((c) =>
800
- c.task_id === child.task_id ? { ...c, state: "settled", reason: null } : c,
801
- );
802
- persist();
803
- // Scope-bound commit; a lineage failure fails the whole batch.
804
- try {
805
- const planChild = input.children.find((c) => c.task_id === child.task_id);
806
- const intentPath = planChild?.intent_path ?? undefined;
807
- const { commit } = input.git?.commitChild
808
- ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
809
- : input.kernel.commitChild
810
- ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
811
- : await commitBatchChild({
812
- root: input.root,
813
- taskId: child.task_id,
814
- batchId: input.batch_id,
815
- expectedHead: head,
816
- branch: record.branch,
817
- intentPath,
818
- });
819
- record.children = record.children.map((c) =>
820
- c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
821
- );
822
- record.commits.push(commit);
823
- head = commit;
824
- persist();
825
- } catch (error) {
826
- const message = error instanceof Error ? error.message : String(error);
827
- record.children = record.children.map((c) =>
828
- c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
829
- );
830
- // review-4: a commit failure is terminal; dependents must be
831
- // skipped_blocked, not left pending.
832
- skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
833
- record.batch_state = "failed";
834
- persist();
835
- break;
836
- }
837
- } else if (terminal.state === "stopped") {
838
- record.children = record.children.map((c) =>
839
- c.task_id === child.task_id
840
- ? { ...c, state: "needs_human", reason: "Kernel reported the child stopped" }
841
- : c,
842
- );
843
- childTerminal = true;
844
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
845
- record.batch_state = "needs_human";
846
- persist();
847
- } else if (terminal.state === "rework" && terminal.operation === "qa") {
848
- record.consecutive_qa_failures += 1;
849
- if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
850
- record.children = record.children.map((c) =>
851
- c.task_id === child.task_id
852
- ? { ...c, state: "needs_human", reason: `QA failure limit reached: ${terminal.summary}` }
853
- : c,
854
- );
855
- record.batch_state = "needs_human";
856
- childTerminal = true;
857
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
858
- persist();
859
- } else {
860
- // Below the limit: the inner loop re-drives the same child.
861
- persist();
862
- }
863
- } else if (terminal.state === "failed" || terminal.state === "blocked" || terminal.state === "rework") {
864
- const reason = terminal.state === "rework" ? terminal.summary : terminal.reason;
865
- childTerminal = true;
866
- record.children = record.children.map((c) =>
867
- c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
868
- );
869
- skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
870
- record.batch_state = "needs_human";
871
- persist();
872
- } else if (terminal.state === "review_ready") {
873
- // A foreground Review reservation is owned by the calling host
874
- // turn; the batch pauses here without parking the child.
875
- record.children = record.children.map((c) =>
876
- c.task_id === child.task_id
877
- ? { ...c, state: "enrolled", reason: `review reservation ${terminal.operation_id} open` }
878
- : c,
879
- );
880
- persist();
881
- return reportFor(
882
- record,
883
- `child ${child.task_id} holds an open Review reservation`,
884
- "Submit the reserved foreground Review verdict, then call startBatch again to continue.",
885
- );
886
- }
887
- }
858
+ const enrolled = requireFreshProjection(
859
+ await input.kernel.projectTask(input.root, child.task_id), child.task_id,
860
+ );
861
+ return executorHandoff(record, child.task_id, enrolled);
862
+
888
863
  }
889
864
 
890
865
  return finalize(input.root, record, stopReasonFor(record), "");
@@ -923,7 +898,8 @@ export async function resumeBatch(
923
898
  projection: (root: string, taskId: string) => Promise<AssuranceProjectionResult>,
924
899
  ): Promise<BatchRunReport> {
925
900
  let existing = readBatchRunState(input.root, input.batch_id);
926
- if (!existing) return startBatch(input);
901
+ if (!existing || existing.batch_state === "needs_human") return startBatch(input);
902
+ existing = applyPlanReconfirmation(input, existing);
927
903
  try {
928
904
  await validatePersistedRun(input, existing);
929
905
  } catch (error) {
@@ -937,7 +913,6 @@ export async function resumeBatch(
937
913
  }
938
914
  if (isTerminalBatchState(existing.batch_state))
939
915
  return replayTerminal(input.root, existing);
940
- if (existing.batch_state === "needs_human") return startBatch(input);
941
916
 
942
917
  // An enrolled-but-unsettled child must reach its own Kernel terminal
943
918
  // settlement before anything else, even under an expired authorization:
@@ -987,6 +962,26 @@ export async function resumeBatch(
987
962
  });
988
963
  return finalize(input.root, existing, "claim held by another batch", "needs-human-attention");
989
964
  }
965
+ if (holdsClaim) {
966
+ const failures = Math.max(existing.consecutive_qa_failures, fresh.qa_failure_count ?? 0);
967
+ if (failures >= existing.budget.qa_failure_limit && fresh.last_qa_failure_at &&
968
+ Date.parse(fresh.last_qa_failure_at) > Date.parse(existing.confirmation_time)) {
969
+ const reason = "QA failure limit reached; repair and settle the own child through Kernel before resuming the batch";
970
+ skipDependents(existing, driven.task_id, `dependency ${driven.task_id} parked`);
971
+ existing = writeBatchRunState(input.root, {
972
+ ...existing, batch_state: "needs_human", consecutive_qa_failures: failures,
973
+ children: existing.children.map((c) => c.task_id === driven.task_id ? { ...c, state: "needs_human", reason } : c),
974
+ });
975
+ return finalize(input.root, existing, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
976
+ { recovery: deriveAssuranceRecovery(driven.task_id, fresh, {}, fresh.recovery_findings) ?? undefined });
977
+ }
978
+ if (failures !== existing.consecutive_qa_failures)
979
+ existing = writeBatchRunState(input.root, { ...existing, consecutive_qa_failures: failures });
980
+ // A failed preparation can leave frozen inputs with run_qa pending.
981
+ // The Parent owns that attempt; batch re-entry never blindly reruns it.
982
+ if (fresh.projection.next_obligation === "run_qa" || fresh.projection.artifact_state !== "frozen")
983
+ return executorHandoff(existing, driven.task_id, fresh);
984
+ }
990
985
  if (!holdsClaim) {
991
986
  // review-1(4th round): a claimless projection still carries a
992
987
  // projection body when the task is a terminal owner. If the fresh
@@ -1001,6 +996,7 @@ export async function resumeBatch(
1001
996
  if (settledRemotely) {
1002
997
  existing = writeBatchRunState(input.root, {
1003
998
  ...existing,
999
+ consecutive_qa_failures: 0,
1004
1000
  children: existing.children.map((c) =>
1005
1001
  c.task_id === driven.task_id
1006
1002
  ? { ...c, state: "settled", reason: "crash after settlement; resuming at commit" }
@@ -1142,6 +1138,8 @@ async function driveInterruptedChild(
1142
1138
  intentPath,
1143
1139
  });
1144
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");
1145
1143
  const adopted =
1146
1144
  existing ??
1147
1145
  (await doCommit().catch((error: unknown) => {
@@ -1178,39 +1176,9 @@ async function driveInterruptedChild(
1178
1176
  );
1179
1177
  record.consecutive_qa_failures = 0;
1180
1178
  persist();
1181
- try {
1182
- const planChild = input.children.find((c) => c.task_id === child.task_id);
1183
- const intentPath = planChild?.intent_path ?? undefined;
1184
- const { commit } = input.git?.commitChild
1185
- ? await input.git.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
1186
- : input.kernel.commitChild
1187
- ? await input.kernel.commitChild(input.root, child.task_id, input.batch_id, head, record.branch, intentPath)
1188
- : await commitBatchChild({
1189
- root: input.root,
1190
- taskId: child.task_id,
1191
- batchId: input.batch_id,
1192
- expectedHead: head,
1193
- branch: record.branch,
1194
- intentPath,
1195
- });
1196
- record.children = record.children.map((c) =>
1197
- c.task_id === child.task_id ? { ...c, state: "committed", commit } : c,
1198
- );
1199
- record.commits.push(commit);
1200
- head = commit;
1201
- persist();
1202
- child = { ...child, state: "committed", commit };
1203
- } catch (error) {
1204
- const message = error instanceof Error ? error.message : String(error);
1205
- record.children = record.children.map((c) =>
1206
- c.task_id === child.task_id ? { ...c, state: "needs_human", reason: message } : c,
1207
- );
1208
- // review-4: dependents are skipped_blocked, not left pending.
1209
- skipDependents(record, child.task_id, `dependency ${child.task_id} failed to commit`);
1210
- record.batch_state = "failed";
1211
- persist();
1212
- return finalize(input.root, record, message, "");
1213
- }
1179
+ // Use the same lookup/commit path for new settlement and crash recovery.
1180
+ child = { ...child, state: "settled", reason: null };
1181
+ continue;
1214
1182
  } else if (terminal.state === "review_ready") {
1215
1183
  record.children = record.children.map((c) =>
1216
1184
  c.task_id === child.task_id
@@ -1227,24 +1195,31 @@ async function driveInterruptedChild(
1227
1195
  } else if (terminal.state === "rework" && terminal.operation === "qa") {
1228
1196
  // review-3(3rd round): interrupted-child rework applies the same
1229
1197
  // consecutive-failure limit as startBatch instead of parking on the
1230
- // first rework; below the limit the loop re-drives the same child.
1198
+ // first rework; below the limit implementation returns to the Parent.
1231
1199
  record.consecutive_qa_failures += 1;
1232
1200
  if (record.consecutive_qa_failures >= record.budget.qa_failure_limit) {
1233
- const reason = `QA failure limit reached: ${terminal.summary}`;
1201
+ const reason = "QA failure limit reached";
1234
1202
  record.children = record.children.map((c) =>
1235
1203
  c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
1236
1204
  );
1237
1205
  record.batch_state = "needs_human";
1238
1206
  skipDependents(record, child.task_id, `dependency ${child.task_id} parked`);
1239
1207
  persist();
1240
- return finalize(input.root, record, reason, "");
1208
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
1209
+ return finalize(input.root, record, reason, "Repair and settle the own child through Kernel, then re-confirm the batch.",
1210
+ { recovery: deriveAssuranceRecovery(child.task_id, fresh, terminal, fresh.recovery_findings) ?? undefined, diagnostics: terminal.diagnostics });
1241
1211
  }
1242
1212
  persist();
1213
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
1214
+ return executorHandoff(record, child.task_id, fresh, terminal);
1215
+ } else if (terminal.state === "rework" || terminal.environment_failure || terminal.state === "review_preparation_failed" || terminal.recovery?.category === "repair") {
1216
+ // Ordinary own-claim repair is a Parent handoff, not a new user gate.
1217
+ const fresh = requireFreshProjection(await input.kernel.projectTask(input.root, child.task_id), child.task_id);
1218
+ return executorHandoff(record, child.task_id, fresh, terminal);
1243
1219
  } else {
1244
1220
  // stopped | failed | blocked: park and stop.
1245
1221
  const reason =
1246
- terminal.state === "stopped" ? "Kernel reported the child stopped"
1247
- : terminal.state === "rework" ? terminal.summary : terminal.reason;
1222
+ terminal.state === "stopped" ? "Kernel reported the child stopped" : terminal.reason;
1248
1223
  record.children = record.children.map((c) =>
1249
1224
  c.task_id === child.task_id ? { ...c, state: "needs_human", reason } : c,
1250
1225
  );
@@ -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,
@@ -315,6 +334,17 @@ export interface BatchRunReport {
315
334
  children: BatchChildRun[];
316
335
  commits: string[];
317
336
  reason: string | null;
337
+ /** Non-authoritative foreground implementation obligation, never persisted as readiness. */
338
+ handoff?: {
339
+ role: "executor";
340
+ task_id: string;
341
+ run_id: string | null;
342
+ record_revision: string;
343
+ next_obligation: import("../kernel/types").AssuranceObligation;
344
+ };
345
+ /** Safe observation only; never an execution-readiness certificate. */
346
+ recovery?: import("../assurance/coordinator").AssuranceRecovery;
347
+ diagnostics?: import("../assurance/qa").QaCheckDiagnostic[];
318
348
  /** The single next action for the operator. */
319
349
  next_action: string;
320
350
  created_at: string;
@@ -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();
@@ -19,11 +19,13 @@ export interface GitWorkspaceSnapshot {
19
19
  }
20
20
 
21
21
  function git(root: string, args: string[]): string | null {
22
- const result = spawnSync("git", ["-C", root, ...args], {
22
+ const diff = args[0] === "diff";
23
+ const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
24
+ const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
23
25
  encoding: "utf8",
24
26
  stdio: ["ignore", "pipe", "pipe"],
25
27
  });
26
- return result.status === 0 ? result.stdout : null;
28
+ return result.status === 0 || (diff && result.status === 1) ? result.stdout : null;
27
29
  }
28
30
 
29
31
  function splitNull(value: string): string[] {
@@ -217,12 +219,14 @@ export function setGitTaskSnapshotTestHook(hook: () => void): () => void {
217
219
  }
218
220
 
219
221
  function gitBytes(root: string, args: string[]): Buffer {
220
- const result = spawnSync("git", ["-C", root, ...args], {
222
+ const diff = args[0] === "diff";
223
+ const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args;
224
+ const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], {
221
225
  encoding: null,
222
226
  stdio: ["ignore", "pipe", "pipe"],
223
227
  maxBuffer: 8 * 1024 * 1024,
224
228
  });
225
- if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
229
+ if ((result.status !== 0 && !(diff && result.status === 1)) || !Buffer.isBuffer(result.stdout)) {
226
230
  throw new Error(`Git task snapshot command failed: git ${args.join(" ")}`);
227
231
  }
228
232
  return result.stdout;
@@ -631,6 +635,29 @@ export function taskRevisionIdentity(
631
635
  };
632
636
  }
633
637
 
638
+ /** The same v4 identity for an immutable delivered commit, without touching the index. */
639
+ export function taskCommitRevisionIdentity(projectRoot: string, scopeHint: unknown, baseHead: string, commit: string): GitTaskDiffIdentity {
640
+ const requested = resolve(projectRoot);
641
+ if (lstatSync(requested).isSymbolicLink()) throw new Error("task commit root must be a real directory");
642
+ const root = realpathSync(requested), scope = assertCanonicalTaskScope(scopeHint);
643
+ if (!GIT_OBJECT_ID.test(baseHead) || !GIT_OBJECT_ID.test(commit)) throw new Error("invalid task commit identity");
644
+ if (gitRequired(root, ["cat-file", "-t", baseHead], "unreadable task base") !== "commit" ||
645
+ gitRequired(root, ["cat-file", "-t", commit], "unreadable task commit") !== "commit" ||
646
+ git(root, ["merge-base", "--is-ancestor", baseHead, commit]) === null) throw new Error("invalid task commit ancestry");
647
+ const paths = decodeNullPaths(gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", baseHead, commit, "--"]), "task commit paths")
648
+ .filter(path => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope)).sort(comparePaths);
649
+ assertNoCaseFoldCollisions(paths, "task commit paths");
650
+ const changed_paths: Record<string, GitTaskIndexEntry> = {};
651
+ for (const path of paths) {
652
+ const current = headEntry(root, commit, path), base = headEntry(root, baseHead, path);
653
+ changed_paths[path] = { status: !base ? "added" : !current ? "deleted" : "modified",
654
+ mode: current?.mode ?? null, oid: current?.oid ?? null, base_mode: base?.mode ?? null, base_oid: base?.oid ?? null };
655
+ }
656
+ const snapshot: GitTaskRevisionSnapshot = { kind: "git-task-revision-v1", repository_root: root, base_head: baseHead,
657
+ base_tree: gitRequired(root, ["rev-parse", `${baseHead}^{tree}`], "unreadable task base tree"), scope, changed_paths };
658
+ return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: paths };
659
+ }
660
+
634
661
  /** The single v4 freshness identity shared by QA, Review, authorization, and completion. */
635
662
  export function taskRevisionDiffHash(
636
663
  projectRoot: string,