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
@@ -1,23 +1,22 @@
1
1
  import { createHash } from "node:crypto";
2
- import { snapshotDigest, QaPreparationError, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
2
+ import { snapshotDigest, QaPreparationError, qaPrepareObservation, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
3
3
  export { QaPreparationError, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
4
- import { assertDeliveryClean, materializeDeliveryWorkspace, removeTaskOwnedTree, writeDeliveryTree } from "./delivery_workspace";
5
- import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand } from "./verification";
6
- import { mkdtempSync, realpathSync } from "node:fs";
7
- import { tmpdir } from "node:os";
8
- import { join, sep } from "node:path";
9
- import { qaFindingId } from "./qa_findings";
10
- import { captureGitTaskRevisionSnapshot } from "../workspace_scope";
4
+ import { assertDeliveryClean, materializeDeliveryWorkspace, writeDeliveryTree } from "./delivery_workspace";
5
+ import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, readQaBaseline, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand, type QaBaseline } from "./verification";
6
+ import { qaFindingId, qaBaselineFindingId, PRE_EXISTING_BASELINE_PREFIX } from "./qa_findings";
7
+ import { captureGitTaskRevisionSnapshot, inScopeTrackedDrift } from "../workspace_scope";
11
8
  import { readTaskRecordRaw } from "../kernel/storage";
12
9
 
13
- function deliveryTreeForSnapshot(
14
- snapshot: SnapshotDescriptor,
15
- writeTree: typeof writeDeliveryTree,
16
- ): string {
17
- if (snapshot.review_revision?.review_tree) return snapshot.review_revision.review_tree;
10
+ function recordForSnapshot(snapshot: SnapshotDescriptor) {
18
11
  const record = readTaskRecordRaw(snapshot.root, snapshot.task_id).record;
19
12
  if (!record || record.contract !== "assurance_kernel/task_record/v4" || !record.git_base_head)
20
13
  throw new Error("QA delivery requires a TaskRecord v4 git_base_head");
14
+ return record;
15
+ }
16
+
17
+ function deliveryTreeForSnapshot(snapshot: SnapshotDescriptor): string {
18
+ if (snapshot.review_revision?.review_tree) return snapshot.review_revision.review_tree;
19
+ const record = recordForSnapshot(snapshot);
21
20
  const captured = captureGitTaskRevisionSnapshot(
22
21
  snapshot.root,
23
22
  record.intent_snapshot.scope_hint,
@@ -36,17 +35,27 @@ export async function runDeterministicQa(
36
35
  options: {
37
36
  signal?: AbortSignal;
38
37
  onProgress?: (progress: QaVerificationProgressInput) => void;
39
- // Coordination tests inject a fake executor without creating a real delivery.
38
+ /**
39
+ * Task QA runs in the task's working directory. Only the Lane batch
40
+ * integration recheck asks for a copy: its merge candidate is never
41
+ * checked out, so there is no working directory to run in.
42
+ */
43
+ delivery?: "materialized";
44
+ // Coordination tests inject a fake executor without a real repository.
40
45
  _runFixedVerification?: typeof runFixedVerification;
41
46
  _resolveVerificationCommand?: typeof resolveVerificationCommand;
42
47
  _materializeDeliveryWorkspace?: typeof materializeDeliveryWorkspace;
43
- _writeDeliveryTree?: typeof writeDeliveryTree;
48
+ /** Replaces the frozen tree and, in place, the comparison with it. */
49
+ _frozenTree?: { tree: string; drift?: () => string[] };
50
+ /** Replaces the Enrollment `base_head` the project baseline is read from. */
51
+ _baseHead?: string;
44
52
  } = {},
45
53
  ): Promise<AssuranceVerdict> {
46
54
  if (snapshot.role !== "qa") throw new Error("deterministic QA requires qa role");
47
55
  const live = () => { if (options.signal?.aborted) throw new VerificationAbortedError(); };
48
56
  live();
49
- const groups = new Map<string, Array<{ id: string; index: number; descriptor: VerificationDescriptor }>>();
57
+ type QaItem = { id: string; index: number; descriptor: VerificationDescriptor; baseline?: string };
58
+ const groups = new Map<string, QaItem[]>();
50
59
  for (const [index, item] of snapshot.acceptance.entries()) {
51
60
  const descriptor = descriptors.get(item.id);
52
61
  if (!descriptor) throw new Error(`verification descriptor missing for ${item.id}`);
@@ -55,12 +64,66 @@ export async function runDeterministicQa(
55
64
  group.push({ id: item.id, index, descriptor });
56
65
  groups.set(key, group);
57
66
  }
58
- const tree = snapshot.review_revision?.review_tree
59
- ?? (options._writeDeliveryTree ? "tree" : deliveryTreeForSnapshot(snapshot, writeDeliveryTree));
67
+ const materialized = options.delivery === "materialized";
68
+ // The project baseline comes from the Enrollment base_head tree, never the
69
+ // delivery. Only task QA in the working directory carries it: the Lane batch
70
+ // integration recheck reruns acceptance descriptors on a merge candidate.
71
+ const baselineHead = materialized ? undefined : options._baseHead
72
+ ?? (options._frozenTree ? undefined : recordForSnapshot(snapshot).git_base_head);
73
+ const baseline: QaBaseline | null = baselineHead ? readQaBaseline(snapshot.root, baselineHead) : null;
74
+ const totalChecks = snapshot.acceptance.length + (baseline?.checks.length ?? 0);
75
+ for (const [offset, check] of (baseline?.checks ?? []).entries()) {
76
+ // Baseline checks run after every acceptance descriptor, so they never share
77
+ // a group with one. Diagnostics reuse the acceptance index space past the
78
+ // acceptance count and name the check as `baseline:<id>`.
79
+ const key = `baseline:${JSON.stringify(check.descriptor.environment)}`;
80
+ const group = groups.get(key) ?? [];
81
+ group.push({ id: `baseline:${check.id}`, index: snapshot.acceptance.length + offset, descriptor: check.descriptor, baseline: check.id });
82
+ groups.set(key, group);
83
+ }
84
+ const tree = options._frozenTree?.tree ?? deliveryTreeForSnapshot(snapshot);
85
+ const drift = materialized ? null : options._frozenTree
86
+ ? options._frozenTree.drift ?? (() => [])
87
+ : ((scope: unknown) => () => inScopeTrackedDrift(snapshot.root, tree, scope))(recordForSnapshot(snapshot).intent_snapshot.scope_hint);
60
88
  const path = verificationPath();
89
+ // Rerun one failed baseline check against the materialized base_head tree,
90
+ // under the same descriptor. Anything short of the identical failure, a
91
+ // materialization or prepare problem included, leaves the finding blocking.
92
+ const failsIdenticallyAtBase = async (item: QaItem, observed: { exit_code: number; timed_out: boolean; output_limited: boolean }, baseTree: string): Promise<boolean> => {
93
+ live();
94
+ let copy: ReturnType<typeof materializeDeliveryWorkspace>;
95
+ try { copy = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, baseTree); }
96
+ catch { return false; }
97
+ const diagnostic = (reason: QaPreparationReason, stage: QaCheckDiagnostic["stage"]): QaCheckDiagnostic[] => [{ acceptance_id: item.id,
98
+ descriptor_ref: `acceptance/${item.index}/verification/command`,
99
+ descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
100
+ stage, outcome: reason, elapsed_ms: 0, exit_code: null, stdout_bytes: null, stderr_bytes: null }];
101
+ const run = async (command: VerificationCommand) => {
102
+ const frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(copy.root, command, path);
103
+ return (options._runFixedVerification ?? runFixedVerification)(copy.root, command, frozen, { signal: options.signal, path });
104
+ };
105
+ try {
106
+ const prepare = item.descriptor.environment.prepare;
107
+ if (prepare) {
108
+ const prepared = await run(prepare);
109
+ if (prepared.exit_code !== 0 || prepared.timed_out || prepared.output_limited) return false;
110
+ }
111
+ const result = await run(item.descriptor.command);
112
+ live();
113
+ return result.exit_code === observed.exit_code && result.timed_out === observed.timed_out && result.output_limited === observed.output_limited;
114
+ } catch (error) {
115
+ if (error instanceof VerificationAbortedError) throw error;
116
+ if (error instanceof VerificationCleanupError) throw new QaPreparationError("resolution", [item.id], "process_cleanup_failed", diagnostic("process_cleanup_failed", "resolution"));
117
+ return false;
118
+ } finally {
119
+ try { copy.cleanup(); }
120
+ catch { throw new QaPreparationError("integrity", [item.id], "delivery_cleanup_failed", diagnostic("delivery_cleanup_failed", "integrity")); }
121
+ }
122
+ };
61
123
  const findings: NonNullable<AssuranceVerdict["findings"]> = [];
62
124
  const evidence: unknown[] = [];
63
125
  const allCommands: Array<{ frozen: FrozenCommand; diagnostics: QaCheckDiagnostic[] }> = [];
126
+ const baselineFailures: Array<{ item: QaItem; finding: (typeof findings)[number]; observed: { exit_code: number; timed_out: boolean; output_limited: boolean } }> = [];
64
127
  for (const [environmentKey, group] of groups) {
65
128
  live();
66
129
  const ids = group.map(item => item.id);
@@ -72,23 +135,30 @@ export async function runDeterministicQa(
72
135
  descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
73
136
  stage, outcome, elapsed_ms: elapsed, exit_code: result?.exit_code ?? null,
74
137
  stdout_bytes: result ? Buffer.byteLength(result.stdout) : null, stderr_bytes: result ? Buffer.byteLength(result.stderr) : null }));
75
- const { delivery, home, deliveryPrefix } = (() => {
76
- let delivery: ReturnType<typeof materializeDeliveryWorkspace> | undefined, home: string | undefined;
138
+ const delivery = (() => {
139
+ if (!materialized) return { root: snapshot.root, seal: undefined, cleanup: () => {} };
140
+ let copy: ReturnType<typeof materializeDeliveryWorkspace> | undefined;
77
141
  try {
78
- delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
79
- home = mkdtempSync(join(tmpdir(), "imm-qa-home-"));
80
- return { delivery, home, deliveryPrefix: `${realpathSync(delivery.root)}${sep}` };
142
+ copy = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
143
+ return { root: copy.root, seal: copy.seal as string | undefined, cleanup: copy.cleanup };
81
144
  } catch {
82
145
  let reason: QaPreparationReason = "delivery_unavailable";
83
- try { try { delivery?.cleanup(); } finally { if (home) removeTaskOwnedTree(home); } }
146
+ try { copy?.cleanup(); }
84
147
  catch { reason = "delivery_cleanup_failed"; }
85
148
  throw new QaPreparationError("prepare", ids, reason, diagnostics("prepare", reason));
86
149
  }
87
150
  })();
88
151
  const clean = () => {
89
- if (options._materializeDeliveryWorkspace) return;
90
- try { assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths); }
91
- catch { throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift")); }
152
+ let paths: string[] | undefined;
153
+ try {
154
+ if (drift) { paths = drift(); if (!paths.length) return; }
155
+ else {
156
+ if (!options._materializeDeliveryWorkspace) assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths);
157
+ return;
158
+ }
159
+ } catch { paths = undefined; }
160
+ throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift"),
161
+ paths ? qaPrepareObservation(0, "", "working_directory", paths) : undefined);
92
162
  };
93
163
  const groupCommands: FrozenCommand[] = [];
94
164
  const execute = async (command: VerificationCommand, stage: "prepare" | "check", affected = group) => {
@@ -100,7 +170,7 @@ export async function runDeterministicQa(
100
170
  try { frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(delivery.root, command, path); }
101
171
  catch { throw fail("resolution", "executable_or_cwd_unavailable"); }
102
172
  let result;
103
- try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, home, path }); }
173
+ try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, path }); }
104
174
  catch (error) {
105
175
  if (error instanceof VerificationAbortedError) throw error;
106
176
  if (error instanceof VerificationLaunchError) throw fail("resolution", "process_launch_failed");
@@ -109,17 +179,13 @@ export async function runDeterministicQa(
109
179
  }
110
180
  live(); clean();
111
181
  groupCommands.push(frozen);
112
- if (frozen?.entry) {
113
- const identityDiagnostics = diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result);
114
- if (!command.executable.startsWith("./")) allCommands.push({ frozen, diagnostics: identityDiagnostics });
115
- else if (frozen.interpreter && !frozen.interpreter.path.startsWith(deliveryPrefix))
116
- allCommands.push({ frozen: { entry: frozen.interpreter, interpreter: null, interpreter_args: [] }, diagnostics: identityDiagnostics });
117
- }
182
+ if (frozen?.entry && !command.executable.startsWith("./"))
183
+ allCommands.push({ frozen, diagnostics: diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result) });
118
184
  // Paths and process output are not persisted. Identity hashes describe
