immune-brain 5.1.0 → 5.1.2

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 (54) 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 +37 -5
  4. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
  5. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
  6. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
  7. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
  8. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  9. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
  10. package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
  11. package/plugins/immune-brain/dist/imm-planner.md +41 -5
  12. package/plugins/immune-brain/dist/imm-run.md +123 -9
  13. package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
  14. package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
  15. package/plugins/immune-brain/hooks/hooks.json +22 -1
  16. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  19. package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
  20. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
  21. package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
  22. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
  23. package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
  24. package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
  25. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  26. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  27. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +4 -1
  28. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
  29. package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
  30. package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
  31. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  32. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  33. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +6 -0
  34. package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
  35. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
  36. package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
  37. package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
  38. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  39. package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
  40. package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
  41. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
  42. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  43. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
  44. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
  46. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  47. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
  48. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
  49. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
  50. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
  52. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  53. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
  54. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
@@ -53,6 +53,7 @@ import {
53
53
  FILE_STORE_CLAIM_RELATIVE,
54
54
  FILE_STORE_TRANSACTIONS_RELATIVE,
55
55
  FILE_STORE_WORKSPACE_RELATIVE,
56
+ inspectRetiredStateResidue,
56
57
  stateDatabasePath,
57
58
  } from "./storage_paths";
58
59
  import { canonicalRecordHash } from "./reducer";
