feat(context-packs): operator-authored composable injection v0 (PL-014)

PRD: slices/rig-context-composable-injection-v0/IMPLEMENTATION-PRD.md.

New typed primitive parallel to skills + workflow_specs.
Filesystem-canonical at ~/.openrig/context-packs/<name>/ +
workspace-local .openrig/context-packs/<name>/. NO new SQLite tables,
NO new migrations, NO new event types.

Daemon (Items 1, 2, 5)
- New domain/context-packs/{context-pack-types, manifest-parser,
  context-pack-library-service, bundle-assembler}.ts.
- Manifest schema: name + version + purpose + files[{path, role,
  summary?}] + estimated_tokens? — guards against ../-escapes,
  absolute paths, unsupported suffixes (.md/.markdown/.yaml/.yml/.txt).
- Library service walks discovery roots in workspace > user_file >
  builtin order (last-wins on collision); workspace-surface
  reconciliation via re-scan (operator filesystem edit always wins).
- Bundle assembler concatenates files with `## File: <path> (role:
  <role>)` framing into ONE coherent paste-ready string. Missing
  files surfaced as warning instead of hard fail. Token estimate via
  the existing chars/4 heuristic.
- New /api/context-packs/{library, library/sync, library/:id,
  library/:id/preview, library/:id/send} routes. send invokes
  SessionTransport.send (one paste, NOT N sends — matches PRD § Item
  5 "single coherent priming injection"); supports dryRun for the
  UI's preview-before-send flow.

CLI (Item 7)
- New `rig context-pack` family: list / show / preview / sync / add /
  send. add installs from a local directory under
  ~/.openrig/context-packs/, refuses symlinks (parallel to `rig specs
  add` contract), and triggers a daemon sync after install.

UI (Items 3, 4)
- SpecsPanel adds 6th filter chip "context-packs" + PACK badge +
  per-row summary (files-count + token estimate).
- LibraryReview dispatches to a new ContextPack review variant for
  context-pack:* ids: header + 4-card summary grid + per-file list
  with role/size/token estimate (MISSING marker for absent files) +
  source-path display + "Send to seat" modal with running-session
  picker + Dry-run / Send buttons + bundle preview pre-block.
- New useContextPackLibrary / useContextPackPreview /
  useContextPackSend / useContextPackSync hooks.

Item 6 carve-out (NAMED v0+1 trigger)
- AgentSpec startup_files extension (kind: context_pack as a startup-
  file source) — DEFERRED. PRD § Bounce Conditions allows this with
  named v0+1 trigger; founder dispatch was hands-off and didn't carve
  it out explicitly. Operator can still send packs to a fresh seat
  via `rig context-pack send` from a startup script or interactive
  command — the headline operator value (typed primitive + library +
  review + send) ships fully without it.
  v0+1 NAMED trigger: operator dogfood reports declaring kind:
  context_pack in agent.yaml startup_files would be friction over
  invoking rig context-pack send manually after launch.

PRD § from_addresses extension is gated by PL-022 markdown-addressing
(PARKED) — out of v0 per dispatch.

Cross-CLI-version drift handling
- UI useContextPackLibrary returns [] on 503 + Array.isArray guard
  on the body so older daemons without the route surface as "no
  packs in library" rather than crashing the SpecsPanel.

Tests
- daemon: 2956/2956 (12 manifest-parser + 11 library-service + 5
  bundle-assembler + 11 routes = 39 new)
- cli: 842/842 (9 new context-pack subcommand tests)
- ui: 543/543 (2 new SpecsPanel filter tests + 2 new LibraryReview
  context-pack variant tests)
