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
@@ -10,23 +10,41 @@ On a batch `handoff.role=executor`, implement only its named enrolled child;
10
10
  the batch stays `running` with that child `enrolled`. Return to the Parent after
11
11
  scoped implementation, staging and focused diagnostics. Enrollment itself does
12
12
  not start QA; the Parent owns the subsequent Kernel Assurance and foreground
13
- Review handoff under the same still-valid batch authorization.
13
+ Review handoff under the same still-valid batch authorization. A child settlement
14
+ returns execution to the bound Parent; continue dependency-ready independent
15
+ work until whole-plan integration and final verification pass. Critical and new
16
+ repair tasks use their own native Enrollment; stopped or excluded work never
17
+ counts as Parent success.
14
18
 
15
- Before handoff, run the permitted diagnostic checks, complete the Delivery
16
- Evidence check below and return its table to the Parent as structured diagnostic
17
- evidence. The read-only Loop
19
+ Before handoff, run the permitted diagnostic checks and stage the task-owned
20
+ changes. On rework, also complete the Delivery Evidence check below and return
21
+ its table to the Parent as structured diagnostic evidence. The read-only Loop
18
22
  runtime action only constructs the dispatch envelope; it does not store
19
23
  evidence or change task state. Preserve failed and blocked attempts. Do not
20
24
  perform QA,
21
25
  review, plan mutation, successor approval, Compounder work, or authority
