immune-brain 2.8.3 → 3.0.2

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +9 -2
  5. package/plugins/immune-brain/.claude-plugin/plugin.json +8 -0
  6. package/plugins/immune-brain/.mcp.json +8 -0
  7. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +354 -64
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +74 -706
  9. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +1 -90
  10. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +13 -160
  11. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +1 -50
  12. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +1 -262
  13. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +3 -2
  14. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +8 -229
  15. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +23 -5
  16. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -0
  17. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7514 -0
  18. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +58 -0
  19. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +1 -1
  20. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  21. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +19 -13
  22. package/plugins/immune-brain/dist/imm-loop.md +6 -7
  23. package/plugins/immune-brain/dist/imm-planner.md +1 -1
  24. package/plugins/immune-brain/dist/imm-pr-fix.md +9 -0
  25. package/plugins/immune-brain/dist/role-prompts/code-review.md +33 -13
  26. package/plugins/immune-brain/dist/role-prompts/executor.md +14 -0
  27. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +9 -0
  28. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +7 -0
  29. package/plugins/immune-brain/hooks/hooks.json +55 -0
  30. package/plugins/immune-brain/runtime/assurance/coordinator.ts +836 -0
  31. package/plugins/immune-brain/runtime/assurance/enrollment.ts +6 -0
  32. package/plugins/immune-brain/runtime/assurance/host_port.ts +18 -0
  33. package/plugins/immune-brain/runtime/assurance/invocations.ts +90 -0
  34. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +50 -0
  35. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +596 -0
  36. package/plugins/immune-brain/runtime/assurance/verification.ts +233 -0
  37. package/plugins/immune-brain/runtime/claude/capability.ts +67 -0
  38. package/plugins/immune-brain/runtime/claude/interaction.ts +70 -0
  39. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +789 -0
  40. package/plugins/immune-brain/runtime/claude/mcp_server.ts +363 -0
  41. package/plugins/immune-brain/runtime/claude/review_host.ts +645 -0
  42. package/plugins/immune-brain/runtime/commands/kernel.ts +221 -3
  43. package/plugins/immune-brain/runtime/github_issue_tracker.ts +2 -2
  44. package/plugins/immune-brain/runtime/kernel/application.ts +8 -5
  45. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +24 -13
  46. package/plugins/immune-brain/runtime/kernel/authority_port.ts +78 -115
  47. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -4
  48. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +89 -0
  49. package/plugins/immune-brain/runtime/kernel/completion.ts +64 -6
  50. package/plugins/immune-brain/runtime/kernel/enrollment.ts +189 -100
  51. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +37 -80
  52. package/plugins/immune-brain/runtime/kernel/intent.ts +24 -0
  53. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +31 -0
  54. package/plugins/immune-brain/runtime/kernel/reducer.ts +36 -13
  55. package/plugins/immune-brain/runtime/kernel/storage.ts +16 -16
  56. package/plugins/immune-brain/runtime/kernel/types.ts +32 -2
  57. package/plugins/immune-brain/runtime/kernel/validation.ts +107 -16
  58. package/plugins/immune-brain/runtime/loop_contract.ts +17 -2
  59. package/plugins/immune-brain/runtime/prompts/code-review.md +33 -13
  60. package/plugins/immune-brain/runtime/prompts/executor.md +14 -0
  61. package/plugins/immune-brain/runtime/prompts/pr-fix.md +9 -0
  62. package/plugins/immune-brain/runtime/prompts/test-fixer.md +7 -0
  63. package/plugins/immune-brain/runtime/v4_runtime.ts +5 -2
  64. package/plugins/immune-brain/runtime/workspace_scope.ts +191 -5
  65. package/plugins/immune-brain/skills/imm-loop/SKILL.md +3 -4
  66. package/plugins/immune-brain/skills/imm-planner/SKILL.md +2 -0
