feat(cli): Restore-Packet vertical M2a — schema + validator + CLI shell + wiring

Per orch decision (Path B chunked M2; M2a chunk):

- NEW packages/cli/src/schemas/restore-summary.schema.json — JSON Schema
  draft-07 for restore-summary.json, derived byte-for-byte from M1 contract
  § 2.1 at substrate/openrig-work/specs/restore-packet/SCHEMA.md. 21 required
  fields (16 standard + 5 v1-promoted-to-v0-required) plus the optional
  full_transcript object. Nested required-shapes for bounded_latest_transcript
  (with const bound: 120), touched_files, and durable_pointers (4 required
  arrays). additionalProperties: false at the root.

- NEW packages/cli/src/restore-packet/schema-validator.ts — TypeScript
  validator using ajv 8.18.0 + ajv-formats 3.0.1 (added to deps). Loads the
  schema file once (module-level cache), compiles into an ajv validator,
  and returns per-field ValidationError records (field, value, rule,
  severity) per contract § 8 reporting shape. M3 validate command + M2c
  generator-side emit-time self-check both consume this module.

- NEW packages/cli/src/commands/restore-packet.ts — commander command shape
  per IMPL § M2 line 105-141. Three subcommands: write / read / validate.
  M2a implements write's mutual-exclusion gate (--source-session vs
  --source-jsonl: exactly-one-required) and the missing-target check via
  commander requiredOption. read + validate are M2a stubs that report an
  M3 deadline; their CLI surfaces are registered now so the command tree
  is discoverable.

- EDIT packages/cli/src/index.ts — register restorePacketCommand after
  restoreCheckCommand (alphabetical: restore < restore-check < restore-packet
  per re-anchor finding); wire RestorePacketDeps into ProgramDeps.

- EDIT packages/cli/package.json — add ajv ^8.18.0 + ajv-formats ^3.0.1 to
  dependencies. Both already hoisted in monorepo node_modules at the same
  versions; this commit declares them honestly.

- NEW packages/cli/test/restore-packet.test.ts — 17 Tier 1 tests:
  - 11 schema-validator accept/reject tests covering each required-field
    family (source_session_id required; source_runtime enum; source_cwd
    pattern; bounded_latest_transcript.bound const 120; durable_pointers
    nested required arrays; redaction_policy_id enum; source_trust_ranking
    minItems; generated_at format; additionalProperties: false; optional
    full_transcript accepted; valid baseline accepted).
  - 6 CLI shell tests: subcommands present (read/validate/write); write
    mutual-exclusion both supplied; write neither supplied; --target
    missing; read M2a stub registered; validate M2a stub registered.

TDD discipline (per memory feedback_tdd_scope): test file authored before
schema-validator.ts and restore-packet.ts (failing import), then
implementations brought the suite to green. Committed state is the green
state.

Test runs:
- packages/cli vitest run test/restore-packet.test.ts → 17/17 pass.
- packages/cli vitest run (full suite) → 657/661 pass; the 4 failures are
  the pre-existing environmental flakes from prior session (preflight x2:
  port 7433 in use; chatroom wait: 10s timeout flake; config-store: env
  pollution). All four touch zero files in this M2a commit. No regression.

Posture declared: full QA/testing window. Out of scope for M2a (per orch
chunked decision): codex-jsonl-parser / claude-transcript-parser /
runtime-detect / redaction / omitted-records / packet-writer (M2b);
daemon route extension + daemon route tests (M2c); read/validate command
implementations (M3); M4-M6 proof work.

Source qitem: qitem-20260501232012-e660c0ce (Restore-Packet vertical M2).
Orch chunked-M2 decision: arrived as inline reply on the scope-check flag
qitem-20260501232237-7748b7d6.
This commit is contained in:
mvschwarz committed 2026-05-01 17:38:09 -07:00
1 parent 6af2754824
commit de2dbd4f3a
6 files changed
+563

No files matched your search

