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
@@ -0,0 +1,284 @@
1
+ // Read-only response continuation. Kernel/batch facts own every action and settlement.
2
+ import { createHash } from "node:crypto";
3
+ import type { StatusProjectionResult } from "./coordinator";
4
+ import { captureGitWorkspaceSnapshot, pathMatchesScope } from "../workspace_scope";
5
+ import { startableChildren } from "../unattended/batch_schedule";
6
+ import { readAnyBatchRunState, isLaneBatchRecord } from "../unattended/batch_state";
7
+ import { observeInitiative } from "../unattended/batch_plan";
8
+ import { observeParentCompletion, observeNativeParent } from "../unattended/batch_git";
9
+ import { projectTaskStatus } from "./coordinator";
10
+ import { readActiveClaimTaskId } from "../unattended/batch_preflight";
11
+ import { readTaskIntent } from "../kernel/intent";
12
+ import { readSettledTaskEvidence, readTaskRecordRaw } from "../kernel/storage";
13
+
14
+ export interface RunSessionIdentity {
15
+ workspace: string;
16
+ session_id: string;
17
+ }
18
+ export type RunBinding = RunSessionIdentity & (
19
+ | { kind: "task"; task_id: string; run_id: string }
20
+ | { kind: "parent"; initiative_slug: string; batch_id: string | null; plan_digest: string; final_verification?: string[]; blocked_native_tasks?: string[] }
21
+ );
22
+ export type ReviewContinuationState = "unobserved" | "ready" | "running" | "returned" | "rejected";
23
+ export interface RunContinuationState {
24
+ binding: RunBinding;
25
+ progress_digest: string | null;
26
+ unchanged_observations: number;
27
+ boundary_id: string | null;
28
+ }
29
+ export interface TaskContinuationObservation extends RunSessionIdentity {
30
+ kind: "task";
31
+ boundary_id: string;
32
+ status: StatusProjectionResult;
33
+ /** Source byte digest, including unstaged scope edits; never a delivery proof. */
34
+ source_digest: string;
35
+ review: ReviewContinuationState;
36
+ recovery_action?: string;
37
+ /** A concrete failed/cancelled Host operation; it must never be auto-retried. */
38
+ host_failure?: string;
39
+ }
40
+ export interface ParentContinuationObservation extends RunSessionIdentity {
41
+ kind: "parent";
42
+ boundary_id: string;
43
+ initiative_slug: string;
44
+ batch_id: string | null;
45
+ plan_digest: string;
46
+ /** Includes dependency readiness, integrations, corrections and final verification. */
47
+ progress_digest: string;
48
+ all_required_integrated: boolean;
49
+ final_passed: boolean;
50
+ pending_repair: boolean;
51
+ runnable: boolean;
52
+ executors_running: boolean;
53
+ next_action: string;
54
+ blocked_reason?: string;
55
+ host_failure?: string;
56
+ }
57
+ export type RunContinuationDecision = {
58
+ action: "continue" | "wait" | "pause" | "settled" | "unbound";
59
+ reason: string;
60
+ next_action: string | null;
61
+ state: RunContinuationState;
62
+ };
63
+
64
+ export const UNCHANGED_CONTINUATION_LIMIT = 2;
65
+
66
+ export function bindRun(binding: RunBinding): RunContinuationState {
67
+ if (!binding.workspace || !binding.session_id ||
68
+ (binding.kind === "task" ? !binding.task_id || !binding.run_id : !binding.initiative_slug || (binding.batch_id !== null && !binding.batch_id) || !binding.plan_digest))
69
+ throw new Error("run continuation requires an exact session and committed run identity");
70
+ if (binding.kind === "parent" && binding.blocked_native_tasks !== undefined &&
71
+ (!Array.isArray(binding.blocked_native_tasks) || !binding.blocked_native_tasks.every(id => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))))
72
+ throw new Error("invalid failed native task observations");
73
+ return { binding, progress_digest: null, unchanged_observations: 0, boundary_id: null };
74
+ }
75
+
76
+ /** Suppress only this rejected gate; independently authorized branches remain runnable. */
77
+ export async function parkFailedCriticalGate(binding: RunBinding, taskId: string | undefined): Promise<boolean> {
78
+ if (binding.kind !== "parent" || !taskId || readActiveClaimTaskId(binding.workspace) === taskId) return false;
79
+ const parent = await observeInitiative(binding.workspace, binding.initiative_slug);
80
+ if (!parent.tasks.some(task => task.task_id === taskId) || readTaskIntent(binding.workspace, taskId).intent.risk !== "critical") return false;
81
+ binding.blocked_native_tasks = [...new Set([...(binding.blocked_native_tasks ?? []), taskId])];
82
+ return true;
83
+ }
84
+
85
+ /** Explicit cancellation or a planning entry releases conversational continuation only. */
86
+ export function releasesRunBinding(prompt: string, phase: "run" | "planner" | "brainstorm" | null): boolean {
87
+ return phase === "planner" || phase === "brainstorm" ||
88
+ /^(?:stop|cancel|pause)(?:\s+(?:execution|the task|this run))?[.!\s]*$/i.test(prompt.trim()) ||
89
+ /^(?:请)?(?:停止|取消|暂停)(?:执行|任务|本次运行|这个任务)?[。!!\s]*$/.test(prompt.trim());
90
+ }
91
+
92
+ function digest(value: unknown): string {
93
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
94
+ }
95
+
96
+ export function scopedSourceDigest(root: string, scope: readonly string[]): string {
97
+ const snapshot = captureGitWorkspaceSnapshot(root);
98
+ if (!snapshot) throw new Error("cannot observe source progress without a Git workspace");
99
+ return digest(Object.entries(snapshot.dirty_files).filter(([path]) => scope.some(entry => pathMatchesScope(path, entry))));
100
+ }
101
+
102
+ function finalProgress(report: import("../unattended/batch_final_verification").FinalVerificationReport | undefined): unknown {
103
+ return report ? { passed: report.passed, head: report.head,
104
+ results: report.results.map(result => ({ command: result.command, passed: result.passed, exit_code: result.exit_code, signal: result.signal })) } : null;
105
+ }
106
+
107
+ export async function observeParentRun(binding: RunBinding & { kind: "parent" },
108
+ boundaryId: string, review: ReviewContinuationState = "unobserved",
109
+ recoveryAction?: string): Promise<ParentContinuationObservation> {
110
+ const root = binding.workspace;
111
+ if (binding.batch_id === null) {
112
+ const facts = await observeNativeParent(root, binding.initiative_slug, undefined, binding.blocked_native_tasks);
113
+ if (!facts || facts.plan_digest !== binding.plan_digest) throw new Error("native Parent plan identity changed");
114
+ let nextAction = ["verify", "repair"].includes(facts.action) ?
115
+ `Call start_unattended_batch for ${binding.initiative_slug} with final_verification ${JSON.stringify(binding.final_verification ?? facts.native?.final_verification.results.map(result => result.command) ?? [])}; if it fails, diagnose and canonically prepare/validate the stable original-goal repair before fresh native Enrollment` : facts.next_action;
116
+ let runnable = ["verify", "repair", "integrate"].includes(facts.action) || (facts.action === "enroll" && facts.task_id !== null);
117
+ let running = false;
118
+ let taskProgress: unknown = null;
119
+ if (facts.action === "drive" && facts.task_id) {
120
+ const status = await projectTaskStatus(root, facts.task_id);
121
+ const task = readTaskRecordRaw(root, facts.task_id).record;
122
+ if (status.error || !task || !status.projection.run_id) throw new Error(status.error ?? "native Parent has no exact child run");
123
+ const source = scopedSourceDigest(root, task.intent_snapshot.scope_hint);
124
+ const decision = decideRunContinuation(bindRun({ kind: "task", workspace: root, session_id: binding.session_id,
125
+ task_id: facts.task_id, run_id: status.projection.run_id }), { kind: "task", workspace: root, session_id: binding.session_id,
126
+ boundary_id: boundaryId, status, source_digest: source, review, recovery_action: recoveryAction });
127
+ runnable = decision.action === "continue"; running = decision.action === "wait";
128
+ nextAction = decision.next_action ?? decision.reason; taskProgress = decision.state.progress_digest;
129
+ }
130
+ if (facts.action === "repair" && (facts.native?.repair_tasks?.length ?? 0) >= 2) {
131
+ runnable = false; nextAction = "Parent coordinator correction budget exhausted; request a user decision";
132
+ }
133
+ return { kind: "parent", workspace: root, session_id: binding.session_id, boundary_id: boundaryId,
134
+ initiative_slug: binding.initiative_slug, batch_id: null, plan_digest: facts.plan_digest,
135
+ progress_digest: digest({ proof: facts.proof, action: facts.action, repair_tasks: facts.native?.repair_tasks,
136
+ final: finalProgress(facts.native?.final_verification), taskProgress }),
137
+ all_required_integrated: facts.proof.missing_task_ids.length === 0, final_passed: facts.proof.final_passed,
138
+ pending_repair: facts.action === "repair", runnable, executors_running: running,
139
+ next_action: nextAction, blocked_reason: facts.proof.reason ?? undefined };
140
+ }
141
+ const record = readAnyBatchRunState(root, binding.batch_id);
142
+ if (!record || record.initiative_slug !== binding.initiative_slug || record.plan_digest !== binding.plan_digest)
143
+ throw new Error("Parent continuation has no matching persisted batch identity");
144
+ const plan = await observeInitiative(root, binding.initiative_slug);
145
+ const completion = await observeParentCompletion(root, binding.initiative_slug, plan.tasks.map(child => child.task_id));
146
+ let runnable = false;
147
+ let running = false;
148
+ let nextAction = `Call start_unattended_batch for ${binding.initiative_slug} to consume the current authorized handoff`;
149
+ let childProgress: unknown = null;
150
+ const activeTask = readActiveClaimTaskId(root);
151
+ const nativeTask = activeTask && !record.children.some(child => child.task_id === activeTask) &&
152
+ (plan.tasks.some(child => child.task_id === activeTask) || record.repair_tasks?.some(task => task.task_id === activeTask));
153
+ const activeChild = nativeTask ? { task_id: activeTask } : record.children.find(child => child.state === "enrolled" || child.state === "settled");
154
+ if (activeChild && (!isLaneBatchRecord(record) || nativeTask)) {
155
+ const status = await projectTaskStatus(root, activeChild.task_id);
156
+ if (status.error) throw new Error(status.error);
157
+ if (status.projection.lifecycle === "active" && status.projection.run_id) {
158
+ const task = readTaskRecordRaw(root, activeChild.task_id).record;
159
+ if (!task) throw new Error("Parent's active child has no TaskRecord");
160
+ const source = scopedSourceDigest(root, task.intent_snapshot.scope_hint);
161
+ const taskBinding = bindRun({ kind: "task", workspace: root, session_id: binding.session_id,
162
+ task_id: activeChild.task_id, run_id: status.projection.run_id });
163
+ const decision = decideRunContinuation(taskBinding, { kind: "task", workspace: root, session_id: binding.session_id,
164
+ boundary_id: boundaryId, status, source_digest: source, review, recovery_action: recoveryAction });
165
+ runnable = decision.action === "continue";
166
+ running = decision.action === "wait";
167
+ nextAction = decision.next_action ?? decision.reason;
168
+ childProgress = decision.state.progress_digest;
169
+ } else if (status.projection.lifecycle === "done") runnable = true;
170
+ } else {
171
+ const live = isLaneBatchRecord(record) ? record.children.filter(child => ["lane_admitted", "enrolled", "settled", "lane_committed"].includes(child.state)) : [];
172
+ running = live.length > 0;
173
+ const schedule = plan.tasks.map(task => ({ task_id: task.task_id, blocked_by: task.blocked_by,
174
+ scope_hint: readTaskIntent(root, task.task_id).intent.scope_hint,
175
+ state: completion.integrated_task_ids.includes(task.task_id) ? "committed" as const :
176
+ record.children.find(child => child.task_id === task.task_id)?.state ?? "needs_human" as const }));
177
+ runnable = record.batch_state === "running" && (startableChildren(schedule, isLaneBatchRecord(record) ? record.max_parallel : 1).length > 0 ||
178
+ live.length > 0);
179
+ if (running && runnable) nextAction = `Supervise the observed Lane Executor handoff for ${binding.initiative_slug}; use the Host's live-session wait/observation before resuming the batch, rather than repeatedly observing an unchanged batch`;
180
+ }
181
+ if (!activeChild && !running) {
182
+ for (const child of plan.tasks) {
183
+ if (binding.blocked_native_tasks?.includes(child.task_id)) continue;
184
+ if (record.children.some(candidate => candidate.task_id === child.task_id) || completion.integrated_task_ids.includes(child.task_id)) continue;
185
+ const intent = readTaskIntent(root, child.task_id);
186
+ if (intent.intent.risk !== "critical") continue;
187
+ if (readSettledTaskEvidence(root, child.task_id)?.proof.terminal_lifecycle === "done") {
188
+ runnable = true; nextAction = `Call start_unattended_batch for ${binding.initiative_slug} to integrate the settled critical Slice and resume the intact ordinary batch authorization`; break;
189
+ }
190
+ if (child.blocked_by.every(id => completion.integrated_task_ids.includes(id))) {
191
+ runnable = true; nextAction = `Invoke native single-task Enrollment for critical Slice ${child.task_id}; it consumes no ordinary batch slot. Drive its Loop to settlement, then resume Parent ${binding.initiative_slug}`; break;
192
+ }
193
+ }
194
+ }
195
+ const failedFinal = record.final_verification_result?.passed === false && plan.tasks.every(child => completion.integrated_task_ids.includes(child.task_id));
196
+ const pendingRepair = record.repair_tasks?.some(task => !completion.integrated_task_ids.includes(task.task_id));
197
+ const exhausted = failedFinal && !activeChild && !pendingRepair && (record.repair_tasks?.length ?? 0) >= 2;
198
+ if (failedFinal && !activeChild) nextAction = pendingRepair
199
+ ? `Call start_unattended_batch for ${binding.initiative_slug} to integrate the settled repair and run recorded final verification again`
200
+ : "Diagnose final verification, call prepareFinalRepairCandidate with the original goal, diagnosed minimum scope, recorded failure and spent repair count; canonically author/validate its stable candidate, then request fresh native Enrollment. Preserve settled deliveries";
201
+ if (exhausted) nextAction = "Parent coordinator correction budget exhausted; resolve the final failure with a user decision before further repair Enrollment";
202
+ return { kind: "parent", workspace: root, session_id: binding.session_id, boundary_id: boundaryId,
203
+ initiative_slug: record.initiative_slug, batch_id: record.batch_id, plan_digest: record.plan_digest,
204
+ progress_digest: digest({ children: record.children.map(child => ({ task_id: child.task_id, state: child.state, commit: child.commit })),
205
+ commits: record.commits, completion, childProgress, final: finalProgress(record.final_verification_result) }), all_required_integrated: completion.missing_task_ids.length === 0,
206
+ final_passed: completion.final_passed, pending_repair: failedFinal, runnable: !exhausted && (runnable || (failedFinal && !activeChild)),
207
+ executors_running: running, next_action: nextAction, blocked_reason: completion.reason ?? undefined };
208
+ }
209
+
210
+ export function decideRunContinuation(previous: RunContinuationState,
211
+ observation: TaskContinuationObservation | ParentContinuationObservation): RunContinuationDecision {
212
+ const result = (action: RunContinuationDecision["action"], reason: string, next_action: string | null,
213
+ state = previous): RunContinuationDecision => ({ action, reason, next_action, state });
214
+ const binding = previous.binding;
215
+ if (binding.workspace !== observation.workspace || binding.session_id !== observation.session_id)
216
+ return result("unbound", "Host session or workspace changed", null);
217
+ if (binding.kind !== observation.kind)
218
+ return result("pause", "run observation does not match the bound entry", null);
219
+ if (observation.host_failure)
220
+ return result("pause", observation.host_failure, null);
221
+
222
+ let progressDigest: string;
223
+ let nextAction: string;
224
+ let wait = false;
225
+ if (binding.kind === "parent" && observation.kind === "parent") {
226
+ if (binding.initiative_slug !== observation.initiative_slug || binding.batch_id !== observation.batch_id || binding.plan_digest !== observation.plan_digest)
227
+ return result("pause", "batch identity changed; reread its authorized plan", null);
228
+ if (observation.all_required_integrated && observation.final_passed && !observation.pending_repair)
229
+ return result("settled", "every required delivery is integrated and final verification passed", null);
230
+ if (!observation.runnable && !observation.executors_running)
231
+ return result("pause", observation.blocked_reason ?? "no executable Parent work remains", observation.next_action);
232
+ progressDigest = observation.progress_digest;
233
+ nextAction = observation.next_action;
234
+ wait = !observation.runnable && observation.executors_running;
235
+ } else if (binding.kind === "task" && observation.kind === "task") {
236
+ const { status } = observation;
237
+ const p = status.projection;
238
+ if (status.error || status.task_id !== binding.task_id || p.run_id !== binding.run_id)
239
+ return result("pause", status.error ?? "Kernel task/run identity changed", null);
240
+ if (p.lifecycle === "done" && p.completion_ready && status.claim === null)
241
+ return result("settled", "Kernel terminal settlement confirmed", null);
242
+ if (p.lifecycle === "stopped" && status.claim === null)
243
+ return result("pause", "Kernel task stopped", null);
244
+ if (status.claim?.task_id !== binding.task_id || status.claim.lifecycle_status !== "active" || p.lifecycle !== "active")
245
+ return result("pause", "Kernel authority is missing or contradictory", null);
246
+ if (p.unresolved_user_decision_ids.length || p.replan_required_ids.length || p.open_user_decision_count ||
247
+ p.next_obligation === "resolve_user_decision" || p.next_obligation === "revise_intent" || p.authorization.state !== "none" || p.authorization.blocked)
248
+ return result("pause", "Kernel requires a concrete user decision or authorization", "Use the existing native authorization operation");
249
+ progressDigest = digest({ lifecycle: p.lifecycle, artifact_state: p.artifact_state, intent_content_hash: p.intent_content_hash,
250
+ diff_hash: p.diff_hash, next_obligation: p.next_obligation, fresh_acceptance_ids: [...p.fresh_acceptance_ids].sort(),
251
+ fresh_approval_kinds: [...p.fresh_approval_kinds].sort(), blocking_finding_ids: [...p.blocking_finding_ids].sort(),
252
+ source_digest: observation.source_digest, review: observation.review });
253
+ if (p.next_obligation === "run_review") {
254
+ switch (observation.review) {
255
+ case "ready": nextAction = "Invoke the exact reserved foreground reviewer once"; break;
256
+ case "running": nextAction = "Await the observed foreground reviewer; do not dispatch another"; wait = true; break;
257
+ case "returned": nextAction = "Call submit_review immediately with the observed verdict before editing"; break;
258
+ case "rejected":
259
+ if (!observation.recovery_action) return result("pause", "Review submission failed without an observed recovery action", null);
260
+ nextAction = observation.recovery_action; break;
261
+ case "unobserved": nextAction = "Call advance_assurance to recover or prepare the current Review reservation"; break;
262
+ }
263
+ } else if (status.warnings?.some(warning => warning.code === "unstaged_scope_paths")) {
264
+ nextAction = "Finish the in-scope implementation and stage task-owned changes before advance_assurance";
265
+ } else {
266
+ switch (p.next_obligation) {
267
+ case "submit_assurance": case "run_qa": nextAction = "Complete in-scope implementation and focused checks, then call advance_assurance"; break;
268
+ case "resolve_findings": nextAction = "Repair the open findings, verify, resolve them and call advance_assurance"; break;
269
+ case "complete": nextAction = "Call advance_assurance to finish Kernel settlement"; break;
270
+ default: return result("pause", "Kernel has no actionable obligation", null);
271
+ }
272
+ }
273
+ } else {
274
+ return result("pause", "run observation does not match the bound entry", null);
275
+ }
276
+ if (previous.boundary_id === observation.boundary_id)
277
+ return result("wait", "duplicate Host boundary", null);
278
+ const unchanged = wait ? previous.unchanged_observations : previous.progress_digest === progressDigest ? previous.unchanged_observations + 1 : 0;
279
+ const state = { ...previous, progress_digest: progressDigest, unchanged_observations: unchanged, boundary_id: observation.boundary_id };
280
+ // Running reviewers/executors progress through their own supervised exit boundary.
281
+ if (!wait && unchanged >= UNCHANGED_CONTINUATION_LIMIT)
282
+ return result("pause", "no substantive progress across continuation boundaries", nextAction, state);
283
+ return result(wait ? "wait" : "continue", wait ? "observed foreground work is still running" : "authorized work remains", nextAction, state);
284
+ }
@@ -13,7 +13,7 @@ import type { TaskRecord, TaskApprovalV2 } from "../kernel/types";
13
13
  import { captureGitTaskSnapshot } from "../workspace_scope";
