From 7feec111f8191ef48d7621d99df6dbf6a74232ab Mon Sep 17 00:00:00 2001 From: Mathis Blanc Date: Tue, 21 Jul 2026 16:58:04 +0200 Subject: [PATCH 01/10] Add generic shared context workflows --- README.md | 15 + bin/context-room.mjs | 159 +++- docs/agent-configuration.md | 57 +- docs/features/agent-cli.md | 25 +- docs/features/index.md | 5 +- docs/features/shared-context.md | 209 +++++ docs/product-overview.md | 14 +- schemas/config.schema.json | 17 + schemas/shared-projects.schema.json | 50 ++ schemas/shared-repository.schema.json | 25 + src/context_room.mjs | 352 +++++++- src/shared_context.mjs | 1077 +++++++++++++++++++++++++ test/shared_context.test.mjs | 433 ++++++++++ 13 files changed, 2406 insertions(+), 32 deletions(-) create mode 100644 docs/features/shared-context.md create mode 100644 schemas/shared-projects.schema.json create mode 100644 schemas/shared-repository.schema.json create mode 100644 src/shared_context.mjs create mode 100644 test/shared_context.test.mjs diff --git a/README.md b/README.md index 6a59850..b7dc5d3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Context Room gives a repository a browser UI to map important docs, edit safe te - Inspect startup context, startup skills, and hooks that can affect agents. - Run local checks with no LLM call. - Report watched doc changes before commits without blocking by default. +- Share accepted documentation and global or project skills across repositories without giving agents a direct edit path to the accepted snapshot. ## Core Loop @@ -46,10 +47,13 @@ Open the printed `/api/health` URL and confirm that `root` is the intended proje ## Main Files - `.context-room/config.json`: project map, safe edit paths, watched paths, hub cards, startup scanners, templates. +- `~/.context-room/shared/registry.json`: user-approved bindings from source repositories and subpaths to generic shared-context projects. - `~/.context-room/preferences.json`: computer-wide appearance preferences shared by every Context Room. - Runtime review state and external baselines live under `.context-room/`. - `docs/agent-configuration.md`: full config guide for agents and humans. - `schemas/config.schema.json`: JSON Schema for config validation and editor autocomplete. +- `schemas/shared-repository.schema.json`: JSON Schema for the optional shared repository manifest. +- `schemas/shared-projects.schema.json`: JSON Schema for its project catalog and cwd mappings. Runtime files under `.context-room/` are excluded from Git where possible. Commit the config only when the project should share the same Context Room map. @@ -67,6 +71,13 @@ context-room agent open [--root .] [--path docs/INDEX.md] [--view hub|settings|f context-room agent annotate --root . --path docs/INDEX.md --note "Human-facing note" context-room agent watch --root . --path docs/ [--mode recursive-live|recursive-current|direct-current|direct-live] context-room agent unwatch --root . --path docs/ +context-room shared init-repository --root /path/to/shared-context --name "Company Shared Context" +context-room shared bind --root . --repository [--project ] +context-room shared setup --root . --repository [--project ] +context-room shared sync|status|proposals --root . +context-room shared propose --root . --title "Change" [--scope project|global] +context-room shared publish --root . --proposal proposal/... [--message "..."] +context-room shared review --root . --proposal proposal/... [--port 4317] context-room install-hooks [--root .] context-room update-all [--dry-run] [--no-restart] [--exclude /path] ``` @@ -78,6 +89,7 @@ context-room update-all [--dry-run] [--no-restart] [--exclude /path] - The Review settings tab stores owner-selected gates outside project config. Local hooks cover commit, push, and local merge; pull requests and hosted merges need a required provider check. - `brief` ranks relevant docs locally and deterministically. It does not call an LLM. - `agent` commands let an agent open files, inspect the queue, leave annotations for the human, and manage explicit folder watch rules without making review decisions. +- `shared` commands connect any compatible shared-context Git repository, refresh its accepted default-branch snapshot, manage scoped proposal worktrees, and open the normal review UI against an exact proposal commit. See [Shared context](docs/features/shared-context.md). - `update-all` installs the latest npm release globally and restarts every verified active room it discovers except a Context Room development checkout. Before acting, it verifies each room's canonical project root through `/api/health`, so paths containing spaces are not inferred from process command text. Preview an update without changing installations or processes: @@ -119,6 +131,7 @@ The reusable HTML examples are available directly at: "title": "My Project", "projectOnly": true, "allowedPaths": ["docs/", "README.md", "AGENTS.md"], + "readOnlyPaths": [], "watchAllow": ["docs/", "README.md"], "watchRules": [], "reviewPaths": [], @@ -156,6 +169,7 @@ The reusable HTML examples are available directly at: Rules that matter: - `allowedPaths` is the edit boundary. Project-relative entries stay in the project; an explicit `~/...` entry authorizes that external home file or folder without making other home paths accessible. +- `readOnlyPaths` narrows allowed files to display-only access. Shared accepted snapshots are added to both arrays and must be changed through proposal branches. - Top-level `projectOnly: true` also requires ordinary allowed, watched, and hub paths to remain physically inside the project after symbolic links are resolved. Fresh setup enables it. Setting it to `false`, or omitting it in a legacy config, can make explicitly configured symlink targets outside the project both readable and editable; retain that compatibility only for trusted, established hubs. - `watchAllow` keeps the simple watch list. A folder entry uses the default recursive live behavior: current and future files at any depth can enter review. - `watchRules` stores explicit folder modes for recursive versus direct-child scope and live versus current-file snapshots. External rules must already be covered by a narrow `~/...` entry in `allowedPaths` and use Context Room review baselines because project Git does not own them. See [Agent configuration](docs/agent-configuration.md#watchrules). @@ -169,6 +183,7 @@ Rules that matter: - [Product overview](docs/product-overview.md): product map and development source map. - [Feature documentation](docs/features/index.md): clear docs for each user-facing feature. - [Agent configuration](docs/agent-configuration.md): config fields, metadata, and agent setup. +- [Shared context](docs/features/shared-context.md): generic shared repositories, proposals, partial acceptance, skills, freshness, and permissions. ## Development diff --git a/bin/context-room.mjs b/bin/context-room.mjs index d62964c..cadb4a1 100755 --- a/bin/context-room.mjs +++ b/bin/context-room.mjs @@ -3,6 +3,18 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { updateAllContextRooms } from "../scripts/update-context-rooms.mjs"; +import { + connectSharedContext, + createSharedProposal, + detectSharedProject, + initializeSharedRepository, + listSharedProposals, + materializeSharedReview, + publishSharedProposal, + readSharedProjectConnection, + sharedContextStatus, + syncSharedContext, +} from "../src/shared_context.mjs"; import { appendAgentAnnotation, buildAgentBrief, @@ -55,13 +67,49 @@ function splitList(value) { } function usage() { - return `Context Room\n\nUsage:\n context-room setup [--root .] [--title "My Project"] [--allow docs/] [--watch docs/] [--port 4317]\n context-room init [--root .] [--title "My Project"] [--allow docs/] [--watch docs/]\n context-room start [--root .] [--port 4317]\n context-room doctor [--root .] [--strict]\n context-room guard [--root .] [--profile advisory|review-only|strict] [--operation commit|push|pull-request|merge]\n context-room brief [--root .] [--task "what the agent will do"] [--limit 12]\n context-room agent state [--root .]\n context-room agent open [--root .] [--path docs/INDEX.md] [--view hub|settings|file|diff] [--heading "Purpose"] [--text "needle"] [--percent 50]\n context-room agent annotate --root . --path docs/INDEX.md --note "Human-facing note" [--target "text"]\n context-room agent queue [--root .]\n context-room agent watch --root . --path docs/ [--mode recursive-live|recursive-current|direct-current|direct-live]\n context-room agent unwatch --root . --path docs/\n context-room install-hook [--root .]\n context-room install-hooks [--root .]\n context-room update-all [--dry-run] [--no-restart] [--exclude /path]\n context-room --version\n\nFolder watch modes:\n recursive-live Current and future files at any depth (default)\n recursive-current Current files at any depth; future files are excluded\n direct-current Current direct child files; future files and subfolders are excluded\n direct-live Current and future direct child files; subfolder files are excluded\n\nFresh setup discovers and watches project documentation, builds project-specific hub sections, and uses the first free port when --port is omitted.\n\nConfig: ${CONFIG_FILE}\n`; + return `Context Room + +Usage: + context-room setup [--root .] [--title "My Project"] [--allow docs/] [--watch docs/] [--port 4317] + context-room init [--root .] [--title "My Project"] [--allow docs/] [--watch docs/] + context-room start [--root .] [--port 4317] + context-room doctor [--root .] [--strict] + context-room guard [--root .] [--profile advisory|review-only|strict] [--operation commit|push|pull-request|merge] + context-room brief [--root .] [--task "what the agent will do"] [--limit 12] + context-room agent state [--root .] + context-room agent open [--root .] [--path docs/INDEX.md] [--view hub|settings|file|diff] [--heading "Purpose"] [--text "needle"] [--percent 50] + context-room agent annotate --root . --path docs/INDEX.md --note "Human-facing note" [--target "text"] + context-room agent queue [--root .] + context-room agent watch --root . --path docs/ [--mode recursive-live|recursive-current|direct-current|direct-live] + context-room agent unwatch --root . --path docs/ + context-room shared init-repository --root . --name "My Shared Context" + context-room shared bind --root . --repository [--project ] + context-room shared setup --root . --repository [--project ] + context-room shared sync|status|proposals --root . + context-room shared propose --root . --title "Change" [--scope project|global] + context-room shared publish --root . --proposal proposal/... [--message "..."] + context-room shared review --root . --proposal proposal/... [--port 4317] + context-room install-hook [--root .] + context-room install-hooks [--root .] + context-room update-all [--dry-run] [--no-restart] [--exclude /path] + context-room --version + +Folder watch modes: + recursive-live Current and future files at any depth (default) + recursive-current Current files at any depth; future files are excluded + direct-current Current direct child files; future files and subfolders are excluded + direct-live Current and future direct child files; subfolder files are excluded + +Fresh setup discovers and watches project documentation, builds project-specific hub sections, and uses the first free port when --port is omitted. + +Config: ${CONFIG_FILE} +`; } const KNOWN_OPTIONS = new Set([ - "advisory", "allow", "dry-run", "exclude", "h", "heading", "help", "highlight", "hook", - "limit", "message", "mode", "no-restart", "note", "operation", "path", "percent", "port", "profile", - "root", "strict", "target", "task", "text", "title", "version", "view", "watch", + "advisory", "allow", "branch", "dry-run", "exclude", "h", "heading", "help", "highlight", "hook", + "limit", "message", "mode", "name", "no-restart", "note", "operation", "path", "percent", "port", "profile", + "project", "proposal", "repository", "root", "scope", "strict", "target", "task", "text", "title", "version", "view", "watch", ]); function packageVersion() { @@ -123,6 +171,109 @@ if (!rootStats?.isDirectory()) { process.exit(2); } +if (command !== "shared" && ["setup", "start", "doctor", "guard", "brief", "agent"].includes(command) && readSharedProjectConnection(root)) { + try { + const shared = syncSharedContext(root, { allowOffline: true }); + if (!shared.online) console.error(`Shared context offline: using ${shared.revision.slice(0, 12)} (${shared.fetchError})`); + } catch (error) { + console.error(`Shared context refresh failed: ${error.message}`); + process.exit(1); + } +} + +if (command === "shared") { + const action = args._[1] || "status"; + try { + if (action === "init-repository") { + console.log(JSON.stringify(initializeSharedRepository(root, { name: args.name || args.title || path.basename(root) }), null, 2)); + process.exit(0); + } + if (action === "bind") { + if (!args.repository || args.repository === true || args.project === true) { + throw new Error("Usage: context-room shared bind --root . --repository [--project ]"); + } + const detected = detectSharedProject(root, { repository: args.repository, projectId: args.project || "" }); + const bindingRoot = detected.projectRoot; + console.log(JSON.stringify(connectSharedContext(bindingRoot, { + repository: args.repository, + projectId: detected.projectId, + sync: false, + }), null, 2)); + process.exit(0); + } + if (action === "setup" || action === "connect") { + if (!args.repository || args.repository === true || args.project === true) { + throw new Error("Usage: context-room shared setup --root . --repository [--project ]"); + } + const detected = detectSharedProject(root, { repository: args.repository, projectId: args.project || "" }); + const setupRoot = detected.projectRoot; + initializeContextRoomProject(setupRoot); + console.log(JSON.stringify(connectSharedContext(setupRoot, { repository: args.repository, projectId: detected.projectId }), null, 2)); + process.exit(0); + } + if (action === "sync") { + console.log(JSON.stringify(syncSharedContext(root, { allowOffline: true }), null, 2)); + process.exit(0); + } + if (action === "status") { + console.log(JSON.stringify(sharedContextStatus(root), null, 2)); + process.exit(0); + } + if (action === "proposals" || action === "list") { + console.log(JSON.stringify(listSharedProposals(root), null, 2)); + process.exit(0); + } + if (action === "propose" || action === "proposal-create") { + console.log(JSON.stringify(createSharedProposal(root, { + title: args.title || args.task || "Shared context change", + scope: args.scope || "project", + branch: args.branch || "", + }), null, 2)); + process.exit(0); + } + if (action === "publish" || action === "proposal-push") { + if (!args.proposal || args.proposal === true) throw new Error("--proposal requires a proposal/* branch"); + console.log(JSON.stringify(publishSharedProposal(root, { proposal: args.proposal, message: args.message }), null, 2)); + process.exit(0); + } + if (action === "review") { + if (!args.proposal || args.proposal === true) throw new Error("--proposal requires a proposal/* branch"); + const result = materializeSharedReview(root, { proposal: args.proposal }); + const config = result.repositoryConfig; + const projectId = result.metadata.projectId; + const projectPrefix = `${config.projectsPath}/${projectId}`; + const allowedPaths = projectId === "global" + ? [`${config.globalSkillsPath}/`] + : [`${projectPrefix}/docs/`, `${projectPrefix}/skills/`]; + initializeContextRoomProject(result.reviewRoot, { + title: `Review · ${args.proposal}`, + allowedPaths, + watchAllow: allowedPaths, + reviewAgentInstructions: false, + }); + const preferredPort = args.port === undefined ? 4317 : Number(args.port); + const port = await selectAvailableContextRoomPort(preferredPort, { allowFallback: args.port === undefined }); + const { server } = createMemoryServer({ root: result.reviewRoot, port }); + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { server.off("error", onError); resolve(); }); + }); + console.log(`Context Room: http://127.0.0.1:${port}`); + console.log(`Proposal: ${args.proposal}`); + console.log(`Proposal head: ${result.metadata.proposalHead}`); + console.log(`Review root: ${result.reviewRoot}`); + process.on("SIGINT", () => server.close(() => process.exit(0))); + process.on("SIGTERM", () => server.close(() => process.exit(0))); + await new Promise(() => {}); + } + throw new Error(`Unknown shared command: ${action}`); + } catch (error) { + console.error(`Shared context failed: ${error.message}`); + process.exit(1); + } +} + if (command === "init") { let result; try { diff --git a/docs/agent-configuration.md b/docs/agent-configuration.md index 4026c90..96857d3 100644 --- a/docs/agent-configuration.md +++ b/docs/agent-configuration.md @@ -4,8 +4,8 @@ context_room: scope: context-room status: current canonical_for: agent configuration - last_verified: 2026-07-20 - sources: [bin/context-room.mjs, src/context_room.mjs, schemas/config.schema.json] + last_verified: 2026-07-21 + sources: [bin/context-room.mjs, src/context_room.mjs, src/shared_context.mjs, schemas/config.schema.json] --- # Agent configuration guide @@ -70,7 +70,8 @@ Use this checklist to make the intended setup clear before checking field detail Check intent: -- `allowedPaths` exposes only safe editable text. A `~/...` entry is an explicit external authorization, so keep it as narrow as a project-relative entry. +- `allowedPaths` exposes only safe text. A `~/...` entry is an explicit external authorization, so keep it as narrow as a project-relative entry. +- `readOnlyPaths` contains the allowed paths that Context Room may display but must not create, edit, or delete. It does not widen `allowedPaths`. - Top-level `projectOnly` controls physical containment for ordinary project paths. Fresh setup writes `true`. Setting it to `false`, or omitting it in a legacy config, can make configured symlink targets outside the project readable and editable; retain that compatibility only for trusted, established hubs. - `watchAllow` contains simple file watches and legacy/default recursive live folder watches. - `watchRules` contains folder watches that need an explicit recursive/direct and live/current-files mode. @@ -91,7 +92,7 @@ If those boundaries are right, the exact JSON shape is a mechanical concern. Safety boundary. -Context Room only reads and writes editable text files inside these files or folders. Project-relative entries stay inside the room's normal project boundary. An entry beginning with `~/` explicitly authorizes that home file or folder even though Git in the project does not own it. Keep both forms narrow and documentation-focused. +Context Room only exposes supported text files inside these files or folders. It may write them unless they also match `readOnlyPaths`. Project-relative entries stay inside the room's normal project boundary. An entry beginning with `~/` explicitly authorizes that home file or folder even though Git in the project does not own it. Keep both forms narrow and documentation-focused. Set top-level `projectOnly: true` to require every ordinary allowed, watched, and hub path to remain physically inside the project root after symbolic links are resolved. Fresh setup writes this flag. Setting it to `false`, or omitting it in an existing configuration, preserves established symlink documentation hubs but can make their configured targets outside the project both readable and editable. Use that mode only for trusted, established hubs. This flag does not govern explicit `~/...` integrations. @@ -105,6 +106,21 @@ Good examples: Do not use `~/` as a broad filesystem browser. Avoid secrets, dependency folders, build outputs, generated files, private exports, and binary assets. External entries remain subject to the same supported-text and blocked-path checks as project entries. +### `readOnlyPaths` + +Display-only boundary. + +Every entry uses the same project-relative or explicit `~/...` path syntax as `allowedPaths`. A matching file can appear in the hub, explorer, and reader, but the server rejects create, edit, and delete operations. Add the path to `allowedPaths` as well; `readOnlyPaths` never grants access by itself. + +Shared-context sync adds the accepted project docs, project skills, and global skills to both arrays. Those entries point through `~/.context-room/shared/` to an accepted immutable Git snapshot. Change them through the shared proposal workflow, not by removing their read-only protection. + +```json +{ + "allowedPaths": ["docs/", "imported-reference/"], + "readOnlyPaths": ["imported-reference/"] +} +``` + ### `watchAllow` Review boundary. @@ -241,6 +257,22 @@ Hook cards include a readable name, provider/source, a short description extract Hooks are read-only by default because they execute code. Enable `startupHooks.editable` only when the project owner intentionally wants Context Room to edit hook files. +### `sharedContext` + +Generated connection summary. + +`context-room shared setup` writes the active repository URL and project ID here after it adds the accepted shared paths and hub section. This field is only a display summary; it does not authorize fetching a remote. The approved connection, source-repository mapping, accepted snapshots, and skill-link registry live under `~/.context-room/shared/`. Use the shared CLI instead of editing this summary directly. + +```json +"sharedContext": { + "enabled": true, + "repository": "git@github.com:example/company-shared-context.git", + "projectId": "my-project" +} +``` + +See [Shared context](features/shared-context.md) for repository setup, refresh, proposals, exact-hash review, partial acceptance, skills, and the required Git-host permission boundary. + ## Documentation metadata Structured Markdown docs should include frontmatter: @@ -279,16 +311,17 @@ Keep metadata small. The goal is not bureaucracy; it lets Context Room find stal 1. Treat `.context-room/config.json` as the source of truth for Context Room setup. 2. Start with `context-room setup`; edit the JSON directly only when the inferred project map needs deliberate curation. 3. Keep `allowedPaths` conservative: documentation, skills, runbooks, agent instructions, and safe text files. -4. Put the truly important docs in `watchAllow` or an explicit `watchRules` mode, not every file in the repo. Use `context-room agent watch` to create folder snapshots. -5. Use stable lowercase IDs with dashes, for example `agent-context`, `architecture`, `release-runbooks`. -6. Preserve the `$schema` field so editors and agents can validate the file shape. -7. After editing config, run: +4. Treat `readOnlyPaths` as context, not an edit surface. For shared accepted paths, create a shared proposal instead of removing the boundary. +5. Put the truly important docs in `watchAllow` or an explicit `watchRules` mode, not every file in the repo. Use `context-room agent watch` to create folder snapshots. +6. Use stable lowercase IDs with dashes, for example `agent-context`, `architecture`, `release-runbooks`. +7. Preserve the `$schema` field so editors and agents can validate the file shape. +8. After editing config, run: ```bash context-room doctor ``` -8. For stronger validation, run: +9. For stronger validation, run: ```bash context-room doctor --strict @@ -297,13 +330,13 @@ context-room guard --profile strict Use strict mode only when the project is ready to enforce metadata and graph health. -9. To generate a local no-LLM context brief for a task, run: +10. To generate a local no-LLM context brief for a task, run: ```bash context-room brief --task "change billing onboarding" ``` -10. If available, start the UI and smoke-test the hub and review queue: +11. If available, start the UI and smoke-test the hub and review queue: ```bash context-room start --root . @@ -311,7 +344,7 @@ context-room start --root . Without `--port`, Context Room selects a free port and prints the URL. Do not stop or reuse an unrelated room to obtain a preferred port. -11. To install or refresh the local Git hooks selected by the owner review gate, run: +12. To install or refresh the local Git hooks selected by the owner review gate, run: ```bash context-room install-hooks diff --git a/docs/features/agent-cli.md b/docs/features/agent-cli.md index 4f35cc9..2405def 100644 --- a/docs/features/agent-cli.md +++ b/docs/features/agent-cli.md @@ -4,15 +4,15 @@ context_room: scope: context-room status: current canonical_for: agent CLI - last_verified: 2026-07-20 - sources: [bin/context-room.mjs, src/context_room.mjs] + last_verified: 2026-07-21 + sources: [bin/context-room.mjs, src/context_room.mjs, src/shared_context.mjs] --- # Agent CLI ## Purpose -The agent CLI lets a coding agent inspect Context Room state, manage explicit folder watch configuration, open files for the user, and leave annotations without bypassing human review. +The agent CLI lets a coding agent inspect Context Room state, manage explicit folder watch configuration, open files for the user, and leave annotations without bypassing human review. Its `shared` commands also let an agent create and publish scoped proposals without editing the accepted shared snapshot. ## Example Flow @@ -39,10 +39,29 @@ The accepted modes are `recursive-live`, `recursive-current`, `direct-current`, After changing a folder rule, run `context-room doctor --root .` and inspect `context-room agent queue --root .` to confirm the intended boundary without making a human review decision. +## Shared Context Commands + +Use the shared CLI when the project is connected to a generic shared-context Git repository: + +```bash +context-room shared bind --root . --repository [--project ] +context-room shared status --root . +context-room shared sync --root . +context-room shared proposals --root . +context-room shared propose --root . --title "Clarify onboarding" [--scope project|global] +context-room shared publish --root . --proposal proposal/... [--message "..."] +context-room shared review --root . --proposal proposal/... [--port 4317] +``` + +`propose` returns a writable worktree. `publish` rejects files outside the proposal's project or global scope. `review` is the owner handoff: the room reuses the normal human inline decisions. Only the review UI exposes **Accept into main**, bound to the exact proposal hash that room examined; the agent-facing CLI has no acceptance command. + +See [Shared context](shared-context.md) for repository initialization, read-only snapshots, refresh behavior, partial acceptance, and Git permission requirements. + ## Rules - Agent queue access is read-only. - Agent commands can navigate, annotate, and update explicit folder watch configuration, but cannot verify files. +- Shared proposal commands can create and push a proposal, but they do not make its content trusted or accept it into the shared default branch. - `agent watch` and `agent unwatch` change `.context-room/config.json`; they do not accept or reject review items and cannot change `.context-room/review-gate.json`. - Annotations must stay human-facing and scoped to an allowed path. - Session state is local runtime state, not project truth. diff --git a/docs/features/index.md b/docs/features/index.md index e1aa599..59528c6 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -4,8 +4,8 @@ context_room: scope: context-room status: current canonical_for: features - last_verified: 2026-07-11 - sources: [README.md, docs/product-overview.md, bin/context-room.mjs, src/context_room.mjs, schemas/config.schema.json] + last_verified: 2026-07-21 + sources: [README.md, docs/product-overview.md, bin/context-room.mjs, src/context_room.mjs, src/shared_context.mjs, schemas/config.schema.json] --- # Features @@ -24,6 +24,7 @@ This folder explains Context Room by user-facing feature. Read this when changin - [Settings](settings.md) - [Health, guard, and brief](health-guard-and-brief.md) - [Agent CLI](agent-cli.md) +- [Shared context](shared-context.md) ## Boundaries diff --git a/docs/features/shared-context.md b/docs/features/shared-context.md new file mode 100644 index 0000000..fab5e8a --- /dev/null +++ b/docs/features/shared-context.md @@ -0,0 +1,209 @@ +--- +context_room: + kind: canonical + scope: context-room + status: current + canonical_for: shared context repositories + last_verified: 2026-07-21 + sources: [src/shared_context.mjs, bin/context-room.mjs, src/context_room.mjs, schemas/shared-repository.schema.json, schemas/shared-projects.schema.json, schemas/config.schema.json] +--- + +# Shared Context + +## Purpose + +Shared Context adds an optional Git repository for documentation and skills that several projects, humans, or agents need to share. The normal Context Room workflow remains the default. + +| Mode | Trusted content | How changes are made | +| --- | --- | --- | +| Project-local | Files in the current project | Edit an allowed file, then use the normal review queue | +| Shared | The accepted commit on the shared repository's default branch | Create and publish a `proposal/*` branch, review its exact commit in a dedicated Context Room, then accept all or part of it | + +The accepted shared snapshot is exposed to the connected project as read-only. An agent therefore cannot change accepted shared documentation or skills through the normal editor. Its writable surface is a proposal worktree created by the CLI. + +## Repository Contract + +Initialize any Git repository with the generic shared layout: + +```bash +context-room shared init-repository --root /path/to/shared-context --name "Company Shared Context" +``` + +The default layout is: + +```text +.context-room/shared-repository.json +projects.json +skills/ + global/ + /SKILL.md +projects/ + / + docs/ + skills/ + /SKILL.md +``` + +The generated repository manifest contains the paths and branch conventions used by the CLI: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/shared-repository.schema.json", + "version": 1, + "name": "Company Shared Context", + "defaultBranch": "main", + "proposalPrefix": "proposal/", + "globalSkillsPath": "skills/global", + "projectsPath": "projects", + "projectsFile": "projects.json" +} +``` + +`projects.json` is the project-resolution authority. Each entry declares a stable project ID and may map it to one or more source-repository remotes plus a subpath: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/shared-projects.schema.json", + "version": 1, + "projects": [ + { + "id": "my-project", + "title": "My Project", + "source": { + "remotes": ["git@github.com:example/product-monorepo.git"], + "subpath": "apps/my-project" + } + } + ] +} +``` + +Commit and push both schemas' data plus every registered `projects//` directory. The paths and proposal prefix come from the manifest; the implementation is not tied to one organization or project name. Context Room normalizes SSH and HTTPS forms of the same Git remote and chooses the longest matching source subpath. + +## Connect And Refresh A Project + +From the project that consumes the shared context: + +```bash +context-room shared setup \ + --root . \ + --repository git@github.com:example/company-shared-context.git +``` + +When the catalog has no source mapping, or the current directory is not in a Git checkout, add `--project my-project` explicitly. + +For a monorepo rollout, `shared bind` records the same approved cwd mapping without initializing or modifying the source project's Context Room config. A later `shared setup` or normal context-dependent command can materialize it: + +```bash +context-room shared bind --root apps/my-project --repository git@github.com:example/company-shared-context.git +``` + +Setup: + +- records an explicitly approved repository, project ID, Git source remote, and source subpath in the user-local registry under `~/.context-room/shared/`; a committed project file cannot silently authorize a new remote; +- resolves the canonical project root even when setup starts from a nested cwd, and applies the same binding in another worktree of the same source repository; +- fetches the shared remote's accepted default branch; +- materializes its exact commit under `~/.context-room/shared/` and advances a local `current` link to that immutable snapshot; +- adds the shared docs and skills to `allowedPaths` and `readOnlyPaths` and creates a Shared context hub section; and +- refreshes global and project skill links. + +When more than one registered project path could match a source checkout, Context Room uses the most specific matching source subpath. + +Inspect or refresh the connection explicitly: + +```bash +context-room shared status --root . +context-room shared sync --root . +``` + +Normal `setup`, `start`, `doctor`, `guard`, `brief`, and `agent` CLI invocations also attempt a shared refresh before doing their work. If the remote is unavailable and a previous snapshot exists, Context Room continues with that snapshot, reports `online: false`, and includes the fetch error and cached revision. Creating, publishing, reviewing, and accepting proposals still require the remote. + +## Propose A Change + +Create a project-scoped proposal from the latest accepted remote commit: + +```bash +context-room shared propose --root . --title "Clarify onboarding" +``` + +The command prints a proposal branch and a writable worktree path. Make the documentation or skill changes inside that returned worktree, then publish the exact proposal: + +```bash +context-room shared publish \ + --root . \ + --proposal proposal/my-project/20260721120000-clarify-onboarding \ + --message "Clarify onboarding" +``` + +Project proposals may change only `projects//docs/` and `projects//skills/`. A global proposal uses `--scope global`, receives a `proposal/global/...` branch by default, and may change only the configured global skills directory. The explicit branch scope must match the requested scope. + +Context Room repeats that validation after fetching the remote branch, so bypassing the local publish command does not widen the review. Proposal files must be reviewable UTF-8 text supported by Context Room and no larger than 750 KB. Symlinks, gitlinks, binaries, and special files are rejected. The proposal commit records its accepted-doc base plus the source repository, branch, and commit when those are available. + +`--branch proposal/...` can provide an explicit unique branch name. Otherwise Context Room derives one from the project or global scope, timestamp, and title. + +## Review And Partial Acceptance + +List remote proposals, then open one in a dedicated review room: + +```bash +context-room shared proposals --root . +context-room shared review \ + --root . \ + --proposal proposal/my-project/20260721120000-clarify-onboarding +``` + +The review command: + +1. fetches the current accepted default branch; +2. records the exact proposal commit hash; +3. creates a detached review worktree from the accepted default branch; +4. applies the proposal as uncommitted changes; and +5. starts the normal Context Room review UI for those changes. + +The normal project room also shows a proposal selector in its top toolbar. Selecting a proposal and pressing **Review** performs the same exact-hash materialization and opens the dedicated room. Local-only projects simply keep the existing UI without these controls. + +Use the existing inline controls to accept or reject each change. Rejecting a change block rewrites the review worktree to remove that block; accepting it keeps the proposed result. This means the final worktree diff contains only the parts the human chose to accept. + +After the review queue is empty, the human owner presses **Accept into main** in the review room. The agent-facing CLI deliberately has no acceptance command. + +Acceptance is bound to the recorded proposal hash. If the proposal branch moved after the room was created, acceptance expires and the new commit must be reviewed in a new room. + +An exact review authority is single-use after a successful acceptance. Reopen the proposal if another reviewed result is needed. + +Before writing, Context Room fetches the latest accepted default branch and applies only the reviewed result onto that newer commit. Unrelated accepted changes already on the default branch are preserved. If the selected result conflicts with the latest default branch, nothing is pushed and the resolved result must be reviewed again. If no accepted change remains, no commit is created. + +## Shared Skills + +A skill is shared when its directory contains `/SKILL.md`. + +- Global skills are linked into `~/.codex/skills/`. +- Project skills are linked into `/.codex/skills/`. +- Both links target the exact accepted immutable snapshot, never a writable proposal checkout. +- Relative scripts and assets stay inside the skill directory; Git executable bits are preserved while all snapshot files remain non-writable. +- A project skill cannot shadow a global skill with the same name. +- Context Room refuses to replace an existing ordinary directory or a symbolic link that it does not manage. +- Refresh removes a managed link when its accepted skill is deleted or renamed, without touching unmanaged paths. + +Skills therefore follow the same trust path as documentation: proposal, exact-commit human review, acceptance into the default branch, then refresh of the read-only snapshot. + +## Permission Boundary + +The CLI enforces proposal path scopes and keeps accepted snapshots read-only, but these are workflow protections, not a sandbox for a process with unrestricted filesystem access. It also cannot distinguish a human from an agent when both processes use the same Git credential. Local rules alone do not prevent that credential from pushing directly to the default branch. + +For an actual owner-only default branch: + +- protect the shared repository's default branch on the Git host; +- give the agent a distinct credential that can push proposal branches but cannot push the default branch; and +- let only a human owner credential, or an owner-controlled acceptance service, run the final default-branch push. + +The owner button asks the local Context Room server to push directly to the configured default branch. The identity running that server must be allowed to do so. A host rule that requires every default-branch update to arrive through a pull request will reject this operation unless an owner-controlled acceptance service adapts the final step. `shared status` reports that provider-side protection is not locally verifiable. + +## Source Map + +- `src/shared_context.mjs`: repository format, connections, cache, snapshots, skill links, proposals, reviews, and acceptance. +- `bin/context-room.mjs`: shared CLI commands and automatic refresh before context-dependent commands. +- `schemas/shared-repository.schema.json`: shared repository manifest contract. +- `schemas/shared-projects.schema.json`: project catalog and cwd-resolution contract. +- `readOnlyPaths` in `schemas/config.schema.json`: displayable paths that the Context Room server must not create, edit, or delete. +- [Review queue](review-queue.md): inline accept and reject behavior reused by proposal review rooms. +- [Agent configuration](../agent-configuration.md): project config fields written by shared setup. diff --git a/docs/product-overview.md b/docs/product-overview.md index 776c8e1..2d5ebff 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -4,8 +4,8 @@ context_room: scope: context-room status: current canonical_for: product overview - last_verified: 2026-07-20 - sources: [README.md, bin/context-room.mjs, src/context_room.mjs, schemas/config.schema.json, docs/agent-configuration.md] + last_verified: 2026-07-21 + sources: [README.md, bin/context-room.mjs, src/context_room.mjs, src/shared_context.mjs, schemas/config.schema.json, schemas/shared-repository.schema.json, docs/agent-configuration.md] --- # Product Overview @@ -22,6 +22,8 @@ Context Room is a local browser UI for keeping project context visible, editable 4. Review watched changes from `watchAllow`, folder `watchRules`, and `reviewPaths`. 5. Run `doctor`, `guard`, or `brief` before handing work to an agent or committing. +Projects that need cross-project documentation or skills can add the optional [Shared context](features/shared-context.md) loop. The accepted shared default branch is mounted as read-only context; agents propose changes on scoped `proposal/*` branches and humans review the exact proposal before accepting all or part of it. + ## Main Surfaces - Hub: card-based navigation from `hubSections`. @@ -32,6 +34,7 @@ Context Room is a local browser UI for keeping project context visible, editable - Startup hooks: project AI-agent and hook-manager files plus current-repository Git hooks by default. - Settings: tabbed editor for project configuration plus computer-wide appearance and keyboard-shortcut preferences. - Agent CLI: queue inspection, navigation, annotations, and explicit folder watch configuration for coding agents. +- Shared context: an optional, generic Git-backed accepted snapshot with project and global skills, scoped proposal worktrees, and exact-commit human review. Feature-level docs live in [Features](features/index.md). @@ -42,11 +45,13 @@ Feature-level docs live in [Features](features/index.md). - Keep executable hooks read-only unless the project owner explicitly enables hook editing. - Keep briefs deterministic. `context-room brief` ranks local docs and does not call an LLM. - Keep config changes source-grounded. Run `context-room doctor` after changing `.context-room/config.json`. +- Keep accepted shared context read-only. Changes belong in a proposal worktree, and only a human should complete the acceptance into the shared default branch. - Keep rooms isolated. Automatic port selection must not stop another room, and a stale tab must not write state after its port begins serving another project root. ## Data Model - `allowedPaths`: files and folders Context Room may expose for editing. +- `readOnlyPaths`: allowed files and folders Context Room may display but must not create, edit, or delete. - `watchAllow`: simple exact file watches and compatible recursive live folder watches. - `watchRules`: explicit folder watches that combine recursive or direct-child scope with live or current-file membership. The full contract lives in [Agent configuration](agent-configuration.md#watchrules). - `reviewPaths`: files and folders that stay in review until the current content is verified. `Mark verified` is reserved for unchanged required-review files. @@ -56,16 +61,21 @@ Feature-level docs live in [Features](features/index.md). - `startupSkills`: skill folders that may shape future agent behavior. - `startupHooks`: hook files that can run around agent work, Git actions, or validation. - `context_room` metadata: optional Markdown frontmatter used by `doctor`, graph health, and briefs. +- `~/.context-room/shared/registry.json`: user-approved source-repository and subpath bindings for generic shared context. +- `/.context-room/shared-repository.json`: versioned contract for a shared repository's branch and path layout. ## Source Map - `bin/context-room.mjs`: CLI entry point and command routing. - `src/context_room.mjs`: server, file access, review queue, graph, brief, UI, and API. +- `src/shared_context.mjs`: shared repository sync, snapshots, skill links, proposals, review materialization, and acceptance. - `src/codex_composer_bridge.mjs`: loopback-only insertion into the active Codex composer. - `src/doc_metadata.mjs`: Markdown metadata parsing. - `src/yaml_utils.mjs`: YAML helpers. - `schemas/config.schema.json`: config contract. +- `schemas/shared-repository.schema.json`: shared repository manifest contract. - `test/context_room.test.mjs`: CLI, config, review, startup scanner, and UI behavior tests. +- `test/shared_context.test.mjs`: shared snapshots, skills, offline fallback, proposal scope, hash expiry, and partial-acceptance tests. - `docs/agent-configuration.md`: detailed config guide. ## Development Loop diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 6060934..3d94685 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -26,6 +26,13 @@ "uniqueItems": true, "default": ["docs/", "context/", "skills/", "README.md", "AGENTS.md"] }, + "readOnlyPaths": { + "type": "array", + "description": "Allowed project-relative or explicit ~/... paths that Context Room may display but must never create, edit, or delete. Shared accepted snapshots should be exposed here.", + "items": { "$ref": "#/$defs/allowedPath" }, + "uniqueItems": true, + "default": [] + }, "watchAllow": { "type": "array", "description": "Simple files or folders to monitor for the review queue. Folder entries use recursive-live behavior: current and future files at any depth can appear for verification.", @@ -51,6 +58,16 @@ "description": "Whether project AGENTS.md files are implicit required-review paths. Disable this only for a room whose human review scope is intentionally narrower.", "default": true }, + "sharedContext": { + "type": ["object", "null"], + "description": "Generated shared-context connection summary. Use `context-room shared setup` instead of editing this object directly.", + "additionalProperties": true, + "properties": { + "enabled": { "type": "boolean" }, + "repository": { "type": "string" }, + "projectId": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" } + } + }, "markdownTemplates": { "type": "array", "description": "Markdown templates available when creating new .md files from the site. Supports {{title}}, {{path}}, and metadata placeholders such as {{scope_yaml}}, {{status_yaml}}, {{canonical_for_yaml}}, {{last_verified_yaml}}, {{sources_inline}}, and {{sources_list}}.", diff --git a/schemas/shared-projects.schema.json b/schemas/shared-projects.schema.json new file mode 100644 index 0000000..f5b6817 --- /dev/null +++ b/schemas/shared-projects.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/shared-projects.schema.json", + "title": "Context Room shared projects catalog", + "type": "object", + "additionalProperties": false, + "required": ["version", "projects"], + "properties": { + "$schema": { "type": "string" }, + "version": { "const": 1 }, + "projects": { + "type": "array", + "items": { "$ref": "#/$defs/project" } + } + }, + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" }, + "title": { "type": "string", "minLength": 1 }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["remotes", "subpath"], + "properties": { + "remotes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "subpath": { + "anyOf": [ + { "const": "." }, + { + "type": "string", + "minLength": 1, + "not": { "pattern": "(^/|(^|/)\\.\\.?(/|$)|\\u0000)" } + } + ] + } + } + } + } + } + } +} diff --git a/schemas/shared-repository.schema.json b/schemas/shared-repository.schema.json new file mode 100644 index 0000000..e6daba2 --- /dev/null +++ b/schemas/shared-repository.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/shared-repository.schema.json", + "title": "Context Room shared repository", + "type": "object", + "additionalProperties": false, + "required": ["version", "name", "defaultBranch", "proposalPrefix", "globalSkillsPath", "projectsPath", "projectsFile"], + "properties": { + "$schema": { "type": "string" }, + "version": { "const": 1 }, + "name": { "type": "string", "minLength": 1 }, + "defaultBranch": { "type": "string", "minLength": 1 }, + "proposalPrefix": { "type": "string", "pattern": "/$" }, + "globalSkillsPath": { "$ref": "#/$defs/relativePath" }, + "projectsPath": { "$ref": "#/$defs/relativePath" }, + "projectsFile": { "$ref": "#/$defs/relativePath" } + }, + "$defs": { + "relativePath": { + "type": "string", + "minLength": 1, + "not": { "pattern": "(^/|(^|/)\\.\\.?(/|$)|(^|/)\\.git(/|$)|^\\.context-room(/|$)|\\u0000)" } + } + } +} diff --git a/src/context_room.mjs b/src/context_room.mjs index 0863aee..665a59e 100644 --- a/src/context_room.mjs +++ b/src/context_room.mjs @@ -15,6 +15,16 @@ import { insertFileReferenceIntoActiveCodexComposer, insertIntoActiveCodexComposer, } from "./codex_composer_bridge.mjs"; +import { + SHARED_REVIEW_CONFIG, + acceptSharedReview, + listSharedProposals, + materializeSharedReview, + readSharedProjectConnection, + readSharedReview, + sharedContextStatus, + syncSharedContext, +} from "./shared_context.mjs"; export { DOC_METADATA_KINDS, DOC_METADATA_STATUSES, parseDocMetadata } from "./doc_metadata.mjs"; @@ -41,6 +51,7 @@ const AGENT_CONTEXT_ASSET_FILENAMES = [ "html-visual-patterns.md", "context-room-visual-components.html", "context-room-data-visual-components.html", + "features/shared-context.md", ]; export const GLOBAL_PREFERENCES_FILE = "~/.context-room/preferences.json"; const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/config.schema.json"; @@ -209,6 +220,8 @@ const BACKGROUND_REPORT_INVALIDATING_PATHS = new Set([ "/api/folder/create", "/api/markdown/apply-template", "/api/files/delete", + "/api/shared-context/accept", + "/api/shared-context/refresh", ]); const BACKGROUND_WATCH_IGNORED_PATHS = new Set([ COLLAB_SESSION_STATE, @@ -577,6 +590,21 @@ export function isAllowedMemoryPath(relPath, settings = defaultMemoryWebappSetti return allowed.some((pattern) => pathMatchesSetting(normalized, pattern) || normalized.startsWith(pattern.replace(/\/$/, "") + "/")) && isEditableTextFile(normalized); } +export function isReadOnlyMemoryPath(relPath, settings = defaultMemoryWebappSettings()) { + const normalized = normalizeRelPath(String(relPath || "")); + if (!normalized) return false; + return sanitizePathList(settings.readOnlyPaths || []).some((pattern) => pathMatchesSetting(normalized, pattern)); +} + +function pathContainsReadOnlyMemoryPath(relPath, settings = defaultMemoryWebappSettings()) { + const normalized = normalizeRelPath(String(relPath || "")).replace(/\/$/, ""); + if (!normalized) return false; + return sanitizePathList(settings.readOnlyPaths || []).some((pattern) => { + const readOnly = normalizeRelPath(pattern).replace(/\/$/, ""); + return readOnly === normalized || readOnly.startsWith(normalized + "/") || normalized.startsWith(readOnly + "/"); + }); +} + export function resolveMemoryPath(root, relPath) { const normalized = normalizeRelPath(relPath); const settings = effectiveMemoryWebappSettings(root); @@ -706,6 +734,7 @@ export function listMemoryFiles(root = process.cwd(), { externalRoots = [] } = { updatedAt: stats ? stats.mtime.toISOString() : null, kind: fileKindForPath(file.path), summary: summarizeContent(content), + readOnly: isReadOnlyMemoryPath(file.path, settings), }; }) .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.path.localeCompare(b.path, "fr")); @@ -715,7 +744,7 @@ export function listExplorerFiles(root = process.cwd(), { externalRoots = [], sh const settings = effectiveMemoryWebappSettings(root); const byPath = new Map(); for (const file of listMemoryFiles(root, { externalRoots })) { - byPath.set(file.path, { ...file, readOnly: false, explorerScope: "allowed" }); + byPath.set(file.path, { ...file, readOnly: isReadOnlyMemoryPath(file.path, settings), explorerScope: "allowed" }); } for (const rel of walkProjectExplorerTextFiles(root, { showHiddenFiles })) { if (byPath.has(rel)) continue; @@ -800,10 +829,12 @@ export function readMemoryFile(root, relPath) { if (isCronJobMarkdownPath(relPath)) return readCronJobMarkdownFile(relPath); const normalized = normalizeRelPath(relPath); if (isSensitiveProjectFile(normalized)) return readSensitiveProjectFile(root, normalized); - const allowed = isAllowedMemoryPath(normalized, effectiveMemoryWebappSettings(root)); + const settings = effectiveMemoryWebappSettings(root); + const allowed = isAllowedMemoryPath(normalized, settings); + const configuredReadOnly = allowed && isReadOnlyMemoryPath(normalized, settings); const abs = allowed ? resolveMemoryPath(root, normalized) : resolveProjectReadableMemoryPath(root, normalized); if (!fs.existsSync(abs)) { - return { path: normalized, content: "", exists: false, updatedAt: null, chars: 0, contentHash: hashContent(""), readOnly: !allowed }; + return { path: normalized, content: "", exists: false, updatedAt: null, chars: 0, contentHash: hashContent(""), readOnly: !allowed || configuredReadOnly }; } const stats = fs.statSync(abs); if (!stats.isFile()) throw new Error(`Not a file: ${relPath}`); @@ -816,7 +847,7 @@ export function readMemoryFile(root, relPath) { updatedAt: stats.mtime.toISOString(), chars: content.length, contentHash: hashContent(content), - readOnly: !allowed, + readOnly: !allowed || configuredReadOnly, }; } @@ -1804,7 +1835,9 @@ export function writeMemoryFile(root, relPath, content) { throw new Error("Content is too large for the local context room"); } const normalized = normalizeRelPath(relPath); - const projectOnly = !resolveExternalPath(normalized) && effectiveMemoryWebappSettings(root).projectOnly === true; + const settings = effectiveMemoryWebappSettings(root); + if (isReadOnlyMemoryPath(normalized, settings)) throw new Error(`Path is read-only in context room: ${normalized}`); + const projectOnly = !resolveExternalPath(normalized) && settings.projectOnly === true; validateEditableContent(normalized, content); if (isCronJobVirtualPath(normalized)) return writeCronJobVirtualFile(root, normalized, content); if (isCronJobMarkdownPath(normalized)) return writeCronJobMarkdownFile(root, normalized, content); @@ -1847,6 +1880,7 @@ export function createFolder(root, { path: relPath } = {}) { const settings = effectiveMemoryWebappSettings(root); const normalized = normalizeRelPath(String(relPath || "")).replace(/\/$/, ""); if (!normalized) throw new Error("Folder path is required"); + if (isReadOnlyMemoryPath(normalized, settings)) throw new Error(`Path is read-only in context room: ${normalized}`); const alreadyAllowed = isAllowedFolderPath(normalized, settings); const abs = alreadyAllowed ? resolveMemoryFolderPath(root, normalized, settings) : resolveCreatableRepoPath(root, normalized); const external = resolveExternalPath(normalized); @@ -2053,6 +2087,7 @@ export function deleteMemoryPaths(root, relPaths = []) { const uniquePaths = [...new Set(relPaths.map((item) => normalizeRelPath(String(item || ""))).filter(Boolean))]; for (const relPath of uniquePaths) { const normalized = relPath.replace(/\/$/, ""); + if (pathContainsReadOnlyMemoryPath(normalized, settings)) throw new Error(`Path is read-only in context room: ${normalized}`); if (isCronJobMarkdownPath(normalized)) { const result = deleteCronJobMarkdownFile(root, normalized); if (result.deleted) deleted.push(normalized); @@ -2394,8 +2429,10 @@ function buildNewFileDiff(root, normalized, abs) { export function revertMemoryFile(root, relPath) { const normalized = normalizeRelPath(relPath); if (!normalized) throw new Error("Path is required"); + const settings = effectiveMemoryWebappSettings(root); + if (isReadOnlyMemoryPath(normalized, settings)) throw new Error(`Path is read-only in context room: ${normalized}`); if (resolveExternalPath(normalized)) { - if (!isAllowedMemoryPath(normalized, readMemoryWebappSettings(root))) throw new Error(`Path not allowed in context room: ${relPath}`); + if (!isAllowedMemoryPath(normalized, settings)) throw new Error(`Path not allowed in context room: ${relPath}`); const review = readReviewBaseFile(root, normalized); const abs = resolveMemoryPath(root, normalized); if (!review.available || review.changeKind === "unchanged") return { path: normalized, reverted: false, deleted: false, reason: "No Context Room baseline change for this file." }; @@ -2411,7 +2448,7 @@ export function revertMemoryFile(root, relPath) { const restored = writeMemoryFile(root, normalized, review.baseContent); return { path: normalized, reverted: true, deleted: false, backupPath: restored.backupPath }; } - if (!isAllowedMemoryPath(normalized, readMemoryWebappSettings(root))) throw new Error(`Path not allowed in context room: ${relPath}`); + if (!isAllowedMemoryPath(normalized, settings)) throw new Error(`Path not allowed in context room: ${relPath}`); const abs = resolveMemoryPath(root, normalized); try { const statusLine = execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--", normalized], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n").find(Boolean) || ""; @@ -3665,6 +3702,11 @@ export function buildConfigDiagnostics(root = process.cwd(), settings = readMemo if (projectPathEscapes(relPath)) issues.push({ path: relPath, type: "allowed_path_symlink_escape", severity: "high", message: `Allowed path escapes the project through a symbolic link while projectOnly is enabled: ${relPath}.` }); else if (!fs.existsSync(path.join(root, relPath.replace(/\/$/, "")))) issues.push({ type: "allowed_path_missing", severity: "low", message: `Allowed path does not exist: ${relPath}.` }); } + for (const relPath of settings.readOnlyPaths || []) { + const covered = (settings.allowedPaths || []).some((allowed) => pathMatchesSetting(relPath, allowed)); + if (!covered) issues.push({ type: "read_only_not_allowed", severity: "high", message: `Read-only path is not covered by allowedPaths: ${relPath}.` }); + if (projectPathEscapes(relPath)) issues.push({ path: relPath, type: "read_only_path_symlink_escape", severity: "high", message: `Read-only path escapes the project through a symbolic link while projectOnly is enabled: ${relPath}.` }); + } for (const relPath of settings.watchAllow || []) { const covered = (settings.allowedPaths || []).some((allowed) => pathMatchesSetting(relPath, allowed) || pathMatchesSetting(allowed, relPath)); if (!covered) issues.push({ type: "watch_not_allowed", severity: "high", message: `Watched path is not covered by allowedPaths: ${relPath}.` }); @@ -4938,6 +4980,7 @@ export function createDefaultProjectConfig({ title = "Context Room", allowedPath title, projectOnly: true, allowedPaths: sanitizePathList(allowedPaths), + readOnlyPaths: [], watchAllow: sanitizePathList(watchAllow), watchRules: [], reviewPaths: [], @@ -5073,12 +5116,18 @@ export function initializeContextRoomProject(root = process.cwd(), options = {}) if (options.title) amended.title = String(title).trim() || existing.title; if (options.allowedPaths?.length) amended.allowedPaths = sanitizePathList(allowedPaths); if (options.watchAllow?.length) amended.watchAllow = sanitizePathList(watchAllow); + if (options.reviewAgentInstructions !== undefined) { + amended.reviewAgentInstructions = Boolean(options.reviewAgentInstructions); + } if (JSON.stringify(amended) !== JSON.stringify(existingRaw)) { fs.writeFileSync(configPath, JSON.stringify(amended, null, 2) + "\n", "utf8"); } saved = normalizeMemoryWebappSettings(amended, { ...defaultMemoryWebappSettings(), projectOnly: false }); } else { const defaults = createDefaultProjectConfig({ title, allowedPaths, watchAllow }); + if (options.reviewAgentInstructions !== undefined) { + defaults.reviewAgentInstructions = Boolean(options.reviewAgentInstructions); + } const inferredHubSections = filterInferredHubSectionsForAllowedPaths(inferred.hubSections, allowedPaths); const inferredCards = flattenHubCards(inferredHubSections); defaults.hubSections = inferredHubSections; @@ -5522,6 +5571,7 @@ export function syncContextRoomAgentContext(root = process.cwd()) { [path.join(sourceRoot, "features", "html-visual-patterns.md"), AGENT_CONTEXT_ASSET_FILENAMES[2]], [path.join(sourceRoot, "context-room-visual-components.html"), AGENT_CONTEXT_ASSET_FILENAMES[3]], [path.join(sourceRoot, "context-room-data-visual-components.html"), AGENT_CONTEXT_ASSET_FILENAMES[4]], + [path.join(sourceRoot, "features", "shared-context.md"), AGENT_CONTEXT_ASSET_FILENAMES[5]], ]; const missing = assets.filter(([source]) => !fs.existsSync(source)).map(([source]) => source); if (missing.length) throw new Error(`Context Room agent context is incomplete: ${missing.join(", ")}`); @@ -5653,6 +5703,7 @@ This compatibility file is generated by Context Room. for (const [target, content] of files) { assertManagedProjectPath(projectRoot, target, path.relative(projectRoot, target)); if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === content) continue; + fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content, "utf8"); updated += 1; } @@ -6170,10 +6221,12 @@ function normalizeMemoryWebappSettings(raw = {}, base = defaultMemoryWebappSetti title: String(raw.title || base.title || "Context Room").trim() || "Context Room", projectOnly: Boolean(raw.projectOnly ?? base.projectOnly ?? false), allowedPaths: sanitizePathList(raw.allowedPaths ?? base.allowedPaths ?? ALLOWED_PREFIXES), + readOnlyPaths: sanitizePathList(raw.readOnlyPaths ?? base.readOnlyPaths ?? []), watchAllow: sanitizePathList(raw.watchAllow ?? base.watchAllow), watchRules: sanitizeWatchRules(raw.watchRules ?? base.watchRules ?? []), reviewPaths: sanitizePathList(raw.reviewPaths ?? base.reviewPaths ?? []), reviewAgentInstructions: Boolean(raw.reviewAgentInstructions ?? base.reviewAgentInstructions ?? true), + sharedContext: raw.sharedContext && typeof raw.sharedContext === "object" ? { ...raw.sharedContext } : base.sharedContext || null, integrations: { hermes: Boolean(raw.integrations?.hermes ?? base.integrations?.hermes ?? false) }, startupContext: normalizeStartupContextSettings(raw.startupContext ?? base.startupContext), startupSkills: normalizeStartupSkillSettings(raw.startupSkills ?? base.startupSkills), @@ -6918,6 +6971,62 @@ async function readBackgroundReports(root, { force = false } = {}) { } } +function sharedReviewAllowedPaths(repositoryConfig, projectId) { + if (projectId === "global") return [`${repositoryConfig.globalSkillsPath}/`]; + const projectPrefix = `${repositoryConfig.projectsPath}/${projectId}`; + return [`${projectPrefix}/docs/`, `${projectPrefix}/skills/`]; +} + +function sharedContextUiState(root) { + const reviewPath = path.join(path.resolve(root), SHARED_REVIEW_CONFIG); + if (fs.existsSync(reviewPath)) { + const review = readSharedReview(root); + return { + enabled: true, + mode: "review", + review, + accepted: review.accepted || null, + }; + } + const connection = readSharedProjectConnection(root); + if (!connection) return { enabled: false, mode: "local" }; + const status = sharedContextStatus(root); + try { + return { + enabled: true, + mode: "project", + status, + proposals: listSharedProposals(root), + }; + } catch (error) { + return { + enabled: true, + mode: "project", + status: sharedContextStatus(root), + proposals: [], + proposalError: error.message, + }; + } +} + +function sharedRequestError(message, statusCode = 400, code = "shared_context_request_failed") { + const error = new Error(message); + error.statusCode = statusCode; + error.code = code; + return error; +} + +async function listenContextRoomServer(server, port) { + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); +} + export function createMemoryServer({ root = process.cwd(), port = DEFAULT_PORT, @@ -6926,6 +7035,7 @@ export function createMemoryServer({ codexReferenceInsert = insertFileReferenceIntoActiveCodexComposer, } = {}) { const projectId = contextRoomProjectId(root); + const sharedReviewServers = new Set(); ensureBackgroundWorker(root, "files"); void readBackgroundReports(root).catch(() => {}); const lastSelectedPath = normalizeRelPath(readCollaborationSessionState(root).selectedPath || ""); @@ -6955,14 +7065,45 @@ export function createMemoryServer({ return; } try { - await routeRequest(req, res, root, globalPreferencesPath, { codexComposerInsert, codexReferenceInsert }); + await routeRequest(req, res, root, globalPreferencesPath, { + codexComposerInsert, + codexReferenceInsert, + startSharedReview: async (proposal) => { + if (fs.existsSync(path.join(path.resolve(root), SHARED_REVIEW_CONFIG))) { + throw sharedRequestError("This Context Room already represents an exact proposal review", 409, "shared_context_review_already_open"); + } + const result = materializeSharedReview(root, { proposal }); + const allowedPaths = sharedReviewAllowedPaths(result.repositoryConfig, result.metadata.projectId); + initializeContextRoomProject(result.reviewRoot, { + title: `Review · ${proposal}`, + allowedPaths, + watchAllow: allowedPaths, + reviewAgentInstructions: false, + }); + const reviewPort = await selectAvailableContextRoomPort(DEFAULT_PORT, { allowFallback: true }); + const reviewRoom = createMemoryServer({ root: result.reviewRoot, port: reviewPort, globalPreferencesPath }); + await listenContextRoomServer(reviewRoom.server, reviewPort); + sharedReviewServers.add(reviewRoom.server); + reviewRoom.server.once("close", () => sharedReviewServers.delete(reviewRoom.server)); + return { + ...result, + port: reviewPort, + url: `http://127.0.0.1:${reviewPort}`, + }; + }, + }); } catch (error) { - sendJson(res, Number(error.statusCode) || 500, { error: error.message }); + sendJson(res, Number(error.statusCode) || 500, { + error: error.message, + ...(error.code ? { code: error.code } : {}), + }); } finally { if (requestInvalidatesBackgroundCaches(req)) invalidateBackgroundCaches(root, { explicit: true }); } }); server.once("close", () => { + for (const reviewServer of sharedReviewServers) reviewServer.close(); + sharedReviewServers.clear(); stopBackgroundWatch(); closeBackgroundWorkers(root); clearBackgroundCacheState(root); @@ -6973,6 +7114,7 @@ export function createMemoryServer({ async function routeRequest(req, res, root, globalPreferencesPath = null, { codexComposerInsert = insertIntoActiveCodexComposer, codexReferenceInsert = insertFileReferenceIntoActiveCodexComposer, + startSharedReview = null, } = {}) { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); @@ -6980,6 +7122,53 @@ async function routeRequest(req, res, root, globalPreferencesPath = null, { sendHtml(res, renderAppHtml()); return; } + if (req.method === "GET" && url.pathname === "/api/shared-context") { + sendJson(res, 200, sharedContextUiState(root)); + return; + } + if (req.method === "POST" && url.pathname === "/api/shared-context/refresh") { + if (!readSharedProjectConnection(root)) throw sharedRequestError("This project is not connected to shared context", 404, "shared_context_not_connected"); + syncSharedContext(root, { allowOffline: true }); + sendJson(res, 200, sharedContextUiState(root)); + return; + } + if (req.method === "POST" && url.pathname === "/api/shared-context/review") { + const body = await readJsonBody(req); + const proposal = String(body.proposal || "").trim(); + if (!proposal) throw sharedRequestError("proposal is required", 400, "shared_context_proposal_required"); + if (!startSharedReview) throw sharedRequestError("Shared proposal review is unavailable", 503, "shared_context_review_unavailable"); + const result = await startSharedReview(proposal); + sendJson(res, 201, { + url: result.url, + port: result.port, + reviewRoot: result.reviewRoot, + review: result.metadata, + }); + return; + } + if (req.method === "POST" && url.pathname === "/api/shared-context/accept") { + const body = await readJsonBody(req); + const review = readSharedReview(root); + const expectedProposalHead = String(body.expectedProposalHead || "").trim(); + if (!expectedProposalHead) { + throw sharedRequestError("expectedProposalHead is required", 400, "shared_context_proposal_head_required"); + } + if (expectedProposalHead !== review.proposalHead) { + throw sharedRequestError("The reviewed proposal commit does not match this acceptance request", 409, "shared_context_proposal_head_mismatch"); + } + const report = buildDocQaReport(root); + if (report.queue.length) { + throw sharedRequestError(`Human review is incomplete: ${report.queue.length} file(s) remain in the review queue`, 409, "shared_context_review_incomplete"); + } + let result; + try { + result = acceptSharedReview(root, { message: body.message }); + } catch (error) { + throw sharedRequestError(error.message, 409, "shared_context_acceptance_stale"); + } + sendJson(res, 200, result); + return; + } if (req.method === "GET" && url.pathname === "/api/files") { const startupSkillFolder = url.searchParams.get("startupSkillFolder") || ""; const startupSkill = url.searchParams.get("startupSkill") || ""; @@ -8801,6 +8990,8 @@ export function renderAppHtml() { .workspace-dock #back.dock-button, .workspace-dock #forward.dock-button { padding: 0; } .workspace-dock .dock-button.diff-dock-button { padding: 0 11px; } .workspace-dock .dock-status { display: none; } + .shared-context-label, .workspace-title { display: none; } + .shared-context-select { width: 220px; } aside { position: fixed; left: 0; right: 0; bottom: 0; top: auto; width: 100%; height: auto; max-height: 0; min-height: 0; margin: 0; padding: 0; border: 0; border-radius: 22px 22px 0 0; background: rgba(6,10,22,0.985); backdrop-filter: none; box-shadow: 0 -20px 70px rgba(0,0,0,0.55); overflow: auto; overscroll-behavior: contain; transition: transform 280ms cubic-bezier(.2,.9,.2,1), max-height 280ms ease, padding 280ms ease; transform: translateY(100%); pointer-events: none; } .app.explorer-expanded aside { height: min(66dvh, 560px) !important; max-height: min(66dvh, 560px) !important; padding: 0 var(--space-3) var(--space-4) !important; border-top: 1px solid var(--line) !important; transform: translateY(0) !important; pointer-events: auto !important; } .app.sidebar-collapsed aside { height: auto; max-height: 0; padding: 0; border: 0; transform: translateY(100%); pointer-events: none; } @@ -8913,6 +9104,12 @@ export function renderAppHtml() { background: var(--accent); color: var(--on-accent); box-shadow: none; } .dock-button.diff-dock-button.active, .mode-toggle button.active { background: var(--accent); color: var(--on-accent); } + .shared-context-controls { display: inline-flex; align-items: center; gap: 6px; min-width: 0; margin-left: auto; } + .shared-context-controls[hidden] { display: none !important; } + .shared-context-label { max-width: 220px; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .shared-context-select { width: min(320px, 30vw); min-height: 32px; padding: 5px 28px 5px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-strong); color: var(--text); font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } + .shared-context-controls .dock-button { min-height: 32px; padding: 0 9px; } + .shared-context-controls:not([hidden]) + .workspace-title { margin-left: 0; } .workspace-title { min-width: 0; margin-left: auto; padding: 0 8px; color: var(--muted); font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .topbar { border-radius: 8px; box-shadow: none; backdrop-filter: none; } .editor-shell { border: 0; border-radius: 0; background: transparent; box-shadow: none; backdrop-filter: none; } @@ -9076,6 +9273,13 @@ export function renderAppHtml() { +
Context Room
Ready
@@ -9149,6 +9353,8 @@ export function renderAppHtml() {