@@ -262,7 +263,7 @@ function isRetiredFileProvablySuperseded(
262
263
  /**
263
264
  * Refuse mutation while the retired `.imm/state/*.json` file store (or the
264
265
  * pre-cutover `.imm/tasks` layout) still holds authority. The check is bounded
265
- * to `existsSync` paths so it can run on every locked mutation.
266
+ * to retired paths so it can run on every locked mutation.
266
267
  */
267
268
  function assertNoRetiredFileStore(
268
269
  root: string,
@@ -270,11 +271,19 @@ function assertNoRetiredFileStore(
270
271
  taskId?: string | null,
271
272
  ): void {
272
273
  const canonical = canonicalRoot(root);
274
+ let residue: ReturnType<typeof inspectRetiredStateResidue>;
275
+ try {
276
+ residue = inspectRetiredStateResidue(canonical);
277
+ } catch (error) {
278
+ throw new KernelStoreSecurityError(
279
+ `retired file-store residue is invalid: ${error instanceof Error ? error.message : String(error)}`,
280
+ );
281
+ }
273
282
  // Real authority in the retired store: must be imported, never ignored.
274
283
  const retired: Array<[string, string]> = [
275
284
  [".imm/tasks", "pre-cutover task store"],
276
285
  [".imm/workspace.json", "pre-cutover workspace owner"],
277
- [".imm/state/tasks", "task records"],
286
+ ...(residue.task_entries.length > 0 ? [[".imm/state/tasks", "task records"] as [string, string]] : []),
278
287
  ];
279
288
  for (const [path, label] of retired) {
280
289
  if (existsSync(resolve(canonical, path)))
@@ -293,6 +302,7 @@ function assertNoRetiredFileStore(
293
302
  ];
294
303
  for (const [path, label] of derived) {
295
304
  const full = resolve(canonical, path);
305
+ if (path === FILE_STORE_WORKSPACE_RELATIVE && residue.workspace_owner === null) continue;
296
306
  if (!existsSync(full)) continue;
297
307
  if (db === undefined || typeof taskId !== "string" || taskId.length === 0)
298
308
  throw new KernelStoreSecurityError(
@@ -325,10 +335,16 @@ function retiredFileStoreConflict(
325
335
  taskId: string | null,
326
336
  ): string | null {
327
337
  const canonical = canonicalRoot(root);
338
+ let residue: ReturnType<typeof inspectRetiredStateResidue>;
339
+ try {
340
+ residue = inspectRetiredStateResidue(canonical);
341
+ } catch (error) {
342
+ return `retired file-store residue is invalid: ${error instanceof Error ? error.message : String(error)}`;
343
+ }
328
344
  const authority: Array<[string, string]> = [
329
345
  [".imm/tasks", "pre-cutover task store"],
330
346
  [".imm/workspace.json", "pre-cutover workspace owner"],
331
- [".imm/state/tasks", "task records"],
347
+ ...(residue.task_entries.length > 0 ? [[".imm/state/tasks", "task records"] as [string, string]] : []),
332
348
  ];
333
349
  for (const [path, label] of authority)
334
350
  if (existsSync(resolve(canonical, path)))
@@ -340,7 +356,7 @@ function retiredFileStoreConflict(
340
356
  [FILE_STORE_WORKSPACE_RELATIVE, "workspace owner"],
341
357
  ];
342
358
  for (const [path, label] of derived)
343
- if (existsSync(resolve(canonical, path)))
359
+ if ((path !== FILE_STORE_WORKSPACE_RELATIVE || residue.workspace_owner !== null) && existsSync(resolve(canonical, path)))
344
360
  return `retired file-store authority is present (${label}: ${path}); import it with the supported migration before mutating this worktree`;
345
361
  }
346
362
  const transactions = resolve(canonical, FILE_STORE_TRANSACTIONS_RELATIVE);
@@ -382,6 +382,36 @@ function readSmallFile(root: string, relativePath: string): string | null {
382
382
  }
383
383
  }
384
384
 
385
+ /** Shared, bounded classification of the two potentially inert retired paths. */
386
+ export function inspectRetiredStateResidue(root: string): {
387
+ task_entries: string[];
388
+ workspace_owner: string | null;
389
+ } {
390
+ const tasksStatus = entryStatus(root, FILE_STORE_TASKS_RELATIVE);
391
+ if (tasksStatus !== "absent" && tasksStatus !== "directory")
392
+ throw new Error(`${FILE_STORE_TASKS_RELATIVE} is not a regular directory`);
393
+ const entries = tasksStatus === "absent" ? [] : listEntries(root, FILE_STORE_TASKS_RELATIVE);
394
+ if (entries === null) throw new Error(`${FILE_STORE_TASKS_RELATIVE} is unreadable`);
395
+ const workspaceStatus = entryStatus(root, FILE_STORE_WORKSPACE_RELATIVE);
396
+ let owner: string | null = null;
397
+ if (workspaceStatus !== "absent") {
398
+ if (workspaceStatus !== "file")
399
+ throw new Error(`${FILE_STORE_WORKSPACE_RELATIVE} is not a regular file`);
400
+ const content = readSmallFile(root, FILE_STORE_WORKSPACE_RELATIVE);
401
+ if (content === null) throw new Error(`${FILE_STORE_WORKSPACE_RELATIVE} is unreadable`);
402
+ const raw = JSON.parse(content);
403
+ if (
404
+ raw === null || typeof raw !== "object" || Array.isArray(raw) ||
405
+ raw.contract !== "assurance_kernel/workspace/v1" ||
406
+ (raw.current_working !== null &&
407
+ (typeof raw.current_working !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(raw.current_working))) ||
408
+ (raw.current_working === null && Object.keys(raw).some((key) => !["contract", "current_working"].includes(key)))
409
+ ) throw new Error(`${FILE_STORE_WORKSPACE_RELATIVE} has invalid workspace content`);
410
+ owner = raw.current_working;
411
+ }
412
+ return { task_entries: entries, workspace_owner: owner };
413
+ }
414
+
385
415
  interface OldLayoutFacts {
386
416
  old_authority_present: boolean;
387
417
  blocked_active: boolean;
@@ -637,6 +667,7 @@ function inspectFileStoreLayout(root: string): FileStoreFacts {
637
667
  if (stateStatus === "symlink" || stateStatus === "other")
638
668
  throw new Error(`${STATE_RELATIVE} is ${stateStatus}`);
639
669
  if (stateStatus !== "directory") return facts;
670
+ const residue = inspectRetiredStateResidue(root);
640
671
  for (const entry of listEntries(root, STATE_RELATIVE) ?? []) {
641
672
  const full = `${STATE_RELATIVE}/${entry}`;
642
673
  const status = entryStatus(root, full);
@@ -667,13 +698,12 @@ function inspectFileStoreLayout(root: string): FileStoreFacts {
667
698
  continue;
668
699
  }
669
700
  if (full === FILE_STORE_WORKSPACE_RELATIVE) {
670
- const owner = readJsonField(root, full, "current_working");
671
- if (typeof owner === "string" && owner.length > 0) facts.blocked_active = true;
701
+ if (residue.workspace_owner !== null) facts.blocked_active = true;
672
702
  continue;
673
703
  }
674
704
  throw new Error(`unknown file under ${STATE_RELATIVE}: ${entry}`);
675
705
  }
676
- for (const entry of listEntries(root, FILE_STORE_TASKS_RELATIVE) ?? []) {
706
+ for (const entry of residue.task_entries) {
677
707
  const full = `${FILE_STORE_TASKS_RELATIVE}/${entry}`;
678
708
  const status = entryStatus(root, full);
679
709
  if (status === "symlink" || status === "other")
@@ -187,6 +187,13 @@ export interface TaskApprovalV2 {
187
187
  review_revision?: ReviewRevisionIdentityV1;
188
188
  /** Legal only on a review attestation, alongside its revision identity. */
189
189
  advisory_findings?: ReviewAdvisoryFindingV1[];
190
+ /**
191
+ * ADR 0017: sha256 over the reviewer's own result bytes as observed by the
192
+ * host, bound at submission so post-hoc audit can prove the persisted
193
+ * verdict equals what the reviewer produced. Legal only on a review
194
+ * attestation; emitted only when present so legacy records stay stable.
195
+ */
196
+ reviewer_verdict_sha256?: string;
190
197
  }
191
198
 
192
199
  export interface ReviewRevisionIdentityV1 {
@@ -314,7 +314,7 @@ export function parseApprovalV2(
314
314
  const item = objectAt(value, `record.approvals[${index}]`, violations);
315
315
  rejectUnknown(
316
316
  item,
317
- ["id", "kind", "authority_role", "task_revision", "intent_content_hash", "diff_hash", "actor_id", "summary", "advisory_findings",
317
+ ["id", "kind", "authority_role", "task_revision", "intent_content_hash", "diff_hash", "actor_id", "summary", "advisory_findings", "reviewer_verdict_sha256",
318
318
  ...(allowReviewRevision ? ["review_revision"] : [])],
319
319
  `record.approvals[${index}]`,
320
320
  violations,
@@ -365,6 +365,9 @@ export function parseApprovalV2(
365
365
  ),
366
366
  }
367
367
  : {}),
368
+ ...(item.reviewer_verdict_sha256 !== undefined
369
+ ? { reviewer_verdict_sha256: stringAt(item.reviewer_verdict_sha256, `record.approvals[${index}].reviewer_verdict_sha256`, violations) }
370
+ : {}),
368
371
  };
369
372
  }
370
373
 
@@ -419,7 +422,7 @@ function parseAttestationV3(
419
422
  const item = objectAt(value, path, violations);
420
423
  rejectUnknown(
421
424
  item,
422
- ["id", "kind", "authority_role", "task_revision", "intent_content_hash", "diff_hash", "actor_id", "summary", "acceptance_results", "advisory_findings",
425
+ ["id", "kind", "authority_role", "task_revision", "intent_content_hash", "diff_hash", "actor_id", "summary", "acceptance_results", "advisory_findings", "reviewer_verdict_sha256",
423
426
  ...(allowReviewRevision ? ["review_revision"] : [])],
424
427
  path,
425
428
  violations,
@@ -462,6 +465,13 @@ function parseAttestationV3(
462
465
  );
463
466
  if (advisoryFindings !== undefined && kind !== "review")
464
467
  violations.push(`${path}.advisory_findings is only valid on review attestations`);
468
+ const reviewerVerdictSha256 = item.reviewer_verdict_sha256 === undefined
469
+ ? undefined
470
+ : stringAt(item.reviewer_verdict_sha256, `${path}.reviewer_verdict_sha256`, violations);
471
+ if (reviewerVerdictSha256 !== undefined && kind !== "review")
472
+ violations.push(`${path}.reviewer_verdict_sha256 is only valid on review attestations`);
473
+ if (reviewerVerdictSha256 !== undefined && !SHA256_HEX.test(reviewerVerdictSha256))
474
+ violations.push(`${path}.reviewer_verdict_sha256 must be sha256:<64 hex>`);
465
475
  return {
466
476
  id: stringAt(item.id, `${path}.id`, violations),
467
477
  kind,
@@ -474,6 +484,7 @@ function parseAttestationV3(
474
484
  acceptance_results: acceptanceResults,
475
485
  ...(reviewRevision ? { review_revision: reviewRevision } : {}),
476
486
  ...(advisoryFindings !== undefined ? { advisory_findings: advisoryFindings } : {}),
487
+ ...(reviewerVerdictSha256 !== undefined ? { reviewer_verdict_sha256: reviewerVerdictSha256 } : {}),
477
488
  };
478
489
  }
479
490
 
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "5.1.0" as const;
2
+ export const PLUGIN_VERSION = "5.1.2" as const;
@@ -34,6 +34,13 @@ 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
+ A defect that lies inside the TaskIntent goal and `scope_hint` is a blocking
38
+ finding even when no acceptance assertion names its exact case word for word:
39
+ cite the acceptance whose principle it violates as `acceptance_id` and
40
+ `violated.ref`. Ask for the fix and a test that pins the case, never for the case
41
+ to be written into the acceptance text; a missing literal clause is not itself a
42
+ finding and does not call for an Intent revision.
43
+
37
44
  ## Invariant and Evidence Coverage
38
45
 
39
46
  Judge whether the change actually closes the invariant its acceptance names,
@@ -53,6 +60,24 @@ seam, or a seam silently replaced by a different one, is a finding carrying
53
60
  named, its absence is not a finding: candidates written before this rule carries
54
61
  no defect for lacking one.
55
62
 
63
+ When the Spec referenced by an acceptance maps an `automated` user scenario to
64
+ it, check that the delivered tests at that acceptance's agreed seam assert the
65
+ scenario's `Then`: a scenario whose observable result is asserted nowhere is a
66
+ finding carrying `violated.kind` of `acceptance` with `ref` the acceptance id.
67
+ A `manual` scenario, and an acceptance with no mapped scenario, produce no such
68
+ finding. This adds no finding kind and no verdict branch: it is the same
69
+ `acceptance` finding the seam rule above already defines.
70
+
71
+ Before pass, enumerate the immutable change set and read each complete per-file
72
+ diff, including deletions. Page bounded reads until every hunk is visible; a
73
+ truncated aggregate output or summary is incomplete coverage. Trace changed
74
+ high-risk behavior through its callers and state owner, and inspect a concrete
75
+ negative case against the embedded acceptance. If immutable evidence remains
76
+ unavailable or incomplete, report the concrete acceptance coverage gap in a
77
+ rework finding with required evidence. `inspected_paths` declares coverage; it
78
+ does not itself prove tool reading. Judge quality by evidence, not elapsed time
79
+ or tool-call count.
80
+
56
81
  A required check that is skipped, that matches zero tests, or whose prerequisite
57
82
  is absent does not prove the acceptance; do not accept it as evidence. Full
58
83
  suite coverage reported at a coarse pass count is not per-invariant evidence.
@@ -54,6 +54,13 @@ Record missing evidence as blocked and repair it before handoff. The Loop owns
54
54
  verdict submission, finding disposition and the second-rework generalize-or-refute
55
55
  rule.
56
56
 
57
+ A finding whose defect lies inside the TaskIntent goal and `scope_hint` is
58
+ ordinary rework even when no acceptance names its exact case: fix the code and
59
+ add the test that pins the case. Do not propose an Intent revision for it, and
60
+ never append a single input case to an acceptance assertion to close a finding;
61
+ acceptance states principles, tests enumerate cases. Route to `imm-planner` only
62
+ when the goal, the scope, or a user-visible commitment must change.
63
+
57
64
  ## Code Quality Guard
58
65
 
59
66
  Before handoff, check the implementation for real implementation rather than
@@ -233,5 +233,5 @@ export const STATIC_REVIEW_RULES = [
233
233
  * to a per-dispatch prompt, because they describe the dispatch itself.
234
234
  */
235
235
  export const REVIEWER_DISPATCH_RULES = [
236
- "A dispatched reviewer is never continued or re-prompted, including through SendMessage. The reserved prompt is dispatched verbatim. A blocked `submit_review` is recovered only through its returned `recovery_action`.",
236
+ "A dispatched reviewer is never continued or re-prompted, including through SendMessage. The reserved prompt is dispatched verbatim. The host binds this reviewer's final result bytes to the reservation, so a Parent may submit them without transcribing them. A blocked `submit_review` is recovered only through its returned `recovery_action`.",
237
237
  ] as const;
@@ -0,0 +1,160 @@
1
+ // Delegated in-Lane Intent revision (ADR 0018). A lane batch whose literal-user
2
+ // confirmation granted `revision_delegation` lets the coordinator approve a
3
+ // Lane child's breaking Intent revision that stays inside the child's TaskIntent
4
+ // as authorized for the batch. The approval is the ordinary Kernel
5
+ // `approve_breaking_intent_revision`, applied in the Lane's own Authority Store
6
+ // with user authority whose actor and confirmation reference name the batch, so
7
+ // the audit history tells it apart from an approval by the user in person.
8
+ // Out-of-bounds revisions are refused here and stay with the user's own gate.
9
+ import { execFileSync } from "node:child_process";
10
+ import { join } from "node:path";
11
+ import { writeFileSync } from "node:fs";
12
+ import { projectAssurance } from "../kernel/assurance_projection";
13
+ import { createMutationAuthorityRegistry, digestOfAction, type CapabilityBindingV2 } from "../kernel/authority_port";
14
+ import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
15
+ import { canonicalIntentHash, classifyIntentRevision, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
16
+ import { boundSpecPath } from "../kernel/spec_binding";
17
+ import { readTaskRecordRaw, recoverKernelStoreFollowUps } from "../kernel/storage";
18
+ import type { TaskIntentV1 } from "../kernel/types";
19
+ import { stagePlanningArtifactTransition } from "../assurance/verdict_authority";
20
+ import { captureStagedIntent, restoreStagedIntent } from "../staged_intent";
21
+ import { taskDeliveryIdentity } from "../workspace_scope";
22
+
23
+ /** The grant and its bounds, stated in the gate that grants it (both Hosts). */
24
+ export const REVISION_DELEGATION_TEXT =
25
+ "Optional, off by default: also let the batch coordinator approve a Lane child's breaking Intent revisions that stay inside that child's authorized TaskIntent (acceptance changes, a narrower scope_hint). Widening scope, changing risk, goal or the bound Spec still needs your own confirmation. The grant ends with this batch.";
26
+
27
+ /** The actor every delegated approval records; never the literal user. */
28
+ export const DELEGATED_ACTOR_ID = "batch-coordinator";
29
+
30
+ export interface RevisionDelegationGrant {
31
+ /** The confirmation that granted it; a fresh confirmation replaces it. */
32
+ confirmation_time: string;
33
+ }
34
+
35
+ export interface DelegatedRevisionRecord {
36
+ at: string;
37
+ task_id: string;
38
+ from_revision: number;
39
+ to_revision: number;
40
+ intent_content_hash: string;
41
+ confirmation_ref: string;
42
+ }
43
+
44
+ export interface LaneRevisionRequest {
45
+ task_id: string;
46
+ next_intent: TaskIntentV1;
47
+ }
48
+
49
+ /** The confirmation reference that traces a delegated approval to its batch authorization. */
50
+ export function delegatedConfirmationRef(batchId: string, grant: RevisionDelegationGrant): string {
51
+ return `delegated-batch:${batchId}@${grant.confirmation_time}`;
52
+ }
53
+
54
+ /** Untrusted tool input: shape only; the bounds are decided against Kernel facts. */
55
+ export function parseLaneRevision(value: unknown): LaneRevisionRequest | undefined {
56
+ if (value === undefined || value === null) return undefined;
57
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("invalid lane_revision: expected an object");
58
+ const { task_id: taskId, next_intent: nextIntent, ...rest } = value as Record<string, unknown>;
59
+ if (Object.keys(rest).length > 0) throw new Error("invalid lane_revision: unknown field");
60
+ if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
61
+ throw new Error("invalid lane_revision: task_id is not a valid task id");
62
+ const intent = parseTaskIntentV1(nextIntent);
63
+ if (intent.task_id !== taskId) throw new Error("invalid lane_revision: next_intent belongs to another task");
64
+ return { task_id: taskId, next_intent: intent };
65
+ }
66
+
67
+ /**
68
+ * Whether the delegation covers this revision. `authorized` is the child's
69
+ * TaskIntent as the batch authorized it; `current` is the Lane's enrolled one.
70
+ * Null means covered.
71
+ */
72
+ export function delegatedRevisionRefusal(authorized: TaskIntentV1, current: TaskIntentV1, next: TaskIntentV1): string | null {
73
+ if (classifyIntentRevision(current, next) !== "breaking") return "only a breaking revision needs this approval; apply a compatible one with revise_intent";
74
+ if (next.revision <= current.revision) return "the revision number must increase";
75
+ if (next.goal !== authorized.goal || next.owner !== authorized.owner || next.task_id !== authorized.task_id)
76
+ return "goal, owner and task identity are outside the delegation";
77
+ if (next.risk !== authorized.risk || next.risk !== current.risk) return "a risk change is outside the delegation";
78
+ if ((boundSpecPath(next) ?? null) !== (boundSpecPath(authorized) ?? null)) return "a change of the bound Spec is outside the delegation";
79
+ const allowed = new Set(authorized.scope_hint);
80
+ const widened = next.scope_hint.filter((entry) => !allowed.has(entry));
81
+ if (widened.length > 0) return `widening scope_hint is outside the delegation: ${widened.join(", ")}`;
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Apply one delegated breaking revision in the Lane, exactly as a Host applies
87
+ * an approved one: write and stage the next sidecar, re-read the projection,
88
+ * issue a user capability bound to that snapshot, execute, and stage the
89
+ * planning transition. The prior sidecar bytes and index entry come back on any
90
+ * failure before commit.
91
+ */
92
+ export function applyDelegatedIntentRevision(input: {
93
+ lane_root: string;
94
+ task_id: string;
95
+ next_intent: TaskIntentV1;
96
+ confirmation_ref: string;
97
+ now: string;
98
+ }): Promise<{ from_revision: number; to_revision: number; intent_content_hash: string }> {
99
+ return (async () => {
100
+ const { lane_root: root, task_id: taskId, next_intent: nextIntent, now } = input;
101
+ recoverKernelStoreFollowUps(root, taskId);
102
+ const prior = readTaskIntent(root, taskId, readTaskRecordRaw(root, taskId).record?.intent_ref.path);
103
+ const before = await projectAssurance(root, taskId, taskDeliveryIdentity);
104
+ if (before.error || before.claim?.task_id !== taskId || before.projection.lifecycle !== "active")
105
+ throw new Error(`the Lane does not hold an active run of ${taskId}`);
106
+ const nextHash = canonicalIntentHash(nextIntent);
107
+ const nextRef = { path: `docs/plans/${taskId}.intent.json`, content_hash: nextHash };
108
+ const snapshot = captureStagedIntent(root, prior.intent_ref.path);
109
+ try {
110
+ writeFileSync(join(root, prior.intent_ref.path), `${JSON.stringify(nextIntent, null, 2)}\n`);
111
+ execFileSync("git", ["add", "--", prior.intent_ref.path], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
112
+ const prepared = await projectAssurance(root, taskId, taskDeliveryIdentity);
113
+ if (prepared.error || prepared.projection.record_revision !== before.projection.record_revision)
114
+ throw new Error("the Lane's TaskRecord changed while the delegated revision was prepared");
115
+ const registry = createMutationAuthorityRegistry();
116
+ const action = capabilityActionFor({
117
+ op: "approve_breaking_intent_revision",
118
+ task_id: taskId,
119
+ at: now,
120
+ actor_id: DELEGATED_ACTOR_ID,
121
+ next_intent: nextIntent,
122
+ next_intent_ref: nextRef,
123
+ });
124
+ const binding: CapabilityBindingV2 = {
125
+ authority_kind: "user",
126
+ task_id: taskId,
127
+ ...(prepared.projection.run_id ? { run_id: prepared.projection.run_id } : {}),
128
+ action_digest: digestOfAction(action),
129
+ expected_record_hash: prepared.projection.record_revision,
130
+ intent_revision: nextIntent.revision,
131
+ intent_content_hash: nextHash,
132
+ diff_hash: prepared.projection.diff_hash,
133
+ actor_id: DELEGATED_ACTOR_ID,
134
+ confirmation_ref: input.confirmation_ref,
135
+ findings_digest: null,
136
+ };
137
+ const capability = registry.issue(binding);
138
+ const result = createCanaryApplication(registry).execute({
139
+ root,
140
+ task_id: taskId,
141
+ operation: {
142
+ op: "approve_breaking_intent_revision",
143
+ capability,
144
+ actor_id: DELEGATED_ACTOR_ID,
145
+ next_intent: nextIntent,
146
+ next_intent_ref: nextRef,
147
+ } as never,
148
+ prior_intent_token: prior.token,
149
+ diffProvider: taskDeliveryIdentity,
150
+ now,
151
+ });
152
+ stagePlanningArtifactTransition(root, result.record);
153
+ return { from_revision: prior.intent.revision, to_revision: nextIntent.revision, intent_content_hash: nextHash };
154
+ } catch (error) {
155
+ const current = readTaskRecordRaw(root, taskId);
156
+ if (current.record?.intent_snapshot.revision === prior.intent.revision) restoreStagedIntent(root, snapshot);
157
+ throw error;
158
+ }
159
+ })();
160
+ }
@@ -0,0 +1,222 @@
1
+ // Authority-backed disposition for a batch the plan moved past.
2
+ //
3
+ // A batch whose plan changed can neither reconfirm (reconfirmation is eligible
4
+ // only with zero recorded commits) nor continue (its authorization cannot
5
+ // execute the revised plan). Before this module the only exits were editing or
6
+ // deleting the state file by hand, which destroyed the evidence of what the
7
+ // batch had actually delivered — exactly what happened to the batch retired
8
+ // during the parallel-lane Initiative, whose record is no longer on disk.
9
+ //
10
+ // Retiring is one explicit literal-user decision. It writes the record to the
11
+ // terminal `superseded` state under the Kernel store lock, preserves every
12
+ // child state, commit and the batch branch, and grants no handoff: children
13
+ // this batch never executed carry no commit, no batch trailer and no approval,
14
+ // and nothing here infers their delivery from a retired record. The old
15
+ // authorization is never reused for the revised plan; a later batch starts
16
+ // under its own fresh authorization.
17
+ //
18
+ // Authority shape, deliberately: the literal-user native confirmation plus a
19
+ // locked expected-byte check that the record did not move while the gate was
20
+ // open. No Batch Authorization capability is minted, because retiring a record
21
+ // consumes no child and enrolls nothing — the capability exists to authorize
22
+ // child consumption, and minting one here would be ceremony without a property.
23
+ import { readSecureProjectBytes } from "../kernel/storage";
24
+ import { batchStatePath } from "../kernel/storage_paths";
25
+ import { batchReason, type BatchReason } from "./batch_reasons";
26
+ import { findExistingActiveBatch, findSettledBatchRecord, type BatchGateDecision } from "./batch_preflight";
27
+ import {
28
+ isTerminalBatchState,
29
+ replaceAnyBatchRunState,
30
+ writeBatchRunReport,
31
+ type AnyBatchRunStateRecord,
32
+ type BatchLaneRunReport,
33
+ type BatchRunReport,
34
+ type BatchRunState,
35
+ } from "./batch_state";
36
+
37
+ /** Child states whose Kernel run is still live; a live run settles on its own. */
38
+ const SERIAL_IN_FLIGHT: ReadonlySet<string> = new Set(["enrolled", "settled"]);
39
+ const LANE_IN_FLIGHT: ReadonlySet<string> = new Set(["lane_admitted", "enrolled", "settled", "lane_committed"]);
40
+
41
+ /** Evidence-only child projection for the gate; never an execution readiness. */
42
+ export interface StaleBatchChildEvidence {
43
+ task_id: string;
44
+ slice_id: string;
45
+ state: string;
46
+ commit: string | null;
47
+ lane_branch: string | null;
48
+ }
49
+
50
+ /** The exact record identity the literal user is asked to retire. */
51
+ export interface StaleBatchFacts {
52
+ batch_id: string;
53
+ initiative_slug: string;
54
+ batch_state: BatchRunState;
55
+ plan_digest: string;
56
+ branch: string;
57
+ base_head: string;
58
+ confirmation_time: string;
59
+ /** Commits this batch itself produced, in child order. */
60
+ recorded_commits: string[];
61
+ children: StaleBatchChildEvidence[];
62
+ }
63
+
64
+ export type StaleBatchLookup =
65
+ /** No active record for this Initiative. */
66
+ | { kind: "none" }
67
+ | { kind: "corrupt"; path: string }
68
+ | { kind: "terminal"; batch_id: string }
69
+ | { kind: "in_flight"; batch_id: string; task_id: string }
70
+ | { kind: "retirable"; record: AnyBatchRunStateRecord; facts: StaleBatchFacts };
71
+
72
+ const RETIRE_REASON =
73
+ "Retired by an explicit literal-user disposition: the batch plan moved past this record, so its authorization could neither reconfirm nor continue. " +
74
+ "Recorded child commits, child states and the batch branch are preserved as evidence. " +
75
+ "Children this batch never executed carry no commit, no batch trailer and no approval; nothing infers their delivery from this record.";
76
+
77
+ const RETIRE_NEXT_ACTION =
78
+ "The Initiative has no active batch. Run each remaining child with imm-run under its own authorization, or start a new batch for this Initiative after checking out its batch branch.";
79
+
80
+ function childEvidenceOf(record: AnyBatchRunStateRecord): StaleBatchChildEvidence[] {
81
+ if (record.contract === "assurance_kernel/batch_run_state/v2")
82
+ return record.children.map((child) => ({
83
+ task_id: child.task_id,
84
+ slice_id: child.slice_id,
85
+ state: child.state,
86
+ commit: child.commit,
87
+ lane_branch: child.lane?.branch ?? null,
88
+ }));
89
+ return record.children.map((child) => ({
90
+ task_id: child.task_id,
91
+ slice_id: child.slice_id,
92
+ state: child.state,
93
+ commit: child.commit,
94
+ lane_branch: null,
95
+ }));
96
+ }
97
+
98
+ function inFlightChildOf(record: AnyBatchRunStateRecord): { task_id: string } | null {
99
+ const inFlight = record.contract === "assurance_kernel/batch_run_state/v2" ? LANE_IN_FLIGHT : SERIAL_IN_FLIGHT;
100
+ return record.children.find((child) => inFlight.has(child.state)) ?? null;
101
+ }
102
+
103
+ /**
104
+ * The Initiative's active batch record as retirement evidence, or why it cannot
105
+ * be retired. Read-only; a terminal record is not a batch to dispose.
106
+ */
107
+ export function readStaleBatchLookup(root: string, initiativeSlug: string): StaleBatchLookup {
108
+ const found = findExistingActiveBatch(root, initiativeSlug);
109
+ if (found === null) {
110
+ const settled = findSettledBatchRecord(root, initiativeSlug);
111
+ if (settled) return { kind: "terminal", batch_id: settled.batch_id };
112
+ return { kind: "none" };
113
+ }
114
+ if (found.corrupt) return { kind: "corrupt", path: found.path };
115
+ const record = found.record;
116
+ if (isTerminalBatchState(record.batch_state)) return { kind: "terminal", batch_id: record.batch_id };
117
+ const inFlight = inFlightChildOf(record);
118
+ if (inFlight) return { kind: "in_flight", batch_id: record.batch_id, task_id: inFlight.task_id };
119
+ return {
120
+ kind: "retirable",
121
+ record,
122
+ facts: {
123
+ batch_id: record.batch_id,
124
+ initiative_slug: record.initiative_slug,
125
+ batch_state: record.batch_state,
126
+ plan_digest: record.plan_digest,
127
+ branch: record.branch ?? `imm/${record.initiative_slug}`,
128
+ base_head: record.base_head,
129
+ confirmation_time: record.confirmation_time,
130
+ recorded_commits: [...record.commits],
131
+ children: childEvidenceOf(record),
132
+ },
133
+ };
134
+ }
135
+
136
+ export type RetireStaleBatchOutcome<HostRejection> =
137
+ | {
138
+ outcome: "retired";
139
+ batch_id: string;
140
+ record: AnyBatchRunStateRecord;
141
+ report: BatchRunReport | BatchLaneRunReport;
142
+ }
143
+ | { outcome: "rejected"; rejection: BatchReason }
144
+ | { outcome: "host_rejection"; value: HostRejection };
145
+
146
+ /**
147
+ * Retire one Initiative's active batch record after a literal-user gate. The
148
+ * gate receives the record's own evidence; a declined, cancelled or failed
149
+ * interaction writes nothing. Between the gate and the write the record is
150
+ * re-read and must be byte-identical, so a record that moved while the gate was
151
+ * open is never retired under a stale decision.
152
+ */
153
+ export async function retireStaleBatch<HostRejection>(options: {
154
+ root: string;
155
+ initiative_slug: string;
156
+ now: string;
157
+ gate: (facts: StaleBatchFacts) => Promise<BatchGateDecision<HostRejection>>;
158
+ }): Promise<RetireStaleBatchOutcome<HostRejection>> {
159
+ const { root, initiative_slug: initiativeSlug, now } = options;
160
+ const lookup = readStaleBatchLookup(root, initiativeSlug);
161
+ if (lookup.kind === "none")
162
+ return { outcome: "rejected", rejection: batchReason("stale_batch_absent", `Initiative ${initiativeSlug} has no active batch`) };
163
+ if (lookup.kind === "corrupt")
164
+ return { outcome: "rejected", rejection: batchReason("batch_state_unreadable", lookup.path) };
165
+ if (lookup.kind === "terminal")
166
+ return {
167
+ outcome: "rejected",
168
+ rejection: batchReason("stale_batch_absent", `batch ${lookup.batch_id} already reached a terminal state`),
169
+ };
170
+ if (lookup.kind === "in_flight")
171
+ return {
172
+ outcome: "rejected",
173
+ rejection: batchReason("stale_batch_in_flight", `batch ${lookup.batch_id}, child ${lookup.task_id}`),
174
+ };
175
+
176
+ const { record, facts } = lookup;
177
+ const expected = readSecureProjectBytes(root, batchStatePath(record.batch_id));
178
+ const decision = await options.gate(facts);
179
+ if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
180
+ // A decline or a cancel is the Host's own envelope (batchReason
181
+ // confirmation_declined / confirmation_cancelled), so it arrives here as a
182
+ // host_rejection: nothing is written and the record stays parked.
183
+
184
+ // Confirmation cannot adopt a record that moved while the gate was open.
185
+ if (!readSecureProjectBytes(root, batchStatePath(record.batch_id)).equals(expected))
186
+ return { outcome: "rejected", rejection: batchReason("plan_changed", record.batch_id) };
187
+
188
+ // Children, commits and branch are carried over untouched: the disposition
189
+ // records what the batch did and did not do, and invents neither.
190
+ const retired: AnyBatchRunStateRecord = { ...record, batch_state: "superseded" };
191
+ const stored = replaceAnyBatchRunState(root, expected, retired, () => {
192
+ if (!readSecureProjectBytes(root, batchStatePath(record.batch_id)).equals(expected))
193
+ throw new Error("batch state changed before the disposition was applied");
194
+ });
195
+ const report =
196
+ stored.contract === "assurance_kernel/batch_run_state/v2"
197
+ ? writeBatchRunReport(root, {
198
+ contract: "assurance_kernel/batch_run_report/v1",
199
+ batch_id: stored.batch_id,
200
+ initiative_slug: stored.initiative_slug,
201
+ batch_state: stored.batch_state,
202
+ max_parallel: stored.max_parallel,
203
+ children: stored.children,
204
+ commits: stored.commits,
205
+ reason: RETIRE_REASON,
206
+ handoffs: [],
207
+ next_action: RETIRE_NEXT_ACTION,
208
+ created_at: now,
209
+ } satisfies BatchLaneRunReport)
210
+ : writeBatchRunReport(root, {
211
+ contract: "assurance_kernel/batch_run_report/v1",
212
+ batch_id: stored.batch_id,
213
+ initiative_slug: stored.initiative_slug,
214
+ batch_state: stored.batch_state,
215
+ children: stored.children,
216
+ commits: stored.commits,
217
+ reason: RETIRE_REASON,
218
+ next_action: RETIRE_NEXT_ACTION,
219
+ created_at: now,
220
+ } satisfies BatchRunReport);
221
+ return { outcome: "retired", batch_id: stored.batch_id, record: stored, report };
222
+ }