immune-brain 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +239 -0
  2. package/README.zh-CN.md +239 -0
  3. package/package.json +84 -0
  4. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
  6. package/plugins/immune-brain/.pi-extension/package.json +11 -0
  7. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
  9. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
  10. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
  11. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
  12. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
  13. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
  14. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
  15. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
  16. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
  17. package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
  18. package/plugins/immune-brain/bin/imm-kernel +4 -0
  19. package/plugins/immune-brain/bin/imm-plan +4 -0
  20. package/plugins/immune-brain/bin/imm-pr-diag +230 -0
  21. package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
  22. package/plugins/immune-brain/bin/imm-retired +4 -0
  23. package/plugins/immune-brain/bin/imm-tracker +4 -0
  24. package/plugins/immune-brain/dist/BASELINE.md +138 -0
  25. package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
  26. package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
  27. package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
  28. package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
  29. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
  30. package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
  31. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
  32. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
  33. package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
  34. package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
  35. package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
  36. package/plugins/immune-brain/dist/imm-loop.md +158 -0
  37. package/plugins/immune-brain/dist/imm-planner.md +387 -0
  38. package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
  39. package/plugins/immune-brain/dist/registry.yaml +49 -0
  40. package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
  41. package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
  42. package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
  43. package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
  44. package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
  45. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
  46. package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
  47. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
  48. package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
  49. package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
  50. package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
  51. package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
  52. package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
  53. package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
  54. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
  55. package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
  56. package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
  57. package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
  58. package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
  59. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
  60. package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
  61. package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
  62. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
  63. package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
  64. package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
  65. package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
  66. package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
  67. package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
  68. package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
  69. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
  70. package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
  71. package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
  72. package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
  73. package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
  74. package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
  75. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
  76. package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
  77. package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
  78. package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
  79. package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
  80. package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
  81. package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
  82. package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
  83. package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
  84. package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
  85. package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
  86. package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
  87. package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
  88. package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
  89. package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
  90. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
  91. package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
  92. package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
  93. package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
  94. package/plugins/immune-brain/skills/.ignore +1 -0
  95. package/plugins/immune-brain/skills/BASELINE.md +138 -0
  96. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
  97. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
  98. package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
  99. package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
  100. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
  101. package/plugins/immune-brain/skills/registry.yaml +49 -0
