immune-brain 3.6.9 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +3 -2
  2. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  3. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +18 -2
  4. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +76 -121
  5. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +106 -600
  6. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +1 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +3 -3
  8. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +17 -43
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7587 -5049
  10. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +11 -12
  11. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +33 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +41 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +11 -1
  16. package/plugins/immune-brain/dist/role-prompts/executor.md +4 -4
  17. package/plugins/immune-brain/runtime/assurance/coordinator.ts +183 -40
  18. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +240 -0
  19. package/plugins/immune-brain/runtime/assurance/qa.ts +132 -58
  20. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +15 -7
  21. package/plugins/immune-brain/runtime/assurance/verification.ts +246 -206
  22. package/plugins/immune-brain/runtime/authorization_operation.ts +20 -0
  23. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +288 -721
  24. package/plugins/immune-brain/runtime/commands/kernel.ts +158 -67
  25. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1 -1
  26. package/plugins/immune-brain/runtime/kernel/actor_identity.ts +33 -0
  27. package/plugins/immune-brain/runtime/kernel/application.ts +22 -6
  28. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +94 -5
  29. package/plugins/immune-brain/runtime/kernel/authority_port.ts +27 -6
  30. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +43 -16
  31. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +10 -6
  32. package/plugins/immune-brain/runtime/kernel/canary_application.ts +50 -63
  33. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +13 -4
  34. package/plugins/immune-brain/runtime/kernel/completion.ts +5 -14
  35. package/plugins/immune-brain/runtime/kernel/enrollment.ts +124 -34
  36. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +13 -5
  37. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  38. package/plugins/immune-brain/runtime/kernel/intent.ts +7 -11
  39. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +4 -1
  40. package/plugins/immune-brain/runtime/kernel/legacy_task_record.ts +323 -0
  41. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +10 -1
  42. package/plugins/immune-brain/runtime/kernel/reducer.ts +32 -31
  43. package/plugins/immune-brain/runtime/kernel/run_identity.ts +121 -0
  44. package/plugins/immune-brain/runtime/kernel/spec_binding.ts +100 -0
  45. package/plugins/immune-brain/runtime/kernel/sqlite_migration.ts +950 -0
  46. package/plugins/immune-brain/runtime/kernel/sqlite_store.ts +1193 -0
  47. package/plugins/immune-brain/runtime/kernel/storage.ts +1254 -1206
  48. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +129 -755
  49. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +419 -46
  50. package/plugins/immune-brain/runtime/kernel/types.ts +12 -43
  51. package/plugins/immune-brain/runtime/kernel/validation.ts +60 -274
  52. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +0 -1
  53. package/plugins/immune-brain/runtime/plan_core.ts +27 -65
  54. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  55. package/plugins/immune-brain/runtime/prompts/code-review.md +11 -1
  56. package/plugins/immune-brain/runtime/prompts/executor.md +4 -4
  57. package/plugins/immune-brain/runtime/staged_intent.ts +58 -0
  58. package/plugins/immune-brain/runtime/unattended/batch_git.ts +37 -7
  59. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +42 -2
  60. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +771 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +189 -0
  62. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +35 -0
  63. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +33 -0
  64. package/plugins/immune-brain/runtime/unattended/types.ts +14 -1
  65. package/plugins/immune-brain/runtime/v4_runtime.ts +19 -23
  66. package/plugins/immune-brain/runtime/verification_descriptor.ts +92 -136
  67. package/plugins/immune-brain/runtime/workspace_scope.ts +98 -13
  68. package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -3
  69. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +0 -4
  70. package/plugins/immune-brain/bin/imm-retired +0 -4
  71. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +0 -716
  72. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +0 -451
  73. package/plugins/immune-brain/runtime/kernel/legacy.ts +0 -299
  74. package/plugins/immune-brain/runtime/kernel/observation.ts +0 -397
  75. package/plugins/immune-brain/runtime/kernel/readiness.ts +0 -282
  76. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +0 -132
@@ -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
  }
@@ -3,8 +3,17 @@
3
3
  // eligible when its Git-tracked TaskIntent is readable and no Kernel owner
4
4
  // (workspace, backend claim, TaskRecord v2, tombstone) blocks enrollment.
5
5
 
6
- import type { ReadinessReport } from "./readiness";
7
- import type { ReadinessEvidenceInput } from "./readiness_evidence";
6
+ // The readiness/evidence pipeline is retired with the authority-observation
7
+ // island. These two derived inputs stay on the eligibility input for caller
8
+ // compatibility only: they are ignored below and are never authority, so they
9
+ // are read structurally instead of importing the deleted modules.
10
+ export interface RetiredReadinessInput {
11
+ status: string;
12
+ }
13
+
14
+ export interface RetiredReadinessEvidenceInput {
15
+ status: string;
16
+ }
8
17
 
9
18
  export type WaivableGate = "observation_window_days";
10
19
 
@@ -26,8 +35,8 @@ export interface CanaryTaskIdentity {
26
35
  }
27
36
 
28
37
  export interface CanaryEligibilityInput {
29
- readiness?: ReadinessReport;
30
- evidence?: ReadinessEvidenceInput;
38
+ readiness?: RetiredReadinessInput;
39
+ evidence?: RetiredReadinessEvidenceInput;
31
40
  task: CanaryTaskIdentity;
32
41
  waiver?: CanaryWaiver;
33
42
  now: string;
@@ -1,4 +1,5 @@
1
1
  import { classifyTaskRisk } from "./intent";
2
+ import { archivePath, boundSpecPath } from "./spec_binding";
2
3
  import type {
3
4
  ApprovalKind,
4
5
  CompletionDecision,
@@ -7,7 +8,7 @@ import type {
7
8
  TaskProjectionV3,
8
9
  TaskRecord,
9
10
  } from "./types";
10
- import { assertKernelInvariantsV3, KernelInvariantError } from "./validation";
11
+ import { assertKernelInvariantsV3 } from "./validation";
11
12
  import { refutationIsLive } from "./refutation";
12
13
 
13
14
  const REQUIRED_ATTESTATIONS: Record<TaskIntentV1["risk"], ApprovalKind[]> = {
@@ -23,23 +24,13 @@ function archiveActivePlanningPath(path: string): string | null {
23
24
 
24
25
  function ownSidecarPaths(intent: TaskIntentV1): Set<string> {
25
26
  const activeIntent = `docs/plans/${intent.task_id}.intent.json`;
26
- const archivedIntent = archiveActivePlanningPath(activeIntent);
27
27
  const excluded = new Set<string>([activeIntent]);
28
+ const archivedIntent = archiveActivePlanningPath(activeIntent);
28
29
  if (archivedIntent) excluded.add(archivedIntent);
29
- const specs = intent.scope_hint.filter((path) => {
30
- const archived = archiveActivePlanningPath(path);
31
- return archived !== null && /^docs\/specs\/[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archived);
32
- });
33
- if (specs.length > 1) {
34
- throw new KernelInvariantError([
35
- `artifact transition requires at most one scope-bound Spec; found ${specs.length}`,
36
- ]);
37
- }
38
- const spec = specs[0];
30
+ const spec = boundSpecPath(intent);
39
31
  if (spec) {
40
32
  excluded.add(spec);
41
- const archived = archiveActivePlanningPath(spec);
42
- if (archived) excluded.add(archived);
33
+ excluded.add(archivePath(spec));
43
34
  }
44
35
  return excluded;
45
36
  }