immune-brain 3.6.8 → 4.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 (82) hide show
  1. package/README.md +11 -4
  2. package/README.zh-CN.md +10 -3
  3. package/package.json +3 -2
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  10. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  11. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7581 -5042
  12. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  13. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  14. package/plugins/immune-brain/dist/imm-loop.md +27 -25
  15. package/plugins/immune-brain/dist/imm-planner.md +53 -32
  16. package/plugins/immune-brain/dist/imm-review-retro.md +123 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -1
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  20. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  21. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  22. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  23. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  24. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  25. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  26. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  27. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  28. package/plugins/immune-brain/runtime/github_issue_tracker.ts +254 -29
  29. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  30. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  31. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  32. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  33. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  34. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  35. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  36. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  37. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  38. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  39. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  40. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  41. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  42. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  43. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  44. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  45. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  46. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  47. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  48. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  49. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  51. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  52. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  53. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  54. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  55. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  56. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  57. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  58. package/plugins/immune-brain/runtime/prompts/code-review.md +3 -1
  59. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  60. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  62. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  63. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  64. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  66. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  67. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  68. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  69. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  70. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  71. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  72. package/plugins/immune-brain/skills/imm-review-retro/SKILL.md +23 -0
  73. package/plugins/immune-brain/skills/imm-review-retro/scripts/review_retro.ts +355 -0
  74. package/plugins/immune-brain/skills/registry.yaml +9 -0
  75. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  76. package/plugins/immune-brain/bin/imm-retired +0 -4
  77. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  78. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  79. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  80. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  81. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  82. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -4,9 +4,9 @@
4
4
  // the dedicated recoverable workspace transaction. Consumed by the Pi canary
5
5
  // host adapter via canary_application.
6
6
 
7
+ import { terminalRequestDigest } from "./run_identity";
7
8
  import {
8
9
  type MutationAuthorityRegistry,
9
- type ValidatedAuthorityV2,
10
10
  } from "./authority_port";
11
11
  import { asTaskDiffSnapshot } from "./completion";
12
12
  import { canonicalIntentHash, readTaskIntent } from "./intent";