119
185
  // observed entries, not a claim about every transitive dependency.
120
186
  evidence.push({ stage, environment: createHash("sha256").update(environmentKey).digest("hex"),
121
187
  command: createHash("sha256").update(JSON.stringify(command)).digest("hex"),
122
- entry: frozen?.entry?.content_hash ?? null, interpreter: frozen?.interpreter?.content_hash ?? null,
188
+ entry: frozen?.entry?.content_hash ?? null,
123
189
  exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited });
124
190
  if (Buffer.byteLength(JSON.stringify(evidence)) > 16_384)
125
191
  throw fail("prepare", "execution_metadata_limit_exceeded");
@@ -132,32 +198,46 @@ export async function runDeterministicQa(
132
198
  const result = await execute(environment.prepare, "prepare");
133
199
  if (result.exit_code !== 0 || result.timed_out || result.output_limited)
134
200
  throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit",
135
- diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result));
201
+ diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result),
202
+ qaPrepareObservation(environment.prepare.timeout_ms, result.stderr, materialized ? "isolated_materialization" : "working_directory"));
136
203
  }
137
204
  for (const item of group) {
138
205
  const started = performance.now();
139
206
  const progress = (phase: QaVerificationProgressInput["phase"], outcome: QaCheckDiagnostic["outcome"], result?: { exit_code: number; stdout: string; stderr: string }) => options.onProgress?.({
140
- index: item.index + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase, elapsed_ms: Math.round(performance.now() - started),
207
+ index: item.index + 1, total: totalChecks, acceptance_id: item.id, phase, elapsed_ms: Math.round(performance.now() - started),
141
208
  diagnostic: diagnostics("check", outcome, [item], "check", Math.round(performance.now() - started), result)[0],
142
209
  });
143
210
  progress("running", "running");
144
211
  const result = await execute(item.descriptor.command, "check", [item]);
145
212
  const failed = result.exit_code !== 0 || result.timed_out || result.output_limited;
146
213
  progress(failed ? "failed" : "passed", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : failed ? "nonzero_exit" : "passed", result);
147
- if (failed) findings.push({ id: qaFindingId(item.id, snapshotDigest(snapshot)), kind: "blocking", acceptance_id: item.id,
148
- summary: `verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""}${result.output_limited ? ", output limit" : ""}) stdout=${Buffer.byteLength(result.stdout)}B stderr=${Buffer.byteLength(result.stderr)}B`, findings_digest: "" });
214
+ if (failed) {
215
+ const finding = { id: item.baseline ? qaBaselineFindingId(item.baseline, snapshotDigest(snapshot)) : qaFindingId(item.id, snapshotDigest(snapshot)),
216
+ kind: "blocking" as const, acceptance_id: item.baseline ? null : item.id,
217
+ summary: `${item.baseline ? `baseline check ${item.baseline} ` : ""}verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""}${result.output_limited ? ", output limit" : ""}) stdout=${Buffer.byteLength(result.stdout)}B stderr=${Buffer.byteLength(result.stderr)}B`, findings_digest: "" };
218
+ findings.push(finding);
219
+ if (item.baseline) baselineFailures.push({ item, finding, observed: { exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited } });
220
+ }
149
221
  }
