immune-brain 4.6.0 → 5.0.0

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 (64) hide show
  1. package/README.md +154 -37
  2. package/README.zh-CN.md +160 -40
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +42 -44
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +142 -474
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +54 -84
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7369 -7016
  16. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  17. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  18. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  19. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  20. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  21. package/plugins/immune-brain/dist/imm-planner.md +23 -8
  22. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  23. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  24. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +33 -7
  25. package/plugins/immune-brain/dist/registry.yaml +9 -9
  26. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  27. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  28. package/plugins/immune-brain/runtime/assurance/coordinator.ts +83 -67
  29. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  30. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  31. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  32. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +62 -369
  33. package/plugins/immune-brain/runtime/claude/mcp_server.ts +2 -0
  34. package/plugins/immune-brain/runtime/claude/review_host.ts +48 -16
  35. package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
  36. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  37. package/plugins/immune-brain/runtime/kernel/application.ts +6 -3
  38. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  39. package/plugins/immune-brain/runtime/kernel/canary_application.ts +5 -3
  40. package/plugins/immune-brain/runtime/kernel/enrollment.ts +149 -5
  41. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  42. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  43. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  44. package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
  45. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  46. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  47. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  48. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
  49. package/plugins/immune-brain/runtime/unattended/batch_git.ts +57 -27
  50. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  51. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +12 -14
  52. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +34 -29
  53. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +26 -90
  54. package/plugins/immune-brain/runtime/workspace_scope.ts +34 -0
  55. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  56. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  57. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  58. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  59. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  60. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  61. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  62. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
  63. package/plugins/immune-brain/skills/registry.yaml +9 -9
  64. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -581
@@ -149,9 +149,9 @@ export type AsyncAgentLaunch = { agentId: string; outputFile: string };
149
149
  /**
150
150
  * Recognise the launch receipt this Claude Code build returns for `Agent`.
151
151
  *
152
- * Every `Agent` call here runs asynchronously — `run_in_background: false` in
153
- * the dispatch envelope is not honoured, and there is no synchronous mode. The
154
- * tool result is therefore
152
+ * Every `Agent` call here runs asynchronously: this Host has no synchronous
153
+ * mode and no background switch, so the dispatch envelope carries nothing that
154
+ * would ask for one. The tool result is therefore
155
155
  * `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
156
156
  * proof that a subagent started, never its answer. Treating it as the verdict
157
157
  * would settle Review on a receipt for starting the reviewer, so the launch
@@ -277,6 +277,12 @@ export class FileHookEventLog implements HookEventLog {
277
277
 
278
278
  interface PendingReview {
279
279
  request: ReviewRequest;
280
+ /**
281
+ * The prompt the Host asked the Parent to dispatch, marker line included.
282
+ * Hook evidence carries Parent-authored text, so this is the only string a
283
+ * dispatched prompt is ever compared against.
284
+ */
285
+ dispatchPrompt: string;
280
286
  initialCursors: Map<string, number>;
281
287
  sessionCursors: Map<string, number>;
282
288
  startEvent?: Extract<ClaudeHookEvent, { type: "SubagentStart" }>;
@@ -286,18 +292,39 @@ interface PendingReview {
286
292
  error?: string;
287
293
  }
288
294
 