@@ -26,6 +26,7 @@ import {
26
26
  commitTerminalLocked,
27
27
  readTaskRecordRaw,
28
28
  readWorkspaceStateRaw,
29
+ currentRunId,
29
30
  revisionForContent,
30
31
  serializeWorkspace,
31
32
  withKernelStoreLock,
@@ -37,8 +38,6 @@ import type {
37
38
  AuthorityAuditDescriptor,
38
39
  MutationAuthorityCapabilityV2,
39
40
  StoredTaskMutationV3,
40
- TaskAction,
41
- TaskIntentV1,
42
41
  TaskRecord,
43
42
  } from "./types";
44
43
  import { TASK_TOMBSTONE_CONTRACT, type TaskTombstone } from "./backend_claim";
@@ -164,9 +163,15 @@ export function applyTaskAction(
164
163
  action.type === "stop" ||
165
164
  action.type === "resolve_user_decision";
166
165
 
166
+ // A capability is opaque: its binding lives in the issuing registry, so
167
+ // the run this worktree holds is read here and handed to the registry as
168
+ // the expected identity. Authority issued for another run is refused
169
+ // before any write, which is what keeps it inside the worktree that
170
+ // issued it even when task, record, intent and diff content are identical.
167
171
  const expectedAuthority = privileged
168
172
  ? {
169
173
  task_id,
174
+ run_id: currentRunId(root, task_id) ?? undefined,
170
175
  action,
171
176
  expected_record_hash: current.revision,
172
177
  intent_revision: isRevisionAction
@@ -189,6 +194,7 @@ export function applyTaskAction(
189
194
  const inspectedAudit = expectedAuthority
190
195
  ? registry.inspect(capability, expectedAuthority, now)
191
196
  : null;
197
+ const authorityRunId = inspectedAudit?.run_id;
192
198
  const mutation = reduceTask(
193
199
  current.record,
194
200
  action,
@@ -227,9 +233,8 @@ export function applyTaskAction(
227
233
  // All preflight passed: consume tokens and authority, then commit.
228
234
  consumeIntentToken(prior_intent_token);
229
235
  consumeIntentToken(freshRead.token);
230
- let consumedAudit: ValidatedAuthorityV2 | null = null;
231
236
  if (expectedAuthority) {
232
- consumedAudit = registry.consume(
237
+ registry.consume(
233
238
  capability as MutationAuthorityCapabilityV2,
234
239
  expectedAuthority,
235
240
  now,
@@ -284,7 +289,17 @@ export function applyTaskAction(
284
289
  next_workspace_content: serializeWorkspace(nextWorkspaceState),
285
290
  ...(input.artifact_transition ? { artifact_relocations: input.artifact_transition.relocations } : {}),
286
291
  };
287
- commitTerminalLocked(root, task_id, transaction, tombstone);
292
+ commitTerminalLocked(
293
+ root,
294
+ task_id,
295
+ transaction,
296
+ tombstone,
297
+ authorityRunId,
298
+ // The request this settlement answers: a retry with a different
299
+ // reason, actor or event time is a different decision and must be
300
+ // authorized again rather than replayed.
301
+ terminalRequestDigest(action),
302
+ );
288
303
  return {
289
304
  revision: canonicalRecordHash(nextRecord),
290
305
  record: nextRecord,
@@ -303,6 +318,7 @@ export function applyTaskAction(
303
318
  workspace.revision,
304
319
  nextWorkspaceState,
305
320
  input.artifact_transition?.relocations,
321
+ authorityRunId,
306
322
  );
307
323
  });
308
324
  }
@@ -12,9 +12,16 @@
12
12
  // "start QA" or "start Review"; it returns Kernel-owned facts only.
13
13
 
14
14
  import { readBackendClaim, readTaskTombstone } from "./backend_claim";
15
- import { readTaskRecord, readAuditTaskPair, readWorkspaceStateRaw, reconcileKernelAuthority } from "./storage";
15
+ import {
16
+ readTaskRecord,
17
+ readAuditTaskPair,
18
+ readCommittedRecord,
19
+ readWorkspaceStateRaw,
20
+ reconcileKernelAuthority,
21
+ } from "./storage";
16
22
  import { projectTask, resolveProjectedRisk } from "./completion";
17
- import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV2 } from "./types";
23
+ import type { AssuranceObligation, TaskIntentV1, TaskRecord, TaskRecordV3 } from "./types";
24
+ import type { TaskRecordV2 } from "./legacy_task_record";
18
25
 
19
26
  export interface TaskDiffSnapshot {
20
27
  diff_hash: string;
@@ -34,6 +41,12 @@ export interface AssuranceAuthorizationReadiness {
34
41
  }
35
42
 
36
43
  export interface AssuranceProjection {
44
+ /**
45
+ * The exact run this projection describes: one worktree owns one store and
46
+ * one active run, so the run id is the authority identity a capability must
47
+ * bind. Null when no run is committed.
48
+ */
49
+ run_id: string | null;
37
50
  record_revision: string;
38
51
  workspace_revision: string;
39
52
  intent_revision: number;
@@ -83,6 +96,7 @@ export function deriveAssuranceAuthorization(input: {
83
96
 
84
97
  function emptyProjection(): AssuranceProjection {
85
98
  return {
99
+ run_id: null,
86
100
  record_revision: "",
87
101
  workspace_revision: "",
88
102
  intent_revision: 0,
@@ -128,6 +142,36 @@ function projectHistoricalTerminal(
128
142
  };
129
143
  }
130
144
 
145
+ function projectHistoricalTerminalV3(
146
+ record: TaskRecordV3,
147
+ recordRevision: string,
148
+ workspaceRevision: string,
149
+ ): AssuranceProjection {
150
+ const freshAcceptanceIds = [
151
+ ...new Set(
152
+ record.attestations
153
+ .filter((item) => item.kind === "qa")
154
+ .flatMap((item) => item.acceptance_results)
155
+ .filter((item) => item.status === "passed")
156
+ .map((item) => item.acceptance_id),
157
+ ),
158
+ ];
159
+ const approvalKinds = [...new Set(record.attestations.map((item) => item.kind))];
160
+ return {
161
+ ...emptyProjection(),
162
+ record_revision: recordRevision,
163
+ workspace_revision: workspaceRevision,
164
+ intent_revision: record.intent_snapshot.revision,
165
+ intent_content_hash: record.intent_ref.content_hash,
166
+ lifecycle: record.lifecycle,
167
+ artifact_state: "frozen",
168
+ risk: record.intent_snapshot.risk,
169
+ fresh_acceptance_ids: freshAcceptanceIds,
170
+ fresh_approval_kinds: approvalKinds,
171
+ completion_ready: record.lifecycle === "done",
172
+ };
173
+ }
174
+
131
175
  function freshApprovalKinds(
132
176
  record: TaskRecord,
133
177
  currentIntentContentHash: string,
@@ -168,6 +212,8 @@ function projectFromRecord(
168
212
  (finding) => finding.kind === "unresolved_user_decision" && finding.status === "open",
169
213
  ).length;
170
214
  return {
215
+ // Filled by the caller from the committed run row.
216
+ run_id: null,
171
217
  record_revision: recordRevision,
172
218
  workspace_revision: workspaceRevision,
173
219
  intent_revision: record.intent_snapshot.revision,
@@ -232,9 +278,19 @@ export async function projectAssurance(
232
278
  const tombstone = readTaskTombstone(root, taskId);
233
279
  if (tombstone) {
234
280
  const authority = reconcileKernelAuthority(root, taskId);
235
- if (authority.state === "repairable_stale_claim")
281
+ // A terminal proof keyed by task id may have been exported by
282
+ // another worktree's run of this same task: it is that run's
283
+ // evidence, not a conflict with this worktree's own run. The local
284
+ // store decides, and an active local run keeps its authority.
285
+ if (authority.state === "active_owner") {
286
+ // fall through: the local run owns this task here
287
+ } else if (authority.state === "repairable_stale_claim")
236
288
  return fail(`task ${taskId} has a repairable stale backend claim`, claim);
237
- return fail(authority.diagnostic ?? `authority state conflicts for ${taskId}`, claim);
289
+ else
290
+ return fail(
291
+ authority.diagnostic ?? `authority state conflicts for ${taskId}`,
292
+ claim,
293
+ );
238
294
  }
239
295
  } else {
240
296
  const authority = reconcileKernelAuthority(root, taskId);
@@ -254,6 +310,28 @@ export async function projectAssurance(
254
310
  }
255
311
  if (!read.record) {
256
312
  if (!terminalOwner) return fail(`task ${taskId} has no TaskRecord v3`, claim);
313
+ const committedRunId = reconcileKernelAuthority(root, taskId).owner_run_id;
314
+ // The store keeps the terminal record itself, so a settled task projects
315
+ // from committed facts even while its audit export is still in flight.
316
+ const committed = await readCommittedRecord(root, taskId);
317
+ if (committed) {
318
+ const workspace = await readWorkspaceStateRaw(root);
319
+ return {
320
+ contract: "assurance_kernel/assurance_projection/v1",
321
+ task_id: taskId,
322
+ error: null,
323
+ claim: null,
324
+ projection: {
325
+ ...projectFromRecord(
326
+ committed.record,
327
+ committed.revision,
328
+ workspace.revision,
329
+ diffProvider(root, committed.record),
330
+ ),
331
+ run_id: committedRunId,
332
+ },
333
+ };
334
+ }
257
335
  const auditPair = await readAuditTaskPair(root, taskId);
258
336
  if (!auditPair) return fail(`task ${taskId} has no terminal audit pair`, claim);
259
337
  const workspace = await readWorkspaceStateRaw(root);
@@ -265,6 +343,14 @@ export async function projectAssurance(
265
343
  claim: null,
266
344
  projection: projectHistoricalTerminal(auditPair.record, auditPair.recordRevision, workspace.revision),
267
345
  };
346
+ if (auditPair.record.contract === "assurance_kernel/task_record/v3")
347
+ return {
348
+ contract: "assurance_kernel/assurance_projection/v1",
349
+ task_id: taskId,
350
+ error: null,
351
+ claim: null,
352
+ projection: projectHistoricalTerminalV3(auditPair.record, auditPair.recordRevision, workspace.revision),
353
+ };
268
354
  return {
269
355
  contract: "assurance_kernel/assurance_projection/v1",
270
356
  task_id: taskId,
@@ -284,7 +370,10 @@ export async function projectAssurance(
284
370
  task_id: taskId,
285
371
  error: null,
286
372
  claim,
287
- projection: projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
373
+ projection: {
374
+ ...projectFromRecord(read.record, read.revision, workspace.revision, snapshot),
375
+ run_id: reconcileKernelAuthority(root, taskId).owner_run_id,
376
+ },
288
377
  };
289
378
  } catch (error) {
290
379
  return fail(error instanceof Error ? error.message : String(error));
@@ -4,6 +4,7 @@
4
4
  // remain here.
5
5
 
6
6
  import { createHash } from "node:crypto";
7
+ import { canonicalActorId } from "./actor_identity";
7
8
 
8
9
  import {
9
10
  MUTATION_AUTHORITY_CAPABILITY_BRAND,
@@ -13,18 +14,23 @@ import {
13
14
  type TaskAction,
14
15
  } from "./types";
15
16
  import { createCapabilityRegistry } from "./capability_registry";
17
+ import type { BaseCapabilityBinding } from "./enrollment_authority";
16
18
 
17
- export interface CapabilityBindingV2 {
19
+ export interface CapabilityBindingV2 extends BaseCapabilityBinding {
18
20
  authority_kind: MutationAuthorityKind;
19
21
  task_id: string;
22
+ /**
23
+ * The exact run this authority was issued for. Two worktrees may each run
24
+ * the same logical task with identical record, intent and diff content but
25
+ * different run identities, so the task and content bindings alone cannot
26
+ * tell them apart.
27
+ */
28
+ run_id?: string;
20
29
  action_digest: string;
21
30
  expected_record_hash: string;
22
31
  intent_revision: number;
23
32
  intent_content_hash: string;
24
33
  diff_hash: string;
25
- actor_id: string;
26
- confirmation_ref: string;
27
- expires_at: string;
28
34
  /** Normalized findings digest required for request_rework; null for other operations. */
29
35
  findings_digest: string | null;
30
36
  }
@@ -32,6 +38,7 @@ export interface CapabilityBindingV2 {
32
38
  export interface ValidatedAuthorityV2 {
33
39
  audit: AuthorityAuditDescriptor;
34
40
  action_digest: string;
41
+ run_id?: string;
35
42
  expected_record_hash: string;
36
43
  intent_revision: number;
37
44
  intent_content_hash: string;
@@ -41,6 +48,7 @@ export interface ValidatedAuthorityV2 {
41
48
 
42
49
  export interface MutationAuthorityInspection {
43
50
  task_id: string;
51
+ run_id?: string;
44
52
  action: TaskAction;
45
53
  expected_record_hash: string;
46
54
  intent_revision: number;
@@ -82,8 +90,10 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
82
90
  {
83
91
  validateBinding(binding, issuedAt) {
84
92
  const missing: string[] = [];
93
+ if (binding.run_id !== undefined && binding.run_id.length === 0)
94
+ throw new Error("authority capability run_id must not be empty");
85
95
  for (const [key, value] of Object.entries(binding)) {
86
- if (key === "findings_digest") continue;
96
+ if (key === "findings_digest" || key === "run_id") continue;
87
97
  if (value === undefined || value === null || value === "") missing.push(key);
88
98
  }
89
99
  if (missing.length > 0)
@@ -101,6 +111,12 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
101
111
  throw new Error("authority capability action digest mismatch");
102
112
  if (state.task_id !== expected.task_id)
103
113
  throw new Error("authority capability task mismatch");
114
+ if (
115
+ state.run_id !== undefined &&
116
+ expected.run_id !== undefined &&
117
+ state.run_id !== expected.run_id
118
+ )
119
+ throw new Error("authority capability run mismatch");
104
120
  if (state.expected_record_hash !== expected.expected_record_hash)
105
121
  throw new Error("authority capability record hash mismatch");
106
122
  if (state.intent_revision !== expected.intent_revision)
@@ -118,12 +134,17 @@ export function createMutationAuthorityRegistry(): MutationAuthorityRegistry {
118
134
  return {
119
135
  audit: {
120
136
  authority_kind: state.authority_kind,
121
- actor_id: state.actor_id,
137
+ // One recorded spelling: this projection is the single point where
138
+ // an authority's actor becomes the history entry's audit identity,
139
+ // so a Host that still supplies the historical `user` spelling is
140
+ // recorded as the canonical literal user.
141
+ actor_id: canonicalActorId(state.actor_id),
122
142
  confirmation_ref: state.confirmation_ref,
123
143
  issued_at: state.issued_at,
124
144
  expires_at: state.expires_at,
125
145
  },
126
146
  action_digest: actionDigest,
147
+ ...(state.run_id !== undefined ? { run_id: state.run_id } : {}),
127
148
  expected_record_hash: state.expected_record_hash,
128
149
  intent_revision: state.intent_revision,
129
150
  intent_content_hash: state.intent_content_hash,
@@ -1,19 +1,24 @@
1
1
  // P2B2 backend claim ownership. NOT exported from kernel/index.ts.
2
- // The ignored state-layout `.imm/state/active-claim.json` is the unique
3
- // workspace-active claim and may be `active | draining` only; terminal state
4
- // lives exclusively in the immutable tracked audit proof
2
+ // The workspace-active claim is DERIVED from the single active run in the
3
+ // worktree authority store (`.imm/state/kernel.sqlite`); terminal state lives
4
+ // exclusively in the immutable tracked audit proof
5
5
  // `.imm/audit/<task-id>/terminal-proof.json`. No writer is exported from this
6
- // module: every claim change goes through the recoverable Kernel store
7
- // transactions owned by storage.ts (enrollment, drain, terminalization).
6
+ // module: every claim change goes through the Kernel store transactions owned
7
+ // by storage.ts (enrollment, drain, terminalization).
8
8
 
9
9
  import { lstatSync, readFileSync } from "node:fs";
10
10
  import { join, resolve } from "node:path";
11
11
 
12
- import { auditTerminalProofPath, stateClaimPath } from "./storage_paths";
12
+ import { auditEvidencePaths, auditRunTerminalProofPath } from "./storage_paths";
13
+ import {
14
+ activeRunId,
15
+ readRunRowById,
16
+ readRunRowByTask,
17
+ withKernelRead,
18
+ type KernelRunRow,
19
+ } from "./sqlite_store";
13
20
  import type { TaskLifecycle } from "./types";
14
21
 
15
- const CLAIM_PATH = stateClaimPath();
16
-
17
22
  export type BackendLifecycleStatus = "active" | "draining";
18
23
 
19
24
  export interface BackendClaim {
@@ -114,9 +119,28 @@ export function parseBackendClaim(raw: Record<string, unknown>): BackendClaim {
114
119
  }
115
120
 
116
121
  export function readBackendClaim(root: string): BackendClaim | null {
117
- const raw = readJsonOrNull(join(root, CLAIM_PATH));
118
- if (!raw) return null;
119
- return parseBackendClaim(raw);
122
+ return (
123
+ withKernelRead(root, (db) => {
124
+ const run = readRunRowById(db, activeRunId(db) ?? "");
125
+ if (!run || run.state !== "active" || run.claim_status === null) return null;
126
+ return claimFromRunRow(run);
127
+ }) ?? null
128
+ );
129
+ }
130
+
131
+ /** Project the workspace claim from the committed run row that owns it. */
132
+ export function claimFromRunRow(run: KernelRunRow): BackendClaim {
133
+ return parseBackendClaim({
134
+ contract: "assurance_kernel/backend_claim/v2",
135
+ backend: "kernel",
136
+ task_id: run.task_id,
137
+ intent_revision: run.intent_revision,
138
+ intent_content_hash: run.intent_content_hash,
139
+ enrollment_event_id: run.enrollment_event_id,
140
+ lifecycle_status: run.claim_status ?? "active",
141
+ created_at: run.created_at,
142
+ updated_at: run.updated_at,
143
+ });
120
144
  }
121
145
 
122
146
  /** Serialize a validated claim to the canonical file bytes. Transaction-only writer consumers. */
@@ -163,7 +187,14 @@ export function parseTaskTombstone(raw: Record<string, unknown>): TaskTombstone
163
187
  /** Fail-closed task-scoped tombstone read. Malformed/unreadable/symlinked state throws; only ENOENT means absent. */
164
188
  export function readTaskTombstone(root: string, taskId: string): TaskTombstone | null {
165
189
  validateTaskId(taskId);
166
- const raw = readJsonOrNull(join(resolve(root), auditTerminalProofPath(taskId)));
190
+ // A local run's proof is that run's evidence. Directory counting cannot
191
+ // choose among several run directories, and a foreign run must not be
192
+ // adopted while this worktree has its own run.
193
+ const localRun = withKernelRead(root, (db) => readRunRowByTask(db, taskId));
194
+ const proofPath = localRun
195
+ ? auditRunTerminalProofPath(taskId, localRun.run_id)
196
+ : auditEvidencePaths(root, taskId).proof;
197
+ const raw = readJsonOrNull(join(resolve(root), proofPath));
167
198
  if (!raw) return null;
168
199
  const tombstone = parseTaskTombstone(raw);
169
200
  if (tombstone.task_id !== taskId)
@@ -191,7 +222,3 @@ export function assertNoKernelBackendForV3(root: string, _commandTask?: string):
191
222
  `backend-owned workspace: task ${claim.task_id} is managed by the Kernel backend (${claim.lifecycle_status}); v3 managed mutation is rejected`,
192
223
  );
193
224
  }
194
-
195
- export function backendClaimPath(root: string): string {
196
- return join(resolve(root), stateClaimPath());
197
- }
@@ -9,7 +9,8 @@
9
9
 
10
10
  import { createHash } from "node:crypto";
11
11
  import { createCapabilityRegistry } from "./capability_registry";
12
- import type { EnrollmentCapabilityBinding } from "./enrollment_authority";
12
+ import { isLiteralUserActor } from "./actor_identity";
13
+ import type { BaseCapabilityBinding, EnrollmentCapabilityBinding } from "./enrollment_authority";
13
14
  import { preparePiCanary, type PiCanaryPreparation } from "./pi_canary_prepare";
14
15
 
15
16
  export const BATCH_AUTHORITY_CAPABILITY_BRAND = Symbol.for(
@@ -42,16 +43,13 @@ export interface BatchBudget {
42
43
  qa_failure_limit: number;
43
44
  }
44
45
 
45
- export interface BatchAuthorizationBinding {
46
+ export interface BatchAuthorizationBinding extends BaseCapabilityBinding {
46
47
  batch_id: string;
47
48
  initiative_slug: string;
48
49
  plan_digest: string;
49
50
  branch: string;
50
51
  base_head: string;
51
52
  budget: BatchBudget;
52
- actor_id: string;
53
- confirmation_ref: string;
54
- expires_at: string;
55
53
  nonce: string;
56
54
  }
57
55
 
@@ -214,7 +212,10 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
214
212
  {
215
213
  validateBinding(binding, issuedAt) {
216
214
  requireNonEmpty(binding);
217
- if (binding.actor_id !== "user")
215
+ // Both the canonical literal-user spelling and the historical `user`
216
+ // spelling are read, so a batch authorized before the convergence keeps
217
+ // validating; new bindings are written canonically.
218
+ if (!isLiteralUserActor(binding.actor_id))
218
219
  throw new Error("batch authorization requires a literal-user actor_id");
219
220
  if (!GIT_COMMIT_ID.test(binding.base_head))
220
221
  throw new Error("batch authorization base_head must be a committed 40-hex commit id");
@@ -256,6 +257,9 @@ export function createBatchAuthorityRegistry(): BatchAuthorityRegistry {
256
257
  branch: state.branch,
257
258
  base_head: state.base_head,
258
259
  budget: { ...state.budget },
260
+ // Kept faithful to the stored state: a batch authorized before the
261
+ // convergence resumes with the spelling it was issued under, and the
262
+ // recorded audit identity is canonicalized once, where it is written.
259
263
  actor_id: state.actor_id,
260
264
  confirmation_ref: state.confirmation_ref,
261
265
  issued_at: state.issued_at,
@@ -8,6 +8,7 @@
8
8
  // once inside the Pi lifecycle extension activation closure; tests use
9
9
  // tests/fixtures/mutation-authority-test-seam.ts.
10
10
 
11
+ import { terminalRequestDigest } from "./run_identity";
11
12
  import {
12
13
  type MutationAuthorityRegistry,
13
14
  } from "./authority_port";
@@ -24,13 +25,15 @@ import {
24
25
  type BackendClaim,
25
26
  } from "./backend_claim";
26
27
  import { canonicalIntentHash } from "./intent";
28
+ import { boundSpecPath, readBoundActiveSpec } from "./spec_binding";
27
29
  import { parseTaskRecord } from "./validation";
28
30
  import type { TaskIntentIdentityToken } from "./intent_token_registry";
29
31
  import {
30
32
  commitDrainLocked,
31
33
  commitTaskRecordLocked,
32
- readSecureProjectFile,
33
34
  readTaskRecordRaw,
35
+ currentRunId,
36
+ readCommittedTerminalResult,
34
37
  readWorkspaceStateRaw,
35
38
  revisionForContent,
36
39
  withKernelStoreLock,
@@ -167,69 +170,29 @@ export function capabilityActionFor(input: {
167
170
  }
168
171
  }
169
172
 
170
- function archivePath(path: string): string {
171
- const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
172
- if (!matched) throw new KernelInvariantError([`artifact path is not active: ${path}`]);
173
- return `docs/${matched[1]}/archive/${matched[2]}`;
174
- }
175
-
176
- function boundSpecPath(intent: TaskIntentV1): string | undefined {
177
- const candidates = intent.scope_hint.filter(
178
- (path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archivePath(path)),
179
- );
180
- if (candidates.length > 1)
181
- throw new KernelInvariantError([`artifact transition requires at most one scope-bound Spec; found ${candidates.length}`]);
182
- return candidates[0];
183
- }
184
-
185
- function readBoundActiveSpec(
186
- root: string,
187
- intent: TaskIntentV1,
188
- required = true,
189
- ): { path: string; content: string } | undefined {
190
- const specPath = boundSpecPath(intent);
191
- if (!specPath) {
192
- if (required) throw new KernelInvariantError(["artifact freeze requires one scope-bound active Spec"]);
193
- return undefined;
194
- }
195
- try {
196
- return { path: specPath, content: readSecureProjectFile(root, specPath) };
197
- } catch (error) {
198
- if (error instanceof Error && error.message.startsWith("source_missing:") && !required) return undefined;
199
- throw error;
200
- }
201
- }
202
-
203
173
  function transitionFor(
204
174
  root: string,
205
175
  record: { intent_ref: { path: string }; intent_snapshot: TaskIntentV1; artifact_state: "active" | "frozen" },
206
176
  direction: "freeze" | "restore",
207
- allowIntentOnly = false,
177
+ allowMissingSpec = false,
208
178
  ) {
209
179
  const activeIntent = `docs/plans/${record.intent_snapshot.task_id}.intent.json`;
210
- const frozenIntent = archivePath(activeIntent);
211
180
  if (direction === "freeze") {
212
181
  if (record.intent_ref.path !== activeIntent)
213
182
  throw new KernelInvariantError(["artifact freeze requires the active intent path"]);
214
- const spec = readBoundActiveSpec(root, record.intent_snapshot, !allowIntentOnly);
215
- const intentContent = readSecureProjectFile(root, activeIntent);
183
+ const spec = readBoundActiveSpec(root, record.intent_snapshot);
184
+ if (boundSpecPath(record.intent_snapshot) && !spec && !allowMissingSpec)
185
+ throw new KernelInvariantError([`source_missing: ${boundSpecPath(record.intent_snapshot)}`]);
216
186
  return {
217
- relocations: [
218
- { from_path: activeIntent, to_path: frozenIntent, content_hash: revisionForContent(intentContent) },
219
- ...(spec ? [{ from_path: spec.path, to_path: archivePath(spec.path), content_hash: revisionForContent(spec.content) }] : []),
220
- ],
221
- next_intent_path: frozenIntent,
187
+ relocations: [] as { from_path: string; to_path: string; content_hash: string }[],
188
+ next_intent_path: activeIntent,
222
189
  next_artifact_state: "frozen" as const,
223
190
  };
224
191
  }
225
- if (record.intent_ref.path !== frozenIntent || record.artifact_state !== "frozen")
192
+ if (record.artifact_state !== "frozen")
226
193
  throw new KernelInvariantError(["artifact restore requires a frozen TaskRecord"]);
227
- const specPath = boundSpecPath(record.intent_snapshot);
228
194
  return {
229
- relocations: [
230
- { from_path: frozenIntent, to_path: activeIntent, content_hash: revisionForContent(readSecureProjectFile(root, frozenIntent)) },
231
- ...(specPath ? [{ from_path: archivePath(specPath), to_path: specPath, content_hash: revisionForContent(readSecureProjectFile(root, archivePath(specPath))) }] : []),
232
- ],
195
+ relocations: [] as { from_path: string; to_path: string; content_hash: string }[],
233
196
  next_intent_path: activeIntent,
234
197
  next_artifact_state: "active" as const,
235
198
  };
@@ -279,7 +242,7 @@ export function createCanaryApplication(
279
242
  type: "freeze_artifacts",
280
243
  from_state: "active:active",
281
244
  to_state: "active:frozen",
282
- reason: `TaskIntent and Spec frozen at ${transition.next_intent_path}`,
245
+ reason: `TaskIntent frozen in place at ${transition.next_intent_path}`,
283
246
  },
284
247
  ],
285
248
  });
@@ -302,6 +265,35 @@ export function createCanaryApplication(
302
265
  const now = input.now ?? new Date().toISOString();
303
266
  const operation = input.operation;
304
267
  const at = now;
268
+ // A settled run clears the active record, so a lost settlement response can
269
+ // only be answered from the committed operation — and only with the
270
+ // committed result. This runs before the record, token and capability
271
+ // checks, which a terminal run can no longer satisfy.
272
+ if (operation.op === "complete" || operation.op === "stop") {
273
+ const replayed = readCommittedTerminalResult(
274
+ input.root,
275
+ input.task_id,
276
+ `${operation.op}:${input.task_id}:${at}`,
277
+ terminalRequestDigest({
278
+ type: operation.op,
279
+ event_id: `${operation.op}:${input.task_id}:${at}`,
280
+ at,
281
+ actor_id: operation.actor_id,
282
+ reason: "reason" in operation ? operation.reason : undefined,
283
+ }),
284
+ );
285
+ if (replayed)
286
+ return {
287
+ // The committed result is the answer: same record identity, same
288
+ // cleared workspace, no second write.
289
+ revision: revisionForContent(`${JSON.stringify(replayed.record, null, 2)}\n`),
290
+ record: replayed.record,
291
+ workspace: {
292
+ revision: revisionForContent(`${JSON.stringify(replayed.workspace, null, 2)}\n`),
293
+ state: replayed.workspace,
294
+ },
295
+ };
296
+ }
305
297
  // Preflight snapshot inside the store lock derives the exact current
306
298
  // identities; authoritative CAS revalidation still happens inside the
307
299
  // locked application port, so any concurrent change fails closed.
@@ -324,10 +316,7 @@ export function createCanaryApplication(
324
316
  const diffHash = asTaskDiffSnapshot(input.diffProvider(input.root, snapshot.record)).diff_hash;
325
317
  if (operation.op === "stop" && !("capability" in operation))
326
318
  throw new KernelInvariantError(["stop requires user authority capability"]);
327
- const hasBoundSpec = snapshot.intent_snapshot.scope_hint.some(
328
- (path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path)
329
- && snapshot.intent_snapshot.scope_hint.includes(archivePath(path)),
330
- );
319
+ const hasBoundSpec = boundSpecPath(snapshot.intent_snapshot) !== undefined;
331
320
  if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
332
321
  throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
333
322
  const artifactTransition =
@@ -390,13 +379,7 @@ export function createCanaryApplication(
390
379
  type: "revise_intent",
391
380
  next_intent: operation.next_intent,
392
381
  next_intent_ref: {
393
- // revise_intent never unfreezes artifacts, so a revision
394
- // while frozen must keep the archived sidecar path that
395
- // record validation enforces (active path otherwise).
396
- path:
397
- snapshot.record.artifact_state === "frozen"
398
- ? `docs/plans/archive/${operation.next_intent.task_id}.intent.json`
399
- : `docs/plans/${operation.next_intent.task_id}.intent.json`,
382
+ path: `docs/plans/${operation.next_intent.task_id}.intent.json`,
400
383
  content_hash: canonicalIntentHash(operation.next_intent),
401
384
  },
402
385
  };
@@ -460,6 +443,7 @@ export function createCanaryApplication(
460
443
  root: string;
461
444
  task_id: string;
462
445
  capability: object;
446
+ run_id?: string | null;
463
447
  now?: string;
464
448
  }): BackendClaim {
465
449
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.task_id))
@@ -498,9 +482,11 @@ export function createCanaryApplication(
498
482
  throw new KernelInvariantError([
499
483
  `workspace is not owned by task ${input.task_id}`,
500
484
  ]);
501
- // The capability is bound to the canonical begin_drain action.
502
- registry.consume(input.capability as never, {
485
+ // The capability is bound to the canonical begin_drain action *and* to
486
+ // the run this worktree holds.
487
+ const validated = registry.consume(input.capability as never, {
503
488
  task_id: input.task_id,
489
+ run_id: currentRunId(input.root, input.task_id) ?? undefined,
504
490
  action: beginDrainCapabilityAction(input.task_id, now),
505
491
  expected_record_hash: current.revision,
506
492
  intent_revision: current.record.intent_snapshot.revision,
@@ -518,6 +504,7 @@ export function createCanaryApplication(
518
504
  serializeBackendClaim(claim),
519
505
  serializeBackendClaim(nextClaim),
520
506
  now,
507
+ validated.run_id,
521
508
  );
522
509
  });
523
510
  }