150
- // Recheck while project entries still exist; cleanup removes them.
222
+ // Recheck while a materialized copy still exists; its cleanup removes project entries.
151
223
  try {
152
224
  for (const frozen of groupCommands) if (frozen?.entry) assertCommandIdentity(frozen);
153
225
  } catch { throw new QaPreparationError("integrity", ids, "command_identity_changed", diagnostics("integrity", "command_identity_changed")); }
154
226
  } finally {
155
- try { try { delivery.cleanup(); } finally { removeTaskOwnedTree(home); } }
227
+ try { delivery.cleanup(); }
156
228
  catch { throw new QaPreparationError("integrity", ids, "delivery_cleanup_failed", diagnostics("integrity", "delivery_cleanup_failed")); }
157
229
  }
158
230
  }
159
231
  live();
160
- // Host entries survive materialization cleanup and must still match before approval.
232
+ // A baseline failure also present at base_head is disclosed, not blocking. Only
233
+ // baseline checks get this tolerance; an acceptance descriptor never does.
234
+ const disclosed: NonNullable<AssuranceVerdict["findings"]> = [];
235
+ for (const failure of baselineFailures) {
236
+ if (!baseline || !(await failsIdenticallyAtBase(failure.item, failure.observed, baseline.tree))) continue;
237
+ findings.splice(findings.indexOf(failure.finding), 1);
238
+ disclosed.push({ ...failure.finding, kind: "advisory", summary: `${PRE_EXISTING_BASELINE_PREFIX} ${failure.finding.summary}` });
239
+ }
240
+ // Host entries must still match before approval.
161
241
  for (const entry of allCommands) {
162
242
  try { if (entry.frozen?.entry) assertCommandIdentity(entry.frozen); }
163
243
  catch { throw new QaPreparationError("integrity", entry.diagnostics.map(item => item.acceptance_id), "command_identity_changed", entry.diagnostics); }
@@ -165,10 +245,13 @@ export async function runDeterministicQa(
165
245
  const base = { contract: "assurance_kernel/assurance_verdict/v2" as const, role: "qa" as const,
166
246
  task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot) };
167
247
  if (findings.length) return { ...base, decision: "rework", findings };
248
+ const disclosure = disclosed.length
249
+ ? `; ${disclosed.map(item => item.summary).join("; ")}` : "";
168
250
  const executionDigest = createHash("sha256").update(JSON.stringify({
169
251
  tree, platform: process.platform, arch: process.arch, observations: evidence,
170
252
  })).digest("hex");
171
253
  return { ...base, decision: "pass", approval: { kind: "qa", authority_role: "qa",
172
- summary: `all ${snapshot.acceptance.length} project verification check(s) passed; execution=sha256:${executionDigest}` } };
254
+ summary: `all ${totalChecks} project verification check(s) passed${disclosure}; execution=sha256:${executionDigest}` },
255
+ ...(disclosed.length ? { findings: disclosed } : {}) };
173
256
  }