@@ -0,0 +1,507 @@
1
+ // P2B2 canary application service. NOT exported from kernel/index.ts.
2
+ // The only production caller of applyTaskAction and the only production
3
+ // owner of the active -> draining claim transition. Accepts a closed semantic
4
+ // operation union; callers can never supply raw TaskAction, CAS identities,
5
+ // authority descriptors, or generic patches. Event identity, timestamps,
6
+ // expected hashes, and Intent-derived facts are derived inside the trusted
7
+ // application boundary. The paired mutation-authority registry is created
8
+ // once inside the Pi lifecycle extension activation closure; tests use
9
+ // tests/fixtures/mutation-authority-test-seam.ts.
10
+
11
+ import {
12
+ type MutationAuthorityRegistry,
13
+ } from "./authority_port";
14
+ import { applyTaskAction } from "./application";
15
+ import { readTaskIntent } from "./intent";
16
+ import {
17
+ inspectIntentTokenPair,
18
+ consumeIntentToken,
19
+ } from "./intent_token_registry";
20
+ import {
21
+ readBackendClaim,
22
+ serializeBackendClaim,
23
+ type BackendClaim,
24
+ } from "./backend_claim";
25
+ import { canonicalIntentHash } from "./intent";
26
+ import { parseTaskRecordV3 } from "./validation";
27
+ import type { TaskIntentIdentityToken } from "./intent_token_registry";
28
+ import {
29
+ commitDrainLocked,
30
+ commitTaskRecordLocked,
31
+ readSecureProjectFile,
32
+ readTaskRecordRaw,
33
+ readWorkspaceStateRaw,
34
+ revisionForContent,
35
+ withKernelStoreLock,
36
+ } from "./storage";
37
+ import { KernelInvariantError } from "./validation";
38
+ import type {
39
+ StoredTaskMutationV3,
40
+ TaskAction,
41
+ TaskApprovalV2,
42
+ TaskFinding,
43
+ TaskIntentV1,
44
+ } from "./types";
45
+
46
+ export type CanaryOperation =
47
+ | { op: "freeze_artifacts"; actor_id: string }
48
+ | { op: "record_finding"; finding: Omit<TaskFinding, "status" | "source" | "review_round">; actor_id: string }
49
+ | { op: "resolve_finding"; finding_id: string; actor_id: string }
50
+ | { op: "request_rework"; capability: object; findings: TaskFinding[]; actor_id: string }
51
+ | { op: "record_approval"; capability: object; approval: TaskApprovalV2; actor_id: string }
52
+ | { op: "record_user_approval"; capability: object; approval: TaskApprovalV2; actor_id: string }
53
+ | { op: "revise_intent"; next_intent: TaskIntentV1; actor_id: string }
54
+ | { op: "approve_breaking_intent_revision"; capability: object; next_intent: TaskIntentV1; actor_id: string }
55
+ | { op: "complete"; actor_id: string }
56
+ | { op: "stop"; capability: object; reason: string; actor_id: string }
57
+ | { op: "resolve_user_decision"; capability: object; finding_id: string; resolution: string; actor_id: string };
58
+
59
+ export interface CanaryExecuteInput {
60
+ root: string;
61
+ task_id: string;
62
+ operation: CanaryOperation;
63
+ prior_intent_token: TaskIntentIdentityToken;
64
+ /** Trusted injected diff provider; the application derives the diff identity. */
65
+ diffProvider: (root: string, intent: TaskIntentV1) => string;
66
+ now?: string;
67
+ }
68
+
69
+ export interface CanaryApplication {
70
+ readonly registry: MutationAuthorityRegistry;
71
+ /** Execute a closed semantic operation through the paired reducer port. */
72
+ execute(input: CanaryExecuteInput): StoredTaskMutationV3;
73
+ /**
74
+ * User-confirmed routing-only operation: converges the workspace-active
75
+ * claim unidirectionally active -> draining under the Kernel store lock.
76
+ * Preserves TaskRecord and workspace bytes; never touches the reducer.
77
+ */
78
+ beginDrain(input: {
79
+ root: string;
80
+ task_id: string;
81
+ capability: object;
82
+ now?: string;
83
+ }): BackendClaim;
84
+ }
85
+
86
+ /**
87
+ * Canonical capability-bound action identity for begin_drain. Shared by the
88
+ * issuer (Pi TUI confirm) and the consuming application so the action digest
89
+ * binds the exact drain request.
90
+ */
91
+ export function beginDrainCapabilityAction(taskId: string, at: string): TaskAction {
92
+ return {
93
+ type: "stop",
94
+ event_id: `begin_drain:${taskId}:${at}`,
95
+ at,
96
+ actor_id: "user",
97
+ expected_record_hash: "",
98
+ expected_workspace_hash: "",
99
+ diff_hash: "",
100
+ reason: "begin_drain",
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Canonical privileged action builder shared by capability issuance and the
106
+ * consuming application. Returns the exact action object (field order and
107
+ * payload shape) that execute()/applyTaskAction() will inspect, so a
108
+ * capability minted against this action's digest always validates.
109
+ * "begin-drain" delegates to the canonical begin_drain action.
110
+ */
111
+ export function capabilityActionFor(input: {
112
+ op: string;
113
+ task_id: string;
114
+ at: string;
115
+ actor_id: string;
116
+ reason?: string;
117
+ findings?: unknown[];
118
+ approval?: unknown;
119
+ next_intent?: unknown;
120
+ next_intent_ref?: unknown;
121
+ finding_id?: string;
122
+ resolution?: unknown;
123
+ }): TaskAction {
124
+ if (input.op === "begin-drain") {
125
+ return beginDrainCapabilityAction(input.task_id, input.at);
126
+ }
127
+ const base = {
128
+ type: input.op,
129
+ event_id: `${input.op}:${input.task_id}:${input.at}`,
130
+ at: input.at,
131
+ actor_id: input.actor_id,
132
+ // Placeholder hashes satisfy the strict v2 action parser; the digest
133
+ // excludes these three fields, so the minted digest equals the digest
134
+ // of the parsed action the application will inspect.
135
+ expected_record_hash: "sha256:" + "0".repeat(64),
136
+ expected_workspace_hash: "sha256:" + "0".repeat(64),
137
+ diff_hash: "sha256:" + "0".repeat(64),
138
+ };
139
+ switch (input.op) {
140
+ case "record_approval":
141
+ return { ...base, approval: input.approval } as TaskAction;
142
+ case "record_user_approval":
143
+ return { ...base, approval: input.approval } as TaskAction;
144
+ case "request_rework":
145
+ return { ...base, findings: input.findings } as TaskAction;
146
+ case "stop":
147
+ return { ...base, reason: input.reason } as TaskAction;
148
+ case "approve_breaking_intent_revision":
149
+ return {
150
+ ...base,
151
+ next_intent: input.next_intent,
152
+ next_intent_ref: input.next_intent_ref,
153
+ } as TaskAction;
154
+ case "resolve_user_decision":
155
+ return {
156
+ ...base,
157
+ finding_id: input.finding_id,
158
+ resolution: input.resolution,
159
+ } as TaskAction;
160
+ default:
161
+ throw new KernelInvariantError([
162
+ `unsupported capability action: ${input.op}`,
163
+ ]);
164
+ }
165
+ }
166
+
167
+ function archivePath(path: string): string {
168
+ const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
169
+ if (!matched) throw new KernelInvariantError([`artifact path is not active: ${path}`]);
170
+ return `docs/${matched[1]}/archive/${matched[2]}`;
171
+ }
172
+
173
+ function boundSpecPath(intent: TaskIntentV1): string | undefined {
174
+ const candidates = intent.scope_hint.filter(
175
+ (path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path) && intent.scope_hint.includes(archivePath(path)),
176
+ );
177
+ if (candidates.length > 1)
178
+ throw new KernelInvariantError([`artifact transition requires at most one scope-bound Spec; found ${candidates.length}`]);
179
+ return candidates[0];
180
+ }
181
+
182
+ function readBoundActiveSpec(
183
+ root: string,
184
+ intent: TaskIntentV1,
185
+ required = true,
186
+ ): { path: string; content: string } | undefined {
187
+ const specPath = boundSpecPath(intent);
188
+ if (!specPath) {
189
+ if (required) throw new KernelInvariantError(["artifact freeze requires one scope-bound active Spec"]);
190
+ return undefined;
191
+ }
192
+ try {
193
+ return { path: specPath, content: readSecureProjectFile(root, specPath) };
194
+ } catch (error) {
195
+ if (error instanceof Error && error.message.startsWith("source_missing:") && !required) return undefined;
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ function transitionFor(
201
+ root: string,
202
+ record: { intent_ref: { path: string }; intent_snapshot: TaskIntentV1; artifact_state: "active" | "frozen" },
203
+ direction: "freeze" | "restore",
204
+ allowIntentOnly = false,
205
+ ) {
206
+ const activeIntent = `docs/plans/${record.intent_snapshot.task_id}.intent.json`;
207
+ const frozenIntent = archivePath(activeIntent);
208
+ if (direction === "freeze") {
209
+ if (record.intent_ref.path !== activeIntent)
210
+ throw new KernelInvariantError(["artifact freeze requires the active intent path"]);
211
+ const spec = readBoundActiveSpec(root, record.intent_snapshot, !allowIntentOnly);
212
+ const intentContent = readSecureProjectFile(root, activeIntent);
213
+ return {
214
+ relocations: [
215
+ { from_path: activeIntent, to_path: frozenIntent, content_hash: revisionForContent(intentContent) },
216
+ ...(spec ? [{ from_path: spec.path, to_path: archivePath(spec.path), content_hash: revisionForContent(spec.content) }] : []),
217
+ ],
218
+ next_intent_path: frozenIntent,
219
+ next_artifact_state: "frozen" as const,
220
+ };
221
+ }
222
+ if (record.intent_ref.path !== frozenIntent || record.artifact_state !== "frozen")
223
+ throw new KernelInvariantError(["artifact restore requires a frozen TaskRecord"]);
224
+ const specPath = boundSpecPath(record.intent_snapshot);
225
+ return {
226
+ relocations: [
227
+ { from_path: frozenIntent, to_path: activeIntent, content_hash: revisionForContent(readSecureProjectFile(root, frozenIntent)) },
228
+ ...(specPath ? [{ from_path: archivePath(specPath), to_path: specPath, content_hash: revisionForContent(readSecureProjectFile(root, archivePath(specPath))) }] : []),
229
+ ],
230
+ next_intent_path: activeIntent,
231
+ next_artifact_state: "active" as const,
232
+ };
233
+ }
234
+
235
+ export function createCanaryApplication(
236
+ registry: MutationAuthorityRegistry,
237
+ ): CanaryApplication {
238
+ function freezeArtifacts(input: CanaryExecuteInput): StoredTaskMutationV3 {
239
+ return withKernelStoreLock(input.root, () => {
240
+ const current = readTaskRecordRaw(input.root, input.task_id);
241
+ if (!current.record)
242
+ throw new KernelInvariantError([`task ${input.task_id} has no TaskRecord v3`]);
243
+ const workspace = readWorkspaceStateRaw(input.root);
244
+ if (current.record.artifact_state === "frozen")
245
+ return { revision: current.revision, record: current.record, workspace };
246
+ if (current.record.lifecycle !== "active")
247
+ throw new KernelInvariantError([`artifact freeze requires active lifecycle, got ${current.record.lifecycle}`]);
248
+ if (workspace.state.current_working !== input.task_id)
249
+ throw new KernelInvariantError([`workspace is not owned by task ${input.task_id}`]);
250
+ const fresh = readTaskIntent(input.root, input.task_id, current.record.intent_ref.path);
251
+ const pair = inspectIntentTokenPair(input.prior_intent_token, fresh.token);
252
+ if (
253
+ pair.prior.intent_content_hash !== current.record.intent_ref.content_hash
254
+ || pair.current.intent_content_hash !== current.record.intent_ref.content_hash
255
+ || pair.prior.sidecar_path !== current.record.intent_ref.path
256
+ || pair.current.sidecar_path !== current.record.intent_ref.path
257
+ || pair.prior.path_dev !== pair.current.path_dev
258
+ || pair.prior.path_ino !== pair.current.path_ino
259
+ || pair.prior.fd_dev !== pair.current.fd_dev
260
+ || pair.prior.fd_ino !== pair.current.fd_ino
261
+ )
262
+ throw new KernelInvariantError(["intent token does not bind the active freeze artifact"]);
263
+ const transition = transitionFor(input.root, current.record, "freeze");
264
+ consumeIntentToken(input.prior_intent_token);
265
+ consumeIntentToken(fresh.token);
266
+ const at = input.now ?? new Date().toISOString();
267
+ const nextRecord = parseTaskRecordV3({
268
+ ...current.record,
269
+ intent_ref: { ...current.record.intent_ref, path: transition.next_intent_path },
270
+ artifact_state: transition.next_artifact_state,
271
+ history: [
272
+ ...current.record.history,
273
+ {
274
+ id: `freeze_artifacts:${input.task_id}:${at}`,
275
+ at,
276
+ type: "freeze_artifacts",
277
+ from_state: "active:active",
278
+ to_state: "active:frozen",
279
+ reason: `TaskIntent and Spec frozen at ${transition.next_intent_path}`,
280
+ },
281
+ ],
282
+ });
283
+ return commitTaskRecordLocked(
284
+ input.root,
285
+ input.task_id,
286
+ current.revision,
287
+ nextRecord,
288
+ workspace.revision,
289
+ workspace.state,
290
+ transition.relocations,
291
+ );
292
+ });
293
+ }
294
+
295
+ function execute(input: CanaryExecuteInput): StoredTaskMutationV3 {
296
+ if (input.operation.op === "freeze_artifacts") return freezeArtifacts(input);
297
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.task_id))
298
+ throw new KernelInvariantError(["task id is not a safe file identity"]);
299
+ const now = input.now ?? new Date().toISOString();
300
+ const operation = input.operation;
301
+ const at = now;
302
+ // Preflight snapshot inside the store lock derives the exact current
303
+ // identities; authoritative CAS revalidation still happens inside the
304
+ // locked application port, so any concurrent change fails closed.
305
+ const snapshot = withKernelStoreLock(input.root, () => {
306
+ const current = readTaskRecordRaw(input.root, input.task_id);
307
+ if (!current.record)
308
+ throw new KernelInvariantError([
309
+ `task ${input.task_id} has no TaskRecord v2`,
310
+ ]);
311
+ const workspace = readWorkspaceStateRaw(input.root);
312
+ return {
313
+ record_revision: current.revision,
314
+ workspace_revision: workspace.revision,
315
+ intent_revision: current.record.intent_snapshot.revision,
316
+ intent_content_hash: current.record.intent_ref.content_hash,
317
+ intent_snapshot: current.record.intent_snapshot,
318
+ record: current.record,
319
+ };
320
+ });
321
+ const diffHash = input.diffProvider(input.root, snapshot.intent_snapshot);
322
+ if (operation.op === "stop" && !("capability" in operation))
323
+ throw new KernelInvariantError(["stop requires user authority capability"]);
324
+ const hasBoundSpec = snapshot.intent_snapshot.scope_hint.some(
325
+ (path) => /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/.test(path)
326
+ && snapshot.intent_snapshot.scope_hint.includes(archivePath(path)),
327
+ );
328
+ if (operation.op === "complete" && hasBoundSpec && snapshot.record.artifact_state !== "frozen")
329
+ throw new KernelInvariantError(["complete requires frozen planning artifacts"]);
330
+ const artifactTransition = snapshot.record.artifact_state === "frozen"
331
+ && (
332
+ operation.op === "request_rework"
333
+ || operation.op === "approve_breaking_intent_revision"
334
+ )
335
+ ? transitionFor(input.root, snapshot.record, "restore")
336
+ : operation.op === "stop" && snapshot.record.artifact_state !== "frozen"
337
+ ? transitionFor(input.root, snapshot.record, "freeze", true)
338
+ : undefined;
339
+ const event_id = `${operation.op}:${input.task_id}:${at}`;
340
+ const base = {
341
+ event_id,
342
+ at,
343
+ actor_id: operation.actor_id,
344
+ expected_record_hash: snapshot.record_revision,
345
+ expected_workspace_hash: snapshot.workspace_revision,
346
+ diff_hash: diffHash,
347
+ };
348
+ let action: unknown;
349
+ let capability: object | undefined;
350
+ switch (operation.op) {
351
+ case "record_finding":
352
+ action = {
353
+ ...base,
354
+ type: "record_finding",
355
+ finding: {
356
+ ...operation.finding,
357
+ status: "open",
358
+ source: operation.finding.kind === "unresolved_user_decision" ? "kernel" : "execution",
359
+ review_round: null,
360
+ },
361
+ };
362
+ break;
363
+ case "resolve_finding":
364
+ action = { ...base, type: "resolve_finding", finding_id: operation.finding_id };
365
+ break;
366
+ case "request_rework":
367
+ capability = operation.capability;
368
+ action = { ...base, type: "request_rework", findings: operation.findings };
369
+ break;
370
+ case "record_approval":
371
+ capability = operation.capability;
372
+ action = { ...base, type: "record_approval", approval: operation.approval };
373
+ break;
374
+ case "record_user_approval":
375
+ capability = operation.capability;
376
+ action = { ...base, type: "record_user_approval", approval: operation.approval };
377
+ break;
378
+ case "revise_intent":
379
+ action = {
380
+ ...base,
381
+ type: "revise_intent",
382
+ next_intent: operation.next_intent,
383
+ next_intent_ref: {
384
+ path: `docs/plans/${operation.next_intent.task_id}.intent.json`,
385
+ content_hash: canonicalIntentHash(operation.next_intent),
386
+ },
387
+ };
388
+ break;
389
+ case "approve_breaking_intent_revision":
390
+ capability = operation.capability;
391
+ action = {
392
+ ...base,
393
+ type: "approve_breaking_intent_revision",
394
+ next_intent: operation.next_intent,
395
+ next_intent_ref: {
396
+ path: `docs/plans/${operation.next_intent.task_id}.intent.json`,
397
+ content_hash: canonicalIntentHash(operation.next_intent),
398
+ },
399
+ };
400
+ break;
401
+ case "complete":
402
+ action = { ...base, type: "complete" };
403
+ break;
404
+ case "stop":
405
+ capability = operation.capability;
406
+ action = { ...base, type: "stop", reason: operation.reason };
407
+ break;
408
+ case "resolve_user_decision":
409
+ capability = operation.capability;
410
+ action = {
411
+ ...base,
412
+ type: "resolve_user_decision",
413
+ finding_id: operation.finding_id,
414
+ resolution: operation.resolution,
415
+ };
416
+ break;
417
+ default: {
418
+ const unreachable: never = operation;
419
+ throw new KernelInvariantError([
420
+ `unsupported canary operation: ${String((unreachable as CanaryOperation).op)}`,
421
+ ]);
422
+ }
423
+ }
424
+ return applyTaskAction({
425
+ root: input.root,
426
+ task_id: input.task_id,
427
+ action,
428
+ prior_intent_token: input.prior_intent_token,
429
+ registry,
430
+ capability: capability as never,
431
+ diffProvider: input.diffProvider,
432
+ now: Date.parse(now),
433
+ ...(operation.op === "complete" || operation.op === "stop"
434
+ ? { terminal: { terminalized_at: now } }
435
+ : {}),
436
+ ...(artifactTransition ? { artifact_transition: artifactTransition } : {}),
437
+ });
438
+ }
439
+
440
+ function beginDrain(input: {
441
+ root: string;
442
+ task_id: string;
443
+ capability: object;
444
+ now?: string;
445
+ }): BackendClaim {
446
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.task_id))
447
+ throw new KernelInvariantError(["task id is not a safe file identity"]);
448
+ const now = input.now ?? new Date().toISOString();
449
+ return withKernelStoreLock(input.root, () => {
450
+ const current = readTaskRecordRaw(input.root, input.task_id);
451
+ if (!current.record)
452
+ throw new KernelInvariantError([
453
+ `task ${input.task_id} has no TaskRecord v2`,
454
+ ]);
455
+ const workspace = readWorkspaceStateRaw(input.root);
456
+ const claim = readBackendClaim(input.root);
457
+ if (!claim)
458
+ throw new KernelInvariantError([
459
+ `task ${input.task_id} has no active backend claim`,
460
+ ]);
461
+ if (claim.task_id !== input.task_id)
462
+ throw new KernelInvariantError([
463
+ `backend claim belongs to task ${claim.task_id}, not ${input.task_id}`,
464
+ ]);
465
+ if (claim.lifecycle_status === "draining") {
466
+ // Exact committed drain replay is idempotent: the claim is
467
+ // already draining and the capability is not re-consumed.
468
+ return claim;
469
+ }
470
+ if (claim.lifecycle_status !== "active")
471
+ throw new KernelInvariantError([
472
+ `backend claim is not active for task ${input.task_id}`,
473
+ ]);
474
+ if (current.record.lifecycle !== "active")
475
+ throw new KernelInvariantError([
476
+ `task ${input.task_id} is already terminal; drain is not permitted`,
477
+ ]);
478
+ if (workspace.state.current_working !== input.task_id)
479
+ throw new KernelInvariantError([
480
+ `workspace is not owned by task ${input.task_id}`,
481
+ ]);
482
+ // The capability is bound to the canonical begin_drain action.
483
+ registry.consume(input.capability as never, {
484
+ task_id: input.task_id,
485
+ action: beginDrainCapabilityAction(input.task_id, now),
486
+ expected_record_hash: current.revision,
487
+ intent_revision: current.record.intent_snapshot.revision,
488
+ intent_content_hash: current.record.intent_ref.content_hash,
489
+ diff_hash: "sha256:" + "0".repeat(64),
490
+ }, Date.parse(now));
491
+ const nextClaim: BackendClaim = {
492
+ ...claim,
493
+ lifecycle_status: "draining",
494
+ updated_at: now,
495
+ };
496
+ return commitDrainLocked(
497
+ input.root,
498
+ input.task_id,
499
+ serializeBackendClaim(claim),
500
+ serializeBackendClaim(nextClaim),
501
+ now,
502
+ );
503
+ });
504
+ }
505
+
506
+ return { registry, execute, beginDrain };
507
+ }
@@ -0,0 +1,73 @@
1
+ // v4 storage retirement: canary eligibility is now derived exclusively from
2
+ // Kernel owners. The readiness/evidence pipeline is retired; a task is
3
+ // eligible when its Git-tracked TaskIntent is readable and no Kernel owner
4
+ // (workspace, backend claim, TaskRecord v2, tombstone) blocks enrollment.
5
+
6
+ import type { ReadinessReport } from "./readiness";
7
+ import type { ReadinessEvidenceInput } from "./readiness_evidence";
8
+
9
+ export type WaivableGate = "observation_window_days";
10
+
11
+ export interface CanaryWaiver {
12
+ gate: WaivableGate;
13
+ task_id: string;
14
+ reason: string;
15
+ actor: string;
16
+ confirmation_ref: string;
17
+ expires_at: string;
18
+ nonce: string;
19
+ }
20
+
21
+ export interface CanaryTaskIdentity {
22
+ id: string;
23
+ intent_path: string;
24
+ intent_revision: number;
25
+ intent_content_hash: string;
26
+ }
27
+
28
+ export interface CanaryEligibilityInput {
29
+ readiness?: ReadinessReport;
30
+ evidence?: ReadinessEvidenceInput;
31
+ task: CanaryTaskIdentity;
32
+ waiver?: CanaryWaiver;
33
+ now: string;
34
+ }
35
+
36
+ export interface CanaryEligibilityResult {
37
+ eligible: boolean;
38
+ waived_gates: WaivableGate[];
39
+ unmet_non_waivable: string[];
40
+ rejections: string[];
41
+ }
42
+
43
+ export function evaluateCanaryEligibility(input: CanaryEligibilityInput): CanaryEligibilityResult {
44
+ const result: CanaryEligibilityResult = {
45
+ eligible: false,
46
+ waived_gates: [],
47
+ unmet_non_waivable: [],
48
+ rejections: [],
49
+ };
50
+ if (!input.task || !input.task.id || !input.task.intent_path) {
51
+ result.rejections.push("task identity required");
52
+ return result;
53
+ }
54
+ // A waiver is never honored after v4 storage retirement: no readiness
55
+ // window exists to waive. Any waiver passed by a caller is rejected.
56
+ if (input.waiver) result.rejections.push("waiver route retired");
57
+ // Legacy readiness/evidence inputs are ignored (never authority); they
58
+ // are accepted for caller compatibility only when absent or syntactically
59
+ // valid, and never influence eligibility.
60
+ if (
61
+ input.readiness &&
62
+ (input.readiness.status !== "candidate" &&
63
+ input.readiness.status !== "collecting" &&
64
+ input.readiness.status !== "blocked")
65
+ ) {
66
+ result.rejections.push("readiness status invalid");
67
+ }
68
+ if (input.evidence && !["valid", "missing", "invalid"].includes(input.evidence.status)) {
69
+ result.rejections.push("evidence status invalid");
70
+ }
71
+ result.eligible = result.rejections.length === 0;
72
+ return result;
73
+ }