- tsc clean across all three packages

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-04 11:27:53 -07:00
1 parent 2a70fa03ea
commit fce486c16d
19 files changed
+2615 -15

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family.
//
// Six subcommands parallel to `rig specs`:
// list / show / preview / add / sync / send
//
// Each delegates to /api/context-packs/library/* against the daemon.
// The `add` verb installs a pack from a directory at
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
// matches `rig specs add` shape (regular files only; no symlinks).
import { Command } from "commander";
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, join } from "node:path";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface ContextPackEntryWire {
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: "builtin" | "user_file" | "workspace";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
}
interface PreviewWire {
id: string;
name: string;
version: string;
bundleText: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
}
interface SendWire {
id: string;
name: string;
version: string;
destinationSession: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
dryRun: boolean;
bundleText?: string;
sent?: boolean;
reason?: string;
error?: string;
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) stack.push(absPath);
}
}
}
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
// First try as id (context-pack:<name>:<version>); else search by name.
if (nameOrId.startsWith("context-pack:")) {
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
}
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
const entries = res.data ?? [];
const matches = entries.filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function contextPackCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("context-pack")
.description("Browse, preview, send, and install operator-authored context packs")
.addHelpText("after", `
Examples:
rig context-pack list
rig context-pack show pl-005-phase-a-priming
rig context-pack preview pl-005-phase-a-priming
rig context-pack add ./my-pack
rig context-pack sync
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all context packs in the library")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
return;
}
for (const e of entries) {
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show pack manifest + per-file metadata")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
console.log(`Files: ${entry.files.length}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
if (entry.purpose) {
console.log("");
console.log("Purpose:");
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
}
console.log("");
for (const f of entry.files) {
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
if (f.summary) console.log(` ${f.summary}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show the assembled bundle (the exact text that would be sent)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
if (preview.missingFiles.length > 0) {
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
console.error("");
}
console.log(`# Preview: ${preview.name} v${preview.version}`);
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
console.log("# ---");
console.log(preview.bundleText);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.description("Re-walk discovery roots and refresh the library index")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
"/api/context-packs/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
console.log(`Indexed ${data.count} context pack(s).`);
if (data.errors.length > 0) {
console.log(`Encountered ${data.errors.length} parse error(s):`);
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("add")
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
.option("--name <name>", "Override the install name (defaults to source directory basename)")
.option("--json", "JSON output")
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
try {
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
const stat = lstatSync(sourceDir);
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
const manifestPath = join(sourceDir, "manifest.yaml");
if (!existsSync(manifestPath)) {
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
}
// Read the manifest's name as the canonical install name when
// --name not given. Cheap parse: trust the daemon to validate
// on next sync; here we just need the name.
const installName = opts.name ?? (() => {
try {
const raw = readFileSync(manifestPath, "utf-8");
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
return m?.[1]?.trim() || basename(sourceDir);
} catch {
return basename(sourceDir);
}
})();
assertTreeHasNoSymlinks(sourceDir);
const targetRoot = getDefaultOpenRigPath("context-packs");
mkdirSync(targetRoot, { recursive: true });
const targetDir = join(targetRoot, installName);
if (existsSync(targetDir)) {
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
}
cpSync(sourceDir, targetDir, { recursive: true });
// Sync the daemon library so the new pack appears immediately.
const client = await getClient();
const syncRes = await client.post<{ count: number; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
if (syncRes.status !== 200) {
// Install succeeded; sync failed → still surface install path.
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
return;
}
if (opts.json) {
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
} else {
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("send")
.argument("<name-or-id>", "Context pack name or library ID")
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
.description("Assemble the pack into one paste-ready bundle and send to a seat")
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
.option("--json", "JSON output")
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
destinationSession,
dryRun: opts.dryRun ?? false,
});
if (res.status !== 200) {
// 502/503/etc. — surface daemon error verbatim.
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
}
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data.dryRun) {
if (data.missingFiles.length > 0) {
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
}
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
if (data.bundleText) {
console.log("# ---");
console.log(data.bundleText);
}
return;
}
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
+3
View File
@@ -41,6 +41,7 @@ import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
import { chatroomCommand } from "./commands/chatroom.js";
import { specsCommand } from "./commands/specs.js";
import { contextPackCommand } from "./commands/context-pack.js";
import { whoamiCommand } from "./commands/whoami.js";
import { unclaimCommand } from "./commands/unclaim.js";
import { releaseCommand } from "./commands/release.js";
@@ -93,6 +94,7 @@ export interface ProgramDeps {
askDeps?: StatusDeps;
chatroomDeps?: StatusDeps;
specsDeps?: StatusDeps;
contextPackDeps?: StatusDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
@@ -151,6 +153,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(askCommand(depsOverride?.askDeps));
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
program.addCommand(specsCommand(depsOverride?.specsDeps));
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
program.addCommand(configCommand(depsOverride?.configPath));
program.addCommand(preflightCommand());
+251
View File
@@ -0,0 +1,251 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family tests.
//
// Stands up a small in-memory daemon mock for the /api/context-packs/*
// surface and exercises each subcommand against it.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { contextPackCommand } from "../src/commands/context-pack.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander.exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const FIXTURE_PACK = {
id: "context-pack:smoke:1",
kind: "context-pack" as const,
name: "smoke",
version: "1",
purpose: "Smoke test pack",
sourceType: "user_file" as const,
sourcePath: "/home/op/.openrig/context-packs/smoke",
relativePath: "smoke",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 100,
files: [
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
],
};
const FIXTURE_PREVIEW = {
id: FIXTURE_PACK.id,
name: FIXTURE_PACK.name,
version: FIXTURE_PACK.version,
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
bundleBytes: 110,
estimatedTokens: 28,
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
missingFiles: [] as Array<{ path: string; role: string }>,
};
describe("rig context-pack CLI (PL-014)", () => {
let server: http.Server;
let port: number;
let sendLog: Array<{ id: string; body: unknown }>;
let sendBehavior: "ok" | "fail" = "ok";
beforeAll(async () => {
sendLog = [];
server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
const url = req.url ?? "";
if (url === "/api/context-packs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PACK]));
return;
}
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
return;
}
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
if (idMatch) {
const id = decodeURIComponent(idMatch[1]!);
const sub = idMatch[3];
if (id !== FIXTURE_PACK.id) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
return;
}
if (sub === "preview" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PREVIEW));
return;
}
if (sub === "send" && req.method === "POST") {
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
sendLog.push({ id, body: parsed });
if (sendBehavior === "fail") {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
...FIXTURE_PREVIEW,
destinationSession: parsed.destinationSession,
dryRun: !!parsed.dryRun,
...(parsed.dryRun ? {} : { sent: true }),
}));
return;
}
if (!sub && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PACK));
return;
}
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(contextPackCommand(runningDeps(port)));
return prog;
}
it("list shows discovered packs", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("smoke");
expect(logs.join("\n")).toContain("1 files");
});
it("list --json emits JSON", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
});
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
expect(parsed[0]!.name).toBe("smoke");
});
it("show resolves by name", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
});
expect(logs.join("\n")).toContain("Name: smoke");
expect(logs.join("\n")).toContain("Smoke test pack");
});
it("show fails with helpful error on unknown name", async () => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("not found in library");
});
it("preview prints the assembled bundle text", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
});
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
expect(logs.join("\n")).toContain("Smoke body");
});
it("send --dry-run does not invoke the daemon send path twice", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog).toHaveLength(1);
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
expect(logs.join("\n")).toContain("(dry-run)");
});
it("send (real) reports successful delivery", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
expect(logs.join("\n")).toContain("Sent smoke v1");
});
it("send fails fast when daemon returns 502", async () => {
sendLog.length = 0;
sendBehavior = "fail";
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("Session not found");
sendBehavior = "ok";
});
it("sync reports indexed count", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("Indexed 1 context pack");
});
});
@@ -0,0 +1,111 @@
// Rig Context / Composable Context Injection v0 (PL-014) — bundle
// assembler.
//
// Concatenates a context_pack's included files into a single coherent
// paste-ready string. Each file gets a `## File: <path> (role: <role>)`
// header so the destination seat can recognize the bundle structure.
// The full bundle leads with a one-line manifest summary so the
// destination has at-a-glance context.
//
// One coherent paste, NOT N separate sends — matches PRD § Item 5: "the
// seat receives the pack as a single coherent priming injection."
import { readFileSync } from "node:fs";
import { ContextPackError, type ContextPackEntry } from "./context-pack-types.js";
import { estimateTokensFromBytes } from "./context-pack-library-service.js";
export interface AssembledBundle {
/** Concatenated bundle string ready for SessionTransport.send. */
text: string;
/** Total length in bytes of the assembled string (UTF-8 encoded). */
bytes: number;
/** Daemon-derived estimate (chars / 4 rounded). */
estimatedTokens: number;
/** Per-file metadata threaded through assembly for the dry-run preview. */
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
/** Files that were referenced in the manifest but missing on disk;
* surfaced as a warning in the preview rather than a hard fail so
* operator can repair. */
missingFiles: Array<{ path: string; role: string }>;
}
export interface AssembleOpts {
packEntry: ContextPackEntry;
/** Defaults to readFileSync; injected for tests. */
readFile?: (absPath: string) => string;
}
const PACK_HEADER_PREFIX = "# OpenRig Context Pack:";
const FILE_HEADER_PREFIX = "## File:";
/**
* Assembles a context pack into a single paste-ready string.
*
* Frame:
* # OpenRig Context Pack: <name> v<version>
* <purpose, if any>
*
* ## File: <path> (role: <role>)
* <file contents>
*
* ## File: <path> (role: <role>)
* <file contents>
*
* ...
*
* Each file is separated by a blank line so adjacent contents don't
* accidentally merge into a contiguous markdown block. Missing files
* are skipped (operator sees them in `missingFiles` for repair).
*/
export function assembleBundle(opts: AssembleOpts): AssembledBundle {
const { packEntry } = opts;
const reader = opts.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
const sections: string[] = [];
sections.push(`${PACK_HEADER_PREFIX} ${packEntry.name} v${packEntry.version}`);
if (packEntry.purpose) {
sections.push(packEntry.purpose.trim());
}
const files: AssembledBundle["files"] = [];
const missingFiles: AssembledBundle["missingFiles"] = [];
for (const f of packEntry.files) {
if (f.absolutePath === null) {
missingFiles.push({ path: f.path, role: f.role });
continue;
}
let content: string;
try {
content = reader(f.absolutePath);
} catch (err) {
throw new ContextPackError(
"file_read_failed",
`failed to read pack file ${f.absolutePath}: ${(err as Error).message}`,
{ packId: packEntry.id, path: f.path },
);
}
const headerLine = f.summary
? `${FILE_HEADER_PREFIX} ${f.path} (role: ${f.role}) — ${f.summary}`
: `${FILE_HEADER_PREFIX} ${f.path} (role: ${f.role})`;
sections.push(headerLine);
sections.push(content.trimEnd());
const bytes = Buffer.byteLength(content, "utf-8");
files.push({
path: f.path,
role: f.role,
bytes,
estimatedTokens: estimateTokensFromBytes(bytes),
});
}
const text = sections.join("\n\n") + "\n";
const bytes = Buffer.byteLength(text, "utf-8");
return {
text,
bytes,
estimatedTokens: estimateTokensFromBytes(bytes),
files,
missingFiles,
};
}
@@ -0,0 +1,182 @@
// Rig Context / Composable Context Injection v0 (PL-014) — library
// service.
//
// Walks the configured discovery roots, parses each pack's
// manifest.yaml, and emits ContextPackEntry records ready for the
// daemon HTTP routes + UI library + send mechanism. Workspace-surface
// reconciliation: the operator's filesystem edit always wins on next
// scan() (matches PL-004 Phase D's contract for workflow_specs).
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { parseManifest } from "./manifest-parser.js";
import {
ContextPackError,
type ContextPackEntry,
type ContextPackEntryFile,
type ContextPackSourceType,
} from "./context-pack-types.js";
export interface ContextPackLibraryRoot {
/** Absolute path to a directory whose immediate children are pack dirs. */
path: string;
sourceType: ContextPackSourceType;
}
export interface ContextPackLibraryOpts {
roots: ContextPackLibraryRoot[];
}
/** Stable id format: context-pack:<name>:<version>. */
export function contextPackId(name: string, version: string): string {
return `context-pack:${name}:${version}`;
}
export function parseContextPackId(id: string): { name: string; version: string } | null {
if (!id.startsWith("context-pack:")) return null;
const rest = id.slice("context-pack:".length);
const last = rest.lastIndexOf(":");
if (last === -1) return null;
return { name: rest.slice(0, last), version: rest.slice(last + 1) };
}
/** Daemon-derived per-file token estimate. Same heuristic the existing
* context-usage-store uses (≈4 chars/token); cheap, stable, no
* dependency on a tokenizer library. */
export function estimateTokensFromBytes(bytes: number): number {
return Math.ceil(bytes / 4);
}
export class ContextPackLibraryService {
private entries = new Map<string, ContextPackEntry>();
private readonly roots: ContextPackLibraryRoot[];
constructor(opts: ContextPackLibraryOpts) {
this.roots = opts.roots;
}
/** Re-walk all roots, replace the in-memory index, return a count. */
scan(): { count: number; errors: Array<{ source: string; error: string }> } {
const next = new Map<string, ContextPackEntry>();
const errors: Array<{ source: string; error: string }> = [];
for (const root of this.roots) {
let dirents: import("node:fs").Dirent[];
try {
dirents = existsSync(root.path)
? readdirSync(root.path, { withFileTypes: true })
: [];
} catch {
continue;
}
for (const dirent of dirents) {
if (!dirent.isDirectory()) continue;
const packDir = join(root.path, dirent.name);
const manifestPath = join(packDir, "manifest.yaml");
if (!existsSync(manifestPath)) continue;
try {
const entry = this.readPackEntry(packDir, manifestPath, root);
// Last-wins: workspace > user_file > builtin in the discovery
// order configured by startup.
next.set(entry.id, entry);
} catch (err) {
errors.push({
source: packDir,
error: err instanceof ContextPackError
? `${err.code}: ${err.message}`
: (err as Error).message,
});
}
}
}
this.entries = next;
return { count: next.size, errors };
}
list(): ContextPackEntry[] {
return Array.from(this.entries.values()).sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
}
get(id: string): ContextPackEntry | null {
return this.entries.get(id) ?? null;
}
getByNameVersion(name: string, version: string): ContextPackEntry | null {
return this.entries.get(contextPackId(name, version)) ?? null;
}
/** Resolve the absolute file path for a pack entry's file, with a
* containment check that prevents path-traversal escaping the pack
* directory. */
resolveFileWithinPack(packEntry: ContextPackEntry, relPath: string): string {
if (relPath.includes("..") || relPath.startsWith("/")) {
throw new ContextPackError(
"file_outside_pack",
`relative path '${relPath}' must be inside the pack directory (no '..', no leading '/')`,
{ packId: packEntry.id, relPath },
);
}
const abs = join(packEntry.sourcePath, relPath);
if (!abs.startsWith(packEntry.sourcePath + "/") && abs !== packEntry.sourcePath) {
throw new ContextPackError(
"file_outside_pack",
`resolved path '${abs}' falls outside pack '${packEntry.sourcePath}'`,
{ packId: packEntry.id, relPath, resolved: abs },
);
}
return abs;
}
private readPackEntry(
packDir: string,
manifestPath: string,
root: ContextPackLibraryRoot,
): ContextPackEntry {
const raw = readFileSync(manifestPath, "utf-8");
const manifest = parseManifest(raw, manifestPath);
let mostRecentMtime = 0;
try {
mostRecentMtime = statSync(manifestPath).mtimeMs;
} catch { /* unreadable manifest stat → fall back to 0 */ }
const files: ContextPackEntryFile[] = manifest.files.map((mf) => {
const abs = join(packDir, mf.path);
let bytes: number | null = null;
let mtime = 0;
try {
const st = statSync(abs);
bytes = st.size;
mtime = st.mtimeMs;
} catch {
bytes = null;
}
if (mtime > mostRecentMtime) mostRecentMtime = mtime;
return {
path: mf.path,
role: mf.role,
summary: mf.summary ?? null,
absolutePath: bytes === null ? null : abs,
bytes,
estimatedTokens: bytes === null ? null : estimateTokensFromBytes(bytes),
};
});
const derivedEstimatedTokens = files.reduce((acc, f) => acc + (f.estimatedTokens ?? 0), 0);
return {
id: contextPackId(manifest.name, manifest.version),
kind: "context-pack",
name: manifest.name,
version: manifest.version,
purpose: manifest.purpose ?? null,
sourceType: root.sourceType,
sourcePath: packDir,
relativePath: relative(root.path, packDir) || ".",
updatedAt: new Date(mostRecentMtime || Date.now()).toISOString(),
manifestEstimatedTokens: typeof manifest.estimatedTokens === "number" ? manifest.estimatedTokens : null,
derivedEstimatedTokens,
files,
};
}
}
@@ -0,0 +1,91 @@
// Rig Context / Composable Context Injection v0 (PL-014) — typed primitive
// shared types.
//
// A context_pack is a directory containing `manifest.yaml` + included
// markdown / yaml / txt files. Operator-authored, library-discoverable,
// reviewable, sendable. Parallel to skills + workflow_specs in shape.
//
// MVP single-host context: storage filesystem-canonical at
// ~/.openrig/context-packs/<name>/ (host) + workspace-local
// .openrig/context-packs/<name>/. NO new SQLite tables; library cache
// is in-memory at the daemon scope.
export interface ContextPackManifestFile {
/** Relative path within the pack directory. */
path: string;
/** Free-form operator-defined role (e.g., "prd", "proof-packet",
* "architecture-brief"); closed enum at v1+ if patterns emerge. */
role: string;
/** One-line description rendered in the review pane and bundle frame. */
summary?: string;
}
export interface ContextPackManifest {
name: string;
version: string;
purpose?: string;
files: ContextPackManifestFile[];
/** Operator-supplied estimate (used as a hint when no per-file
* estimate is available); the library service computes a derived
* `derivedEstimatedTokens` from actual file sizes for display. */
estimatedTokens?: number;
}
export type ContextPackSourceType = "builtin" | "user_file" | "workspace";
/** Library-shaped record emitted by ContextPackLibraryService. */
export interface ContextPackEntry {
/** Stable identifier for routes / UI navigation:
* `context-pack:<name>:<version>`. */
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: ContextPackSourceType;
/** Absolute path to the pack directory. */
sourcePath: string;
/** Path relative to the discovery root that found this pack. */
relativePath: string;
/** ISO timestamp of the most recent mtime under the pack dir
* (manifest.yaml or any included file — whichever is newer). */
updatedAt: string;
/** Operator-supplied estimate from the manifest, or null. */
manifestEstimatedTokens: number | null;
/** Daemon-derived estimate from actual file sizes (chars / 4
* rounded — matches the existing context-usage convention). */
derivedEstimatedTokens: number;
/** Per-file metadata projected from the manifest + on-disk reads. */
files: ContextPackEntryFile[];
}
export interface ContextPackEntryFile {
path: string;
role: string;
summary: string | null;
/** Absolute path to the file on disk; null when manifest references
* a file that doesn't exist (the entry surfaces honestly with
* bytes=null instead of refusing to index). */
absolutePath: string | null;
/** File size in bytes; null when missing or unreadable. */
bytes: number | null;
/** Daemon-derived per-file token estimate (chars / 4 rounded). */
estimatedTokens: number | null;
}
export class ContextPackError extends Error {
constructor(
public readonly code:
| "manifest_missing"
| "manifest_parse_error"
| "manifest_invalid"
| "pack_not_found"
| "file_outside_pack"
| "file_read_failed",
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ContextPackError";
}
}
@@ -0,0 +1,121 @@
// Rig Context / Composable Context Injection v0 (PL-014) — manifest
// parser.
//
// Parses ~/.openrig/context-packs/<name>/manifest.yaml into a typed
// shape with structured-error rejects on malformed input. Pure
// (no fs touches in the parser itself; caller hands in raw YAML).
import { parse as parseYaml } from "yaml";
import { ContextPackError, type ContextPackManifest, type ContextPackManifestFile } from "./context-pack-types.js";
const ALLOWED_FILE_SUFFIXES = [".md", ".markdown", ".yaml", ".yml", ".txt"];
export function parseManifest(rawYaml: string, sourcePath: string): ContextPackManifest {
let parsed: unknown;
try {
parsed = parseYaml(rawYaml);
} catch (err) {
throw new ContextPackError(
"manifest_parse_error",
`manifest at ${sourcePath} is not valid YAML: ${(err as Error).message}`,
{ sourcePath },
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} must be a YAML object at the root`,
{ sourcePath },
);
}
const obj = parsed as Record<string, unknown>;
const name = obj["name"];
if (typeof name !== "string" || name.length === 0) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} is missing required field 'name' (string)`,
{ sourcePath },
);
}
// Version may be a number or string in YAML; normalize to string for
// round-tripping with library ids and `<name>:<version>` formatting.
const versionRaw = obj["version"];
if (versionRaw === undefined || versionRaw === null) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} is missing required field 'version'`,
{ sourcePath },
);
}
const version = String(versionRaw);
const purpose = typeof obj["purpose"] === "string" ? (obj["purpose"] as string) : undefined;
const filesRaw = obj["files"];
if (!Array.isArray(filesRaw)) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} must declare 'files: [...]' (got: ${typeof filesRaw})`,
{ sourcePath },
);
}
const files: ContextPackManifestFile[] = [];
for (let i = 0; i < filesRaw.length; i++) {
const f = filesRaw[i];
if (!f || typeof f !== "object" || Array.isArray(f)) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} has malformed entry at files[${i}] (must be an object with 'path' + 'role')`,
{ sourcePath, index: i },
);
}
const fr = f as Record<string, unknown>;
const path = fr["path"];
if (typeof path !== "string" || path.length === 0) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} files[${i}] missing 'path' (string)`,
{ sourcePath, index: i },
);
}
if (path.includes("..") || path.startsWith("/")) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} files[${i}].path '${path}' must be a relative path inside the pack (no '..' segments, no leading '/')`,
{ sourcePath, index: i, path },
);
}
const role = fr["role"];
if (typeof role !== "string" || role.length === 0) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} files[${i}] missing 'role' (string)`,
{ sourcePath, index: i, path },
);
}
if (!ALLOWED_FILE_SUFFIXES.some((s) => path.endsWith(s))) {
throw new ContextPackError(
"manifest_invalid",
`manifest at ${sourcePath} files[${i}].path '${path}' has an unsupported suffix; allowed: ${ALLOWED_FILE_SUFFIXES.join(", ")}`,
{ sourcePath, index: i, path },
);
}
const summary = typeof fr["summary"] === "string" ? (fr["summary"] as string) : undefined;
files.push(summary === undefined ? { path, role } : { path, role, summary });
}
const estimatedTokensRaw = obj["estimated_tokens"] ?? obj["estimatedTokens"];
const estimatedTokens = typeof estimatedTokensRaw === "number" && Number.isFinite(estimatedTokensRaw)
? Math.max(0, Math.floor(estimatedTokensRaw))
: undefined;
return {
name,
version,
...(purpose !== undefined ? { purpose } : {}),
files,
...(estimatedTokens !== undefined ? { estimatedTokens } : {}),
};
}
+138
View File
@@ -0,0 +1,138 @@
// Rig Context / Composable Context Injection v0 (PL-014) — daemon HTTP
// routes for context_packs.
//
// Endpoints:
// GET /api/context-packs/library — list all packs
// POST /api/context-packs/library/sync — re-walk discovery roots
// GET /api/context-packs/library/:id — pack manifest + files
// GET /api/context-packs/library/:id/preview — assembled bundle (dry-run shape)
// POST /api/context-packs/library/:id/send — send to a destination session
//
// :id is `context-pack:<name>:<version>` — same shape as
// SpecLibraryEntry.id for workflows, so URL-encoding rules carry over.
import { Hono } from "hono";
import type { ContextPackLibraryService } from "../domain/context-packs/context-pack-library-service.js";
import type { SessionTransport } from "../domain/session-transport.js";
import { assembleBundle } from "../domain/context-packs/bundle-assembler.js";
interface SendBody {
destinationSession?: string;
/** When true, returns the assembled bundle without invoking SessionTransport. */
dryRun?: boolean;
}
export function contextPacksRoutes(): Hono {
const router = new Hono();
// GET /library
router.get("/library", (c) => {
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
return c.json(lib.list());
});
// POST /library/sync
router.post("/library/sync", (c) => {
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
const result = lib.scan();
return c.json({ ...result, entries: lib.list() });
});
// GET /library/:id
router.get("/library/:id", (c) => {
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
const id = decodeURIComponent(c.req.param("id"));
// Guard: literal /sync is handled above; reject as 404 here so a
// pack with id="sync" (which can't exist since ids carry a colon)
// doesn't shadow the literal.
if (id === "sync") return c.notFound();
const entry = lib.get(id);
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
return c.json(entry);
});
// GET /library/:id/preview — assembled bundle (read-only, no send)
router.get("/library/:id/preview", (c) => {
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
const id = decodeURIComponent(c.req.param("id"));
const entry = lib.get(id);
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
try {
const bundle = assembleBundle({ packEntry: entry });
return c.json({
id,
name: entry.name,
version: entry.version,
bundleText: bundle.text,
bundleBytes: bundle.bytes,
estimatedTokens: bundle.estimatedTokens,
files: bundle.files,
missingFiles: bundle.missingFiles,
});
} catch (err) {
return c.json({ error: (err as Error).message }, 500);
}
});
// POST /library/:id/send
router.post("/library/:id/send", async (c) => {
const lib = c.get("contextPackLibrary" as never) as ContextPackLibraryService | undefined;
const transport = c.get("sessionTransport" as never) as SessionTransport | undefined;
if (!lib) return c.json({ error: "context_pack_library_unavailable" }, 503);
if (!transport) return c.json({ error: "session_transport_unavailable" }, 503);
const id = decodeURIComponent(c.req.param("id"));
const entry = lib.get(id);
if (!entry) return c.json({ error: `Context pack '${id}' not found in library` }, 404);
const body = await c.req.json<SendBody>().catch(() => ({} as SendBody));
const destinationSession = body.destinationSession;
const dryRun = !!body.dryRun;
if (typeof destinationSession !== "string" || destinationSession.length === 0) {
return c.json({
error: "destinationSession_required",
hint: "POST body must include { destinationSession: <session-name> }",
}, 400);
}
let bundle;
try {
bundle = assembleBundle({ packEntry: entry });
} catch (err) {
return c.json({ error: (err as Error).message }, 500);
}
const previewPayload = {
id,
name: entry.name,
version: entry.version,
destinationSession,
bundleBytes: bundle.bytes,
estimatedTokens: bundle.estimatedTokens,
files: bundle.files,
missingFiles: bundle.missingFiles,
dryRun,
};
if (dryRun) {
return c.json({ ...previewPayload, bundleText: bundle.text });
}
const result = await transport.send(destinationSession, bundle.text);
if (!result.ok) {
return c.json({
...previewPayload,
sent: false,
error: result.error ?? result.reason ?? "send_failed",
reason: result.reason,
}, 502);
}
return c.json({ ...previewPayload, sent: true });
});
return router;
}
+5
View File
@@ -54,6 +54,7 @@ import type { AskService } from "./domain/ask-service.js";
import { specReviewRoutes } from "./routes/spec-review.js";
import { specLibraryRoutes } from "./routes/spec-library.js";
import { configRoutes } from "./routes/config.js";
import { contextPacksRoutes } from "./routes/context-packs.js";
import type { SpecReviewService } from "./domain/spec-review-service.js";
import type { SpecLibraryService } from "./domain/spec-library-service.js";
import type { ChatRepository } from "./domain/chat-repository.js";
@@ -182,6 +183,8 @@ export interface AppDeps {
specLibraryService?: SpecLibraryService;
/** Workflows in Spec Library v0 — active workflow lens persistence. */
activeLensStore?: import("./domain/active-lens-store.js").ActiveLensStore;
/** Rig Context / Composable Context Injection v0 (PL-014) — context_packs library service. */
contextPackLibrary?: import("./domain/context-packs/context-pack-library-service.js").ContextPackLibraryService;
whoamiService?: WhoamiService;
contextUsageStore?: import("./domain/context-usage-store.js").ContextUsageStore;
contextMonitor?: { pollOnce(): Promise<void> };
@@ -359,6 +362,7 @@ export function createApp(deps: AppDeps): Hono {
c.set("specReviewService" as never, deps.specReviewService);
c.set("specLibraryService" as never, deps.specLibraryService);
c.set("activeLensStore" as never, deps.activeLensStore);
c.set("contextPackLibrary" as never, deps.contextPackLibrary);
c.set("whoamiService" as never, deps.whoamiService);
c.set("contextUsageStore" as never, deps.contextUsageStore);
c.set("contextMonitor" as never, deps.contextMonitor);
@@ -401,6 +405,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/specs/review", specReviewRoutes());
app.route("/api/specs/library", specLibraryRoutes());
app.route("/api/config", configRoutes());
app.route("/api/context-packs", contextPacksRoutes());
app.route("/api/whoami", whoamiRoutes());
app.route("/api/seat", seatRoutes);
app.route("/api/rigs/:rigId/chat", chatRoutes());
+40
View File
@@ -75,6 +75,8 @@ import { WorkflowRuntime } from "./domain/workflow-runtime.js";
import { makeWorkflowKeepalivePolicy } from "./domain/policies/workflow-keepalive.js";
import { SpecReviewService } from "./domain/spec-review-service.js";
import { SpecLibraryService } from "./domain/spec-library-service.js";
import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js";
import { SettingsStore as ContextPackSettingsStore } from "./domain/user-settings/settings-store.js";
import { WhoamiService } from "./domain/whoami-service.js";
import { NodeCmuxService } from "./domain/node-cmux-service.js";
import { createApp, type AppDeps } from "./server.js";
@@ -545,6 +547,44 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
lib.scan();
return lib;
})(),
contextPackLibrary: (() => {
// Rig Context / Composable Context Injection v0 (PL-014).
// Discovery roots:
// 1. ~/.openrig/context-packs/ (host-global; user_file)
// 2. <workspace>/.openrig/context-packs/ (workspace-local; workspace)
// Workspace path comes from the workspace.root setting if set;
// otherwise inherits from ~/.openrig/workspace/. Both are
// optional — empty roots → empty library.
const userPacksRoot = getDefaultOpenRigPath("context-packs");
try { fs.mkdirSync(userPacksRoot, { recursive: true }); } catch { /* best-effort */ }
const roots: Array<{ path: string; sourceType: "builtin" | "user_file" | "workspace" }> = [
{ path: userPacksRoot, sourceType: "user_file" },
];
// Workspace-local context-packs/ — best-effort lookup off the
// workspace.root setting. If unset, falls back to the default
// workspace path which is the same parent as user packs (skipped
// as a duplicate). Last-wins ordering: workspace > user_file
// (operator's per-project edits win on collision).
try {
const settingsStore = new ContextPackSettingsStore();
const cfg = settingsStore.resolveConfig();
const workspacePacksRoot = nodePath.join(cfg.workspaceRoot, ".openrig", "context-packs");
if (workspacePacksRoot !== userPacksRoot && fs.existsSync(workspacePacksRoot)) {
roots.push({ path: workspacePacksRoot, sourceType: "workspace" });
}
} catch {
// Settings unavailable in this build path → use only user_file root.
}
// Bundled built-in packs would live under packages/daemon/context-packs/
// when shipped; absent today (no built-in packs at v0).
const builtinPacksRoot = nodePath.resolve(import.meta.dirname, "../context-packs");
if (fs.existsSync(builtinPacksRoot)) {
roots.unshift({ path: builtinPacksRoot, sourceType: "builtin" });
}
const lib = new ContextPackLibraryService({ roots });
lib.scan();
return lib;
})(),
};
// Copy bundled reference docs to ~/.openrig/reference/ so agents can find them at a stable path
@@ -0,0 +1,98 @@
// Rig Context / Composable Context Injection v0 (PL-014) — bundle
// assembler tests.
import { describe, it, expect } from "vitest";
import { assembleBundle } from "../src/domain/context-packs/bundle-assembler.js";
import type { ContextPackEntry } from "../src/domain/context-packs/context-pack-types.js";
function makeEntry(opts: Partial<ContextPackEntry> & { files: ContextPackEntry["files"]; purpose?: string | null }): ContextPackEntry {
return {
id: opts.id ?? "context-pack:test:1",
kind: "context-pack",
name: opts.name ?? "test",
version: opts.version ?? "1",
purpose: opts.purpose ?? null,
sourceType: opts.sourceType ?? "user_file",
sourcePath: opts.sourcePath ?? "/tmp/test",
relativePath: opts.relativePath ?? "test",
updatedAt: opts.updatedAt ?? "2026-05-04T00:00:00Z",
manifestEstimatedTokens: opts.manifestEstimatedTokens ?? null,
derivedEstimatedTokens: opts.derivedEstimatedTokens ?? 0,
files: opts.files,
};
}
describe("assembleBundle", () => {
it("frames the bundle with name + version + per-file headers", () => {
const entry = makeEntry({
name: "alpha",
version: "1",
purpose: "Alpha pack purpose",
files: [
{ path: "prd.md", role: "prd", summary: "PRD summary", absolutePath: "/abs/prd.md", bytes: 12, estimatedTokens: 3 },
{ path: "notes.md", role: "notes", summary: null, absolutePath: "/abs/notes.md", bytes: 5, estimatedTokens: 2 },
],
});
const reads: Record<string, string> = {
"/abs/prd.md": "## PRD body",
"/abs/notes.md": "Note 1",
};
const bundle = assembleBundle({
packEntry: entry,
readFile: (p) => reads[p]!,
});
expect(bundle.text).toContain("# OpenRig Context Pack: alpha v1");
expect(bundle.text).toContain("Alpha pack purpose");
expect(bundle.text).toContain("## File: prd.md (role: prd) — PRD summary");
expect(bundle.text).toContain("## File: notes.md (role: notes)");
expect(bundle.text).toContain("## PRD body");
expect(bundle.text).toContain("Note 1");
expect(bundle.bytes).toBe(Buffer.byteLength(bundle.text, "utf-8"));
});
it("skips files marked missing (absolutePath null) and reports them", () => {
const entry = makeEntry({
files: [
{ path: "present.md", role: "r", summary: null, absolutePath: "/abs/present.md", bytes: 5, estimatedTokens: 2 },
{ path: "absent.md", role: "r", summary: null, absolutePath: null, bytes: null, estimatedTokens: null },
],
});
const reads: Record<string, string> = { "/abs/present.md": "content" };
const bundle = assembleBundle({ packEntry: entry, readFile: (p) => reads[p]! });
expect(bundle.files).toHaveLength(1);
expect(bundle.files[0]!.path).toBe("present.md");
expect(bundle.missingFiles).toEqual([{ path: "absent.md", role: "r" }]);
expect(bundle.text).not.toContain("absent.md");
});
it("estimates token count from byte count of the assembled text", () => {
const entry = makeEntry({
files: [
{ path: "a.md", role: "r", summary: null, absolutePath: "/abs/a.md", bytes: 100, estimatedTokens: 25 },
],
});
const bundle = assembleBundle({
packEntry: entry,
readFile: () => "x".repeat(100),
});
// bundle text ≈ headers + 100 chars ; estimateTokens = ceil(bytes / 4)
expect(bundle.estimatedTokens).toBeGreaterThanOrEqual(25);
});
it("preserves operator-supplied purpose verbatim (trimmed)", () => {
const entry = makeEntry({
purpose: " Multi-line purpose\nthat spans ",
files: [],
});
const bundle = assembleBundle({ packEntry: entry, readFile: () => "" });
expect(bundle.text).toContain("Multi-line purpose\nthat spans");
});
it("handles a pack with no files (empty bundle, no crash)", () => {
const entry = makeEntry({ files: [] });
const bundle = assembleBundle({ packEntry: entry, readFile: () => "" });
expect(bundle.files).toEqual([]);
expect(bundle.missingFiles).toEqual([]);
expect(bundle.text).toContain("# OpenRig Context Pack: test v1");
});
});
@@ -0,0 +1,204 @@
// Rig Context / Composable Context Injection v0 (PL-014) — library
// service tests.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ContextPackLibraryService,
contextPackId,
parseContextPackId,
estimateTokensFromBytes,
} from "../src/domain/context-packs/context-pack-library-service.js";
function writePack(root: string, name: string, manifest: string, files: Record<string, string>) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "manifest.yaml"), manifest);
for (const [path, content] of Object.entries(files)) {
writeFileSync(join(dir, path), content);
}
}
describe("ContextPackLibraryService", () => {
let tmp: string;
let userRoot: string;
let workspaceRoot: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "context-pack-lib-"));
userRoot = join(tmp, "user");
workspaceRoot = join(tmp, "workspace");
mkdirSync(userRoot, { recursive: true });
mkdirSync(workspaceRoot, { recursive: true });
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("scans a pack and emits a normalized entry", () => {
writePack(userRoot, "smoke", `
name: smoke
version: 1
purpose: Smoke pack
files:
- path: notes.md
role: notes
summary: Smoke notes
`, { "notes.md": "# Smoke\n\nHello world." });
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
const result = lib.scan();
expect(result.count).toBe(1);
expect(result.errors).toEqual([]);
const entries = lib.list();
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry.id).toBe(contextPackId("smoke", "1"));
expect(entry.name).toBe("smoke");
expect(entry.kind).toBe("context-pack");
expect(entry.purpose).toBe("Smoke pack");
expect(entry.files).toHaveLength(1);
expect(entry.files[0]!.bytes).toBeGreaterThan(0);
expect(entry.files[0]!.estimatedTokens).toBeGreaterThan(0);
expect(entry.derivedEstimatedTokens).toBe(entry.files[0]!.estimatedTokens);
});
it("surfaces missing files with bytes=null instead of refusing the entry", () => {
writePack(userRoot, "missing", `
name: missing
version: 1
files:
- path: present.md
role: r
- path: absent.md
role: r
`, { "present.md": "data" });
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
lib.scan();
const entry = lib.getByNameVersion("missing", "1")!;
expect(entry).toBeDefined();
const present = entry.files.find((f) => f.path === "present.md")!;
const absent = entry.files.find((f) => f.path === "absent.md")!;
expect(present.bytes).toBeGreaterThan(0);
expect(absent.bytes).toBeNull();
expect(absent.estimatedTokens).toBeNull();
});
it("workspace root wins on collision (last in roots array)", () => {
const sameManifest = `
name: collision
version: 1
files:
- path: notes.md
role: r
`;
writePack(userRoot, "collision", sameManifest, { "notes.md": "user content" });
writePack(workspaceRoot, "collision", sameManifest, { "notes.md": "workspace content" });
const lib = new ContextPackLibraryService({
roots: [
{ path: userRoot, sourceType: "user_file" },
{ path: workspaceRoot, sourceType: "workspace" },
],
});
lib.scan();
const entry = lib.getByNameVersion("collision", "1")!;
expect(entry.sourceType).toBe("workspace");
expect(entry.sourcePath).toContain("/workspace/");
});
it("captures parse errors instead of throwing them out of scan", () => {
writePack(userRoot, "broken", "{not valid yaml", { "notes.md": "x" });
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
const result = lib.scan();
expect(result.count).toBe(0);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]!.error).toContain("manifest_parse_error");
});
it("ignores directories without manifest.yaml", () => {
mkdirSync(join(userRoot, "not-a-pack"));
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
const result = lib.scan();
expect(result.count).toBe(0);
});
it("re-scan reflects filesystem edits (workspace-surface reconciliation)", () => {
writePack(userRoot, "evolve", `
name: evolve
version: 1
files:
- path: a.md
role: r
`, { "a.md": "initial" });
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
lib.scan();
expect(lib.list()).toHaveLength(1);
// Operator edits the manifest to bump version.
writeFileSync(join(userRoot, "evolve", "manifest.yaml"), `
name: evolve
version: 2
files:
- path: a.md
role: r
`);
lib.scan();
const list = lib.list();
expect(list).toHaveLength(1);
expect(list[0]!.version).toBe("2");
});
it("resolveFileWithinPack rejects path-traversal attempts", () => {
writePack(userRoot, "guard", `
name: guard
version: 1
files:
- path: notes.md
role: r
`, { "notes.md": "x" });
const lib = new ContextPackLibraryService({
roots: [{ path: userRoot, sourceType: "user_file" }],
});
lib.scan();
const entry = lib.getByNameVersion("guard", "1")!;
expect(() => lib.resolveFileWithinPack(entry, "../etc/passwd")).toThrow(/inside the pack/);
expect(() => lib.resolveFileWithinPack(entry, "/abs")).toThrow(/inside the pack/);
});
});
describe("contextPackId / parseContextPackId", () => {
it("encodes and decodes name:version", () => {
expect(contextPackId("foo", "1")).toBe("context-pack:foo:1");
expect(parseContextPackId("context-pack:foo:1")).toEqual({ name: "foo", version: "1" });
});
it("splits on the LAST colon so names with colons round-trip", () => {
const id = contextPackId("rsi:v2", "3");
expect(parseContextPackId(id)).toEqual({ name: "rsi:v2", version: "3" });
});
it("returns null for non-context-pack ids", () => {
expect(parseContextPackId("workflow:foo:1")).toBeNull();
expect(parseContextPackId("context-pack:no-version")).toBeNull();
});
});
describe("estimateTokensFromBytes", () => {
it("uses the chars/4 heuristic", () => {
expect(estimateTokensFromBytes(0)).toBe(0);
expect(estimateTokensFromBytes(4)).toBe(1);
expect(estimateTokensFromBytes(7)).toBe(2);
expect(estimateTokensFromBytes(100)).toBe(25);
});
});
@@ -0,0 +1,97 @@
// Rig Context / Composable Context Injection v0 (PL-014) — manifest
// parser unit tests.
import { describe, it, expect } from "vitest";
import { parseManifest } from "../src/domain/context-packs/manifest-parser.js";
import { ContextPackError } from "../src/domain/context-packs/context-pack-types.js";
const validManifest = `
name: pl-005-priming
version: 1
purpose: Priming for PL-005 Phase A
files:
- path: prd.md
role: prd
summary: Phase A PRD
- path: proof.md
role: proof-packet
estimated_tokens: 500
`;
describe("parseManifest", () => {
it("parses a valid manifest into the typed shape", () => {
const m = parseManifest(validManifest, "/test/manifest.yaml");
expect(m.name).toBe("pl-005-priming");
expect(m.version).toBe("1");
expect(m.purpose).toContain("Priming for PL-005");
expect(m.files).toHaveLength(2);
expect(m.files[0]).toEqual({ path: "prd.md", role: "prd", summary: "Phase A PRD" });
expect(m.files[1]).toEqual({ path: "proof.md", role: "proof-packet" });
expect(m.estimatedTokens).toBe(500);
});
it("normalizes numeric versions to strings", () => {
const m = parseManifest("name: x\nversion: 2\nfiles: []", "/x.yaml");
expect(m.version).toBe("2");
});
it("rejects non-YAML content with manifest_parse_error", () => {
expect(() => parseManifest("{not valid", "/x.yaml")).toThrow(ContextPackError);
try {
parseManifest("{not valid", "/x.yaml");
} catch (err) {
expect((err as ContextPackError).code).toBe("manifest_parse_error");
}
});
it("rejects missing name", () => {
expect(() => parseManifest("version: 1\nfiles: []", "/x.yaml")).toThrow(/name/);
});
it("rejects missing version", () => {
expect(() => parseManifest("name: x\nfiles: []", "/x.yaml")).toThrow(/version/);
});
it("rejects malformed files array", () => {
expect(() => parseManifest("name: x\nversion: 1\nfiles: not-an-array", "/x.yaml")).toThrow(/files/);
});
it("rejects file entry with .. in path (escape attempt)", () => {
const bad = "name: x\nversion: 1\nfiles:\n - path: ../escape.md\n role: notes\n";
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/relative path inside the pack/);
});
it("rejects file entry with absolute path", () => {
const bad = "name: x\nversion: 1\nfiles:\n - path: /etc/passwd\n role: notes\n";
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/relative path inside the pack/);
});
it("rejects file entry with unsupported suffix", () => {
const bad = "name: x\nversion: 1\nfiles:\n - path: code.ts\n role: code\n";
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/unsupported suffix/);
});
it("rejects file entry missing role", () => {
const bad = "name: x\nversion: 1\nfiles:\n - path: notes.md\n";
expect(() => parseManifest(bad, "/x.yaml")).toThrow(/missing 'role'/);
});
it("accepts allowed suffixes md/markdown/yaml/yml/txt", () => {
const ok = `name: x
version: 1
files:
- { path: a.md, role: r }
- { path: b.markdown, role: r }
- { path: c.yaml, role: r }
- { path: d.yml, role: r }
- { path: e.txt, role: r }
`;
const m = parseManifest(ok, "/x.yaml");
expect(m.files).toHaveLength(5);
});
it("ignores estimated_tokens when not a finite number", () => {
const m = parseManifest("name: x\nversion: 1\nfiles: []\nestimated_tokens: 'not-a-number'", "/x.yaml");
expect(m.estimatedTokens).toBeUndefined();
});
});
@@ -0,0 +1,253 @@
// Rig Context / Composable Context Injection v0 (PL-014) — daemon
// HTTP route tests.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { ContextPackLibraryService } from "../src/domain/context-packs/context-pack-library-service.js";
import { contextPacksRoutes } from "../src/routes/context-packs.js";
interface FakeSendResult {
ok: boolean;
sessionName: string;
reason?: string;
error?: string;
}
class FakeSessionTransport {
public calls: Array<{ sessionName: string; text: string }> = [];
public response: FakeSendResult = { ok: true, sessionName: "x" };
async send(sessionName: string, text: string): Promise<FakeSendResult> {
this.calls.push({ sessionName, text });
return { ...this.response, sessionName };
}
}
function writePack(root: string, name: string, manifest: string, files: Record<string, string>) {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "manifest.yaml"), manifest);
for (const [path, content] of Object.entries(files)) {
writeFileSync(join(dir, path), content);
}
}
describe("context-packs routes (PL-014)", () => {
let tmp: string;
let libRoot: string;
let lib: ContextPackLibraryService;
let transport: FakeSessionTransport;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "context-pack-routes-"));
libRoot = join(tmp, "lib");
mkdirSync(libRoot, { recursive: true });
lib = new ContextPackLibraryService({
roots: [{ path: libRoot, sourceType: "user_file" }],
});
transport = new FakeSessionTransport();
});
afterEach(() => rmSync(tmp, { recursive: true, force: true }));
function buildApp(opts?: { withTransport?: boolean; withLib?: boolean }): Hono {
const app = new Hono();
const withTransport = opts?.withTransport !== false;
const withLib = opts?.withLib !== false;
app.use("*", async (c, next) => {
if (withLib) c.set("contextPackLibrary" as never, lib);
if (withTransport) c.set("sessionTransport" as never, transport);
await next();
});
app.route("/api/context-packs", contextPacksRoutes());
return app;
}
it("GET /library returns 503 when service is not wired", async () => {
const app = buildApp({ withLib: false });
const res = await app.request("/api/context-packs/library");
expect(res.status).toBe(503);
});
it("GET /library returns the indexed packs", async () => {
writePack(libRoot, "smoke", `
name: smoke
version: 1
purpose: Smoke
files:
- path: notes.md
role: notes
`, { "notes.md": "Hello" });
lib.scan();
const app = buildApp();
const res = await app.request("/api/context-packs/library");
expect(res.status).toBe(200);
const body = await res.json() as Array<{ name: string }>;
expect(body).toHaveLength(1);
expect(body[0]!.name).toBe("smoke");
});
it("POST /library/sync re-indexes and returns the count + entries", async () => {
writePack(libRoot, "p1", `
name: p1
version: 1
files:
- path: a.md
role: r
`, { "a.md": "x" });
const app = buildApp();
const res = await app.request("/api/context-packs/library/sync", { method: "POST" });
expect(res.status).toBe(200);
const body = await res.json() as { count: number; entries: Array<{ name: string }> };
expect(body.count).toBe(1);
expect(body.entries[0]!.name).toBe("p1");
});
it("GET /library/:id returns the pack manifest", async () => {
writePack(libRoot, "p1", `
name: p1
version: 1
files:
- path: a.md
role: r
`, { "a.md": "content" });
lib.scan();
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:p1:1")}`);
expect(res.status).toBe(200);
const body = await res.json() as { name: string; files: unknown[] };
expect(body.name).toBe("p1");
expect(body.files).toHaveLength(1);
});
it("GET /library/:id returns 404 for unknown id", async () => {
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:missing:1")}`);
expect(res.status).toBe(404);
});
it("GET /library/:id/preview returns the assembled bundle", async () => {
writePack(libRoot, "preview-pack", `
name: preview-pack
version: 1
purpose: Preview test
files:
- path: a.md
role: r
`, { "a.md": "BODY-A" });
lib.scan();
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:preview-pack:1")}/preview`);
expect(res.status).toBe(200);
const body = await res.json() as { bundleText: string; bundleBytes: number; missingFiles: unknown[] };
expect(body.bundleText).toContain("# OpenRig Context Pack: preview-pack v1");
expect(body.bundleText).toContain("BODY-A");
expect(body.bundleBytes).toBeGreaterThan(0);
expect(body.missingFiles).toEqual([]);
});
it("POST /library/:id/send dry-run returns the bundle without invoking transport", async () => {
writePack(libRoot, "dry", `
name: dry
version: 1
files:
- path: a.md
role: r
`, { "a.md": "dry content" });
lib.scan();
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:dry:1")}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destinationSession: "test@rig", dryRun: true }),
});
expect(res.status).toBe(200);
const body = await res.json() as { dryRun: boolean; bundleText?: string; sent?: boolean };
expect(body.dryRun).toBe(true);
expect(body.bundleText).toContain("dry content");
expect(body.sent).toBeUndefined();
expect(transport.calls).toEqual([]);
});
it("POST /library/:id/send invokes SessionTransport.send with the assembled bundle", async () => {
writePack(libRoot, "real", `
name: real
version: 1
files:
- path: a.md
role: r
`, { "a.md": "real content" });
lib.scan();
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:real:1")}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destinationSession: "driver@rig" }),
});
expect(res.status).toBe(200);
const body = await res.json() as { sent: boolean };
expect(body.sent).toBe(true);
expect(transport.calls).toHaveLength(1);
expect(transport.calls[0]!.sessionName).toBe("driver@rig");
expect(transport.calls[0]!.text).toContain("real content");
});
it("POST /library/:id/send 502s when SessionTransport reports failure", async () => {
writePack(libRoot, "fail", `
name: fail
version: 1
files:
- path: a.md
role: r
`, { "a.md": "x" });
lib.scan();
transport.response = { ok: false, sessionName: "x", reason: "session_missing", error: "Session not found" };
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:fail:1")}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destinationSession: "missing@rig" }),
});
expect(res.status).toBe(502);
const body = await res.json() as { error: string; sent: boolean };
expect(body.error).toContain("Session not found");
expect(body.sent).toBe(false);
});
it("POST /library/:id/send 400s without destinationSession", async () => {
writePack(libRoot, "no-dest", `
name: no-dest
version: 1
files:
- path: a.md
role: r
`, { "a.md": "x" });
lib.scan();
const app = buildApp();
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:no-dest:1")}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("POST /library/:id/send 503s when SessionTransport is missing from context", async () => {
writePack(libRoot, "no-transport", `
name: no-transport
version: 1
files:
- path: a.md
role: r
`, { "a.md": "x" });
lib.scan();
const app = buildApp({ withTransport: false });
const res = await app.request(`/api/context-packs/library/${encodeURIComponent("context-pack:no-transport:1")}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destinationSession: "x" }),
});
expect(res.status).toBe(503);
});
});
@@ -20,6 +20,13 @@ import {
type LibraryAgentReview,
type LibraryWorkflowReview,
} from "../hooks/useSpecLibrary.js";
import {
useContextPackLibrary,
useContextPackPreview,
useContextPackSend,
type ContextPackEntry,
} from "../hooks/useContextPackLibrary.js";
import { usePsEntries } from "../hooks/usePsEntries.js";
import { useQueryClient } from "@tanstack/react-query";
import {
WorkflowHeader,
@@ -189,6 +196,17 @@ function LibraryRigReviewContent({ review }: { review: LibraryRigReview }) {
}
export function LibraryReview({ entryId }: LibraryReviewProps) {
// PL-014: context_packs live at /api/context-packs/library and have
// an id prefix of "context-pack:". Dispatch to the pack-specific
// review page before invoking useLibraryReview (which would 404
// against the spec-library route for context-pack ids).
if (entryId.startsWith("context-pack:")) {
return <LibraryContextPackReviewPage entryId={entryId} />;
}
return <LibrarySpecReview entryId={entryId} />;
}
function LibrarySpecReview({ entryId }: LibraryReviewProps) {
const navigate = useNavigate();
const { data: review, isLoading, error } = useLibraryReview(entryId);
@@ -465,3 +483,242 @@ function LibraryWorkflowReviewPage({ review }: { review: LibraryWorkflowReview }
</WorkspacePage>
);
}
// --- Rig Context / Composable Context Injection v0 (PL-014):
// context_pack review page ---
function LibraryContextPackReviewPage({ entryId }: { entryId: string }) {
const navigate = useNavigate();
const { data: packs = [], isLoading: packsLoading, error: packsError } = useContextPackLibrary();
const entry = packs.find((p) => p.id === entryId) ?? null;
const { data: preview, isLoading: previewLoading } = useContextPackPreview(entry ? entryId : null);
if (packsLoading) {
return (
<WorkspacePage>
<div className="font-mono text-[10px] text-stone-400">Loading context pack…</div>
</WorkspacePage>
);
}
if (packsError || !entry) {
return (
<WorkspacePage>
<div data-testid="library-review-error" className="space-y-4">
<WorkflowHeader
eyebrow="Library"
title="Context Pack Not Found"
description={(packsError as Error)?.message ?? `No context pack with id ${entryId}.`}
/>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
</div>
</WorkspacePage>
);
}
return <ContextPackReviewBody entry={entry} preview={preview} previewLoading={previewLoading} />;
}
function ContextPackReviewBody({
entry,
preview,
previewLoading,
}: {
entry: ContextPackEntry;
preview: ReturnType<typeof useContextPackPreview>["data"];
previewLoading: boolean;
}) {
const navigate = useNavigate();
const [showSendPicker, setShowSendPicker] = useState(false);
const [destinationSession, setDestinationSession] = useState<string>("");
const [sendError, setSendError] = useState<string | null>(null);
const [sendStatus, setSendStatus] = useState<"idle" | "dry-run-shown" | "sent">("idle");
const sendMutation = useContextPackSend();
const { data: psEntries = [] } = usePsEntries();
const runningSessions: string[] = (() => {
const sessions: string[] = [];
for (const rig of psEntries) {
const nodes = (rig as { nodes?: Array<{ canonicalSessionName?: string | null; sessionStatus?: string | null }> }).nodes ?? [];
for (const n of nodes) {
if (n.canonicalSessionName && n.sessionStatus === "running") sessions.push(n.canonicalSessionName);
}
}
return sessions.sort();
})();
const onDryRun = async () => {
setSendError(null);
if (!destinationSession) {
setSendError("Pick a destination session first.");
return;
}
try {
await sendMutation.mutateAsync({ id: entry.id, destinationSession, dryRun: true });
setSendStatus("dry-run-shown");
} catch (err) {
setSendError((err as Error).message);
}
};
const onSend = async () => {
setSendError(null);
if (!destinationSession) {
setSendError("Pick a destination session first.");
return;
}
try {
await sendMutation.mutateAsync({ id: entry.id, destinationSession, dryRun: false });
setSendStatus("sent");
} catch (err) {
setSendError((err as Error).message);
}
};
return (
<WorkspacePage>
<div data-testid="library-review-context-pack" className="space-y-4">
<WorkflowHeader
eyebrow={`Library — Context Pack${entry.sourceType === "builtin" ? " (built-in)" : ""}`}
title={entry.name}
description={entry.purpose ?? "Operator-authored composable context bundle."}
actions={
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
data-testid="context-pack-send-button"
onClick={() => setShowSendPicker((v) => !v)}
>
{showSendPicker ? "Hide Send" : "Send to seat"}
</Button>
<Button variant="outline" size="sm" onClick={() => navigate({ to: "/specs" })}>Back to Specs</Button>
</div>
}
/>
<WorkflowSummaryGrid>
<WorkflowSummaryCard label="Version" value={entry.version} testId="lib-pack-version" />
<WorkflowSummaryCard label="Files" value={entry.files.length} testId="lib-pack-files" />
<WorkflowSummaryCard
label="Tokens (~)"
value={String(entry.derivedEstimatedTokens)}
testId="lib-pack-tokens"
/>
<WorkflowSummaryCard label="Source" value={entry.sourceType} testId="lib-pack-source" />
</WorkflowSummaryGrid>
<div data-testid="lib-pack-source-path" className="font-mono text-[9px] text-stone-500">
path: {entry.sourcePath}
</div>
<section className="border border-stone-300/40 bg-white/8">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-600">
Files
</header>
<ul data-testid="lib-pack-file-list" className="divide-y divide-stone-100">
{entry.files.map((f) => {
const missing = f.bytes === null;
return (
<li
key={f.path}
data-testid={`lib-pack-file-${f.path}`}
data-missing={missing ? "true" : "false"}
className={`px-3 py-2 font-mono text-[10px] ${missing ? "text-red-700" : "text-stone-800"}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-bold truncate">{f.path}</span>
<span className="font-mono text-[8px] text-stone-500 shrink-0">
role: {f.role}
{missing
? " · MISSING"
: ` · ${f.bytes}B · ~${f.estimatedTokens} tokens`}
</span>
</div>
{f.summary && (
<div className="mt-0.5 text-stone-600 text-[9px]">{f.summary}</div>
)}
</li>
);
})}
</ul>
</section>
{showSendPicker && (
<section data-testid="context-pack-send-modal" className="border border-stone-400 bg-white px-3 py-3 space-y-2">
<div className="font-mono text-[10px] uppercase tracking-[0.10em] text-stone-700">Send to seat</div>
<select
data-testid="context-pack-send-session"
className="w-full font-mono text-[10px] border border-stone-300 px-2 py-1"
value={destinationSession}
onChange={(e) => setDestinationSession(e.target.value)}
>
<option value="">Pick a running session…</option>
{runningSessions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
data-testid="context-pack-send-dry-run"
onClick={() => void onDryRun()}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? "…" : "Dry run"}
</Button>
<Button
size="sm"
data-testid="context-pack-send-confirm"
onClick={() => void onSend()}
disabled={sendMutation.isPending || !destinationSession}
>
{sendMutation.isPending ? "Sending…" : "Send"}
</Button>
</div>
{sendError && (
<div data-testid="context-pack-send-error" className="font-mono text-[9px] text-red-600">{sendError}</div>
)}
{sendStatus === "sent" && (
<div data-testid="context-pack-send-success" className="font-mono text-[9px] text-emerald-700">
Sent to {destinationSession}.
</div>
)}
{sendStatus === "dry-run-shown" && sendMutation.data?.bundleText && (
<pre
data-testid="context-pack-send-bundle-preview"
className="font-mono text-[9px] bg-stone-50 border border-stone-200 px-2 py-1 max-h-64 overflow-y-auto whitespace-pre-wrap"
>
{sendMutation.data.bundleText}
</pre>
)}
</section>
)}
<section className="border border-stone-300/40 bg-white/8">
<header className="border-b border-stone-200 bg-stone-50 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.10em] text-stone-600">
Bundle preview
</header>
{previewLoading && (
<div className="px-3 py-2 font-mono text-[9px] text-stone-400">Loading bundle…</div>
)}
{preview && (
<>
{preview.missingFiles.length > 0 && (
<div data-testid="lib-pack-missing-warning" className="px-3 py-2 font-mono text-[9px] text-red-700 border-b border-stone-200">
Warning: {preview.missingFiles.length} file{preview.missingFiles.length === 1 ? "" : "s"} referenced by manifest but missing on disk.
</div>
)}
<pre
data-testid="lib-pack-bundle-text"
className="font-mono text-[9px] text-stone-800 bg-stone-50 px-3 py-2 max-h-96 overflow-y-auto whitespace-pre-wrap"
>
{preview.bundleText}
</pre>
</>
)}
</section>
</div>
</WorkspacePage>
);
}
+45 -15
View File
@@ -3,36 +3,55 @@ import { useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { useSpecsWorkspace, type SpecsDraft } from "./SpecsWorkspace.js";
import { useSpecLibrary, useLibraryReview, type SpecLibraryEntry } from "../hooks/useSpecLibrary.js";
import { useContextPackLibrary, type ContextPackEntry } from "../hooks/useContextPackLibrary.js";
import { usePsEntries } from "../hooks/usePsEntries.js";
import { useExpandRig, useRemoveLibrarySpec, useRenameLibrarySpec, type ExpandRigResult } from "../hooks/mutations.js";
import { ExpansionOutcome } from "./ExpansionOutcome.js";
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows";
type LibraryFilter = "all" | "apps" | "rigs" | "agents" | "workflows" | "context-packs";
function deriveStability(entry: SpecLibraryEntry): "Stable" | "Experimental" | "Community" {
// PL-014: unified entry shape covers both spec library entries and
// context-pack entries — both are filesystem-canonical, library-
// discoverable, reviewable. Discriminated by kind.
type UnifiedLibraryEntry = SpecLibraryEntry | (ContextPackEntry & {
summary?: string;
hasServices?: undefined;
});
function deriveStability(entry: UnifiedLibraryEntry): "Stable" | "Experimental" | "Community" {
if (entry.sourceType !== "builtin") return "Community";
const rp = (entry.relativePath ?? "").replaceAll("\\", "/");
if (rp.startsWith("rigs/launch/")) return "Stable";
return "Experimental";
}
function deriveTypeBadge(entry: SpecLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" {
function deriveTypeBadge(entry: UnifiedLibraryEntry): "APP" | "RIG" | "AGENT" | "WORKFLOW" | "PACK" {
if (entry.kind === "context-pack") return "PACK";
if (entry.kind === "workflow") return "WORKFLOW";
if (entry.kind === "agent") return "AGENT";
if (entry.hasServices) return "APP";
if ((entry as SpecLibraryEntry).hasServices) return "APP";
return "RIG";
}
function filterEntries(entries: SpecLibraryEntry[], filter: LibraryFilter): SpecLibraryEntry[] {
function filterEntries(entries: UnifiedLibraryEntry[], filter: LibraryFilter): UnifiedLibraryEntry[] {
switch (filter) {
case "apps": return entries.filter((e) => e.kind === "rig" && e.hasServices);
case "rigs": return entries.filter((e) => e.kind === "rig" && !e.hasServices);
case "apps": return entries.filter((e) => e.kind === "rig" && (e as SpecLibraryEntry).hasServices);
case "rigs": return entries.filter((e) => e.kind === "rig" && !(e as SpecLibraryEntry).hasServices);
case "agents": return entries.filter((e) => e.kind === "agent");
case "workflows": return entries.filter((e) => e.kind === "workflow");
case "context-packs": return entries.filter((e) => e.kind === "context-pack");
default: return entries;
}
}
// PL-014: project ContextPackEntry into the row-shape the LibraryList
// renders (adds a `summary` line surfacing files-count + token estimate).
function contextPackToUnified(entry: ContextPackEntry): UnifiedLibraryEntry {
const firstPurposeLine = entry.purpose?.split("\n")[0]?.trim();
const summary = `${entry.files.length} file${entry.files.length === 1 ? "" : "s"} · ~${entry.derivedEstimatedTokens} tokens${firstPurposeLine ? ` · ${firstPurposeLine}` : ""}`;
return { ...entry, summary };
}
interface SpecsPanelProps {
onClose: () => void;
}
@@ -98,10 +117,10 @@ function LibraryList({
renderExpanded,
}: {
title: string;
entries: SpecLibraryEntry[];
entries: UnifiedLibraryEntry[];
onSelect: (id: string) => void;
renderAction?: (entry: SpecLibraryEntry) => ReactNode;
renderExpanded?: (entry: SpecLibraryEntry) => ReactNode;
renderAction?: (entry: UnifiedLibraryEntry) => ReactNode;
renderExpanded?: (entry: UnifiedLibraryEntry) => ReactNode;
}) {
if (entries.length === 0) return null;
@@ -245,7 +264,18 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
await openSurface("/specs/agent");
};
const { data: allLibrary = [] } = useSpecLibrary();
const { data: specEntries = [] } = useSpecLibrary();
// PL-014: context_packs are a sibling primitive served from a
// separate daemon route. Merge them into the unified library list so
// filter chips operate on one array.
const { data: contextPacks = [] } = useContextPackLibrary();
const allLibrary: UnifiedLibraryEntry[] = [
...specEntries,
// Defensive: if the daemon returns a non-array placeholder (older
// version without the route), Array.isArray guard keeps the panel
// from blowing up on render.
...(Array.isArray(contextPacks) ? contextPacks.map(contextPackToUnified) : []),
];
const [libraryFilter, setLibraryFilter] = useState<LibraryFilter>("all");
const filteredLibrary = filterEntries(allLibrary, libraryFilter);
const [addToRigEntryId, setAddToRigEntryId] = useState<string | null>(null);
@@ -263,7 +293,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
const rigDraftHistory = recentRigDrafts.filter((draft) => draft.id !== currentRigDraft?.id);
const agentDraftHistory = recentAgentDrafts.filter((draft) => draft.id !== currentAgentDraft?.id);
const startRename = (entry: SpecLibraryEntry) => {
const startRename = (entry: UnifiedLibraryEntry) => {
setLibraryActionError(null);
setConfirmRemoveEntryId(null);
setRenameEntryId(entry.id);
@@ -295,7 +325,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
}
};
const renderLibraryAction = (entry: SpecLibraryEntry, allowAddToRig: boolean) => (
const renderLibraryAction = (entry: UnifiedLibraryEntry, allowAddToRig: boolean) => (
<div className="flex items-center gap-1">
{allowAddToRig && (
<button
@@ -339,7 +369,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
</div>
);
const renderLibraryExpanded = (entry: SpecLibraryEntry) => {
const renderLibraryExpanded = (entry: UnifiedLibraryEntry) => {
const showRename = renameEntryId === entry.id;
const showRemove = confirmRemoveEntryId === entry.id;
if (!showRename && !showRemove) return null;
@@ -465,7 +495,7 @@ export function SpecsPanel({ onClose }: SpecsPanelProps) {
<div className="space-y-2">
<div className="font-mono text-[8px] uppercase tracking-[0.16em] text-stone-500">Library</div>
<div className="flex gap-1">
{(["all", "apps", "rigs", "agents", "workflows"] as LibraryFilter[]).map((f) => (
{(["all", "apps", "rigs", "agents", "workflows", "context-packs"] as LibraryFilter[]).map((f) => (
<button
key={f}
data-testid={`filter-${f}`}
@@ -0,0 +1,128 @@
// Rig Context / Composable Context Injection v0 (PL-014) — UI hooks
// for the context_packs library + review + send.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export interface ContextPackEntryFile {
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}
export interface ContextPackEntry {
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: "builtin" | "user_file" | "workspace";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: ContextPackEntryFile[];
}
export interface ContextPackPreview {
id: string;
name: string;
version: string;
bundleText: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
}
export interface ContextPackSendResponse {
id: string;
name: string;
version: string;
destinationSession: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
dryRun: boolean;
bundleText?: string;
sent?: boolean;
}
async function fetchContextPacks(): Promise<ContextPackEntry[]> {
const res = await fetch("/api/context-packs/library");
if (!res.ok) {
if (res.status === 503) return []; // honest fallback when library not configured
throw new Error(`HTTP ${res.status}`);
}
const body = await res.json().catch(() => null);
// Cross-CLI-version drift guard: an older daemon that doesn't ship
// the route may surface 200 with a non-array placeholder. Fall back
// to an empty list rather than letting consumers .map() into an
// exception.
return Array.isArray(body) ? body : [];
}
export function useContextPackLibrary() {
return useQuery({
queryKey: ["context-packs", "library"],
queryFn: fetchContextPacks,
staleTime: 30_000,
});
}
async function fetchContextPackPreview(id: string): Promise<ContextPackPreview> {
const res = await fetch(`/api/context-packs/library/${encodeURIComponent(id)}/preview`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useContextPackPreview(id: string | null) {
return useQuery({
queryKey: ["context-packs", "preview", id],
queryFn: () => fetchContextPackPreview(id!),
enabled: !!id,
staleTime: 30_000,
});
}
export function useContextPackSend() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; destinationSession: string; dryRun?: boolean }): Promise<ContextPackSendResponse> => {
const res = await fetch(`/api/context-packs/library/${encodeURIComponent(input.id)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
destinationSession: input.destinationSession,
dryRun: input.dryRun ?? false,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return res.json();
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["context-packs"] });
},
});
}
export function useContextPackSync() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/context-packs/library/sync", { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ count: number; entries: ContextPackEntry[] }>;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["context-packs"] });
},
});
}
+131
View File
@@ -384,4 +384,135 @@ describe("LibraryReview", () => {
expect(screen.getByTestId("agent-drilldown-route")).toBeDefined();
});
});
// --- PL-014: context-pack review variant ---
it("renders the context-pack review variant for context-pack:* ids", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/context-packs/library") {
return new Response(JSON.stringify([
{
id: "context-pack:pl-005-priming:1",
kind: "context-pack",
name: "pl-005-priming",
version: "1",
purpose: "Priming for PL-005 Phase A review.",
sourceType: "user_file",
sourcePath: "/home/op/.openrig/context-packs/pl-005-priming",
relativePath: "pl-005-priming",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 800,
files: [
{ path: "prd.md", role: "prd", summary: "PRD", absolutePath: "/abs/prd.md", bytes: 200, estimatedTokens: 50 },
{ path: "missing.md", role: "proof", summary: null, absolutePath: null, bytes: null, estimatedTokens: null },
],
},
]), { status: 200 });
}
if (url === "/api/context-packs/library/context-pack%3Apl-005-priming%3A1/preview") {
return new Response(JSON.stringify({
id: "context-pack:pl-005-priming:1",
name: "pl-005-priming",
version: "1",
bundleText: "# OpenRig Context Pack: pl-005-priming v1\n\nPRD body\n",
bundleBytes: 60,
estimatedTokens: 15,
files: [{ path: "prd.md", role: "prd", bytes: 200, estimatedTokens: 50 }],
missingFiles: [{ path: "missing.md", role: "proof" }],
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
render(createAppTestRouter({
initialPath: "/specs/library/context-pack:pl-005-priming:1",
routes: [
{ path: "/specs/library/context-pack:pl-005-priming:1", component: () => <LibraryReview entryId="context-pack:pl-005-priming:1" /> },
],
}));
await waitFor(() => expect(screen.getByTestId("library-review-context-pack")).toBeDefined());
expect(screen.getByText("pl-005-priming")).toBeDefined();
expect(screen.getByTestId("lib-pack-version").textContent).toContain("1");
expect(screen.getByTestId("lib-pack-files").textContent).toContain("2");
// Per-file row with role + size
const present = screen.getByTestId("lib-pack-file-prd.md");
expect(present.textContent).toContain("role: prd");
expect(present.textContent).toContain("200B");
// Missing file rendered with MISSING marker + data-missing flag
const missing = screen.getByTestId("lib-pack-file-missing.md");
expect(missing.getAttribute("data-missing")).toBe("true");
expect(missing.textContent).toContain("MISSING");
// Bundle preview present + missing-file warning (preview is a
// separate query; wait for it to resolve)
await waitFor(() => expect(screen.getByTestId("lib-pack-bundle-text")).toBeDefined());
expect(screen.getByTestId("lib-pack-bundle-text").textContent).toContain("PRD body");
expect(screen.getByTestId("lib-pack-missing-warning")).toBeDefined();
});
it("Send-to-seat modal opens on click and shows running sessions", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/context-packs/library") {
return new Response(JSON.stringify([
{
id: "context-pack:test:1",
kind: "context-pack",
name: "test",
version: "1",
purpose: null,
sourceType: "user_file",
sourcePath: "/x/test",
relativePath: "test",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 50,
files: [{ path: "a.md", role: "r", summary: null, absolutePath: "/abs/a.md", bytes: 30, estimatedTokens: 8 }],
},
]), { status: 200 });
}
if (url.endsWith("/preview")) {
return new Response(JSON.stringify({
id: "context-pack:test:1", name: "test", version: "1",
bundleText: "ok", bundleBytes: 2, estimatedTokens: 1,
files: [], missingFiles: [],
}), { status: 200 });
}
if (url === "/api/ps") {
return new Response(JSON.stringify([
{
rigId: "rig-1",
name: "demo",
nodes: [
{ canonicalSessionName: "driver@demo", sessionStatus: "running" },
{ canonicalSessionName: "qa@demo", sessionStatus: "running" },
{ canonicalSessionName: "stopped@demo", sessionStatus: "exited" },
],
},
]), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
render(createAppTestRouter({
initialPath: "/specs/library/context-pack:test:1",
routes: [
{ path: "/specs/library/context-pack:test:1", component: () => <LibraryReview entryId="context-pack:test:1" /> },
],
}));
await waitFor(() => expect(screen.getByTestId("context-pack-send-button")).toBeDefined());
fireEvent.click(screen.getByTestId("context-pack-send-button"));
await waitFor(() => expect(screen.getByTestId("context-pack-send-modal")).toBeDefined());
const select = screen.getByTestId("context-pack-send-session") as HTMLSelectElement;
const options = Array.from(select.querySelectorAll("option")).map((o) => o.value);
expect(options).toContain("driver@demo");
expect(options).toContain("qa@demo");
// Stopped session not surfaced (only sessionStatus === "running")
expect(options).not.toContain("stopped@demo");
});
});
+104
View File
@@ -385,3 +385,107 @@ describe("SpecsPanel Workflows filter chip (Workflows in Spec Library v0)", () =
});
});
});
describe("SpecsPanel Context Packs filter chip (PL-014)", () => {
const origFetch = globalThis.fetch;
const mockFetch3 = vi.fn();
beforeEach(() => {
globalThis.fetch = mockFetch3 as unknown as typeof fetch;
mockFetch3.mockImplementation(async (url: string) => {
if (typeof url === "string" && url.includes("/api/specs/library") && !url.includes("kind=")) {
return {
ok: true,
json: async () => [
{
id: "rig-1",
kind: "rig",
name: "demo",
version: "0.2",
sourceType: "builtin",
sourcePath: "/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-09T00:00:00Z",
},
],
};
}
if (typeof url === "string" && url.includes("/api/specs/library") && url.includes("kind=")) {
return { ok: true, json: async () => [] };
}
if (typeof url === "string" && url.includes("/api/context-packs/library")) {
return {
ok: true,
json: async () => [
{
id: "context-pack:pl-005-priming:1",
kind: "context-pack",
name: "pl-005-priming",
version: "1",
purpose: "Priming for PL-005",
sourceType: "user_file",
sourcePath: "/home/op/.openrig/context-packs/pl-005-priming",
relativePath: "pl-005-priming",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 800,
files: [
{ path: "prd.md", role: "prd", summary: null, absolutePath: "/abs/prd.md", bytes: 200, estimatedTokens: 50 },
{ path: "proof.md", role: "proof", summary: null, absolutePath: "/abs/proof.md", bytes: 3000, estimatedTokens: 750 },
],
},
],
};
}
return { ok: true, json: async () => [] };
});
});
afterEach(() => {
globalThis.fetch = origFetch;
window.localStorage.clear();
cleanup();
});
function renderFilterPanel() {
const onClose = vi.fn();
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={qc}>
{createAppTestRouter({
initialPath: "/",
routes: [
{ path: "/", component: () => <div>home</div> },
{ path: "/specs/library/$entryId", component: () => <div data-testid="library-review">review</div> },
],
rootComponent: ({ children }) => (
<SpecsWorkspaceProvider>
<SpecsPanel onClose={onClose} />
{children}
</SpecsWorkspaceProvider>
),
})}
</QueryClientProvider>
);
}
it("renders the Context Packs filter chip and a pack row with PACK badge", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
expect(screen.getByTestId("filter-context-packs")).toBeDefined();
const packRow = screen.getByTestId("library-entry-context-pack:pl-005-priming:1");
expect(packRow.textContent).toContain("PACK");
expect(packRow.textContent).toContain("2 files");
expect(packRow.textContent).toContain("~800 tokens");
});
it("clicking Context Packs filter shows only pack entries", async () => {
renderFilterPanel();
await waitFor(() => expect(screen.getByText("pl-005-priming")).toBeDefined());
fireEvent.click(screen.getByTestId("filter-context-packs"));
await waitFor(() => {
expect(screen.getByText("pl-005-priming")).toBeDefined();
expect(screen.queryByText("demo")).toBeNull();
});
});
});