immune-brain 4.4.1 → 4.6.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 (51) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
  8. package/plugins/immune-brain/dist/BASELINE.md +18 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +1401 -1101
  10. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -0
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +21 -0
  12. package/plugins/immune-brain/dist/imm-loop.md +52 -6
  13. package/plugins/immune-brain/dist/imm-planner.md +102 -13
  14. package/plugins/immune-brain/dist/role-prompts/code-review.md +19 -0
  15. package/plugins/immune-brain/dist/role-prompts/qa.md +13 -1
  16. package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
  17. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  18. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  19. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
  20. package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
  21. package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
  22. package/plugins/immune-brain/runtime/github_issue_tracker.ts +956 -634
  23. package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
  24. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  25. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  26. package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
  27. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  28. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  29. package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
  30. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  31. package/plugins/immune-brain/runtime/kernel/storage.ts +8 -3
  32. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  33. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  34. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  35. package/plugins/immune-brain/runtime/prompts/code-review.md +19 -0
  36. package/plugins/immune-brain/runtime/prompts/qa.md +13 -1
  37. package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
  38. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  39. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +152 -46
  40. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  41. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +193 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +112 -122
  43. package/plugins/immune-brain/runtime/unattended/batch_state.ts +57 -16
  44. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  45. package/plugins/immune-brain/runtime/v4_runtime.ts +54 -0
  46. package/plugins/immune-brain/runtime/workspace_scope.ts +75 -12
  47. package/plugins/immune-brain/skills/BASELINE.md +18 -1
  48. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +5 -0
  49. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +10 -1
  51. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -53,7 +53,6 @@ export interface ApplyTaskActionInput {
53
53
  capability?: MutationAuthorityCapabilityV2;
54
54
  /** Trusted injected diff provider; the action's diff_hash is only an expectation. */
55
55
  diffProvider: (root: string, record: TaskRecord) => string | { diff_hash: string; changed_paths?: readonly string[] };
56
- now?: number;
57
56
  /**
58
57
  * Terminal commit mode: record + workspace + active-claim removal + task
59
58
  * tombstone converge through one recoverable terminal transaction marker
@@ -72,8 +71,7 @@ export interface ApplyTaskActionInput {
72
71
  export function applyTaskAction(
73
72
  input: ApplyTaskActionInput,
74
73
  ): StoredTaskMutationV3 {
75
- const { root, task_id, prior_intent_token, registry, capability, diffProvider, now } =
76
- input;
74
+ const { root, task_id, prior_intent_token, registry, capability, diffProvider } = input;
77
75
  return withKernelStoreLock(root, () => {
78
76
  const current = readTaskRecordRaw(root, task_id);
79
77
  if (!current.record)
@@ -192,7 +190,7 @@ export function applyTaskAction(
192
190
  // replay identity, and conflicting reuse, so any throw is a zero-write
193
191
  // failure (including stale record/workspace CAS).
194
192
  const inspectedAudit = expectedAuthority
195
- ? registry.inspect(capability, expectedAuthority, now)
193
+ ? registry.inspect(capability, expectedAuthority)
196
194
  : null;
197
195
  const authorityRunId = inspectedAudit?.run_id;
198
196
  const mutation = reduceTask(
@@ -237,7 +235,6 @@ export function applyTaskAction(
237
235
  registry.consume(
238
236
  capability as MutationAuthorityCapabilityV2,
239
237
  expectedAuthority,
240
- now,
241
238
  );
242
239
  }
243
240
 
@@ -62,17 +62,15 @@ export interface MutationAuthorityRegistry {
62
62
  readonly brand: symbol;
63
63
  /** Issue one capability bound to an exact binding. Library primitive; no production caller in P2B2. */
64
64
  issue(binding: CapabilityBindingV2, issuedAt?: string): MutationAuthorityCapabilityV2;
65
- /** Inspect without consuming. Fails on missing, expired, mismatched, or reused capability. */
65
+ /** Inspect without consuming. Fails on missing, mismatched, or reused capability. */
66
66
  inspect(
67
67
  capability: MutationAuthorityCapabilityV2 | undefined,
68
68
  expected: MutationAuthorityInspection,
69
- now?: number,
70
69
  ): ValidatedAuthorityV2;
71
70
  /** Consume irreversibly. Returns the validated authority. */
72
71
  consume(
73
72
  capability: MutationAuthorityCapabilityV2,
74
73
  expected: MutationAuthorityInspection,
75
- now?: number,
76
74
  ): ValidatedAuthorityV2;
77
75
  isConsumed(capability: MutationAuthorityCapabilityV2): boolean;
78
76
  }