295
+ /**
296
+ * The reserved dispatch prompt: the reservation marker line followed by the
297
+ * per-dispatch snapshot facts, byte for byte.
298
+ *
299
+ * One place builds it and one place compares against it, so the marker format
300
+ * cannot drift from what the binding rule accepts. The static instructions are
301
+ * deliberately absent: the dispatched reviewer definition carries them, and they
302
+ * are generated from one source by the plugin build.
303
+ */
304
+ function reservedDispatchPrompt(
305
+ request: Pick<ReviewRequest, "operationId" | "taskId" | "snapshotPrompt">,
306
+ ): string {
307
+ return `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.snapshotPrompt}`;
308
+ }
309
+
310
+ /**
311
+ * Whether hook evidence carrying a prompt belongs to this reservation.
312
+ *
313
+ * The prompt is Parent-authored text, so the marker is not an acceptance path:
314
+ * a dispatch that keeps the matching `operation_id` and `task_id` marker but
315
+ * appends, truncates or rewrites the body does not bind and cannot settle. Only
316
+ * the exact reserved dispatch prompt does. Evidence with no prompt at all is
317
+ * judged by the identifiers the hook itself supplies.
318
+ */
319
+ function bindsPrompt(prompt: string | undefined, pending: PendingReview): boolean {
320
+ return prompt === undefined || prompt === pending.dispatchPrompt;
321
+ }
322
+
289
323
  function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
290
324
  if (event.taskId && event.taskId !== pending.request.taskId) return false;
291
- if (event.prompt !== undefined) {
292
- const match = /<!-- immune-brain:operation_id=([^\s]+)\s+task_id=([^\s]+)\s+-->/.exec(event.prompt);
293
- if (match) {
294
- if (match[1] !== pending.request.operationId || match[2] !== pending.request.taskId) return false;
295
- } else if (event.prompt !== pending.request.prompt) {
296
- return false;
297
- }
298
- }
325
+ if (!bindsPrompt(event.prompt, pending)) return false;
299
326
  if (event.operationId) return event.operationId === pending.request.operationId;
300
- return event.prompt !== undefined && event.prompt === pending.request.prompt;
327
+ return event.prompt !== undefined;
301
328
  }
302
329
 
303
330
  export class ClaudeReviewHost implements AssuranceHostPort {
@@ -321,17 +348,19 @@ export class ClaudeReviewHost implements AssuranceHostPort {
321
348
  }
322
349
  this.pending.set(request.operationId, {
323
350
  request,
351
+ dispatchPrompt: reservedDispatchPrompt(request),
324
352
  initialCursors,
325
353
  sessionCursors,
326
354
  consumed: false,
327
355
  });
328
356
  return {
329
357
  id: request.operationId,
358
+ // Only parameters the Claude `Agent` tool accepts. The Host has no turn
359
+ // cap and no background switch, so `request.maxTurns` (which the Pi port
360
+ // forwards natively) has no receiver here and is not projected.
330
361
  dispatch: {
331
362
  name: CLAUDE_REVIEWER_AGENT,
332
- prompt: `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.prompt}`,
333
- max_turns: request.maxTurns,
334
- run_in_background: false,
363
+ prompt: reservedDispatchPrompt(request),
335
364
  },
336
365
  };
337
366
  }
