feat(specs): make built-in inventory visibility configurable (#2379)

Operators can set specs.builtins_visible to false to leave built-in rigs, agents and workflows out of the shared spec inventory that the CLI and TUI list; the default stays visible. rig specs ls --include-builtins lists them on demand, and explicit lookups and launch paths still reach them, so this is an inventory preference, not an access boundary. With built-ins hidden, a visible user spec wins a name lookup; with both visible, a same-name match stays ambiguous, as today, and rig specs ls --include-builtins --json shows each entry's explicit ID. A user's copy of a workflow keeps its own source-specific library entry, projected only while the cached row for that name and version holds the file's current content, so an older version can't take its link. Requested in #2333 by @EZotoff. Contributed by @Nikhila-Dhevi-R.
This commit is contained in:
NIKHILA authored and GitHub committed 2026-11-03 10:40:04 -08:00
1 parent e7e6dc4172
commit 076c710d42
21 files changed
+290 -31

No files matched your search

+1 -1
View File
@@ -631,7 +631,7 @@ Only Claude and Codex tenures can be woken.
| Invocation | Aliases | Declared options |
|---|---|---|
| `rig specs` | — | — |
| `rig specs ls` | — | `--kind <kind>`<br>`--json` |
| `rig specs ls` | — | `--kind <kind>`<br>`--include-builtins`<br>`--json` |
| `rig specs show <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs preview <name-or-id>` | — | `--kind <kind>`<br>`--json` |
| `rig specs add <path>` | — | `--json` |
+12
View File
@@ -1274,3 +1274,15 @@ These are the built-in specs shipped with OpenRig. Read them as worked examples.
Also shipped, in the same tree: `launch/kernel`, OpenRig's own team, which starts automatically. `first-project` is
starter's old name and still resolves to it. `workshop`, the four-agent middle team, is a rig bundle rather than a
built-in. List the built-ins with `rig specs ls --kind rig`.
To omit built-in rigs, agents and workflows from the shared spec inventory, run
`rig config set specs.builtins_visible false`. This changes the CLI, TUI and other
consumers of the library listing; user specs remain visible. Use
`rig specs ls --include-builtins` to list them deliberately, and set the value
back to `true` to restore the default. Explicit `show`, `preview`, and launch
lookups can still find hidden built-ins. When built-ins are hidden and a user
spec has the same name, the user spec wins the name lookup; use the built-in's
ID from `rig specs ls --include-builtins --json` to select it. With built-ins visible,
same-name matches remain ambiguous. This is an inventory preference, not an
access-control boundary; other endpoints and on-disk files can still expose the
built-ins.
+1
View File
@@ -69,6 +69,7 @@ Keys:
topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology)
context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context)
context.system_world default System World, replacement manifest path, or disabled
specs.builtins_visible show built-ins in spec inventory (default true)
skills.root versioned skill catalog (default $OPENRIG_HOME/skills)
skills.router_line.enabled the person's yes to the seat-skills line (one line plus OpenRig's markers) in AGENTS.md / CLAUDE.md (default false)
onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on)
+21 -13
View File
@@ -157,21 +157,25 @@ export async function resolveLibrarySpec(
nameOrId: string,
opts?: { kind?: string },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(
opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library",
);
const rawEntries = res.data ?? [];
const entries =
const url = opts?.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const res = await client.get<LibraryEntry[]>(url);
const filterKind = (entries: LibraryEntry[]) =>
opts?.kind !== undefined && !KNOWN_SPEC_KINDS.includes(opts.kind as (typeof KNOWN_SPEC_KINDS)[number])
? rawEntries.filter((e) => e.kind === opts.kind)
: rawEntries;
// Try exact ID match first
? entries.filter((entry) => entry.kind === opts.kind)
: entries;
const visibleEntries = filterKind(res.data ?? []);
// An exact ID always wins, including when a visible name matches a hidden ID.
const visibleId = visibleEntries.find((entry) => entry.id === nameOrId);
if (visibleId) return visibleId;
const separator = opts?.kind !== undefined ? "&" : "?";
const all = await client.get<LibraryEntry[]>(`${url}${separator}include_builtins=true`);
const entries = filterKind(all.data ?? []);
const byId = entries.find((e) => e.id === nameOrId);
if (byId) return byId;
// Try name match
const byName = entries.filter((e) => e.name === nameOrId);
// Visible names take precedence over hidden built-ins with the same name.
const visibleNames = visibleEntries.filter((e) => e.name === nameOrId);
const byName = visibleNames.length ? visibleNames : entries.filter((e) => e.name === nameOrId);
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
@@ -230,12 +234,16 @@ Examples:
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--include-builtins", "Include built-ins hidden by the visibility setting")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
.action(async (opts: { kind?: string; json?: boolean; includeBuiltins?: boolean }) => {
try {
warnIfUnknownKind(opts.kind);
const client = await getClient();
const url = opts.kind !== undefined ? `/api/specs/library?kind=${encodeURIComponent(opts.kind)}` : "/api/specs/library";
const params = new URLSearchParams();
if (opts.kind !== undefined) params.set("kind", opts.kind);
if (opts.includeBuiltins) params.set("include_builtins", "true");
const url = `/api/specs/library${params.size ? `?${params}` : ""}`;
const res = await client.get<LibraryEntry[]>(url);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const rawEntries = res.data ?? [];
@@ -11,7 +11,7 @@ interface AgentLibraryEntry {
/** Resolve the runtime's terminal seat or the shipped general-purpose agent. */
export async function resolveDefaultAgentRef(client: DaemonClient, runtime: string): Promise<string> {
if (runtime === "terminal") return "builtin:terminal";
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent");
const res = await client.get<AgentLibraryEntry[]>("/api/specs/library?kind=agent&include_builtins=true");
const entry = res.data?.find(
(candidate) =>
candidate.kind === "agent"
+6
View File
@@ -13,6 +13,7 @@ import {
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
specs: { builtinsVisible: boolean };
launch: { nonInterruptive: boolean };
daemon: { port: number; host: string; dbConflict?: "warn" | "stop" };
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
@@ -283,6 +284,7 @@ const DEFAULTS = {
topology: { root: getDefaultOpenRigPath("topology") },
// OPR.0.5.9.5 Wave B — canonical addressable context library.
context: { root: getDefaultOpenRigPath("context"), systemWorld: "default" },
specs: { builtinsVisible: true },
skills: { root: getDefaultOpenRigPath("skills"), routerLine: { enabled: false } },
onboarding: { defaultPack: { enabled: true } },
health: { contextPressure: { warningPercent: 95, criticalPercent: 99 } },
@@ -454,6 +456,7 @@ export const VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
@@ -581,6 +584,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
@@ -688,6 +692,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
@@ -1259,6 +1264,7 @@ export class ConfigStore {
root: v("context.root") as string,
systemWorld: v("context.system_world") as string,
},
specs: { builtinsVisible: v("specs.builtins_visible") as boolean },
skills: {
root: v("skills.root") as string,
routerLine: { enabled: v("skills.router_line.enabled") as boolean },
@@ -46,7 +46,7 @@ describe("bootstrap source paths respect the daemon target", () => {
// The responder reads relative paths from a different root, like an already-running daemon.
server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/specs/library") {
if (req.url === "/api/specs/library" || req.url === "/api/specs/library?include_builtins=true") {
catalogReads++;
res.end(JSON.stringify(catalog));
return;
@@ -184,7 +184,7 @@ describe("bootstrap source paths respect the daemon target", () => {
("preserves catalog source paths (explicit URL=%s, plan=%s)", async (explicitUrl, plan) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "catalog-rig", [plan ? "--plan" : "--yes"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe(catalog[0]!.sourcePath);
expect(requests[0]?.content).toBe("name: daemon\n");
});
@@ -192,7 +192,7 @@ describe("bootstrap source paths respect the daemon target", () => {
it.each([false, true])("preserves unresolved bare-name fallback (explicit URL=%s)", async (explicitUrl) => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
await run("bootstrap", "unlisted", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests[0]?.sourceRef).toBe("unlisted");
});
@@ -208,7 +208,7 @@ describe("bootstrap source paths respect the daemon target", () => {
if (explicitUrl) vi.stubEnv("OPENRIG_URL", daemonUrl);
catalog.push({ ...catalog[0]!, id: "other-id", sourcePath: path.join(callerDir, "rig.yaml") });
await run("bootstrap", "catalog-rig", ["--plan"]);
expect(catalogReads).toBe(1);
expect(catalogReads).toBe(2);
expect(requests).toEqual([]);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -400,7 +400,7 @@ describe("Bootstrap CLI", () => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/specs/library") {
if (url === "/api/specs/library" || url === "/api/specs/library?include_builtins=true") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "my-lib-rig", sourcePath: "/specs/my-lib-rig.yaml", kind: "rig", version: "0.2", sourceType: "user_file" }]));
} else if (url.includes("/api/bootstrap")) {
@@ -138,6 +138,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",
+8
View File
@@ -63,6 +63,14 @@ describe("ConfigStore", () => {
});
// Test 1
it("persists the built-in inventory preference as a validated boolean", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
expect(store.resolveOne("specs.builtins_visible").value).toBe(true);
store.set("specs.builtins_visible", "false");
expect(store.resolve().specs.builtinsVisible).toBe(false);
expect(() => store.set("specs.builtins_visible", "no")).toThrow();
});
it("resolve() returns defaults when no config file and no env", () => {
const store = new ConfigStore(join(tmpDir, "config.json"));
const config = store.resolve();
+17
View File
@@ -7,6 +7,23 @@ const clientFor = (entries: object[]) => ({ get: vi.fn(async () => ({ status: 20
afterEach(() => vi.restoreAllMocks());
describe("first-project compatibility alias", () => {
it("keeps same-name user and built-in specs ambiguous when both are visible", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
await expect(resolveLibrarySpec(clientFor([starter, user]), "starter", { kind: "rig" })).rejects.toThrow("ambiguous");
});
it("finds an explicitly requested hidden built-in without shadowing a visible user spec", async () => {
const user = { ...starter, id: "user-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
const client = { get } as unknown as DaemonClient;
expect(await resolveLibrarySpec(client, "starter", { kind: "rig" })).toEqual(user);
expect(await resolveLibrarySpec(client, "starter-id", { kind: "rig" })).toEqual(starter);
expect(get).toHaveBeenCalledWith("/api/specs/library?kind=rig&include_builtins=true");
});
it("prefers a hidden exact ID over a visible spec with that name", async () => {
const user = { ...starter, id: "user-id", name: "starter-id", sourceType: "user_file" };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("include_builtins=true") ? [starter, user] : [user] }));
expect(await resolveLibrarySpec({ get } as unknown as DaemonClient, "starter-id")).toEqual(starter);
});
it("resolves the built-in starter without creating a second library entry, with a stderr notice", async () => {
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
+20 -1
View File
@@ -60,6 +60,7 @@ const RIG_REVIEW = {
describe("Specs CLI", () => {
let server: http.Server;
let port: number;
let lastLibraryUrl = "";
const libraryRequests: string[] = [];
beforeAll(async () => {
@@ -68,6 +69,7 @@ describe("Specs CLI", () => {
libraryRequests.push(rawUrl);
const url = decodeURIComponent(rawUrl);
if (url === "/api/specs/library" || url.startsWith("/api/specs/library?")) {
lastLibraryUrl = url;
res.writeHead(200, { "Content-Type": "application/json" });
const kind = new URL(`http://localhost${url}`).searchParams.get("kind");
const filtered = kind ? LIBRARY_ENTRIES.filter((e) => e.kind === kind) : LIBRARY_ENTRIES;
@@ -137,6 +139,13 @@ describe("Specs CLI", () => {
expect(parsed.length).toBe(2);
});
it("requests hidden built-ins only when explicitly asked", async () => {
await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--include-builtins", "--kind", "rig"]); });
const params = new URL(`http://localhost${lastLibraryUrl}`).searchParams;
expect(params.get("kind")).toBe("rig");
expect(params.get("include_builtins")).toBe("true");
});
it("specs ls --kind warns on unrecognized kind and still queries daemon", async () => {
const requestStart = libraryRequests.length;
const { stdout, stderr, exitCode } = await captureOutputs(async () => {
@@ -165,6 +174,16 @@ describe("Specs CLI", () => {
expect(libraryRequests.slice(requestStart)).toContain("/api/specs/library?kind=rig%23draft");
});
it("filters an unknown kind even when built-ins are requested", async () => {
const { stdout, stderr } = await captureOutputs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "ls", "--kind", "rig&kind=agent", "--include-builtins", "--json"]);
});
expect(stderr.join("\n")).toContain("Warning: unrecognized --kind");
expect(JSON.parse(stdout.join("\n"))).toEqual([]);
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("kind")).toBe("rig&kind=agent");
expect(new URL(`http://localhost${libraryRequests.at(-1)}`).searchParams.get("include_builtins")).toBe("true");
});
it.each(["rig", "agent", "workflow"])("specs ls --kind %s runs without warning", async (kind) => {
const requestStart = libraryRequests.length;
const { stderr, exitCode } = await captureOutputs(async () => {
@@ -231,7 +250,7 @@ describe("Specs CLI", () => {
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
if (url.startsWith("/api/specs/library?kind=rig")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "../src/client.js";
import { resolveDefaultAgentRef } from "../src/commands/topology-default-agent.js";
describe("default agent lookup", () => {
it("finds the built-in orchestrator when inventory hides built-ins", async () => {
const client = {
get: async (url: string) => ({
data: url.includes("include_builtins=true")
? [{ kind: "agent", name: "orchestrator", sourceType: "builtin", sourcePath: "/builtin/orchestrator/agent.yaml" }]
: [],
}),
} as unknown as DaemonClient;
expect(await resolveDefaultAgentRef(client, "codex")).toBe("path:/builtin/orchestrator");
});
});
+1 -1
View File
@@ -1408,7 +1408,7 @@ describe("Up CLI", () => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
if (url.startsWith("/api/specs/library?kind=rig")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
@@ -401,6 +401,8 @@ export interface ScanWorkflowSpecFolderOpts {
* its source file disappeared (OQ-4 acceptance: deletion + audit log).
* Omitted in unit tests that don't care about emission. */
eventBus?: EventBus;
/** A user file with the same bytes and identity as an existing built-in row. */
onBuiltinAlias?: (name: string, version: string, sourcePath: string, sourceHash: string) => void;
}
export interface ScanWorkflowSpecFolderResult {
@@ -498,7 +500,11 @@ export function scanWorkflowSpecFolder(
// validation error), record a diagnostic row keyed by source_path
// so the Library UI can render the error inline.
try {
opts.cache.readThrough(filePath);
const cached = opts.cache.readThrough(filePath);
if (opts.onBuiltinAlias && opts.builtinDir && cached.sourcePath !== filePath
&& isUnderDir(cached.sourcePath, opts.builtinDir)) {
opts.onBuiltinAlias(cached.name, cached.version, filePath, cached.sourceHash);
}
result.valid += 1;
} catch (err) {
// Diagnostic: hash the raw content best-effort so we can detect
@@ -106,6 +106,7 @@ export const SETTINGS_VALID_KEYS = [
// OPR.0.5.9.5 Wave B — canonical context library; old key is refused.
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
// OPR.0.7.0.13 (router): the person's recorded yes to OpenRig's one-line seat-skills bootstrap in AGENTS.md / CLAUDE.md.
"skills.router_line.enabled",
@@ -266,6 +267,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" },
"context.root": { primary: "OPENRIG_CONTEXT_ROOT" },
"context.system_world": { primary: "OPENRIG_CONTEXT_SYSTEM_WORLD" },
"specs.builtins_visible": { primary: "OPENRIG_SPECS_BUILTINS_VISIBLE" },
"skills.root": { primary: "OPENRIG_SKILLS_ROOT" },
"skills.router_line.enabled": { primary: "OPENRIG_SKILLS_ROUTER_LINE_ENABLED" },
"onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" },
@@ -371,6 +373,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"topology.root": ["topology", "root"],
"context.root": ["context", "root"],
"context.system_world": ["context", "systemWorld"],
"specs.builtins_visible": ["specs", "builtinsVisible"],
"skills.root": ["skills", "root"],
"skills.router_line.enabled": ["skills", "routerLine", "enabled"],
"onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"],
@@ -666,6 +669,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
case "topology.root": return DEFAULT_TOPOLOGY_ROOT;
case "context.root": return DEFAULT_CONTEXT_ROOT;
case "context.system_world": return "default";
case "specs.builtins_visible": return true;
case "skills.root": return DEFAULT_SKILLS_ROOT;
case "skills.router_line.enabled": return false;
case "onboarding.default_pack.enabled": return true;
+54 -6
View File
@@ -1,10 +1,11 @@
import { Hono } from "hono";
import type Database from "better-sqlite3";
import { readFileSync, realpathSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, dirname } from "node:path";
import { parse as parseYaml } from "yaml";
import type { ActiveLensStore } from "../domain/active-lens-store.js";
import type { SpecLibraryService } from "../domain/spec-library-service.js";
import type { SpecLibraryEntry, SpecLibraryService } from "../domain/spec-library-service.js";
import { SpecReviewService, SpecReviewError } from "../domain/spec-review-service.js";
import {
getWorkflowReview,
@@ -14,6 +15,17 @@ import {
} from "../domain/spec-library-workflow-scanner.js";
import type { WorkflowSpecCache } from "../domain/workflow-spec-cache.js";
import type { EventBus } from "../domain/event-bus.js";
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
function visibleEntries(entries: SpecLibraryEntry[], settings: SettingsStore | undefined, includeBuiltins = false): SpecLibraryEntry[] {
const showBuiltins = includeBuiltins || settings?.resolveOne("specs.builtins_visible").value !== false;
return entries.filter((entry) => {
if (entry.id.startsWith("workflow-source:") && entries.some((other) =>
!other.id.startsWith("workflow-source:") && other.kind === "workflow" && other.sourcePath === entry.sourcePath)) return false;
if (entry.id.startsWith("workflow-source:") && showBuiltins && !includeBuiltins) return false;
return showBuiltins || entry.sourceType !== "builtin";
});
}
export function specLibraryRoutes(): Hono {
const router = new Hono();
@@ -33,9 +45,11 @@ export function specLibraryRoutes(): Hono {
const cache = c.get("workflowSpecCache" as never) as WorkflowSpecCache | undefined;
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
const aliases: Array<{ name: string; version: string; sourcePath: string; sourceHash: string }> = [];
if (cache && folder) {
try {
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus });
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus,
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }) });
} catch { /* best-effort — folder scan failure must not break Library list */ }
}
// Re-scan workflow specs on each list request — cheap (single
@@ -43,6 +57,34 @@ export function specLibraryRoutes(): Hono {
// surfaces always see the freshest cache state without a separate
// POST /sync.
const workflowEntries = scanWorkflowSpecs({ db, workflowBuiltinSpecsDir: builtinDir ?? null });
for (const alias of aliases) {
const original = workflowEntries.find((entry) => entry.sourceType === "builtin" && entry.name === alias.name && entry.version === alias.version);
if (!original) continue;
try {
const hashFile = (file: string) => createHash("sha256").update(readFileSync(file, "utf8")).digest("hex");
if (hashFile(alias.sourcePath) !== alias.sourceHash || hashFile(original.sourcePath) !== alias.sourceHash) continue;
workflowEntries.push({ ...original, id: `workflow-source:${createHash("sha256").update(alias.sourcePath).digest("hex")}`,
sourceType: "user_file", isBuiltIn: false, sourcePath: alias.sourcePath, relativePath: alias.sourcePath });
} catch { /* A file changed or disappeared during the scan; try again on the next request. */ }
}
// Keep a source-specific link valid when an identical copy is edited and
// becomes the cache's own user-file row. The normal row is listed once.
if (folder && cache) {
const sourceHashes = new Map<string, string>();
for (const entry of [...workflowEntries]) {
if (entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
try {
let sourceHash = sourceHashes.get(entry.sourcePath);
if (sourceHash === undefined) {
sourceHash = createHash("sha256").update(readFileSync(entry.sourcePath, "utf8")).digest("hex");
sourceHashes.set(entry.sourcePath, sourceHash);
}
const cached = cache.getByNameVersion(entry.name, entry.version);
if (!cached || cached.sourcePath !== entry.sourcePath || cached.sourceHash !== sourceHash) continue;
} catch { continue; /* Missing or changed source: do not project an unverified alias. */ }
workflowEntries.push({ ...entry, id: `workflow-source:${createHash("sha256").update(entry.sourcePath).digest("hex")}` });
}
}
lib.setWorkflowEntries(workflowEntries);
}
return lib;
@@ -81,7 +123,8 @@ export function specLibraryRoutes(): Hono {
router.get("/", (c) => {
const lib = refreshWorkflowEntries(c);
const kind = c.req.query("kind") as "rig" | "agent" | "workflow" | undefined;
const entries = lib.list(kind ? { kind } : undefined);
const settings = c.get("settingsStore" as never) as SettingsStore | undefined;
const entries = visibleEntries(lib.list(kind ? { kind } : undefined), settings, c.req.query("include_builtins") === "true");
return c.json(entries.map((entry) => {
// Preserve the authored/catalog path and also identify its actual source.
// Consumers still read through the existing file-root boundary.
@@ -121,7 +164,9 @@ export function specLibraryRoutes(): Hono {
// payload shape — topology graph + per-step list + source-path
// — projected from the workflow_specs SQLite cache.
if (result.entry.kind === "workflow") {
const parsed = parseWorkflowLibraryId(id);
const parsed = id.startsWith("workflow-source:")
? { name: result.entry.name, version: result.entry.version }
: parseWorkflowLibraryId(id);
if (!parsed) return c.json({ error: `Workflow library id '${id}' could not be parsed` }, 400);
const db = (c.get("rigRepo" as never) as { db: Database.Database } | undefined)?.db;
const builtinDir = c.get("workflowBuiltinSpecsDir" as never) as string | undefined;
@@ -135,7 +180,8 @@ export function specLibraryRoutes(): Hono {
if (!review) {
return c.json({ error: `Workflow spec '${parsed.name}' v${parsed.version} not in workflow_specs cache` }, 404);
}
return c.json({ ...review, libraryEntryId: id });
return c.json({ ...review, sourcePath: result.entry.sourcePath,
isBuiltIn: result.entry.sourceType === "builtin", libraryEntryId: id });
}
try {
@@ -182,7 +228,9 @@ export function specLibraryRoutes(): Hono {
router.post("/sync", (c) => {
const lib = c.get("specLibraryService" as never) as SpecLibraryService;
lib.scan();
return c.json(lib.list());
refreshWorkflowEntries(c);
const settings = c.get("settingsStore" as never) as SettingsStore | undefined;
return c.json(visibleEntries(lib.list(), settings, c.req.query("include_builtins") === "true"));
});
// DELETE /:id — remove a user-file library entry
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
import { SpecLibraryService } from "../src/domain/spec-library-service.js";
import { SpecReviewService } from "../src/domain/spec-review-service.js";
import { specLibraryRoutes } from "../src/routes/spec-library.js";
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
const VALID_RIG_YAML = `
version: "0.2"
@@ -43,17 +44,55 @@ describe("spec library routes", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
function createApp(): Hono {
function createApp(settingsStore?: SettingsStore): Hono {
const app = new Hono();
app.use("*", async (c, next) => {
c.set("specLibraryService" as never, lib);
c.set("specReviewService" as never, new SpecReviewService());
c.set("settingsStore" as never, settingsStore);
await next();
});
app.route("/api/specs/library", specLibraryRoutes());
return app;
}
it("hides built-in rigs, agents and workflows from inventory while retaining explicit access", async () => {
const builtin = join(tmpDir, "builtins");
const user = join(tmpDir, "user");
mkdirSync(join(builtin, "rigs", "sample"), { recursive: true });
mkdirSync(join(builtin, "agents", "sample"), { recursive: true });
mkdirSync(user);
writeFileSync(join(builtin, "rigs", "sample", "rig.yaml"), VALID_RIG_YAML);
writeFileSync(join(builtin, "agents", "sample", "agent.yaml"), 'name: builtin-agent\nversion: "0.2"\nrole: Test\n');
writeFileSync(join(user, "rig.yaml"), VALID_RIG_YAML);
lib = new SpecLibraryService({ roots: [{ path: builtin, sourceType: "builtin" }, { path: user, sourceType: "user_file" }], specReviewService: new SpecReviewService() });
lib.scan();
const workflow = (id: string, sourceType: "builtin" | "user_file") => ({ id, kind: "workflow" as const, name: id, version: "1", sourceType, sourcePath: join(tmpDir, id), relativePath: id, updatedAt: "" });
lib.setWorkflowEntries([workflow("builtin-workflow", "builtin"), workflow("user-workflow", "user_file")]);
const configPath = join(tmpDir, "config.json");
const settings = new SettingsStore(configPath);
const app = createApp(settings);
const get = async (url: string) => await (await app.request(url)).json() as Array<{ id: string; sourceType: string; kind: string }>;
const all = await get("/api/specs/library");
expect(all.some((entry) => entry.sourceType === "builtin" && entry.kind === "rig")).toBe(true);
expect(all.some((entry) => entry.sourceType === "builtin" && entry.kind === "agent")).toBe(true);
expect(all.some((entry) => entry.id === "builtin-workflow")).toBe(true);
writeFileSync(configPath, JSON.stringify({ specs: { builtinsVisible: false } }));
const visible = await get("/api/specs/library");
expect(visible.map((entry) => entry.sourceType)).toEqual(["user_file", "user_file"]);
expect(visible.some((entry) => entry.id === "user-workflow")).toBe(true);
const workflows = await get("/api/specs/library?kind=workflow");
expect(workflows.map((entry) => entry.id)).toEqual(["user-workflow"]);
const notOptedIn = await get("/api/specs/library?include_builtins=false");
expect(notOptedIn).toHaveLength(visible.length);
const sync = await (await app.request("/api/specs/library/sync", { method: "POST" })).json() as Array<{ sourceType: string }>;
expect(sync.every((entry) => entry.sourceType === "user_file")).toBe(true);
const explicit = await get("/api/specs/library?include_builtins=true");
expect(explicit).toHaveLength(all.length);
const builtinRig = all.find((entry) => entry.kind === "rig" && entry.sourceType === "builtin")!;
expect((await app.request(`/api/specs/library/${builtinRig.id}`)).status).toBe(200);
});
it("keeps source provenance and serves canonical source identity for file-root mapping", async () => {
const res = await createApp().request("/api/specs/library");
const entries = await res.json() as Array<{ sourcePath: string; resolvedSourcePath: string }>;
@@ -21,6 +21,7 @@ import { SpecLibraryService } from "../src/domain/spec-library-service.js";
import { SpecReviewService } from "../src/domain/spec-review-service.js";
import { ActiveLensStore } from "../src/domain/active-lens-store.js";
import { specLibraryRoutes } from "../src/routes/spec-library.js";
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
const VALID_YAML = (id: string) => `workflow:
id: ${id}
@@ -90,6 +91,7 @@ describe("spec-library route folder scan (slice 11)", () => {
c.set("rigRepo" as never, { db });
c.set("workflowBuiltinSpecsDir" as never, builtinDir);
c.set("workflowSpecCache" as never, cache);
c.set("settingsStore" as never, new SettingsStore(join(tmp, "config.json")));
if (opts.withFolder) c.set("workflowsFolderDir" as never, folder);
await next();
});
@@ -97,6 +99,75 @@ describe("spec-library route folder scan (slice 11)", () => {
return app;
}
it("keeps an identical user copy visible when built-in workflows are hidden", async () => {
const builtin = join(builtinDir, "same.yaml");
const user = join(folder, "same.yaml");
writeFileSync(builtin, VALID_YAML("same"));
cache.readThrough(builtin);
writeFileSync(user, VALID_YAML("same"));
writeFileSync(join(tmp, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
const app = createApp();
const response = await app.request("/api/specs/library?kind=workflow");
const entries = await response.json() as Array<{ id: string; sourcePath: string; sourceType: string }>;
const userEntry = entries.find((entry) => entry.sourcePath === user && entry.sourceType === "user_file");
expect(userEntry).toBeDefined();
const review = await (await app.request(`/api/specs/library/${encodeURIComponent(userEntry!.id)}/review`)).json();
expect(review).toMatchObject({ sourcePath: user, isBuiltIn: false });
const all = await (await app.request("/api/specs/library?kind=workflow&include_builtins=true")).json() as Array<{ id: string; sourcePath: string }>;
expect(all.find((entry) => entry.sourcePath === builtin)?.id).not.toBe(userEntry!.id);
writeFileSync(join(tmp, "config.json"), JSON.stringify({ specs: { builtinsVisible: true } }));
expect((await app.request(`/api/specs/library/${encodeURIComponent(userEntry!.id)}/review`)).status).toBe(200);
writeFileSync(join(tmp, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
rmSync(user);
expect(await (await app.request("/api/specs/library/sync", { method: "POST" })).json()).toEqual([]);
expect(await (await app.request("/api/specs/library?kind=workflow")).json()).toEqual([]);
});
it("keeps a user-copy review link usable after the copy is edited", async () => {
const builtin = join(builtinDir, "same.yaml");
const user = join(folder, "same.yaml");
writeFileSync(builtin, VALID_YAML("same"));
cache.readThrough(builtin);
writeFileSync(user, VALID_YAML("same"));
writeFileSync(join(tmp, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
const app = createApp();
const entries = await (await app.request("/api/specs/library?kind=workflow")).json() as Array<{ id: string }>;
const id = entries[0]!.id;
writeFileSync(user, VALID_YAML("same").replace("Folder-scan fixture", "Changed objective"));
const response = await app.request(`/api/specs/library/${encodeURIComponent(id)}/review`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ sourcePath: user, purpose: "Changed objective" });
const after = await (await app.request("/api/specs/library?kind=workflow")).json() as Array<{ sourcePath: string }>;
expect(after.filter((entry) => entry.sourcePath === user)).toHaveLength(1);
});
it("reviews the current file through its source link without changing version history", async () => {
const builtin = join(builtinDir, "same.yaml");
const user = join(folder, "same.yaml");
const version = (value: string) => VALID_YAML("same")
.replace("version: '1'", `version: '${value}'`)
.replace("Folder-scan fixture", `Objective ${value}`)
.replace("objective: Draft.", `objective: Step ${value}.`);
writeFileSync(builtin, version("1"));
cache.readThrough(builtin);
writeFileSync(user, version("1"));
writeFileSync(join(tmp, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
const app = createApp();
const entries = await (await app.request("/api/specs/library?kind=workflow")).json() as Array<{ id: string }>;
const sourceId = entries[0]!.id;
for (const value of ["2", "10", "2"]) {
writeFileSync(user, version(value));
const response = await app.request(`/api/specs/library/${encodeURIComponent(sourceId)}/review`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ version: value, sourcePath: user, purpose: `Objective ${value}`,
steps: [expect.objectContaining({ objective: `Step ${value}.` })] });
}
const history = await app.request(`/api/specs/library/${encodeURIComponent("workflow:same:10")}/review`);
expect(history.status).toBe(200);
expect(await history.json()).toMatchObject({ version: "10", purpose: "Objective 10",
steps: [expect.objectContaining({ objective: "Step 10." })] });
});
it("keeps accepted name/version pairs distinct and reviewable when a version contains a colon", async () => {
writeFileSync(join(folder, "first.yaml"), VALID_YAML("demo").replace("version: '1'", "version: 'release:1'"));
writeFileSync(join(folder, "second.yaml"), VALID_YAML("demo:release"));
@@ -131,7 +131,9 @@ describe("config routes (User Settings v0)", () => {
// + 1 daemon.db_conflict (warn by default; optional pre-open conflict stop).
// + 1 provider.selected (the providers chosen at rig setup --providers; default empty).
// + 2 retention.stream_days / stream_max_items (slice 22 outcome 9 stream retention; default 30, 20000).
expect(Object.keys(body.settings).length).toBe(93);
// + 1 specs.builtins_visible (show built-in library entries by default).
expect(Object.keys(body.settings).length).toBe(94);
expect(body.settings["specs.builtins_visible"]).toMatchObject({ value: true, source: "default", defaultValue: true });
expect(body.settings["retention.stream_days"]).toMatchObject({ value: 30, source: "default", defaultValue: 30 });
expect(body.settings["retention.stream_max_items"]).toMatchObject({ value: 20000, source: "default", defaultValue: 20000 });
expect(body.settings["decisions.enabled"]).toMatchObject({ value: false, source: "default", defaultValue: false });
@@ -118,6 +118,7 @@ describe("SettingsStore (User Settings v0)", () => {
"topology.root",
"context.root",
"context.system_world",
"specs.builtins_visible",
"skills.root",
"skills.router_line.enabled",
"onboarding.default_pack.enabled",