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
@@ -12,8 +12,9 @@ import {
12
12
  readTaskRecordRaw,
13
13
  readWorkspaceStateRaw,
14
14
  } from "./storage";
15
+ import { stateDatabasePath } from "./storage_paths";
15
16
 
16
- const SOURCE_PATH = ".imm/state/workspace.json";
17
+ const SOURCE_PATH = stateDatabasePath();
17
18
  const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
18
19
 
19
20
  /**
@@ -64,6 +65,13 @@ export interface PiCanaryPreparation {
64
65
  git_error: string | null;
65
66
  workspace: {
66
67
  current_working: string | null;
68
+ /**
69
+ * The workspace CAS token at preparation time. A workspace can return to
70
+ * the same owner value at a different revision (another task enrolled and
71
+ * settled in between), so the owner alone cannot prove the confirmed
72
+ * state is still current.
73
+ */
74
+ revision: string;
67
75
  };
68
76
  digest: string;
69
77
  }
@@ -152,6 +160,7 @@ export function preparePiCanary(root: string, input: PiCanaryPrepareInput): PiCa
152
160
  const state = readWorkspaceStateRaw(canonicalRoot);
153
161
  const workspace: PiCanaryPreparation["workspace"] = {
154
162
  current_working: state.state.current_working,
163
+ revision: state.revision,
155
164
  };
156
165
  if (claim && state.state.current_working !== claim.task_id)
