feat(daemon): session_source: { mode: rebuild } (Option A: artifact-set seat)

Add Option A of the Seat Continuity and Handover primitive: a v1 narrow
MVP for `session_source: { mode: rebuild, ref: { kind: artifact_set,
value: [paths...] } }` declared at member-level in a rig spec or
`rig expand` payload. Fresh-launches the harness and seeds the running
TUI with operator-declared artifacts in trust-precedence order
(highest-trust first: rig CULTURE, role doc, handover packet, queue
files, session logs).

Identity-honesty bedrock: the rebuild seat persists with NO
`resumeToken` and `continuityOutcome: "rebuilt"` (NEVER `"fresh"`,
`"resumed"`, or `"forked"`). Verified at four layers — schema dispatch,
orchestrator threading (no `resumeToken`, no `forkSource`),
SQLite read-back (sessions.resume_token IS NULL), and negative-grep on
resolver output JSON (no `restored`/`resumed`/`snapshot`/`forked` tags).

Schema (types, rigspec-schema, rigspec-codec):

- `SessionSourceSpec` refactored from a single fork-only shape to a
  discriminated union `SessionSourceForkSpec | SessionSourceRebuildSpec`
  so consumers can typed-narrow on `mode`. Existing fork consumers
  unchanged.
- `validateSessionSource` dispatches on `mode` and delegates to either
  `validateForkSessionSource` (existing behavior, sharper "v1 fork
  mode" wording) or `validateRebuildSessionSource` (new). Rebuild
  rejects: terminal runtime; non-`artifact_set` ref.kind (with
  cross-mode hint when the kind belongs to fork); empty/non-array
  `ref.value`; empty-string entries within `ref.value`.
- `normalizeSessionSource` likewise dispatches and produces a typed
  `SessionSourceSpec` for either mode.
- Codec serializer's `ref.value` slot already accepts the array shape
  via the YAML library; commented for clarity.

Expansion (routes/rigs):

- `normalizeExpansionPodFragment` now reads both `mode: fork` and
  `mode: rebuild` shapes from the `/api/rigs/:id/expand` body.

Resolver (NEW: session-source-rebuild-resolver):

- `resolveRebuildArtifacts(spec, opts)` loads each declared path,
  preserves operator-declared trust-precedence order, records missing
  paths as gaps, and refuses (with a clear error citing trust-
  precedence order as guidance) only when ALL declared paths fail to
  resolve. The function does NOT execute, parse, or evaluate artifact
  contents — it just records that a path exists and hands metadata to
  the orchestrator's existing `deliverStartup` seam. Pluggable
  `exists` injection seam for unit tests.

Orchestrator (startup-orchestrator):

- `StartupResult.continuityOutcome` literal widened to include
  `"rebuilt"` (the broader `ContinuityOutcome` union in `types.ts`
  already included it from L2 lifecycle work).
- `StartupInput.rebuildArtifacts?: ResolvedStartupFile[]` added.
- When `rebuildArtifacts` is set, the artifacts are prepended to
  `resolvedStartupFiles` so the existing partition + delivery path
  handles them in operator order.
- `launchHarness` is called with NO `resumeToken` and NO `forkSource`
  on rebuild — fresh-launch shape. `continuityOutcome` is set to
  `"rebuilt"`.

Instantiator (rigspec-instantiator):

- Dispatches on `member.sessionSource?.mode`: `"fork"` builds the
  `forkSource` opt (existing behavior); `"rebuild"` resolves artifacts
  via the new resolver and passes them as `rebuildArtifacts`. If the
  resolver refuses (no paths resolved), the instantiator returns
  `{status: "failed", error: <resolver error>}` before calling
  `startNode`.

Out of scope for Option A (per slice IMPL): the full code-direct seat
handover (Option B / Mode 3 sub-mission); `SeatHandoverService` daemon
module; `rig seat handover` / `rig occupant rebuild` CLI commands;
nodes-table schema migration (HARD GATE on audit row 4 cleared:
`continuityOutcome: "rebuilt"` already in the enum); live session-
binding mutation; cross-host work; auto-discovery of artifacts
(operator declares paths in `ref.value` in trust-precedence order);
content execution or evaluation from artifacts in the resolver.

Tests (Tier 1, in-package mocks only — no real Claude / Codex; no
host `~/.claude` / `~/.codex` / `~/.openrig` mutation; resolver real-
filesystem smoke uses `mktempSync` temp paths only):

- `test/session-source-rebuild.test.ts` — 26 cases: 10 schema accept/
  reject (full Honest Refusal Matrix for rebuild + cross-mode); 1
  codec roundtrip; 4 resolver unit (all-existing, gaps recorded,
  all-missing fail, operator order preserved); 6 orchestrator
  integration including SQLite read-back asserting NO `resumeToken`
  on rebuild seat and `continuityOutcome: "rebuilt"`; 3 honest-UX
  literal contract; 2 real-filesystem smoke.
- `test/session-source-fork.test.ts` — two assertion strings updated
  to match the new multi-mode error wording (`"v1 supports fork
  only"` → `"v1 supports fork or rebuild"`; `"v1 supports native_id
  only"` → `"v1 fork mode supports native_id only"`). Pure wording
  propagation, no behavior change. All 33 fork cases still PASS.
- Daemon regression: 137 files, 2146/2146 PASS.

Tier 2 (real-runtime rebuild against a disposable Tart/OpenRig VM)
is the tester's call per the corrected SOP framing — disposable-VM
proof is default-allowed; skip only with concrete substrate reason.
Human gate applies only for protected/non-disposable hosts, host
`~/.claude` / `~/.codex` mutation, unexpected external spend/service
calls, destructive lifecycle, or leaving the disposable VM boundary.

Doc patches: `cli-reference.md` adds a new "Session source declaration
(`session_source`)" subsection covering BOTH modes (fork + rebuild)
with example YAML and validation rules. `architecture.md` adds a
one-paragraph reference covering both modes alongside the existing
cross-host notes.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/seat-continuity-and-handover/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-04-29 23:07:22 -07:00
1 parent cdce3a6864
commit 578bd5c57d
11 files changed
+803 -32

No files matched your search

+2
View File
@@ -149,6 +149,8 @@ Important route behaviors:
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
**Session source declaration:** Member YAML may carry `session_source` to control how a new managed seat derives its starting context. v1 supports two modes: `fork` (start from a prior native runtime conversation; captures and persists a NEW post-fork token, never the parent — see cli-reference.md "Session source declaration"); and `rebuild` (fresh-launch seeded with operator-declared artifacts in trust-precedence order; the seat's `continuityOutcome` is `rebuilt` and no `resumeToken` is persisted). Both modes flow through the existing rig spec and `rig expand` member-input shapes; no new top-level command was introduced for either.
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
- `rig preflight [--json]` — system readiness check (Node, tmux, writable dirs, port)
+46
View File
@@ -420,6 +420,52 @@ Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
Notes:
- Adds a pod fragment to a running rig.
- `--rig-root` controls agent resolution.
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
### Session source declaration (`session_source`)
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
```yaml
# Fork from a prior native runtime conversation. Captures and persists a NEW
# post-fork token; the parent token is NEVER persisted onto the new seat.
members:
- id: reviewer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: fork
ref:
kind: native_id # v1 fork mode supports "native_id" only
value: "0b0165d7-cb4d-4650-90de-15c0a1ede9e6"
```
```yaml
# Rebuild from operator-declared artifacts (CULTURE, role doc, handover packet,
# queue files, session logs). Fresh-launches the harness and seeds the running
# TUI with the artifacts in the operator-declared trust-precedence order.
# The seat's continuityOutcome is `rebuilt` (NEVER `fresh`/`resumed`/`forked`)
# and NO `resumeToken` is persisted.
members:
- id: writer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: rebuild
ref:
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
value: # ordered list, highest-trust first
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
- /path/to/handover-packet.md
- /path/to/state/<pod>/<member>.queue.md
- /path/to/state/<pod>/shared.session.log
- /path/to/state/<pod>/<member>.session.log
```
Notes:
- `terminal` runtime rejects `session_source` (no native fork primitive; no agent context to rebuild).
- `mode: fork` requires `ref.kind: native_id` and a non-empty `ref.value` string. Other ref kinds (`artifact_path`, `name`, `last`) are reserved shapes for follow-up slices and are refused in v1 fork mode.
- `mode: rebuild` requires `ref.kind: artifact_set` and a non-empty `ref.value` array of paths. Missing paths are recorded as gaps and the launch proceeds with what resolved; if NO declared paths resolve, the launch fails with a clear error.
- The two modes are mutually exclusive on a given member; mixing is a schema error.
### `rig unclaim`
@@ -63,6 +63,8 @@ export class RigSpecCodec {
if (m.sessionSource.ref.value !== undefined) ref["value"] = m.sessionSource.ref.value;
member["session_source"] = { mode: m.sessionSource.mode, ref };
}
// (rebuild ref.value is an array; the codec re-emits it as-is via the
// same `ref.value` slot — the YAML serializer handles array emission.)
return member;
});
@@ -1042,6 +1042,27 @@ export class PodRigInstantiator {
codexConfigProfile: input.member.codexConfigProfile,
};
// session_source dispatch: fork (native runtime fork) vs rebuild (artifact-
// injected fresh launch). Mutually exclusive; only one is set on a member.
let forkSourceOpt: { forkSource: { kind: "native_id" | "artifact_path" | "name" | "last"; value?: string } } | undefined;
let rebuildArtifactsOpt: { rebuildArtifacts: import("./runtime-adapter.js").ResolvedStartupFile[] } | undefined;
if (input.member.sessionSource?.mode === "fork") {
const ref = input.member.sessionSource.ref;
forkSourceOpt = {
forkSource: {
kind: ref.kind,
...(ref.value !== undefined ? { value: ref.value } : {}),
},
};
} else if (input.member.sessionSource?.mode === "rebuild") {
const { resolveRebuildArtifacts } = await import("./session-source-rebuild-resolver.js");
const resolved = resolveRebuildArtifacts(input.member.sessionSource);
if (!resolved.ok) {
return { status: "failed", error: resolved.error, sessionName: canonicalSessionName };
}
rebuildArtifactsOpt = { rebuildArtifacts: resolved.files };
}
const startupResult = await this.deps.startupOrchestrator.startNode({
rigId: input.rigId,
nodeId: input.nodeId,
@@ -1062,12 +1083,8 @@ export class PodRigInstantiator {
}),
],
isRestore: false,
...(input.member.sessionSource ? {
forkSource: {
kind: input.member.sessionSource.ref.kind,
...(input.member.sessionSource.ref.value !== undefined ? { value: input.member.sessionSource.ref.value } : {}),
},
} : {}),
...(forkSourceOpt ?? {}),
...(rebuildArtifactsOpt ?? {}),
});
return {
+71 -11
View File
@@ -318,14 +318,23 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
return errors;
}
if (isTerminalRuntime) {
errors.push(`${prefix}: terminal runtime has no native fork primitive; remove session_source for terminal members`);
errors.push(`${prefix}: terminal runtime has no native fork primitive and no agent context to rebuild; remove session_source for terminal members`);
return errors;
}
const ss = raw as Record<string, unknown>;
const mode = ss["mode"];
if (mode !== "fork") {
errors.push(`${prefix}.mode: v1 supports "fork" only (got ${JSON.stringify(mode)})`);
if (mode === "fork") {
return validateForkSessionSource(ss, prefix);
}
if (mode === "rebuild") {
return validateRebuildSessionSource(ss, prefix);
}
errors.push(`${prefix}.mode: v1 supports "fork" or "rebuild" (got ${JSON.stringify(mode)})`);
return errors;
}
function validateForkSessionSource(ss: Record<string, unknown>, prefix: string): string[] {
const errors: string[] = [];
const ref = ss["ref"];
if (ref === null || typeof ref !== "object") {
errors.push(`${prefix}.ref: required object with "kind" and (when kind=native_id) "value"`);
@@ -335,11 +344,13 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
const kind = refRec["kind"];
if (kind !== "native_id") {
if (kind === "artifact_path") {
errors.push(`${prefix}.ref.kind: "artifact_path" deferred to follow-up slice; v1 narrow MVP supports "native_id" only`);
errors.push(`${prefix}.ref.kind: "artifact_path" deferred to follow-up slice; v1 fork mode supports "native_id" only`);
} else if (kind === "name" || kind === "last") {
errors.push(`${prefix}.ref.kind: "${kind}" is weaker than "native_id"; v1 scope supports "native_id" only`);
errors.push(`${prefix}.ref.kind: "${kind}" is weaker than "native_id"; v1 fork mode supports "native_id" only`);
} else if (kind === "artifact_set") {
errors.push(`${prefix}.ref.kind: "artifact_set" belongs to mode "rebuild"; for fork mode use ref.kind: "native_id"`);
} else {
errors.push(`${prefix}.ref.kind: required; v1 supports "native_id" only (got ${JSON.stringify(kind)})`);
errors.push(`${prefix}.ref.kind: required; v1 fork mode supports "native_id" only (got ${JSON.stringify(kind)})`);
}
return errors;
}
@@ -350,6 +361,41 @@ function validateSessionSource(raw: unknown, prefix: string, isTerminalRuntime:
return errors;
}
function validateRebuildSessionSource(ss: Record<string, unknown>, prefix: string): string[] {
const errors: string[] = [];
const ref = ss["ref"];
if (ref === null || typeof ref !== "object") {
errors.push(`${prefix}.ref: required object with "kind: artifact_set" and "value: [paths...]" for rebuild mode`);
return errors;
}
const refRec = ref as Record<string, unknown>;
const kind = refRec["kind"];
if (kind !== "artifact_set") {
if (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last") {
errors.push(`${prefix}.ref.kind: rebuild mode requires ref.kind: "artifact_set"; the named kinds (${JSON.stringify(kind)}) belong to mode: "fork"`);
} else {
errors.push(`${prefix}.ref.kind: required; v1 rebuild mode supports "artifact_set" only (got ${JSON.stringify(kind)})`);
}
return errors;
}
const value = refRec["value"];
if (!Array.isArray(value)) {
errors.push(`${prefix}.ref.value: required non-empty array of artifact paths in trust-precedence order (highest-trust first: rig CULTURE, role doc, handover packet, queue file, pod shared session log, member session log)`);
return errors;
}
if (value.length === 0) {
errors.push(`${prefix}.ref.value: rebuild requires at least one artifact path; declare paths in trust-precedence order (highest-trust first)`);
return errors;
}
for (let i = 0; i < value.length; i++) {
const p = value[i];
if (typeof p !== "string" || p.trim() === "") {
errors.push(`${prefix}.ref.value[${i}]: each entry must be a non-empty string (file path)`);
}
}
return errors;
}
function validatePodLocalEdge(edge: Record<string, unknown>, index: number, podPrefix: string, memberIds: Set<string>): string[] {
const errors: string[] = [];
const prefix = `${podPrefix}.edges[${index}]`;
@@ -673,14 +719,28 @@ function normalizeSessionSource(raw: unknown): import("./types.js").SessionSourc
if (raw === null || typeof raw !== "object") return undefined;
const ss = raw as Record<string, unknown>;
const mode = ss["mode"];
if (mode !== "fork") return undefined;
const ref = ss["ref"];
if (ref === null || typeof ref !== "object") return undefined;
const refRec = ref as Record<string, unknown>;
const kind = refRec["kind"];
if (kind !== "native_id" && kind !== "artifact_path" && kind !== "name" && kind !== "last") return undefined;
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
return { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
if (mode === "fork") {
const kind = refRec["kind"];
if (kind !== "native_id" && kind !== "artifact_path" && kind !== "name" && kind !== "last") return undefined;
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
return { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
}
if (mode === "rebuild") {
const kind = refRec["kind"];
if (kind !== "artifact_set") return undefined;
const value = refRec["value"];
if (!Array.isArray(value)) return undefined;
const paths: string[] = [];
for (const p of value) {
if (typeof p === "string" && p.trim() !== "") paths.push(p);
}
if (paths.length === 0) return undefined;
return { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
}
return undefined;
}
function normalizePod(raw: Record<string, unknown>): RigSpecPod {
@@ -0,0 +1,89 @@
import { existsSync } from "node:fs";
import { basename, dirname } from "node:path";
import type { ResolvedStartupFile } from "./runtime-adapter.js";
import type { SessionSourceRebuildSpec } from "./types.js";
/**
* Result of resolving a `session_source.mode: rebuild` artifact set into the
* `ResolvedStartupFile[]` shape that `adapter.deliverStartup` already
* accepts. Records gaps (paths the operator declared but that don't exist
* on disk at resolve time) without failing — the operator's other declared
* artifacts may still carry enough context. The launch only fails if NONE
* of the declared paths resolved.
*/
export interface RebuildArtifactsResult {
ok: true;
files: ResolvedStartupFile[];
/** Paths from `ref.value` that did not resolve to an existing file. */
gaps: string[];
}
export type RebuildArtifactsOutcome =
| RebuildArtifactsResult
| { ok: false; error: string; gaps: string[] };
/**
* Test/host injection seam: lets unit tests assert path resolution without
* touching the real filesystem.
*/
export type ExistsFn = (path: string) => boolean;
/**
* Resolve operator-declared rebuild artifacts into the orchestrator's
* existing `ResolvedStartupFile[]` shape, preserving the operator's
* trust-precedence ordering.
*
* Identity-honesty notes:
* - This function does NOT execute, parse, or evaluate artifact contents.
* It just records that a path exists and hands the orchestrator the
* metadata it needs to deliver the bytes via the standard `deliverStartup`
* seam (which itself only reads + paste-injects via tmux for the
* `send_text` hint).
* - The artifacts are tagged `appliesOn: ["fresh_start"]` because rebuild
* IS a fresh launch from the runtime's perspective; the artifacts seed
* it with operator context, but the runtime conversation itself is new.
* `continuityOutcome: rebuilt` (set by the orchestrator) is the only
* signal that distinguishes this from a vanilla fresh launch.
*
* @param spec - the rebuild spec from the member; `ref.value` is the
* operator-declared list of artifact paths in trust-
* precedence order (highest-trust first).
* @param opts.exists - filesystem existence check; defaults to `existsSync`.
* Tests pass a stub.
*/
export function resolveRebuildArtifacts(
spec: SessionSourceRebuildSpec,
opts: { exists?: ExistsFn } = {},
): RebuildArtifactsOutcome {
const exists = opts.exists ?? existsSync;
const gaps: string[] = [];
const files: ResolvedStartupFile[] = [];
for (const path of spec.ref.value) {
if (!exists(path)) {
gaps.push(path);
continue;
}
files.push({
path: basename(path),
absolutePath: path,
ownerRoot: dirname(path),
// `send_text` so the orchestrator's existing post-launch TUI delivery
// path picks these up after the harness is ready. Operator-curated
// context is meant to seed the running conversation, not to land as
// filesystem-projected guidance/skill content.
deliveryHint: "send_text",
required: true,
// `fresh_start` because rebuild IS a fresh-launch from the runtime's
// perspective; the artifacts are the operator-declared seed context.
appliesOn: ["fresh_start"],
});
}
if (files.length === 0) {
return {
ok: false,
error: `rebuild: none of the ${spec.ref.value.length} declared artifact path${spec.ref.value.length === 1 ? "" : "s"} resolved to an existing file. Verify the paths in session_source.ref.value (declared in trust-precedence order; highest-trust first).`,
gaps,
};
}
return { ok: true, files, gaps };
}
@@ -33,6 +33,15 @@ export interface StartupInput {
* the new seat — the parent token is NEVER persisted.
*/
forkSource?: ForkSource;
/**
* Rebuild-mode artifact set (operator-declared via
* `session_source.mode: rebuild`). When set, the orchestrator merges
* these artifacts into the post-launch delivery path, fresh-launches
* the harness with NO `resumeToken` and NO `forkSource`, and records
* `continuityOutcome: "rebuilt"` on the seat. NEVER paired with
* `resumeToken` or `forkSource` — rebuild is a distinct creation path.
*/
rebuildArtifacts?: ResolvedStartupFile[];
/** Skip harness launch (legacy nodes that already resumed via old helpers). */
skipHarnessLaunch?: boolean;
/** Allow runtime adapter retry_fresh fallback when native resume data is stale. */
@@ -42,7 +51,7 @@ export interface StartupInput {
}
export type StartupResult =
| { ok: true; startupStatus: "ready"; continuityOutcome: "resumed" | "fresh" | "forked" }
| { ok: true; startupStatus: "ready"; continuityOutcome: "resumed" | "fresh" | "forked" | "rebuilt" }
| { ok: false; startupStatus: "attention_required" | "failed"; errors: string[] };
interface StartupOrchestratorDeps {
@@ -98,11 +107,13 @@ export class StartupOrchestrator {
async startNode(input: StartupInput): Promise<StartupResult> {
const errors: string[] = [];
let continuityOutcome: "resumed" | "fresh" | "forked" = input.resumeToken
let continuityOutcome: "resumed" | "fresh" | "forked" | "rebuilt" = input.resumeToken
? "resumed"
: input.forkSource
? "forked"
: "fresh";
: input.rebuildArtifacts && input.rebuildArtifacts.length > 0
? "rebuilt"
: "fresh";
// 1. Mark pending
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
@@ -126,8 +137,16 @@ export class StartupOrchestrator {
// 3. Partition startup files by concrete hint: pre-launch (filesystem) vs post-launch (TUI)
// Note: new file-building paths (NS-T05+) emit only concrete hints. The auto fallback
// is compatibility-only for pre-NS-T05 persisted startup contexts in node_startup_context.
//
// Rebuild-mode artifacts (when set) are merged in front of resolvedStartupFiles
// so the operator's trust-precedence ordering is preserved when the post-launch
// delivery loop walks the array. Rebuild artifacts are tagged
// appliesOn: ["fresh_start"] by the resolver, which matches the rebuild context.
const context = input.isRestore ? "restore" : "fresh_start";
const applicableFiles = input.resolvedStartupFiles.filter((f) => f.appliesOn.includes(context));
const sourceFiles = input.rebuildArtifacts && input.rebuildArtifacts.length > 0
? [...input.rebuildArtifacts, ...input.resolvedStartupFiles]
: input.resolvedStartupFiles;
const applicableFiles = sourceFiles.filter((f) => f.appliesOn.includes(context));
const preLaunchFiles: ResolvedStartupFile[] = [];
let postLaunchFiles: ResolvedStartupFile[] = [];
for (const f of applicableFiles) {
+25 -7
View File
@@ -507,15 +507,25 @@ export interface ContinuityPolicySpec {
}
/**
* Member-level launch input for declaring that a new managed seat should be
* started from a prior native runtime conversation source. v1 narrow MVP:
* mode="fork" + ref.kind="native_id" only. The original seat is NOT claimed
* to have continued; the new seat persists its own NEW post-fork token.
* Member-level launch input for declaring how a new managed seat should
* derive its starting context. Discriminated union over `mode`:
*
* Other ref.kind values ("artifact_path", "name", "last") are reserved
* shapes — schema rejects them today with an honest "v1 scope" error.
* - `fork` — start from a prior native runtime conversation source
* (Claude `--fork-session` / Codex `fork`); persists a NEW post-fork
* token; identity-honest (parent token is NEVER persisted onto the
* new seat). v1 supports `ref.kind: "native_id"` only.
*
* - `rebuild` — fresh-launch a new seat seeded with operator-declared
* artifacts (CULTURE, role doc, handover packet, queue files, session
* logs). NO native-runtime resume or fork; NO `resumeToken` on the
* resulting seat; `continuityOutcome` is `"rebuilt"` (NEVER `"fresh"`,
* `"resumed"`, or `"forked"`). v1 supports `ref.kind: "artifact_set"`
* only, with `ref.value` as a non-empty array of file paths in
* operator-declared trust-precedence order.
*/
export interface SessionSourceSpec {
export type SessionSourceSpec = SessionSourceForkSpec | SessionSourceRebuildSpec;
export interface SessionSourceForkSpec {
mode: "fork";
ref: {
kind: "native_id" | "artifact_path" | "name" | "last";
@@ -523,6 +533,14 @@ export interface SessionSourceSpec {
};
}
export interface SessionSourceRebuildSpec {
mode: "rebuild";
ref: {
kind: "artifact_set";
value: string[];
};
}
export interface RigSpecPodMember {
id: string;
label?: string;
+10 -2
View File
@@ -34,12 +34,20 @@ function normalizeExpansionPodFragment(raw: Record<string, unknown>): ExpansionP
const ss = rawSessionSource as Record<string, unknown>;
const mode = ss["mode"];
const ref = ss["ref"];
if (mode === "fork" && ref !== null && typeof ref === "object") {
if (ref !== null && typeof ref === "object") {
const refRec = ref as Record<string, unknown>;
const kind = refRec["kind"];
if (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last") {
if (mode === "fork" && (kind === "native_id" || kind === "artifact_path" || kind === "name" || kind === "last")) {
const value = typeof refRec["value"] === "string" ? (refRec["value"] as string) : undefined;
sessionSource = { mode: "fork", ref: { kind, ...(value !== undefined ? { value } : {}) } };
} else if (mode === "rebuild" && kind === "artifact_set" && Array.isArray(refRec["value"])) {
const paths: string[] = [];
for (const p of refRec["value"] as unknown[]) {
if (typeof p === "string" && p.trim() !== "") paths.push(p);
}
if (paths.length > 0) {
sessionSource = { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
}
}
}
}
@@ -96,7 +96,7 @@ describe("session_source schema validation", () => {
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('v1 supports "fork" only'))).toBe(true);
expect(result.errors.some((e) => e.includes('v1 supports "fork" or "rebuild"'))).toBe(true);
});
it("rejects ref.kind=artifact_path (deferred per dossier)", () => {
@@ -159,7 +159,7 @@ describe("session_source schema validation", () => {
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('v1 supports "native_id" only'))).toBe(true);
expect(result.errors.some((e) => e.includes('v1 fork mode supports "native_id" only'))).toBe(true);
});
});
@@ -0,0 +1,510 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import { resolveRebuildArtifacts } from "../src/domain/session-source-rebuild-resolver.js";
import { StartupOrchestrator, type StartupInput } from "../src/domain/startup-orchestrator.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import type { RuntimeAdapter, NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
import type { RigSpec, SessionSourceRebuildSpec } from "../src/domain/types.js";
// ============================================================================
// Fixtures
// ============================================================================
function baseValidSpec(): Record<string, unknown> {
return {
version: "0.2",
name: "rebuild-test-rig",
pods: [
{
id: "dev",
label: "Dev pod",
members: [
{
id: "writer",
agent_ref: "local:agents/writer",
profile: "default",
runtime: "claude-code",
cwd: ".",
},
],
edges: [],
},
],
edges: [],
};
}
function withMember(spec: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
const next = JSON.parse(JSON.stringify(spec));
const pods = next.pods as Array<Record<string, unknown>>;
const members = pods[0]!.members as Array<Record<string, unknown>>;
members[0] = { ...members[0], ...override };
return next;
}
// ============================================================================
// Schema validation — Honest Refusal Matrix (rebuild-mode rows)
// ============================================================================
describe("session_source rebuild — schema validation", () => {
it("accepts claude-code + rebuild + artifact_set with non-empty value array", () => {
const spec = withMember(baseValidSpec(), {
session_source: {
mode: "rebuild",
ref: { kind: "artifact_set", value: ["/tmp/CULTURE.md", "/tmp/role.md"] },
},
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts codex + rebuild + artifact_set", () => {
const spec = withMember(baseValidSpec(), {
runtime: "codex",
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x/y.md"] } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(true);
});
it("rejects terminal runtime with rebuild", () => {
const spec = withMember(baseValidSpec(), {
runtime: "terminal",
agent_ref: "builtin:terminal",
profile: "none",
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md"] } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("terminal runtime has no native fork primitive and no agent context to rebuild"))).toBe(true);
});
it("rejects rebuild + ref.kind=native_id (kind belongs to fork mode)", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "native_id", value: "abc" } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('rebuild mode requires ref.kind: "artifact_set"'))).toBe(true);
expect(result.errors.some((e) => e.includes('belong to mode: "fork"'))).toBe(true);
});
it("rejects rebuild + ref.kind=artifact_path", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "artifact_path", value: "/x" } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('rebuild mode requires ref.kind: "artifact_set"'))).toBe(true);
});
it("rejects rebuild + empty artifact_set value", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: [] } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("rebuild requires at least one artifact path"))).toBe(true);
// The error mentions trust-precedence ordering as guidance.
expect(result.errors.some((e) => e.includes("trust-precedence"))).toBe(true);
});
it("rejects rebuild + value not an array", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: "/just/one/path.md" } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("required non-empty array"))).toBe(true);
});
it("rejects rebuild + value array containing empty strings", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md", " "] } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("each entry must be a non-empty string"))).toBe(true);
});
it("rejects unknown ref.kind for rebuild", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "rebuild", ref: { kind: "magic", value: ["/x"] } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('v1 rebuild mode supports "artifact_set" only'))).toBe(true);
});
it("rejects unknown mode (neither fork nor rebuild)", () => {
const spec = withMember(baseValidSpec(), {
session_source: { mode: "snapshot", ref: { kind: "native_id", value: "x" } },
});
const result = RigSpecSchema.validate(spec);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes('v1 supports "fork" or "rebuild"'))).toBe(true);
});
});
// ============================================================================
// Codec roundtrip
// ============================================================================
describe("session_source rebuild — codec roundtrip", () => {
it("preserves rebuild + artifact_set + value array through serialize → parse → normalize", () => {
const seed: RigSpec = {
version: "0.2",
name: "rebuild-test-rig",
pods: [{
id: "dev",
label: "Dev pod",
members: [{
id: "writer",
agentRef: "local:agents/writer",
profile: "default",
runtime: "claude-code",
cwd: ".",
sessionSource: { mode: "rebuild", ref: { kind: "artifact_set", value: ["/tmp/CULTURE.md", "/tmp/role.md", "/tmp/handover.md"] } },
}],
edges: [],
}],
edges: [],
};
const yaml = RigSpecCodec.serialize(seed);
expect(yaml).toContain("session_source:");
expect(yaml).toContain("mode: rebuild");
expect(yaml).toContain("kind: artifact_set");
expect(yaml).toContain("/tmp/CULTURE.md");
expect(yaml).toContain("/tmp/role.md");
expect(yaml).toContain("/tmp/handover.md");
const parsed = RigSpecCodec.parse(yaml);
const validation = RigSpecSchema.validate(parsed);
expect(validation.valid).toBe(true);
const normalized = RigSpecSchema.normalize(parsed as Record<string, unknown>) as RigSpec;
const member = normalized.pods[0]!.members[0]!;
expect(member.sessionSource).toEqual(seed.pods[0]!.members[0]!.sessionSource);
});
});
// ============================================================================
// Rebuild artifact resolver
// ============================================================================
describe("resolveRebuildArtifacts", () => {
function makeSpec(paths: string[]): SessionSourceRebuildSpec {
return { mode: "rebuild", ref: { kind: "artifact_set", value: paths } };
}
it("resolves all-existing paths into ResolvedStartupFile[] preserving operator order", () => {
const exists = (p: string) => p === "/x/CULTURE.md" || p === "/y/role.md" || p === "/z/handover.md";
const result = resolveRebuildArtifacts(makeSpec(["/x/CULTURE.md", "/y/role.md", "/z/handover.md"]), { exists });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.files.map((f) => f.absolutePath)).toEqual(["/x/CULTURE.md", "/y/role.md", "/z/handover.md"]);
expect(result.gaps).toEqual([]);
// Identity-honesty: deliveryHint is `send_text` (operator-curated context for the running TUI).
expect(result.files.every((f) => f.deliveryHint === "send_text")).toBe(true);
// appliesOn: ["fresh_start"] — rebuild IS a fresh launch from the runtime's perspective.
expect(result.files.every((f) => f.appliesOn.includes("fresh_start"))).toBe(true);
// Required so the orchestrator surfaces missing-resource failures honestly.
expect(result.files.every((f) => f.required === true)).toBe(true);
}
});
it("records missing paths as gaps without failing if some files exist", () => {
const exists = (p: string) => p === "/exists.md";
const result = resolveRebuildArtifacts(makeSpec(["/exists.md", "/missing.md", "/also-missing.md"]), { exists });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.files.map((f) => f.absolutePath)).toEqual(["/exists.md"]);
expect(result.gaps).toEqual(["/missing.md", "/also-missing.md"]);
}
});
it("fails the launch with a clear error when ALL declared paths are missing", () => {
const exists = () => false;
const result = resolveRebuildArtifacts(makeSpec(["/missing-1.md", "/missing-2.md"]), { exists });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("none of the 2 declared artifact paths resolved");
expect(result.error).toContain("trust-precedence order");
expect(result.gaps).toEqual(["/missing-1.md", "/missing-2.md"]);
}
});
it("preserves operator-declared ordering (does NOT impose alphabetical or any other sort)", () => {
const exists = () => true;
const order = ["/zzz-low-trust.md", "/aaa-mid-trust.md", "/mmm-highest-trust.md"];
const result = resolveRebuildArtifacts(makeSpec(order), { exists });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.files.map((f) => f.absolutePath)).toEqual(order);
}
});
});
// ============================================================================
// Startup orchestrator — rebuild integration
// ============================================================================
function mockOrchTmux(): TmuxAdapter {
return {
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function makeStubAdapter(): RuntimeAdapter {
return {
runtime: "claude-code",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
// Fresh-launch shape: returns ok with NO resumeToken (rebuild seats have
// no native runtime conversation to resume from).
launchHarness: vi.fn(async () => ({ ok: true })),
};
}
function emptyPlan(): ProjectionPlan {
return { runtime: "claude-code", cwd: ".", entries: [], startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] };
}
describe("StartupOrchestrator rebuild integration", () => {
let db: Database.Database;
let sessionRegistry: SessionRegistry;
let eventBus: EventBus;
let rigRepo: RigRepository;
beforeEach(() => {
db = createFullTestDb();
sessionRegistry = new SessionRegistry(db);
eventBus = new EventBus(db);
rigRepo = new RigRepository(db);
});
afterEach(() => { db.close(); });
function seed(): { rigId: string; nodeId: string; sessionId: string } {
const rig = rigRepo.createRig("rebuild-rig");
const node = rigRepo.addNode(rig.id, "writer", { runtime: "claude-code" });
const session = sessionRegistry.registerSession(node.id, "r01-writer");
sessionRegistry.updateStatus(session.id, "running");
return { rigId: rig.id, nodeId: node.id, sessionId: session.id };
}
function makeInput(s: { rigId: string; nodeId: string; sessionId: string }, overrides?: Partial<StartupInput>): StartupInput {
return {
rigId: s.rigId,
nodeId: s.nodeId,
sessionId: s.sessionId,
binding: { id: "b1", nodeId: s.nodeId, tmuxSession: "r01-writer", tmuxWindow: null, tmuxPane: null, cmuxWorkspace: null, cmuxSurface: null, updatedAt: "", cwd: "." },
adapter: makeStubAdapter(),
plan: emptyPlan(),
resolvedStartupFiles: [],
startupActions: [],
isRestore: false,
...overrides,
};
}
function createOrch(): StartupOrchestrator {
return new StartupOrchestrator({
db, sessionRegistry, eventBus,
tmuxAdapter: mockOrchTmux(),
sleep: async () => {},
});
}
function makeRebuildArtifacts(): ResolvedStartupFile[] {
return [
{ path: "CULTURE.md", absolutePath: "/x/CULTURE.md", ownerRoot: "/x", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
{ path: "role.md", absolutePath: "/y/role.md", ownerRoot: "/y", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
{ path: "handover.md", absolutePath: "/z/handover.md", ownerRoot: "/z", deliveryHint: "send_text", required: true, appliesOn: ["fresh_start"] },
];
}
it("sets continuityOutcome=rebuilt when rebuildArtifacts is provided and launch succeeds", async () => {
const s = seed();
const orch = createOrch();
const result = await orch.startNode(makeInput(s, { rebuildArtifacts: makeRebuildArtifacts() }));
expect(result).toEqual({
ok: true,
startupStatus: "ready",
continuityOutcome: "rebuilt",
});
});
it("calls adapter.launchHarness with NO resumeToken AND NO forkSource on rebuild", async () => {
const s = seed();
const adapter = makeStubAdapter();
const launchSpy = adapter.launchHarness as ReturnType<typeof vi.fn>;
const orch = createOrch();
await orch.startNode(makeInput(s, { adapter, rebuildArtifacts: makeRebuildArtifacts() }));
expect(launchSpy).toHaveBeenCalledTimes(1);
const opts = launchSpy.mock.calls[0]![1];
expect(opts.resumeToken).toBeUndefined();
expect(opts.forkSource).toBeUndefined();
});
it("rebuild seat persists with NO resumeToken (identity-honesty: rebuild is artifact-injected, not native-resumed)", async () => {
const s = seed();
const orch = createOrch();
await orch.startNode(makeInput(s, { rebuildArtifacts: makeRebuildArtifacts() }));
const row = db.prepare("SELECT resume_token FROM sessions WHERE id = ?").get(s.sessionId) as { resume_token: string | null };
expect(row.resume_token).toBeNull();
});
it("hands rebuild artifacts to adapter.deliverStartup (post-launch delivery via send_text)", async () => {
const s = seed();
const adapter = makeStubAdapter();
const deliverSpy = adapter.deliverStartup as ReturnType<typeof vi.fn>;
const orch = createOrch();
const artifacts = makeRebuildArtifacts();
await orch.startNode(makeInput(s, { adapter, rebuildArtifacts: artifacts }));
// deliverStartup is called twice: once pre-launch (filesystem files; empty
// for rebuild-only) and once post-launch (TUI files; the rebuild artifacts).
expect(deliverSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
// Post-launch call must include the rebuild artifacts in operator order.
const allDeliveredFiles = deliverSpy.mock.calls.flatMap((call) => call[0] as ResolvedStartupFile[]);
const deliveredAbsPaths = allDeliveredFiles.map((f) => f.absolutePath);
for (const artifact of artifacts) {
expect(deliveredAbsPaths).toContain(artifact.absolutePath);
}
// Operator order preserved: CULTURE before role before handover.
const cultureIdx = deliveredAbsPaths.indexOf("/x/CULTURE.md");
const roleIdx = deliveredAbsPaths.indexOf("/y/role.md");
const handoverIdx = deliveredAbsPaths.indexOf("/z/handover.md");
expect(cultureIdx).toBeGreaterThanOrEqual(0);
expect(cultureIdx).toBeLessThan(roleIdx);
expect(roleIdx).toBeLessThan(handoverIdx);
});
it("fresh path (no rebuildArtifacts, no resumeToken, no forkSource) is unchanged: continuityOutcome=fresh", async () => {
const s = seed();
const orch = createOrch();
const result = await orch.startNode(makeInput(s));
expect(result).toEqual({ ok: true, startupStatus: "ready", continuityOutcome: "fresh" });
});
it("fork path (forkSource set) is unchanged by rebuild plumbing: continuityOutcome=forked", async () => {
const s = seed();
const adapter = makeStubAdapter();
(adapter.launchHarness as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
resumeToken: "NEW-FORK-TOKEN",
resumeType: "claude_id",
});
const orch = createOrch();
const result = await orch.startNode(makeInput(s, {
adapter,
forkSource: { kind: "native_id", value: "PARENT-FORK-TOKEN" },
}));
expect(result).toEqual({ ok: true, startupStatus: "ready", continuityOutcome: "forked" });
});
});
// ============================================================================
// Honest UX literal contract — continuityOutcome includes "rebuilt"; no
// `restored|resumed|snapshot|forked` slips into the rebuild dossier wording.
// ============================================================================
describe("identity-honesty literal contract — rebuild", () => {
it('continuityOutcome union accepts "rebuilt" alongside other outcomes', () => {
const r: { ok: true; startupStatus: "ready"; continuityOutcome: "rebuilt" } = {
ok: true, startupStatus: "ready", continuityOutcome: "rebuilt",
};
expect(r.continuityOutcome).toBe("rebuilt");
});
it("rebuild mode literal is 'rebuild' (NOT 'fresh', 'resumed', 'forked', 'restored', or 'snapshot')", () => {
const spec: SessionSourceRebuildSpec = { mode: "rebuild", ref: { kind: "artifact_set", value: ["/x"] } };
expect(spec.mode).toBe("rebuild");
expect(["fresh", "resumed", "forked", "restored", "snapshot"]).not.toContain(spec.mode as string);
});
it("resolver-produced ResolvedStartupFile entries do NOT carry any 'restored'/'resumed'/'snapshot'/'forked' tags", () => {
const exists = () => true;
const result = resolveRebuildArtifacts(
{ mode: "rebuild", ref: { kind: "artifact_set", value: ["/x.md"] } },
{ exists },
);
if (!result.ok) throw new Error("expected ok");
const serialized = JSON.stringify(result.files);
expect(serialized).not.toMatch(/restored/i);
expect(serialized).not.toMatch(/resumed/i);
expect(serialized).not.toMatch(/snapshot/i);
expect(serialized).not.toMatch(/forked/i);
});
});
// ============================================================================
// Real-filesystem smoke: resolver default `exists` works (light integration)
// ============================================================================
describe("rebuild resolver real-filesystem smoke (default existsSync)", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "openrig-rebuild-resolver-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("resolves real files written under a temp dir", () => {
const a = join(dir, "CULTURE.md");
const b = join(dir, "role.md");
writeFileSync(a, "culture body");
writeFileSync(b, "role body");
const result = resolveRebuildArtifacts({
mode: "rebuild",
ref: { kind: "artifact_set", value: [a, b] },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.files.map((f) => f.absolutePath)).toEqual([a, b]);
expect(result.gaps).toEqual([]);
}
});
it("returns gaps for missing files alongside resolved ones", () => {
const real = join(dir, "real.md");
writeFileSync(real, "x");
const ghost = join(dir, "ghost.md"); // never written
const result = resolveRebuildArtifacts({
mode: "rebuild",
ref: { kind: "artifact_set", value: [real, ghost] },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.files.map((f) => f.absolutePath)).toEqual([real]);
expect(result.gaps).toEqual([ghost]);
}
});
});