immune-brain 5.2.0 → 5.5.1

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 (77) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
  8. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
  9. package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
  10. package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
  11. package/plugins/immune-brain/dist/BASELINE.md +17 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
  13. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
  14. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
  15. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  16. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  17. package/plugins/immune-brain/dist/imm-planner.md +63 -19
  18. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  19. package/plugins/immune-brain/dist/imm-run.md +89 -344
  20. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  21. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  22. package/plugins/immune-brain/hooks/hooks.json +22 -2
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  25. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  26. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
  27. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  28. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  29. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  30. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  31. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  32. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  33. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  34. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  35. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  36. package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
  37. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  38. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  39. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  40. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  41. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  42. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  43. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  44. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  45. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  46. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  47. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  48. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  49. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  50. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  51. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  52. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  53. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  54. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  55. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  56. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  57. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  58. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  59. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  60. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  61. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  62. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  63. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  64. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  66. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  67. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  68. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  69. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  70. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  71. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  72. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  73. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  74. package/plugins/immune-brain/skills/BASELINE.md +17 -1
  75. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
  76. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
  77. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -24,9 +24,14 @@ import {
24
24
  import {
25
25
  INTENT_MAX_BYTES,
26
26
  canonicalIntentHash,
27
+ parseProjectTaskIntent,
27
28
  parseTaskIntentV1,
28
- RISK_FLOOR_SCOPE_PREFIXES,
29
29
  } from "../kernel/intent";
30
+ import {
31
+ floorMatchingScopeEntries,
32
+ floorScopePrefixes,
33
+ readRiskFloor,
34
+ } from "../kernel/risk_floor";
30
35
  import { inspectSpecBinding } from "../kernel/spec_binding";
31
36
  import { projectTask, resolveProjectedRisk } from "../kernel/completion";
32
37
  import { deriveAssuranceAuthorization } from "../kernel/assurance_projection";
@@ -39,7 +44,7 @@ import {
39
44
  import { inspectRoutingPolicy } from "../managed_task_routing_policy"
40
45
  import { projectLegacyAudit } from "../kernel/legacy_audit";
41
46
  import { inspectStorageLayout } from "../kernel/storage_paths";
42
- import { migrateLegacyLayout } from "../kernel/storage_layout_migration";
47
+ import { commitPendingMigrationDiff, migrateAndCommitLegacyLayout } from "../kernel/storage_layout_migration";
43
48
  import { readBackendClaim } from "../kernel/backend_claim";
44
49
  import { inspectEnrollmentGitBase, type EnrollmentGitBase } from "../assurance/enrollment_git_base";
45
50
  import { probeKernelStore } from "../kernel";
@@ -158,26 +163,6 @@ const RISK_ORDER: Record<TaskRisk, number> = {
158
163
  critical: 2,
159
164
  };
160
165
 
161
- function matchingFloorEntries(intent: TaskIntentV1): string[] {
162
- return intent.scope_hint.filter((entry) => {
163
- try {
164
- const trial = parseTaskIntentV1({
165
- contract: intent.contract,
166
- task_id: intent.task_id,
167
- goal: intent.goal,
168
- acceptance: intent.acceptance,
169
- scope_hint: [entry],
170
- risk: "routine",
171
- revision: intent.revision,
172
- owner: "user",
173
- });
174
- return trial.risk !== "routine";
175
- } catch {
176
- return false;
177
- }
178
- });
179
- }
180
-
181
166
  function readWorkspaceOwner(root: string): string | null {
182
167
  try {
183
168
  return readWorkspaceStateRaw(root).state.current_working;
@@ -204,10 +189,12 @@ function runInspect(root: string): KernelExecution {
204
189
  : null,
205
190
  workspace: { current_working: workspace },
206
191
  };
192
+ const floor = readRiskFloor(root);
207
193
  const idleRisk = {
208
194
  declared: null as TaskRisk | null,
209
195
  resolved: null as TaskRisk | null,
210
- floor_prefixes: [...RISK_FLOOR_SCOPE_PREFIXES],
196
+ floor_prefixes: floorScopePrefixes(floor),
197
+ floor_invalid: floor.invalid,
211
198
  floor_applied: false,
212
199
  matching_scope_entries: [] as string[],
213
200
  };
@@ -265,7 +252,7 @@ function runInspect(root: string): KernelExecution {
265
252
  if (raw.risk !== "routine" && raw.risk !== "material" && raw.risk !== "critical")
266
253
  throw new Error(`intent.risk is unreadable for ${claim.task_id}`);
267
254
  declared = raw.risk;
268
- intent = parseTaskIntentV1(raw);
255
+ intent = parseProjectTaskIntent(root, raw, record.intent_snapshot);
269
256
  if (canonicalIntentHash(intent) !== record.intent_ref.content_hash)
270
257
  throw new Error("TaskIntent sidecar does not match TaskRecord content hash");
271
258
  } catch (error) {
@@ -280,11 +267,11 @@ function runInspect(root: string): KernelExecution {
280
267
  }
281
268
  const diffHash = identity.diff_hash;
282
269
  const changedPaths = identity.changed_paths;
283
- const decision = projectTask(intent, record, diffHash, record.intent_ref.content_hash, changedPaths);
270
+ const decision = projectTask(intent, record, diffHash, record.intent_ref.content_hash, changedPaths, floor);
284
271
  const openUserDecisionCount = record.findings.filter(
285
272
  (finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
286
273
  ).length;
287
- const resolved = resolveProjectedRisk(intent, changedPaths);
274
+ const resolved = resolveProjectedRisk(intent, changedPaths, floor);
288
275
  return {
289
276
  result: jsonResult({
290
277
  contract: "assurance_kernel/inspect/v1",
@@ -303,7 +290,7 @@ function runInspect(root: string): KernelExecution {
303
290
  diff_hash: diffHash,
304
291
  lifecycle: record.lifecycle,
305
292
  artifact_state: record.artifact_state,
306
- risk: resolveProjectedRisk(intent, changedPaths),
293
+ risk: resolved,
307
294
  next_obligation: decision.next_obligation,
308
295
  fresh_acceptance_ids: decision.fresh_acceptance_ids,
309
296
  missing_acceptance_ids: decision.missing_acceptance_ids,
@@ -325,9 +312,10 @@ function runInspect(root: string): KernelExecution {
325
312
  risk: {
326
313
  declared,
327
314
  resolved,
328
- floor_prefixes: [...RISK_FLOOR_SCOPE_PREFIXES],
315
+ floor_prefixes: floorScopePrefixes(floor),
316
+ floor_invalid: floor.invalid,
329
317
  floor_applied: RISK_ORDER[resolved] > RISK_ORDER[declared],
330
- matching_scope_entries: matchingFloorEntries(intent),
318
+ matching_scope_entries: floorMatchingScopeEntries(intent.scope_hint, floor),
331
319
  },
332
320
  unobservable: UNOBSERVABLE,
333
321
  }),
@@ -474,31 +462,16 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
474
462
  };
475
463
  }
476
464
 
477
- // Storage-layout gate (BR-REQ-005/006): a stateful mutation recovers or
478
- // migrates first and then STOPS without authoring. Kernel transaction
479
- // markers recover under the store lock; the one-release migrator replays
480
- // its frozen manifest under both locks. The original authoring is retried
481
- // only after the affected migration diff is committed.
482
- try {
483
- probeKernelStore(root);
484
- } catch (error) {
485
- return {
486
- result: errorResult(
487
- "layout_recovery_failed",
488
- `Kernel transaction recovery failed before authoring: ${error instanceof Error ? error.message : String(error)}`,
489
- 1,
490
- ),
491
- journal: journalFor(
492
- "intent",
493
- null,
494
- "rejected",
495
- "source_invalid",
496
- null,
497
- "Resolve the pending Kernel transaction marker before retrying.",
498
- ),
499
- };
465
+ // Pending diffs may be committed; conversion stays on the explicit command.
466
+ let layout = inspectStorageLayout(root);
467
+ if (layout.layout === "migration_uncommitted") {
468
+ try {
469
+ commitPendingMigrationDiff(root);
470
+ layout = inspectStorageLayout(root);
471
+ } catch (error) {
472
+ return sourceFailure("intent", error);
473
+ }
500
474
  }
501
- const layout = inspectStorageLayout(root);
502
475
  if (layout.layout === "migration_blocked_active") {
503
476
  return {
504
477
  result: errorResult(
@@ -532,7 +505,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
532
505
  "rejected",
533
506
  "source_invalid",
534
507
  null,
535
- "Run imm-kernel migrate --storage-layout, commit the affected paths, then retry intent author.",
508
+ "Run imm-kernel migrate --storage-layout, then retry intent author.",
536
509
  ),
537
510
  };
538
511
  }
@@ -549,11 +522,17 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
549
522
  "rejected",
550
523
  "source_invalid",
551
524
  null,
552
- "Commit the migration diff or resolve the layout condition before authoring.",
525
+ "Resolve the reported layout condition before retrying authoring.",
553
526
  ),
554
527
  };
555
528
  }
556
529
 
530
+ try {
531
+ probeKernelStore(root);
532
+ } catch (error) {
533
+ return sourceFailure("intent", error);
534
+ }
535
+
557
536
  // Existing managed ownership rejects before opening the destination.
558
537
  const owner = intentActiveOwner(root);
559
538
  if (owner.kernel)
@@ -632,7 +611,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
632
611
  }
633
612
  let intent: ReturnType<typeof parseTaskIntentV1>;
634
613
  try {
635
- intent = parseTaskIntentV1(parsed);
614
+ intent = parseProjectTaskIntent(root, parsed);
636
615
  } catch (error) {
637
616
  return {
638
617
  result: errorResult(
@@ -1086,7 +1065,7 @@ function runIntentValidate(args: string[], root: string): KernelExecution {
1086
1065
  // Strict parse + verification eligibility per acceptance item.
1087
1066
  let intent: ReturnType<typeof parseTaskIntentV1>;
1088
1067
  try {
1089
- intent = parseTaskIntentV1(parsed);
1068
+ intent = parseProjectTaskIntent(root, parsed);
1090
1069
  } catch (error) {
1091
1070
  return {
1092
1071
  result: jsonResult(
@@ -1331,7 +1310,7 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
1331
1310
  reason: layout.reason,
1332
1311
  next_action:
1333
1312
  layout.layout === "migration_required"
1334
- ? "run the one-release migration through the next stateful mutation and commit the affected diff"
1313
+ ? "run imm-kernel migrate --storage-layout, then retry legacy audit"
1335
1314
  : "resolve the reported layout condition before legacy audit",
1336
1315
  },
1337
1316
  }),
@@ -1418,58 +1397,17 @@ function runStorageLayoutMigration(root: string): KernelExecution {
1418
1397
  "Resolve the reported layout condition, then rerun imm-kernel migrate --storage-layout.",
1419
1398
  ),
1420
1399
  };
1421
- const migration = migrateLegacyLayout(root);
1422
- if (migration.outcome === "migrated")
1400
+ const migration = migrateAndCommitLegacyLayout(root);
1401
+ if (migration.outcome === "migrated" || migration.outcome === "already_migrated")
1423
1402
  return {
1424
1403
  result: jsonResult({
1425
1404
  contract: "assurance_kernel/migration_completed/v1",
1426
1405
  operation: "storage layout migration",
1406
+ layout: "ready",
1427
1407
  affected_paths: migration.affected_paths,
1428
- next_action: "commit the affected migration paths, then retry the original command",
1429
- }),
1430
- journal: journalFor(
1431
- "migrate",
1432
- null,
1433
- "escalated",
1434
- "migration_ambiguous",
1435
- null,
1436
- "Commit the affected migration paths, then retry the original command.",
1437
- ),
1438
- };
1439
- if (migration.outcome === "migration_uncommitted")
1440
- return {
1441
- result: jsonResult({
1442
- contract: "assurance_kernel/migration_pending_commit/v1",
1443
- operation: "storage layout migration",
1444
- affected_paths: migration.affected_paths,
1445
- reason: migration.reason,
1446
- next_action: "commit the preserved audit evidence, then rerun imm-kernel migrate --storage-layout",
1447
- }),
1448
- journal: journalFor(
1449
- "migrate",
1450
- null,
1451
- "escalated",
1452
- "migration_ambiguous",
1453
- null,
1454
- "Commit the preserved audit evidence, then rerun imm-kernel migrate --storage-layout.",
1455
- ),
1456
- };
1457
- if (migration.outcome === "already_migrated")
1458
- return {
1459
- result: jsonResult({
1460
- contract: "assurance_kernel/migration_completed/v1",
1461
- operation: "storage layout migration",
1462
- affected_paths: [],
1463
1408
  next_action: "retry the original command",
1464
1409
  }),
1465
- journal: journalFor(
1466
- "migrate",
1467
- null,
1468
- "ok",
1469
- "command_ok",
1470
- null,
1471
- "Retry the original command.",
1472
- ),
1410
+ journal: journalFor("migrate", null, "ok", "command_ok", null, "Retry the original command."),
1473
1411
  };
1474
1412
  return {
1475
1413
  result: errorResult(
@@ -1,6 +1,7 @@
1
- import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { basename, relative, resolve, sep } from "node:path";
4
+ import { observeLocalInitiative } from "./local_initiative";
4
5
  import { readTaskIntent } from "./kernel/intent";
5
6
 
6
7
  const CONTRACT = "immune_brain/github_issue_tracker_result/v1" as const;
@@ -877,6 +878,49 @@ async function readBlockedByIds(
877
878
  }
878
879
  }
879
880
 
881
+ export async function assertIsolatedTaskPrerequisites(root: string, taskId: string,
882
+ transport?: GhTransport): Promise<void> {
883
+ const directory = resolve(root, "docs/initiatives");
884
+ const local = existsSync(directory) ? readdirSync(directory, { withFileTypes: true })
885
+ .filter(entry => entry.isFile() && /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(entry.name))
886
+ // Only a file that names this Task can own it; an unrelated or malformed Initiative file must
887
+ // not block Enrollment, while a malformed file that does name it still fails closed below.
888
+ .filter(entry => readFileSync(resolve(directory, entry.name), "utf8").includes(taskId))
889
+ .map(entry => observeLocalInitiative(root, entry.name.slice(0, -3)))
890
+ .filter(parent => parent.tasks.some(task => task.task_id === taskId)) : [];
891
+ if (local.length > 1) throw new Error("Slice has ambiguous Local Initiative ownership");
892
+ if (local.length === 1) {
893
+ const parent = local[0]!;
894
+ const child = parent.tasks.find(task => task.task_id === taskId)!;
895
+ if (child.blocked_by.length) {
896
+ const { observeParentCompletion } = await import("./unattended/batch_git");
897
+ const proof = await observeParentCompletion(root, parent.initiative_id, child.blocked_by);
898
+ if (proof.missing_task_ids.length) throw new Error(`Isolated Slice prerequisites are not integrated: ${proof.missing_task_ids.join(", ")}; execute only this Slice after its prerequisites are delivered`);
899
+ }
900
+ return;
901
+ }
902
+ if (!transport) {
903
+ const remotes = spawnSync("git", ["-C", root, "remote"], { encoding: "utf8" });
904
+ if (remotes.status !== 0) throw new Error("cannot verify repository carrier before Slice Enrollment");
905
+ if (!remotes.stdout.trim()) return;
906
+ }
907
+ const gh = transport ?? createGhTransport();
908
+ const source = await snapshot(resolve(root), gh, "create-initiative");
909
+ if ("contract" in source) throw new Error(source.message);
910
+ const task = taskLookup(source.issues, source.repository.id, taskId);
911
+ if (task.kind === "missing") return;
912
+ if (task.kind === "ambiguous") throw new Error(task.message);
913
+ const initiative = ownershipMarkerValue(task.issue.body, "initiative-id");
914
+ if (!initiative) throw new Error("Task Issue has no unique Initiative identity");
915
+ const observation = await observeGithubInitiative(root, initiative, gh);
916
+ const child = observation.tasks.find(candidate => candidate.task_id === taskId);
917
+ if (!child) throw new Error("Task Issue is absent from its Parent topology");
918
+ if (!child.blocked_by.length) return;
919
+ const { observeParentCompletion } = await import("./unattended/batch_git");
920
+ const proof = await observeParentCompletion(root, initiative, child.blocked_by);
921
+ if (proof.missing_task_ids.length) throw new Error(`Isolated Slice prerequisites are not integrated: ${proof.missing_task_ids.join(", ")}; execute only this Slice after its prerequisites are delivered`);
922
+ }
923
+
880
924
  export async function observeGithubInitiative(
881
925
  root: string,
882
926
  initiativeId: string,
@@ -1669,6 +1713,20 @@ async function closeParentWhenComplete(
1669
1713
  if (sliceChildren.length === 0
1670
1714
  || !sliceChildren.every((candidate) => candidate.state === "closed" && candidate.state_reason === "completed"))
1671
1715
  return childResult;
1716
+ const sliceIds = [...parent.body.matchAll(/<!-- immune-brain:slice-id=([^>]+) -->/g)].map(match => match[1]!);
1717
+ if (sliceIds.length !== sliceChildren.length || new Set(sliceIds).size !== sliceIds.length ||
1718
+ sliceChildren.some(candidate => !sliceIds.includes(ownershipMarkerValue(candidate.body, "slice-id") ?? "")))
1719
+ return retry("required Slice topology does not match the Parent", "ambiguous_remote_state");
1720
+ const taskIds = sliceChildren.map(candidate => ownershipMarkerValue(candidate.body, "task-id"));
1721
+ if (taskIds.some(id => !id)) return retry("required Slice has no Task identity", "ambiguous_remote_state");
1722
+ try {
1723
+ const { observeParentCompletion } = await import("./unattended/batch_git");
1724
+ const completion = await observeParentCompletion(root, initiativeId, taskIds as string[]);
1725
+ if (!completion.complete) return result(operation.op, childResult.status,
1726
+ `${childResult.message}; Parent #${parentNumber} remains open: ${completion.reason}`, child.issue);
1727
+ } catch (error) {
1728
+ return retry(`local completion proof could not be verified: ${error instanceof Error ? error.message : String(error)}`);
1729
+ }
1672
1730
  const close = await gh.run(["issue", "close", String(parentNumber), "--repo", source.repository.name_with_owner, "--reason", "completed"], { cwd: root });
1673
1731
  const confirmed = await readParent();
1674
1732
  if ("contract" in confirmed) return confirmed;
@@ -11,6 +11,7 @@ import {
11
11
  import { asTaskDiffSnapshot } from "./completion";
12
12
  import { taskDeliveryIdentity } from "../workspace_scope";
13
13
  import { canonicalIntentHash, readTaskIntent } from "./intent";
14
+ import { readRiskFloor } from "./risk_floor";
14
15
  import {
15
16
  inspectIntentTokenPair,
16
17
  consumeIntentToken,
@@ -98,7 +99,7 @@ export function applyTaskAction(
98
99
  ]);
99
100
 
100
101
  // Fresh secure reread of the sidecar inside the same lock.
101
- const freshRead = readTaskIntent(root, task_id, current.record.intent_ref.path);
102
+ const freshRead = readTaskIntent(root, task_id, current.record.intent_ref.path, current.record.intent_snapshot);
102
103
  const { prior: priorIdentity, current: currentIdentity } =
103
104
  inspectIntentTokenPair(prior_intent_token, freshRead.token);
104
105
  if (
@@ -161,6 +162,7 @@ export function applyTaskAction(
161
162
  action.type === "approve_breaking_intent_revision" ||
162
163
  action.type === "request_rework" ||
163
164
  action.type === "authorize_rework" ||
165
+ action.type === "accept_residual_risk" ||
164
166
  action.type === "stop" ||
165
167
  action.type === "resolve_user_decision";
166
168
 
@@ -201,6 +203,7 @@ export function applyTaskAction(
201
203
  action,
202
204
  inspectedAudit ? (inspectedAudit.audit as AuthorityAuditDescriptor) : null,
203
205
  trustedDiff.changed_paths,
206
+ readRiskFloor(root),
204
207
  );
205
208
  if (!isReducedMutation(mutation))
206
209
  throw new KernelInvariantError(["reducer returned an invalid mutation"]);
@@ -21,7 +21,9 @@ import {
21
21
  reconcileKernelAuthority,
22
22
  } from "./storage";
23
23
  import { projectTask, resolveProjectedRisk } from "./completion";
24
+ import { readRiskFloor, type RiskFloor } from "./risk_floor";
24
25
  import { inspectSpecBinding } from "./spec_binding";
26
+ import { MAX_REWORK_GRANT_ROUNDS } from "./validation";
25
27
  import { taskDeliveryIdentity } from "../workspace_scope";
26
28
  import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
27
29
  import type { TaskRecordV2 } from "./legacy_task_record";
@@ -41,6 +43,90 @@ export interface AssuranceAuthorizationReadiness {
41
43
  state: "resolve_user_decision" | "authorize_rework" | "none";
42
44
  /** Non-null only when Kernel facts prove the authorization is blocked. */
43
45
  blocked: string | null;
46
+ /**
47
+ * Present only with state "authorize_rework": the exits the literal user may
48
+ * choose at the open replan boundary. Both Hosts render exactly these and map
49
+ * each to one Kernel action, so the offer cannot drift between them.
50
+ */
51
+ exits?: ReworkExitsReadiness;
52
+ }
53
+
54
+ export interface ReworkExitsReadiness {
55
+ /** `authorize_rework` with `rounds` in this inclusive range. */
56
+ grant_rounds: { min: number; max: number };
57
+ /** `accept_residual_risk`; offered only when the boundary is a budget exhaustion. */
58
+ accept_residual_risk: boolean;
59
+ /** `approve_breaking_intent_revision`; always available. */
60
+ replan: true;
61
+ /** The open blocking Review findings the user would be accepting. */
62
+ findings: Array<{ id: string; summary: string }>;
63
+ }
64
+
65
+ /** The one exit the literal user picked at the replan boundary. */
66
+ export type ReworkChoice =
67
+ | { exit: "grant_rounds"; rounds: number }
68
+ | { exit: "accept_residual_risk"; reason: string }
69
+ | { exit: "replan" };
70
+
71
+ /**
72
+ * Parse a Host's form content into a choice. Both Hosts call this, so what is
73
+ * accepted cannot drift between them. Unknown keys, an exit the boundary does not
74
+ * offer, an out-of-range grant and a blank reason all yield null.
75
+ */
76
+ export function parseReworkChoice(exits: ReworkExitsReadiness, content: unknown): ReworkChoice | null {
77
+ if (typeof content !== "object" || content === null || Array.isArray(content)) return null;
78
+ const fields = content as Record<string, unknown>;
79
+ const allowed = new Set(["exit", "rounds", "reason"]);
80
+ if (Object.keys(fields).some((key) => !allowed.has(key))) return null;
81
+ if (fields.exit === "replan") return { exit: "replan" };
82
+ if (fields.exit === "grant_rounds") {
83
+ const rounds = fields.rounds ?? exits.grant_rounds.min;
84
+ if (!Number.isInteger(rounds)) return null;
85
+ const value = rounds as number;
86
+ if (value < exits.grant_rounds.min || value > exits.grant_rounds.max) return null;
87
+ return { exit: "grant_rounds", rounds: value };
88
+ }
89
+ if (fields.exit === "accept_residual_risk") {
90
+ if (!exits.accept_residual_risk) return null;
91
+ const reason = typeof fields.reason === "string" ? fields.reason.trim() : "";
92
+ return reason ? { exit: "accept_residual_risk", reason } : null;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** The exits as the lines both Hosts show, so the offer reads the same everywhere. */
98
+ export function reworkExitLines(exits: ReworkExitsReadiness): string[] {
99
+ return [
100
+ `Exit grant_rounds: grant ${exits.grant_rounds.min}-${exits.grant_rounds.max} more Review round(s)`,
101
+ ...(exits.accept_residual_risk
102
+ ? ["Exit accept_residual_risk: accept the open Review findings below as residual risk (a reason is required)"]
103
+ : []),
104
+ "Exit replan: revise the Intent through approve_breaking_intent_revision",
105
+ ...(exits.findings.length > 0
106
+ ? [`Open blocking Review findings (${exits.findings.length}):`, ...exits.findings.map((finding) => ` - ${finding.id}: ${finding.summary}`)]
107
+ : []),
108
+ ];
109
+ }
110
+
111
+ export function deriveReworkExits(record: Pick<TaskRecord, "findings">): ReworkExitsReadiness {
112
+ const boundaries = record.findings.filter(
113
+ (finding) => finding.kind === "replan_required" && finding.status === "open",
114
+ );
115
+ const findings = record.findings
116
+ .filter(
117
+ (finding) =>
118
+ finding.kind === "blocking" && finding.source === "review" && finding.status === "open",
119
+ )
120
+ .map((finding) => ({ id: finding.id, summary: finding.summary }));
121
+ return {
122
+ grant_rounds: { min: 1, max: MAX_REWORK_GRANT_ROUNDS },
123
+ accept_residual_risk:
124
+ boundaries.length > 0 &&
125
+ boundaries.every((finding) => finding.replan_cause === "budget_exhausted") &&
126
+ findings.length > 0,
127
+ replan: true,
128
+ findings,
129
+ };
44
130
  }
45
131
 
46
132
  export interface AssuranceProjection {
@@ -84,6 +170,7 @@ export interface AssuranceProjectionResult {
84
170
  export function deriveAssuranceAuthorization(input: {
85
171
  next_obligation: AssuranceObligation;
86
172
  open_user_decision_count: number;
173
+ exits?: ReworkExitsReadiness;
87
174
  }): AssuranceAuthorizationReadiness {
88
175
  if (input.open_user_decision_count === 1)
89
176
  return { state: "resolve_user_decision", blocked: null };
@@ -93,7 +180,11 @@ export function deriveAssuranceAuthorization(input: {
93
180
  blocked: `resolve-user-decision requires exactly one open user decision; found ${input.open_user_decision_count}`,
94
181
  };
95
182
  if (input.next_obligation === "revise_intent")
96
- return { state: "authorize_rework", blocked: null };
183
+ return {
184
+ state: "authorize_rework",
185
+ blocked: null,
186
+ ...(input.exits ? { exits: input.exits } : {}),
187
+ };
97
188
  return { state: "none", blocked: null };
98
189
  }
99
190
 
@@ -145,8 +236,10 @@ function projectHistoricalTerminal(
145
236
  };
146
237
  }
147
238
 
148
- function projectHistoricalTerminalV3(
149
- record: TaskRecordV3,
239
+ // Terminal proof validates historical settlement, not the caller's live checkout.
240
+ // Current integrated delivery remains independently checked by the batch boundary.
241
+ function projectSettledTerminal(
242
+ record: TaskRecordV3 | TaskRecord,
150
243
  recordRevision: string,
151
244
  workspaceRevision: string,
152
245
  ): AssuranceProjection {
@@ -201,6 +294,7 @@ function projectFromRecord(
201
294
  recordRevision: string,
202
295
  workspaceRevision: string,
203
296
  snapshot: TaskDiffSnapshot,
297
+ floor: RiskFloor,
204
298
  ): AssuranceProjection {
205
299
  const intent: TaskIntentV1 = record.intent_snapshot;
206
300
  const decision = projectTask(
@@ -209,6 +303,7 @@ function projectFromRecord(
209
303
  snapshot.diff_hash,
210
304
  record.intent_ref.content_hash,
211
305
  snapshot.changed_paths,
306
+ floor,
212
307
  );
213
308
  const approvalKinds = freshApprovalKinds(record, record.intent_ref.content_hash, snapshot.diff_hash);
214
309
  const openUserDecisionCount = record.findings.filter(
@@ -224,7 +319,7 @@ function projectFromRecord(
224
319
  diff_hash: snapshot.diff_hash,
225
320
  lifecycle: record.lifecycle,
226
321
  artifact_state: record.artifact_state,
227
- risk: resolveProjectedRisk(intent, snapshot.changed_paths),
322
+ risk: resolveProjectedRisk(intent, snapshot.changed_paths, floor),
228
323
  next_obligation: decision.next_obligation,
229
324
  fresh_acceptance_ids: decision.fresh_acceptance_ids,
230
325
  missing_acceptance_ids: decision.missing_acceptance_ids,
@@ -242,6 +337,7 @@ function projectFromRecord(
242
337
  : deriveAssuranceAuthorization({
243
338
  next_obligation: decision.next_obligation,
244
339
  open_user_decision_count: openUserDecisionCount,
340
+ exits: deriveReworkExits(record),
245
341
  }),
246
342
  };
247
343
  }
@@ -329,12 +425,7 @@ export async function projectAssurance(
329
425
  error: null,
330
426
  claim: null,
331
427
  projection: {
332
- ...projectFromRecord(
333
- committed.record,
334
- committed.revision,
335
- workspace.revision,
336
- diffProvider(root, committed.record),
337
- ),
428
+ ...projectSettledTerminal(committed.record, committed.revision, workspace.revision),
338
429
  run_id: committedRunId,
339
430
  },
340
431
  };
@@ -353,20 +444,12 @@ export async function projectAssurance(
353
444
  claim: null,
354
445
  projection: projectHistoricalTerminal(auditPair.record, auditPair.recordRevision, workspace.revision),
355
446
  };
356
- if (auditPair.record.contract === "assurance_kernel/task_record/v3")
357
- return {
358
- contract: "assurance_kernel/assurance_projection/v1",
359
- task_id: taskId,
360
- error: null,
361
- claim: null,
362
- projection: projectHistoricalTerminalV3(auditPair.record, auditPair.recordRevision, workspace.revision),
363
- };
364
447
  return {
365
448
  contract: "assurance_kernel/assurance_projection/v1",
366
449
  task_id: taskId,
367
450
  error: null,
368
451
  claim: null,
369
- projection: projectFromRecord(auditPair.record, auditPair.recordRevision, workspace.revision, diffProvider(root, auditPair.record)),
452
+ projection: projectSettledTerminal(auditPair.record, auditPair.recordRevision, workspace.revision),
370
453
  };
371
454
  }
372
455
  if (read.record.task_id !== taskId)
@@ -381,7 +464,7 @@ export async function projectAssurance(
381
464
  error: null,
382
465
  claim,
383
466
  projection: {
384
- ...projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
467
+ ...projectFromRecord(read.record, read.revision, workspace.revision, snapshot, readRiskFloor(root)),
385
468
  run_id: reconcileKernelAuthority(root, taskId).owner_run_id,
386
469
  },
387
470
  };