157
166
  throw new Error(
@@ -1,4 +1,4 @@
1
- // Pure TaskRecord v3 reducer with a closed factual action vocabulary.
1
+ // Pure TaskRecord v4 reducer with a closed factual action vocabulary.
2
2
  // Never reads files, Git, workspace, or host context. Returns a branded
3
3
  // ReducedTaskMutation; the caller cannot construct or serialize it.
4
4
 
@@ -7,7 +7,6 @@ import { completionDecision } from "./completion";
7
7
  import { anchorForEvidence, isFreshPassingQaAttestation, refutationIdentity, refutationIsLive } from "./refutation";
8
8
  import {
9
9
  REDUCED_MUTATION_BRAND,
10
- TASK_RECORD_CONTRACT_V4,
11
10
  type AuthorityAuditDescriptor,
12
11
  type ReducedTaskMutation,
13
12
  type TaskAction,
@@ -163,10 +162,6 @@ function appendHistory(
163
162
  record.history.push(entry as (typeof record.history)[number]);
164
163
  }
165
164
 
166
- function sha256Hex(value: string): string {
167
- return createHash("sha256").update(value).digest("hex");
168
- }
169
-
170
165
  function intentRefMatches(intent: TaskIntentV1, ref: TaskIntentRefV3): boolean {
171
166
  const activePath = `docs/plans/${intent.task_id}.intent.json`;
172
167
  const archivedPath = `docs/plans/archive/${intent.task_id}.intent.json`;
@@ -209,19 +204,11 @@ export function findingsDigestV2(findings: TaskFinding[]): string {
209
204
  }
210
205
 
211
206
  /**
212
- * Two findings share an acceptance boundary when they name the same
213
- * acceptance, or when neither does and their review evidence names the same
214
- * violated reference. Null boundaries without comparable evidence are not
215
- * shared, so a second-round finding can no longer park the task by accident.
207
+ * Rework rounds one Review run may demand before the Kernel pauses the task for
208
+ * a human decision. Ordinary defects return to execution without a user gate,
209
+ * but an unbounded repair/review cycle is not progress either.
216
210
  */
217
- function sharesAcceptanceBoundary(left: TaskFinding, right: TaskFinding): boolean {
218
- if (left.acceptance_id !== null && right.acceptance_id !== null)
219
- return left.acceptance_id === right.acceptance_id;
220
- if (left.acceptance_id !== null || right.acceptance_id !== null) return false;
221
- const leftRef = left.evidence?.violated.ref ?? null;
222
- const rightRef = right.evidence?.violated.ref ?? null;
223
- return leftRef !== null && leftRef === rightRef;
224
- }
211
+ export const REVIEW_REWORK_ROUND_BUDGET = 5;
225
212
 
226
213
  export function reduceTask(
227
214
  recordRaw: TaskRecord,
@@ -444,10 +431,7 @@ export function reduceTask(
444
431
  const reviewRevision = approval.review_revision;
445
432
  if (reviewRevision && approval.kind !== "review")
446
433
  throw new KernelInvariantError(["review_revision is only valid on review approvals"]);
447
- if (record.contract !== TASK_RECORD_CONTRACT_V4) {
448
- if (reviewRevision)
449
- throw new KernelInvariantError(["review_revision requires a TaskRecord v4"]);
450
- } else if (approval.kind === "review") {
434
+ if (approval.kind === "review") {
451
435
  if (!reviewRevision)
452
436
  throw new KernelInvariantError(["v4 review approval requires review_revision"]);
453
437
  if (reviewRevision.base_head !== record.git_base_head)
@@ -567,9 +551,11 @@ export function reduceTask(
567
551
  ]);
568
552
  }
569
553
  const identity = refutationIdentity(record, action.diff_hash);
570
- // A prior finding that is still refuted by live evidence is not an
571
- // outstanding dispute, so a new claim on its boundary is not a repeat
572
- // offence. Once its evidence goes stale it blocks again and counts.
554
+ // A prior blocking Review claim is only a repeat offence when it names the
555
+ // same security boundary. Ordinary defects on an already-covered
556
+ // acceptance return to execution: changing the plan is a human decision,
557
+ // fixing a bug is not. A claim still refuted by live evidence is not an
558
+ // outstanding dispute at all.
573
559
  const priorBlockingReviewFindings = record.findings.filter(
574
560
  (finding) =>
575
561
  finding.source === "review" &&
@@ -604,12 +590,26 @@ export function reduceTask(
604
590
  ({ finding, inherited }) =>
605
591
  finding.kind === "blocking" &&
606
592
  inherited === undefined &&
607
- priorBlockingReviewFindings.some((prior) =>
608
- sharesAcceptanceBoundary(finding, prior),
593
+ finding.evidence?.violated.kind === "security_boundary" &&
594
+ priorBlockingReviewFindings.some(
595
+ (prior) =>
596
+ prior.evidence?.violated.kind === "security_boundary" &&
597
+ prior.evidence?.violated.ref === finding.evidence?.violated.ref,
609
598
  ),
610
599
  )?.finding;
600
+ // Only rounds that actually raised an unrefuted blocking claim count
601
+ // against the budget: a claim live QA evidence already answers is not
602
+ // progress lost, so repeating it can never park the task.
603
+ const effectiveBlockingRounds = new Set(
604
+ priorBlockingReviewFindings.map((finding) => finding.review_round),
605
+ ).size;
606
+ const hasEffectiveBlockingNow = admissions.some(
607
+ ({ finding, inherited }) => finding.kind === "blocking" && inherited === undefined,
608
+ );
611
609
  const parkForReplan =
612
- authorityAudit.authority_kind === "review" && disputed !== undefined;
610
+ authorityAudit.authority_kind === "review" &&
611
+ (disputed !== undefined ||
612
+ (hasEffectiveBlockingNow && effectiveBlockingRounds >= REVIEW_REWORK_ROUND_BUDGET));
613
613
  if (!parkForReplan) {
614
614
  record.artifact_state = "active";
615
615
  record.intent_ref.path = `docs/plans/${record.task_id}.intent.json`;
@@ -644,11 +644,13 @@ export function reduceTask(
644
644
  id: `${action.event_id}:replan-required`,
645
645
  kind: "replan_required" as const,
646
646
  status: "open" as const,
647
- acceptance_id: disputed.acceptance_id,
647
+ acceptance_id: disputed?.acceptance_id ?? null,
648
648
  source: "kernel" as const,
649
649
  review_round: round,
650
650
  summary:
651
- "Review returned this acceptance boundary twice; a durable replan is required.",
651
+ disputed !== undefined
652
+ ? "Review returned the same security boundary twice; a durable replan is required."
653
+ : `Review exhausted its ${REVIEW_REWORK_ROUND_BUDGET}-round rework budget; a durable replan is required.`,
652
654
  };
653
655
  if (findingIds.has(boundary.id))
654
656
  throw new KernelInvariantError([
@@ -708,7 +710,6 @@ export function reduceTask(
708
710
  throw new KernelInvariantError(["stop requires a reason"]);
709
711
  transitionLifecycle(record, "stopped");
710
712
  record.artifact_state = "frozen";
711
- record.intent_ref.path = `docs/plans/archive/${record.task_id}.intent.json`;
712
713
  appendHistory(record, action, from, action.reason, authorityAudit);
713
714
  break;
714
715
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Exact execution identity.
3
+ *
4
+ * A worktree owns one storage identity (`workspace_id`) and every enrolled
5
+ * execution owns one `run_id`. Authority mutations bind the exact run instead
6
+ * of resolving "the latest task occurrence", and every replayable operation
7
+ * derives its identity from committed facts so a replayed call returns the
8
+ * recorded result instead of writing again.
9
+ *
10
+ * `task_id` names the logical task: different worktrees may each run the same
11
+ * logical task with distinct run identities, and a terminal task cannot be
12
+ * re-enrolled in the same worktree.
13
+ */
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import type { DatabaseSync } from "node:sqlite";
16
+
17
+ import { KernelStoreSecurityError, workspaceIdentity } from "./sqlite_store";
18
+
19
+ export interface RunIdentity {
20
+ workspace_id: string;
21
+ task_id: string;
22
+ run_id: string;
23
+ }
24
+
25
+ export interface RunRowIdentity {
26
+ task_id: string;
27
+ run_id: string;
28
+ }
29
+
30
+ export function mintRunId(): string {
31
+ return `run-${randomUUID()}`;
32
+ }
33
+
34
+ export function runIdentity(db: DatabaseSync, row: RunRowIdentity): RunIdentity {
35
+ return { workspace_id: workspaceIdentity(db), task_id: row.task_id, run_id: row.run_id };
36
+ }
37
+
38
+ /** Bind a mutation to one exact run; a mismatch fails before any write. */
39
+ export function assertRunBinding(
40
+ identity: RunIdentity,
41
+ expected: RunRowIdentity,
42
+ operation: string,
43
+ ): void {
44
+ if (identity.run_id !== expected.run_id || identity.task_id !== expected.task_id)
45
+ throw new KernelStoreSecurityError(
46
+ `${operation} is bound to run ${expected.run_id} (task ${expected.task_id}) but the store holds run ${identity.run_id} (task ${identity.task_id})`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Deterministic replay identity for the authority operations that can lose a
52
+ * response. The event identifier is part of the committed authority bytes, so
53
+ * an identical request maps to an identical operation id and a replayed call
54
+ * returns the recorded result instead of writing again.
55
+ */
56
+ export function enrollmentOperationId(taskId: string, eventId: string): string {
57
+ return `enroll:${taskId}:${eventId}`;
58
+ }
59
+
60
+ /**
61
+ * Digest of an enrollment request's own content. The capability object is
62
+ * opaque and excluded, so a lost-response retry of the *same* confirmation
63
+ * matches, while a different path, digest, actor, nonce or event time is a
64
+ * different request and must not be answered from the committed operation.
65
+ */
66
+ export function enrollmentRequestDigest(request: {
67
+ task_id: string;
68
+ intent_path: string;
69
+ intent_revision: number;
70
+ intent_content_hash: string;
71
+ preparation_digest: string;
72
+ enrollment_event_id: string;
73
+ actor_id: string;
74
+ confirmation_ref: string;
75
+ nonce: string;
76
+ }): string {
77
+ const canonical = JSON.stringify({
78
+ task_id: request.task_id,
79
+ intent_path: request.intent_path,
80
+ intent_revision: request.intent_revision,
81
+ intent_content_hash: request.intent_content_hash,
82
+ preparation_digest: request.preparation_digest,
83
+ enrollment_event_id: request.enrollment_event_id,
84
+ actor_id: request.actor_id,
85
+ confirmation_ref: request.confirmation_ref,
86
+ nonce: request.nonce,
87
+ });
88
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
89
+ }
90
+
91
+ export function drainOperationId(taskId: string, updatedAt: string): string {
92
+ return `drain:${taskId}:${updatedAt}`;
93
+ }
94
+
95
+ /**
96
+ * Digest of a terminal request's own content. The capability is deliberately
97
+ * opaque and excluded, so a retry that re-mints authority for the *same*
98
+ * request produces the same digest, while a different reason, actor or event
99
+ * time produces a different one and must be authorized again instead of being
100
+ * answered from the committed operation.
101
+ */
102
+ export function terminalRequestDigest(action: {
103
+ type: string;
104
+ event_id: string;
105
+ at: string;
106
+ actor_id: string;
107
+ reason?: unknown;
108
+ }): string {
109
+ const canonical = JSON.stringify({
110
+ type: action.type,
111
+ event_id: action.event_id,
112
+ at: action.at,
113
+ actor_id: action.actor_id,
114
+ reason: typeof action.reason === "string" ? action.reason : null,
115
+ });
116
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
117
+ }
118
+
119
+ export function terminalOperationId(taskId: string, eventId: string): string {
120
+ return `terminal:${taskId}:${eventId}`;
121
+ }
@@ -0,0 +1,100 @@
1
+ // Spec binding ownership. A simple TaskIntent binds no Spec. A complex
2
+ // TaskIntent binds at most one active Spec by path; freeze records Git
3
+ // content identity without relocating source files. Archive paths are
4
+ // historical evidence, not a freeze requirement.
5
+
6
+ import type { TaskIntentV1 } from "./types";
7
+ import { readSecureProjectFile } from "./storage";
8
+ import { KernelInvariantError } from "./validation";
9
+
10
+ const ACTIVE_SPEC_RE = /^docs\/specs\/(?!archive\/)[^/]+\.spec\.md$/;
11
+ const ARCHIVED_SPEC_RE = /^docs\/specs\/archive\/[^/]+\.spec\.md$/;
12
+
13
+ /** The archive counterpart of an active planning-artifact path. */
14
+ export function archivePath(path: string): string {
15
+ const matched = path.match(/^docs\/(plans|specs)\/([^/]+)$/);
16
+ if (!matched) throw new KernelInvariantError([`artifact path is not active: ${path}`]);
17
+ return `docs/${matched[1]}/archive/${matched[2]}`;
18
+ }
19
+
20
+ /** The inverse of `archivePath`, for an already-archived Spec path. */
21
+ export function activePath(path: string): string {
22
+ const matched = path.match(/^docs\/(plans|specs)\/archive\/([^/]+)$/);
23
+ if (!matched) throw new KernelInvariantError([`artifact path is not archived: ${path}`]);
24
+ return `docs/${matched[1]}/${matched[2]}`;
25
+ }
26
+
27
+ /**
28
+ * The active Spec the intent binds, or `undefined` when it binds none.
29
+ * Archive counterparts are not part of the binding predicate.
30
+ */
31
+ export function boundSpecPath(intent: TaskIntentV1): string | undefined {
32
+ const candidates = intent.scope_hint.filter((path) => ACTIVE_SPEC_RE.test(path));
33
+ if (candidates.length > 1)
34
+ throw new KernelInvariantError([`artifact transition requires at most one scope-bound Spec; found ${candidates.length}`]);
35
+ return candidates[0];
36
+ }
37
+
38
+ /**
39
+ * Bound Spec bytes when the intent names one. Simple intents have none.
40
+ * `required` still fails closed for callers that demand a Spec.
41
+ */
42
+ export function readBoundActiveSpec(
43
+ root: string,
44
+ intent: TaskIntentV1,
45
+ required = false,
46
+ ): { path: string; content: string } | undefined {
47
+ const specPath = boundSpecPath(intent);
48
+ if (!specPath) {
49
+ if (required) throw new KernelInvariantError(["artifact freeze requires one scope-bound active Spec"]);
50
+ return undefined;
51
+ }
52
+ try {
53
+ return { path: specPath, content: readSecureProjectFile(root, specPath) };
54
+ } catch (error) {
55
+ if (error instanceof Error && error.message.startsWith("source_missing:") && !required) return undefined;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export interface BoundSpec {
61
+ active: string;
62
+ archive: string;
63
+ }
64
+
65
+ export type SpecBindingInspection =
66
+ | { ok: true; binding: BoundSpec | null }
67
+ | {
68
+ ok: false;
69
+ code: "binding_missing" | "binding_incomplete" | "binding_ambiguous";
70
+ missing: string[];
71
+ message: string;
72
+ };
73
+
74
+ /**
75
+ * Enrollment and validate caller: a missing Spec is a simple task; a
76
+ * malformed or incomplete complex binding is refused before any write.
77
+ */
78
+ export function inspectSpecBinding(intent: TaskIntentV1): SpecBindingInspection {
79
+ const active = intent.scope_hint.filter((path) => ACTIVE_SPEC_RE.test(path));
80
+ const archived = intent.scope_hint.filter((path) => ARCHIVED_SPEC_RE.test(path));
81
+ if (active.length === 0 && archived.length === 0)
82
+ return { ok: true, binding: null };
83
+ if (active.length > 1)
84
+ return {
85
+ ok: false,
86
+ code: "binding_ambiguous",
87
+ missing: [],
88
+ message: `enrollment requires at most one scope-bound Spec; found ${active.length}: ${active.join(", ")}`,
89
+ };
90
+ if (active.length === 0) {
91
+ const missing = archived.map((path) => activePath(path));
92
+ return {
93
+ ok: false,
94
+ code: "binding_incomplete",
95
+ missing,
96
+ message: `complex Spec binding is incomplete; add ${missing.join(", ")}`,
97
+ };
98
+ }
99
+ return { ok: true, binding: { active: active[0]!, archive: archivePath(active[0]!) } };
100
+ }