immune-brain 3.6.9 → 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 (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 +27 -25
  13. package/plugins/immune-brain/dist/imm-planner.md +34 -27
  14. package/plugins/immune-brain/dist/imm-review-retro.md +2 -2
  15. package/plugins/immune-brain/dist/role-prompts/code-review.md +3 -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 +3 -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
@@ -0,0 +1,189 @@
1
+ // One frozen table is the only producer of batch-gate reason and recovery prose.
2
+ // Both Host adapters render from it, so a condition reads identically on either
3
+ // Host by construction rather than because two copies still agree. The table is
4
+ // Host-neutral: it names no Host, no SDK, and no transport.
5
+ //
6
+ // Deliberately NOT in this table: a Host's own transport prose, which names that
7
+ // Host's interaction form — Pi's interactive-TUI refusal and its missing-port
8
+ // message, Claude's MCP elicitation refusal — and a transport error's own
9
+ // `message`/`recoveryAction`, which the transport owns. Those stay in the
10
+ // adapter that owns the transport.
11
+
12
+ export type BatchReasonKey =
13
+ | "invalid_slug"
14
+ | "batch_state_unreadable"
15
+ | "claim_already_active"
16
+ | "git_head_unreadable"
17
+ | "branch_already_exists"
18
+ | "git_status_unreadable"
19
+ | "working_tree_dirty"
20
+ | "authorized_scope_underivable"
21
+ | "working_tree_unstaged"
22
+ | "working_tree_out_of_scope"
23
+ | "empty_enrollable_set"
24
+ | "plan_projection_failed"
25
+ | "confirmation_port_unavailable"
26
+ | "confirmation_timed_out"
27
+ | "confirmation_cancelled"
28
+ | "confirmation_declined"
29
+ | "confirmation_no_decision"
30
+ | "confirmation_failed"
31
+ | "claim_appeared_during_confirmation"
32
+ | "plan_became_unreadable"
33
+ | "plan_changed"
34
+ | "repository_became_unreadable"
35
+ | "head_moved"
36
+ | "cancelled_before_execution"
37
+ | "batch_run_rejected";
38
+
39
+ export interface BatchReasonSpec {
40
+ readonly state: "rejected" | "cancelled" | "blocked";
41
+ /** A fixed sentence, or a template given the condition's own detail. */
42
+ readonly reason: string | ((detail: string) => string);
43
+ readonly recovery_action: string;
44
+ }
45
+
46
+ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> = Object.freeze({
47
+ invalid_slug: {
48
+ state: "rejected",
49
+ reason: (detail: string) => `invalid initiative slug: ${detail}`,
50
+ recovery_action: "specify a valid initiative slug and retry in the current Host",
51
+ },
52
+ batch_state_unreadable: {
53
+ state: "blocked",
54
+ reason: (detail: string) => `batch run state is unreadable or invalid: ${detail}`,
55
+ recovery_action: "resolve or remove the invalid batch state file, then retry in the current Host",
56
+ },
57
+ claim_already_active: {
58
+ state: "blocked",
59
+ reason: (detail: string) => `an active workspace claim already exists for task: ${detail}`,
60
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
61
+ },
62
+ git_head_unreadable: {
63
+ state: "rejected",
64
+ reason: (detail: string) => detail,
65
+ recovery_action: "commit working changes and ensure a committed Git HEAD exists in the current Host",
66
+ },
67
+ branch_already_exists: {
68
+ state: "rejected",
69
+ reason: (detail: string) => `branch preflight failed: branch refs/heads/${detail} already exists`,
70
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
71
+ },
72
+ git_status_unreadable: {
73
+ state: "rejected",
74
+ reason: "branch preflight failed: git status is unreadable",
75
+ recovery_action: "check the repository integrity and retry in the current Host",
76
+ },
77
+ working_tree_dirty: {
78
+ state: "rejected",
79
+ reason: "branch preflight failed: working tree is dirty",
80
+ recovery_action: "delete or rename the conflicting branch, or commit working changes in the current Host",
81
+ },
82
+ authorized_scope_underivable: {
83
+ state: "rejected",
84
+ reason: "branch preflight failed: cannot derive the in-flight child's authorized scope",
85
+ recovery_action: "resolve the child's intent record, then retry in the current Host",
86
+ },
87
+ working_tree_unstaged: {
88
+ state: "rejected",
89
+ reason: "branch preflight failed: working tree has unstaged or untracked changes",
90
+ recovery_action: "stage the in-flight changes with git add, then retry in the current Host",
91
+ },
92
+ working_tree_out_of_scope: {
93
+ state: "rejected",
94
+ reason: "branch preflight failed: working tree has changes outside the authorized child scope",
95
+ recovery_action: "commit or unstage changes outside the active task scope, then retry in the current Host",
96
+ },
97
+ empty_enrollable_set: {
98
+ state: "rejected",
99
+ reason: "empty enrollable child set: no enrollable child tasks found in the initiative plan",
100
+ recovery_action: "ensure the initiative has uncompleted, non-critical child tasks in the current Host",
101
+ },
102
+ plan_projection_failed: {
103
+ state: "rejected",
104
+ reason: (detail: string) => `failed to project batch plan: ${detail}`,
105
+ recovery_action: "review initiative issues and planning sidecars in the current Host",
106
+ },
107
+ confirmation_port_unavailable: {
108
+ state: "rejected",
109
+ reason: "native confirmation port is unavailable",
110
+ recovery_action: "retry through a fresh native gate in the current Host",
111
+ },
112
+ confirmation_timed_out: {
113
+ state: "rejected",
114
+ reason: "native confirmation timed out waiting for user interaction",
115
+ recovery_action: "retry through a fresh native gate in the current Host",
116
+ },
117
+ confirmation_cancelled: {
118
+ state: "cancelled",
119
+ reason: "native interaction cancelled",
120
+ recovery_action: "wait for a fresh literal-user request",
121
+ },
122
+ confirmation_declined: {
123
+ state: "rejected",
124
+ reason: "native interaction declined",
125
+ recovery_action: "wait for a fresh literal-user request",
126
+ },
127
+ confirmation_no_decision: {
128
+ state: "rejected",
129
+ reason: "native interaction returned no decision",
130
+ recovery_action: "retry through a fresh native gate in the current Host",
131
+ },
132
+ confirmation_failed: {
133
+ state: "rejected",
134
+ reason: (detail: string) => detail,
135
+ recovery_action: "retry through a fresh native gate in the current Host",
136
+ },
137
+ claim_appeared_during_confirmation: {
138
+ state: "blocked",
139
+ reason: (detail: string) => `an active workspace claim appeared during confirmation for task: ${detail}`,
140
+ recovery_action: "resolve or stop the active task before starting a batch in the current Host",
141
+ },
142
+ plan_became_unreadable: {
143
+ state: "rejected",
144
+ reason: "batch plan became unreadable after native confirmation",
145
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
146
+ },
147
+ plan_changed: {
148
+ state: "rejected",
149
+ reason: "batch plan changed after native confirmation",
150
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
151
+ },
152
+ repository_became_unreadable: {
153
+ state: "rejected",
154
+ reason: "Git repository became unreadable after native confirmation",
155
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
156
+ },
157
+ head_moved: {
158
+ state: "rejected",
159
+ reason: "Git HEAD moved after native confirmation",
160
+ recovery_action: "review the current workspace and retry through a fresh native gate in the current Host",
161
+ },
162
+ cancelled_before_execution: {
163
+ state: "cancelled",
164
+ reason: "user cancelled before batch execution",
165
+ recovery_action: "wait for a fresh literal-user request",
166
+ },
167
+ batch_run_rejected: {
168
+ state: "rejected",
169
+ // The runner owns the specific reason; the fallback is this entry's own text.
170
+ reason: (detail: string) => detail || "batch run rejected",
171
+ recovery_action: "delete or rename the conflicting branch, or commit working changes and retry in the current Host",
172
+ },
173
+ });
174
+
175
+ export interface BatchReason {
176
+ state: "rejected" | "cancelled" | "blocked";
177
+ reason: string;
178
+ recovery_action: string;
179
+ }
180
+
181
+ /** Resolve one key into the envelope fields both Hosts return. */
182
+ export function batchReason(key: BatchReasonKey, detail = ""): BatchReason {
183
+ const spec = BATCH_REASONS[key];
184
+ return {
185
+ state: spec.state,
186
+ reason: typeof spec.reason === "function" ? spec.reason(detail) : spec.reason,
187
+ recovery_action: spec.recovery_action,
188
+ };
189
+ }
@@ -377,6 +377,41 @@ function validateRunAuthorization(input: StartBatchInput, existing: BatchRunStat
377
377
  }
378
378
 
379
379
  export async function startBatch(input: StartBatchInput): Promise<BatchRunReport> {
380
+ try {
381
+ return await startBatchLocked(input);
382
+ } catch (error) {
383
+ const message = error instanceof Error ? error.message : String(error);
384
+ if (!/retired file-store|kernel store|CAS mismatch|store is busy|locked/i.test(message))
385
+ throw error;
386
+ // A store condition the run cannot resolve stops the batch with one
387
+ // explicit recovery action instead of escaping as an unstructured crash.
388
+ // The rejected report keeps whatever this batch already persisted — a run
389
+ // resumed after partial progress reports its children and commits rather
390
+ // than an empty plan — and only a batch with no durable state reports the
391
+ // empty plan it validated.
392
+ return rejectionReport(input, message);
393
+ }
394
+ }
395
+
396
+ function rejectionReport(input: StartBatchInput, reason: string): BatchRunReport {
397
+ const persisted = (() => {
398
+ try {
399
+ return readBatchRunState(input.root, input.batch_id);
400
+ } catch {
401
+ return null;
402
+ }
403
+ })();
404
+ return reportFor(
405
+ {
406
+ ...(persisted ?? prepareBatchRunState({ ...input, children: [], now: input.now })),
407
+ batch_state: "rejected",
408
+ },
409
+ reason,
410
+ "settle the reported kernel store condition and retry in the current Host",
411
+ );
412
+ }
413
+
414
+ async function startBatchLocked(input: StartBatchInput): Promise<BatchRunReport> {
380
415
  // Validation failure before the first enrollment: zero writes, rejected.
381
416
  // reportFor only builds the report object; finalize would persist it and
382
417
  // the spec forbids any write on a pre-enrollment rejection.
@@ -0,0 +1,33 @@
1
+ // A native confirmation is bounded on both Hosts. Claude bounded its MCP
2
+ // elicitation with IMMUNE_BRAIN_BATCH_TIMEOUT_MS and Pi waited indefinitely; the
3
+ // bound, its default, and the "this deadline expired rather than the user
4
+ // cancelling" distinction are shared here. Each Host still owns its transport:
5
+ // this module only produces the signal to hand it and reports expiry.
6
+
7
+ export const CONFIRMATION_TIMEOUT_DEFAULT_MS = 60_000;
8
+ export const CONFIRMATION_TIMEOUT_ENV = "IMMUNE_BRAIN_BATCH_TIMEOUT_MS";
9
+
10
+ export interface ConfirmationDeadline {
11
+ /** The caller's signal, combined with this deadline. */
12
+ signal: AbortSignal;
13
+ /** True when this deadline fired and the caller's own signal did not. */
14
+ timedOut(): boolean;
15
+ clear(): void;
16
+ }
17
+
18
+ export function startConfirmationDeadline(input: {
19
+ env?: Record<string, string | undefined>;
20
+ signal?: AbortSignal;
21
+ }): ConfirmationDeadline {
22
+ const configured = Number(input.env?.[CONFIRMATION_TIMEOUT_ENV]);
23
+ const timeoutMs = Number.isFinite(configured) && configured > 0 ? configured : CONFIRMATION_TIMEOUT_DEFAULT_MS;
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => {
26
+ controller.abort(new Error("native confirmation timed out waiting for user interaction"));
27
+ }, timeoutMs);
28
+ return {
29
+ signal: input.signal ? AbortSignal.any([input.signal, controller.signal]) : controller.signal,
30
+ timedOut: () => controller.signal.aborted && !input.signal?.aborted,
31
+ clear: () => clearTimeout(timer),
32
+ };
33
+ }
@@ -27,12 +27,25 @@ export type BatchPlanChildStatus =
27
27
  | "needs_human"
28
28
  | "blocked";
29
29
 
30
+ /**
31
+ * Stable per-child exclusion reasons a Host renders verbatim. The Spec-binding
32
+ * reasons mirror the shared enrollment precondition, and the incomplete form
33
+ * names every path the TaskIntent has to add.
34
+ */
35
+ export type BatchPlanChildReason =
36
+ | "critical"
37
+ | "invalid_intent"
38
+ | "dependency_unavailable"
39
+ | "spec_binding_missing"
40
+ | "spec_binding_ambiguous"
41
+ | `spec_binding_incomplete: ${string}`;
42
+
30
43
  export interface BatchPlanChild {
31
44
  task_id: string;
32
45
  slice_id: string;
33
46
  blocked_by: string[];
34
47
  status: BatchPlanChildStatus;
35
- reason: "critical" | "invalid_intent" | "dependency_unavailable" | null;
48
+ reason: BatchPlanChildReason | null;
36
49
  intent_path: string | null;
37
50
  intent_revision: number | null;
38
51
  intent_content_hash: string | null;
@@ -10,8 +10,12 @@
10
10
  * - `imm-plan --routing-status --json` (strict Git-owned route projection)
11
11
  * - `imm-plan <plan-path> [--json]` (read-only Plan validation)
12
12
  * - `imm-tracker` (opt-in, one-way, non-authoritative GitHub Issue projection)
13
- * - a stable `drain_required` / `v3_storage_retired` wall for every v3
14
- * mutating command (work/review/migrate/finish/autowork/heal/...).
13
+ *
14
+ * Every retired v3 mutating command (`imm-work`, `imm-review`, `imm-migrate`,
15
+ * `imm-finish`, `imm-autowork`, `imm-heal`, `imm-check-child-output`,
16
+ * `imm-retire-stale-wrapper`) and its `bin/` wrapper is fully removed: the name
17
+ * falls through to the generic unknown-command response instead of a
18
+ * per-command diagnostic. Only the retired *option* wall on `imm-plan` remains.
15
19
  *
16
20
  * v3 State Ledger mutations, migrations, authority receipts, automatic
17
21
  * observations, and TaskRecord v1 writers are NOT reachable from any shipped
@@ -33,21 +37,8 @@ import {
33
37
  } from "./plan_core";
34
38
  import { runGithubTrackerCli } from "./github_issue_tracker";
35
39
 
36
- // Retired v3 mutating command wall. Read-only v3 commands that only project
37
- // state (imm-plan validate) stay available; every writer is retired.
38
- const RETIRED_MUTATING_COMMANDS = new Set([
39
- "imm-work",
40
- "imm-review",
41
- "imm-migrate",
42
- "imm-finish",
43
- "imm-autowork",
44
- "imm-heal",
45
- "imm-check-child-output",
46
- "imm-retire-stale-wrapper",
47
- ]);
48
-
49
- const READ_ONLY_V3_COMMANDS = new Set(["imm-plan"]);
50
-
40
+ // The retired *option* wall on imm-plan: the command stays available for
41
+ // read-only validation, while its v3 mutating options keep this rejection.
51
42
  const RETIRED_PLAN_OPTIONS = new Set([
52
43
  "--sync",
53
44
  "--terminate-current",
@@ -85,7 +76,10 @@ function unavailableRoutingProjection(): RoutingPolicyProjection {
85
76
  };
86
77
  }
87
78
 
88
- function retiredResponse(command: string, args: string[], root: string): {
79
+ // The read-only v3 option wall: retired plan options keep this exact
80
+ // rejection, so the message shape is deliberately not a parameter of the
81
+ // caller's option name or arguments.
82
+ function retiredPlanOptionResponse(root: string): {
89
83
  stdout: string;
90
84
  stderr: string;
91
85
  returncode: number;
@@ -124,7 +118,7 @@ function runPlanCli(args: string[], root: string): {
124
118
  stderr: string;
125
119
  returncode: number;
126
120
  } {
127
- if (hasRetiredPlanOption(args)) return retiredResponse("imm-plan", args, root);
121
+ if (hasRetiredPlanOption(args)) return retiredPlanOptionResponse(root);
128
122
  if (
129
123
  args.length === 2 &&
130
124
  args[0] === "--routing-status" &&
@@ -182,6 +176,10 @@ async function runKernelCli(args: string[], root: string): Promise<{
182
176
  // status --json, and the explicit audit command. All other kernel
183
177
  // subcommands (readiness, journal, migrate) are retired.
184
178
  const sub = args[0] ?? "";
179
+ // The explicit claimless storage-layout migration is reachable here: a
180
+ // worktree still on the retired file store has no other entry point, and
181
+ // every mutating subcommand stays fail-closed behind it.
182
+ if (sub === "migrate") return runKernelCommand(args, root);
185
183
  if (sub === "intent") return runKernelCommand(args, root);
186
184
  if (sub === "status" && args.includes("--json")) return runKernelCommand(args, root);
187
185
  if (sub === "inspect" && args.includes("--json")) return runKernelCommand(args, root);
@@ -206,7 +204,7 @@ async function runKernelCli(args: string[], root: string): Promise<{
206
204
  }
207
205
  return {
208
206
  stdout: "",
209
- stderr: "invalid_kernel_command: imm-kernel supports intent author|validate, status --json, inspect --json, and audit --legacy only\n",
207
+ stderr: "invalid_kernel_command: imm-kernel supports intent author|validate, migrate --storage-layout, status --json, inspect --json, and audit --legacy only\n",
210
208
  returncode: 2,
211
209
  };
212
210
  }
@@ -219,7 +217,6 @@ async function runCli(command: string, args: string[], root: string): Promise<{
219
217
  if (command === "imm-kernel") return runKernelCli(args, root);
220
218
  if (command === "imm-plan") return runPlanCli(args, root);
221
219
  if (command === "imm-tracker") return runGithubTrackerCli(args, root);
222
- if (RETIRED_MUTATING_COMMANDS.has(command)) return retiredResponse(command, args, root);
223
220
  return {
224
221
  stdout: "",
225
222
  stderr: `Unknown Immune-Brain v4 command: ${command}\n`,
@@ -268,7 +265,6 @@ async function main(argv: string[]): Promise<number> {
268
265
  ],
269
266
  },
270
267
  ],
271
- retired: [...RETIRED_MUTATING_COMMANDS].sort(),
272
268
  },
273
269
  null,
274
270
  2,
@@ -294,4 +290,4 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
294
290
  process.exit(code);
295
291
  }
296
292
 
297
- export { main, runCli, runKernelCli, runGithubTrackerCli, RETIRED_MUTATING_COMMANDS };
293
+ export { main, runCli, runKernelCli, runGithubTrackerCli };
@@ -1,50 +1,34 @@
1
- // Shared verification_descriptor/v1 pure parser.
2
- //
3
- // The complete `acceptance[].verification` string of a TaskIntent is accepted
4
- // only as strict canonical JSON for `assurance_kernel/verification_descriptor/v1`.
5
- // Never execute free text, an executable path, a shell string, PATH lookup,
6
- // environment overrides, or a cwd outside the repository. The production runner
7
- // registry contains only the host-resolved `bun` runner.
8
- //
9
- // This module is the single parser implementation for the wire contract. Pi
10
- // assurance (`.pi-extension/pi-canary-verification.ts`) re-exports it and keeps
11
- // runner resolution/execution extension-owned. Kernel intent author/validate
12
- // consume the same implementation, so the two consumers cannot drift.
13
- //
14
- // JSON whitespace and key ordering are NOT eligibility conditions: parsing is
15
- // whitespace/order-insensitive. `canonicalDescriptorBytes` produces the
16
- // deterministic bytes that Planner authoring binds.
1
+ // One host-neutral parser for project-owned verification. Historical TaskRecords
2
+ // retain their verification strings; only v2 descriptors are executable.
3
+ import { isAbsolute } from "node:path";
17
4
 
18
- import { isAbsolute, sep } from "node:path";
19
-
20
- export const VERIFICATION_DESCRIPTOR_CONTRACT =
21
- "assurance_kernel/verification_descriptor/v1" as const;
5
+ export const VERIFICATION_DESCRIPTOR_CONTRACT = "assurance_kernel/verification_descriptor/v2" as const;
6
+ export const VERIFICATION_DESCRIPTOR_BOUNDS = {
7
+ max_arg_tokens: 64,
8
+ max_arg_token_bytes: 512,
9
+ max_cwd_depth: 32,
10
+ max_timeout_ms: 600_000,
11
+ max_output_bytes: 262_144,
12
+ max_descriptor_bytes: 65_536,
13
+ max_writable_paths: 32,
14
+ } as const;
22
15
 
23
- export interface VerificationDescriptor {
24
- contract: typeof VERIFICATION_DESCRIPTOR_CONTRACT;
25
- runner_id: "bun";
26
- runner_version: string;
16
+ export interface VerificationCommand {
17
+ executable: string;
27
18
  argv: string[];
28
19
  cwd: string;
29
20
  timeout_ms: number;
30
21
  max_output_bytes: number;
31
22
  }
32
-
33
- const DESCRIPTOR_FIELDS = [
34
- "contract",
35
- "runner_id",
36
- "runner_version",
37
- "argv",
38
- "cwd",
39
- "timeout_ms",
40
- "max_output_bytes",
41
- ] as const;
42
-
43
- const MAX_ARGV_TOKENS = 64;
44
- const MAX_ARGV_TOKEN_BYTES = 512;
45
- const MAX_CWD_DEPTH = 32;
46
- const MAX_TIMEOUT_MS = 600_000;
47
- const MAX_OUTPUT_BYTES = 262_144;
23
+ export interface VerificationEnvironment {
24
+ prepare: VerificationCommand | null;
25
+ writable_paths: string[];
26
+ }
27
+ export interface VerificationDescriptor {
28
+ contract: typeof VERIFICATION_DESCRIPTOR_CONTRACT;
29
+ command: VerificationCommand;
30
+ environment: VerificationEnvironment;
31
+ }
48
32
 
49
33
  export class VerificationDescriptorError extends Error {
50
34
  constructor(message: string) {
@@ -53,110 +37,82 @@ export class VerificationDescriptorError extends Error {
53
37
  }
54
38
  }
55
39
 
56
- /** Parse the complete verification string as strict canonical JSON. */
57
- export function parseVerificationDescriptor(text: string): VerificationDescriptor {
58
- const trimmed = text.trim();
59
- if (!trimmed) throw new VerificationDescriptorError("verification string is empty");
60
- let raw: Record<string, unknown>;
61
- try {
62
- raw = JSON.parse(trimmed) as Record<string, unknown>;
63
- } catch {
64
- throw new VerificationDescriptorError("verification string is not valid JSON");
40
+ function object(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ throw new VerificationDescriptorError(`${label} must be an object`);
43
+ const raw = value as Record<string, unknown>;
44
+ if (Object.keys(raw).some(key => !fields.includes(key)))
45
+ throw new VerificationDescriptorError(`${label} has an unknown field`);
46
+ return raw;
47
+ }
48
+
49
+ export function verificationRelativePath(value: unknown, label: string): string {
50
+ if (typeof value !== "string" || !value || value.length > 512 || /[\x00-\x1f\x7f\\]/.test(value)
51
+ || isAbsolute(value) || value.startsWith("~") || value.split("/").includes("..")
52
+ || value.split("/").includes(".git") || value.split("/").length > VERIFICATION_DESCRIPTOR_BOUNDS.max_cwd_depth)
53
+ throw new VerificationDescriptorError(`${label} must stay inside the repository`);
54
+ return value.split("/").filter(part => part && part !== ".").join("/") || ".";
55
+ }
56
+
57
+ function bound(value: unknown, max: number, label: string): number {
58
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max)
59
+ throw new VerificationDescriptorError(`${label} exceeds the host bound`);
60
+ return value;
61
+ }
62
+
63
+ function command(value: unknown): VerificationCommand {
64
+ const raw = object(value, ["executable", "argv", "cwd", "timeout_ms", "max_output_bytes"], "verification command");
65
+ if (typeof raw.executable !== "string") throw new VerificationDescriptorError("verification executable is invalid");
66
+ let executable: string = raw.executable;
67
+ if (executable.startsWith("./")) {
68
+ const path = verificationRelativePath(executable, "verification executable");
69
+ if (path === ".") throw new VerificationDescriptorError("verification executable must be a file");
70
+ executable = `./${path}`;
71
+ } else if (!/^[A-Za-z0-9_][A-Za-z0-9_.+-]{0,127}$/.test(executable)) {
72
+ throw new VerificationDescriptorError("verification executable must be a host tool name or ./project-file");
73
+ }
74
+ if (!Array.isArray(raw.argv) || raw.argv.length > VERIFICATION_DESCRIPTOR_BOUNDS.max_arg_tokens)
75
+ throw new VerificationDescriptorError("verification argv must be a bounded array");
76
+ for (const arg of raw.argv) {
77
+ if (typeof arg !== "string" || Buffer.byteLength(arg) > VERIFICATION_DESCRIPTOR_BOUNDS.max_arg_token_bytes || /[\x00-\x1f\x7f]/.test(arg))
78
+ throw new VerificationDescriptorError("verification argv must contain bounded literal strings");
65
79
  }
66
- const unknown = Object.keys(raw).filter((key) => !DESCRIPTOR_FIELDS.includes(key as never));
67
- if (unknown.length > 0)
68
- throw new VerificationDescriptorError(`verification descriptor has unknown field: ${unknown[0]}`);
80
+ return {
81
+ executable,
82
+ argv: raw.argv as string[],
83
+ cwd: verificationRelativePath(raw.cwd, "verification cwd"),
84
+ timeout_ms: bound(raw.timeout_ms, VERIFICATION_DESCRIPTOR_BOUNDS.max_timeout_ms, "verification timeout_ms"),
85
+ max_output_bytes: bound(raw.max_output_bytes, VERIFICATION_DESCRIPTOR_BOUNDS.max_output_bytes, "verification max_output_bytes"),
86
+ };
87
+ }
88
+
89
+ export function parseVerificationDescriptor(text: string): VerificationDescriptor {
90
+ if (Buffer.byteLength(text) > VERIFICATION_DESCRIPTOR_BOUNDS.max_descriptor_bytes)
91
+ throw new VerificationDescriptorError("verification descriptor exceeds the byte bound");
92
+ let value: unknown;
93
+ try { value = JSON.parse(text); }
94
+ catch { throw new VerificationDescriptorError("verification string is not valid JSON"); }
95
+ if (value && typeof value === "object" && "contract" in value
96
+ && value.contract === "assurance_kernel/verification_descriptor/v1")
97
+ throw new VerificationDescriptorError("verification_contract_migration_required: revise the verification definition to v2 before execution");
98
+ const raw = object(value, ["contract", "command", "environment"], "verification descriptor");
69
99
  if (raw.contract !== VERIFICATION_DESCRIPTOR_CONTRACT)
70
100
  throw new VerificationDescriptorError("verification descriptor contract is invalid");
71
- if (raw.runner_id !== "bun")
72
- throw new VerificationDescriptorError(
73
- `verification runner must be bun; got ${String(raw.runner_id)}`,
74
- );
75
- if (typeof raw.runner_version !== "string" || !raw.runner_version.trim())
76
- throw new VerificationDescriptorError("verification runner_version is invalid");
77
- if (!Array.isArray(raw.argv) || raw.argv.length === 0)
78
- throw new VerificationDescriptorError("verification argv must be a non-empty array");
79
- if (raw.argv.length > MAX_ARGV_TOKENS)
80
- throw new VerificationDescriptorError("verification argv exceeds the token bound");
81
- for (const token of raw.argv) {
82
- if (typeof token !== "string" || !token.trim())
83
- throw new VerificationDescriptorError("verification argv tokens must be non-empty strings");
84
- if (Buffer.byteLength(token) > MAX_ARGV_TOKEN_BYTES)
85
- throw new VerificationDescriptorError("verification argv token exceeds the byte bound");
86
- if (/[\x00-\x1f\x7f]/.test(token))
87
- throw new VerificationDescriptorError("verification argv token contains control characters");
88
- if (
89
- token.includes("..") ||
90
- token.includes("\\") ||
91
- token.startsWith("/") ||
92
- token.startsWith("~") ||
93
- token.includes("$") ||
94
- token.includes(";") ||
95
- token.includes("&") ||
96
- token.includes("|") ||
97
- token.includes(">") ||
98
- token.includes("<") ||
99
- token.includes("`") ||
100
- token.includes("*") ||
101
- token.includes("?") ||
102
- token.includes("[") ||
103
- token.includes("]") ||
104
- token.includes("{") ||
105
- token.includes("}") ||
106
- token.includes("(") ||
107
- token.includes(")") ||
108
- token.includes(" ") ||
109
- token.includes("\t")
110
- )
111
- throw new VerificationDescriptorError(
112
- `verification argv token is not a safe literal: ${token}`,
113
- );
114
- }
115
- if (typeof raw.cwd !== "string" || !raw.cwd.trim())
116
- throw new VerificationDescriptorError("verification cwd is invalid");
117
- if (isAbsolute(raw.cwd) || raw.cwd.includes("\\"))
118
- throw new VerificationDescriptorError("verification cwd must be repository-relative");
119
- if (raw.cwd === ".." || raw.cwd.startsWith(`..${sep}`) || raw.cwd.split(sep).includes(".."))
120
- throw new VerificationDescriptorError("verification cwd escapes the repository");
121
- if (raw.cwd.split(sep).filter(Boolean).length > MAX_CWD_DEPTH)
122
- throw new VerificationDescriptorError("verification cwd exceeds the depth bound");
123
- if (typeof raw.timeout_ms !== "number" || !Number.isFinite(raw.timeout_ms) || raw.timeout_ms < 1)
124
- throw new VerificationDescriptorError("verification timeout_ms must be a finite positive integer");
125
- if (!Number.isInteger(raw.timeout_ms) || raw.timeout_ms > MAX_TIMEOUT_MS)
126
- throw new VerificationDescriptorError("verification timeout_ms exceeds the host ceiling");
127
- if (
128
- typeof raw.max_output_bytes !== "number" ||
129
- !Number.isFinite(raw.max_output_bytes) ||
130
- raw.max_output_bytes < 1
131
- )
132
- throw new VerificationDescriptorError(
133
- "verification max_output_bytes must be a finite positive integer",
134
- );
135
- if (!Number.isInteger(raw.max_output_bytes) || raw.max_output_bytes > MAX_OUTPUT_BYTES)
136
- throw new VerificationDescriptorError(
137
- "verification max_output_bytes exceeds the host ceiling",
138
- );
101
+ const env = raw.environment === undefined ? {} : object(raw.environment, ["prepare", "writable_paths"], "verification environment");
102
+ const writable = env.writable_paths ?? [];
103
+ if (!Array.isArray(writable) || writable.length > VERIFICATION_DESCRIPTOR_BOUNDS.max_writable_paths)
104
+ throw new VerificationDescriptorError("verification writable_paths exceeds the host bound");
105
+ const paths = writable.map(path => verificationRelativePath(path, "verification writable path")).sort();
106
+ if (paths.includes(".") || new Set(paths).size !== paths.length
107
+ || paths.some((path, i) => paths.some((other, j) => j !== i && path.startsWith(`${other}/`))))
108
+ throw new VerificationDescriptorError("verification writable paths must be distinct non-overlapping directories");
139
109
  return {
140
110
  contract: VERIFICATION_DESCRIPTOR_CONTRACT,
141
- runner_id: "bun",
142
- runner_version: raw.runner_version,
143
- argv: raw.argv as string[],
144
- cwd: raw.cwd,
145
- timeout_ms: raw.timeout_ms,
146
- max_output_bytes: raw.max_output_bytes,
111
+ command: command(raw.command),
112
+ environment: { prepare: env.prepare === undefined || env.prepare === null ? null : command(env.prepare), writable_paths: paths },
147
113
  };
148
114
  }
149
115
 
150
- /** Canonical bytes of the parsed descriptor (for digest binding). */
151
116
  export function canonicalDescriptorBytes(descriptor: VerificationDescriptor): string {
152
117
  return `${JSON.stringify(descriptor, null, 2)}\n`;
153
118
  }
154
-
155
- /** Re-exported bounds for host-side consistency checks. */
156
- export const VERIFICATION_DESCRIPTOR_BOUNDS = {
157
- max_arg_tokens: MAX_ARGV_TOKENS,
158
- max_arg_token_bytes: MAX_ARGV_TOKEN_BYTES,
159
- max_cwd_depth: MAX_CWD_DEPTH,
160
- max_timeout_ms: MAX_TIMEOUT_MS,
161
- max_output_bytes: MAX_OUTPUT_BYTES,
162
- } as const;