@@ -12,6 +12,7 @@ import {
12
12
  type MutationAuthorityRegistry,
13
13
  } from "./authority_port";
14
14
  import { applyTaskAction } from "./application";
15
+ import { asTaskDiffSnapshot } from "./completion";
15
16
  import { readTaskIntent } from "./intent";
16
17
  import {
17
18
  inspectIntentTokenPair,
@@ -23,7 +24,7 @@ import {
23
24
  type BackendClaim,
24
25
  } from "./backend_claim";
25
26
  import { canonicalIntentHash } from "./intent";
26
- import { parseTaskRecordV3 } from "./validation";
27
+ import { parseTaskRecord } from "./validation";
27
28
  import type { TaskIntentIdentityToken } from "./intent_token_registry";
28
29
  import {
29
30
  commitDrainLocked,
@@ -41,6 +42,7 @@ import type {
41
42
  TaskApprovalV2,
42
43
  TaskFinding,
43
44
  TaskIntentV1,
45
+ TaskRecord,
44
46
  } from "./types";
45
47
 
46
48
  export type CanaryOperation =
@@ -62,7 +64,7 @@ export interface CanaryExecuteInput {
62
64
  operation: CanaryOperation;
63
65
  prior_intent_token: TaskIntentIdentityToken;
64
66
  /** Trusted injected diff provider; the application derives the diff identity. */
65
- diffProvider: (root: string, intent: TaskIntentV1) => string;
67
+ diffProvider: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
66
68
  now?: string;
67
69
  }
68
70
 
@@ -264,7 +266,7 @@ export function createCanaryApplication(
264
266
  consumeIntentToken(input.prior_intent_token);
265
267
  consumeIntentToken(fresh.token);
266
268
  const at = input.now ?? new Date().toISOString();
267
- const nextRecord = parseTaskRecordV3({
269
+ const nextRecord = parseTaskRecord({
268
270
  ...current.record,
269
271
  intent_ref: { ...current.record.intent_ref, path: transition.next_intent_path },
270
272
  artifact_state: transition.next_artifact_state,
@@ -318,7 +320,7 @@ export function createCanaryApplication(
318
320
  record: current.record,
319
321
  };
320
322
  });
321
- const diffHash = input.diffProvider(input.root, snapshot.intent_snapshot);
323
+ const diffHash = asTaskDiffSnapshot(input.diffProvider(input.root, snapshot.record)).diff_hash;
322
324
  if (operation.op === "stop" && !("capability" in operation))
323
325
  throw new KernelInvariantError(["stop requires user authority capability"]);
324
326
  const hasBoundSpec = snapshot.intent_snapshot.scope_hint.some(
@@ -0,0 +1,89 @@
1
+ // Shared opaque capability registry primitive. NOT exported from kernel/index.ts.
2
+ // Each registry owns its WeakMap; capabilities are only recognized by the
3
+ // registry that issued them. No module-level singleton.
4
+
5
+ export interface CapabilityRegistryHooks<TBinding, TExpected, TValidated> {
6
+ /** Validate the binding at issue time. Throw on invalid input. */
7
+ validateBinding(binding: TBinding, issuedAt: string): void;
8
+ /** Validate stored state against expected input at inspect time. Return the domain-specific validated result. */
9
+ validateAndProject(
10
+ state: TBinding & { issued_at: string },
11
+ expected: TExpected,
12
+ now: number,
13
+ ): TValidated;
14
+ }
15
+
16
+ export interface CapabilityRegistry<TBinding, TExpected, TValidated, TCapability> {
17
+ readonly brand: symbol;
18
+ issue(binding: TBinding, issuedAt?: string): TCapability;
19
+ inspect(capability: TCapability, expected: TExpected, now?: number): TValidated;
20
+ consume(capability: TCapability, expected: TExpected, now?: number): TValidated;
21
+ isConsumed(capability: TCapability): boolean;
22
+ }
23
+
24
+ interface StoredState<TBinding> {
25
+ binding: TBinding;
26
+ issued_at: string;
27
+ consumed: boolean;
28
+ }
29
+
30
+ export function createCapabilityRegistry<TBinding, TExpected, TValidated, TCapability = object>(
31
+ capabilityBrand: symbol,
32
+ hooks: CapabilityRegistryHooks<TBinding, TExpected, TValidated>,
33
+ domainLabel: string,
34
+ ): CapabilityRegistry<TBinding, TExpected, TValidated, TCapability> {
35
+ const states = new WeakMap<object, StoredState<TBinding>>();
36
+ const brand = Symbol(`${domainLabel}-registry`);
37
+
38
+ function isCapability(value: unknown): value is TCapability {
39
+ return (
40
+ !!value &&
41
+ typeof value === "object" &&
42
+ (value as Record<symbol, unknown>)[capabilityBrand] === true &&
43
+ (value as Record<symbol, unknown>)[brand] === true
44
+ );
45
+ }
46
+
47
+ function stateOf(capability: TCapability): StoredState<TBinding> {
48
+ const state = states.get(capability as object);
49
+ if (!state) throw new Error(`${domainLabel} capability is not recognized by this registry`);
50
+ return state;
51
+ }
52
+
53
+ return {
54
+ brand,
55
+ issue(binding: TBinding, issuedAt = new Date().toISOString()): TCapability {
56
+ hooks.validateBinding(binding, issuedAt);
57
+ const capability = Object.freeze(
58
+ Object.defineProperties(
59
+ {},
60
+ {
61
+ [capabilityBrand]: { value: true, enumerable: false, writable: false, configurable: false },
62
+ [brand]: { value: true, enumerable: false, writable: false, configurable: false },
63
+ },
64
+ ),
65
+ ) as TCapability;
66
+ states.set(capability as object, { binding: { ...binding }, issued_at: issuedAt, consumed: false });
67
+ return capability;
68
+ },
69
+ inspect(capability: TCapability, expected: TExpected, now = Date.now()): TValidated {
70
+ if (!isCapability(capability))
71
+ throw new Error(`${domainLabel} capability is not recognized by this registry`);
72
+ const state = stateOf(capability);
73
+ if (state.consumed) throw new Error(`${domainLabel} capability already consumed`);
74
+ return hooks.validateAndProject(
75
+ { ...state.binding, issued_at: state.issued_at },
76
+ expected,
77
+ now,
78
+ );
79
+ },
80
+ consume(capability: TCapability, expected: TExpected, now = Date.now()): TValidated {
81
+ const validated = this.inspect(capability, expected, now);
82
+ stateOf(capability).consumed = true;
83
+ return validated;
84
+ },
85
+ isConsumed(capability: TCapability): boolean {
86
+ return stateOf(capability).consumed;
87
+ },
88
+ };
89
+ }
@@ -1,12 +1,13 @@
1
+ import { classifyTaskRisk } from "./intent";
1
2
  import type {
2
3
  ApprovalKind,
3
4
  CompletionDecision,
4
5
  TaskAttestationV3,
5
6
  TaskIntentV1,
6
7
  TaskProjectionV3,
7
- TaskRecordV3,
8
+ TaskRecord,
8
9
  } from "./types";
9
- import { assertKernelInvariantsV3 } from "./validation";
10
+ import { assertKernelInvariantsV3, KernelInvariantError } from "./validation";
10
11
 
11
12
  const REQUIRED_ATTESTATIONS: Record<TaskIntentV1["risk"], ApprovalKind[]> = {
12
13
  routine: ["qa"],
@@ -14,6 +15,55 @@ const REQUIRED_ATTESTATIONS: Record<TaskIntentV1["risk"], ApprovalKind[]> = {
14
15
  critical: ["qa", "review", "user"],
15
16
  };
16
17
 
18
+ function archiveActivePlanningPath(path: string): string | null {
19
+ const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
20
+ return matched ? `docs/${matched[1]}/archive/${matched[2]}` : null;
21
+ }
22
+
23
+ function ownSidecarPaths(intent: TaskIntentV1): Set<string> {
24
+ const activeIntent = `docs/plans/${intent.task_id}.intent.json`;
25
+ const archivedIntent = archiveActivePlanningPath(activeIntent);
26
+ const excluded = new Set<string>([activeIntent]);
27
+ if (archivedIntent) excluded.add(archivedIntent);
28
+ const specs = intent.scope_hint.filter((path) => {
29
+ const archived = archiveActivePlanningPath(path);
30
+ return archived !== null && /^docs\/specs\/[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archived);
31
+ });
32
+ if (specs.length > 1) {
33
+ throw new KernelInvariantError([
34
+ `artifact transition requires at most one scope-bound Spec; found ${specs.length}`,
35
+ ]);
36
+ }
37
+ const spec = specs[0];
38
+ if (spec) {
39
+ excluded.add(spec);
40
+ const archived = archiveActivePlanningPath(spec);
41
+ if (archived) excluded.add(archived);
42
+ }
43
+ return excluded;
44
+ }
45
+
46
+ export type TaskDiffInput = string | { diff_hash: string; changed_paths?: readonly string[] };
47
+
48
+ export function asTaskDiffSnapshot(value: TaskDiffInput): {
49
+ diff_hash: string;
50
+ changed_paths?: readonly string[];
51
+ } {
52
+ if (typeof value === "string") return { diff_hash: value };
53
+ return { diff_hash: value.diff_hash, changed_paths: value.changed_paths };
54
+ }
55
+
56
+ export function resolveProjectedRisk(
57
+ intent: TaskIntentV1,
58
+ changedPaths: readonly string[] = [],
59
+ ): TaskIntentV1["risk"] {
60
+ const excluded = ownSidecarPaths(intent);
61
+ return classifyTaskRisk(
62
+ changedPaths.filter((path) => !excluded.has(path)),
63
+ intent.risk,
64
+ );
65
+ }
66
+
17
67
  function hasDistinctAuthorityAssignment(
18
68
  required: ApprovalKind[],
19
69
  candidates: TaskAttestationV3[],
@@ -32,9 +82,10 @@ function hasDistinctAuthorityAssignment(
32
82
 
33
83
  export function completionDecision(
34
84
  intent: TaskIntentV1,
35
- record: TaskRecordV3,
85
+ record: TaskRecord,
36
86
  currentDiffHash: string,
37
87
  currentIntentContentHash: string,
88
+ changedPaths: readonly string[] = [],
38
89
  ): CompletionDecision {
39
90
  assertKernelInvariantsV3(intent, record);
40
91
  const freshAttestations = record.attestations.filter(
@@ -63,7 +114,7 @@ export function completionDecision(
63
114
  )
64
115
  .map((item) => item.id);
65
116
 
66
- const requiredKinds = REQUIRED_ATTESTATIONS[intent.risk];
117
+ const requiredKinds = REQUIRED_ATTESTATIONS[resolveProjectedRisk(intent, changedPaths)];
67
118
  const candidates = freshAttestations.filter((item) => requiredKinds.includes(item.kind));
68
119
  const missingApprovalKinds = requiredKinds.filter(
69
120
  (kind) => !candidates.some((attestation) => attestation.kind === kind),
@@ -117,11 +168,18 @@ export function completionDecision(
117
168
 
118
169
  export function projectTask(
119
170
  intent: TaskIntentV1,
120
- record: TaskRecordV3,
171
+ record: TaskRecord,
121
172
  currentDiffHash: string,
122
173
  currentIntentContentHash: string,
174
+ changedPaths: readonly string[] = [],
123
175
  ): TaskProjectionV3 {
124
- const decision = completionDecision(intent, record, currentDiffHash, currentIntentContentHash);
176
+ const decision = completionDecision(
177
+ intent,
178
+ record,
179
+ currentDiffHash,
180
+ currentIntentContentHash,
181
+ changedPaths,
182
+ );
125
183
  const blocked =
126
184
  decision.blocking_finding_ids.length > 0 ||
127
185
  decision.unresolved_user_decision_ids.length > 0 ||
@@ -9,14 +9,14 @@ import {
9
9
  type EnrollmentCapabilityBinding,
10
10
  } from "./enrollment_authority";
11
11
  import { readTaskTombstone, type BackendClaim } from "./backend_claim";
12
- import { preparePiCanary } from "./pi_canary_prepare";
12
+ import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
13
13
  import {
14
14
  commitEnrollmentLocked,
15
15
  readTaskRecordRaw,
16
16
  readWorkspaceStateRaw,
17
17
  withKernelStoreLock,
18
18
  } from "./storage";
19
- import type { TaskRecordV3, WorkspaceStateLike } from "./types";
19
+ import type { TaskRecord, TaskRecordV4, WorkspaceStateLike } from "./types";
20
20
 
21
21
  export interface EnrollCanaryInput {
22
22
  task_id: string;
@@ -29,17 +29,121 @@ export interface EnrollCanaryInput {
29
29
  }
30
30
 
31
31
  export interface EnrollCanaryResult {
32
- record: TaskRecordV3;
32
+ record: TaskRecord;
33
33
  backend_claim: BackendClaim;
34
34
  workspace: { revision: string; state: WorkspaceStateLike };
35
35
  }
36
36
 
37
- function buildTaskRecordV3(
37
+ type EnrollmentPreconditionState = {
38
+ validated: ReturnType<EnrollmentAuthorityRegistry["inspect"]> | null;
39
+ current: ReturnType<typeof readTaskRecordRaw> | null;
40
+ workspace: ReturnType<typeof readWorkspaceStateRaw> | null;
41
+ intent: Awaited<ReturnType<typeof readTaskIntent>> | null;
42
+ gitBaseHead: string | null;
43
+ };
44
+
45
+ type EnrollmentPreconditionResult = EnrollmentPreconditionState & {
46
+ blockers: string[];
47
+ };
48
+
49
+ function runEnrollmentPreconditionChecks<T>(
50
+ root: string,
51
+ input: EnrollCanaryInput,
52
+ capability: object,
53
+ registry: EnrollmentAuthorityRegistry,
54
+ mode: "report" | "fail_fast",
55
+ beforeLock: (validated: EnrollmentPreconditionState["validated"]) => void,
56
+ onReady: (state: EnrollmentPreconditionState) => T,
57
+ ): T;
58
+ function runEnrollmentPreconditionChecks(
59
+ root: string,
60
+ input: EnrollCanaryInput,
61
+ capability: object,
62
+ registry: EnrollmentAuthorityRegistry,
63
+ mode: "report" | "fail_fast",
64
+ beforeLock: (validated: EnrollmentPreconditionState["validated"]) => void,
65
+ ): EnrollmentPreconditionResult;
66
+ function runEnrollmentPreconditionChecks<T>(
67
+ root: string,
68
+ input: EnrollCanaryInput,
69
+ capability: object,
70
+ registry: EnrollmentAuthorityRegistry,
71
+ mode: "report" | "fail_fast",
72
+ beforeLock: (validated: EnrollmentPreconditionState["validated"]) => void,
73
+ onReady?: (state: EnrollmentPreconditionState) => T,
74
+ ): T | EnrollmentPreconditionResult {
75
+ const blockers: string[] = [];
76
+ let validated: EnrollmentPreconditionState["validated"] = null;
77
+ try {
78
+ validated = registry.inspect(capability, input.capability_binding);
79
+ if (mode === "fail_fast" && validated.task_id !== input.task_id)
80
+ throw new Error("enrollment capability task mismatch");
81
+ } catch (error) {
82
+ if (mode === "fail_fast") throw error;
83
+ blockers.push(`capability: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ beforeLock(validated);
86
+
87
+ return withKernelStoreLock(root, () => {
88
+ let current: EnrollmentPreconditionState["current"] = null;
89
+ let workspace: EnrollmentPreconditionState["workspace"] = null;
90
+ let intent: EnrollmentPreconditionState["intent"] = null;
91
+ let gitBaseHead: string | null = null;
92
+ const fail = (report: string, error: unknown) => {
93
+ if (mode === "fail_fast")
94
+ throw error instanceof Error ? error : new Error(String(error));
95
+ blockers.push(report);
96
+ };
97
+
98
+ const tombstone = readTaskTombstone(root, input.task_id);
99
+ if (tombstone) {
100
+ fail(
101
+ "task tombstone exists; same-task re-enrollment is forbidden",
102
+ new Error(`task ${input.task_id} is terminal; same-task re-enrollment is forbidden`),
103
+ );
104
+ } else {
105
+ current = readTaskRecordRaw(root, input.task_id);
106
+ if (current.record)
107
+ fail(
108
+ "task record already exists",
109
+ new Error(`task ${input.task_id} already has a TaskRecord`),
110
+ );
111
+ }
112
+ workspace = readWorkspaceStateRaw(root);
113
+ if (workspace.state.current_working !== null)
114
+ fail(
115
+ `workspace already owned by ${workspace.state.current_working}`,
116
+ new Error(`workspace is already owned by ${workspace.state.current_working}`),
117
+ );
118
+ try {
119
+ intent = readTaskIntent(root, input.task_id);
120
+ } catch (error) {
121
+ fail(`intent: ${error instanceof Error ? error.message : String(error)}`, error);
122
+ }
123
+ try {
124
+ gitBaseHead = readGitHead(root);
125
+ } catch (error) {
126
+ fail(`git base: ${error instanceof Error ? error.message : String(error)}`, error);
127
+ }
128
+
129
+ const state: EnrollmentPreconditionState = {
130
+ validated,
131
+ current,
132
+ workspace,
133
+ intent,
134
+ gitBaseHead,
135
+ };
136
+ return onReady ? onReady(state) : { ...state, blockers };
137
+ });
138
+ }
139
+
140
+ function buildTaskRecordV4(
38
141
  input: EnrollCanaryInput,
39
142
  intent: Awaited<ReturnType<typeof readTaskIntent>>,
40
- ): TaskRecordV3 {
143
+ gitBaseHead: string,
144
+ ): TaskRecordV4 {
41
145
  return {
42
- contract: "assurance_kernel/task_record/v3",
146
+ contract: "assurance_kernel/task_record/v4",
43
147
  task_id: input.task_id,
44
148
  intent_snapshot: intent.intent,
45
149
  intent_ref: {
@@ -49,6 +153,7 @@ function buildTaskRecordV3(
49
153
  lifecycle: "active",
50
154
  artifact_state: "active",
51
155
  baseline: intent.content_hash,
156
+ git_base_head: gitBaseHead,
52
157
  attestations: [],
53
158
  findings: [],
54
159
  history: [],
@@ -75,43 +180,25 @@ export function runEnrollmentRehearsal(
75
180
  generated_at: string;
76
181
  };
77
182
  } {
78
- const blockers: string[] = [];
79
- const result = {
183
+ const preconditions = runEnrollmentPreconditionChecks(
184
+ root,
185
+ input,
186
+ capability,
187
+ registry,
188
+ "report",
189
+ () => undefined,
190
+ );
191
+ return {
80
192
  rehearsed: true,
81
193
  writes_performed: false,
82
194
  evidence: {
83
- contract: "assurance_kernel/enrollment_rehearsal/v1" as const,
195
+ contract: "assurance_kernel/enrollment_rehearsal/v1",
84
196
  task_id: input.task_id,
85
- outcome: "ready" as "ready" | "not_ready",
86
- blockers,
197
+ outcome: preconditions.blockers.length === 0 ? "ready" : "not_ready",
198
+ blockers: preconditions.blockers,
87
199
  generated_at: input.now,
88
200
  },
89
201
  };
90
- try {
91
- // capability inspect (does not consume)
92
- registry.inspect(capability, input.capability_binding);
93
- } catch (error) {
94
- blockers.push(`capability: ${error instanceof Error ? error.message : String(error)}`);
95
- }
96
- withKernelStoreLock(root, () => {
97
- const tombstone = readTaskTombstone(root, input.task_id);
98
- if (tombstone) {
99
- blockers.push("task tombstone exists; same-task re-enrollment is forbidden");
100
- } else {
101
- const current = readTaskRecordRaw(root, input.task_id);
102
- if (current.record) blockers.push("task record already exists");
103
- }
104
- const workspace = readWorkspaceStateRaw(root);
105
- if (workspace.state.current_working !== null)
106
- blockers.push(`workspace already owned by ${workspace.state.current_working}`);
107
- });
108
- try {
109
- readTaskIntent(root, input.task_id);
110
- } catch (error) {
111
- blockers.push(`intent: ${error instanceof Error ? error.message : String(error)}`);
112
- }
113
- result.evidence.outcome = blockers.length === 0 ? "ready" : "not_ready";
114
- return result;
115
202
  }
116
203
 
117
204
  /**
@@ -124,71 +211,73 @@ export function enrollCanaryTask(
124
211
  input: EnrollCanaryInput,
125
212
  registry: EnrollmentAuthorityRegistry,
126
213
  ): EnrollCanaryResult {
127
- const validated = registry.inspect(input.capability, input.capability_binding);
128
- if (validated.task_id !== input.task_id)
129
- throw new Error("enrollment capability task mismatch");
130
-
131
- // v4 storage retirement: the capability is bound to the preparation
132
- // digest computed from Kernel owners only. Recompute it inside the store
133
- // lock and reject if the owner set changed since the confirmation.
134
- const recomputed = preparePiCanary(root, { task_id: input.task_id, now: input.now });
135
- if (recomputed.digest !== input.preparation_digest)
136
- throw new Error("enrollment preparation digest mismatch");
137
-
138
- return withKernelStoreLock(root, () => {
139
- const tombstone = readTaskTombstone(root, input.task_id);
140
- if (tombstone)
141
- throw new Error(
142
- `task ${input.task_id} is terminal; same-task re-enrollment is forbidden`,
143
- );
144
- const current = readTaskRecordRaw(root, input.task_id);
145
- if (current.record)
146
- throw new Error(`task ${input.task_id} already has a TaskRecord`);
147
- const workspace = readWorkspaceStateRaw(root);
148
- if (workspace.state.current_working !== null)
149
- throw new Error(`workspace is already owned by ${workspace.state.current_working}`);
150
-
151
- // fresh secure intent reread inside the lock
152
- const intent = readTaskIntent(root, input.task_id);
153
- if (intent.intent.revision !== input.intent_revision)
154
- throw new Error("intent revision mismatch");
214
+ let gitBaseHead: string | null = null;
215
+ return runEnrollmentPreconditionChecks(
216
+ root,
217
+ input,
218
+ input.capability,
219
+ registry,
220
+ "fail_fast",
221
+ () => {
222
+ // v4 storage retirement: the capability is bound to the preparation
223
+ // digest computed from Kernel owners only. Recompute it before the
224
+ // locked owner reads and reject if the owner set changed.
225
+ const recomputed = preparePiCanary(root, { task_id: input.task_id, now: input.now });
226
+ if (recomputed.digest !== input.preparation_digest)
227
+ throw new Error("enrollment preparation digest mismatch");
228
+ gitBaseHead = recomputed.git_base_head;
229
+ if (!gitBaseHead)
230
+ throw new Error(recomputed.git_error ?? "enrollment requires a committed Git HEAD");
231
+ },
232
+ (checks) => {
233
+ if (!checks.validated || !checks.intent || !checks.workspace || !checks.current)
234
+ throw new Error("enrollment precondition state incomplete");
235
+ if (checks.intent.intent.revision !== input.intent_revision)
236
+ throw new Error("intent revision mismatch");
237
+ if (checks.intent.content_hash !== checks.validated.intent_content_hash)
238
+ throw new Error("intent content hash mismatch");
239
+ // The confirmation is bound to this exact commit; a moved HEAD means the
240
+ // operator approved a different base than the one being recorded.
241
+ if (checks.gitBaseHead !== gitBaseHead)
242
+ throw new Error("Git HEAD moved after the enrollment confirmation");
155
243
 
156
- // consume immediately before the marker write
157
- registry.consume(input.capability, input.capability_binding);
244
+ // consume immediately before the marker write
245
+ registry.consume(input.capability, input.capability_binding);
158
246
 
159
- const record = buildTaskRecordV3(input, intent);
160
- const nextWorkspace: WorkspaceStateLike = {
161
- ...workspace.state,
162
- current_working: input.task_id,
163
- };
164
- const claim: BackendClaim = {
165
- contract: "assurance_kernel/backend_claim/v2",
166
- backend: "kernel",
167
- task_id: input.task_id,
168
- intent_revision: input.intent_revision,
169
- intent_content_hash: intent.content_hash,
170
- enrollment_event_id: `enroll-${input.task_id}-${input.now}`,
171
- lifecycle_status: "active",
172
- created_at: input.now,
173
- updated_at: input.now,
174
- };
175
- const mutation = commitEnrollmentLocked(
176
- root,
177
- input.task_id,
178
- {
179
- contract: "assurance_kernel/workspace_transaction/v2",
247
+ const record = buildTaskRecordV4(input, checks.intent, gitBaseHead);
248
+ const nextWorkspace: WorkspaceStateLike = {
249
+ ...checks.workspace.state,
250
+ current_working: input.task_id,
251
+ };
252
+ const claim: BackendClaim = {
253
+ contract: "assurance_kernel/backend_claim/v2",
254
+ backend: "kernel",
180
255
  task_id: input.task_id,
181
- expected_record_hash: current.revision,
182
- next_record_content: `${JSON.stringify(record, null, 2)}\n`,
183
- expected_workspace_hash: workspace.revision,
184
- next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
185
- },
186
- claim as unknown as Record<string, unknown>,
187
- );
188
- return {
189
- record: mutation.record,
190
- backend_claim: claim,
191
- workspace: { revision: "", state: mutation.workspace },
192
- };
193
- });
256
+ intent_revision: input.intent_revision,
257
+ intent_content_hash: checks.intent.content_hash,
258
+ enrollment_event_id: `enroll-${input.task_id}-${input.now}`,
259
+ lifecycle_status: "active",
260
+ created_at: input.now,
261
+ updated_at: input.now,
262
+ };
263
+ const mutation = commitEnrollmentLocked(
264
+ root,
265
+ input.task_id,
266
+ {
267
+ contract: "assurance_kernel/workspace_transaction/v2",
268
+ task_id: input.task_id,
269
+ expected_record_hash: checks.current.revision,
270
+ next_record_content: `${JSON.stringify(record, null, 2)}\n`,
271
+ expected_workspace_hash: checks.workspace.revision,
272
+ next_workspace_content: `${JSON.stringify(nextWorkspace, null, 2)}\n`,
273
+ },
274
+ claim as unknown as Record<string, unknown>,
275
+ );
276
+ return {
277
+ record: mutation.record,
278
+ backend_claim: claim,
279
+ workspace: { revision: "", state: mutation.workspace },
280
+ };
281
+ },
282
+ );
194
283
  }