diff --git a/docs/as-built/cli-reference.md b/docs/as-built/cli-reference.md index 8bf8f58b78..df67414f58 100644 --- a/docs/as-built/cli-reference.md +++ b/docs/as-built/cli-reference.md @@ -628,7 +628,7 @@ Only Claude and Codex tenures can be woken. | Invocation | Aliases | Declared options | |---|---|---| | `rig specs` | — | — | -| `rig specs ls` | — | `--kind `
`--json` | +| `rig specs ls` | — | `--kind `
`--include-builtins`
`--json` | | `rig specs show ` | — | `--kind `
`--json` | | `rig specs preview ` | — | `--kind `
`--json` | | `rig specs add ` | — | `--json` | diff --git a/docs/reference/rig-spec.md b/docs/reference/rig-spec.md index 2008d83a20..b6e811a4f7 100644 --- a/docs/reference/rig-spec.md +++ b/docs/reference/rig-spec.md @@ -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. diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index f97c869c09..0ec835c4a6 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -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) diff --git a/packages/cli/src/commands/specs.ts b/packages/cli/src/commands/specs.ts index 7f1045766f..c7f39933df 100644 --- a/packages/cli/src/commands/specs.ts +++ b/packages/cli/src/commands/specs.ts @@ -157,21 +157,25 @@ export async function resolveLibrarySpec( nameOrId: string, opts?: { kind?: string }, ): Promise { - const res = await client.get( - 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(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(`${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 ", "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(url); if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`); const rawEntries = res.data ?? []; diff --git a/packages/cli/src/commands/topology-default-agent.ts b/packages/cli/src/commands/topology-default-agent.ts index 7137097731..4bf6dff9b8 100644 --- a/packages/cli/src/commands/topology-default-agent.ts +++ b/packages/cli/src/commands/topology-default-agent.ts @@ -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 { if (runtime === "terminal") return "builtin:terminal"; - const res = await client.get("/api/specs/library?kind=agent"); + const res = await client.get("/api/specs/library?kind=agent&include_builtins=true"); const entry = res.data?.find( (candidate) => candidate.kind === "agent" diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts index 9db37d02b7..4d61099d84 100644 --- a/packages/cli/src/config-store.ts +++ b/packages/cli/src/config-store.ts @@ -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 = { "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 = { "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 }, diff --git a/packages/cli/test/bootstrap-source-paths.test.ts b/packages/cli/test/bootstrap-source-paths.test.ts index 09839d8fac..3a6726a99d 100644 --- a/packages/cli/test/bootstrap-source-paths.test.ts +++ b/packages/cli/test/bootstrap-source-paths.test.ts @@ -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); }); diff --git a/packages/cli/test/bootstrap.test.ts b/packages/cli/test/bootstrap.test.ts index fabe7fb297..01e0782451 100644 --- a/packages/cli/test/bootstrap.test.ts +++ b/packages/cli/test/bootstrap.test.ts @@ -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")) { diff --git a/packages/cli/test/config-store-extended.test.ts b/packages/cli/test/config-store-extended.test.ts index b3a224f0d8..937ffc81a7 100644 --- a/packages/cli/test/config-store-extended.test.ts +++ b/packages/cli/test/config-store-extended.test.ts @@ -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", diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 2b986bb5e4..a354de06a7 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -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(); diff --git a/packages/cli/test/specs-alias.test.ts b/packages/cli/test/specs-alias.test.ts index 2a74dfe267..561ed4ea7e 100644 --- a/packages/cli/test/specs-alias.test.ts +++ b/packages/cli/test/specs-alias.test.ts @@ -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(() => {}); diff --git a/packages/cli/test/specs.test.ts b/packages/cli/test/specs.test.ts index eac9137ee8..53d85de9c9 100644 --- a/packages/cli/test/specs.test.ts +++ b/packages/cli/test/specs.test.ts @@ -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 { diff --git a/packages/cli/test/topology-default-agent.test.ts b/packages/cli/test/topology-default-agent.test.ts new file mode 100644 index 0000000000..bba2fa6723 --- /dev/null +++ b/packages/cli/test/topology-default-agent.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/test/up.test.ts b/packages/cli/test/up.test.ts index 5ce675b27f..dc7627fc10 100644 --- a/packages/cli/test/up.test.ts +++ b/packages/cli/test/up.test.ts @@ -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") { diff --git a/packages/daemon/src/domain/spec-library-workflow-scanner.ts b/packages/daemon/src/domain/spec-library-workflow-scanner.ts index 1bd1f93171..11eef8fec3 100644 --- a/packages/daemon/src/domain/spec-library-workflow-scanner.ts +++ b/packages/daemon/src/domain/spec-library-workflow-scanner.ts @@ -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 diff --git a/packages/daemon/src/domain/user-settings/settings-store.ts b/packages/daemon/src/domain/user-settings/settings-store.ts index ce23c63f5e..2697198742 100644 --- a/packages/daemon/src/domain/user-settings/settings-store.ts +++ b/packages/daemon/src/domain/user-settings/settings-store.ts @@ -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 = "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 = { "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; diff --git a/packages/daemon/src/routes/spec-library.ts b/packages/daemon/src/routes/spec-library.ts index f8b3f25c68..3192f34265 100644 --- a/packages/daemon/src/routes/spec-library.ts +++ b/packages/daemon/src/routes/spec-library.ts @@ -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(); + 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 diff --git a/packages/daemon/test/spec-library-routes.test.ts b/packages/daemon/test/spec-library-routes.test.ts index 16f925e686..ec3493ba37 100644 --- a/packages/daemon/test/spec-library-routes.test.ts +++ b/packages/daemon/test/spec-library-routes.test.ts @@ -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 }>; diff --git a/packages/daemon/test/spec-library-workflow-folder-route.test.ts b/packages/daemon/test/spec-library-workflow-folder-route.test.ts index 345d1c4bd1..47d00b5a7d 100644 --- a/packages/daemon/test/spec-library-workflow-folder-route.test.ts +++ b/packages/daemon/test/spec-library-workflow-folder-route.test.ts @@ -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")); diff --git a/packages/daemon/test/user-settings-routes.test.ts b/packages/daemon/test/user-settings-routes.test.ts index a9ce4afd68..93555248cb 100644 --- a/packages/daemon/test/user-settings-routes.test.ts +++ b/packages/daemon/test/user-settings-routes.test.ts @@ -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 }); diff --git a/packages/daemon/test/user-settings-store.test.ts b/packages/daemon/test/user-settings-store.test.ts index af1ac32187..09c4b36964 100644 --- a/packages/daemon/test/user-settings-store.test.ts +++ b/packages/daemon/test/user-settings-store.test.ts @@ -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",