immune-brain 5.3.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 (75) 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 +13 -1
  12. package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
  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-planner.md +57 -16
  17. package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
  18. package/plugins/immune-brain/dist/imm-run.md +89 -344
  19. package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
  20. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
  21. package/plugins/immune-brain/hooks/hooks.json +21 -1
  22. package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
  23. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
  24. package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +1 -7
  26. package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
  27. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
  28. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
  29. package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
  30. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
  31. package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
  32. package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
  35. package/plugins/immune-brain/runtime/claude/lane_guard.ts +8 -15
  36. package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
  37. package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
  40. package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
  42. package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
  43. package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
  44. package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
  45. package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
  46. package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
  47. package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
  50. package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
  52. package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
  53. package/plugins/immune-brain/runtime/plan_core.ts +15 -8
  54. package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
  58. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
  59. package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
  60. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
  62. package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
  63. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
  65. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
  66. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
  67. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
  68. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
  69. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
  70. package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
  71. package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
  72. package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
  73. package/plugins/immune-brain/skills/BASELINE.md +13 -1
  74. package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
  75. package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
@@ -24,6 +24,15 @@ import {
24
24
  type TaskIntentV1,
25
25
  type TaskRisk,
26
26
  } from "./types";
27
+ import {
28
+ applyScopeRiskFloor,
29
+ changedPathRiskFloor,
30
+ NO_RISK_FLOOR,
31
+ raiseRisk,
32
+ readRiskFloor,
33
+ touchesChangedPathFloor,
34
+ type RiskFloor,
35
+ } from "./risk_floor";
27
36
  import {
28
37
  mintToken,
29
38
  type TaskIntentIdentityToken,
@@ -33,91 +42,6 @@ import {
33
42
  export const INTENT_MAX_BYTES = 64 * 1024;
34
43
  export const INTENT_SIDECAR_RELATIVE_PREFIX = "docs/plans/";
35
44
 
36
- // Deterministic risk-tier floor: an intent whose scope_hint touches kernel or
37
- // authority runtime paths is forced to at least `material` regardless of the
38
- // tier the author declared, so self-graded `routine` can never buy weaker
39
- // gating over the paths where gating matters most. The floor is derived solely
40
- // from scope_hint, never from prose fields. The .pi-extension directory is the
41
- // human gate itself (enrollment confirmation, capability minting, and review
42
- // authorization), so a self-graded routine over it must not weaken gating over
43
- // the code that implements gating.
44
- export const RISK_FLOOR_SCOPE_PREFIXES = [
45
- "plugins/immune-brain/runtime/kernel",
46
- "plugins/immune-brain/runtime/authority_commit_receipts.ts",
47
- "plugins/immune-brain/runtime/assurance",
48
- "plugins/immune-brain/runtime/claude/interaction.ts",
49
- "plugins/immune-brain/runtime/claude/capability.ts",
50
- "plugins/immune-brain/runtime/claude/review_host.ts",
51
- "plugins/immune-brain/runtime/claude/kernel_ports.ts",
52
- "plugins/immune-brain/runtime/claude/mcp_server.ts",
53
- "plugins/immune-brain/.pi-extension",
54
- ] as const;
55
-
56
- // Concrete changed paths use a narrower, segment-safe prefix policy. Keep this
57
- // separate from the glob-aware scope_hint policy above: the two inputs have
58
- // different contracts and docs-only scope remains routine today.
59
- export const CHANGED_PATH_RISK_FLOOR_PREFIXES = [
60
- ...RISK_FLOOR_SCOPE_PREFIXES,
61
- "docs/specs",
62
- "docs/plans",
63
- ] as const;
64
-
65
- function segmentMatches(e: string, s: string): boolean {
66
- if (e === "*") return true;
67
- if (!e.includes("*") && !e.includes("?")) return e === s;
68
- const rx = new RegExp(
69
- `^${e.split("*").map((p) => p.split("?").map((q) => q.replace(/[.\\+^${}()|[\]\\/]/g, "\\$&")).join(".")).join(".*")}$`,
70
- );
71
- return rx.test(s);
72
- }
73
-
74
- // Decide whether a scope entry can match the kernel/authority prefix itself,
75
- // any of its ancestor prefixes, or any descendant path beneath it. Glob `**`
76
- // spans zero or more segments, `*` matches one segment, `?` one character.
77
- // A match on any of those relations means the entry touches the floored path,
78
- // so a routine-declared intent cannot hide behind wildcard-leading patterns.
79
- function scopeEntryTouchesPrefixSegments(es: string[], ps: string[]): boolean {
80
- const memo = new Map<string, boolean>();
81
- const m = (pi: number, ei: number): boolean => {
82
- const key = `${pi}:${ei}`;
83
- const cached = memo.get(key);
84
- if (cached !== undefined) return cached;
85
- let result: boolean;
86
- if (ei === es.length) {
87
- result = true; // es matched a prefix of ps, ps itself, or a descendant
88
- } else {
89
- const e = es[ei];
90
- if (e === "**") {
91
- if (pi >= ps.length) result = true; // beyond ps, descendant always exists
92
- else result = m(pi, ei + 1) || m(pi + 1, ei);
93
- } else if (pi < ps.length && segmentMatches(e, ps[pi])) {
94
- result = m(pi + 1, ei + 1);
95
- } else if (pi >= ps.length) {
96
- result = m(pi + 1, ei + 1); // remaining literal segments match descendant segments
97
- } else {
98
- result = false;
99
- }
100
- }
101
- memo.set(key, result);
102
- return result;
103
- };
104
- return m(0, 0);
105
- }
106
-
107
- function scopeEntryTouchesRiskFloorPaths(entry: string): boolean {
108
- if (!entry) return false;
109
- const es = entry.split("/");
110
- return RISK_FLOOR_SCOPE_PREFIXES.some((prefix) =>
111
- scopeEntryTouchesPrefixSegments(es, prefix.split("/")),
112
- );
113
- }
114
-
115
- function riskFloorForScope(scopeHint: string[]): TaskRisk | null {
116
- return scopeHint.some(scopeEntryTouchesRiskFloorPaths)
117
- ? "material"
118
- : null;
119
- }
120
-
121
45
  const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
122
46
  const portablePathCollator = new Intl.Collator("und", {
123
47
  usage: "search",
@@ -318,18 +242,16 @@ export function parseTaskIntentV1(raw: unknown): TaskIntentV1 {
318
242
  const acceptance = (acceptanceRaw as unknown[]).map((item, index) =>
319
243
  parseAcceptanceItemV1(item, index, []),
320
244
  );
321
- const declaredRisk = risk as TaskRisk;
322
- const flooredRisk = riskFloorForScope(scopeHint);
245
+ // The declared risk is returned as written. The project's risk floor is
246
+ // applied where a risk is decided, by a caller that knows the project root
247
+ // (ADR 0023): `readTaskIntent`, `parseProjectTaskIntent`, `classifyTaskRisk`.
323
248
  return {
324
249
  contract: TASK_INTENT_CONTRACT_V1,
325
250
  task_id: taskId,
326
251
  goal,
327
252
  acceptance,
328
253
  scope_hint: scopeHint,
329
- risk:
330
- flooredRisk !== null && RISK_RANK[declaredRisk] < RISK_RANK[flooredRisk]
331
- ? flooredRisk
332
- : declaredRisk,
254
+ risk: risk as TaskRisk,
333
255
  revision,
334
256
  owner: "user",
335
257
  };
@@ -345,18 +267,27 @@ export function canonicalIntentHash(intent: unknown): string {
345
267
 
346
268
  const RISK_RANK: Record<TaskRisk, number> = { routine: 0, material: 1, critical: 2 };
347
269
 
270
+ /**
271
+ * A candidate intent parsed for one project: the strict parse, then that
272
+ * project's scope floor. Every caller that turns candidate JSON into an intent
273
+ * the Kernel will hash or record uses this, so the recorded risk and the risk
274
+ * `readTaskIntent` later reads back agree.
275
+ */
276
+ export function parseProjectTaskIntent(root: string, raw: unknown, recordedIntent?: TaskIntentV1): TaskIntentV1 {
277
+ const parsed = parseTaskIntentV1(raw);
278
+ // An enrolled revision keeps its recorded risk; today's policy only changes gating.
279
+ if (recordedIntent && parsed.revision === recordedIntent.revision &&
280
+ raiseRisk(recordedIntent.risk, parsed.risk) === recordedIntent.risk)
281
+ return { ...parsed, risk: recordedIntent.risk };
282
+ return applyScopeRiskFloor(parsed, readRiskFloor(root));
283
+ }
284
+
348
285
  export function classifyTaskRisk(
349
286
  changedPaths: readonly string[],
350
287
  declaredRisk: TaskRisk,
288
+ floor: RiskFloor = NO_RISK_FLOOR,
351
289
  ): TaskRisk {
352
- const pathFloor = changedPaths.some((path) =>
353
- CHANGED_PATH_RISK_FLOOR_PREFIXES.some(
354
- (prefix) => path === prefix || path.startsWith(`${prefix}/`),
355
- ),
356
- )
357
- ? "material"
358
- : "routine";
359
- return RISK_RANK[declaredRisk] >= RISK_RANK[pathFloor] ? declaredRisk : pathFloor;
290
+ return raiseRisk(declaredRisk, changedPathRiskFloor(changedPaths, floor));
360
291
  }
361
292
 
362
293
  function contentHashWithoutRevision(intent: TaskIntentV1): string {
@@ -364,19 +295,99 @@ function contentHashWithoutRevision(intent: TaskIntentV1): string {
364
295
  return canonicalIntentHash(rest);
365
296
  }
366
297
 
298
+ function isConcreteScopeEntry(entry: string): boolean {
299
+ return !entry.includes("*") && !entry.includes("?");
300
+ }
301
+
302
+ function scopeEntryParent(entry: string): string {
303
+ const slash = entry.lastIndexOf("/");
304
+ return slash < 0 ? "" : entry.slice(0, slash);
305
+ }
306
+
307
+ /**
308
+ * The entries a same-directory additive scope revision adds, or null when the
309
+ * delta is anything else (ADR 0021). Additive means the next scope contains
310
+ * every previous entry; same-directory means each added entry is a concrete
311
+ * path whose parent directory already holds a concrete previous entry, outside
312
+ * every path the project's risk floor names, and not an ancestor of any
313
+ * previous entry. The check is purely textual: it cannot tell a file from a
314
+ * directory written without a trailing slash, so each Host also refuses a
315
+ * directory in the worktree (see `assertScopeAdditionsAreFiles`).
316
+ */
317
+ export function sameDirectoryScopeAdditions(
318
+ previous: readonly string[],
319
+ next: readonly string[],
320
+ floor: RiskFloor = NO_RISK_FLOOR,
321
+ ): string[] | null {
322
+ const prior = new Set(previous);
323
+ const retained = new Set(next);
324
+ if (previous.some((entry) => !retained.has(entry))) return null;
325
+ const added = next.filter((entry) => !prior.has(entry));
326
+ const anchors = new Set(previous.filter(isConcreteScopeEntry).map(scopeEntryParent));
327
+ for (const entry of added) {
328
+ if (!isConcreteScopeEntry(entry) || touchesChangedPathFloor(entry, floor)) return null;
329
+ if (!anchors.has(scopeEntryParent(entry))) return null;
330
+ if (previous.some((existing) => existing.startsWith(`${entry}/`))) return null;
331
+ }
332
+ return added;
333
+ }
334
+
335
+ /** The previous concrete entries that anchor an added entry's directory. */
336
+ export function scopeAdditionAnchors(
337
+ previous: readonly string[],
338
+ added: readonly string[],
339
+ ): string[] {
340
+ const parents = new Set(added.map(scopeEntryParent));
341
+ return previous.filter(
342
+ (entry) => isConcreteScopeEntry(entry) && parents.has(scopeEntryParent(entry)),
343
+ );
344
+ }
345
+
346
+ // The revise_intent history entry carries the paths a compatible scope revision
347
+ // added, one `scope_added:<path>` line each, so Review can name them without a
348
+ // TaskRecord field (ADR 0021, D5).
349
+ const SCOPE_ADDED_PREFIX = "scope_added:";
350
+
351
+ export function scopeAdditionsReasonLines(added: readonly string[]): string[] {
352
+ return added.map((entry) => `${SCOPE_ADDED_PREFIX}${entry}`);
353
+ }
354
+
355
+ export function scopeAdditionsFromHistory(
356
+ history: ReadonlyArray<{ type: string; reason: string }>,
357
+ ): string[] {
358
+ const added: string[] = [];
359
+ for (const entry of history) {
360
+ if (entry.type !== "revise_intent") continue;
361
+ for (const line of entry.reason.split("\n"))
362
+ if (line.startsWith(SCOPE_ADDED_PREFIX)) added.push(line.slice(SCOPE_ADDED_PREFIX.length));
363
+ }
364
+ return [...new Set(added)].sort();
365
+ }
366
+
367
367
  export function classifyIntentRevision(
368
368
  previous: TaskIntentV1,
369
369
  next: TaskIntentV1,
370
+ floor: RiskFloor = NO_RISK_FLOOR,
370
371
  ): "unchanged" | "compatible" | "breaking" {
371
372
  if (contentHashWithoutRevision(previous) === contentHashWithoutRevision(next))
372
373
  return "unchanged";
373
374
 
375
+ const scopeChanged =
376
+ JSON.stringify(previous.scope_hint) !== JSON.stringify(next.scope_hint);
377
+ // Narrowing removes authority: a next scope made only of previous entries
378
+ // needs no confirmation. An empty scope is not a narrowing, and any added
379
+ // entry is classified by the same-directory rule below, as before.
380
+ const priorScope = new Set(previous.scope_hint);
381
+ const scopeNarrowed =
382
+ next.scope_hint.length > 0 && next.scope_hint.every((entry) => priorScope.has(entry));
374
383
  const breaking =
375
384
  previous.contract !== next.contract ||
376
385
  previous.task_id !== next.task_id ||
377
386
  previous.owner !== next.owner ||
378
387
  previous.goal !== next.goal ||
379
- JSON.stringify(previous.scope_hint) !== JSON.stringify(next.scope_hint) ||
388
+ (scopeChanged &&
389
+ !scopeNarrowed &&
390
+ sameDirectoryScopeAdditions(previous.scope_hint, next.scope_hint, floor) === null) ||
380
391
  RISK_RANK[next.risk] < RISK_RANK[previous.risk] ||
381
392
  previous.acceptance.some((prior) => {
382
393
  const current = next.acceptance.find((item) => item.id === prior.id);
@@ -387,6 +398,48 @@ export function classifyIntentRevision(
387
398
  return next.revision > previous.revision ? "compatible" : "breaking";
388
399
  }
389
400
 
401
+ /**
402
+ * The Host-side half of the same-directory rule (ADR 0021, D4). The classifier
403
+ * is pure and cannot tell a file from a directory, so before a Host submits a
404
+ * compatible scope revision it refuses one whose added entries, or the previous
405
+ * entries that anchor them, are directories in the worktree.
406
+ */
407
+ export function assertScopeAdditionsAreFiles(
408
+ root: string,
409
+ previous: readonly string[],
410
+ next: readonly string[],
411
+ ): void {
412
+ const added = sameDirectoryScopeAdditions(previous, next, readRiskFloor(root));
413
+ if (!added || added.length === 0) return;
414
+ for (const entry of [...added, ...scopeAdditionAnchors(previous, added)]) {
415
+ let isDirectory = false;
416
+ try {
417
+ isDirectory = statSync(join(root, entry)).isDirectory();
418
+ } catch {
419
+ // An absent or unreadable entry is not a directory.
420
+ }
421
+ if (isDirectory)
422
+ throw new Error(
423
+ `scope entry ${entry} is a directory, so adding it is not a same-directory file addition; name concrete files, or widen with a glob entry such as ${entry}/** through approve_breaking_intent_revision`,
424
+ );
425
+ }
426
+ }
427
+
428
+ /**
429
+ * The record as if the next intent's scope were already in force. A revision is
430
+ * prepared, checked and bound against the scope it asks for, not the scope it
431
+ * replaces (ADR 0021, D6).
432
+ */
433
+ export function withNextScope<R extends { intent_snapshot: { scope_hint: string[] } }>(
434
+ record: R,
435
+ nextScope: readonly string[],
436
+ ): R {
437
+ return {
438
+ ...record,
439
+ intent_snapshot: { ...record.intent_snapshot, scope_hint: [...nextScope] },
440
+ };
441
+ }
442
+
390
443
  // ---------------------------------------------------------------------------
391
444
  // Opaque file identity token.
392
445
  // ---------------------------------------------------------------------------
@@ -545,6 +598,8 @@ function readTaskIntentSource(
545
598
  root: string,
546
599
  taskId: string,
547
600
  requestedPath?: string,
601
+ requireTracked = true,
602
+ recordedIntent?: TaskIntentV1,
548
603
  ): TaskIntentReadSource {
549
604
  validateTaskId(taskId);
550
605
 
@@ -568,7 +623,7 @@ function readTaskIntentSource(
568
623
  const fileIdentity = pathIdentities[pathIdentities.length - 1];
569
624
 
570
625
  // Git tracking is an ownership convention, not user authentication.
571
- try {
626
+ if (requireTracked) try {
572
627
  execFileSync(
573
628
  "git",
574
629
  ["ls-files", "--error-unmatch", "--", sidecarPath],
@@ -615,7 +670,7 @@ function readTaskIntentSource(
615
670
  const sourceBytesSha256 = sha256Hex(bytes);
616
671
  let intent: TaskIntentV1;
617
672
  try {
618
- intent = parseTaskIntentV1(JSON.parse(bytes.toString("utf8")));
673
+ intent = parseProjectTaskIntent(canonicalRoot, JSON.parse(bytes.toString("utf8")), recordedIntent);
619
674
  } catch (error) {
620
675
  throw new TaskIntentObservationError(
621
676
  "invalid",
@@ -649,12 +704,21 @@ function readTaskIntentSource(
649
704
  };
650
705
  }
651
706
 
707
+ /** Validate the active sidecar before Enrollment can commit its untracked preconditions. */
708
+ export function observeEnrollmentTaskIntent(root: string, taskId: string): ObservedTaskIntent {
709
+ const { identity: _identity, ...observed } = readTaskIntentSource(
710
+ root, taskId, `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`, false,
711
+ );
712
+ return observed;
713
+ }
714
+
652
715
  export function observeTaskIntent(
653
716
  root: string,
654
717
  taskId: string,
655
718
  requestedPath?: string,
719
+ recordedIntent?: TaskIntentV1,
656
720
  ): ObservedTaskIntent {
657
- const { identity: _identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
721
+ const { identity: _identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath, true, recordedIntent);
658
722
  return observed;
659
723
  }
660
724
 
@@ -662,7 +726,8 @@ export function readTaskIntent(
662
726
  root: string,
663
727
  taskId: string,
664
728
  requestedPath?: string,
729
+ recordedIntent?: TaskIntentV1,
665
730
  ): ReadTaskIntentResult {
666
- const { identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath);
731
+ const { identity, ...observed } = readTaskIntentSource(root, taskId, requestedPath, true, recordedIntent);
667
732
  return { ...observed, token: mintToken(identity) };
668
733
  }
@@ -4,11 +4,13 @@
4
4
 
5
5
  import { createHash } from "node:crypto";
6
6
  import { completionDecision } from "./completion";
7
+ import { NO_RISK_FLOOR, type RiskFloor } from "./risk_floor";
7
8
  import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
8
9
  import {
9
10
  REDUCED_MUTATION_BRAND,
10
11
  type AuthorityAuditDescriptor,
11
12
  type ReducedTaskMutation,
13
+ type ReviewRevisionIdentityV1,
12
14
  type TaskAction,
13
15
  type TaskFinding,
14
16
  type TaskIntentRefV3,
@@ -22,7 +24,12 @@ import {
22
24
  parseTaskAction,
23
25
  parseTaskRecord,
24
26
  } from "./validation";
25
- import { classifyIntentRevision, canonicalIntentHash } from "./intent";
27
+ import {
28
+ canonicalIntentHash,
29
+ classifyIntentRevision,
30
+ sameDirectoryScopeAdditions,
31
+ scopeAdditionsReasonLines,
32
+ } from "./intent";
26
33
  import { assertValidSpecBinding } from "./spec_binding";
27
34
 
28
35
  const RISK_RANK: Record<TaskIntentV1["risk"], number> = {
@@ -132,6 +139,7 @@ function appendHistory(
132
139
  from: string,
133
140
  detail: string | null,
134
141
  audit: AuthorityAuditDescriptor | null,
142
+ reviewRevision?: ReviewRevisionIdentityV1,
135
143
  ): void {
136
144
  if (record.history.some((entry) => entry.id === action.event_id))
137
145
  throw new KernelInvariantError([
@@ -145,6 +153,7 @@ function appendHistory(
145
153
  to_state: string;
146
154
  reason: string;
147
155
  authority?: AuthorityAuditDescriptor;
156
+ review_revision?: ReviewRevisionIdentityV1;
148
157
  } = {
149
158
  id: action.event_id,
150
159
  at: action.at,
@@ -160,6 +169,7 @@ function appendHistory(
160
169
  ),
161
170
  };
162
171
  if (audit) entry.authority = { ...audit };
172
+ if (reviewRevision) entry.review_revision = { ...reviewRevision };
163
173
  record.history.push(entry as (typeof record.history)[number]);
164
174
  }
165
175
 
@@ -178,6 +188,7 @@ function hasPrivilegedKind(action: TaskAction): boolean {
178
188
  action.type === "approve_breaking_intent_revision" ||
179
189
  action.type === "request_rework" ||
180
190
  action.type === "authorize_rework" ||
191
+ action.type === "accept_residual_risk" ||
181
192
  action.type === "stop" ||
182
193
  action.type === "resolve_user_decision"
183
194
  );
@@ -207,15 +218,22 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
207
218
  /**
208
219
  * Rework rounds one Review run may demand before the Kernel pauses the task for
209
220
  * a human decision. Ordinary defects return to execution without a user gate,
210
- * but an unbounded repair/review cycle is not progress either.
221
+ * but an unbounded repair/review cycle is not progress either. The literal user
222
+ * may extend it with `authorize_rework` rounds; the grant is cumulative and
223
+ * persisted as `review_round_budget_extension`.
211
224
  */
212
225
  export const REVIEW_REWORK_ROUND_BUDGET = 5;
213
226
 
227
+ export function effectiveReviewRoundBudget(record: TaskRecord): number {
228
+ return REVIEW_REWORK_ROUND_BUDGET + (record.review_round_budget_extension ?? 0);
229
+ }
230
+
214
231
  export function reduceTask(
215
232
  recordRaw: TaskRecord,
216
233
  actionRaw: TaskAction,
217
234
  authorityAudit: AuthorityAuditDescriptor | null = null,
218
235
  changedPaths?: readonly string[],
236
+ floor: RiskFloor = NO_RISK_FLOOR,
219
237
  ): ReducedTaskMutation {
220
238
  const previous = parseTaskRecord(recordRaw);
221
239
  assertKernelInvariantsV3(previous.intent_snapshot, previous);
@@ -474,6 +492,7 @@ export function reduceTask(
474
492
  const revisionClass = classifyIntentRevision(
475
493
  record.intent_snapshot,
476
494
  action.next_intent,
495
+ floor,
477
496
  );
478
497
  if (action.type === "revise_intent" && revisionClass !== "compatible")
479
498
  throw new KernelInvariantError([
@@ -507,6 +526,16 @@ export function reduceTask(
507
526
  throw new KernelInvariantError([
508
527
  "next intent ref must match the next intent",
509
528
  ]);
529
+ // A compatible scope revision is self-served: the user confirmed nothing,
530
+ // so the added paths ride in this entry's reason for Review to name.
531
+ const selfServedAdditions =
532
+ action.type === "revise_intent"
533
+ ? sameDirectoryScopeAdditions(
534
+ record.intent_snapshot.scope_hint,
535
+ action.next_intent.scope_hint,
536
+ floor,
537
+ ) ?? []
538
+ : [];
510
539
  record.intent_snapshot = { ...action.next_intent };
511
540
  record.intent_ref = { ...action.next_intent_ref };
512
541
  if (action.type === "approve_breaking_intent_revision") {
@@ -516,7 +545,16 @@ export function reduceTask(
516
545
  }
517
546
  if (record.artifact_state === "frozen") record.artifact_state = "active";
518
547
  }
519
- appendHistory(record, action, from, `intent_revision_${record.intent_snapshot.revision}`, authorityAudit);
548
+ appendHistory(
549
+ record,
550
+ action,
551
+ from,
552
+ [
553
+ `intent_revision_${record.intent_snapshot.revision}`,
554
+ ...scopeAdditionsReasonLines(selfServedAdditions),
555
+ ].join("\n"),
556
+ authorityAudit,
557
+ );
520
558
  break;
521
559
  }
522
560
  case "request_rework": {
@@ -541,6 +579,23 @@ export function reduceTask(
541
579
  "request_rework requires review, qa, or user authority",
542
580
  ]);
543
581
  const round = reviewRound(record);
582
+ // The reviewed revision is audit data for the next round's delta, not
583
+ // authority: it is legal only on Review rework and, on a v4 record, must
584
+ // name the Enrollment base exactly as an approval does. Its absence is a
585
+ // legacy record and never blocks.
586
+ if (action.review_revision) {
587
+ if (authorityAudit.authority_kind !== "review")
588
+ throw new KernelInvariantError([
589
+ `${authorityAudit.authority_kind} rework cannot carry a review revision`,
590
+ ]);
591
+ if (
592
+ record.contract === "assurance_kernel/task_record/v4" &&
593
+ action.review_revision.base_head !== record.git_base_head
594
+ )
595
+ throw new KernelInvariantError([
596
+ "review_revision.base_head must equal the Enrollment git_base_head",
597
+ ]);
598
+ }
544
599
  // An anchor is authority: it decides whether this claim inherits an
545
600
  // existing refutation. The Kernel recomputes it from the finding's own
546
601
  // evidence, so a caller cannot bind one claim's identity to another
@@ -622,7 +677,7 @@ export function reduceTask(
622
677
  const parkForReplan =
623
678
  authorityAudit.authority_kind === "review" &&
624
679
  (disputed !== undefined ||
625
- (hasEffectiveBlockingNow && effectiveBlockingRounds >= REVIEW_REWORK_ROUND_BUDGET));
680
+ (hasEffectiveBlockingNow && effectiveBlockingRounds >= effectiveReviewRoundBudget(record)));
626
681
  if (!parkForReplan) {
627
682
  record.artifact_state = "active";
628
683
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
@@ -660,10 +715,14 @@ export function reduceTask(
660
715
  acceptance_id: disputed?.acceptance_id ?? null,
661
716
  source: "kernel" as const,
662
717
  review_round: round,
718
+ replan_cause:
719
+ disputed !== undefined
720
+ ? ("repeated_security_boundary" as const)
721
+ : ("budget_exhausted" as const),
663
722
  summary:
664
723
  disputed !== undefined
665
724
  ? "Review returned the same security boundary twice; a durable replan is required."
666
- : `Review exhausted its ${REVIEW_REWORK_ROUND_BUDGET}-round rework budget; a durable replan is required.`,
725
+ : `Review exhausted its ${effectiveReviewRoundBudget(record)}-round rework budget; a durable replan is required.`,
667
726
  };
668
727
  if (findingIds.has(boundary.id))
669
728
  throw new KernelInvariantError([
@@ -671,7 +730,14 @@ export function reduceTask(
671
730
  ]);
672
731
  record.findings.push(boundary);
673
732
  }
674
- appendHistory(record, action, from, `review_round_${round}`, authorityAudit);
733
+ appendHistory(
734
+ record,
735
+ action,
736
+ from,
737
+ `review_round_${round}`,
738
+ authorityAudit,
739
+ action.review_revision,
740
+ );
675
741
  break;
676
742
  }
677
743
  case "authorize_rework": {
@@ -691,11 +757,74 @@ export function reduceTask(
691
757
  "authorize_rework requires an open replan boundary",
692
758
  ]);
693
759
  for (const finding of open) finding.status = "resolved";
760
+ // The grant is cumulative so the budget is a single derivable number;
761
+ // a legacy caller without `rounds` grants exactly one further round.
762
+ record.review_round_budget_extension =
763
+ (record.review_round_budget_extension ?? 0) + (action.rounds ?? 1);
694
764
  record.artifact_state = "active";
695
765
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
696
766
  appendHistory(record, action, from, open.map((finding) => finding.id).join(","), authorityAudit);
697
767
  break;
698
768
  }
769
+ case "accept_residual_risk": {
770
+ // A parked record has already restored its artifacts for replan, so the
771
+ // exit cannot require a frozen snapshot; the open budget boundary below
772
+ // is the real gate and completion re-freezes through the normal path.
773
+ if (record.lifecycle !== "active")
774
+ throw new KernelInvariantError([
775
+ `cannot accept residual risk while lifecycle is ${record.lifecycle}`,
776
+ ]);
777
+ if (authorityAudit?.authority_kind !== "user")
778
+ throw new KernelInvariantError([
779
+ "accept_residual_risk requires literal-user authority",
780
+ ]);
781
+ const boundaries = record.findings.filter(
782
+ (finding) => finding.kind === "replan_required" && finding.status === "open",
783
+ );
784
+ // Only a budget exhaustion offers this exit. A boundary without a
785
+ // recorded cause predates the field and a repeated security boundary is
786
+ // a dispute about the design: both stay replan or stop only.
787
+ if (
788
+ boundaries.length === 0 ||
789
+ boundaries.some((finding) => finding.replan_cause !== "budget_exhausted")
790
+ )
791
+ throw new KernelInvariantError([
792
+ "accept_residual_risk requires an open replan boundary caused by budget exhaustion",
793
+ ]);
794
+ if (record.findings.some((finding) => finding.kind === "unresolved_user_decision" && finding.status === "open"))
795
+ throw new KernelInvariantError([
796
+ "accept_residual_risk cannot settle a task with an open user decision",
797
+ ]);
798
+ const accepted = record.findings.filter(
799
+ (finding) =>
800
+ finding.kind === "blocking" &&
801
+ finding.source === "review" &&
802
+ finding.status === "open",
803
+ );
804
+ if (accepted.length === 0)
805
+ throw new KernelInvariantError([
806
+ "accept_residual_risk requires at least one open blocking Review finding",
807
+ ]);
808
+ for (const finding of boundaries) finding.status = "resolved";
809
+ for (const finding of accepted) finding.status = "accepted_risk";
810
+ record.residual_risk_acceptance = {
811
+ intent_revision: record.intent_snapshot.revision,
812
+ intent_content_hash: record.intent_ref.content_hash,
813
+ diff_hash: diffHash,
814
+ finding_ids: accepted.map((finding) => finding.id),
815
+ reason: action.reason,
816
+ actor_id: action.actor_id,
817
+ accepted_at: action.at,
818
+ };
819
+ appendHistory(
820
+ record,
821
+ action,
822
+ from,
823
+ `accepted_risk:${accepted.map((finding) => finding.id).join(",")}\n${action.reason}`,
824
+ authorityAudit,
825
+ );
826
+ break;
827
+ }
699
828
  case "complete": {
700
829
  if (record.lifecycle !== "active" || record.artifact_state !== "frozen")
701
830
  throw new KernelInvariantError([
@@ -709,6 +838,7 @@ export function reduceTask(
709
838
  diffHash,
710
839
  record.intent_ref.content_hash,
711
840
  changedPaths,
841
+ floor,
712
842
  );
713
843
  if (!decision.complete)
714
844
  throw new KernelInvariantError([