14
14
  import { parseVerificationDescriptor } from "../verification_descriptor";
15
15
  import type { VerificationDescriptor } from "./verification";
16
- import { captureReviewBundle, captureReviewManifest, type ReviewBundle, type ReviewManifestV5, type ReviewRevision } from "./review_evidence";
16
+ import { captureReviewBundle, captureReviewManifest, REVIEW_DELTA_FROM_ROUND, type ReviewBundle, type ReviewDeltaInput, type ReviewManifestV5, type ReviewRevision } from "./review_evidence";
17
17
  import { reviewReworkFindings, reviewAdvisoryRecords, type SnapshotDescriptor, type AssuranceRole, type AssuranceVerdict, type HostContext } from "./coordinator";
18
18
  import type { InvocationToken } from "./invocations";
19
19
  import type { ConfirmationReferenceSource } from "./host_port";
@@ -37,6 +37,7 @@ export interface SnapshotDescriptorInput {
37
37
  acceptance: Array<{ id: string; assertion: string; verification: string }>;
38
38
  dirty_files?: string[];
39
39
  review_bundle_digest?: string | null;
40
+ review_delta?: SnapshotDescriptor["review_delta"];
40
41
  /** Present only when Review authority binds an immutable Git revision (v4). */
41
42
  review_revision?: {
42
43
  contract: "assurance_kernel/review_revision_identity/v1";
@@ -68,11 +69,34 @@ export function buildSnapshot(input: SnapshotDescriptorInput): SnapshotDescripto
68
69
  dirty_files: [...(input.dirty_files ?? [])].sort(),
69
70
  review_bundle_digest: input.review_bundle_digest ?? null,
70
71
  ...(input.review_revision ? { review_revision: input.review_revision } : {}),
72
+ ...(input.review_delta ? { review_delta: input.review_delta } : {}),
71
73
  root: resolve(input.root),
72
74
  };
73
75
  }