@@ -434,6 +463,9 @@ export class ClaudeReviewHost implements AssuranceHostPort {
434
463
  if (event.toolName !== AGENT_TOOL) return;
435
464
  if (!event.agentId || !event.operationId || event.operationId !== state.request.operationId) return;
436
465
  if ("taskId" in event && event.taskId && event.taskId !== state.request.taskId) return;
466
+ // The `Agent` tool input is where this Host actually reports the
467
+ // dispatched prompt, so it is bound by the same exactness rule.
468
+ if (!bindsPrompt(event.prompt, state)) return;
437
469
  if (state.startEvent && event.sessionId !== state.startEvent.sessionId) return;
438
470
  if (state.startEvent && event.agentId !== state.startEvent.agentId) return;
439
471
  if (state.stopEvent && event.sessionId !== state.stopEvent.sessionId) return;
@@ -458,7 +490,7 @@ export class ClaudeReviewHost implements AssuranceHostPort {
458
490
  }
459
491
  if (state.stopEvent) {
460
492
  if (state.stopEvent === event) return;
461
- state.error = "duplicate SubagentStop observed for review reservation";
493
+ state.error = "duplicate SubagentStop observed for review reservation: a reviewer was continued after it finished; a reviewer cannot be continued and a fresh reviewer must be dispatched";
462
494
  return;
463
495
  }
464
496
  state.stopEvent = event;
@@ -0,0 +1,83 @@
1
+ import type { InternalRole } from "../role_prompt_bridge";
2
+
3
+ /**
4
+ * Claude plugin agent definitions for the read-only internal roles.
5
+ *
6
+ * A native definition is the strictest boundary the Host can express for these
7
+ * roles: `Read, Grep, Glob` grants no write, shell or dispatch tool, while the
8
+ * Pi mapping leaves the same boundary as prompt text. `INTERNAL_ROLE_PROMPTS`
9
+ * declares `no tools` or `read-only tools` for every role here; a native
10
+ * definition cannot express an empty allowlist without inheriting every tool,
11
+ * so read-only-without-shell is what ships.
12
+ *
13
+ * The bodies are generated from the matching `runtime/prompts/` source by
14
+ * `scripts/build-claude-plugin.ts`, never maintained by hand.
15
+ */
16
+ export interface ClaudeReadonlyRoleAgent {
17
+ /** The plugin-qualified agent type the Claude `Agent` tool accepts. */
18
+ readonly agent: string;
19
+ /** Repository-relative definition the plugin build generates. */
20
+ readonly definition: string;
21
+ /** The role prompt source the definition body is generated from. */
22
+ readonly prompt: string;
23
+ /** Frontmatter `description`: when the Host may select this agent. */
24
+ readonly description: string;
25
+ }
26
+
27
+ export const CLAUDE_READONLY_ROLE_AGENTS = {
28
+ qa: {
29
+ agent: "immune-brain:immune-brain-qa",
30
+ definition: "plugins/immune-brain/agents/immune-brain-qa.md",
31
+ prompt: "plugins/immune-brain/runtime/prompts/qa.md",
32
+ description:
33
+ "Immune-Brain QA authority. Judges recorded execution evidence against one active target and returns one decision.",
34
+ },
35
+ "ui-review": {
36
+ agent: "immune-brain:immune-brain-ui-review",
37
+ definition: "plugins/immune-brain/agents/immune-brain-ui-review.md",
38
+ prompt: "plugins/immune-brain/runtime/prompts/ui-review.md",
39
+ description:
40
+ "Immune-Brain UI Review authority. Read-only review of a bounded UI change surface and its evidence.",
41
+ },
42
+ "advisory-reviewer": {
43
+ agent: "immune-brain:immune-brain-advisory-reviewer",
44
+ definition: "plugins/immune-brain/agents/immune-brain-advisory-reviewer.md",
45
+ prompt: "plugins/immune-brain/runtime/prompts/advisory-reviewer.md",
46
+ description:
47
+ "Immune-Brain advisory reviewer for one explicit lens. Read-only advice; synthesis stays with the coordinating Parent.",
48
+ },
49
+ } as const satisfies Record<string, ClaudeReadonlyRoleAgent>;
50
+
51
+ /** The read-only roles that ship a generated native definition. */
52
+ export type ClaudeReadonlyRole = keyof typeof CLAUDE_READONLY_ROLE_AGENTS;
53
+
54
+ export const CLAUDE_READONLY_ROLES = Object.keys(
55
+ CLAUDE_READONLY_ROLE_AGENTS,
56
+ ) as ClaudeReadonlyRole[];
57
+
58
+ /** The tool allowlist every generated read-only definition declares. */
59
+ export const CLAUDE_READONLY_TOOLS = ["Read", "Grep", "Glob"] as const;
60
+
61
+ /**
62
+ * The read-only internal roles that ship no definition because the Host already
63
+ * provides the agent. `arch-explorer` is architecture discovery: Claude's own
64
+ * `Explore` agent is the read-only research agent, and shipping a second
65
+ * definition would fork a boundary the Host owns.
66
+ */
67
+ export const CLAUDE_HOST_PROVIDED_ROLE_AGENTS = {
68
+ "arch-explorer": { agent: "Explore" },
69
+ } as const satisfies Record<string, { agent: string }>;
70
+
71
+ /**
72
+ * Roles that are not dispatched as native Claude agents.
73
+ *
74
+ * `code-review` ships its own definition with a wider allowlist (it must run
75
+ * read-only Git commands), so it is not part of this table. The remaining roles
76
+ * write: `executor`, `test-fixer` and `pr-fix` mutate the workspace, and
77
+ * `compounder` writes learning storage. A write-capable role stays bounded by
78
+ * its dispatch envelope and the Parent, not by an agent definition.
79
+ */
80
+ export const CLAUDE_NATIVE_AGENT_ROLES = new Set<InternalRole>([
81
+ ...CLAUDE_READONLY_ROLES,
82
+ ...(Object.keys(CLAUDE_HOST_PROVIDED_ROLE_AGENTS) as InternalRole[]),
83
+ ]);
@@ -29,7 +29,7 @@ import {
29
29
  import { inspectSpecBinding } from "../kernel/spec_binding";
30
30
  import { projectTask, resolveProjectedRisk } from "../kernel/completion";
31
31
  import { deriveAssuranceAuthorization } from "../kernel/assurance_projection";
32
- import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
32
+ import { taskDeliveryIdentity } from "../workspace_scope";
33
33
  import type { TaskIntentV1, TaskRisk } from "../kernel/types";
34
34
  import {
35
35
  canonicalDescriptorBytes,
@@ -273,10 +273,7 @@ function runInspect(root: string): KernelExecution {
273
273
  const workspaceState = readWorkspaceStateRaw(root);
274
274
  let identity;
275
275
  try {
276
- identity =
277
- record.contract === "assurance_kernel/task_record/v4"
278
- ? taskRevisionIdentity(root, intent.scope_hint, record.git_base_head, intent.task_id)
279
- : taskDiffIdentity(root, intent.scope_hint, intent.task_id);
276
+ identity = taskDeliveryIdentity(root, record);
280
277
  } catch (error) {
281
278
  return sourceFailure("inspect", error);
282
279
  }
@@ -571,7 +568,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
571
568
  "rejected",
572
569
  "kernel_owner_active",
573
570
  null,
574
- "Route to imm-loop instead of authoring a new draft.",
571
+ "Route to imm-run instead of authoring a new draft.",
575
572
  ),
576
573
  };
577
574
  if (owner.v3)
@@ -9,6 +9,7 @@ import {
9
9
  type MutationAuthorityRegistry,
10
10
  } from "./authority_port";
11
11
  import { asTaskDiffSnapshot } from "./completion";
12
+ import { taskDeliveryIdentity } from "../workspace_scope";
12
13
  import { canonicalIntentHash, readTaskIntent } from "./intent";
13
14
  import {
14
15
  inspectIntentTokenPair,
@@ -51,8 +52,9 @@ export interface ApplyTaskActionInput {
51
52
  /** Paired mutation authority registry that must recognize every supplied capability. */
52
53
  registry: MutationAuthorityRegistry;
53
54
  capability?: MutationAuthorityCapabilityV2;
54
- /** Trusted injected diff provider; the action's diff_hash is only an expectation. */
55
- diffProvider: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
55
+ /** Trusted injected diff provider; the action's diff_hash is only an expectation.
56
+ * Defaults to the shared delivery identity selector when omitted. */
57
+ diffProvider?: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
56
58
  /**
57
59
  * Terminal commit mode: record + workspace + active-claim removal + task
58
60
  * tombstone converge through one recoverable terminal transaction marker
@@ -71,7 +73,8 @@ export interface ApplyTaskActionInput {
71
73
  export function applyTaskAction(
72
74
  input: ApplyTaskActionInput,
73
75
  ): StoredTaskMutationV3 {
74
- const { root, task_id, prior_intent_token, registry, capability, diffProvider } = input;
76
+ const { root, task_id, prior_intent_token, registry, capability } = input;
77
+ const diffProvider = input.diffProvider ?? taskDeliveryIdentity;
75
78
  return withKernelStoreLock(root, () => {
76
79
  const current = readTaskRecordRaw(root, task_id);
77
80
  if (!current.record)
@@ -2,9 +2,9 @@
2
2
  // projection that binds a task's backend claim, TaskRecord v3, workspace,
3
3
  // attestations, findings, and completion facts into one closed result.
4
4
  //
5
- // This module is deliberately INTERNAL to the Kernel runtime: it is imported
6
- // by the Pi extension only through the structural runtime-stub adapter and is
7
- // never re-exported from ./index.ts. Hosts consume the projection without
5
+ // This module is deliberately INTERNAL to the Kernel runtime: both host
6
+ // adapters import it statically and it is never re-exported from ./index.ts.
7
+ // Hosts consume the projection without
8
8
  // reconstructing evidence/approval freshness or authorization readiness.
9
9
  //
10
10
  // It does not know about Pi session state, pending native Review verdicts,
@@ -13,13 +13,15 @@
13
13
 
14
14
  import { readBackendClaim, readTaskTombstone } from "./backend_claim";
15
15
  import {
16
- readTaskRecord,
16
+ readTaskRecordRaw,
17
17
  readAuditTaskPair,
18
+ localRunId,
18
19
  readCommittedRecord,
19
20
  readWorkspaceStateRaw,
20
21
  reconcileKernelAuthority,
21
22
  } from "./storage";
22
23
  import { projectTask, resolveProjectedRisk } from "./completion";
24
+ import { taskDeliveryIdentity } from "../workspace_scope";
23
25
  import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
24
26
  import type { TaskRecordV2 } from "./legacy_task_record";
25
27
 
@@ -244,7 +246,9 @@ function projectFromRecord(
244
246
  /**
245
247
  * One closed read-only projection for a task. The injected diffProvider keeps
246
248
  * this module free of git/worktree concerns; hosts supply it from their own
247
- * task-scope diff implementation.
249
+ * task-scope diff implementation. When no provider is supplied the shared
250
+ * delivery identity selector (workspace_scope.taskDeliveryIdentity) computes
251
+ * it; a supplied provider still overrides it (I5).
248
252
  *
249
253
  * Error semantics:
250
254
  * - no authority facts -> error null, claim null, empty projection
@@ -257,7 +261,7 @@ function projectFromRecord(
257
261
  export async function projectAssurance(
258
262
  root: string,
259
263
  taskId: string,
260
- diffProvider: (root: string, record: TaskRecord) => TaskDiffSnapshot,
264
+ diffProvider: (root: string, record: TaskRecord) => TaskDiffSnapshot = taskDeliveryIdentity,
261
265
  ): Promise<AssuranceProjectionResult> {
262
266
  const fail = (
263
267
  error: string,
@@ -303,7 +307,7 @@ export async function projectAssurance(
303
307
  }
304
308
  let read;
305
309
  try {
306
- read = await readTaskRecord(root, taskId);
310
+ read = await readTaskRecordRaw(root, taskId);
307
311
  } catch (error) {
308
312
  if (!terminalOwner || !(error instanceof Error) || !error.message.startsWith("TaskRecord v2")) throw error;
309
313
  return fail(error instanceof Error ? error.message : String(error));
@@ -332,7 +336,10 @@ export async function projectAssurance(
332
336
  },
333
337
  };
334
338
  }
335
- const auditPair = await readAuditTaskPair(root, taskId);
339
+ // Run-aware: with a local run row the store already answered above;
340
+ // without one (a fresh clone or pre-run-layout evidence) only the
341
+ // resolved pair may serve, never another run's directory by drift.
342
+ const auditPair = await readAuditTaskPair(root, taskId, localRunId(root, taskId) ?? undefined);
336
343
  if (!auditPair) return fail(`task ${taskId} has no terminal audit pair`, claim);
337
344
  const workspace = await readWorkspaceStateRaw(root);
338
345
  if (auditPair.record.contract === "assurance_kernel/task_record/v2")
@@ -14,6 +14,7 @@ import {
14
14
  } from "./authority_port";
15
15
  import { applyTaskAction } from "./application";
16
16
  import { asTaskDiffSnapshot } from "./completion";
17
+ import { taskDeliveryIdentity } from "../workspace_scope";
17
18
  import { readTaskIntent } from "./intent";
18
19
  import {
19
20
  inspectIntentTokenPair,
@@ -67,8 +68,9 @@ export interface CanaryExecuteInput {
67
68
  task_id: string;
68
69
  operation: CanaryOperation;
69
70
  prior_intent_token: TaskIntentIdentityToken;
70
- /** Trusted injected diff provider; the application derives the diff identity. */
71
- diffProvider: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
71
+ /** Trusted injected diff provider; the application derives the diff identity.
72
+ * Defaults to the shared delivery identity selector when omitted. */
73
+ diffProvider?: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
72
74
  now?: string;
73
75
  }
74
76
 
@@ -313,7 +315,7 @@ export function createCanaryApplication(
313
315
  record: current.record,
314
316
  };
315
317
  });
316
- const diffHash = asTaskDiffSnapshot(input.diffProvider(input.root, snapshot.record)).diff_hash;
318
+ const diffHash = asTaskDiffSnapshot((input.diffProvider ?? taskDeliveryIdentity)(input.root, snapshot.record)).diff_hash;
317
319
  if (operation.op === "stop" && !("capability" in operation))
318
320
  throw new KernelInvariantError(["stop requires user authority capability"]);
319
321
  const hasBoundSpec = boundSpecPath(snapshot.intent_snapshot) !== undefined;
@@ -1,7 +1,10 @@
1
- // P2B0 canary enrollment core. NOT exported from kernel/index.ts.
2
- // Atomically creates TaskRecord v3 + workspace working claim + backend claim
3
- // for one confirmed canary task. Requires a valid EnrollmentCapability.
4
- // No CLI, runtime route, or production issuer exists in P2B0.
1
+ // Canary enrollment core. NOT exported from kernel/index.ts.
2
+ // Atomically creates TaskRecord v4 + workspace working claim + backend claim
3
+ // for one confirmed canary task through a valid EnrollmentCapability.
4
+ // Production callers go through the single `enrollTask` entry (D3): it issues
5
+ // the capability, rehearses zero-write, runs the optional caller checkpoint,
6
+ // then commits. `runEnrollmentRehearsal` stays exported as a zero-write
7
+ // precheck for tests and diagnostics only.
5
8
 
6
9
  import { readRunRowByTask, withKernelRead } from "./sqlite_store";
7
10
  import { readTaskIntent } from "./intent";
@@ -21,6 +24,7 @@ import { dirtyScopePaths, writeEnrollmentBaseline } from "../workspace_scope";
21
24
  import { enrollmentRequestDigest } from "./run_identity";
22
25
  import {
23
26
  commitEnrollmentLocked,
27
+ enrollmentEventIdFor,
24
28
  readCommittedEnrollmentResult,
25
29
  readTaskRecordRaw,
26
30
  readWorkspaceStateRaw,
@@ -53,6 +57,48 @@ export interface EnrollCanaryInput {
53
57
  now: string;
54
58
  }
55
59
 
60
+ /**
61
+ * A rehearsal that is not ready rejects through this single typed error so the
62
+ * "Kernel enrollment rehearsal failed" message is produced in exactly one place
63
+ * (deepen-authority-seams D3). Callers that decorate it — the Pi enroll Tool
64
+ * appends its empty-initial-commit note — read `message` or `blockers`.
65
+ */
66
+ export class EnrollmentRehearsalError extends Error {
67
+ readonly blockers: readonly string[];
68
+ constructor(blockers: readonly string[]) {
69
+ super(`Kernel enrollment rehearsal failed: ${blockers.join("; ")}`);
70
+ this.name = "EnrollmentRehearsalError";
71
+ this.blockers = blockers;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The one Enrollment entry's request. The entry issues the capability for this
77
+ * binding itself, so a caller supplies the binding, not a pre-issued capability.
78
+ */
79
+ export interface EnrollmentEntryRequest {
80
+ binding: EnrollmentCapabilityBinding;
81
+ /** Present only for a batch-derived child enrollment. */
82
+ batch?: EnrollBatchContext;
83
+ now: string;
84
+ /**
85
+ * Optional caller checkpoint run after a ready rehearsal and before the
86
+ * commit. Declining cancels the Enrollment with zero Kernel writes; once the
87
+ * commit has started, cancellation has no effect.
88
+ */
89
+ checkpoint?: () => boolean;
90
+ }
91
+
92
+ /** The same request with a mandatory checkpoint: the entry yields a discriminated outcome. */
93
+ export interface EnrollmentEntryRequestWithCheckpoint extends EnrollmentEntryRequest {
94
+ checkpoint: () => boolean;
95
+ }
96
+
97
+ /** The entry's outcome when a checkpoint is supplied: enrolled, or cancelled. */
98
+ export type EnrollmentEntryOutcome =
99
+ | { outcome: "enrolled"; result: EnrollCanaryResult }
100
+ | { outcome: "cancelled" };
101
+
56
102
  export interface EnrollCanaryResult {
57
103
  record: TaskRecord;
58
104
  backend_claim: BackendClaim;
@@ -255,7 +301,105 @@ export function runEnrollmentRehearsal(
255
301
  }
256
302
 
257
303
  function enrollmentEventId(taskId: string, now: string): string {
258
- return `enroll-${taskId}-${now}`;
304
+ return enrollmentEventIdFor(taskId, now);
305
+ }
306
+
307
+ /**
308
+ * The single Enrollment entry (deepen-authority-seams D3): issues the
309
+ * capability for the supplied binding, runs the zero-write rehearsal, calls the
310
+ * optional caller checkpoint, and commits. A rehearsal that is not ready
311
+ * rejects with its blockers in the one shared message and writes nothing; a
312
+ * declined checkpoint cancels with zero writes; once commit has started,
313
+ * cancellation has no effect. Replay of a lost Enrollment keeps today's
314
+ * behavior because the commit step is the unchanged durable operation.
315
+ */
316
+ /**
317
+ * A request that supplies no checkpoint, or one that is `undefined`. Only such
318
+ * a request is guaranteed to return the plain result: the entry has no caller
319
+ * window in which it could cancel.
320
+ */
321
+ export interface EnrollmentEntryRequestWithoutCheckpoint extends EnrollmentEntryRequest {
322
+ checkpoint?: undefined;
323
+ }
324
+
325
+ /**
326
+ * The one Cancellation boundary. Between the readiness decision and the
327
+ * caller checkpoint the entry yields once, so a cancellation already queued by
328
+ * the host (a microtask, an already-resolved signal) is observed by the
329
+ * checkpoint instead of losing the race against the synchronous step that
330
+ * follows. Zero writes either way.
331
+ */
332
+ const CANCELLATION_BOUNDARY = Promise.resolve();
333
+
334
+ export async function enrollTask(
335
+ root: string,
336
+ registry: EnrollmentAuthorityRegistry,
337
+ request: EnrollmentEntryRequestWithCheckpoint,
338
+ ): Promise<EnrollmentEntryOutcome>;
339
+ export async function enrollTask(
340
+ root: string,
341
+ registry: EnrollmentAuthorityRegistry,
342
+ request: EnrollmentEntryRequestWithoutCheckpoint,
343
+ ): Promise<EnrollCanaryResult>;
344
+ /**
345
+ * The general interface shape, whose checkpoint is optional: the caller can
346
+ * still hold it back, so the return has to stay the full union. Without this
347
+ * overload a value typed `EnrollmentEntryRequest` matches neither of the two
348
+ * above and the call does not compile, even though the runtime answers it.
349
+ */
350
+ export async function enrollTask(
351
+ root: string,
352
+ registry: EnrollmentAuthorityRegistry,
353
+ request: EnrollmentEntryRequest,
354
+ ): Promise<EnrollCanaryResult | EnrollmentEntryOutcome>;
355
+ export async function enrollTask(
356
+ root: string,
357
+ registry: EnrollmentAuthorityRegistry,
358
+ request: EnrollmentEntryRequest,
359
+ ): Promise<EnrollCanaryResult | EnrollmentEntryOutcome> {
360
+ const { binding, batch, now, checkpoint } = request;
361
+ // (1) Issue the capability for the supplied binding.
362
+ const capability = registry.issue(binding);
363
+ const input: EnrollCanaryInput = {
364
+ task_id: binding.task_id,
365
+ intent_path: binding.intent_path,
366
+ intent_revision: binding.intent_revision,
367
+ preparation_digest: binding.preparation_digest,
368
+ capability,
369
+ capability_binding: binding,
370
+ ...(batch ? { batch } : {}),
371
+ now,
372
+ };
373
+ // Replay of a lost Enrollment: the exact request already committed here, so
374
+ // the durable operation is the only correct answer. This must precede the
375
+ // rehearsal, whose precondition checks would otherwise report the now-existing
376
+ // TaskRecord and workspace owner as blockers and make the durable replay
377
+ // unreachable. A request committed for a different binding throws, so the
378
+ // divergent-request refusal is preserved.
379
+ const { eventId, digest } = digestForEnrollment(input);
380
+ const committed = readCommittedEnrollmentResult(root, input.task_id, eventId, digest);
381
+ if (committed) {
382
+ const result: EnrollCanaryResult = {
383
+ record: committed.record,
384
+ backend_claim: committed.claim,
385
+ workspace: { revision: "", state: committed.workspace },
386
+ };
387
+ return checkpoint ? { outcome: "enrolled", result } : result;
388
+ }
389
+ // (2) Rehearse: not ready rejects with the blockers and writes nothing.
390
+ const rehearsal = runEnrollmentRehearsal(root, input, capability, registry);
391
+ if (!rehearsal.rehearsed || rehearsal.evidence.outcome !== "ready")
392
+ throw new EnrollmentRehearsalError(rehearsal.evidence.blockers);
393
+ // The boundary: a cancellation the host queued while the rehearsal ran is
394
+ // delivered here, before the checkpoint can start the commit.
395
+ await CANCELLATION_BOUNDARY;
396
+ // (3) Optional caller checkpoint: declining cancels before any write.
397
+ if (checkpoint && !checkpoint()) return { outcome: "cancelled" };
398
+ // (4) Commit; not cancellable from here. Without a caller checkpoint the
399
+ // entry is the plain durable operation the replay of a lost Enrollment
400
+ // already handles inside `enrollCanaryTask`.
401
+ const result = enrollCanaryTask(root, input, registry);
402
+ return checkpoint ? { outcome: "enrolled", result } : result;
259
403
  }
260
404
 
261
405
  function digestForEnrollment(input: EnrollCanaryInput): {
@@ -10,7 +10,9 @@ export * from "./completion";
10
10
  export {
11
11
  readWorkspaceStateRaw,
12
12
  readTaskRecordRaw,
13
- readTaskRecord,
13
+ readSettledRunEvidence,
14
+ readSettledTaskEvidence,
15
+ enrollmentEventIdFor,
14
16
  readSecureProjectFile,
15
17
  commitTaskRecordLocked,
16
18
  withKernelStoreLock,