diff --git a/docs/as-built/architecture/plugin-agent-image-context-pack.md b/docs/as-built/architecture/plugin-agent-image-context-pack.md index cf2246170..de186102d 100644 --- a/docs/as-built/architecture/plugin-agent-image-context-pack.md +++ b/docs/as-built/architecture/plugin-agent-image-context-pack.md @@ -67,9 +67,10 @@ in-memory cache at daemon scope. `ContextPackLibraryService.scan()` walks roots, parses each `manifest.yaml`, replaces the in-memory index; collisions are **last-wins** in discovery order (workspace > user_file > builtin). -Startup wires three roots: builtin (`../context-packs`, first), user-file -(`~/.openrig/context-packs`), and workspace-local -`/.openrig/context-packs` when present + distinct. A pack is +Startup wires the builtin root (`../context-packs`, first), the configured +user-file root (`context.root`, default `$OPENRIG_HOME/context`), its +`system/` child as the canonical System World root, and the workspace-local +`/.openrig/context-packs` root when present + distinct. A pack is addressed by its path-like **ref** (e.g. `packs/compaction-restore`), which is its unique identity; the opaque entry id is `context-pack:` (a UI routing key). Colon-id `context-pack::` addressing and the `/library/:id` @@ -79,8 +80,8 @@ ordinary files only and rejects context-pack expansion; compose a durable ref first, then use a dedicated delivery verb when delivery is intended. > Source: `domain/context-packs/context-pack-library-service.ts:59-94` -> (scan; last-wins L79-81), `:31-33` (id); `startup.ts:481-502` (3-root -> IIFE; builtin `unshift` L497, workspace L491-493) @HEAD. +> (scan; last-wins L79-81), `:31-33` (id); `startup.ts` +> (`contextPackLibrary` construction) @HEAD. The parser is pure (no fs; caller passes raw YAML). It rejects malformed YAML, missing `name`/`version`, non-array `files`, per-file path traversal diff --git a/docs/as-built/architecture/workspace-primitive.md b/docs/as-built/architecture/workspace-primitive.md index bc878cc54..456c73f61 100644 --- a/docs/as-built/architecture/workspace-primitive.md +++ b/docs/as-built/architecture/workspace-primitive.md @@ -229,7 +229,9 @@ discovery and exposes empty `install.context` / `install.skills` selectors. The ignore file excludes `exhaust/` and local `.openrig/` projection state while keeping authored project context versionable. -The initializer is additive. It never deletes or overwrites an existing file; +The workspace owner is called by the shared instance initializer used by CLI +daemon start and direct daemon first start. The initializer is additive. It +never deletes or overwrites an existing file; the retained `--force` spelling is a compatibility no-op for overwrite behavior. CLI and daemon scaffolds are byte-parity pinned. Mission and slice content is created explicitly through `rig scope`, not seeded by workspace @@ -239,6 +241,8 @@ and the retired `artifacts/`, `evidence/`, `progress/`, `field-notes/`, > Source: `domain/workspace/default-workspace-scaffold.ts` > (`workspaceScaffoldDirs`, `workspaceScaffoldFiles`); CLI +> `domain/instance-initialization.ts`; daemon `index.ts`; CLI +> `packages/cli/src/daemon-lifecycle.ts` and > `packages/cli/src/commands/config-init-workspace.ts` (`runInitWorkspace`); > parity: `packages/daemon/test/getting-started-narrative-parity.test.ts` > @HEAD. diff --git a/docs/as-built/cli-reference.md b/docs/as-built/cli-reference.md index 7b4c5b0aa..b08b7200b 100644 --- a/docs/as-built/cli-reference.md +++ b/docs/as-built/cli-reference.md @@ -116,7 +116,11 @@ Subcommands: - `logs [--follow]` Notes: -- `start` launches the daemon process and accepts runtime overrides for port, host, and DB path. +- `start` additively reconciles the canonical instance layout before launching + the daemon process, then accepts runtime overrides for port, host, and DB + path. Direct daemon first start uses the same initializer. Existing files are + preserved and wrong-type managed paths are refused before any write. See + `docs/reference/instance-layout.md`. - `logs` reads daemon log output and can follow it. - **Deploy identity (v0.4.4, OPR.0.4.4.11 FR-6/7)**: a PACKAGED build (built via `scripts/build-package.sh`) is stamped with `{semver, commit, dirty, builtAt}`; the daemon's `/healthz` payload carries the four stamp fields additively and `rig --version` renders ` ([, dirty])`. A source/dev run has NO stamp and adds NOTHING (never an invented SHA) — `/healthz` keeps its legacy body and `--version` prints the plain semver. This is the 30-second stale-deploy diagnostic: an unstamped or old-commit `/healthz` on a long-running host means you are looking at an older deployed build, not the source tree. Source: `packages/{daemon,cli}/src/build-info.ts` (`stampFields`), `packages/daemon/src/server.ts` (`/healthz`), `packages/cli/src/version.ts`. @@ -154,6 +158,10 @@ Supported keys: - `transcripts.enabled` - `transcripts.path` - `workspace.root` (and other workspace-rooted paths used by `init-workspace`) +- `context.root` (default `$OPENRIG_HOME/context`; env + `OPENRIG_CONTEXT_ROOT`) — the single writable addressable context library. + The removed `context.packs_root`, config field `context.packsRoot`, and env + `OPENRIG_CONTEXT_PACKS_ROOT` are refused with replacement guidance. - `skills.root` (default `$OPENRIG_HOME/skills`; env `OPENRIG_SKILLS_ROOT`) — the one authoritative Git-versioned managed skill catalog. An override replaces the default; it does not add an overlay root. diff --git a/docs/reference/getting-started.md b/docs/reference/getting-started.md index 7576339e0..b01a61201 100644 --- a/docs/reference/getting-started.md +++ b/docs/reference/getting-started.md @@ -16,7 +16,8 @@ is no magic one-command onboarding; each step is a real, inspectable verb. the kernel (see "Kernel framing" below). 2. **`rig up `** - launch a rig. This **auto-starts the daemon** if it is not already running (the four daemon-dependent verbs below do not auto-start - it - they tell you to run `rig up` or `rig daemon start`). + it - they tell you to run `rig up` or `rig daemon start`). Daemon start first + reconciles the additive [OpenRig instance layout](instance-layout.md). 3. **`rig status`** - see what is currently true: daemon up/down + port, kernel readiness (distinct from daemon health), and your effective workspace root (default vs override). diff --git a/docs/reference/instance-layout.md b/docs/reference/instance-layout.md new file mode 100644 index 000000000..620934dfc --- /dev/null +++ b/docs/reference/instance-layout.md @@ -0,0 +1,67 @@ +# OpenRig Instance Layout + +An OpenRig instance keeps its managed state under one configured +`$OPENRIG_HOME`. `rig daemon start` and direct daemon first start reconcile the +same additive layout before the database is opened or the listener binds. + +```text +$OPENRIG_HOME/ + config.json # typed instance settings; created as an empty object + state/ # runtime-owned durable state + context/ # addressable context library (`context.root`) + system/ # canonical System World library root + skills/ # managed skill catalog (`skills.root`) + workspace/ # project work tree (`workspace.root`) + SPEC.md # project intent + project.yaml # project context and skill selection + workspace.yaml # project-location catalog + .gitignore + missions/ + exhaust/ + specs/ # canonical instance spec library + topology/ # instance, rig, pod, and seat continuity tree + plugins/ # installed OpenRig plugins + run/ # process coordination files + logs/ # daemon and operation logs + transcripts/ # durable per-seat terminal transcripts + backups/ # operator-created recovery artifacts + secrets/ # local connector and host secrets +``` + +The initializer creates only missing managed entries. It never overwrites an +existing file, and it checks every managed path before the first write. A path +with the wrong type is reported by its exact location; unrelated user-owned +content is preserved. A second run against an already-converged instance writes +nothing. + +The workspace subtree is owned by the [Project Workspace +Contract](project-workspace.md). The instance initializer calls that owner +rather than carrying another copy of its file bytes. `skills/` and `topology/` +are created as empty roots; their respective catalog and topology workflows own +their contents. + +## Context library setting + +The addressable context library has one typed setting and one environment +override: + +| Surface | Value | +| --- | --- | +| Config key | `context.root` | +| Environment | `OPENRIG_CONTEXT_ROOT` | +| Default | `$OPENRIG_HOME/context` | +| Resolved property | `contextRoot` | + +The removed `context.packs_root`, `context.packsRoot`, and +`OPENRIG_CONTEXT_PACKS_ROOT` spellings are refused with guidance to use +`context.root`; they are not compatibility aliases. Bundle installation and +`rig context add` both resolve the same configured landing root. + +## Existing spec libraries + +Creating `$OPENRIG_HOME/specs` does not migrate existing launch-era specs. +Upgraded installations may still have a separate legacy specs library that the +runtime reads for compatibility. Treat the two-home state as an explicit +limitation: use the live spec-library commands to determine where a spec is +served from, and do not infer convergence merely because the canonical +directory exists. diff --git a/docs/reference/project-workspace.md b/docs/reference/project-workspace.md index b46a6ca63..3db5cee21 100644 --- a/docs/reference/project-workspace.md +++ b/docs/reference/project-workspace.md @@ -4,6 +4,9 @@ OpenRig's Project TUI is a file-backed view over a workspace root. The folder shape is intentionally simple so humans and agents can create or repair it without daemon-internal knowledge. +This subtree is part of the wider [OpenRig Instance Layout](instance-layout.md). +The instance initializer delegates these exact workspace bytes to this owner. + ## Default Shape `rig config init-workspace` creates the default workspace at diff --git a/packages/cli/src/commands/config-init-workspace.ts b/packages/cli/src/commands/config-init-workspace.ts index cc250af68..22a2325e3 100644 --- a/packages/cli/src/commands/config-init-workspace.ts +++ b/packages/cli/src/commands/config-init-workspace.ts @@ -1,15 +1,18 @@ -// Canonical project-workspace scaffold used by `rig config init-workspace`. -// -// Keep this byte-compatible with -// packages/daemon/src/domain/workspace/default-workspace-scaffold.ts. The two -// packages intentionally do not depend on one another, and the daemon parity -// test pins their shared output. +// Public adapter for the S01 project-workspace owner used by S05 instance +// initialization. The bytes and behavior live in @openrig/daemon. import { Command } from "commander"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { + ensureDefaultWorkspace, + workspaceScaffoldDirs, + workspaceScaffoldFiles, + type InitWorkspaceResult, +} from "@openrig/daemon/instance-initialization"; import { ConfigStore } from "../config-store.js"; +export { workspaceScaffoldDirs, workspaceScaffoldFiles }; +export type { InitWorkspaceResult }; + export interface InitWorkspaceOpts { root?: string; /** Deprecated compatibility input. Existing files are always preserved. */ @@ -18,63 +21,6 @@ export interface InitWorkspaceOpts { json?: boolean; } -export interface InitWorkspaceResult { - root: string; - rootCreated: boolean; - subdirs: Array<{ name: string; path: string; created: boolean }>; - files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>; - dryRun: boolean; -} - -const WORKSPACE_DIRS = ["missions", "exhaust"] as const; - -const PROJECT_SPEC = `--- -intent: Organize this project's durable work as missions and slices, then move it through queue-backed agent collaboration. ---- - -# Project - -This is the project-level context for the default OpenRig workspace. It gives -agents a stable project intent before they descend into the active mission and -slice. Edit it to describe what your project is for and who benefits from it. -`; - -const PROJECT_MANIFEST = `schema: openrig.project/v0alpha1 -kind: project -install: - intent: SPEC.md - context: [] - skills: [] -missions: - root: missions -# Add ordered project Markdown addresses and stable catalog skill IDs above. -# Skill source and the System World remain outside the project workspace. -`; - -const WORKSPACE_CATALOG = `schema: openrig.workspace/v0alpha1 -projects: - - id: default - root: . -`; - -const WORKSPACE_GITIGNORE = `# OpenRig disposable work and local runtime projection state. -/exhaust/ -/.openrig/ -`; - -export function workspaceScaffoldDirs(): string[] { - return [...WORKSPACE_DIRS]; -} - -export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> { - return [ - { relPath: "SPEC.md", content: PROJECT_SPEC }, - { relPath: "project.yaml", content: PROJECT_MANIFEST }, - { relPath: "workspace.yaml", content: WORKSPACE_CATALOG }, - { relPath: ".gitignore", content: WORKSPACE_GITIGNORE }, - ]; -} - export function initWorkspaceCommand(configPath?: string): Command { const cmd = new Command("init-workspace") .description("Scaffold a repo-ready project workspace") @@ -110,37 +56,12 @@ export function initWorkspaceCommand(configPath?: string): Command { export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult { const store = new ConfigStore(opts.configPath); const root = opts.root ?? (store.get("workspace.root") as string); - const dryRun = !!opts.dryRun; - const scaffoldFiles = workspaceScaffoldFiles(); - const scaffoldDirs = workspaceScaffoldDirs(); - const rootExists = existsSync(root); - const result: InitWorkspaceResult = { - root, - rootCreated: !rootExists, - subdirs: [], - files: [], - dryRun, - }; - - if (!rootExists && !dryRun) mkdirSync(root, { recursive: true }); - - for (const sub of scaffoldDirs) { - const subPath = join(root, sub); - const subExists = existsSync(subPath); - if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true }); - result.subdirs.push({ name: sub, path: subPath, created: !subExists }); + const result = ensureDefaultWorkspace({ root, dryRun: !!opts.dryRun }); + if (!result.ok) { + const detail = result.conflicts + .map((conflict) => `${conflict.path}: expected ${conflict.expected}, found ${conflict.actual}`) + .join("; "); + throw new Error(`Workspace initialization blocked: ${detail}`); } - - for (const file of scaffoldFiles) { - const absPath = join(root, file.relPath); - const exists = existsSync(absPath); - if (exists) { - result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" }); - continue; - } - if (!dryRun) writeFileSync(absPath, file.content, "utf-8"); - result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null }); - } - return result; } diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 5a36f7b6e..cf6f8bd6e 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -69,7 +69,7 @@ Keys: workspace.* root, slices_root, steering_path, specs_root, projects_root, catalog_path, operator_seat_name topology.root the topology tree root (instance altitude at its top; default $OPENRIG_HOME/topology) - context.packs_root context-pack library landing zone for 'rig context add' (default $OPENRIG_HOME/context-packs) + context.root addressable context library for 'rig context add' (default $OPENRIG_HOME/context) skills.root versioned skill catalog (default $OPENRIG_HOME/skills) onboarding.default_pack.enabled deliver the two-part fresh-seat mental-model pack (default on) files.allowlist name:/abs/path,name:/abs/path diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index c65f99913..35c6dcd1a 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -7,7 +7,7 @@ // // Each delegates to /api/context-packs/library/* against the daemon. // The `add` verb installs a pack from a directory at -// ~/.openrig/context-packs// — host-symlink-free contract, +// $OPENRIG_HOME/context// — host-symlink-free contract, // matches `rig specs add` shape (regular files only; no symlinks). import { Command } from "commander"; @@ -571,7 +571,7 @@ Examples: return; } if (entries.length === 0) { - console.log("No context packs in library. Author one at ~/.openrig/context-packs// then run: rig context sync"); + console.log("No context packs in library. Author one under `rig config get context.root`, then run: rig context sync"); return; } for (const e of entries) { @@ -883,14 +883,14 @@ Examples: cmd.command("add") .argument("", "Local directory OR http(s):// URL of a context pack (manifest.yaml + files)") - .description("Install a context pack from a local directory or a URL into the context-packs landing zone") + .description("Install a context pack from a local directory or a URL into the configured context library") .option("--name ", "Override the install name (defaults to the manifest name / source basename)") .option("--json", "JSON output") .action(async (source: string, opts: { name?: string; json?: boolean }) => { try { - // OPR.0.5.3.7 R4 — config-resolved landing zone (env > config > $OPENRIG_HOME/context-packs), + // OPR.0.5.9.5 Wave B — config-resolved context library, // never a hardcoded ~/.openrig literal; the daemon resolves the same key. - const targetRoot = new ConfigStore().resolve().context.packsRoot; + const targetRoot = new ConfigStore().resolve().context.root; let targetDir: string; if (isHttpUrl(source)) { // R4 — URL install: fetch → validate → atomic stage+rename (no partial pack). diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index f9512210a..7f9e7df7d 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -66,6 +66,16 @@ export function realDeps(): LifecycleDeps { removeFile: (p) => { try { fs.unlinkSync(p); } catch { /* ignore */ } }, exists: (p) => fs.existsSync(p), mkdirp: (p) => fs.mkdirSync(p, { recursive: true }), + pathKind: (p) => { + try { + const value = fs.lstatSync(p); + if (value.isDirectory()) return "directory"; + if (value.isFile()) return "file"; + return "other"; + } catch { + return "missing"; + } + }, openForAppend: (p) => fs.openSync(p, "a"), isProcessAlive, // RULING 1ae863d2 — sibling-home scan (home-resolution honesty). @@ -152,6 +162,9 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command { transcriptsEnabled: config.transcripts.enabled, transcriptsPath: config.transcripts.path, workspaceRoot: config.workspace.root, + contextRoot: config.context.root, + skillsRoot: config.skills.root, + topologyRoot: config.topology.root, // V1 pre-release CLI/daemon Item 1 — project the // ConfigStore-resolved rotation tunables into the daemon // process env so file-stored values diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 41503131d..1cf5a17ca 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -215,6 +215,9 @@ Examples: transcriptsLines: resolvedConfig.transcripts.lines, transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds, workspaceRoot: resolvedConfig.workspace.root, + contextRoot: resolvedConfig.context.root, + skillsRoot: resolvedConfig.skills.root, + topologyRoot: resolvedConfig.topology.root, }, deps.lifecycleDeps); status = await getDaemonStatus(deps.lifecycleDeps); } catch (err) { diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index b1a8375a2..a29ad4bd6 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -4,7 +4,8 @@ import { existsSync, readFileSync } from "node:fs"; import { parse as parseYamlDoc } from "yaml"; import { Command } from "commander"; import { DaemonClient, DaemonConnectionError } from "../client.js"; -import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps , daemonStatusGuard} from "../daemon-lifecycle.js"; +import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js"; +import type { RiggedConfig } from "../config-store.js"; import { realDeps } from "./daemon.js"; import type { StatusDeps } from "./status.js"; @@ -133,12 +134,7 @@ Examples: // Run preflight before auto-start let status = await getDaemonStatus(deps.lifecycleDeps); if (status.state !== "running") { - let resolvedConfig: { - daemon: { port: number; host: string }; - db: { path: string }; - transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number }; - workspace: { root: string }; - } | null = null; + let resolvedConfig: RiggedConfig | null = null; // bug-fix slice auth-bearer-tailscale-trust: track whether // daemon.host was operator-explicit (env or config file) vs // default-fallback. The daemon's multi-bind path (loopback + @@ -199,6 +195,9 @@ Examples: transcriptsLines: resolvedConfig?.transcripts.lines, transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds, workspaceRoot: resolvedConfig?.workspace.root, + contextRoot: resolvedConfig?.context.root, + skillsRoot: resolvedConfig?.skills.root, + topologyRoot: resolvedConfig?.topology.root, }, deps.lifecycleDeps); status = await getDaemonStatus(deps.lifecycleDeps); } catch (err) { diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts index 8e0eea8b9..f81fa8787 100644 --- a/packages/cli/src/config-store.ts +++ b/packages/cli/src/config-store.ts @@ -46,11 +46,10 @@ export interface RiggedConfig { topology: { root: string; }; - // OPR.0.5.3.7 R4 — the context-pack library landing zone (`rig context add` - // installs here). Config-resolved so a shipped verb runs unmodified on a - // stranger machine — never a hardcoded ~/.openrig literal. Twin of the daemon. + // OPR.0.5.9.5 Wave B — the addressable context library root (`rig context add` + // installs here). The removed packsRoot shape is refused, never bridged. context: { - packsRoot: string; + root: string; }; skills: { root: string; @@ -231,8 +230,8 @@ const DEFAULTS = { }, // OPR.0.5.3.6 D1 — derived under the OpenRig home, never a shared-docs literal. topology: { root: getDefaultOpenRigPath("topology") }, - // OPR.0.5.3.7 R4 — derived under the OpenRig home, never a shared-docs literal. - context: { packsRoot: getDefaultOpenRigPath("context-packs") }, + // OPR.0.5.9.5 Wave B — canonical addressable context library. + context: { root: getDefaultOpenRigPath("context") }, skills: { root: getDefaultOpenRigPath("skills") }, onboarding: { defaultPack: { enabled: true } }, files: { allowlist: "" }, @@ -362,8 +361,8 @@ export const VALID_KEYS = [ // shared-docs/rigs location stays readable via the daemon's // resolveLegacyTopologyRigsRoot fallback (advisory-emitting). "topology.root", - // OPR.0.5.3.7 R4 — context-pack landing zone; lockstep with the daemon twin. - "context.packs_root", + // OPR.0.5.9.5 Wave B — canonical context library; old key is refused. + "context.root", "skills.root", "onboarding.default_pack.enabled", "files.allowlist", @@ -454,8 +453,7 @@ export const ENV_MAP: Record = { "workspace.projects_root": { primary: "OPENRIG_WORKSPACE_PROJECTS_ROOT" }, "workspace.catalog_path": { primary: "OPENRIG_WORKSPACE_CATALOG_PATH" }, "topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" }, - // OPR.0.5.3.7 R4 — new key, OPENRIG_* only (no RIGGED_* legacy). - "context.packs_root": { primary: "OPENRIG_CONTEXT_PACKS_ROOT" }, + "context.root": { primary: "OPENRIG_CONTEXT_ROOT" }, "skills.root": { primary: "OPENRIG_SKILLS_ROOT" }, "onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" }, // UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST / @@ -534,7 +532,7 @@ const KEY_TO_PATH: Record = { "workspace.projects_root": ["workspace", "projectsRoot"], "workspace.catalog_path": ["workspace", "catalogPath"], "topology.root": ["topology", "root"], - "context.packs_root": ["context", "packsRoot"], + "context.root": ["context", "root"], "skills.root": ["skills", "root"], "onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"], "files.allowlist": ["files", "allowlist"], @@ -586,6 +584,28 @@ function isValidKey(key: string): key is ValidKey { return (VALID_KEYS as readonly string[]).includes(key); } +const REMOVED_CONTEXT_KEY = "context.packs_root"; +const REMOVED_CONTEXT_ENV = "OPENRIG_CONTEXT_PACKS_ROOT"; + +export function removedContextSettingMessage(key: string): string | null { + return key === REMOVED_CONTEXT_KEY + ? 'Config key "context.packs_root" was removed; use "context.root".' + : null; +} + +function assertNoRemovedContextSetting(fileConfig: Record): void { + if (process.env[REMOVED_CONTEXT_ENV]?.trim()) { + throw new Error( + "OPENRIG_CONTEXT_PACKS_ROOT was removed; use OPENRIG_CONTEXT_ROOT (config key context.root).", + ); + } + if (getNestedValue(fileConfig, ["context", "packsRoot"]) !== undefined) { + throw new Error( + 'Config file contains removed key "context.packs_root" (context.packsRoot); replace it with "context.root" (context.root).', + ); + } +} + // ── OPR.0.4.4.15 (G15-P1) — TWIN of the daemon settings-store's ONE // registered dynamic key class: `feed.subscriptions..enabled` // (boolean; v1 per-host key set CLOSED to {enabled}; no env mapping). @@ -926,7 +946,7 @@ export class ConfigStore { root: v("topology.root") as string, }, context: { - packsRoot: v("context.packs_root") as string, + root: v("context.root") as string, }, skills: { root: v("skills.root") as string, @@ -1023,6 +1043,8 @@ export class ConfigStore { * default). */ resolveWithSource(key: string): ResolvedSetting { + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) throw new Error(removedMessage); // OPR.0.4.4.15: dynamic class resolves file-or-default (no env in v1). const feedHost = parseFeedHostSubscriptionKey(key); if (feedHost) { @@ -1096,6 +1118,8 @@ export class ConfigStore { } set(key: string, value: string): void { + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) throw new Error(removedMessage); // OPR.0.4.4.15: the registered dynamic class is accepted; every other // unknown key keeps the reject-loud behavior below byte-for-byte. const feedHost = parseFeedHostSubscriptionKey(key); @@ -1132,6 +1156,8 @@ export class ConfigStore { try { unlinkSync(this.configPath); } catch { /* missing is fine */ } return; } + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) throw new Error(removedMessage); // OPR.0.4.4.15: dynamic-class reset removes the whole host node. const feedHost = parseFeedHostSubscriptionKey(key); if (feedHost) { @@ -1158,15 +1184,19 @@ export class ConfigStore { } private readConfigFile(): Record { - if (!existsSync(this.configPath)) return {}; - const raw = readFileSync(this.configPath, "utf-8"); - try { - return JSON.parse(raw) as Record; - } catch { - throw new Error( - `Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset` - ); + let parsed: Record = {}; + if (existsSync(this.configPath)) { + const raw = readFileSync(this.configPath, "utf-8"); + try { + parsed = JSON.parse(raw) as Record; + } catch { + throw new Error( + `Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset` + ); + } } + assertNoRemovedContextSetting(parsed); + return parsed; } } diff --git a/packages/cli/src/daemon-lifecycle.ts b/packages/cli/src/daemon-lifecycle.ts index 7a2f3093b..aca91ea60 100644 --- a/packages/cli/src/daemon-lifecycle.ts +++ b/packages/cli/src/daemon-lifecycle.ts @@ -3,7 +3,14 @@ import { existsSync } from "node:fs"; import type { ChildProcess } from "node:child_process"; import { ConfigStore } from "./config-store.js"; import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js"; -import { workspaceScaffoldDirs, workspaceScaffoldFiles } from "./commands/config-init-workspace.js"; +import { + ensureDefaultWorkspace, + ensureOpenRigInstance, + formatInstanceInitializationConflicts, + type InitializationFsOps, + type InitWorkspaceResult, + type ManagedPathKind, +} from "@openrig/daemon/instance-initialization"; export interface DaemonState { pid: number; @@ -139,6 +146,9 @@ export interface StartOptions { transcriptsPath?: string; /** When set, daemon startup idempotently ensures the default workspace exists. */ workspaceRoot?: string; + contextRoot?: string; + skillsRoot?: string; + topologyRoot?: string; // V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables. // Threaded through to the daemon process env so the rotation hook // picks up file-stored ConfigStore values, not just shell env. @@ -167,6 +177,9 @@ export interface LifecycleDeps { removeFile: (path: string) => void; exists: (path: string) => boolean; mkdirp: (path: string) => void; + /** Exact production path typing for additive initialization. Tests that do + * not exercise collisions may omit it and use the legacy exists seam. */ + pathKind?: (path: string) => ManagedPathKind; openForAppend: (path: string) => number; isProcessAlive: (pid: number) => boolean; // OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry. @@ -215,43 +228,23 @@ const STATUS_PROBE_RETRY_DELAY_MS = 200; // restores the timeout the removed HEALTHZ_PROBE_TIMEOUT_MS supplied to them. const HEALTHZ_PROBE_TIMEOUT_MS = 250; -export interface WorkspaceScaffoldResult { - root: string; - rootCreated: boolean; - subdirs: Array<{ name: string; path: string; created: boolean }>; - files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>; +export type WorkspaceScaffoldResult = InitWorkspaceResult; + +const INITIALIZATION_FILE_NAMES = new Set(["config.json", "SPEC.md", "project.yaml", "workspace.yaml", ".gitignore"]); + +function lifecycleInitializationFs(deps: LifecycleDeps): InitializationFsOps { + return { + pathKind: deps.pathKind ?? ((candidate) => { + if (!deps.exists(candidate)) return "missing"; + return INITIALIZATION_FILE_NAMES.has(path.basename(candidate)) ? "file" : "directory"; + }), + mkdirp: deps.mkdirp, + writeFile: deps.writeFile, + }; } export function ensureWorkspaceScaffold(root: string, deps: LifecycleDeps): WorkspaceScaffoldResult { - const rootExists = deps.exists(root); - if (!rootExists) deps.mkdirp(root); - - const result: WorkspaceScaffoldResult = { - root, - rootCreated: !rootExists, - subdirs: [], - files: [], - }; - - for (const sub of workspaceScaffoldDirs()) { - const subPath = path.join(root, sub); - const existed = deps.exists(subPath); - if (!existed) deps.mkdirp(subPath); - result.subdirs.push({ name: sub, path: subPath, created: !existed }); - } - - for (const file of workspaceScaffoldFiles()) { - const absPath = path.join(root, file.relPath); - const existed = deps.exists(absPath); - if (existed) { - result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" }); - continue; - } - deps.writeFile(absPath, file.content); - result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null }); - } - - return result; + return ensureDefaultWorkspace({ root, fs: lifecycleInitializationFs(deps) }); } class HealthProbeTimeoutError extends Error { @@ -611,9 +604,16 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom } const daemonEntry = path.join(getDaemonPath(), "dist/index.js"); - deps.mkdirp(OPENRIG_DIR); - if (opts.workspaceRoot) { - ensureWorkspaceScaffold(opts.workspaceRoot, deps); + const initialization = ensureOpenRigInstance({ + home: OPENRIG_DIR, + workspaceRoot: opts.workspaceRoot, + contextRoot: opts.contextRoot, + skillsRoot: opts.skillsRoot, + topologyRoot: opts.topologyRoot, + fs: lifecycleInitializationFs(deps), + }); + if (!initialization.ok) { + throw new Error(`OpenRig instance initialization blocked: ${formatInstanceInitializationConflicts(initialization)}`); } const logFd = deps.openForAppend(LOG_FILE); diff --git a/packages/cli/test/config-store-extended.test.ts b/packages/cli/test/config-store-extended.test.ts index 1208a7952..fbf44e3e2 100644 --- a/packages/cli/test/config-store-extended.test.ts +++ b/packages/cli/test/config-store-extended.test.ts @@ -51,6 +51,7 @@ function clearEnv(): () => void { "OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", "OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT", "OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH", + "OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS", "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS", "OPENRIG_UI_PREVIEW_MAX_PINS", "OPENRIG_UI_PREVIEW_DEFAULT_LINES", @@ -121,7 +122,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => { "workspace.catalog_path", // OPR.0.5.3.6 D1 — the topology tree root (instance at its top). "topology.root", - "context.packs_root", + "context.root", "skills.root", "onboarding.default_pack.enabled", "files.allowlist", "progress.scan_roots", @@ -180,6 +181,29 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => { expect([...VALID_KEYS]).toEqual(expected); }); + it("resolves context.root from the canonical default and OPENRIG_CONTEXT_ROOT", () => { + const store = new ConfigStore(configPath); + expect(store.resolveWithSource("context.root")).toMatchObject({ + value: join(HOISTED_HOME, "context"), + source: "default", + }); + + process.env["OPENRIG_CONTEXT_ROOT"] = join(tmpDir, "context-library"); + expect(store.resolve().context.root).toBe(join(tmpDir, "context-library")); + }); + + it("refuses every removed context.packs_root input with context.root guidance", () => { + const store = new ConfigStore(configPath); + expect(() => store.get("context.packs_root")).toThrow(/removed.*context\.root/i); + + writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } })); + expect(() => store.resolve()).toThrow(/context\.packs_root.*context\.root/i); + + writeFileSync(configPath, "{}\n"); + process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = "/legacy-env"; + expect(() => store.resolve()).toThrow(/OPENRIG_CONTEXT_PACKS_ROOT.*OPENRIG_CONTEXT_ROOT/i); + }); + it("W2c idle-gate-qitem cadence defaults to scan=60 and active-wake=900; B6 gate defaults off/empty", () => { const config = new ConfigStore(configPath).resolve(); const idleGate = (config.policies as typeof config.policies & { diff --git a/packages/cli/test/context-pack-add-ref-safety.test.ts b/packages/cli/test/context-pack-add-ref-safety.test.ts index a78c6a13e..713b74b78 100644 --- a/packages/cli/test/context-pack-add-ref-safety.test.ts +++ b/packages/cli/test/context-pack-add-ref-safety.test.ts @@ -58,7 +58,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE const tmpHomes: string[] = []; const tmpSrcs: string[] = []; const origHome = process.env["OPENRIG_HOME"]; - const origPacksRoot = process.env["OPENRIG_CONTEXT_PACKS_ROOT"]; + const origContextRoot = process.env["OPENRIG_CONTEXT_ROOT"]; afterEach(() => { for (const d of [...tmpHomes, ...tmpSrcs]) rmSync(d, { recursive: true, force: true }); @@ -66,15 +66,15 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE tmpSrcs.length = 0; if (origHome === undefined) delete process.env["OPENRIG_HOME"]; else process.env["OPENRIG_HOME"] = origHome; - if (origPacksRoot === undefined) delete process.env["OPENRIG_CONTEXT_PACKS_ROOT"]; - else process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = origPacksRoot; + if (origContextRoot === undefined) delete process.env["OPENRIG_CONTEXT_ROOT"]; + else process.env["OPENRIG_CONTEXT_ROOT"] = origContextRoot; }); function isolatedHome(): string { const home = mkdtempSync(join(tmpdir(), "ctx-add-home-")); tmpHomes.push(home); process.env["OPENRIG_HOME"] = home; - process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = join(home, "context-packs"); + process.env["OPENRIG_CONTEXT_ROOT"] = join(home, "context"); return home; } @@ -104,7 +104,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE expect(errLogs.join("\n")).toMatch(/unsafe/); // the REAL pin: rejection happened BEFORE any fs mutation — the target // store root was never created, nothing was copied anywhere under HOME - expect(existsSync(join(home, "context-packs")), "no store root created").toBe(false); + expect(existsSync(join(home, "context")), "no store root created").toBe(false); expect(readdirSync(home), "isolated HOME untouched").toEqual([]); }); @@ -121,7 +121,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE } catch { /* daemon sync may fail against the mock-less port — the WRITE has already happened */ } console.error = origErr; console.log = origLog; - expect(existsSync(join(home, "context-packs", "packs", "compaction-restore", "manifest.yaml"))).toBe(true); + expect(existsSync(join(home, "context", "packs", "compaction-restore", "manifest.yaml"))).toBe(true); }); // Slice-03 lineage repair (R2 terminal HIGH-1): a lexically-safe path-like ref @@ -134,7 +134,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE tmpSrcs.push(src); const outside = mkdtempSync(join(tmpdir(), "ctx-add-outside-")); tmpSrcs.push(outside); - const storeRoot = join(home, "context-packs"); + const storeRoot = join(home, "context"); mkdirSync(storeRoot, { recursive: true }); symlinkSync(outside, join(storeRoot, "linked")); // attacker-planted namespace segment let failed = false; @@ -159,7 +159,7 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE const home = isolatedHome(); const src = validSourcePack(); tmpSrcs.push(src); - const storeRoot = join(home, "context-packs"); + const storeRoot = join(home, "context"); const outside = join(home, "outside-missing"); const leaf = join(storeRoot, "dangle"); mkdirSync(storeRoot, { recursive: true }); @@ -201,6 +201,6 @@ describe("ATOM 2 — `context add` write boundary rejects unsafe install refs BE process.exitCode = origExit; expect(failed, "add must fail on an unsafe version").toBe(true); expect(errLogs.join("\n")).toMatch(/version/i); - expect(existsSync(join(home, "context-packs")), "no store root created — rejected before write").toBe(false); + expect(existsSync(join(home, "context")), "no store root created — rejected before write").toBe(false); }); }); diff --git a/packages/cli/test/context.test.ts b/packages/cli/test/context.test.ts index ed46fd374..d231cc124 100644 --- a/packages/cli/test/context.test.ts +++ b/packages/cli/test/context.test.ts @@ -72,7 +72,7 @@ const FIXTURE_PACK = { purpose: "Smoke test pack", taxonomy: "mission", sourceType: "user_file" as const, - sourcePath: "/home/op/.openrig/context-packs/smoke", + sourcePath: "/home/op/.openrig/context/smoke", relativePath: "packs/smoke", updatedAt: "2026-05-04T00:00:00Z", manifestEstimatedTokens: null, @@ -506,25 +506,25 @@ files: return { baseUrl: `http://127.0.0.1:${port2}/`, close: () => srv.close() }; } - async function withPacksRoot(fn: (root: string) => Promise): Promise { - const root = mkdtempSync(join(tmpdir(), "openrig-r4-packsroot-")); - const saved = process.env["OPENRIG_CONTEXT_PACKS_ROOT"]; - process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = root; + async function withContextRoot(fn: (root: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), "openrig-r4-context-root-")); + const saved = process.env["OPENRIG_CONTEXT_ROOT"]; + process.env["OPENRIG_CONTEXT_ROOT"] = root; try { await fn(root); } finally { - if (saved === undefined) delete process.env["OPENRIG_CONTEXT_PACKS_ROOT"]; - else process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = saved; + if (saved === undefined) delete process.env["OPENRIG_CONTEXT_ROOT"]; + else process.env["OPENRIG_CONTEXT_ROOT"] = saved; rmSync(root, { recursive: true, force: true }); } } const R4_MANIFEST = "name: url-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: SKILL.md\n role: instruction\n"; - it("add : installs into the OPENRIG_CONTEXT_PACKS_ROOT landing zone (config-resolved, no hardcoded path)", async () => { + it("add : installs into the OPENRIG_CONTEXT_ROOT landing zone (config-resolved, no hardcoded path)", async () => { const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST, "SKILL.md": "# hello\nbody\n" }); try { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]); }); @@ -545,7 +545,7 @@ files: { "redirect-manifest": "/pack/manifest.yaml" }, ); try { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}redirect-manifest`, "--name", "redirected-pack"]); }); @@ -563,7 +563,7 @@ files: const absManifest = "name: evil-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: http://127.0.0.1:1/secret.md\n role: instruction\n"; const pack = await startPackServer({ "manifest.yaml": absManifest }); try { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { errLogs, exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]); }); @@ -577,7 +577,7 @@ files: }); it("add : unreachable URL fails loud with the reason and leaves NO partial pack", async () => { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { errLogs, exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", "http://127.0.0.1:1/manifest.yaml"]); }); @@ -590,7 +590,7 @@ files: it("add : malformed manifest fails loud with the reason and leaves NO partial pack", async () => { const pack = await startPackServer({ "manifest.yaml": "name: bad\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: ../escape.md\n role: x\n" }); try { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { errLogs, exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]); }); @@ -607,7 +607,7 @@ files: // manifest declares SKILL.md but the server does not serve it const pack = await startPackServer({ "manifest.yaml": R4_MANIFEST }); try { - await withPacksRoot(async (root) => { + await withContextRoot(async (root) => { const { errLogs, exitCode } = await captureLogs(async () => { await makeCmd().parseAsync(["node", "rig", "context", "add", `${pack.baseUrl}manifest.yaml`]); }); diff --git a/packages/cli/test/daemon-lifecycle.test.ts b/packages/cli/test/daemon-lifecycle.test.ts index 1d1d88f76..3007a0bc1 100644 --- a/packages/cli/test/daemon-lifecycle.test.ts +++ b/packages/cli/test/daemon-lifecycle.test.ts @@ -91,7 +91,7 @@ describe("Daemon Lifecycle", () => { expect(opts.detached).toBe(true); }); - it("start: idempotently scaffolds configured workspace before spawning", async () => { + it("start: initializes the canonical instance and configured workspace before spawning", async () => { const created = new Set(); const written = new Map(); const deps = startableDeps({ @@ -99,8 +99,32 @@ describe("Daemon Lifecycle", () => { mkdirp: vi.fn((p: string) => { created.add(p); }), writeFile: vi.fn((p: string, content: string) => { written.set(p, content); }), }); - await startDaemon({ port: 7433, db: "openrig.sqlite", workspaceRoot: "/tmp/openrig-workspace" }, deps); + await startDaemon({ + port: 7433, + db: "openrig.sqlite", + workspaceRoot: "/tmp/openrig-workspace", + contextRoot: "/tmp/openrig-context", + skillsRoot: "/tmp/openrig-skills", + topologyRoot: "/tmp/openrig-topology", + }, deps); + for (const root of [ + path.join(OPENRIG_DIR, "state"), + "/tmp/openrig-context", + path.join("/tmp/openrig-context", "system"), + "/tmp/openrig-skills", + path.join(OPENRIG_DIR, "specs"), + "/tmp/openrig-topology", + path.join(OPENRIG_DIR, "plugins"), + path.join(OPENRIG_DIR, "run"), + path.join(OPENRIG_DIR, "logs"), + path.join(OPENRIG_DIR, "transcripts"), + path.join(OPENRIG_DIR, "backups"), + path.join(OPENRIG_DIR, "secrets"), + ]) { + expect(created.has(root), root).toBe(true); + } + expect(written.get(path.join(OPENRIG_DIR, "config.json"))).toBe("{}\n"); expect(created.has("/tmp/openrig-workspace")).toBe(true); expect(created.has(path.join("/tmp/openrig-workspace", "missions"))).toBe(true); expect(created.has(path.join("/tmp/openrig-workspace", "exhaust"))).toBe(true); @@ -110,6 +134,20 @@ describe("Daemon Lifecycle", () => { expect(written.get(path.join("/tmp/openrig-workspace", ".gitignore"))).toContain("/exhaust/"); }); + it("start: refuses an exact user-owned type conflict before any write or spawn", async () => { + const conflict = "/tmp/openrig-context"; + const deps = startableDeps({ + pathKind: vi.fn((candidate: string) => candidate === conflict ? "file" : "missing"), + }); + + await expect(startDaemon({ contextRoot: conflict }, deps)).rejects.toThrow( + `${conflict}: expected directory, found file`, + ); + expect(deps.mkdirp).not.toHaveBeenCalled(); + expect(deps.writeFile).not.toHaveBeenCalled(); + expect(deps.spawn).not.toHaveBeenCalled(); + }); + // Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => { const deps = startableDeps(); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 98d9c8d08..04029db97 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -12,7 +12,10 @@ "@openrig/daemon/spec-conformance": [ "../daemon/dist/spec-conformance-surface.d.ts" ], - "@openrig/daemon/gateway-protocol": ["../daemon/dist/gateway-protocol-surface.d.ts"] + "@openrig/daemon/gateway-protocol": ["../daemon/dist/gateway-protocol-surface.d.ts"], + "@openrig/daemon/instance-initialization": [ + "../daemon/dist/instance-initialization-surface.d.ts" + ] } }, "include": ["src"], diff --git a/packages/daemon/context-packs-src/world-example/your-world.md b/packages/daemon/context-packs-src/world-example/your-world.md index bb6f3ed5e..9ffc9e9d9 100644 --- a/packages/daemon/context-packs-src/world-example/your-world.md +++ b/packages/daemon/context-packs-src/world-example/your-world.md @@ -3,7 +3,7 @@ A world pack situates an agent: it describes where the agent is, what exists there, and how to act. -Use `rig context add ` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.packs_root` only when a path is needed. +Use `rig context add ` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.root` only when a path is needed. Copy this pack, update its manifest, and replace each prompt below with your world's facts. diff --git a/packages/daemon/context-packs-src/world-public/claims.yaml b/packages/daemon/context-packs-src/world-public/claims.yaml index f20e88026..3b9225a3b 100644 --- a/packages/daemon/context-packs-src/world-public/claims.yaml +++ b/packages/daemon/context-packs-src/world-public/claims.yaml @@ -124,7 +124,7 @@ claims: kind: judgment flagged: "Authored purpose and taste; no live scan can prove it." - id: world-example-install - statement: "Use `rig context add ` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.packs_root` only when a path is needed." + statement: "Use `rig context add ` to install a world pack, confirm its ref with `rig context list`, and derive the configured store with `rig config get context.root` only when a path is needed." kind: operational check: world-example-install - id: world-example-authoring diff --git a/packages/daemon/context-packs-src/world-public/verify-world.sh b/packages/daemon/context-packs-src/world-public/verify-world.sh index f75502bbc..d353991b9 100644 --- a/packages/daemon/context-packs-src/world-public/verify-world.sh +++ b/packages/daemon/context-packs-src/world-public/verify-world.sh @@ -368,19 +368,19 @@ else fail private-ref-boundary 'the world-public or local-world namespace projection is false' fi -configured_packs_root=$(rig config get context.packs_root 2>/dev/null) -configured_packs_status=$? +configured_context_root=$(rig config get context.root 2>/dev/null) +configured_context_status=$? example_install_ok=1 -[ "$configured_packs_status" -eq 0 ] || example_install_ok=0 -[ -n "$configured_packs_root" ] || example_install_ok=0 -if [ -n "${OPENRIG_CONTEXT_PACKS_ROOT:-}" ] && [ "$configured_packs_root" != "$OPENRIG_CONTEXT_PACKS_ROOT" ]; then +[ "$configured_context_status" -eq 0 ] || example_install_ok=0 +[ -n "$configured_context_root" ] || example_install_ok=0 +if [ -n "${OPENRIG_CONTEXT_ROOT:-}" ] && [ "$configured_context_root" != "$OPENRIG_CONTEXT_ROOT" ]; then example_install_ok=0 fi rig context add --help >/dev/null 2>&1 || example_install_ok=0 rig context list --help >/dev/null 2>&1 || example_install_ok=0 probe_ref="world-public-verify-$$" -probe_target="$configured_packs_root/$probe_ref" +probe_target="$configured_context_root/$probe_ref" probe_installed=0 cleanup_probe() { [ "$probe_installed" -eq 1 ] || return 0 @@ -517,7 +517,7 @@ if [ "$example_regions_ok" -eq 1 ] && grep -Fq 'rig context get world-example' "$example/your-world.md" && grep -Fq 'rig context add ' "$example/your-world.md" && grep -Fq 'rig context list' "$example/your-world.md" && - grep -Fq 'rig config get context.packs_root' "$example/your-world.md" && + grep -Fq 'rig config get context.root' "$example/your-world.md" && grep -Fq "Copy this pack, update its manifest, and replace each prompt below with your world's facts." "$example/your-world.md" && grep -Fq 'Run `rig context get world-example`, copy this pack, and make the project a book world by describing the writer, manuscript, sources, editorial rules, decisions, current draft state, and next useful actions in a few coherent files.' "$example/your-world.md" && grep -Fq "Name the world and the agent's place in it." "$example/your-world.md" && diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 65ec52bfc..4cff6398d 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -36,6 +36,10 @@ "./skill-loadout": { "types": "./dist/skill-loadout-surface.d.ts", "import": "./dist/skill-loadout-surface.js" + }, + "./instance-initialization": { + "types": "./dist/instance-initialization-surface.d.ts", + "import": "./dist/instance-initialization-surface.js" } }, "scripts": { diff --git a/packages/daemon/src/domain/bundle-context-packs-router.ts b/packages/daemon/src/domain/bundle-context-packs-router.ts index 4320c322f..058cc3225 100644 --- a/packages/daemon/src/domain/bundle-context-packs-router.ts +++ b/packages/daemon/src/domain/bundle-context-packs-router.ts @@ -60,9 +60,9 @@ export interface RouteContextPacksInput { /** Relative paths to context-pack manifest.yaml files declared in the * bundle manifest's context_packs[] block. */ declaredContextPacks: string[]; - /** Absolute path to the operator context-packs library (per - * context-pack-types.ts:9-10: typically `/context-packs` - * or a workspace-local `.openrig/context-packs`). Caller resolves. */ + /** Absolute path to the operator context library (per + * context-pack-types.ts:9-10: typically ``; the caller may + * also route to a workspace-local `.openrig/context-packs`). */ targetContextPacksDir: string; } diff --git a/packages/daemon/src/domain/context-packs/context-pack-types.ts b/packages/daemon/src/domain/context-packs/context-pack-types.ts index 32f3604a3..2a3878bba 100644 --- a/packages/daemon/src/domain/context-packs/context-pack-types.ts +++ b/packages/daemon/src/domain/context-packs/context-pack-types.ts @@ -6,7 +6,7 @@ // reviewable, sendable. Parallel to skills + workflow_specs in shape. // // MVP single-host context: storage filesystem-canonical at -// ~/.openrig/context-packs// (host) + workspace-local +// // (host; default $OPENRIG_HOME/context) + workspace-local // .openrig/context-packs//. NO new SQLite tables; library cache // is in-memory at the daemon scope. diff --git a/packages/daemon/src/domain/context-packs/manifest-parser.ts b/packages/daemon/src/domain/context-packs/manifest-parser.ts index aa8626961..00f3c5a8b 100644 --- a/packages/daemon/src/domain/context-packs/manifest-parser.ts +++ b/packages/daemon/src/domain/context-packs/manifest-parser.ts @@ -1,7 +1,7 @@ // Rig Context / Composable Context Injection v0 (PL-014) — manifest // parser. // -// Parses ~/.openrig/context-packs//manifest.yaml into a typed +// Parses //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). diff --git a/packages/daemon/src/domain/instance-initialization.ts b/packages/daemon/src/domain/instance-initialization.ts new file mode 100644 index 000000000..c2932cc17 --- /dev/null +++ b/packages/daemon/src/domain/instance-initialization.ts @@ -0,0 +1,104 @@ +import { join } from "node:path"; +import { + ensureDefaultWorkspace, + nodeInitializationFs, + type InitializationConflict, + type InitializationFsOps, +} from "./workspace/default-workspace-scaffold.js"; + +export interface OpenRigInstanceInitializationOptions { + home: string; + workspaceRoot?: string; + contextRoot?: string; + skillsRoot?: string; + topologyRoot?: string; + dryRun?: boolean; + fs?: InitializationFsOps; +} + +export interface OpenRigInstanceInitializationResult { + ok: boolean; + home: string; + createdPaths: string[]; + conflicts: InitializationConflict[]; + dryRun: boolean; +} + +export function openRigContextLibraryRoots(contextRoot: string): [string, string] { + return [contextRoot, join(contextRoot, "system")]; +} + +/** Reconcile one canonical OpenRig instance layout. This is the shared owner + * for CLI setup and daemon first-start. It owns empty roots and config.json; + * S01 owns workspace contents, S04 owns skill contents, and topology owns its + * contents. */ +export function ensureOpenRigInstance( + options: OpenRigInstanceInitializationOptions, +): OpenRigInstanceInitializationResult { + const fs = options.fs ?? nodeInitializationFs(); + const dryRun = options.dryRun ?? false; + const workspaceRoot = options.workspaceRoot ?? join(options.home, "workspace"); + const contextRoot = options.contextRoot ?? join(options.home, "context"); + const skillsRoot = options.skillsRoot ?? join(options.home, "skills"); + const topologyRoot = options.topologyRoot ?? join(options.home, "topology"); + const [contextLibraryRoot, systemContextRoot] = openRigContextLibraryRoots(contextRoot); + const directories = [...new Set([ + options.home, + join(options.home, "state"), + contextLibraryRoot, + systemContextRoot, + skillsRoot, + join(options.home, "specs"), + topologyRoot, + join(options.home, "plugins"), + join(options.home, "run"), + join(options.home, "logs"), + join(options.home, "transcripts"), + join(options.home, "backups"), + join(options.home, "secrets"), + ])]; + const configPath = join(options.home, "config.json"); + const conflicts: InitializationConflict[] = []; + + for (const path of directories) { + const actual = fs.pathKind(path); + if (actual !== "missing" && actual !== "directory") { + conflicts.push({ path, expected: "directory", actual }); + } + } + const configKind = fs.pathKind(configPath); + if (configKind !== "missing" && configKind !== "file") { + conflicts.push({ path: configPath, expected: "file", actual: configKind }); + } + + const workspacePlan = ensureDefaultWorkspace({ root: workspaceRoot, dryRun: true, fs }); + conflicts.push(...workspacePlan.conflicts); + if (conflicts.length > 0) { + return { ok: false, home: options.home, createdPaths: [], conflicts, dryRun }; + } + + const missingDirectories = directories.filter((path) => fs.pathKind(path) === "missing"); + const createdPaths = [ + ...missingDirectories, + ...(configKind === "missing" ? [configPath] : []), + ...(workspacePlan.rootCreated ? [workspaceRoot] : []), + ...workspacePlan.subdirs.filter((entry) => entry.created).map((entry) => entry.path), + ...workspacePlan.files.filter((entry) => entry.created).map((entry) => entry.absPath), + ]; + + if (!dryRun) { + for (const path of missingDirectories) fs.mkdirp(path); + if (configKind === "missing") fs.writeFile(configPath, "{}\n"); + ensureDefaultWorkspace({ root: workspaceRoot, fs }); + } + + return { ok: true, home: options.home, createdPaths, conflicts: [], dryRun }; +} + +export function formatInstanceInitializationConflicts( + result: Pick, +): string { + return result.conflicts + .map((conflict) => `${conflict.path}: expected ${conflict.expected}, found ${conflict.actual}`) + .join("; "); +} diff --git a/packages/daemon/src/domain/user-settings/settings-store.ts b/packages/daemon/src/domain/user-settings/settings-store.ts index 53e06550c..5ab529d80 100644 --- a/packages/daemon/src/domain/user-settings/settings-store.ts +++ b/packages/daemon/src/domain/user-settings/settings-store.ts @@ -37,11 +37,10 @@ const DEFAULT_TOPOLOGY_ROOT = path.join( "topology", ); -// OPR.0.5.3.7 R4 — the context-pack library landing zone's derived default, -// under $OPENRIG_HOME, matching the CLI twin's getDefaultOpenRigPath("context-packs"). -const DEFAULT_CONTEXT_PACKS_ROOT = path.join( +// OPR.0.5.9.5 Wave B — canonical addressable context library. +const DEFAULT_CONTEXT_ROOT = path.join( process.env["OPENRIG_HOME"] || process.env["RIGGED_HOME"] || path.join(os.homedir(), ".openrig"), - "context-packs", + "context", ); const DEFAULT_SKILLS_ROOT = path.join( @@ -102,8 +101,8 @@ export const SETTINGS_VALID_KEYS = [ // for that literal, so walkers carry none. Lockstep with the CLI // config-store twin (each side's parity test pins its own list). "topology.root", - // OPR.0.5.3.7 R4 — context-pack landing zone; lockstep with the CLI twin. - "context.packs_root", + // OPR.0.5.9.5 Wave B — canonical context library; old key is refused. + "context.root", "skills.root", "onboarding.default_pack.enabled", "files.allowlist", @@ -228,8 +227,7 @@ const ENV_MAP: Record = "workspace.projects_root": { primary: "OPENRIG_WORKSPACE_PROJECTS_ROOT" }, "workspace.catalog_path": { primary: "OPENRIG_WORKSPACE_CATALOG_PATH" }, "topology.root": { primary: "OPENRIG_TOPOLOGY_ROOT" }, - // OPR.0.5.3.7 R4 — new key, OPENRIG_* only (no RIGGED_* legacy). - "context.packs_root": { primary: "OPENRIG_CONTEXT_PACKS_ROOT" }, + "context.root": { primary: "OPENRIG_CONTEXT_ROOT" }, "skills.root": { primary: "OPENRIG_SKILLS_ROOT" }, "onboarding.default_pack.enabled": { primary: "OPENRIG_ONBOARDING_DEFAULT_PACK_ENABLED" }, "files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" }, @@ -306,7 +304,7 @@ const KEY_TO_PATH: Record = { "workspace.projects_root": ["workspace", "projectsRoot"], "workspace.catalog_path": ["workspace", "catalogPath"], "topology.root": ["topology", "root"], - "context.packs_root": ["context", "packsRoot"], + "context.root": ["context", "root"], "skills.root": ["skills", "root"], "onboarding.default_pack.enabled": ["onboarding", "defaultPack", "enabled"], "files.allowlist": ["files", "allowlist"], @@ -371,6 +369,28 @@ export function isSettingsValidKey(key: string): key is SettingsValidKey { return (SETTINGS_VALID_KEYS as readonly string[]).includes(key); } +const REMOVED_CONTEXT_KEY = "context.packs_root"; +const REMOVED_CONTEXT_ENV = "OPENRIG_CONTEXT_PACKS_ROOT"; + +export function removedContextSettingMessage(key: string): string | null { + return key === REMOVED_CONTEXT_KEY + ? 'Config key "context.packs_root" was removed; use "context.root".' + : null; +} + +function assertNoRemovedContextSetting(fileConfig: Record): void { + if (process.env[REMOVED_CONTEXT_ENV]?.trim()) { + throw new Error( + "OPENRIG_CONTEXT_PACKS_ROOT was removed; use OPENRIG_CONTEXT_ROOT (config key context.root).", + ); + } + if (getNestedValue(fileConfig, ["context", "packsRoot"]) !== undefined) { + throw new Error( + 'Config file contains removed key "context.packs_root" (context.packsRoot); replace it with "context.root" (context.root).', + ); + } +} + // ── OPR.0.4.4.15 (guard G15-P1 fold, arch-endorsed) ───────────────────────── // ONE registered dynamic key CLASS — NOT a general dynamic-key mechanism: // `feed.subscriptions..enabled` (boolean; the v1 per-host key set is @@ -539,7 +559,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string | case "workspace.root": return DEFAULT_WORKSPACE_ROOT; // OPR.0.5.3.6 D1 — derived under $OPENRIG_HOME, never a shared-docs literal. case "topology.root": return DEFAULT_TOPOLOGY_ROOT; - case "context.packs_root": return DEFAULT_CONTEXT_PACKS_ROOT; + case "context.root": return DEFAULT_CONTEXT_ROOT; case "skills.root": return DEFAULT_SKILLS_ROOT; case "onboarding.default_pack.enabled": return true; // Preview Terminal v0 (PL-018) defaults — match cli/src/config-store.ts. @@ -772,6 +792,8 @@ export interface ClaudeCompactionPolicy { export interface ResolvedConfig { skillsRoot: string; + contextRoot: string; + topologyRoot: string; workspaceRoot: string; workspaceSlicesRoot: string; workspaceSteeringPath: string; @@ -802,6 +824,7 @@ export class SettingsStore { resolveOne(key: SettingsValidKey, fileConfig?: Record, workspaceRoot?: string): ResolvedSetting { const fc = fileConfig ?? this.readConfigFile(); + assertNoRemovedContextSetting(fc); const wr = workspaceRoot ?? this.resolveWorkspaceRootRaw(fc); const defaultValue = getDefaultValue(key, wr); // Slice 27 BLOCKING-FIX-2 — env override is validated; on invalid @@ -857,6 +880,8 @@ export class SettingsStore { const wr = this.resolveWorkspaceRootRaw(fc); return { skillsRoot: this.resolveOne("skills.root", fc, wr).value as string, + contextRoot: this.resolveOne("context.root", fc, wr).value as string, + topologyRoot: this.resolveOne("topology.root", fc, wr).value as string, workspaceRoot: wr, workspaceSlicesRoot: this.resolveOne("workspace.slices_root", fc, wr).value as string, workspaceSteeringPath: this.resolveOne("workspace.steering_path", fc, wr).value as string, @@ -916,6 +941,8 @@ export class SettingsStore { } set(key: string, value: string): void { + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) throw new Error(removedMessage); // OPR.0.4.4.15: the ONE registered dynamic class is accepted here; // every OTHER unknown key keeps the reject-loud behavior below // byte-for-byte. @@ -986,6 +1013,8 @@ export class SettingsStore { try { unlinkSync(this.configPath); } catch { /* missing is fine */ } return; } + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) throw new Error(removedMessage); // OPR.0.4.4.15: dynamic-class reset removes the whole host node // (unsubscribe leaves no residue). const feedHost = parseFeedHostSubscriptionKey(key); @@ -1019,15 +1048,19 @@ export class SettingsStore { } private readConfigFile(): Record { - if (!existsSync(this.configPath)) return {}; - const raw = readFileSync(this.configPath, "utf-8"); - try { - return JSON.parse(raw) as Record; - } catch { - throw new Error( - `Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`, - ); + let parsed: Record = {}; + if (existsSync(this.configPath)) { + const raw = readFileSync(this.configPath, "utf-8"); + try { + parsed = JSON.parse(raw) as Record; + } catch { + throw new Error( + `Config file at ${this.configPath} is malformed. Fix the JSON or reset with: rig config reset`, + ); + } } + assertNoRemovedContextSetting(parsed); + return parsed; } } diff --git a/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts b/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts index 049ef11ad..c614cc8a4 100644 --- a/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts +++ b/packages/daemon/src/domain/workspace/default-workspace-scaffold.ts @@ -1,8 +1,8 @@ -// Canonical daemon-side project-workspace scaffold. -// -// Keep this byte-compatible with -// packages/cli/src/commands/config-init-workspace.ts. The two packages do not -// depend on one another, and the parity test pins their shared output. +// Canonical project-workspace scaffold. S01 owns these bytes; S05's instance +// initializer calls this owner rather than copying its behavior. + +import { lstatSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; const WORKSPACE_DIRS = ["missions", "exhaust"] as const; @@ -52,3 +52,102 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri { relPath: ".gitignore", content: WORKSPACE_GITIGNORE }, ]; } + +export type ManagedPathKind = "missing" | "file" | "directory" | "other"; + +export interface InitializationFsOps { + pathKind(path: string): ManagedPathKind; + mkdirp(path: string): void; + writeFile(path: string, content: string): void; +} + +export interface InitializationConflict { + path: string; + expected: "file" | "directory"; + actual: Exclude; +} + +export interface InitWorkspaceResult { + ok: boolean; + root: string; + rootCreated: boolean; + subdirs: Array<{ name: string; path: string; created: boolean }>; + files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>; + conflicts: InitializationConflict[]; + dryRun: boolean; +} + +export function nodeInitializationFs(): InitializationFsOps { + return { + pathKind(path) { + try { + const value = lstatSync(path); + if (value.isDirectory()) return "directory"; + if (value.isFile()) return "file"; + return "other"; + } catch { + return "missing"; + } + }, + mkdirp: (path) => mkdirSync(path, { recursive: true }), + writeFile: (path, content) => writeFileSync(path, content, "utf8"), + }; +} + +/** Additively reconcile the S01 project workspace. All collisions are found + * before the first write, so a malformed user-owned path never leaves a + * half-created scaffold. */ +export function ensureDefaultWorkspace(options: { + root: string; + dryRun?: boolean; + fs?: InitializationFsOps; +}): InitWorkspaceResult { + const fs = options.fs ?? nodeInitializationFs(); + const dryRun = options.dryRun ?? false; + const rootKind = fs.pathKind(options.root); + const subdirs = workspaceScaffoldDirs().map((name) => { + const path = join(options.root, name); + return { name, path, created: fs.pathKind(path) === "missing" }; + }); + const files = workspaceScaffoldFiles().map(({ relPath }) => { + const absPath = join(options.root, relPath); + const created = fs.pathKind(absPath) === "missing"; + return { relPath, absPath, created, skipped: created ? null : "exists" as const }; + }); + const conflicts: InitializationConflict[] = []; + + if (rootKind !== "missing" && rootKind !== "directory") { + conflicts.push({ path: options.root, expected: "directory", actual: rootKind }); + } + for (const subdir of subdirs) { + const actual = fs.pathKind(subdir.path); + if (actual !== "missing" && actual !== "directory") { + conflicts.push({ path: subdir.path, expected: "directory", actual }); + } + } + for (const file of files) { + const actual = fs.pathKind(file.absPath); + if (actual !== "missing" && actual !== "file") { + conflicts.push({ path: file.absPath, expected: "file", actual }); + } + } + + if (conflicts.length === 0 && !dryRun) { + if (rootKind === "missing") fs.mkdirp(options.root); + for (const subdir of subdirs) if (subdir.created) fs.mkdirp(subdir.path); + for (const file of workspaceScaffoldFiles()) { + const absPath = join(options.root, file.relPath); + if (fs.pathKind(absPath) === "missing") fs.writeFile(absPath, file.content); + } + } + + return { + ok: conflicts.length === 0, + root: options.root, + rootCreated: rootKind === "missing", + subdirs, + files, + conflicts, + dryRun, + }; +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 2f59663a3..b5c0a03d8 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -25,6 +25,11 @@ import { } from "./middleware/auth-bearer-token.js"; import type { SlowOperationInstrumentation } from "./domain/slow-op-recorder.js"; import type { ProviderService } from "./domain/provider/provider-service.js"; +import { SettingsStore } from "./domain/user-settings/settings-store.js"; +import { + ensureOpenRigInstance, + formatInstanceInitializationConflicts, +} from "./domain/instance-initialization.js"; /** OPR.0.4.3.21 (51elv2) — bound the graceful-shutdown recorder drain. */ export const SLOW_OP_SHUTDOWN_DRAIN_TIMEOUT_MS = 5_000; @@ -230,6 +235,17 @@ export async function startServer(port?: number) { // D15 — anchor the default db under OPENRIG_HOME, never a bare CWD-relative // filename (which could open the shared fleet db). Explicit OPENRIG_DB wins. const dbPath = resolveDaemonDbPath(readOpenRigEnv("OPENRIG_DB", "RIGGED_DB"), OPENRIG_HOME); + const instanceConfig = new SettingsStore().resolveConfig(); + const initialization = ensureOpenRigInstance({ + home: OPENRIG_HOME, + workspaceRoot: instanceConfig.workspaceRoot, + contextRoot: instanceConfig.contextRoot, + skillsRoot: instanceConfig.skillsRoot, + topologyRoot: instanceConfig.topologyRoot, + }); + if (!initialization.ok) { + throw new Error(`OpenRig instance initialization blocked: ${formatInstanceInitializationConflicts(initialization)}`); + } // bug-fix slice auth-bearer-tailscale-trust: distinguish "explicit // operator opt-in" from "default" by treating an undefined env as diff --git a/packages/daemon/src/instance-initialization-surface.ts b/packages/daemon/src/instance-initialization-surface.ts new file mode 100644 index 000000000..7d8d6023e --- /dev/null +++ b/packages/daemon/src/instance-initialization-surface.ts @@ -0,0 +1,16 @@ +export { + ensureOpenRigInstance, + formatInstanceInitializationConflicts, + type OpenRigInstanceInitializationOptions, + type OpenRigInstanceInitializationResult, +} from "./domain/instance-initialization.js"; +export { + ensureDefaultWorkspace, + nodeInitializationFs, + workspaceScaffoldDirs, + workspaceScaffoldFiles, + type InitializationConflict, + type InitializationFsOps, + type InitWorkspaceResult, + type ManagedPathKind, +} from "./domain/workspace/default-workspace-scaffold.js"; diff --git a/packages/daemon/src/routes/bundles.ts b/packages/daemon/src/routes/bundles.ts index b93661b61..b84d24e63 100644 --- a/packages/daemon/src/routes/bundles.ts +++ b/packages/daemon/src/routes/bundles.ts @@ -456,11 +456,9 @@ async function routeAgentImagesAfterBootstrap(bundlePath: string): Promise/context-packs — per startup.ts:496 - * (the user-file root the live ContextPackLibraryService is constructed - * against). No SettingsStore complexity (unlike workflow_specs); - * context-packs canonical path is OPENRIG_HOME-rooted per - * context-pack-types.ts:9-10. + * Target resolution uses context.root, exactly like the live + * ContextPackLibraryService. A configured root replaces the default; bundle + * routing must not silently create a second writable library. * * Mirror of routeSkillsAfterBootstrap / routePluginsAfterBootstrap / * routeWorkflowSpecsAfterBootstrap pattern: takes bundlePath only @@ -468,7 +466,7 @@ async function routeAgentImagesAfterBootstrap(bundlePath: string): Promise { - const targetContextPacksDir = getDefaultOpenRigPath("context-packs"); + const targetContextPacksDir = new ContextPackSettingsStore().resolveOne("context.root").value as string; const tmpDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "bundle-context-packs-route-")); try { await unpack(bundlePath, tmpDir); diff --git a/packages/daemon/src/routes/config.ts b/packages/daemon/src/routes/config.ts index f7424a1a6..69c31d092 100644 --- a/packages/daemon/src/routes/config.ts +++ b/packages/daemon/src/routes/config.ts @@ -13,17 +13,15 @@ // agents stay on CLI-shell-out per the shipped openrig-user-settings skill. import { Hono } from "hono"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { SETTINGS_VALID_KEYS, isSettingsValidKey, parseFeedHostSubscriptionKey, + removedContextSettingMessage, type SettingsStore, } from "../domain/user-settings/settings-store.js"; import { - workspaceScaffoldDirs, - workspaceScaffoldFiles, + ensureDefaultWorkspace, } from "../domain/workspace/default-workspace-scaffold.js"; interface InitWorkspaceBody { @@ -41,7 +39,11 @@ export function configRoutes(): Hono { if (!store) return c.json({ error: "settings_unavailable" }, 503); // OPR.0.4.4.15: dynamic-class enumeration rides ADDITIVELY beside the // static settings map (existing consumers of `settings` unaffected). - return c.json({ settings: store.resolveAllWithSource(), feedHostSubscriptions: store.listFeedHostSubscriptions() }); + try { + return c.json({ settings: store.resolveAllWithSource(), feedHostSubscriptions: store.listFeedHostSubscriptions() }); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } }); router.post("/init-workspace", async (c) => { @@ -50,49 +52,17 @@ export function configRoutes(): Hono { const body = (await c.req.json().catch(() => ({}))) as InitWorkspaceBody; const root = body.root ?? (store.resolveOne("workspace.root").value as string); const dryRun = !!body.dryRun; - // Precompute both sides before mutation so the daemon and CLI retain the - // same inspect-then-write shape. - let scaffoldFiles: ReturnType; - let scaffoldDirs: ReturnType; - try { - scaffoldFiles = workspaceScaffoldFiles(); - scaffoldDirs = workspaceScaffoldDirs(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return c.json({ error: "init_workspace_scaffold_unavailable", message }, 400); - } - - const subdirs: Array<{ name: string; path: string; created: boolean }> = []; - const files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }> = []; - - const rootExists = existsSync(root); - if (!rootExists && !dryRun) mkdirSync(root, { recursive: true }); - - for (const sub of scaffoldDirs) { - const subPath = join(root, sub); - const subExists = existsSync(subPath); - if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true }); - subdirs.push({ name: sub, path: subPath, created: !subExists }); - } - - for (const file of scaffoldFiles) { - const absPath = join(root, file.relPath); - const fileExists = existsSync(absPath); - if (fileExists) { - files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" }); - } else { - if (!dryRun) writeFileSync(absPath, file.content, "utf-8"); - files.push({ relPath: file.relPath, absPath, created: true, skipped: null }); - } - } - - return c.json({ root, rootCreated: !rootExists, subdirs, files, dryRun }); + const result = ensureDefaultWorkspace({ root, dryRun }); + if (!result.ok) return c.json({ error: "init_workspace_conflict", ...result }, 409); + return c.json(result); }); router.get("/:key", (c) => { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400); // OPR.0.4.4.15: the ONE registered dynamic class resolves here; every // other unknown key keeps the 400 below byte-for-byte. const dynamic = store.resolveFeedHostSubscription(key); @@ -107,6 +77,8 @@ export function configRoutes(): Hono { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400); const isDynamic = parseFeedHostSubscriptionKey(key) !== null; if (!isDynamic && !isSettingsValidKey(key)) { return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400); @@ -127,6 +99,8 @@ export function configRoutes(): Hono { const store = c.get("settingsStore" as never) as SettingsStore | undefined; if (!store) return c.json({ error: "settings_unavailable" }, 503); const key = c.req.param("key"); + const removedMessage = removedContextSettingMessage(key); + if (removedMessage) return c.json({ error: removedMessage, replacement: "context.root" }, 400); const isDynamic = parseFeedHostSubscriptionKey(key) !== null; if (!isDynamic && !isSettingsValidKey(key)) { return c.json({ error: `Unknown config key '${key}'`, validKeys: SETTINGS_VALID_KEYS }, 400); diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index d3ac726b1..74ab4cc50 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -109,6 +109,7 @@ import { PluginDiscoveryService } from "./domain/plugin-discovery-service.js"; // Slice 28 Checkpoint C-3 — skill-library discovery (SC-29 #11 cumulative). import { SkillLibraryDiscoveryService } from "./domain/skill-library-discovery.js"; import { ContextPackLibraryService } from "./domain/context-packs/context-pack-library-service.js"; +import { openRigContextLibraryRoots } from "./domain/instance-initialization.js"; import { AgentImageLibraryService } from "./domain/agent-images/agent-image-library-service.js"; import { SnapshotCapturer } from "./domain/agent-images/snapshot-capturer.js"; import { SettingsStore as ContextPackSettingsStore } from "./domain/user-settings/settings-store.js"; @@ -749,18 +750,21 @@ export async function createDaemon(opts?: DaemonOptions): Promise // routes. Startup context-pack expansion is intentionally unsupported; // dedicated send/broadcast/walk/queue verbs own delivery. const contextPackLibrary = (() => { - // OPR.0.5.3.7 R4 — config-resolved (env > config > $OPENRIG_HOME/context-packs), - // the same key `rig context add` writes to, so both agree on the landing zone. - const userPacksRoot = new ContextPackSettingsStore().resolveOne("context.packs_root").value as string; - try { fs.mkdirSync(userPacksRoot, { recursive: true }); } catch { /* best-effort */ } + // OPR.0.5.9.5 Wave B — config-resolved canonical context library. The + // shared instance initializer normally creates both roots; the mkdirs keep + // direct createDaemon test harnesses compatible. + const userPacksRoot = new ContextPackSettingsStore().resolveOne("context.root").value as string; + const [contextRoot, systemPacksRoot] = openRigContextLibraryRoots(userPacksRoot); + try { fs.mkdirSync(systemPacksRoot, { recursive: true }); } catch { /* best-effort */ } const roots: Array<{ path: string; sourceType: "builtin" | "user_file" | "workspace" }> = [ - { path: userPacksRoot, sourceType: "user_file" }, + { path: contextRoot, sourceType: "user_file" }, + { path: systemPacksRoot, sourceType: "user_file" }, ]; try { const settingsStore = new ContextPackSettingsStore(); const cfg = settingsStore.resolveConfig(); const workspacePacksRoot = nodePath.join(cfg.workspaceRoot, ".openrig", "context-packs"); - if (workspacePacksRoot !== userPacksRoot && fs.existsSync(workspacePacksRoot)) { + if (workspacePacksRoot !== userPacksRoot && workspacePacksRoot !== systemPacksRoot && fs.existsSync(workspacePacksRoot)) { roots.push({ path: workspacePacksRoot, sourceType: "workspace" }); } } catch { /* settings unavailable; fall through with user-file root only */ } diff --git a/packages/daemon/test/bundle-context-packs-router.test.ts b/packages/daemon/test/bundle-context-packs-router.test.ts index 07259814f..39af56f67 100644 --- a/packages/daemon/test/bundle-context-packs-router.test.ts +++ b/packages/daemon/test/bundle-context-packs-router.test.ts @@ -43,7 +43,7 @@ function packFixture(packParentDir: string): { dirs: string[]; files: string[] } } const BUNDLE_ROOT = "/bundle/root"; -const TARGET = "/operator/.openrig/context-packs"; +const TARGET = "/operator/.openrig/context"; function makeInput(overrides?: Partial): RouteContextPacksInput { return { diff --git a/packages/daemon/test/bundle-routes.test.ts b/packages/daemon/test/bundle-routes.test.ts index 0560015c5..1effbecc8 100644 --- a/packages/daemon/test/bundle-routes.test.ts +++ b/packages/daemon/test/bundle-routes.test.ts @@ -2310,16 +2310,19 @@ describe("Bundle API routes", () => { }); // Item 6 / Checkpoint 7.3f step 3: /install routes declared context_packs - // after successful bootstrap. Target = /context-packs + // after successful bootstrap. Target = the configured context.root // (startup.ts:496 user-file root). Bundle has context_packs[] with paths // to context-pack manifest.yaml files; router copies the parent dir to - // /context-packs//. Consumer-scan reachability + // //. Consumer-scan reachability // proven via real ContextPackLibraryService.scan() against the routed // root (guard d491eca9 + 3cd581e3 file-vs-dir discrimination lessons). it("POST /api/bundles/install routes declared context_packs after successful bootstrap (completed branch) — proves consumer-scan reachability", async () => { const origHome = process.env.OPENRIG_HOME; + const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT; const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-context-packs-route-test-")); + const configuredContextRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-context-root-test-")); process.env.OPENRIG_HOME = auditHome; + process.env.OPENRIG_CONTEXT_ROOT = configuredContextRoot; const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator); try { const { specPath } = seedPackage(); @@ -2385,7 +2388,7 @@ files: // Router copies the PARENT DIR of manifest.yaml: declared // "packages/test-pkg/context-packs/intent/manifest.yaml" → parent // basename "intent" → target/intent/ - const expectedPackDir = path.join(auditHome, "context-packs", "intent"); + const expectedPackDir = path.join(configuredContextRoot, "intent"); expect(fs.existsSync(expectedPackDir)).toBe(true); expect(fs.existsSync(path.join(expectedPackDir, "manifest.yaml"))).toBe(true); expect(fs.existsSync(path.join(expectedPackDir, "brief.md"))).toBe(true); @@ -2394,7 +2397,7 @@ files: // against the routed target root + assert the pack is visible. const { ContextPackLibraryService } = await import("../src/domain/context-packs/context-pack-library-service.js"); const consumer = new ContextPackLibraryService({ - roots: [{ path: path.join(auditHome, "context-packs"), sourceType: "user_file" }], + roots: [{ path: configuredContextRoot, sourceType: "user_file" }], }); const scanResult = consumer.scan(); expect(scanResult.count).toBeGreaterThanOrEqual(1); @@ -2412,7 +2415,10 @@ files: (setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap; if (origHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = origHome; + if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT; + else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot; fs.rmSync(auditHome, { recursive: true, force: true }); + fs.rmSync(configuredContextRoot, { recursive: true, force: true }); } }); @@ -2421,8 +2427,10 @@ files: // 5f410eee decoupling lesson; PROACTIVELY shipped). it("POST /api/bundles/install routes declared context_packs even when both --skip-version-check and --force are set (B1 mirror)", async () => { const origHome = process.env.OPENRIG_HOME; + const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT; const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-cpacks-dual-override-")); process.env.OPENRIG_HOME = auditHome; + process.env.OPENRIG_CONTEXT_ROOT = path.join(auditHome, "context"); const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator); try { const { specPath } = seedPackage(); @@ -2482,7 +2490,7 @@ files: expect(body.contextPacksRouting).toBeDefined(); expect(body.contextPacksRouting.routedCount).toBe(1); expect(body.contextPacksRouting.records[0].status).toBe("routed"); - expect(fs.existsSync(path.join(auditHome, "context-packs", "dualcontext", "manifest.yaml"))).toBe(true); + expect(fs.existsSync(path.join(auditHome, "context", "dualcontext", "manifest.yaml"))).toBe(true); } finally { fs.rmSync(targetRoot, { recursive: true, force: true }); } @@ -2490,6 +2498,8 @@ files: (setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap; if (origHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = origHome; + if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT; + else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot; fs.rmSync(auditHome, { recursive: true, force: true }); } }); @@ -2500,8 +2510,10 @@ files: // status=missing, NO pack copied to target. it("POST /api/bundles/install context_packs degenerate-input: declared manifest absent → status=missing, no false routedCount", async () => { const origHome = process.env.OPENRIG_HOME; + const origContextRoot = process.env.OPENRIG_CONTEXT_ROOT; const auditHome = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-cpacks-degenerate-")); process.env.OPENRIG_HOME = auditHome; + process.env.OPENRIG_CONTEXT_ROOT = path.join(auditHome, "context"); const origBootstrap = setup.bootstrapOrchestrator.bootstrap.bind(setup.bootstrapOrchestrator); try { const { specPath } = seedPackage(); @@ -2550,7 +2562,7 @@ files: expect(body.contextPacksRouting.rejectedCount).toBe(1); expect(body.contextPacksRouting.records[0].status).toBe("missing"); // CRUCIAL: nothing landed at target - expect(fs.existsSync(path.join(auditHome, "context-packs", "nonexistent"))).toBe(false); + expect(fs.existsSync(path.join(auditHome, "context", "nonexistent"))).toBe(false); } finally { fs.rmSync(targetRoot, { recursive: true, force: true }); } @@ -2558,6 +2570,8 @@ files: (setup.bootstrapOrchestrator as unknown as { bootstrap: typeof origBootstrap }).bootstrap = origBootstrap; if (origHome === undefined) delete process.env.OPENRIG_HOME; else process.env.OPENRIG_HOME = origHome; + if (origContextRoot === undefined) delete process.env.OPENRIG_CONTEXT_ROOT; + else process.env.OPENRIG_CONTEXT_ROOT = origContextRoot; fs.rmSync(auditHome, { recursive: true, force: true }); } }); diff --git a/packages/daemon/test/instance-initialization.test.ts b/packages/daemon/test/instance-initialization.test.ts new file mode 100644 index 000000000..4e7845438 --- /dev/null +++ b/packages/daemon/test/instance-initialization.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { + ensureOpenRigInstance, + openRigContextLibraryRoots, +} from "../src/domain/instance-initialization.js"; + +const roots: string[] = []; + +function freshRoot(): string { + const root = mkdtempSync(join(tmpdir(), "openrig-instance-init-")); + roots.push(root); + return root; +} + +function tree(root: string): Array<{ path: string; kind: "directory" | "file"; content?: string }> { + const out: Array<{ path: string; kind: "directory" | "file"; content?: string }> = []; + const walk = (dir: string): void => { + for (const name of readdirSync(dir).sort()) { + const abs = join(dir, name); + const rel = relative(root, abs); + if (statSync(abs).isDirectory()) { + out.push({ path: rel, kind: "directory" }); + walk(abs); + } else { + out.push({ path: rel, kind: "file", content: readFileSync(abs, "utf8") }); + } + } + }; + walk(root); + return out; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("canonical OpenRig instance initialization", () => { + it("declares the canonical addressable and System World library roots together", () => { + expect(openRigContextLibraryRoots("/instance/context")).toEqual([ + "/instance/context", + "/instance/context/system", + ]); + }); + + it("creates the canonical roots plus the S01 workspace through one idempotent owner", () => { + const parent = freshRoot(); + const home = join(parent, "home"); + + const first = ensureOpenRigInstance({ home }); + const firstTree = tree(home); + const second = ensureOpenRigInstance({ home }); + + expect(first.ok).toBe(true); + expect(first.createdPaths.length).toBeGreaterThan(0); + expect(second).toMatchObject({ ok: true, createdPaths: [], conflicts: [] }); + expect(tree(home)).toEqual(firstTree); + expect(firstTree).toEqual(expect.arrayContaining([ + { path: "config.json", kind: "file", content: "{}\n" }, + { path: "state", kind: "directory" }, + { path: "context", kind: "directory" }, + { path: "context/system", kind: "directory" }, + { path: "skills", kind: "directory" }, + { path: "workspace", kind: "directory" }, + { path: "workspace/missions", kind: "directory" }, + { path: "workspace/exhaust", kind: "directory" }, + { path: "specs", kind: "directory" }, + { path: "topology", kind: "directory" }, + { path: "plugins", kind: "directory" }, + { path: "run", kind: "directory" }, + { path: "logs", kind: "directory" }, + { path: "transcripts", kind: "directory" }, + { path: "backups", kind: "directory" }, + { path: "secrets", kind: "directory" }, + ])); + }); + + it("preserves user-owned files byte-for-byte", () => { + const home = join(freshRoot(), "home"); + mkdirSync(join(home, "workspace"), { recursive: true }); + writeFileSync(join(home, "config.json"), "{\"operator\":true}\n"); + writeFileSync(join(home, "workspace", "SPEC.md"), "# Mine\n"); + + const result = ensureOpenRigInstance({ home }); + + expect(result.ok).toBe(true); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe("{\"operator\":true}\n"); + expect(readFileSync(join(home, "workspace", "SPEC.md"), "utf8")).toBe("# Mine\n"); + }); + + it("reports exact type conflicts and performs no partial writes", () => { + const home = join(freshRoot(), "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, "context"), "owned file\n"); + + const result = ensureOpenRigInstance({ home }); + + expect(result.ok).toBe(false); + expect(result.conflicts).toEqual([ + { path: join(home, "context"), expected: "directory", actual: "file" }, + ]); + expect(existsSync(join(home, "state"))).toBe(false); + expect(readFileSync(join(home, "context"), "utf8")).toBe("owned file\n"); + }); + + it("treats a user-owned symlink as a conflict instead of writing through it", () => { + const parent = freshRoot(); + const home = join(parent, "home"); + const outside = join(parent, "outside"); + mkdirSync(home, { recursive: true }); + mkdirSync(outside); + symlinkSync(outside, join(home, "context")); + + const result = ensureOpenRigInstance({ home }); + + expect(result.conflicts).toContainEqual({ + path: join(home, "context"), + expected: "directory", + actual: "other", + }); + expect(readdirSync(outside)).toEqual([]); + expect(existsSync(join(home, "state"))).toBe(false); + }); +}); diff --git a/packages/daemon/test/public-world-pack.test.ts b/packages/daemon/test/public-world-pack.test.ts index e691ef56f..3e5a54937 100644 --- a/packages/daemon/test/public-world-pack.test.ts +++ b/packages/daemon/test/public-world-pack.test.ts @@ -177,7 +177,7 @@ function writeRigFixture(options: { { relativePath: "world/install", sourceType: "user_file", sourcePath: "/fixture/private-world" }, ]); const configRoot = options.configRoot === undefined - ? '"${OPENRIG_CONTEXT_PACKS_ROOT:-/fixture/context-packs}"' + ? '"${OPENRIG_CONTEXT_ROOT:-/fixture/context}"' : `'${options.configRoot}'`; writeFileSync( join(fixturePath, "world-public-get.txt"), @@ -223,13 +223,13 @@ function writeRigFixture(options: { " fi", ' [ "$4" = "--name" ] || exit 64', ` if [ ${options.partialSync ? 1 : 0} -eq 1 ]; then`, - ' target="$OPENRIG_CONTEXT_PACKS_ROOT/$5"', + ' target="$OPENRIG_CONTEXT_ROOT/$5"', ' mkdir -p "$target"', ' printf \'%s\\n\' \'name: partial-sync-fixture\' > "$target/manifest.yaml"', ' printf \'{"installedAt":"%s","syncError":"HTTP 503"}\\n\' "$target"', " exit 0", " fi", - ' target="$OPENRIG_CONTEXT_PACKS_ROOT/$5"', + ' target="$OPENRIG_CONTEXT_ROOT/$5"', ' mkdir -p "$target"', ' cp -R "$3"/. "$target"/', ' printf \'%s\\n\' "$5" > "$install_state"', @@ -238,7 +238,7 @@ function writeRigFixture(options: { ' if [ -f "$install_state" ]; then', ' installed_ref=$(sed -n \'1p\' "$install_state")', ' cat "$fixture_dir/context-list.json" | sed \'s/]$//\'', - " printf ',{\"relativePath\":\"%s\",\"sourceType\":\"user_file\",\"sourcePath\":\"%s/%s\"}]\\n' \"$installed_ref\" \"$OPENRIG_CONTEXT_PACKS_ROOT\" \"$installed_ref\"", + " printf ',{\"relativePath\":\"%s\",\"sourceType\":\"user_file\",\"sourcePath\":\"%s/%s\"}]\\n' \"$installed_ref\" \"$OPENRIG_CONTEXT_ROOT\" \"$installed_ref\"", " else", ' cat "$fixture_dir/context-list.json"', " fi", @@ -246,10 +246,10 @@ function writeRigFixture(options: { ` [ ${options.partialSync ? 1 : 0} -eq 0 ] || exit 72`, ' [ -f "$install_state" ] || exit 1', ' [ "$(sed -n \'1p\' "$install_state")" = "$3" ] || exit 1', - ' rm -rf -- "$OPENRIG_CONTEXT_PACKS_ROOT/$3"', + ' rm -rf -- "$OPENRIG_CONTEXT_ROOT/$3"', ' rm -f "$install_state"', ' printf \'{"removed":true,"ref":"%s"}\\n\' "$3"', - 'elif [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "context.packs_root" ]; then', + 'elif [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "context.root" ]; then', ` printf '%s\\n' ${configRoot}`, 'elif [ "$1" = "whoami" ]; then', ` printf '%s\\n' '${options.identityJson ?? '{"identity":{"rigName":"fixture","memberId":"qa","sessionName":"qa@fixture"}}'}'`, @@ -425,7 +425,7 @@ describe("public world pack", () => { const result = runVerifier(redPack, { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -458,7 +458,7 @@ describe("public world pack", () => { const result = runVerifier(redPack, { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -505,7 +505,7 @@ describe("public world pack", () => { const result = runVerifier(redPack, { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -520,7 +520,7 @@ describe("public world pack", () => { const fixtureEnv = { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }; @@ -547,7 +547,7 @@ describe("public world pack", () => { const wrongIdentity = runVerifier(packDir, { ...process.env, PATH: `${wrongIdentityPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -559,7 +559,7 @@ describe("public world pack", () => { const wrongTopology = runVerifier(packDir, { ...process.env, PATH: `${wrongTopologyPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -585,7 +585,7 @@ describe("public world pack", () => { const result = runVerifier(publicWorldDir(), { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -596,11 +596,11 @@ describe("public world pack", () => { }); it("fails when the context store projection ignores its typed override", () => { - const rigPath = writeRigFixture({ configRoot: "/fixture/wrong-context-packs" }); + const rigPath = writeRigFixture({ configRoot: "/fixture/wrong-context" }); const result = runVerifier(publicWorldDir(), { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/expected-context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/expected-context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -614,7 +614,7 @@ describe("public world pack", () => { const result = runVerifier(publicWorldDir(), { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -632,7 +632,7 @@ describe("public world pack", () => { const result = runVerifier(publicWorldDir(), { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: "/fixture/context-packs", + OPENRIG_CONTEXT_ROOT: "/fixture/context", OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -648,7 +648,7 @@ describe("public world pack", () => { const result = runVerifier(publicWorldDir(), { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: contextRoot, + OPENRIG_CONTEXT_ROOT: contextRoot, OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }); @@ -662,7 +662,7 @@ describe("public world pack", () => { const strangerRoot = mkdtempSync(join(tmpdir(), "public-world-stranger-")); temporaryRoots.push(strangerRoot); const openrigHome = join(strangerRoot, "openrig-home"); - const contextRoot = join(openrigHome, "context-packs"); + const contextRoot = join(openrigHome, "context"); mkdirSync(openrigHome); expect(readdirSync(openrigHome)).toEqual([]); @@ -671,7 +671,7 @@ describe("public world pack", () => { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, OPENRIG_HOME: openrigHome, - OPENRIG_CONTEXT_PACKS_ROOT: contextRoot, + OPENRIG_CONTEXT_ROOT: contextRoot, OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }; @@ -721,7 +721,7 @@ describe("public world pack", () => { const env = { ...process.env, PATH: `${rigPath}:${process.env.PATH ?? ""}`, - OPENRIG_CONTEXT_PACKS_ROOT: join(strangerRoot, "context-packs"), + OPENRIG_CONTEXT_ROOT: join(strangerRoot, "context"), OPENRIG_SESSION_NAME: "qa@fixture", RIGGED_SESSION_NAME: "", }; @@ -747,7 +747,7 @@ describe("public world pack", () => { expect(exercise).toContain("rig context get world-example"); expect(exercise).toContain("rig context add "); expect(exercise).toContain("rig context list"); - expect(exercise).toContain("rig config get context.packs_root"); + expect(exercise).toContain("rig config get context.root"); expect(exercise).not.toMatch(/identity\/|ontology\/|terrain\//); const packDir = publicWorldDir(); diff --git a/packages/daemon/test/user-settings-routes.test.ts b/packages/daemon/test/user-settings-routes.test.ts index d8e42ae2c..3e369d0c4 100644 --- a/packages/daemon/test/user-settings-routes.test.ts +++ b/packages/daemon/test/user-settings-routes.test.ts @@ -22,6 +22,7 @@ function clearEnv(): () => void { "OPENRIG_PORT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS", "OPENRIG_WORKSPACE_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT", "OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH", + "OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT", "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED", "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT", "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION", @@ -93,7 +94,7 @@ describe("config routes (User Settings v0)", () => { // + 2 B6 founder ruling (policies.idle_gate_qitem.auto_register / // opt_in_sessions — the not-default-on gate) // + 1 OPR.0.5.3.6 D1 (topology.root — the topology tree root) - // + 1 OPR.0.5.3.7 R4 (context.packs_root) + // + 1 OPR.0.5.9.5 Wave B (context.root) // + 1 S15 (onboarding.default_pack.enabled) // + 1 S04 (queue.pickup_stall_threshold_minutes) // + 2 S02 (queue.stuck_sweep_interval_seconds / @@ -113,6 +114,7 @@ describe("config routes (User Settings v0)", () => { expect(body.settings["onboarding.default_pack.enabled"]).toMatchObject({ value: true, source: "default" }); expect(String(body.settings["workspace.projects_root"]?.value)).toMatch(/projects$/); expect(String(body.settings["workspace.catalog_path"]?.value)).toMatch(/workspace\.yaml$/); + expect(String(body.settings["context.root"]?.value)).toMatch(/context$/); expect(body.settings["workspace.field_notes_root"]).toBeUndefined(); expect(body.settings["workspace.dogfood_evidence_root"]).toBeUndefined(); }); @@ -153,6 +155,27 @@ describe("config routes (User Settings v0)", () => { expect(body.validKeys).toContain("workspace.root"); }); + it.each(["GET", "POST", "DELETE"] as const)( + "%s /api/config/context.packs_root refuses the removed key with its replacement", + async (method) => { + const app = buildApp(); + const res = await app.request("/api/config/context.packs_root", { + method, + headers: { "Content-Type": "application/json" }, + body: method === "POST" ? JSON.stringify({ value: "/legacy" }) : undefined, + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ replacement: "context.root" }); + }, + ); + + it("GET /api/config refuses a persisted context.packsRoot before projecting settings", async () => { + writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } })); + const res = await buildApp().request("/api/config"); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ error: expect.stringContaining("context.root") }); + }); + it("POST /api/config/:key sets the value and persists to disk", async () => { const app = buildApp(); const res = await app.request("/api/config/workspace.slices_root", { diff --git a/packages/daemon/test/user-settings-store.test.ts b/packages/daemon/test/user-settings-store.test.ts index 42b883a62..33dd97a4c 100644 --- a/packages/daemon/test/user-settings-store.test.ts +++ b/packages/daemon/test/user-settings-store.test.ts @@ -32,6 +32,7 @@ function clearEnv(): () => void { "OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT", "OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT", "OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH", + "OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT", "OPENRIG_SKILLS_ROOT", "OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS", "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS", @@ -101,7 +102,7 @@ describe("SettingsStore (User Settings v0)", () => { "workspace.catalog_path", // OPR.0.5.3.6 D1 — the topology tree root (instance at its top). "topology.root", - "context.packs_root", + "context.root", "skills.root", "onboarding.default_pack.enabled", "files.allowlist", "progress.scan_roots", @@ -162,6 +163,25 @@ describe("SettingsStore (User Settings v0)", () => { ]); }); + it("resolves context.root and refuses the removed key, file field, and env", () => { + const store = new SettingsStore(configPath); + expect(store.resolveOne("context.root")).toMatchObject({ + value: expect.stringMatching(/context$/), + source: "default", + }); + + process.env["OPENRIG_CONTEXT_ROOT"] = join(tmpDir, "context-library"); + expect(store.resolveOne("context.root")).toMatchObject({ value: join(tmpDir, "context-library"), source: "env" }); + delete process.env["OPENRIG_CONTEXT_ROOT"]; + + expect(() => store.set("context.packs_root", "/legacy")).toThrow(/removed.*context\.root/i); + writeFileSync(configPath, JSON.stringify({ context: { packsRoot: "/legacy" } })); + expect(() => store.resolveConfig()).toThrow(/context\.packs_root.*context\.root/i); + writeFileSync(configPath, "{}\n"); + process.env["OPENRIG_CONTEXT_PACKS_ROOT"] = "/legacy-env"; + expect(() => store.resolveOne("context.root")).toThrow(/OPENRIG_CONTEXT_PACKS_ROOT.*OPENRIG_CONTEXT_ROOT/i); + }); + it("W2c idle-gate-qitem cadence defaults to scan=60 and active-wake=900", () => { const store = new SettingsStore(configPath); expect(() => store.resolveOne("policies.idle_gate_qitem.scan_interval_seconds"))