74
76
 
75
77
 
78
+ /**
79
+ * The Review round the next dispatch opens and the revision the previous round
80
+ * analyzed. Both ensureReviewRevision and the snapshot read it through here so
81
+ * the manifest digest they bind cannot diverge. A record with no persisted
82
+ * prior revision yields `prior: null`, which skips the delta rule.
83
+ */
84
+ function reviewDeltaInput(record: TaskRecord): ReviewDeltaInput {
85
+ const round = Math.max(
86
+ 0,
87
+ ...record.findings.filter((item) => item.source === "review").map((item) => item.review_round ?? 0),
88
+ ) + 1;
89
+ const prior = [...record.history]
90
+ .reverse()
91
+ .find((entry) => entry.type === "request_rework" && entry.reason.split("\n")[0] === `review_round_${round - 1}`);
92
+ return {
93
+ round,
94
+ prior: prior?.review_revision
95
+ ? { review_commit: prior.review_revision.review_commit, review_tree: prior.review_revision.review_tree }
96
+ : null,
97
+ };
98
+ }
99
+
76
100
  export async function ensureReviewRevision(
77
101
  root: string,
78
102
  taskId: string,
@@ -103,6 +127,7 @@ export async function ensureReviewRevision(
103
127
  // write that exact summary, so the submit-time digest comparison held by
104
128
  // coincidence rather than by construction.
105
129
  outcomes: qaOutcomes(record),
130
+ delta: reviewDeltaInput(record),
106
131
  });