22
- writes. If the requested change needs scope expansion, stop and return an
23
- `imm-planner` route with the concrete missing scope and verification reason.
26
+ writes. A sibling file in a directory the scope already names a file in is a
27
+ same-directory addition: submit `revise_intent` with the complete next intent
28
+ and continue, without a Planner route or a native gate. If the requested change
29
+ needs scope expansion beyond that (a glob, a new directory, a removed entry, a
30
+ path the project's risk floor names in `.imm/risk-floor.json`), stop and return an `imm-planner` route
31
+ with the concrete missing scope and verification reason.
24
32
 
25
33
  ## Delivery Evidence
26
34
 
27
- Reuse the TaskIntent and any bound Spec's agreed seams and controls. Before the
28
- first Assurance attempt, return one compact row per acceptance; update affected
29
- rows after repair. This is handoff evidence, not a new artifact or approval gate.
35
+ Prefer direct checks. For truncated display, use an explicit shell with
36
+ `pipefail`, preserve the check's real exit status and a task-owned full log;
37
+ display success is not check success. Keep global shell settings unchanged.
38
+ When the Spec or user requests test-first, observe a business-assertion red
39
+ before implementation, then the corresponding green at the same seam; missing
40
+ modules, syntax errors or harness failures are not that red. A known omitted
41
+ generated output blocks handoff: fix it within scope or use the existing Intent
42
+ revision path before delivery.
43
+
44
+ Reuse the TaskIntent and any bound Spec's agreed seams and controls. Return one
45
+ compact row per violated acceptance or invariant only when handling rework; a
46
+ first delivery needs no table. This is handoff evidence, not a new artifact or
47
+ approval gate.
30
48
 
31
49
  | Acceptance / invariant | Entry and consumers | Positive / negative / boundary controls | Command, outcome and input | Finding coverage |
32
50
  |---|---|---|---|---|
@@ -37,6 +55,13 @@ both affected hosts and recovery/replay branches. An async, return-type or
37
55
  exception-contract change requires checking every affected caller's await,
38
56
  cancellation and error propagation.
39
57
 
58
+ Before Assurance, run the existing tests that call a changed entry or assert
59
+ the changed contract, found by searching the tests for its exported names,
60
+ including tests outside `scope_hint`. Focused acceptance descriptors do not run
61
+ them, so a stale caller test otherwise surfaces only at final verification. A
62
+ failing caller test outside the scope is a scope gap: add it through the Intent
63
+ revision path before delivery, never leave it for final verification.
64
+
40
65
  Use the real producer for the success control where available. Derive rejection
41
66
  controls from a successful state, change only the target condition, and assert
42
67
  the intended rejection reason and required side effects. A control must fail
@@ -75,7 +100,7 @@ internal return values that a public assertion already covers. Before adding a
75
100
  test, search existing tests for the same behavior and extend or parameterize
76
101
  them instead of duplicating setup. When the change retires or replaces a
77
102
  behavior, update or delete the tests that protected it within scope and name
78
- the retired behavior or continuing owner in Delivery Evidence.
103
+ the retired behavior or continuing owner in the handoff.
79
104
  Treat naming, function length, parameter count, nesting, and abstraction taste
80
105
  as contextual signals, never as automatic failure thresholds.
81
106
 
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  {
15
- "matcher": "Skill|mcp__.*immune-brain.*__(enroll|start_unattended_batch)",
15
+ "matcher": "Skill|Agent|mcp__.*immune-brain.*__.*",
16
16
  "hooks": [
17
17
  {
18
18
  "type": "command",
@@ -61,6 +61,26 @@
61
61
  ]
62
62
  }
63
63
  ],
64
+ "Stop": [
65
+ {
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
70
+ }
71
+ ]
72
+ }
73
+ ],
74
+ "PostToolUseFailure": [
75
+ {
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/claude/mcp-server.mjs\" --hook"
80
+ }
81
+ ]
82
+ }
83
+ ],
64
84
  "SessionEnd": [
65
85
  {
66
86
  "hooks": [
@@ -19,8 +19,11 @@ import type { ReviewBundle, ReviewManifestV5 } from "./review_evidence";
19
19
  import { createVerdictAuthority, type VerdictAuthority } from "./verdict_authority";
20
20
  import type { ConfirmationReferenceSource } from "./host_port";
21
21
  import { buildRoleDelegationPacket, STATIC_REVIEW_RULES } from "../role_prompt_bridge";
22
- import type { AssuranceProjectionResult } from "../kernel/assurance_projection";
22
+ import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
23
+ import { taskStatusIdentity } from "../workspace_scope";
24
+ import { pluginSourceSkewWarning, type PluginSourceSkewWarning } from "../plugin_source_skew";
23
25
  import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
26
+ import { scopeAdditionsFromHistory } from "../kernel/intent";
24
27
  import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
25
28
  import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
26
29
  import { isLaneWorkspaceForTask } from "../unattended/lane_workspace";
@@ -43,7 +46,12 @@ export interface TaskTombstone {
43
46
  }
44
47
  export interface TaskRecordRead {
45
48
  revision?: string;
46
- record?: { contract?: string; findings: Array<{ kind: string; status: string; id?: string; acceptance_id?: string | null }> } | null;
49
+ record?: {
50
+ contract?: string;
51
+ findings: Array<{ kind: string; status: string; id?: string; acceptance_id?: string | null }>;
52
+ /** Kernel history; Review disclosure reads `revise_intent` reasons from it. */
53
+ history?: Array<{ type: string; reason: string }>;
54
+ } | null;
47
55
  }
48
56
  export interface TaskIntentRead {
49
57
  /**
@@ -159,9 +167,13 @@ export interface AssuranceVerdict {
159
167
  export function reviewReworkFindings(verdict: AssuranceVerdict): TaskFinding[] {
160
168
  if (verdict.decision !== "rework" || !verdict.findings?.length)
161
169
  throw new Error("review rework findings require a rework verdict");
170
+ // Advisory notes belong to a pass attestation; one that reached a rework
171
+ // verdict is a contract violation, never Executor work to filter away.
172
+ if (verdict.findings.some((finding) => finding.kind !== "blocking"))
173
+ throw new Error("review rework findings must all be blocking");
162
174
  return verdict.findings.map((finding) => ({
163
175
  id: finding.id,
164
- kind: finding.kind,
176
+ kind: "blocking" as const,
165
177
  status: "open" as const,
166
178
  acceptance_id: finding.acceptance_id,
167
179
  source: "review" as const,
@@ -221,6 +233,14 @@ export interface SnapshotDescriptor {
221
233
  review_bundle_digest: string | null;
222
234
  /** Present only when Review authority binds an immutable Git revision (v4). */
223
235
  review_revision?: ReviewRevisionIdentity;
236
+ /**
237
+ * Review round 3 and later: the paths the latest rework changed. `skipped`
238
+ * means the prior revision was absent or unresolvable and the admission rule
239
+ * does not apply to this round.
240
+ */
241
+ review_delta?:
242
+ | { round: number; prior_review_commit: string; delta_paths: string[] }
243
+ | { round: number; skipped: "prior_review_revision_absent" | "prior_review_revision_unresolvable" };
224
244
  root: string;
225
245
  }
226
246
 
@@ -257,6 +277,56 @@ export function qaDiagnosticMetadata(d: QaCheckDiagnostic): QaCheckDiagnostic {
257
277
  return safe;
258
278
  }
259
279
 
280
+ /**
281
+ * What a failed `prepare` stage ran under, plus the end of its stderr; for an
282
+ * in-place run that found the working directory different from the frozen
283
+ * tree, the differing in-scope paths. It travels only in the direct Assurance
284
+ * Tool result: it is not part of `QaCheckDiagnostic` and is never persisted
285
+ * (no TaskRecord, finding, audit evidence, batch state or tracker projection).
286
+ */
287
+ export type QaDeliveryConditions =
288
+ | { delivery: "working_directory" }
289
+ | { delivery: "isolated_materialization"; worktree_node_modules: "absent" };
290
+ export interface QaPrepareObservation {
291
+ conditions: QaDeliveryConditions;
292
+ declared_timeout_ms: number;
293
+ stderr_tail: string;
294
+ drift_paths?: string[];
295
+ }
296
+ export const QA_DRIFT_PATH_LIMIT = 64;
297
+ export const QA_PREPARE_STDERR_TAIL_BYTES = 2048;
298
+ export const QA_PREPARE_REPRODUCTION_NOTE = "a reproduction in the live worktree is not equivalent: this run used an isolated delivery with no worktree node_modules";
299
+ export const QA_DRIFT_NOTE = "in-scope tracked content of the working directory differs from the frozen snapshot; see prepare_observation.drift_paths";
300
+ const QA_PREPARE_REASONS: QaPreparationReason[] = ["timeout", "nonzero_exit", "output_limit"];
301
+ // Control characters other than newline and tab: C0, DEL and C1.
302
+ const QA_TAIL_CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
303
+ /** The last bytes of stderr, whole UTF-8 characters only, without terminal control sequences. */
304
+ export function qaPrepareStderrTail(stderr: string): string {
305
+ const bytes = Buffer.from(stderr, "utf8");
306
+ let start = Math.max(0, bytes.length - QA_PREPARE_STDERR_TAIL_BYTES);
307
+ // Do not begin inside a multi-byte character.
308
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++;
309
+ return bytes.subarray(start).toString("utf8").replace(QA_TAIL_CONTROL, "");
310
+ }
311
+ export function qaPrepareObservation(declaredTimeoutMs: number, stderr: string,
312
+ delivery: QaDeliveryConditions["delivery"] = "working_directory", driftPaths?: string[]): QaPrepareObservation {
313
+ return {
314
+ conditions: delivery === "isolated_materialization" ? { delivery, worktree_node_modules: "absent" } : { delivery: "working_directory" },
315
+ declared_timeout_ms: declaredTimeoutMs,
316
+ stderr_tail: qaPrepareStderrTail(stderr),
317
+ ...(driftPaths ? { drift_paths: driftPaths.slice(0, QA_DRIFT_PATH_LIMIT).map(path => path.replace(QA_TAIL_CONTROL, "").slice(0, 512)) } : {}),
318
+ };
319
+ }
320
+ /** The Tool boundary never spreads arbitrary executor fields. */
321
+ export function qaPrepareObservationMetadata(o: QaPrepareObservation): QaPrepareObservation {
322
+ const delivery = o.conditions?.delivery;
323
+ if (!Number.isSafeInteger(o.declared_timeout_ms) || o.declared_timeout_ms < 0 || typeof o.stderr_tail !== "string"
324
+ || (delivery !== "working_directory" && delivery !== "isolated_materialization")
325
+ || (o.drift_paths !== undefined && (!Array.isArray(o.drift_paths) || o.drift_paths.some(path => typeof path !== "string"))))
326
+ throw new Error("invalid QA prepare observation");
327
+ return qaPrepareObservation(o.declared_timeout_ms, o.stderr_tail, delivery, o.drift_paths);
328
+ }
329
+
260
330
  export interface QaVerificationProgressInput {
261
331
  index: number;
262
332
  total: number;
@@ -267,13 +337,18 @@ export interface QaVerificationProgressInput {
267
337
  }
268
338
 
269
339
  export class QaPreparationError extends Error {
340
+ /** Present for a `prepare` stage ending in timeout, nonzero exit or output limit, and for in-place drift. */
341
+ public readonly prepare_observation?: QaPrepareObservation;
270
342
  constructor(public readonly stage: QaCheckDiagnostic["stage"], public readonly acceptance_ids: string[], public readonly reason: QaPreparationReason,
271
- public readonly diagnostics: QaCheckDiagnostic[] = []) {
343
+ public readonly diagnostics: QaCheckDiagnostic[] = [], prepareObservation?: QaPrepareObservation) {
272
344
  if (!QA_STAGES.includes(stage) || !QA_OUTCOMES.includes(reason)) throw new Error("invalid QA preparation classification");
273
345
  const safe = diagnostics.map(qaDiagnosticMetadata);
274
346
  if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
275
347
  super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.length}`);
276
348
  this.diagnostics = safe;
349
+ if (prepareObservation && ((stage === "prepare" && QA_PREPARE_REASONS.includes(reason))
350
+ || (reason === "protected_input_or_output_drift" && prepareObservation.drift_paths)))
351
+ this.prepare_observation = qaPrepareObservationMetadata(prepareObservation);
277
352
  this.name = "QaPreparationError";
278
353
  }
279
354
  }
@@ -291,6 +366,38 @@ export interface AssuranceRecovery {
291
366
  next_action: string;
292
367
  }
293
368
 
369
+ export interface StatusProjectionWarning {
370
+ code: "unstaged_scope_paths" | PluginSourceSkewWarning["code"];
371
+ paths: string[];
372
+ message: string;
373
+ }
374
+ export type StatusProjectionResult = AssuranceProjectionResult & { warnings?: StatusProjectionWarning[] };
375
+ /**
376
+ * The read-only status projection both Hosts return from their status Tool.
377
+ * Unstaged or untracked in-scope paths, and a loaded plugin build that differs
378
+ * from this workspace's own plugin source, come back as warnings beside the
379
+ * projection instead of replacing it. Every path that freezes a snapshot or
380
+ * binds authority keeps projecting through the refusing delivery identity.
381
+ */
382
+ export async function projectTaskStatus(root: string, taskId: string, loadedFrom?: string): Promise<StatusProjectionResult> {
383
+ let unstaged: string[] = [];
384
+ const result = await projectAssurance(root, taskId, (dir, record) => {
385
+ const identity = taskStatusIdentity(dir, record);
386
+ unstaged = identity.unstaged_scope_paths;
387
+ return identity;
388
+ });
389
+ const warnings: StatusProjectionWarning[] = [];
390
+ if (!result.error && unstaged.length > 0) warnings.push({
391
+ code: "unstaged_scope_paths",
392
+ paths: unstaged,
393
+ message: `task scope contains unstaged or untracked changes: ${unstaged.join(", ")}; advance_assurance refuses until they are staged or removed`,
394
+ });
395
+ // Reported beside errors too: a stale loaded build is the likely cause of one.
396
+ const skew = pluginSourceSkewWarning(root, loadedFrom);
397
+ if (skew) warnings.push(skew);
398
+ return warnings.length === 0 ? result : { ...result, warnings };
399
+ }
400
+
294
401
  /** Observation only; fresh Kernel facts remain the sole owner of the obligation. */
295
402
  export function deriveAssuranceRecovery(taskId: string, fresh: AssuranceProjectionResult,
296
403
  result: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[]; reason?: string },
@@ -332,6 +439,8 @@ export interface AssuranceTimings { total_ms: number; stage_ms: Record<string, n
332
439
  type AssuranceRecoveryFields = {
333
440
  timings?: AssuranceTimings;
334
441
  diagnostics?: QaCheckDiagnostic[];
442
+ /** Direct Tool result only; never persisted (see `QaPrepareObservation`). */
443
+ prepare_observation?: QaPrepareObservation;
335
444
  environment_failure?: boolean;
336
445
  recovery?: AssuranceRecovery;
337
446
  recovery_error?: "fresh_kernel_projection_unavailable";
@@ -467,6 +576,22 @@ export function snapshotDigest(snapshot: SnapshotDescriptor): string {
467
576
  return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
468
577
  }
469
578
 
579
+ export interface ReviewPromptOptions {
580
+ /**
581
+ * Paths added by compatible scope revisions, derived from Kernel history.
582
+ * The user confirmed none of them, so Review is told to treat each as task
583
+ * work and to check it against the goal (ADR 0021).
584
+ */
585
+ selfServedScopeAdditions?: readonly string[];
586
+ }
587
+
588
+ function selfServedScopeLines(added: readonly string[] | undefined): string[] {
589
+ if (!added?.length) return [];
590
+ return [
591
+ `Self-served scope additions (added by compatible Intent revisions with no user confirmation; judge each against the goal and its acceptance, not as pre-approved): ${added.join(", ")}`,
592
+ ];
593
+ }
594
+
470
595
  /**
471
596
  * The per-dispatch Review facts: which evidence to read, which revision it pins,
472
597
  * what the acceptances assert, and the exact verdict shapes.
@@ -476,7 +601,20 @@ export function snapshotDigest(snapshot: SnapshotDescriptor): string {
476
601
  * dispatched agent carries no instructions (Pi) gets `buildReviewPrompt`, which
477
602
  * prepends both.
478
603
  */
479
- export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidencePath?: string): string {
604
+ function reviewDeltaLines(delta: SnapshotDescriptor["review_delta"]): string[] {
605
+ if (!delta) return [];
606
+ if ("skipped" in delta)
607
+ return [`Review round ${delta.round}: no rework delta is available (${delta.skipped}); the delta admission rule does not apply to this round.`];
608
+ return [
609
+ `Review round ${delta.round}: review only the latest rework delta and its direct interactions. Prior reviewed commit ${delta.prior_review_commit}; delta_paths: ${delta.delta_paths.length ? delta.delta_paths.join(", ") : "(none)"}. A blocking finding must have a caller_chain path in delta_paths or violated.kind "security_boundary"; otherwise it is advisory on a pass verdict.`,
610
+ ];
611
+ }
612
+
613
+ export function buildReviewSnapshotPrompt(
614
+ snapshot: SnapshotDescriptor,
615
+ evidencePath?: string,
616
+ options: ReviewPromptOptions = {},
617
+ ): string {
480
618
  if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
481
619
  const acceptance = snapshot.acceptance.map((item) => `- ${item.id}: ${item.assertion}`).join("\n");
482
620
  const digest = snapshotDigest(snapshot);
@@ -501,11 +639,13 @@ export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidence
501
639
  revision
502
640
  ? `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review revision ${revision.review_commit} (base ${revision.base_head}, tree ${revision.review_tree}, manifest ${revision.manifest_digest}), state ${snapshot.lifecycle}:${snapshot.artifact_state}.`
503
641
  : `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review bundle ${snapshot.review_bundle_digest}, state ${snapshot.lifecycle}:${snapshot.artifact_state}.`,
642
+ ...selfServedScopeLines(options.selfServedScopeAdditions),
643
+ ...reviewDeltaLines(snapshot.review_delta),
504
644
  "Acceptance assertions:", acceptance,
505
645
  `PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]}}`,
506
646
  `A pass verdict may carry non-blocking notes as findings, but every one of them must set kind "advisory"; a blocking finding is a rework verdict and must omit approval: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>","inspected_paths":["<every changed path of the reviewed change set>"]},"findings":[{"id":"review-1","kind":"advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
507
- `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
508
- `REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
647
+ `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete input or state → observable wrong result>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
648
+ `REWORK verdicts must omit the approval field entirely; do not emit "approval": null. A rework carries only blocking findings, at least one; a note that blocks nothing is an advisory finding on a pass verdict, never on rework.`,
509
649
  ].join("\n");
510
650
  }
511
651
 
@@ -514,7 +654,11 @@ export function buildReviewSnapshotPrompt(snapshot: SnapshotDescriptor, evidence
514
654
  * rules, and the per-dispatch facts. Dispatched by a Host whose agent carries no
515
655
  * instructions of its own.
516
656
  */
517
- export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: string): string {
657
+ export function buildReviewPrompt(
658
+ snapshot: SnapshotDescriptor,
659
+ evidencePath?: string,
660
+ options: ReviewPromptOptions = {},
661
+ ): string {
518
662
  if (snapshot.role !== "review") throw new Error("native review prompt requires review role");
519
663
  const digest = snapshotDigest(snapshot);
520
664
  const rolePacket = buildRoleDelegationPacket({
@@ -529,7 +673,7 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
529
673
  return [
530
674
  rolePacket.prompt,
531
675
  ...STATIC_REVIEW_RULES,
532
- buildReviewSnapshotPrompt(snapshot, evidencePath),
676
+ buildReviewSnapshotPrompt(snapshot, evidencePath, options),
533
677
  ].join("\n");
534
678
  }
535
679
 
@@ -642,9 +786,46 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
642
786
  if (!Array.isArray(raw.findings) || raw.findings.length === 0) throw new Error("rework verdict findings are invalid");
643
787
  if (raw.approval !== undefined && raw.approval !== null) throw new Error("rework verdict must omit approval");
644
788
  const findings = parseVerdictFindings(raw.findings, snapshot);
789
+ // The verdict bytes stay the Reviewer's: a Review rework that mixes in
790
+ // advisory notes, or blocks on nothing, is rejected for resubmission and
791
+ // never demoted, filtered or synthesized here.
792
+ if (snapshot.role === "review") {
793
+ const outOfDelta = outOfDeltaBlockingFindings(findings, snapshot);
794
+ if (outOfDelta.length > 0)
795
+ throw new Error(`review round ${snapshot.review_delta!.round} admits a blocking finding only when a caller_chain path is in the rework delta or it is a security_boundary violation; finding(s) ${outOfDelta.map((finding) => finding.id).join(", ")} are outside the delta (${(snapshot.review_delta as { delta_paths: string[] }).delta_paths.join(", ") || "no changed paths"}); resubmit each as an advisory finding on a pass verdict, or justify it with violated.kind "security_boundary"`);
796
+ const advisory = findings.filter((finding) => finding.kind === "advisory");
797
+ if (advisory.length > 0 || !findings.some((finding) => finding.kind === "blocking"))
798
+ throw new Error(`review rework verdict must contain only blocking findings and at least one: ${advisory.length > 0 ? `advisory finding(s) ${advisory.map((finding) => finding.id).join(", ")} are not allowed on rework; ` : "it has no blocking finding; "}resubmit decision "pass" with approval.inspected_paths listing every reviewed changed path when nothing blocks, carrying any non-blocking notes as advisory findings`);
799
+ }
645
800
  return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
646
801
  }
647
802
 
803
+ /**
804
+ * Review round 3 and later admits a blocking finding only when it touches the
805
+ * latest rework delta or crosses a security boundary. The rule is skipped, never
806
+ * blocking, when no delta was computed for the round.
807
+ */
808
+ function outOfDeltaBlockingFindings(
809
+ findings: ReturnType<typeof parseVerdictFindings>,
810
+ snapshot: SnapshotDescriptor,
811
+ ): Array<{ id: string }> {
812
+ const delta = snapshot.review_delta;
813
+ if (!delta || !("delta_paths" in delta)) return [];
814
+ const changed = new Set(delta.delta_paths);
815
+ return findings.filter((finding) => {
816
+ if (finding.kind !== "blocking") return false;
817
+ const evidence = (finding as { evidence?: { violated: { kind: string }; caller_chain: string[] } }).evidence;
818
+ if (!evidence) return false;
819
+ if (evidence.violated.kind === "security_boundary") return false;
820
+ return !evidence.caller_chain.some((entry) => changed.has(callerChainPath(entry)));
821
+ });
822
+ }
823
+
824
+ /** The path component of a caller_chain entry: drops a `:line`, `#symbol` or `(` suffix. */
825
+ function callerChainPath(entry: string): string {
826
+ return entry.trim().replace(/^\.\//, "").split(/[:#(\s]/, 1)[0] ?? "";
827
+ }
828
+
648
829
  /**
649
830
  * Parse one verdict's findings. Review findings carry evidence and derive their
650
831
  * anchor here so a capability minted for one anchor set cannot be spent on
@@ -1151,11 +1332,17 @@ export class AssuranceCoordinator {
1151
1332
  try {
1152
1333
  ensureOperationLive();
1153
1334
  const workload = classifyReviewWorkload(review.snapshot, review.reviewManifest ?? review.reviewBundle!);
1335
+ // Derived from Kernel history on every dispatch; nothing is stored.
1336
+ const promptOptions: ReviewPromptOptions = {
1337
+ selfServedScopeAdditions: scopeAdditionsFromHistory(
1338
+ (await this.ports.readTaskRecord(ctx.cwd, taskId)).record?.history ?? [],
1339
+ ),
1340
+ };
1154
1341
  hostReservation = this.ports.host.prepareReview({
1155
1342
  taskId,
1156
1343
  operationId,
1157
- prompt: buildReviewPrompt(review.snapshot, evidence.path),
1158
- snapshotPrompt: buildReviewSnapshotPrompt(review.snapshot, evidence.path),
1344
+ prompt: buildReviewPrompt(review.snapshot, evidence.path, promptOptions),
1345
+ snapshotPrompt: buildReviewSnapshotPrompt(review.snapshot, evidence.path, promptOptions),
1159
1346
  evidencePath: evidence.path,
1160
1347
  maxTurns: reviewTurnBudget(workload),
1161
1348
  });
@@ -1182,8 +1369,11 @@ export class AssuranceCoordinator {
1182
1369
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}`,
1183
1370
  recovery_error: "fresh_kernel_projection_unavailable" };
1184
1371
  if (error instanceof QaPreparationError && !authorityCommitted && !authorityBoundaryStarted && !aborted())
1185
- return { state: "failed", operation: "qa", operation_id: operationId, reason: error.message,
1186
- environment_failure: true, diagnostics: error.diagnostics.map(qaDiagnosticMetadata) };
1372
+ return { state: "failed", operation: "qa", operation_id: operationId,
1373
+ reason: error.prepare_observation?.drift_paths ? `${error.message}; ${QA_DRIFT_NOTE}`
1374
+ : error.prepare_observation?.conditions.delivery === "isolated_materialization" ? `${error.message}; ${QA_PREPARE_REPRODUCTION_NOTE}` : error.message,
1375
+ environment_failure: true, diagnostics: error.diagnostics.map(qaDiagnosticMetadata),
1376
+ ...(error.prepare_observation ? { prepare_observation: qaPrepareObservationMetadata(error.prepare_observation) } : {}) };
1187
1377
  if (phase === "verifying" && !authorityCommitted && !authorityBoundaryStarted && !aborted() && !(error instanceof VerificationAbortedError))
1188
1378
  return { state: "failed", operation: "qa", operation_id: operationId,
1189
1379
  reason: "QA execution failed before attestation (execution_failed)", environment_failure: true };
@@ -118,7 +118,8 @@ function assertNoEscapingSymlinks(root: string, dir = root): void {
118
118
 
119
119
 
120
120
  export function writeDeliveryTree(sourceRoot: string, snapshot: GitTaskRevisionSnapshot): string {
121
- const indexDirectory = mkdtempSync(join(tmpdir(), "imm-delivery-index-"));
121
+ // Inside the Git directory: a task QA run creates nothing outside the repository.
122
+ const indexDirectory = mkdtempSync(join(git(sourceRoot, ["rev-parse", "--absolute-git-dir"]), "imm-delivery-index-"));
122
123
  try {
123
124
  git(sourceRoot, ["read-tree", snapshot.base_tree], {
124
125
  GIT_INDEX_FILE: join(indexDirectory, "index"),
@@ -1,6 +1,10 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
+ import { observeEnrollmentTaskIntent } from "../kernel/intent";
3
+ import { inspectSpecBinding } from "../kernel/spec_binding";
4
+ import { dirtyScopePaths } from "../workspace_scope";
5
+ import { commitWorkflowPaths, identityEnv, type WorkflowCommitResult } from "./workflow_autocommit";
2
6
  import { preparePiCanary, readGitHead, revalidatePiCanary, type PiCanaryPreparation, type PiCanaryPrepareInput } from "../kernel/pi_canary_prepare";
3
- import { withKernelStoreLock } from "../kernel/storage";
7
+ import { reconcileKernelAuthority, withKernelStoreLock } from "../kernel/storage";
4
8
 
5
9
  export type EnrollmentGitBase = { state: "committed"; head: string } | { state: "unborn"; branch: string };
6
10
 
@@ -28,18 +32,100 @@ export function enrollmentGitBaseNotice(base: EnrollmentGitBase): string | undef
28
32
  : undefined;
29
33
  }
30
34
 
31
- function identityEnv(root: string): NodeJS.ProcessEnv {
32
- const configured = (key: string): string => {
33
- const result = spawnSync("git", ["config", "--get", key], { cwd: root, encoding: "utf8" });
34
- if (result.status !== 0 && result.status !== 1) throw new Error(`Cannot read Git identity: ${key}`);
35
- return result.stdout?.trim() ?? "";
36
- };
37
- const name = configured("user.name") || "Immune-Brain Enrollment";
38
- const email = configured("user.email") || "enrollment@immune-brain.local";
39
- return { ...process.env,
40
- GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || name, GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || email,
41
- GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || name, GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || email,
42
- };
35
+ /** Subject of the commit Enrollment makes for its own preconditions. */
36
+ export function enrollmentPreconditionSubject(taskId: string): string {
37
+ return `chore(imm): commit Enrollment preconditions for ${taskId}`;
38
+ }
39
+
40
+ /**
41
+ * The paths Enrollment commits for `taskId`, empty when no precondition would
42
+ * fail. On a committed HEAD an untracked TaskIntent is the one failing state:
43
+ * the set is then the task's own TaskIntent and the Spec its binding names.
44
+ * In-scope dirt is not committed on a committed HEAD — the confirmed
45
+ * Enrollment gate claims it as task work (host-owned-environment D8), and a
46
+ * staged TaskIntent over a clean scope fails nothing and yields no commit. The
47
+ * `unbornScope` form is the exception: there the empty root commit is what
48
+ * makes the Planner's staged and untracked in-scope edits dirty, and the
49
+ * disclosure could not list them because no HEAD existed to compare against,
50
+ * so the commit carries them. The TaskIntent is read from the worktree file,
51
+ * tracked or not. Returns null when no set can be derived (no sidecar, an
52
+ * invalid one, an unborn HEAD); the ordinary Enrollment checks then report
53
+ * that condition themselves.
54
+ */
55
+ export function enrollmentCommittablePaths(root: string, taskId: string, unbornScope = false): string[] | null {
56
+ const intentPath = `docs/plans/${taskId}.intent.json`;
57
+ try {
58
+ if (inspectEnrollmentGitBase(root).state !== "committed") return null;
59
+ const { intent } = observeEnrollmentTaskIntent(root, taskId);
60
+ const binding = inspectSpecBinding(intent);
61
+ if (!binding.ok) return null;
62
+ const dirtyScope = unbornScope ? dirtyScopePaths(root, intent.scope_hint, taskId) : [];
63
+ const untrackedIntent = spawnSync("git", ["ls-files", "--error-unmatch", "--", intentPath], { cwd: root }).status === 1;
64
+ if (dirtyScope.length === 0 && !untrackedIntent) return [];
65
+ return [...new Set([intentPath, ...(binding.binding ? [binding.binding.active] : []), ...dirtyScope])];
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read-only. True when no Kernel owner would refuse a fresh Enrollment of
73
+ * `taskId`: the authority projection is unowned (or carries only historical
74
+ * audit evidence with no local run), and no backend claim, TaskRecord or
75
+ * workspace owner exists. An unreadable owner state does not admit.
76
+ */
77
+ function freshEnrollmentAdmitted(root: string, taskId: string): boolean {
78
+ try {
79
+ const authority = reconcileKernelAuthority(root, taskId);
80
+ if (authority.state !== "unowned" && !(authority.state === "terminal_owner" && authority.owner_run_id === null))
81
+ return false;
82
+ const owners = preparePiCanary(root, { task_id: taskId, now: new Date().toISOString() });
83
+ return !owners.backend_claim.present && !owners.task_record_v3?.present && owners.workspace.current_working === null;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Commit the Enrollment committable set when a precondition would fail, so the
91
+ * preparation that follows binds the resulting HEAD. A clean set makes no
92
+ * commit, and neither does an Enrollment an owner check would refuse anyway:
93
+ * an owned workspace keeps its owner's undelivered work uncommitted. A refused
94
+ * commit throws with Git's message.
95
+ */
96
+ export function commitEnrollmentPreconditions(root: string, taskId: string, signal?: AbortSignal, unbornScope = false): WorkflowCommitResult {
97
+ return withKernelStoreLock(root, () => {
98
+ const paths = enrollmentCommittablePaths(root, taskId, unbornScope);
99
+ if (!paths || paths.length === 0 || !freshEnrollmentAdmitted(root, taskId)) return { committed: false };
100
+ return commitWorkflowPaths(root, { paths, subject: enrollmentPreconditionSubject(taskId), signal });
101
+ });
102
+ }
103
+
104
+ /** A preparation's owner facts, without the fields a new Git base legitimately moves. */
105
+ const withoutGit = ({ digest, git_base_head, git_error, ...owners }: PiCanaryPreparation) => owners;
106
+
107
+ /**
108
+ * Unborn-HEAD path only, called after `initializeEnrollmentGitBase`: the scope
109
+ * could not be compared with an unborn HEAD, so commit it now that a base
110
+ * exists, in-scope edits included. The re-preparation must equal the confirmed
111
+ * one in everything but the Git base, exactly as the empty base commit
112
+ * requires; a TaskIntent or owner changed while the commit ran is refused, not
113
+ * enrolled.
114
+ */
115
+ export function commitUnbornEnrollmentPreconditions(
116
+ root: string, input: PiCanaryPrepareInput, confirmed: PiCanaryPreparation, signal?: AbortSignal,
117
+ ): { result: WorkflowCommitResult; preparation: PiCanaryPreparation } {
118
+ const result = commitEnrollmentPreconditions(root, input.task_id, signal, true);
119
+ if (!result.committed) return { result, preparation: confirmed };
120
+ const current = preparePiCanary(root, input);
121
+ if (current.git_base_head !== result.commit || JSON.stringify(withoutGit(current)) !== JSON.stringify(withoutGit(confirmed)))
122
+ throw new Error(`Workspace changed during the Enrollment precondition commit; retry Enrollment${preconditionCommitNote(result)}`);
123
+ return { result, preparation: current };
124
+ }
125
+
126
+ /** Suffix naming a precondition commit that outlives a failed or declined Enrollment. */
127
+ export function preconditionCommitNote(result: WorkflowCommitResult): string {
128
+ return result.committed ? `; Enrollment precondition commit ${result.commit} remains` : "";
43
129
  }
44
130
 
45
131
  /** Called only after native confirmation. Never stages files or writes Git config. */
@@ -70,7 +156,6 @@ export function initializeEnrollmentGitBase(
70
156
  git(root, ["update-ref", "--no-deref", base.branch, commit, "0".repeat(commit.length)]);
71
157
  created = commit;
72
158
  const current = preparePiCanary(root, input);
73
- const withoutGit = ({ digest, git_base_head, git_error, ...owners }: PiCanaryPreparation) => owners;
74
159
  if (git(root, ["symbolic-ref", "--no-recurse", "HEAD"]) !== base.branch || current.git_base_head !== commit
75
160
  || JSON.stringify(withoutGit(current)) !== JSON.stringify(withoutGit(previous)))
76
161
  throw new Error("Workspace changed during empty Git base initialization; retry Enrollment");
@@ -110,14 +110,8 @@ function refuse(reason_code: typeof RUN_ENTRY_REQUIRED | typeof PHASE_ENTRY_REQU
110
110
  * Whether the session's entry state admits one operation. `state` is null when
111
111
  * the store could not be read, which refuses `enroll`, `start_unattended_batch`
112
112
  * and the `imm-run` Skill. The Planner and Brainstorm Skills are never refused.
113
- * `context` is accepted for Host callers; admission no longer depends on it.
114
113
  */
115
- export function admitPhaseOperation(
116
- operation: PhaseOperation,
117
- state: PhaseEntryState | null,
118
- context?: { activeClaim: boolean },
119
- ): PhaseAdmission {
120
- void context;
114
+ export function admitPhaseOperation(operation: PhaseOperation, state: PhaseEntryState | null): PhaseAdmission {
121
115
  if (operation.kind === "tool") {
122
116
  if (state?.run_live) return { ok: true };
123
117
  return refuse(RUN_ENTRY_REQUIRED, `${operation.name} needs a literal-user imm-run entry in this session`);