174
257
 
@@ -19,6 +19,14 @@ export function qaEvidenceFreshnessId(snapshotDigest: string): string {
19
19
  return `qa-evidence-freshness-${digest16}-${attemptRef(snapshotDigest)}`;
20
20
  }
21
21
 
22
+ /** Summary prefix that marks a baseline failure also present at `base_head`. */
23
+ export const PRE_EXISTING_BASELINE_PREFIX = "pre-existing baseline failure:";
24
+
25
+ /** Digest-scoped id for a project baseline check finding; `acceptance_id` stays null. */
26
+ export function qaBaselineFindingId(checkId: string, snapshotDigest: string): string {
27
+ return `qa-baseline-${checkId}-${attemptRef(snapshotDigest)}`;
28
+ }
29
+
22
30
  function attemptRef(snapshotDigest: string): string {
23
31
  const digest8 = snapshotDigest.slice("sha256:".length, "sha256:".length + 8);
24
32
  return `${digest8}-${randomUUID().slice(0, 6)}`;
@@ -287,9 +287,54 @@ export interface ReviewManifestV5 {
287
287
  lifecycle: string;
288
288
  artifact_state: string;
289
289
  risk: string;
290
+ /** Round 3 and later only: the prior round's reviewed commit. */
291
+ prior_review_commit?: string;
292
+ /** Paths whose tree entries differ between the prior and current review trees. */
293
+ delta_paths?: string[];
294
+ /** Round 3 and later only: why no delta is available, so the rule is skipped. */
295
+ delta_skipped?: "prior_review_revision_absent" | "prior_review_revision_unresolvable";
290
296
  manifest_digest: string;
291
297
  }
292
298
 
299
+ /** The first Review round that judges only the rework delta. */
300
+ export const REVIEW_DELTA_FROM_ROUND = 3;
301
+
302
+ export interface ReviewDeltaInput {
303
+ /** The round this Review opens, derived from the Kernel's review findings. */
304
+ round: number;
305
+ /** The revision the previous round reviewed; null for a legacy record. */
306
+ prior: { review_commit: string; review_tree: string } | null;
307
+ }
308
+
309
+ function reviewDeltaFields(
310
+ root: string,
311
+ delta: ReviewDeltaInput | undefined,
312
+ currentTree: string,
313
+ ): Pick<ReviewManifestV5, "prior_review_commit" | "delta_paths" | "delta_skipped"> {
314
+ if (!delta || delta.round < REVIEW_DELTA_FROM_ROUND) return {};
315
+ if (!delta.prior) return { delta_skipped: "prior_review_revision_absent" };
316
+ try {
317
+ gitEvidence(root, ["cat-file", "-e", `${delta.prior.review_commit}^{commit}`]);
318
+ gitEvidence(root, ["cat-file", "-e", `${delta.prior.review_tree}^{tree}`]);
319
+ const names = gitEvidenceBytes(root, [
320
+ "diff-tree",
321
+ "-r",
322
+ "--name-only",
323
+ "--no-renames",
324
+ "-z",
325
+ delta.prior.review_tree,
326
+ currentTree,
327
+ ]).toString("utf8");
328
+ return {
329
+ prior_review_commit: delta.prior.review_commit,
330
+ delta_paths: names.split("\0").filter(Boolean).sort(),
331
+ };
332
+ } catch {
333
+ // A pruned or unreachable prior revision must never block Review.
334
+ return { delta_skipped: "prior_review_revision_unresolvable" };
335
+ }
336
+ }
337
+
293
338
  function manifestDigest(manifest: Omit<ReviewManifestV5, "manifest_digest">): string {
294
339
  return `sha256:${createHash("sha256").update(JSON.stringify(manifest)).digest("hex")}`;
295
340
  }
@@ -485,6 +530,8 @@ export function captureReviewManifest(
485
530
  artifactState: string;
486
531
  risk: string;
487
532
  outcomes: Record<string, ReviewOutcome>;
533
+ /** Present once the task has a Review history; absent keeps rounds 1-2 byte-identical. */
534
+ delta?: ReviewDeltaInput;
488
535
  },
489
536
  ): ReviewManifestV5 {
490
537
  const { snapshot, revision } = publishInput(root, input);
@@ -508,6 +555,7 @@ export function captureReviewManifest(
508
555
  lifecycle: input.lifecycle,
509
556
  artifact_state: input.artifactState,
510
557
  risk: input.risk,
558
+ ...reviewDeltaFields(root, input.delta, revision.review_tree),
511
559
  };
512
560
  const manifest = { ...unsigned, manifest_digest: manifestDigest(unsigned) };
513
561
  if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_REVIEW_BUNDLE_BYTES)