107
132
  return {
108
133
  contract: "assurance_kernel/review_revision/v1",
@@ -182,6 +207,7 @@ export async function buildAssuranceSnapshot(
182
207
  artifactState: projection.projection.artifact_state,
183
208
  risk: intent.risk,
184
209
  outcomes: qaOutcomes(record.record),
210
+ delta: reviewDeltaInput(record.record),
185
211
  })
186
212
  : null;
187
213
  const taskSnapshot = !reviewBundle && !reviewManifest
@@ -221,6 +247,7 @@ export async function buildAssuranceSnapshot(
221
247
  manifest_digest: reviewManifest.manifest_digest,
222
248
  }
223
249
  : undefined,
250
+ review_delta: reviewManifest ? reviewDeltaOf(reviewManifest, record.record) : undefined,
224
251
  }),
225
252
  descriptors,
226
253
  reviewBundle,
@@ -228,6 +255,17 @@ export async function buildAssuranceSnapshot(
228
255
  };
229
256
  }
230
257
 
258
+ function reviewDeltaOf(
259
+ manifest: ReviewManifestV5,
260
+ record: TaskRecord,
261
+ ): SnapshotDescriptor["review_delta"] {
262
+ const { round } = reviewDeltaInput(record);
263
+ if (round < REVIEW_DELTA_FROM_ROUND) return undefined;
264
+ return manifest.delta_paths
265
+ ? { round, prior_review_commit: manifest.prior_review_commit!, delta_paths: manifest.delta_paths }
266
+ : { round, skipped: manifest.delta_skipped ?? "prior_review_revision_absent" };
267
+ }
268
+
231
269
  function qaOutcomes(
232
270
  record: TaskRecord,
233
271
  ): Record<string, { status: "passed" | "failed" | "blocked"; summary: string }> {
@@ -301,7 +339,7 @@ export function createVerdictAuthority(options: {
301
339
  recoverKernelStoreFollowUps(ctx.cwd, snapshot.task_id);
302
340
  const record = readTaskRecordRaw(ctx.cwd, snapshot.task_id).record;
303
341
  if (!record) throw new Error("TaskRecord disappeared before authority application");
304
- const priorIntentToken = readTaskIntent(ctx.cwd, snapshot.task_id, record.intent_ref.path).token;
342
+ const priorIntentToken = readTaskIntent(ctx.cwd, snapshot.task_id, record.intent_ref.path, record.intent_snapshot).token;
305
343
  const now = new Date().toISOString();
306
344
  const findings = verdict.decision === "rework" ? reviewReworkFindings(verdict) : [];
307
345
  const advisories = verdict.decision === "pass" ? reviewAdvisoryRecords(verdict) : [];
@@ -316,7 +354,11 @@ export function createVerdictAuthority(options: {
316
354
  } : undefined;
317
355
  const op = verdict.decision === "rework" ? "request_rework" : "record_approval";
318
356
  const payload = approval ? { approval } : { findings };
319
- const action = capabilityActionFor({ op, task_id: snapshot.task_id, at: now, actor_id: actorId, ...payload });
357
+ // Only a Review-authored rework persists the revision it analyzed; the next
358
+ // round derives its delta from it. QA rework never carries one.
359
+ const reworkRevision = verdict.decision === "rework" && snapshot.role === "review" && snapshot.review_revision
360
+ ? { review_revision: snapshot.review_revision } : {};
361
+ const action = capabilityActionFor({ op, task_id: snapshot.task_id, at: now, actor_id: actorId, ...payload, ...reworkRevision });
320
362
  const capability = registry.issue({
321
363
  authority_kind: snapshot.role, task_id: snapshot.task_id,
322
364
  ...(snapshot.run_id ? { run_id: snapshot.run_id } : {}),
@@ -332,7 +374,7 @@ export function createVerdictAuthority(options: {
332
374
  // then afterCommit. A thrown hook never undoes the committed transition.
333
375
  const settlement = (async () => app.execute({ root: ctx.cwd, task_id: snapshot.task_id,
334
376
  operation: approval ? { op: "record_approval", capability, approval, actor_id: actorId }
335
- : { op: "request_rework", capability, findings, actor_id: actorId }, prior_intent_token: priorIntentToken, now }))();
377
+ : { op: "request_rework", capability, findings, actor_id: actorId, ...reworkRevision }, prior_intent_token: priorIntentToken, now }))();
336
378
  let hookFailed = false;
337
379
  let hookError: unknown;
338
380
  try { hooks.onCommit?.(); } catch (error) { hookFailed = true; hookError = error; }
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { accessSync, constants, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
5
5
  import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
6
- import { VERIFICATION_DESCRIPTOR_BOUNDS, VerificationDescriptorError, type VerificationCommand } from "../verification_descriptor";
6
+ import { VERIFICATION_DESCRIPTOR_BOUNDS, VerificationDescriptorError, parseVerificationDescriptor, type VerificationCommand, type VerificationDescriptor } from "../verification_descriptor";
7
7
  export * from "../verification_descriptor";
8
8
 
9
9
  export interface ExecutableIdentity {
@@ -15,8 +15,6 @@ export interface ExecutableIdentity {
15
15
  }
16
16
  export interface FrozenCommand {
17
17
  entry: ExecutableIdentity;
18
- interpreter: ExecutableIdentity | null;
19
- interpreter_args: string[];
20
18
  }
21
19
  export interface VerificationResult {
22
20
  exit_code: number;
@@ -54,6 +52,21 @@ export function verificationPath(): string {
54
52
  return (process.env.PATH ?? "").split(delimiter).filter(path => isAbsolute(path)).join(delimiter);
55
53
  }
56
54
 
55
+ // A hosting process that carries one of these would point a command's Git calls
56
+ // at its own repository instead of the delivery materialization. Every other
57
+ // variable, including Git identity and transport settings, is user environment.
58
+ const GIT_REPOSITORY_BINDINGS: ReadonlySet<string> = new Set([
59
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
60
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_PREFIX",
61
+ ]);
62
+
63
+ function verificationEnvironment(path: string, processToken: string): Record<string, string> {
64
+ const env: Record<string, string> = {};
65
+ for (const [name, value] of Object.entries(process.env))
66
+ if (value !== undefined && !GIT_REPOSITORY_BINDINGS.has(name)) env[name] = value;
67
+ return { ...env, PATH: path, IMM_VERIFICATION_PROCESS_TOKEN: processToken };
68
+ }
69
+
57
70
  function toolPath(name: string, path: string): string {
58
71
  for (const dir of path.split(delimiter).filter(isAbsolute)) {
59
72
  const candidate = join(dir, name);
@@ -77,42 +90,19 @@ export function resolveVerificationCommand(root: string, command: VerificationCo
77
90
  insideVerificationRoot(root, resolve(root, command.cwd));
78
91
  const entry = identity(command.executable.startsWith("./")
79
92
  ? insideVerificationRoot(root, resolve(root, command.executable)) : toolPath(command.executable, path));
80
- const prefix = readFileSync(entry.path).subarray(0, 512).toString("utf8");
81
- let interpreter: ExecutableIdentity | null = null;
82
- let interpreter_args: string[] = [];
83
- if (prefix.startsWith("#!")) {
84
- const lineEnd = prefix.indexOf("\n");
85
- if (lineEnd < 0) throw new VerificationDescriptorError("verification interpreter declaration is unbounded");
86
- const parts = prefix.slice(2, lineEnd).trim().split(/\s+/);
87
- const name = parts.shift()!;
88
- if (!isAbsolute(name)) throw new VerificationDescriptorError("verification interpreter must be absolute");
89
- if (name === "/usr/bin/env" || name === "/bin/env") {
90
- if (parts.length !== 1 || !/^[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(parts[0]!))
91
- throw new VerificationDescriptorError("verification interpreter resolution is ambiguous; use a direct interpreter command");
92
- interpreter = identity(toolPath(parts[0]!, path));
93
- } else {
94
- if (parts.length > 1) throw new VerificationDescriptorError("verification interpreter arguments are ambiguous");
95
- interpreter = identity(name);
96
- interpreter_args = parts;
97
- }
98
- if (readFileSync(interpreter.path).subarray(0, 2).toString() === "#!")
99
- throw new VerificationDescriptorError("verification interpreter is itself a script; resolve the host tool explicitly");
100
- }
101
- return { entry, interpreter, interpreter_args };
93
+ return { entry };
102
94
  }
103
95
 
104
96
  export function assertCommandIdentity(frozen: FrozenCommand): void {
105
- for (const item of [frozen.entry, frozen.interpreter]) {
106
- if (item && JSON.stringify(identity(item.invocation_path)) !== JSON.stringify(item))
107
- throw new VerificationDescriptorError("verification executable identity changed");
108
- }
97
+ if (JSON.stringify(identity(frozen.entry.invocation_path)) !== JSON.stringify(frozen.entry))
98
+ throw new VerificationDescriptorError("verification executable identity changed");
109
99
  }
110
100
 
111
101
  export async function runFixedVerification(
112
102
  root: string,
113
103
  command: VerificationCommand,
114
104
  frozen: FrozenCommand,
115
- options: { signal?: AbortSignal; home: string; path: string; _processScanner?: string; _procRoot?: string } ,
105
+ options: { signal?: AbortSignal; path: string; _processScanner?: string; _procRoot?: string },
116
106
  ): Promise<VerificationResult> {
117
107
  if (options.signal?.aborted) throw new VerificationAbortedError();
118
108
  if (process.platform === "win32") throw new VerificationDescriptorError("fixed verification process-group isolation requires a POSIX host");
@@ -129,12 +119,9 @@ export async function runFixedVerification(
129
119
  let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), captured = 0;
130
120
  let aborted = false, timed_out = false, output_limited = false, settled = false;
131
121
  const processToken = randomBytes(24).toString("hex");
132
- const args = frozen.interpreter
133
- ? [...frozen.interpreter_args, frozen.entry.path, ...command.argv] : command.argv;
134
- const child = spawn(frozen.interpreter?.invocation_path ?? frozen.entry.invocation_path, args, {
122
+ const child = spawn(frozen.entry.invocation_path, command.argv, {
135
123
  cwd, shell: false, detached: true,
136
- env: { PATH: options.path, HOME: options.home, TMPDIR: options.home, XDG_CACHE_HOME: options.home,
137
- IMM_VERIFICATION_PROCESS_TOKEN: processToken },
124
+ env: verificationEnvironment(options.path, processToken),
138
125
  stdio: ["ignore", "pipe", "pipe"],
139
126
  });
140
127
  const killGroup = () => {
@@ -282,3 +269,64 @@ export function findingsDigest(findings: Array<{ id: string; kind: string; accep
282
269
  const normalized = findings.map(f => JSON.stringify({ acceptance_id: f.acceptance_id, id: f.id, kind: f.kind, summary: f.summary }));
283
270
  return `sha256:${createHash("sha256").update(`[${normalized.join(",")}]`).digest("hex")}`;
284
271
  }
272
+
273
+ export const QA_BASELINE_PATH = "docs/agents/qa-baseline.json";
274
+ export const QA_BASELINE_CONTRACT = "assurance_kernel/qa_baseline/v1";
275
+ const QA_BASELINE_MAX_BYTES = 262_144;
276
+ const QA_BASELINE_MAX_CHECKS = 16;
277
+
278
+ export interface QaBaselineCheck {
279
+ id: string;
280
+ descriptor: VerificationDescriptor;
281
+ }
282
+ export interface QaBaseline {
283
+ /** Tree of the Enrollment `base_head`, the one a pre-existing failure is rerun against. */
284
+ tree: string;
285
+ checks: QaBaselineCheck[];
286
+ }
287
+
288
+ function baselineGit(root: string, args: string[]): string {
289
+ const env: Record<string, string> = {};
290
+ for (const [name, value] of Object.entries(process.env))
291
+ if (value !== undefined && !GIT_REPOSITORY_BINDINGS.has(name)) env[name] = value;
292
+ return execFileSync("git", ["-C", root, ...args], {
293
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: QA_BASELINE_MAX_BYTES * 2,
294
+ env: { ...env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Read the project QA baseline from the Enrollment `base_head` tree, never from
300
+ * the delivery, so a task cannot weaken its own baseline. An absent file adds
301
+ * no checks; an unreadable commit or an invalid file fails closed.
302
+ */
303
+ export function readQaBaseline(root: string, baseHead: string): QaBaseline {
304
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(baseHead))
305
+ throw new VerificationDescriptorError("qa baseline base_head has an invalid identity");
306
+ const tree = baselineGit(root, ["rev-parse", `${baseHead}^{tree}`]).trim();
307
+ if (!baselineGit(root, ["ls-tree", "--name-only", tree, "--", QA_BASELINE_PATH]).trim()) return { tree, checks: [] };
308
+ const text = baselineGit(root, ["cat-file", "blob", `${tree}:${QA_BASELINE_PATH}`]);
309
+ if (Buffer.byteLength(text) > QA_BASELINE_MAX_BYTES)
310
+ throw new VerificationDescriptorError("qa baseline exceeds the byte bound");
311
+ let value: unknown;
312
+ try { value = JSON.parse(text); }
313
+ catch { throw new VerificationDescriptorError("qa baseline is not valid JSON"); }
314
+ if (!value || typeof value !== "object" || Array.isArray(value)
315
+ || Object.keys(value).some(key => key !== "contract" && key !== "checks")
316
+ || (value as { contract?: unknown }).contract !== QA_BASELINE_CONTRACT
317
+ || !Array.isArray((value as { checks?: unknown }).checks)
318
+ || (value as { checks: unknown[] }).checks.length > QA_BASELINE_MAX_CHECKS)
319
+ throw new VerificationDescriptorError("qa baseline contract is invalid");
320
+ const seen = new Set<string>();
321
+ const checks = (value as { checks: unknown[] }).checks.map((item): QaBaselineCheck => {
322
+ const check = item as Record<string, unknown> | null;
323
+ if (!check || typeof check !== "object" || Array.isArray(check)
324
+ || Object.keys(check).some(key => key !== "id" && key !== "verification")
325
+ || typeof check.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(check.id) || seen.has(check.id)
326
+ || !check.verification || typeof check.verification !== "object" || Array.isArray(check.verification))
327
+ throw new VerificationDescriptorError("qa baseline check is invalid");
328
+ seen.add(check.id);
329
+ return { id: check.id, descriptor: parseVerificationDescriptor(JSON.stringify(check.verification)) };
330
+ });
331
+ return { tree, checks };
332
+ }