@@ -88,7 +86,7 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
88
86
  const inner = createCapabilityRegistry<CapabilityBindingV2, MutationAuthorityInspection, ValidatedAuthorityV2, MutationAuthorityCapabilityV2>(
89
87
  MUTATION_AUTHORITY_CAPABILITY_BRAND,
90
88
  {
91
- validateBinding(binding, issuedAt) {
89
+ validateBinding(binding) {
92
90
  const missing: string[] = [];
93
91
  if (binding.run_id !== undefined && binding.run_id.length === 0)
94
92
  throw new Error("authority capability run_id must not be empty");
@@ -100,12 +98,8 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
100
98
  throw new Error(`authority capability binding is incomplete: ${missing.join(", ")}`);
101
99
  if (binding.findings_digest !== null && !/^sha256:[a-f0-9]{64}$/.test(binding.findings_digest))
102
100
  throw new Error("authority capability findings_digest must be a canonical sha256 hash");
103
- if (Number.isNaN(Date.parse(binding.expires_at)) || Date.parse(binding.expires_at) <= Date.parse(issuedAt))
104
- throw new Error("authority capability must have a future expiry");
105
101
  },
106
- validateAndProject(state, expected, now) {
107
- if (Date.parse(state.expires_at) <= now)
108
- throw new Error("authority capability has expired");
102
+ validateAndProject(state, expected) {
109
103
  const actionDigest = digestOfAction(expected.action);
110
104
  if (state.action_digest !== actionDigest)
111
105
  throw new Error("authority capability action digest mismatch");
@@ -141,7 +135,6 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
141
135
  actor_id: canonicalActorId(state.actor_id),
142
136
  confirmation_ref: state.confirmation_ref,
143
137
  issued_at: state.issued_at,
144
- expires_at: state.expires_at,
145
138
  },
146
139
  action_digest: actionDigest,
147
140
  ...(state.run_id !== undefined ? { run_id: state.run_id } : {}),
@@ -162,12 +155,11 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
162
155
  inspect(
163
156
  capability: MutationAuthorityCapabilityV2 | undefined,
164
157
  expected: MutationAuthorityInspection,
165
- now = Date.now(),
166
158
  ): ValidatedAuthorityV2 {
167
159
  if (!capability)
168
160
  throw new Error("privileged action requires an opaque authority capability");
169
161
  try {
170
- return inner.inspect(capability, expected, now);
162
+ return inner.inspect(capability, expected);
171
163
  } catch (err) {
172
164
  if (err instanceof Error && err.message.includes("not recognized by this registry"))
173
165
  throw new Error("privileged action requires an opaque authority capability");
@@ -177,10 +169,9 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
177
169
  consume(
178
170
  capability: MutationAuthorityCapabilityV2,
179
171
  expected: MutationAuthorityInspection,
180
- now = Date.now(),
181
172
  ): ValidatedAuthorityV2 {
182
173
  try {
183
- return inner.consume(capability, expected, now);
174
+ return inner.consume(capability, expected);
184
175
  } catch (err) {
185
176
  if (err instanceof Error && err.message.includes("not recognized by this registry"))
186
177
  throw new Error("privileged action requires an opaque authority capability");
@@ -8,6 +8,7 @@
8
8
  // plus an advancing HEAD lineage instead.
9
9
 
10
10
  import { createHash } from "node:crypto";
11
+ import { spawnSync } from "node:child_process";
11
12
  import { createCapabilityRegistry } from "./capability_registry";
12
13
  import { isLiteralUserActor } from "./actor_identity";
13
14
  import type { BaseCapabilityBinding, EnrollmentCapabilityBinding } from "./enrollment_authority";
@@ -19,15 +20,6 @@ export const BATCH_AUTHORITY_CAPABILITY_BRAND = Symbol.for(
19
20
 
20
21
  const GIT_COMMIT_ID = /^[a-f0-9]{40}$/;
21
22
 
22
- /** review-7(3rd rework): typed expiry marker thrown at the Kernel enrollment
23
- * boundary so the driver can classify a real enrollment-time expiry as an
24
- * intentional budget stop without free-form message matching. */
25
- export class BatchAuthorizationExpiryError extends Error {
26
- constructor(message: string) {
27
- super(message);
28
- this.name = "BatchAuthorizationExpiryError";
29
- }
30
- }
31
23
 
32
24
  export interface BatchPlanChild {
33
25
  task_id: string;
@@ -39,7 +31,6 @@ export interface BatchPlanChild {
39
31
 
40
32
  export interface BatchBudget {
41
33
  max_children: number;
42
- deadline_at: string;
43
34
  qa_failure_limit: number;
44
35
  }
45
36
 
@@ -63,7 +54,6 @@ export interface ValidatedBatchAuthorization {
63
54
  actor_id: string;
64
55
  confirmation_ref: string;
65
56
  issued_at: string;
66
- expires_at: string;
67
57
  nonce: string;
68
58
  }
69
59
 
@@ -81,7 +71,6 @@ export interface BatchAuthorityRegistry {
81
71
  inspect(
82
72
  capability: object,
83
73
  expected: BatchAuthorizationBinding,
84
- now?: number,
85
74
  ): ValidatedBatchAuthorization;
86
75
  children(capability: object): BatchPlanChild[];
87
76
  consumedChildren(capability: object): string[];
@@ -91,7 +80,6 @@ export interface BatchAuthorityRegistry {
91
80
  capability: object,
92
81
  expected: BatchAuthorizationBinding,
93
82
  taskId: string,
94
- now?: number,
95
83
  ): ValidatedBatchAuthorization;
96
84
  /** Undo one slot consumption when the bound write did not commit. */
97
85
  releaseChild(capability: object, taskId: string): void;
@@ -138,7 +126,6 @@ function requireNonEmpty(binding: BatchAuthorizationBinding): void {
138
126
  "base_head",
139
127
  "actor_id",
140
128
  "confirmation_ref",
141
- "expires_at",
142
129
  "nonce",
143
130
  ] as const) {
144
131
  const value = binding[key];
@@ -149,14 +136,11 @@ function requireNonEmpty(binding: BatchAuthorizationBinding): void {
149
136
  throw new Error(`batch authorization binding is incomplete: ${missing.join(", ")}`);
150
137
  }
151
138
 
152
- function validateBudget(budget: BatchBudget, issuedAt: string): void {
139
+ function validateBudget(budget: BatchBudget): void {
153
140
  if (!Number.isInteger(budget.max_children) || budget.max_children <= 0)
154
141
  throw new Error("batch budget max_children must be a positive integer");
155
142
  if (!Number.isInteger(budget.qa_failure_limit) || budget.qa_failure_limit <= 0)
156
143
  throw new Error("batch budget qa_failure_limit must be a positive integer");
157
- const deadline = Date.parse(budget.deadline_at);
158
- if (Number.isNaN(deadline) || deadline <= Date.parse(issuedAt))
159
- throw new Error("batch budget must have a future deadline_at");
160
144
  }
161
145
 
162
146
  function validateChildren(children: BatchPlanChild[], planDigest: string): void {
@@ -210,7 +194,7 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
210
194
  >(
211
195
  BATCH_AUTHORITY_CAPABILITY_BRAND,
212
196
  {
213
- validateBinding(binding, issuedAt) {
197
+ validateBinding(binding) {
214
198
  requireNonEmpty(binding);
215
199
  // Both the canonical literal-user spelling and the historical `user`
216
200
  // spelling are read, so a batch authorized before the convergence keeps
@@ -219,29 +203,15 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
219
203
  throw new Error("batch authorization requires a literal-user actor_id");
220
204
  if (!GIT_COMMIT_ID.test(binding.base_head))
221
205
  throw new Error("batch authorization base_head must be a committed 40-hex commit id");
222
- const expires = Date.parse(binding.expires_at);
223
- if (Number.isNaN(expires) || expires <= Date.parse(issuedAt))
224
- throw new Error("batch authorization must have a future expiry");
225
- validateBudget(binding.budget, issuedAt);
206
+ validateBudget(binding.budget);
226
207
  },
227
- validateAndProject(state, expected, now) {
228
- // Fail closed on an unusable clock. `now` reaches here as
229
- // Date.parse(...) from callers, and NaN makes every `<=` compare
230
- // false, which would silently accept an expired authorization.
231
- if (!Number.isFinite(now))
232
- throw new Error("batch authorization requires a valid clock");
233
- const expires = Date.parse(state.expires_at);
234
- if (Number.isNaN(expires) || expires <= now)
235
- throw new BatchAuthorizationExpiryError("batch authorization has expired");
236
- if (Date.parse(state.budget.deadline_at) <= now)
237
- throw new BatchAuthorizationExpiryError("batch authorization deadline has expired");
208
+ validateAndProject(state, expected) {
238
209
  for (const key of Object.keys(expected) as Array<keyof BatchAuthorizationBinding>) {
239
210
  if (key === "budget") {
240
211
  const a = state.budget ?? ({} as BatchBudget);
241
212
  const b = expected.budget ?? ({} as BatchBudget);
242
213
  if (
243
214
  a.max_children !== b.max_children ||
244
- a.deadline_at !== b.deadline_at ||
245
215
  a.qa_failure_limit !== b.qa_failure_limit
246
216
  )
247
217
  throw new Error("batch authorization budget mismatch");
@@ -263,7 +233,6 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
263
233
  actor_id: state.actor_id,
264
234
  confirmation_ref: state.confirmation_ref,
265
235
  issued_at: state.issued_at,
266
- expires_at: state.expires_at,
267
236
  nonce: state.nonce,
268
237
  };
269
238
  },
@@ -299,9 +268,9 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
299
268
  consumed.set(capability, new Set<string>());
300
269
  return capability;
301
270
  },
302
- inspect(capability, expected, now = Date.now()) {
271
+ inspect(capability, expected) {
303
272
  planOf(capability);
304
- return inner.inspect(capability, expected, now);
273
+ return inner.inspect(capability, expected);
305
274
  },
306
275
  children(capability) {
307
276
  return planOf(capability).map((child) => ({ ...child, blocked_by: [...child.blocked_by] }));
@@ -312,8 +281,8 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
312
281
  isChildConsumed(capability, taskId) {
313
282
  return slotsOf(capability).has(taskId);
314
283
  },
315
- consumeChild(capability, expected, taskId, now = Date.now()) {
316
- const validated = this.inspect(capability, expected, now);
284
+ consumeChild(capability, expected, taskId) {
285
+ const validated = this.inspect(capability, expected);
317
286
  const plan = planOf(capability);
318
287
  if (!plan.some((child) => child.task_id === taskId))
319
288
  throw new Error(`batch_child_not_in_plan: ${taskId}`);
@@ -331,6 +300,24 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
331
300
  };
332
301
  }
333
302
 
303
+ /**
304
+ * Before a batch has consumed a slot it has created no commit, so its lineage
305
+ * can only stand on the confirmed base_head or on a fast-forward of it: the
306
+ * user's own commit on the batch branch, which the runner adopts. A head that
307
+ * does not descend from base_head is not a lineage.
308
+ */
309
+ export function assertBatchLineageOrigin(root: string, baseHead: string, expectedHead: string): void {
310
+ if (expectedHead === baseHead) return;
311
+ const descends =
312
+ spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", baseHead, expectedHead], {
313
+ stdio: ["ignore", "ignore", "ignore"],
314
+ }).status === 0;
315
+ if (!descends)
316
+ throw new Error(
317
+ `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${baseHead} or a fast-forward of it, not ${expectedHead}`,
318
+ );
319
+ }
320
+
334
321
  export interface DeriveChildEnrollmentInput {
335
322
  capability: object;
336
323
  binding: BatchAuthorizationBinding;
@@ -355,7 +342,7 @@ export function deriveChildEnrollment(
355
342
  registry: BatchAuthorityRegistry,
356
343
  input: DeriveChildEnrollmentInput,
357
344
  ): DerivedChildEnrollment {
358
- const validated = registry.inspect(input.capability, input.binding, Date.parse(input.now));
345
+ const validated = registry.inspect(input.capability, input.binding);
359
346
  const child = registry
360
347
  .children(input.capability)
361
348
  .find((entry) => entry.task_id === input.task_id);
@@ -385,13 +372,8 @@ export function deriveChildEnrollment(
385
372
  // settled a child there is no commit it could have created, so a
386
373
  // caller-supplied expected_head other than base_head is not a lineage.
387
374
  // Checked last so the intent-divergence reason keeps its precedence.
388
- if (
389
- registry.consumedChildren(input.capability).length === 0 &&
390
- input.expected_head !== validated.base_head
391
- )
392
- throw new Error(
393
- `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${validated.base_head}, not ${input.expected_head}`,
394
- );
375
+ if (registry.consumedChildren(input.capability).length === 0)
376
+ assertBatchLineageOrigin(root, validated.base_head, input.expected_head);
395
377
 
396
378
  return {
397
379
  child,
@@ -404,7 +386,6 @@ export function deriveChildEnrollment(
404
386
  preparation_digest: preparation.digest,
405
387
  actor_id: validated.actor_id,
406
388
  confirmation_ref: validated.confirmation_ref,
407
- expires_at: validated.expires_at,
408
389
  nonce: `${validated.nonce}:${child.task_id}`,
409
390
  },
410
391
  };
@@ -431,7 +431,6 @@ export function createCanaryApplication(
431
431
  registry,
432
432
  capability: capability as never,
433
433
  diffProvider: input.diffProvider,
434
- now: Date.parse(now),
435
434
  ...(operation.op === "complete" || operation.op === "stop"
436
435
  ? { terminal: { terminalized_at: now } }
437
436
  : {}),
@@ -492,7 +491,7 @@ export function createCanaryApplication(
492
491
  intent_revision: current.record.intent_snapshot.revision,
493
492
  intent_content_hash: current.record.intent_ref.content_hash,
494
493
  diff_hash: "sha256:" + "0".repeat(64),
495
- }, Date.parse(now));
494
+ });
496
495
  const nextClaim: BackendClaim = {
497
496
  ...claim,
498
497
  lifecycle_status: "draining",
@@ -23,7 +23,6 @@ export interface CanaryWaiver {
23
23
  reason: string;
24
24
  actor: string;
25
25
  confirmation_ref: string;
26
- expires_at: string;
27
26
  nonce: string;
28
27
  }
29
28
 
@@ -4,20 +4,19 @@
4
4
 
5
5
  export interface CapabilityRegistryHooks<TBinding, TExpected, TValidated> {
6
6
  /** Validate the binding at issue time. Throw on invalid input. */
7
- validateBinding(binding: TBinding, issuedAt: string): void;
7
+ validateBinding(binding: TBinding): void;
8
8
  /** Validate stored state against expected input at inspect time. Return the domain-specific validated result. */
9
9
  validateAndProject(
10
10
  state: TBinding & { issued_at: string },
11
11
  expected: TExpected,
12
- now: number,
13
12
  ): TValidated;
14
13
  }
15
14
 
16
15
  export interface CapabilityRegistry<TBinding, TExpected, TValidated, TCapability> {
17
16
  readonly brand: symbol;
18
17
  issue(binding: TBinding, issuedAt?: string): TCapability;
19
- inspect(capability: TCapability, expected: TExpected, now?: number): TValidated;
20
- consume(capability: TCapability, expected: TExpected, now?: number): TValidated;
18
+ inspect(capability: TCapability, expected: TExpected): TValidated;
19
+ consume(capability: TCapability, expected: TExpected): TValidated;
21
20
  isConsumed(capability: TCapability): boolean;
22
21
  }
23
22
 
@@ -53,7 +52,7 @@ export function createCapabilityRegistry<TBinding, TExpected, TValidated, TCapab
53
52
  return {
54
53
  brand,
55
54
  issue(binding: TBinding, issuedAt = new Date().toISOString()): TCapability {
56
- hooks.validateBinding(binding, issuedAt);
55
+ hooks.validateBinding(binding);
57
56
  const capability = Object.freeze(
58
57
  Object.defineProperties(
59
58
  {},
@@ -66,7 +65,7 @@ export function createCapabilityRegistry<TBinding, TExpected, TValidated, TCapab
66
65
  states.set(capability as object, { binding: { ...binding }, issued_at: issuedAt, consumed: false });
67
66
  return capability;
68
67
  },
69
- inspect(capability: TCapability, expected: TExpected, now = Date.now()): TValidated {
68
+ inspect(capability: TCapability, expected: TExpected): TValidated {
70
69
  if (!isCapability(capability))
71
70
  throw new Error(`${domainLabel} capability is not recognized by this registry`);
72
71
  const state = stateOf(capability);
@@ -74,11 +73,10 @@ export function createCapabilityRegistry<TBinding, TExpected, TValidated, TCapab
74
73
  return hooks.validateAndProject(
75
74
  { ...state.binding, issued_at: state.issued_at },
76
75
  expected,
77
- now,
78
76
  );
79
77
  },
80
- consume(capability: TCapability, expected: TExpected, now = Date.now()): TValidated {
81
- const validated = this.inspect(capability, expected, now);
78
+ consume(capability: TCapability, expected: TExpected): TValidated {
79
+ const validated = this.inspect(capability, expected);
82
80
  stateOf(capability).consumed = true;
83
81
  return validated;
84
82
  },
@@ -15,8 +15,9 @@ import type {
15
15
  BatchAuthorizationBinding,
16
16
  } from "./batch_authority";
17
17
  import type { BackendClaim } from "./backend_claim";
18
+ import { assertBatchLineageOrigin } from "./batch_authority";
18
19
  import { preparePiCanary, readGitHead } from "./pi_canary_prepare";
19
- import { writeEnrollmentBaseline } from "../workspace_scope";
20
+ import { dirtyScopePaths, writeEnrollmentBaseline } from "../workspace_scope";
20
21
  import { enrollmentRequestDigest } from "./run_identity";
21
22
  import {
22
23
  commitEnrollmentLocked,
@@ -163,6 +164,20 @@ function runEnrollmentPreconditionChecks<T>(
163
164
  } catch (error) {
164
165
  fail(`git base: ${error instanceof Error ? error.message : String(error)}`, error);
165
166
  }
167
+ // In-scope dirt present now would be recorded in the Enrollment baseline as
168
+ // user dirt and withheld from every later task snapshot, so the task could
169
+ // never deliver it. Refuse before the capability is consumed.
170
+ if (intent && gitBaseHead) {
171
+ try {
172
+ const dirty = dirtyScopePaths(root, intent.intent.scope_hint, input.task_id);
173
+ if (dirty.length > 0) {
174
+ const message = `task scope is already dirty before Enrollment: ${dirty.join(", ")}; commit, stash or revert these paths first`;
175
+ fail(message, new Error(message));
176
+ }
177
+ } catch (error) {
178
+ fail(`scope: ${error instanceof Error ? error.message : String(error)}`, error);
179
+ }
180
+ }
166
181
 
167
182
  const state: EnrollmentPreconditionState = {
168
183
  validated,
@@ -332,24 +347,19 @@ export function enrollCanaryTask(
332
347
 
333
348
  // A batch-derived enrollment must still stand on the lineage the
334
349
  // literal user confirmed: child N's base is the commit child N-1
335
- // produced on the batch branch, and child 1's base is base_head.
350
+ // produced on the batch branch, and child 1's base is base_head or a
351
+ // fast-forward of it the runner adopted.
336
352
  // expected_head is caller-supplied, so anchor its origin here rather
337
353
  // than trusting the caller's own assertion: before this batch has
338
354
  // consumed any slot it has created no commit, so the only lineage
339
- // value it can hold is the confirmed base_head.
355
+ // value it can hold descends from the confirmed base_head.
340
356
  if (input.batch) {
341
357
  const batch = input.batch.registry.inspect(
342
358
  input.batch.capability,
343
359
  input.batch.binding,
344
- Date.parse(input.now),
345
360
  );
346
- if (
347
- input.batch.registry.consumedChildren(input.batch.capability).length === 0 &&
348
- input.batch.expected_head !== batch.base_head
349
- )
350
- throw new Error(
351
- `batch_head_lineage_broken: the first child must enroll on the confirmed base_head ${batch.base_head}, not ${input.batch.expected_head}`,
352
- );
361
+ if (input.batch.registry.consumedChildren(input.batch.capability).length === 0)
362
+ assertBatchLineageOrigin(root, batch.base_head, input.batch.expected_head);
353
363
  if (checks.gitBaseHead !== input.batch.expected_head)
354
364
  throw new Error(
355
365
  `batch_head_lineage_broken: expected ${input.batch.expected_head}, found ${checks.gitBaseHead}`,
@@ -364,7 +374,6 @@ export function enrollCanaryTask(
364
374
  input.batch.capability,
365
375
  input.batch.binding,
366
376
  input.task_id,
367
- Date.parse(input.now),
368
377
  );
369
378
 
370
379
  // Set by beforeLock above, which throws when the repository has no
@@ -7,7 +7,7 @@ import { createCapabilityRegistry } from "./capability_registry";
7
7
  export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollment-capability-brand");
8
8
 
9
9
  /**
10
- * The three fields every capability binding shares, whichever authority issues
10
+ * The two fields every capability binding shares, whichever authority issues
11
11
  * it. `nonce` is deliberately not part of this base: the enrollment and batch
12
12
  * bindings carry it for their own replay digest, while `CapabilityBindingV2`
13
13
  * uses `action_digest` instead and has no `nonce` field at all.
@@ -15,7 +15,6 @@ export const ENROLLMENT_CAPABILITY_BRAND = Symbol.for("assurance-kernel.enrollme
15
15
  export interface BaseCapabilityBinding {
16
16
  actor_id: string;
17
17
  confirmation_ref: string;
18
- expires_at: string;
19
18
  }
20
19
 
21
20
  export interface EnrollmentCapabilityBinding extends BaseCapabilityBinding {
@@ -36,7 +35,6 @@ export interface ValidatedEnrollment {
36
35
  actor_id: string;
37
36
  confirmation_ref: string;
38
37
  issued_at: string;
39
- expires_at: string;
40
38
  nonce: string;
41
39
  }
42
40
 
@@ -44,8 +42,8 @@ export interface EnrollmentAuthorityRegistry {
44
42
  readonly brand: symbol;
45
43
  /** Issue one capability bound to an exact binding. Library primitive. */
46
44
  issue(binding: EnrollmentCapabilityBinding, issuedAt?: string): object;
47
- inspect(capability: object, expected: EnrollmentCapabilityBinding, now?: number): ValidatedEnrollment;
48
- consume(capability: object, expected: EnrollmentCapabilityBinding, now?: number): ValidatedEnrollment;
45
+ inspect(capability: object, expected: EnrollmentCapabilityBinding): ValidatedEnrollment;
46
+ consume(capability: object, expected: EnrollmentCapabilityBinding): ValidatedEnrollment;
49
47
  isConsumed(capability: object): boolean;
50
48
  }
51
49
 
@@ -53,18 +51,15 @@ export function createEnrollmentAuthorityRegistry(): EnrollmentAuthorityRegistry
53
51
  return createCapabilityRegistry<EnrollmentCapabilityBinding, EnrollmentCapabilityBinding, ValidatedEnrollment>(
54
52
  ENROLLMENT_CAPABILITY_BRAND,
55
53
  {
56
- validateBinding(binding, issuedAt) {
54
+ validateBinding(binding) {
57
55
  const missing: string[] = [];
58
56
  for (const [key, value] of Object.entries(binding)) {
59
57
  if (value === undefined || value === null || value === "") missing.push(key);
60
58
  }
61
59
  if (missing.length > 0)
62
60
  throw new Error(`enrollment capability binding is incomplete: ${missing.join(", ")}`);
63
- if (Number.isNaN(Date.parse(binding.expires_at)) || Date.parse(binding.expires_at) <= Date.parse(issuedAt))
64
- throw new Error("enrollment capability must have a future expiry");
65
61
  },
66
- validateAndProject(state, expected, now) {
67
- if (Date.parse(state.expires_at) <= now) throw new Error("enrollment capability has expired");
62
+ validateAndProject(state, expected) {
68
63
  for (const key of Object.keys(expected) as Array<keyof EnrollmentCapabilityBinding>) {
69
64
  if (state[key] !== expected[key])
70
65
  throw new Error(`enrollment capability ${key} mismatch`);
@@ -78,7 +73,6 @@ export function createEnrollmentAuthorityRegistry(): EnrollmentAuthorityRegistry
78
73
  actor_id: state.actor_id,
79
74
  confirmation_ref: state.confirmation_ref,
80
75
  issued_at: state.issued_at,
81
- expires_at: state.expires_at,
82
76
  nonce: state.nonce,
83
77
  };
84
78
  },
@@ -480,10 +480,15 @@ function ensureSecureDirectory(root: string, relativePath: string): string {
480
480
  return target.path;
481
481
  }
482
482
 
483
- export function readSecureProjectFile(
483
+ export function readSecureProjectFile(root: string, relativePath: string): string {
484
+ return readSecureProjectBytes(root, relativePath).toString("utf8");
485
+ }
486
+
487
+ /** Preserve byte identity while retaining the same secure filesystem checks. */
488
+ export function readSecureProjectBytes(
484
489
  root: string,
485
490
  relativePath: string,
486
- ): string {
491
+ ): Buffer {
487
492
  const candidate = safeCandidate(root, relativePath);
488
493
  assertNoSymlinkSegments(candidate.root, candidate.path);
489
494
  const before = pathStatOrNull(candidate.path);
@@ -498,7 +503,7 @@ export function readSecureProjectFile(
498
503
  const opened = fstatSync(fd);
499
504
  if (opened.dev !== before.dev || opened.ino !== before.ino)
500
505
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
501
- const content = readFileSync(fd, "utf8");
506
+ const content = readFileSync(fd);
502
507
  const after = lstatSync(candidate.path);
503
508
  if (after.dev !== opened.dev || after.ino !== opened.ino)
504
509
  throw new KernelStoreSecurityError(`source identity changed: ${relativePath}`);
@@ -265,7 +265,8 @@ export interface AuthorityAuditDescriptor {
265
265
  actor_id: string;
266
266
  confirmation_ref: string;
267
267
  issued_at: string;
268
- expires_at: string;
268
+ /** Read-only: history written before authority lost its clock still carries it. Never written. */
269
+ expires_at?: string;
269
270
  }
270
271
 
271
272
  export interface TaskActionBase {
@@ -287,7 +287,9 @@ function parseHistoryV3(
287
287
  actor_id: stringAt(auth.actor_id, `${path}.authority.actor_id`, violations),
288
288
  confirmation_ref: stringAt(auth.confirmation_ref, `${path}.authority.confirmation_ref`, violations),
289
289
  issued_at: stringAt(auth.issued_at, `${path}.authority.issued_at`, violations),
290
- expires_at: stringAt(auth.expires_at, `${path}.authority.expires_at`, violations),
290
+ ...(auth.expires_at !== undefined
291
+ ? { expires_at: stringAt(auth.expires_at, `${path}.authority.expires_at`, violations) }
292
+ : {}),
291
293
  };
292
294
  }
293
295
  return {
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "4.4.1" as const;
2
+ export const PLUGIN_VERSION = "4.6.0" as const;
@@ -34,6 +34,25 @@ about the fix's scope, not a new bug. A trigger the accepted contract explicitly
34
34
  excludes is reported as an advisory note against that boundary, never as blocking
35
35
  rework.
36
36
 
37
+ ## Invariant and Evidence Coverage
38
+
39
+ Judge whether the change actually closes the invariant its acceptance names,
40
+ not only the path the report mentions. A fix that covers one trigger but leaves
41
+ another trigger of the same invariant is a coverage gap in the same class, not a
42
+ new independent bug: say so and name the class. Check the negative and bound
43
+ behavior, not only the happy path. The six generalized classes worth checking
44
+ are: normalization precedes retrieval, validation precedes merge, the deadline
45
+ covers the whole lifecycle including cleanup, ranking tolerates missing
46
+ embeddings, the budget covers the complete serialized request, and each success,
47
+ failure, and timeout outcome carries its own duration evidence.
48
+
49
+ A required check that is skipped, that matches zero tests, or whose prerequisite
50
+ is absent does not prove the acceptance; do not accept it as evidence. Full
51
+ suite coverage reported at a coarse pass count is not per-invariant evidence.
52
+ Environment, preparation, and cleanup breakdowns are environment findings, not
53
+ assertion findings. Keep automated behavioral or geometry evidence separated
54
+ from a human quality judgment.
55
+
37
56
  Return exactly one JSON object with the fields required by the Loop review
38
57
  contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
39
58
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
@@ -20,4 +20,16 @@ Plan remains a literal-user decision.
20
20
  For elevated-risk work, check the latest referenced Spec's Design Conformance
21
21
  against implementation evidence. A local implementation mismatch is `rework`;
22
22
  return `rework` with bounded repair evidence. A structural or intended design
23
- change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation. If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
23
+ change is `replan`; return `replan` with the missing design fact. QA must not approve a changed design or silently accept a deviation.
24
+
25
+ Judge the recorded execution evidence, not the descriptor text. A required check
26
+ proves an acceptance invariant only when it actually executed and passed: an
27
+ absent dependency, a prerequisite preparation failure, a skipped required check,
28
+ or a check that matched zero tests is a failure, not a pass. Structural
29
+ "Enrollment ready" or "descriptor valid" is not execution evidence, and a
30
+ contract string proves nothing about provider behavior. Automated behavioral or
31
+ geometry evidence cannot stand in for a human motion-quality judgment, and vice
32
+ versa; keep the two claims separate. Environment, preparation, and cleanup
33
+ breakdowns are environment findings, not assertion findings: report them with
34
+ the descriptor reference and observed outcome rather than as a code defect.
35
+ If the checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only a literal user may invoke `--approve-successor`.
@@ -20,6 +20,7 @@ import { dirname, join } from "node:path";
20
20
  import { localRunId, readAuditTaskPair, readSecureProjectFile } from "../kernel/storage";
21
21
  import { captureGitTaskRevisionSnapshot, pathMatchesScope } from "../workspace_scope";
22
22
  import { expectedBatchHead, findExistingActiveBatch, findSettledBatchRecord } from "./batch_preflight";
23
+ export { classifyBatchLineage, type BatchLineage } from "./batch_preflight";
23
24
 
24
25
  export type BatchGitPreflightRejectReason =
25
26
  | "batch_branch_exists"
@@ -744,7 +745,14 @@ export async function lookupBatchCommit(input: {
744
745
  stdio: ["ignore", "pipe", "pipe"],
745
746
  });
746
747
  const currentHead = currentHeadResult.stdout.trim();
747
- if (currentHead !== commit) {
748
+ // HEAD is the own commit, or a fast-forward the user added on top of it
749
+ // before the runner persisted it.
750
+ if (
751
+ currentHead !== commit &&
752
+ spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", commit, currentHead], {
753
+ stdio: ["ignore", "ignore", "ignore"],
754
+ }).status !== 0
755
+ ) {
748
756
  throw new Error(`batch_head_lineage_broken: current HEAD ${currentHead} diverged from adopted commit ${commit}`);
749
757
  }
750
758
  const parentsResult = spawnSync("git", ["-C", root, "rev-parse", `${commit}^@`], {