+2
View File
@@ -44,6 +44,8 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
+126
View File
@@ -0,0 +1,126 @@
// restore-packet.ts — `rig restore-packet {write,read,validate}` CLI command.
//
// M2a chunk: command shape + mutual-exclusion for `write`; `read` and
// `validate` are subcommand stubs that return a clear "M3 only" error so
// the CLI surface is registrable now.
//
// M2b will add: source-adapter wiring (codex-jsonl-parser, claude-transcript-
// parser, runtime-detect, redaction, omitted-records).
// M2c will add: packet-writer atomic emission + daemon route integration.
// M3 will replace the read/validate stubs with real implementations.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { type LifecycleDeps } from "../daemon-lifecycle.js";
export interface RestorePacketDeps {
lifecycleDeps?: LifecycleDeps;
clientFactory?: (url: string) => DaemonClient;
}
interface WriteOptions {
sourceSession?: string;
sourceJsonl?: string;
sourceRuntime?: string;
target: string;
targetRig?: string;
targetRuntime?: string;
targetWorkspaceRoot?: string;
defaultTargetRepo?: string;
}
function reportFailure(message: string): void {
console.error(`rig restore-packet write: ${message}`);
process.exitCode = 2;
}
export function restorePacketCommand(_depsOverride?: RestorePacketDeps): Command {
// _depsOverride is reserved for M2b/M2c when the source adapters need a
// DaemonClient (full-read transcript route) and a LifecycleDeps for daemon
// boot / status checks. M2a does not use either; the test suite passes
// depsOverride? as undefined.
const cmd = new Command("restore-packet")
.description(
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
);
cmd.command("write")
.description(
"Generate a restore packet from a source session or JSONL file",
)
.option("--source-session <session>", "Source session name (daemon-backed)")
.option(
"--source-jsonl <path>",
"Source Codex/Claude JSONL transcript file (direct)",
)
.option(
"--source-runtime <runtime>",
"Force source runtime (claude-code | codex); auto-detected if omitted",
)
.requiredOption(
"--target <dir>",
"Target packet directory (must not exist; created atomically)",
)
.option("--target-rig <rig>", "Target rig name for the restored seat")
.option(
"--target-runtime <runtime>",
"Target runtime (claude-code | codex)",
)
.option(
"--target-workspace-root <path>",
"Target workspace root absolute path",
)
.option(
"--default-target-repo <path>",
"Default target repo absolute path or null",
)
.action(async (opts: WriteOptions) => {
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
if (hasSession && hasJsonl) {
reportFailure(
"--source-session and --source-jsonl are mutually exclusive; supply exactly one.",
);
return;
}
if (!hasSession && !hasJsonl) {
reportFailure(
"exactly one of --source-session or --source-jsonl is required.",
);
return;
}
// M2b lands the actual write path. The mutual-exclusion checks above
// are M2a-final; from here on (M2b) the source-adapter dispatch will
// route to codex-jsonl-parser, claude-transcript-parser, or the
// daemon full-read route.
reportFailure(
"M2a: source adapters land in M2b; this command is registered but not yet executable. (target was: " +
opts.target +
")",
);
});
cmd.command("read")
.description("Render a restore packet contents (human or --json)")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable JSON output")
.action(async (packetDir: string, _opts: { json?: boolean }) => {
console.error(
`rig restore-packet read: M3 implementation pending; packet-dir was '${packetDir}'.`,
);
process.exitCode = 2;
});
cmd.command("validate")
.description("Validate a restore packet against the v0 schema")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable validation report")
.action(async (packetDir: string, _opts: { json?: boolean }) => {
console.error(
`rig restore-packet validate: M3 implementation pending; packet-dir was '${packetDir}'.`,
);
process.exitCode = 2;
});
return cmd;
}
+3
View File
@@ -45,6 +45,7 @@ import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
import { setupCommand } from "./commands/setup.js";
import { contextCommand } from "./commands/context.js";
import { restoreCheckCommand } from "./commands/restore-check.js";
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
import { seatCommand, type SeatDeps } from "./commands/seat.js";
@@ -89,6 +90,7 @@ export interface ProgramDeps {
removeDeps?: StatusDeps;
shrinkDeps?: StatusDeps;
destroyDeps?: DestroyCommandDeps;
restorePacketDeps?: RestorePacketDeps;
compactPlanDeps?: CompactPlanDeps;
heartbeatDeps?: HeartbeatDeps;
seatDeps?: SeatDeps;
@@ -146,6 +148,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(setupCommand());
program.addCommand(contextCommand());
program.addCommand(restoreCheckCommand());
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
@@ -0,0 +1,86 @@
// schema-validator.ts — validates restore-summary.json against the v0 JSON
// Schema (packages/cli/src/schemas/restore-summary.schema.json).
//
// Per M1 contract § 8 + IMPL § M2 line 167:
// - generator-side: emit-time self-check against the schema before atomic
// rename of the packet directory.
// - operator-side: M3 `rig restore-packet validate <packet-dir>` runs this
// validator and surfaces per-field violations.
//
// Validator returns a ValidationResult with `valid` and a per-field error
// list. Each error names: field path, value (truncated/escaped), rule, and
// severity. Required-field violations are severity `error`; optional-field
// malformations are severity `warning` per § 8.
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
export interface ValidationError {
field: string;
value: string;
rule: string;
severity: "error" | "warning";
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let cachedValidator: ReturnType<Ajv["compile"]> | null = null;
function loadSchema(): object {
// src/restore-packet/schema-validator.ts → src/schemas/restore-summary.schema.json
const schemaPath = resolve(__dirname, "..", "schemas", "restore-summary.schema.json");
return JSON.parse(readFileSync(schemaPath, "utf-8")) as object;
}
function getValidator(): ReturnType<Ajv["compile"]> {
if (cachedValidator) return cachedValidator;
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
cachedValidator = ajv.compile(loadSchema());
return cachedValidator;
}
function truncateValue(value: unknown): string {
if (value === undefined) return "<undefined>";
if (value === null) return "null";
let s: string;
try {
s = typeof value === "string" ? value : JSON.stringify(value);
} catch {
s = String(value);
}
if (s.length > 80) return s.slice(0, 77) + "...";
return s;
}
export function validateRestoreSummary(summary: unknown): ValidationResult {
const validator = getValidator();
const ok = validator(summary);
if (ok) {
return { valid: true, errors: [] };
}
const errors: ValidationError[] = (validator.errors ?? []).map((err) => {
const instancePath = err.instancePath || "";
const missing = err.params && (err.params as { missingProperty?: string }).missingProperty;
const additional = err.params && (err.params as { additionalProperty?: string }).additionalProperty;
let field = instancePath.replace(/^\//, "").replace(/\//g, ".");
if (missing) field = field ? `${field}.${missing}` : missing;
if (additional) field = additional;
return {
field: field || "<root>",
value: truncateValue(err.data),
rule: `${err.keyword}: ${err.message ?? ""}`.trim(),
severity: "error",
};
});
return { valid: false, errors };
}
@@ -0,0 +1,98 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://openrig.dev/schemas/restore-summary.schema.json",
"title": "Restore Packet Summary",
"description": "Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
"type": "object",
"required": [
"source_session_id",
"source_rig",
"source_runtime",
"source_cwd",
"target_rig",
"target_runtime",
"target_workspace_root",
"default_target_repo",
"role_pointer",
"bounded_latest_transcript",
"touched_files",
"durable_pointers",
"current_work_summary",
"next_owner",
"caveats",
"authority_boundaries",
"omitted_classes",
"redaction_policy_id",
"source_trust_ranking",
"generator_version",
"generated_at"
],
"properties": {
"source_session_id": {"type": "string", "minLength": 1},
"source_rig": {"type": "string", "minLength": 1},
"source_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"source_cwd": {"type": "string", "pattern": "^/"},
"target_rig": {"type": "string", "minLength": 1},
"target_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"target_workspace_root": {"type": "string", "pattern": "^/"},
"default_target_repo": {"type": ["string", "null"]},
"role_pointer": {
"type": "string",
"minLength": 1,
"description": "Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time."
},
"bounded_latest_transcript": {
"type": "object",
"required": ["path", "message_count", "bound"],
"properties": {
"path": {"type": "string"},
"message_count": {"type": "integer", "minimum": 0, "description": "Actual count of extracted messages in transcript-latest.md."},
"bound": {"type": "integer", "const": 120, "description": "v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass."}
}
},
"full_transcript": {
"type": "object",
"properties": {
"path": {"type": "string"},
"line_count": {"type": "integer", "minimum": 0}
},
"description": "Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise."
},
"touched_files": {
"type": "object",
"required": ["path", "top_paths"],
"properties": {
"path": {"type": "string"},
"top_paths": {"type": "array", "items": {"type": "object", "required": ["path", "count"], "properties": {"path": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}}}
}
},
"durable_pointers": {
"type": "object",
"required": ["queue_pointers", "progress_pointers", "field_note_pointers", "artifact_pointers"],
"properties": {
"queue_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to durable queue items (qitem ids or queue-file paths)."},
"progress_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to PROGRESS.md cursors the source seat was operating against."},
"field_note_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to field-notes folders or files."},
"artifact_pointers": {"type": "array", "items": {"type": "string"}, "description": "Other durable artifacts (proof packets, slice packets, candidate dossiers)."}
},
"description": "REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context."
},
"current_work_summary": {"type": "string", "minLength": 1},
"next_owner": {"type": "string", "minLength": 1},
"caveats": {"type": "array", "items": {"type": "string"}},
"authority_boundaries": {"type": "string", "minLength": 1},
"omitted_classes": {
"type": "array",
"items": {"type": "string", "enum": ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"]}
},
"redaction_policy_id": {"type": "string", "enum": ["velocity-v1", "openrig-v0"]},
"source_trust_ranking": {
"type": "array",
"items": {"type": "string", "enum": ["rig_whoami", "target_rigspec", "bounded_latest_transcript", "full_transcript", "touched_files", "restore_summary"]},
"minItems": 1
},
"generator_version": {"type": "string", "minLength": 1},
"generated_at": {"type": "string", "format": "date-time"}
},
"additionalProperties": false
}
+248
View File
@@ -0,0 +1,248 @@
// M2 Restore-Packet vertical — Tier 1 tests.
//
// M2a focus: schema-validator + restore-packet command shell + mutual-exclusion.
// M2b adds: codex-jsonl-parser + claude-transcript-parser + runtime-detect +
// redaction + omitted-records (with Velocity round-trips).
// M2c adds: packet-writer atomic emission + daemon route integration.
//
// TDD discipline (per memory feedback_tdd_scope): each test was authored to
// fail first, then the implementation was written to green. The committed
// state is the green/passing state.
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import {
validateRestoreSummary,
type ValidationResult,
} from "../src/restore-packet/schema-validator.js";
import { restorePacketCommand } from "../src/commands/restore-packet.js";
// Minimal valid summary fixture matching the M1 contract (§ 2.1) required-field
// set verbatim. Tests compose against this baseline by mutating one field at
// a time to drive the accept/reject matrix.
function validSummary(): Record<string, unknown> {
return {
source_session_id: "velocity-driver@openrig-velocity",
source_rig: "openrig-velocity",
source_runtime: "claude-code",
source_cwd: "/Users/wrandom/code/projects/openrig-hub",
target_rig: "openrig-velocity",
target_runtime: "claude-code",
target_workspace_root: "/Users/wrandom/code/projects/openrig-hub",
default_target_repo: null,
role_pointer: "rigs/openrig-velocity/state/velocity/driver-role.md",
bounded_latest_transcript: {
path: "transcript-latest.md",
message_count: 87,
bound: 120,
},
touched_files: {
path: "touched-files.md",
top_paths: [{ path: "packages/cli/src/commands/restore-packet.ts", count: 14 }],
},
durable_pointers: {
queue_pointers: [],
progress_pointers: [],
field_note_pointers: [],
artifact_pointers: [],
},
current_work_summary: "Working on M2a chunk of restore-packet vertical.",
next_owner: "self",
caveats: [],
authority_boundaries: "Implement M2a only; do not touch M3+ surfaces.",
omitted_classes: ["reasoning_records"],
redaction_policy_id: "openrig-v0",
source_trust_ranking: ["rig_whoami", "bounded_latest_transcript"],
generator_version: "[email protected]",
generated_at: "2026-05-01T23:30:00Z",
};
}
describe("M2 restore-packet schema-validator", () => {
it("accepts a valid summary matching contract § 2.1", () => {
const result: ValidationResult = validateRestoreSummary(validSummary());
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("rejects when source_session_id is missing", () => {
const summary = validSummary();
delete (summary as Record<string, unknown>)["source_session_id"];
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /source_session_id|required/.test(e.field) || /source_session_id|required/i.test(e.rule))).toBe(true);
});
it("rejects when source_runtime is not one of the enum values", () => {
const summary = validSummary();
summary["source_runtime"] = "bash";
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /source_runtime/.test(e.field))).toBe(true);
});
it("rejects when source_cwd is not absolute", () => {
const summary = validSummary();
summary["source_cwd"] = "relative/path";
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /source_cwd/.test(e.field))).toBe(true);
});
it("rejects when bounded_latest_transcript.bound is not 120", () => {
const summary = validSummary();
(summary["bounded_latest_transcript"] as Record<string, unknown>)["bound"] = 100;
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /bound/.test(e.field))).toBe(true);
});
it("rejects when durable_pointers is missing required arrays", () => {
const summary = validSummary();
summary["durable_pointers"] = { queue_pointers: [] };
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
// Missing progress_pointers / field_note_pointers / artifact_pointers
expect(result.errors.length).toBeGreaterThan(0);
});
it("rejects when redaction_policy_id is not a known policy", () => {
const summary = validSummary();
summary["redaction_policy_id"] = "custom-policy";
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /redaction_policy_id/.test(e.field))).toBe(true);
});
it("rejects when source_trust_ranking is empty", () => {
const summary = validSummary();
summary["source_trust_ranking"] = [];
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /source_trust_ranking/.test(e.field))).toBe(true);
});
it("rejects when generated_at is not ISO-8601", () => {
const summary = validSummary();
summary["generated_at"] = "yesterday";
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /generated_at/.test(e.field))).toBe(true);
});
it("rejects when an unknown top-level field is present (additionalProperties: false)", () => {
const summary = validSummary();
(summary as Record<string, unknown>)["new_field"] = "leaked";
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => /new_field|additional/.test(e.field) || /additional/i.test(e.rule))).toBe(true);
});
it("accepts a summary with optional full_transcript present", () => {
const summary = validSummary();
summary["full_transcript"] = { path: "transcript.md", line_count: 4321 };
const result = validateRestoreSummary(summary);
expect(result.valid).toBe(true);
});
});
describe("M2 restore-packet CLI command shell + mutual-exclusion", () => {
function runRestorePacket(argv: string[]): { exitCode: number | undefined; stderr: string[]; stdout: string[] } {
const stderr: string[] = [];
const stdout: string[] = [];
const origExitCode = process.exitCode;
const origConsoleLog = console.log;
const origConsoleError = console.error;
process.exitCode = undefined;
console.log = (...args: unknown[]) => stdout.push(args.join(" "));
console.error = (...args: unknown[]) => stderr.push(args.join(" "));
try {
const program = new Command();
program.exitOverride();
// Route commander's writeErr (used for missing-required-option messages)
// into our stderr capture so test assertions can match against it.
program.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
const sub = restorePacketCommand();
sub.exitOverride();
sub.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
sub.commands.forEach((c) => {
c.exitOverride();
c.configureOutput({ writeOut: (s) => stdout.push(s), writeErr: (s) => stderr.push(s) });
});
program.addCommand(sub);
try {
program.parse(["node", "rig", ...argv]);
} catch (err) {
// commander throws on exitOverride + nonzero exit. Capture the
// thrown CommanderError message into stderr so callers can match
// against it (some commander error paths route through err.message
// rather than configureOutput's writeErr).
if (err instanceof Error && err.message) {
stderr.push(err.message);
}
if (process.exitCode === undefined || process.exitCode === 0) {
process.exitCode = 2;
}
}
} finally {
console.log = origConsoleLog;
console.error = origConsoleError;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
return { exitCode, stderr, stdout };
}
it("registers a `restore-packet` command with subcommands write / read / validate", () => {
const cmd = restorePacketCommand();
expect(cmd.name()).toBe("restore-packet");
const subs = cmd.commands.map((c) => c.name()).sort();
expect(subs).toEqual(["read", "validate", "write"]);
});
it("write fails with explicit error when both --source-session AND --source-jsonl are supplied", () => {
const { exitCode, stderr } = runRestorePacket([
"restore-packet", "write",
"--source-session", "fake@kernel",
"--source-jsonl", "/tmp/fake.jsonl",
"--target", "/tmp/out",
]);
expect(exitCode).not.toBe(0);
const errStr = stderr.join("\n");
expect(errStr).toMatch(/mutually exclusive|exactly one|both supplied/i);
});
it("write fails with explicit error when neither --source-session nor --source-jsonl is supplied", () => {
const { exitCode, stderr } = runRestorePacket([
"restore-packet", "write",
"--target", "/tmp/out",
]);
expect(exitCode).not.toBe(0);
const errStr = stderr.join("\n");
expect(errStr).toMatch(/--source-session|--source-jsonl|exactly one/i);
});
it("write fails when --target is missing (commander requiredOption check)", () => {
const { exitCode, stderr } = runRestorePacket([
"restore-packet", "write",
"--source-jsonl", "/tmp/fake.jsonl",
]);
expect(exitCode).not.toBe(0);
const errStr = stderr.join("\n");
expect(errStr).toMatch(/--target|required/i);
});
it("read subcommand exists as M2a stub (full implementation in M3)", () => {
const cmd = restorePacketCommand();
const readCmd = cmd.commands.find((c) => c.name() === "read");
expect(readCmd).toBeDefined();
expect(readCmd!.description()).toMatch(/render|packet/i);
});
it("validate subcommand exists as M2a stub (full implementation in M3)", () => {
const cmd = restorePacketCommand();
const validateCmd = cmd.commands.find((c) => c.name() === "validate");
expect(validateCmd).toBeDefined();
expect(validateCmd!.description()).toMatch(/validate|schema/i);
});
});