diff --git a/AGENTS.md b/AGENTS.md index 8e0f0a0..a5d7c9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ These instructions apply to this repository. Use them for Context Room product, - Product/source map: `docs/product-overview.md`. - Feature behavior: `docs/features/index.md`, then the relevant feature page. +- Documentation creation, audit, and consumption: `docs/features/documentation-lifecycle.md`. +- Global local/shared cockpit: `docs/features/context-hub.md`. - Visual HTML documents: `docs/features/html-visual-documents.md`. - Visual pattern contracts: `docs/features/html-visual-patterns.md`. - Visual catalogs: `docs/context-room-visual-components.html` and `docs/context-room-data-visual-components.html`. @@ -57,5 +59,6 @@ node bin/context-room.mjs doctor --root . For package/release work, also run: ```bash +npm run package:privacy npm pack --dry-run ``` diff --git a/LICENSE b/LICENSE index 9b15d73..0bc99a6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Mathis Blanc +Copyright (c) 2026 Context Room contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6a59850..95ef21c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local-first documentation control room for AI-assisted projects. -Context Room gives a repository a browser UI to map important docs, edit safe text files, review watched doc changes, and generate deterministic context briefs before agent work. +Context Room gives a repository a browser UI to map important docs, edit safe text files, review watched doc changes, generate deterministic context briefs, and call a documentation-only Codex researcher before agent work. ## Use It For @@ -11,7 +11,9 @@ Context Room gives a repository a browser UI to map important docs, edit safe te - Review important doc changes before they become trusted context. - Inspect startup context, startup skills, and hooks that can affect agents. - Run local checks with no LLM call. +- Give a working agent a compact, source-linked context packet through a fresh documentation-only Codex process. - 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 +48,14 @@ 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/doc-context.schema.json`: structured evidence contract for documentation research. +- `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. @@ -59,14 +65,33 @@ Runtime files under `.context-room/` are excluded from Git where possible. Commi context-room init [--title "My Project"] [--allow docs/,src/] [--watch docs/] context-room setup [--root .] [--title "My Project"] [--port 4317] context-room start [--root .] [--port 4317] +context-room hub [--root .] [--port 4317] [--no-local] +context-room hub list +context-room hub add-shared --repository +context-room hub proposals [--project ] [--session ] +context-room hub open [--project ] [--session ] [--proposal proposal/...] 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 "change billing onboarding"] [--limit 12] +context-room context ask "change billing onboarding" [--root . | --repository --project ] [--goal "outcome"] [--files path,...] [--depth quick|standard|exhaustive] [--budget 1200] [--session ] [--json] +context-room docs capabilities [--root . | --repository --project ] [--session ] +context-room docs search "query" [--status current|proposal] [--kind canonical] [--limit 8] [--budget 1200] [--session ] +context-room docs read path[#section] [--budget 1600] [--session ] +context-room docs related path [--session ] +context-room docs trace path[#section] [--session ] context-room agent queue [--root .] context-room agent open [--root .] [--path docs/INDEX.md] [--view hub|settings|file|diff] 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 secure-github|security-check --root . +context-room shared propose --root . --title "Change" --description "Complete current agent recap" [--scope project|global] [--session ] +context-room shared publish --root . --proposal proposal/... [--title "Updated name"] [--description "Required when updating"] [--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] ``` @@ -77,7 +102,11 @@ context-room update-all [--dry-run] [--no-restart] [--exclude /path] - `guard` and `review-only` are non-blocking. `--profile strict` can always fail; a selected `--operation` fails when review is pending. - 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. +- `context ask` starts a fresh read-only, ephemeral Codex researcher for one task and returns a schema-constrained documentation packet. It can use the detected local project or target a shared repository directly with `--repository` and `--project`, without creating local project state. The researcher uses only the deterministic `docs` subcommands and never inspects code. Accepted shared revisions and same-session proposals are frozen for the call; proposal evidence stays pending, never current. +- `docs` exposes section-level search, exact reads, reference traversal, provenance, hashes, and project-specific canonical subjects without calling a model. - `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. +- `hub` starts or reuses one computer-wide cockpit for local projects and shared proposals. Local files retain their normal project review queue; shared changes retain exact-hash proposal review. +- `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: @@ -115,10 +144,11 @@ The reusable HTML examples are available directly at: ```json { - "$schema": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/config.schema.json", + "$schema": "https://unpkg.com/context-room@latest/schemas/config.schema.json", "title": "My Project", "projectOnly": true, "allowedPaths": ["docs/", "README.md", "AGENTS.md"], + "readOnlyPaths": [], "watchAllow": ["docs/", "README.md"], "watchRules": [], "reviewPaths": [], @@ -156,6 +186,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,13 +200,19 @@ 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. +- [Context Hub](docs/features/context-hub.md): one UI for local-only, shared-only, and linked local-plus-shared projects. +- [Documentation research agent](docs/features/documentation-agent.md): working-agent launcher, dedicated documentation CLI, Codex isolation, evidence schema, and safety boundaries. +- [Documentation lifecycle](docs/features/documentation-lifecycle.md): creation, scheduled audit, task proposal reuse, and local/shared/mixed routing. ## Development ```bash npm test node bin/context-room.mjs doctor --root . +npm run package:privacy +npm pack --dry-run node bin/context-room.mjs start --root . ``` -The package has no runtime dependencies beyond Node.js built-ins. +`package:privacy` inspects the exact npm file list and rejects absolute user-home paths or email addresses before publication. diff --git a/bin/context-room.mjs b/bin/context-room.mjs index d62964c..760c3e1 100755 --- a/bin/context-room.mjs +++ b/bin/context-room.mjs @@ -3,12 +3,47 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { updateAllContextRooms } from "../scripts/update-context-rooms.mjs"; +import { + checkSharedGitHubSecurity, + connectSharedContext, + detectSharedProject, + ensureSharedProposal, + initializeSharedRepository, + listSharedProposals, + materializeSharedReview, + publishSharedProposal, + readSharedProjectConnection, + secureSharedGitHubRepository, + sharedContextStatus, + syncSharedContext, +} from "../src/shared_context.mjs"; +import { + clearContextHubRuntime, + contextHubHostRoot, + listContextHubProjects, + readContextHubRegistry, + readContextHubRuntime, + registerContextHubProject, + registerContextHubSharedRepository, + writeContextHubRuntime, +} from "../src/context_hub.mjs"; +import { + documentationCapabilities, + readDocumentation, + relatedDocumentation, + renderDocumentationPacket, + resolveDocumentationProjectRoot, + runDocumentationAgent, + searchDocumentation, + traceDocumentation, +} from "../src/doc_agent.mjs"; import { appendAgentAnnotation, buildAgentBrief, buildAgentReviewQueue, buildContextRoomDoctorReport, buildDocQaReport, + contextHubUiState, createMemoryServer, initializeContextRoomProject, readAgentAnnotations, @@ -55,13 +90,61 @@ 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 hub [--root .] [--port 4317] [--no-local] + context-room hub list + context-room hub proposals [--project ] [--session ] + context-room hub open [--project ] [--session ] [--proposal proposal/...] + context-room hub add-shared --repository + 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 context ask "what the agent needs to do" [--root . | --repository --project ] [--goal "desired outcome"] [--files path,...] [--depth quick|standard|exhaustive] [--budget 1200] [--session ] [--json] + context-room docs capabilities [--root . | --repository --project ] [--session ] + context-room docs search "query" [--root . | --repository --project ] [--status current|proposal] [--kind canonical] [--limit 8] [--budget 1200] [--session ] + context-room docs read path[#section] [--root . | --repository --project ] [--budget 1600] [--session ] + context-room docs related path [--root . | --repository --project ] [--session ] + context-room docs trace path[#section] [--root . | --repository --project ] [--session ] + 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 secure-github|security-check --root . + context-room shared propose --root . --title "Change" --description "Current proposal summary" [--scope project|global] [--session ] + context-room shared publish --root . --proposal proposal/... [--title "Updated name"] [--description "Required when updating"] [--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", "budget", "depth", "description", "dry-run", "exclude", "files", "goal", "h", "heading", "help", "highlight", "hook", + "json", "kind", "limit", "message", "mode", "name", "no-restart", "note", "operation", "path", "percent", "port", "profile", "query", + "no-local", "project", "proposal", "repository", "root", "scope", "section", "session", "status", "strict", "target", "task", "text", "title", "version", "view", "watch", ]); function packageVersion() { @@ -101,6 +184,11 @@ if (args.title === true || args.title === "") { process.exit(2); } +if (args.description === true || args.description === "") { + console.error("--description requires a value."); + process.exit(2); +} + if (args.allow === true || args.allow === "") { console.error("--allow requires a path list."); process.exit(2); @@ -111,7 +199,21 @@ if (args.watch === true || args.watch === "") { process.exit(2); } -const root = path.resolve(args.root || process.cwd()); +const requestedRoot = path.resolve(args.root || process.cwd()); +const documentationCommand = ["context", "docs"].includes(command); +if (documentationCommand && (args.repository === true || args.project === true)) { + console.error("--repository and --project each require a value."); + process.exit(2); +} +const sharedDocumentationTarget = documentationCommand && args.repository && args.repository !== true; +if (documentationCommand && (Boolean(args.repository && args.repository !== true) !== Boolean(args.project && args.project !== true))) { + console.error("Shared-only documentation requires both --repository and --project ."); + process.exit(2); +} +const root = documentationCommand && !sharedDocumentationTarget ? resolveDocumentationProjectRoot(requestedRoot) : requestedRoot; +const documentationTargetOptions = sharedDocumentationTarget + ? { repository: String(args.repository), projectId: String(args.project) } + : {}; let rootStats; try { rootStats = fs.statSync(root); @@ -123,6 +225,318 @@ if (!rootStats?.isDirectory()) { process.exit(2); } +if (command === "hub") { + const action = args._[1] || "start"; + try { + if (action === "list" || action === "status") { + console.log(JSON.stringify({ + runtime: readContextHubRuntime(), + registry: readContextHubRegistry(), + projects: listContextHubProjects(), + }, null, 2)); + process.exit(0); + } + if (action === "add-shared") { + if (!args.repository || args.repository === true) throw new Error("Usage: context-room hub add-shared --repository "); + console.log(JSON.stringify(registerContextHubSharedRepository(args.repository), null, 2)); + process.exit(0); + } + if (action === "proposals") { + let proposals = contextHubUiState(root).proposals; + if (args.project && args.project !== true) proposals = proposals.filter((proposal) => proposal.projectId === args.project || proposal.projectTitle === args.project); + if (args.session && args.session !== true) proposals = proposals.filter((proposal) => proposal.sessionId === args.session); + console.log(JSON.stringify(proposals, null, 2)); + process.exit(0); + } + if (action === "open") { + const active = readContextHubRuntime(); + if (!active) throw new Error("Context Hub is not running; run context-room hub first"); + const query = new URLSearchParams({ hub: "1" }); + if (args.project && args.project !== true) { + const requestedProject = String(args.project); + const project = contextHubUiState(root).projects.find((item) => ( + item.id === requestedProject + || item.projectKey === requestedProject + || item.shared?.projectId === requestedProject + || item.title.toLowerCase() === requestedProject.toLowerCase() + )); + if (!project) throw new Error(`Unknown Context Hub project: ${requestedProject}`); + query.set("project", project.id); + } + const search = [ + args.session && args.session !== true ? String(args.session) : "", + args.proposal && args.proposal !== true ? String(args.proposal) : "", + ].filter(Boolean).join(" "); + if (search) query.set("q", search); + console.log(`Context Room Hub: ${active.url}/?${query.toString()}`); + process.exit(0); + } + if (action !== "start") throw new Error(`Unknown hub command: ${action}`); + let focusedProject = null; + if (!args["no-local"]) { + initializeContextRoomProject(root, { title: args.title }); + const connection = readSharedProjectConnection(root); + focusedProject = registerContextHubProject(root, { + title: args.title, + shared: connection ? { repository: connection.repository, projectId: connection.projectId } : null, + }); + } + const runtime = readContextHubRuntime(); + if (runtime) { + try { + const response = await fetch(runtime.url + "/api/health", { signal: AbortSignal.timeout(1500) }); + const health = response.ok ? await response.json() : null; + const expectedRoot = runtime.root ? fs.realpathSync(runtime.root) : ""; + const actualRoot = health?.root ? fs.realpathSync(health.root) : ""; + if (response.ok && health?.ok === true && expectedRoot && actualRoot === expectedRoot) { + const focus = focusedProject ? `&project=${encodeURIComponent(focusedProject.id)}` : ""; + console.log(`Context Room Hub: ${runtime.url}/?hub=1${focus}`); + console.log(`Already running since: ${runtime.startedAt || "unknown"}`); + process.exit(0); + } + } catch {} + clearContextHubRuntime(runtime.pid); + } + if (args.port === true || args.port === "") throw new Error("--port requires a number"); + const hostRoot = contextHubHostRoot(); + fs.mkdirSync(hostRoot, { recursive: true }); + initializeContextRoomProject(hostRoot, { + title: "Context Room Hub", + allowedPaths: [], + watchAllow: [], + reviewAgentInstructions: false, + }); + const preferredPort = args.port === undefined ? 4317 : Number(args.port); + const port = await selectAvailableContextRoomPort(preferredPort, { allowFallback: args.port === undefined }); + const { server } = createMemoryServer({ root: hostRoot, port, registerInHub: false }); + 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(); }); + }); + const url = `http://127.0.0.1:${port}`; + writeContextHubRuntime({ port, root: hostRoot, url }); + const focus = focusedProject ? `&project=${encodeURIComponent(focusedProject.id)}` : ""; + console.log(`Context Room Hub: ${url}/?hub=1${focus}`); + console.log(`Projects: ${listContextHubProjects().length}`); + const close = () => server.close(() => { + clearContextHubRuntime(process.pid); + process.exit(0); + }); + process.on("SIGINT", close); + process.on("SIGTERM", close); + await new Promise(() => {}); + } catch (error) { + console.error(`Context Room Hub failed: ${error.message}`); + process.exit(1); + } +} + +if (command !== "shared" && !sharedDocumentationTarget && ["setup", "start", "doctor", "guard", "brief", "context", "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; + const result = connectSharedContext(bindingRoot, { + repository: args.repository, + projectId: detected.projectId, + sync: false, + }); + if (!process.env.NODE_TEST_CONTEXT) registerContextHubSharedRepository(args.repository); + if (!process.env.NODE_TEST_CONTEXT && fs.existsSync(path.join(bindingRoot, CONFIG_FILE))) { + registerContextHubProject(bindingRoot, { shared: { repository: args.repository, projectId: detected.projectId } }); + } + console.log(JSON.stringify(result, 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); + const result = connectSharedContext(setupRoot, { repository: args.repository, projectId: detected.projectId }); + if (!process.env.NODE_TEST_CONTEXT) { + registerContextHubSharedRepository(args.repository); + registerContextHubProject(setupRoot, { shared: { repository: args.repository, projectId: detected.projectId } }); + } + console.log(JSON.stringify(result, 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 === "secure-github") { + console.log(JSON.stringify(secureSharedGitHubRepository(root), null, 2)); + process.exit(0); + } + if (action === "security-check") { + const result = checkSharedGitHubSecurity(root); + console.log(JSON.stringify(result, null, 2)); + process.exit(result.verified ? 0 : 1); + } + if (action === "proposals" || action === "list") { + let proposals = listSharedProposals(root); + if (args.project && args.project !== true) proposals = proposals.filter((proposal) => proposal.projectId === args.project); + if (args.session && args.session !== true) proposals = proposals.filter((proposal) => proposal.sessionId === args.session); + console.log(JSON.stringify(proposals, null, 2)); + process.exit(0); + } + if (action === "propose" || action === "proposal-create") { + if (!args.description) throw new Error("--description is required when creating a proposal"); + console.log(JSON.stringify(ensureSharedProposal(root, { + title: args.title || args.task || "Shared context change", + description: args.description, + scope: args.scope || "project", + branch: args.branch || "", + sessionId: args.session || process.env.CODEX_THREAD_ID || "", + }), 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, + title: args.title, + description: args.description, + }), 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 === "context") { + const action = args._[1] || "ask"; + if (action !== "ask") { + console.error(`Unknown context command: ${action}`); + process.exit(1); + } + const task = args.task && args.task !== true ? String(args.task) : args._.slice(2).join(" ").trim(); + if (!task) { + console.error("Usage: context-room context ask \"what the agent needs to do\" [--root . | --repository --project ] [--goal \"desired outcome\"] [--files path,...] [--depth quick|standard|exhaustive] [--budget 1200] [--session ] [--json]"); + process.exit(2); + } + try { + const result = runDocumentationAgent({ + root, + ...documentationTargetOptions, + cliPath: fileURLToPath(import.meta.url), + task, + goal: args.goal && args.goal !== true ? String(args.goal) : "", + files: splitList(args.files), + depth: args.depth && args.depth !== true ? String(args.depth) : "standard", + budget: args.budget === undefined ? undefined : args.budget, + sessionId: args.session && args.session !== true ? String(args.session) : undefined, + }); + if (args.json) console.log(JSON.stringify(result.packet, null, 2)); + else process.stdout.write(renderDocumentationPacket(result.packet)); + process.exit(0); + } catch (error) { + console.error(`Context Room documentation agent failed: ${error.message}`); + process.exit(1); + } +} + +if (command === "docs") { + const action = args._[1] || "capabilities"; + const selector = args._[2] || args.path || ""; + const sessionId = args.session && args.session !== true ? String(args.session) : ""; + try { + if (action === "capabilities") { + console.log(JSON.stringify(documentationCapabilities(root, { ...documentationTargetOptions, sessionId }), null, 2)); + process.exit(0); + } + if (action === "search") { + const query = args.query && args.query !== true ? String(args.query) : args._.slice(2).join(" ").trim(); + console.log(JSON.stringify(searchDocumentation(root, query, { + ...documentationTargetOptions, + status: args.status && args.status !== true ? String(args.status) : "", + kind: args.kind && args.kind !== true ? String(args.kind) : "", + limit: args.limit, + budget: args.budget, + sessionId, + }), null, 2)); + process.exit(0); + } + if (action === "read") { + console.log(JSON.stringify(readDocumentation(root, selector, { ...documentationTargetOptions, section: args.section, budget: args.budget, sessionId }), null, 2)); + process.exit(0); + } + if (action === "related") { + console.log(JSON.stringify(relatedDocumentation(root, selector, { ...documentationTargetOptions, sessionId }), null, 2)); + process.exit(0); + } + if (action === "trace") { + console.log(JSON.stringify(traceDocumentation(root, selector, { ...documentationTargetOptions, section: args.section, sessionId }), null, 2)); + process.exit(0); + } + throw new Error(`Unknown docs command: ${action}`); + } catch (error) { + console.error(`Context Room docs failed: ${error.message}`); + process.exit(1); + } +} + if (command === "init") { let result; try { @@ -140,6 +554,7 @@ if (command === "init") { else console.log(`Documentation discovered: ${result.documentationPaths.length}`); console.log(`Watched paths: ${result.config.watchAllow.length}`); console.log(`Hub sections: ${result.config.hubSections.length}`); + if (!process.env.NODE_TEST_CONTEXT) registerContextHubProject(root, { title: args.title }); console.log(`Agent setup guide: ${result.agentContextPath}`); console.log(`Agent next step: read ${JSON.stringify(result.agentContextPath)} and follow its setup checklist.`); console.log(`Run: context-room setup --root ${quotedCliValue(root)}`); @@ -347,7 +762,7 @@ if (command === "start" || command === "setup") { allowedPaths: splitList(args.allow), watchAllow: splitList(args.watch), }); - ({ server } = createMemoryServer({ root, port })); + ({ server } = createMemoryServer({ root, port, registerInHub: true })); } catch (error) { console.error(`Context Room setup failed: ${error.message}`); process.exit(1); 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..3bca168 100644 --- a/docs/features/agent-cli.md +++ b/docs/features/agent-cli.md @@ -4,15 +4,40 @@ 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-23 + sources: [bin/context-room.mjs, src/context_room.mjs, src/context_hub.mjs, src/doc_agent.mjs, src/shared_context.mjs, schemas/doc-context.schema.json] --- # 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, request task-specific documentation context, manage explicit folder watch configuration, open files for the user, and leave annotations without bypassing human review. Its `hub` commands expose the computer-wide local/shared catalog, while `shared` commands let an agent create and publish scoped proposals without editing the accepted shared snapshot. + +## Documentation Context Commands + +The working agent uses one public command: + +```bash +context-room context ask "what I need to do and why" [--root . | --repository --project ] [--goal "outcome"] [--files path,...] [--depth quick|standard|exhaustive] [--budget 1200] [--session ] [--json] +``` + +Each call starts a fresh read-only, ephemeral `codex exec`. By default it uses the detected project. `--repository --project ` instead reads that shared project's accepted docs, project skills, and global skills directly from an immutable cached Git snapshot; it does not initialize or bind the current directory. The researcher uses the deterministic `context-room docs capabilities|search|read|related|trace` surface and returns a schema-constrained evidence packet. It may read only documentation; working-file paths are search terms, not permission to inspect code. `--session`, or `CODEX_THREAD_ID`, also exposes the exact frozen project/global proposals owned by that task as separate pending evidence. + +See [Documentation research agent](documentation-agent.md) for the complete retrieval, truth, output, and safety contract. + +## Context Hub Commands + +```bash +context-room hub --root . +context-room hub --no-local +context-room hub list +context-room hub add-shared --repository +context-room hub proposals [--project ] [--session ] +context-room hub open [--project ] [--session ] [--proposal proposal/...] +``` + +`hub` starts or reuses the one global cockpit. From a project, it registers the canonical initialized root and prints a URL focused on that project. `--no-local` starts or reuses the Hub without registering the current directory. `hub list` and `hub proposals` are read-only. `hub open` prints a focused deep link into the running Hub. `hub add-shared` explicitly registers a compatible shared repository for shared-only browsing. ## Example Flow @@ -39,17 +64,46 @@ 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 security-check --root . +context-room shared proposals --root . +context-room shared propose --root . --title "Clarify onboarding" --description "Complete current agent recap" [--scope project|global] [--session ] +context-room shared publish --root . --proposal proposal/... [--title "Updated proposal name"] [--description "Required for every update"] [--message "..."] +context-room shared review --root . --proposal proposal/... [--port 4317] +``` + +`propose` returns a writable worktree and records the proposal name and agent recap. With a task ID, repeated calls reuse the one open proposal for that repository and project/global scope. `publish` rejects files outside the proposal's scope. The first publish uses the recap recorded by `propose`; every later publish requires a complete current `--description`, and may also replace the proposal name with `--title`. The latest commit therefore always owns the recap shown to owners. + +`review` is the owner handoff: the room reuses the normal human inline decisions. Only the review UI exposes **Prepare pull request**, bound to the exact proposal hash that room examined. It publishes an `accepted/*` branch containing only the selected result; the agent-facing CLI has no acceptance or merge command. `security-check` verifies the live GitHub rule that blocks direct changes to `main`. + +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. +- `context ask` launches a new documentation-only Codex researcher for every request; it never resumes an earlier research session. +- Accepted evidence and same-session pending proposal evidence are returned in separate packet fields. +- `docs` commands remain deterministic and make no model call. - 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. +See [Documentation lifecycle](documentation-lifecycle.md) for the maintenance skill, scheduled audit, task proposal reuse, and local/shared/mixed routing. + ## Source Map - `context-room agent state`, `agent queue`, `agent open`, `agent annotate`, `agent watch`, and `agent unwatch` are CLI entry points. +- `context-room context ask` launches the researcher; `context-room docs` exposes its deterministic documentation toolbox. +- `src/doc_agent.mjs` builds the section-level corpus, enforces the prompt boundary, invokes Codex read-only and validates the context packet. - `writeFolderWatchRule` and `removeFolderWatchRule` apply the same validated folder-rule mutations used by the webapp. - `readCollaborationSessionState`, `writeAgentCommand`, `appendAgentAnnotation`, and `resolveAgentAnnotation` handle runtime state. - `/api/agent/*` routes carry local command state between the CLI and browser UI. diff --git a/docs/features/context-hub.md b/docs/features/context-hub.md new file mode 100644 index 0000000..5de44cf --- /dev/null +++ b/docs/features/context-hub.md @@ -0,0 +1,83 @@ +--- +context_room: + kind: canonical + scope: context-room + status: current + canonical_for: global Context Hub + last_verified: 2026-07-22 + sources: [src/context_hub.mjs, src/context_room.mjs, src/shared_context.mjs, bin/context-room.mjs, docs/features/shared-context.md] +--- + +# Context Hub + +## Purpose + +Context Hub is one local cockpit for every registered Context Room project and shared-context repository. It keeps local files and shared proposals together without pretending they use the same trust workflow. + +| Source | Trusted content | Owner workflow | +| --- | --- | --- | +| Local project | Files inside that project's allowed paths | Open the project room, edit normally, then complete its local review queue | +| Shared repository | The accepted default-branch snapshot | Open an exact proposal commit, accept all or part, then prepare and merge its pull request | + +A project may be local-only, shared-only, or local and connected to shared docs and skills. The UI labels every item by source and explains which review path it uses. + +## Start Or Reuse The Hub + +```bash +context-room hub --root . +``` + +The command initializes and registers the current local project, then starts one global Hub service. If that service is already healthy, another invocation reuses it and prints a URL focused on the current project instead of starting another Hub. + +Use a shared-only launch when the current directory should not become a local project: + +```bash +context-room hub --no-local +``` + +Inspect the user-local catalog or add a shared repository without connecting a local project: + +```bash +context-room hub list +context-room hub add-shared --repository git@github.com:example/company-shared-context.git +context-room hub proposals --session +context-room hub open --session +``` + +Normal `init`, `setup`, and `start` flows register their initialized project automatically. Shared setup records the repository and links the local project to its shared project ID. + +`hub proposals` exposes the aggregated proposal index to agents and can filter by project or Codex task ID. `hub open` prints a deep link into the running Hub with the same focus. + +## Inbox And Projects + +**Inbox** combines work that may need attention: + +- published shared proposals, including their current title, latest cumulative agent recap, author, session, files, branch, and exact hash; +- local projects whose normal review queue contains files; +- proposal states such as ready, updated after review, in review, accepted branch ready, and merged. + +**Projects** shows every registered project, including clean local projects and shared projects with no local folder. Filters can narrow by project or by local versus shared source. Search covers project names, proposal metadata, paths, sessions, hashes, roots, and repositories. The selected-proposal overview labels the recap explicitly before the owner opens the diff. + +Repository-wide proposal scopes appear as a dedicated **Global skills** project. They stay searchable and filterable without being duplicated under every project that consumes them. + +Keyboard shortcuts inside the Hub: + +- `/`: focus search; +- `j` and `k`: move through visible items; +- `Escape`: return to the current Context Room. + +## Freshness And Isolation + +Opening a connected local project refreshes its accepted shared snapshot before its room starts. If the remote is unavailable, the normal shared-context offline rules apply. + +Each local project still runs in an isolated Context Room server with its own project identity. The Hub embeds that room instead of giving one server arbitrary path access. Shared proposal reviews use the same isolation: each exact commit receives its own review worktree and room. + +The global registry lives at `$HOME/.context-room/hub/registry.json`. The running Hub record lives beside it in `runtime.json`. Both are computer-local state, not project truth and not files to commit. + +## Source Map + +- `src/context_hub.mjs`: global project, shared-repository, and runtime registry. +- `src/context_room.mjs`: aggregate Hub state, inbox UI, project-room isolation, and exact review embedding. +- `src/shared_context.mjs`: shared-only repository listing, proposal lifecycle signals, and exact review materialization. +- `bin/context-room.mjs`: `context-room hub` commands and automatic registration. +- [Shared context](shared-context.md): proposal, acceptance, skills, freshness, and permission contracts. diff --git a/docs/features/documentation-agent.md b/docs/features/documentation-agent.md new file mode 100644 index 0000000..ec790af --- /dev/null +++ b/docs/features/documentation-agent.md @@ -0,0 +1,148 @@ +--- +context_room: + kind: canonical + scope: context-room + status: current + canonical_for: documentation research agent + last_verified: 2026-07-23 + sources: [bin/context-room.mjs, src/doc_agent.mjs, schemas/doc-context.schema.json, src/context_room.mjs, src/shared_context.mjs] +--- + +# Documentation Research Agent + +## Purpose + +The documentation research agent gives a working agent the smallest documentation context that is complete for a task. The working agent uses one command; a fresh read-only Codex process explores only the project's Context Room documentation through a dedicated deterministic CLI. + +This is not vector search and does not use embeddings. Markdown links, Context Room metadata, semantic HTML text, section headings, declared sources, and exact content hashes remain the retrieval foundation. + +## Working-Agent Command + +```bash +context-room context ask \ + "Change session expiration without signing out existing mobile users" +``` + +Optional task context controls the research depth and returned context budget: + +```bash +context-room context ask \ + --task "Change session expiration" \ + --goal "Keep existing mobile users signed in" \ + --files src/auth/session.ts \ + --depth standard \ + --budget 1200 \ + --session "$CODEX_THREAD_ID" \ + --json +``` + +`--files` supplies vocabulary and task context only. The documentation researcher must not open those files. + +From a nested directory, the command walks upward to the nearest initialized Context Room project. If that project has approved shared context, Context Room refreshes the accepted shared snapshot before starting the researcher and uses the verified offline snapshot when refresh is unavailable. `--session` selects pending shared proposals from one Codex task; `CODEX_THREAD_ID` is the fallback. + +No local project is required for a shared-only query: + +```bash +context-room context ask \ + --repository git@github.com:example/company-shared-context.git \ + --project payments \ + --task "Change session expiration" \ + --session "$CODEX_THREAD_ID" +``` + +This reads only the selected project's accepted docs and project skills plus accepted global skills. It does not create `.context-room` state or bind the current directory. The accepted Git revision is frozen before the child starts, just like the proposal heads. + +## Documentation-Agent CLI + +The spawned researcher receives the exact installed CLI path and uses only these commands: + +```bash +context-room docs capabilities --root . +context-room docs search "session expiration" --status current --limit 8 --budget 1200 +context-room docs search "session expiration" --status proposal --session "$CODEX_THREAD_ID" +context-room docs read docs/authentication.md#expiration --budget 1600 +context-room docs related docs/authentication.md +context-room docs trace docs/authentication.md#expiration +``` + +- `capabilities` describes the current project's corpus, canonical subjects, truth states, sources, revisions, and available operations. +- `search` ranks exact documentation sections with deterministic lexical matching and returns compact snippets. A search without `--status proposal` excludes pending proposal material. +- `read` returns one document or section with its truth state, source, revision, line range, and hash. +- `related` follows declared sources, Markdown or HTML links, and incoming documentation references. +- `trace` exposes canonical ownership, verification date, references, health issues, revision, and content hash. + +The CLI indexes Markdown, MDX, text documentation, and semantic HTML exposed through the project's Context Room `allowedPaths`. Shared accepted documentation already mounted read-only by Context Room participates in the same corpus and keeps its accepted Git revision. + +When a task already owns shared proposals, Context Room adds their exact commits as a separate session overlay. Project and global proposals may coexist. Every pending document carries its repository path, proposal branch, head, base revision, task ID, title, latest agent recap, review state, conflict signal, and deletion state. Proposals from other sessions remain invisible. + +## Research Lifecycle + +Every `context ask` call starts a new non-interactive Codex process. Context Room does not resume an earlier research process. The task ID is used only to select the task's pending proposals. + +Before launch, the parent process resolves those proposals once and freezes their exact heads. The child receives that frozen manifest through its environment, so later `docs` commands cannot silently move to a newer proposal commit during the same answer. + +The invocation is equivalent to: + +```bash +codex \ + -C \ + --sandbox read-only \ + --ask-for-approval never \ + exec \ + --ephemeral \ + --ignore-user-config \ + --output-schema schemas/doc-context.schema.json \ + - +``` + +The child process reuses the local Codex authentication but does not persist its session. Network access is not enabled. The prompt tells the agent to treat working-file paths as search terms, use only the documentation CLI, and never inspect source code, tests, runtime configuration, Git history, or websites. + +## Context Packet + +`schemas/doc-context.schema.json` requires one stable result with: + +- summary; +- current facts; +- constraints; +- accepted decisions; +- target differences; +- pending changes from this session, kept explicitly non-canonical; +- unknowns and conflicts; +- optional deeper reads; +- examined paths and documentation revision. + +Every evidence item carries one exact path, section, truth state, revision, and 64-character content hash. Claims supported by several sections stay separate instead of joining their hashes. `targetDifferences` contains only differences supported by target documentation. Target, draft, historical, superseded, or proposal material must never be presented as current behavior. + +`pendingSessionChanges` is the only field allowed to cite a session proposal. Context Room validates each item against the frozen corpus and exact proposal head. Proposal paths, hashes, or truth states are rejected from `currentFacts`, `constraints`, `decisions`, and `targetDifferences`. Missing facts remain explicit unknowns, while `coverage.docsRevision` records the accepted local-plus-shared corpus; pending heads remain attached to their own evidence. + +The default output is compact Markdown for the working agent. `--json` exposes the schema-conformant packet directly. + +## Boundaries + +- `context-room docs` is deterministic and never calls a model. +- `context-room context ask` is the only surface in this feature that launches Codex. +- The researcher reads documentation only. A separate future role may research code. +- Research never edits documentation, creates proposals, or suggests changes to the documentation CLI. +- Documentation or CLI improvements happen during a separate documentation-update task and follow the normal local review or shared proposal workflow. +- The researcher cannot accept shared truth or bypass human review. +- Retrieved document text is evidence, not executable instruction. + +## Local, Shared, And Mixed Projects + +| Project mode | Accepted research corpus | Pending session overlay | +| --- | --- | --- | +| Local only | Local Context Room documentation | None | +| Shared only | Selected project's accepted docs and project skills plus accepted global skills | Same-session project and global proposals | +| Shared through a connected project identity | Accepted shared project docs and accepted global/project skills | Same-session project and global proposals | +| Local plus shared | Local docs plus accepted shared snapshot | Same-session project and global proposals | + +Local edits continue through the normal review queue. Context Room does not invent local proposal branches. The complete creation, audit, and consumption loop lives in [Documentation lifecycle](documentation-lifecycle.md). + +## Source Map + +- `src/doc_agent.mjs`: project resolution, documentation corpus, section retrieval, Codex prompt, invocation, validation, and packet rendering. +- `schemas/doc-context.schema.json`: final Codex response contract. +- `bin/context-room.mjs`: public `context ask` and internal `docs` command routing. +- `src/context_room.mjs`: allowed documentation files and graph metadata. +- `src/shared_context.mjs`: accepted shared snapshot freshness, task proposal resolution, and exact-head overlay documents. +- `test/doc_agent.test.mjs` and `test/shared_context.test.mjs`: corpus, retrieval, proposal isolation, frozen provenance, prompt boundary, ephemeral invocation, validation, and rendering coverage. diff --git a/docs/features/documentation-lifecycle.md b/docs/features/documentation-lifecycle.md new file mode 100644 index 0000000..e61f439 --- /dev/null +++ b/docs/features/documentation-lifecycle.md @@ -0,0 +1,76 @@ +--- +context_room: + kind: canonical + scope: context-room + status: current + canonical_for: documentation creation audit and consumption lifecycle + last_verified: 2026-07-23 + sources: [bin/context-room.mjs, src/doc_agent.mjs, src/shared_context.mjs, docs/features/documentation-agent.md, docs/features/shared-context.md] +--- + +# Documentation Lifecycle + +## Purpose + +Context Room separates three responsibilities so documentation can stay complete without letting agents make accepted truth by themselves. + +| Loop | Actor | Result | +| --- | --- | --- | +| Create or update | Working agent with the documentation-maintenance skill | Local review changes, a shared project proposal, a shared global proposal, or a combination | +| Audit | Scheduled documentation auditor | No change when clean; otherwise the same human-reviewable routes | +| Consume | Fresh read-only documentation researcher | A compact evidence packet for one working task | + +## 1. Create Or Update + +The maintenance skill first finds the canonical owner with `docs capabilities`, `search`, and `trace`. It applies the normal documentation quality rules, then routes each change independently: + +- local canonical owner: edit the local file and leave it in the normal Context Room review queue; +- shared project docs or skill: create or reuse a project proposal; +- shared global skill: create or reuse a global proposal; +- mixed change: use all required routes without copying one fact into several owners. + +If a durable fact is unresolved, the agent asks the user focused questions instead of writing an assumption. After the answer, it resumes the same workflow. + +For shared content, `shared propose` uses `CODEX_THREAD_ID` or `--session`. The identity is the shared repository, project or global scope, and task ID. A later message in the same task therefore returns the same open proposal worktree. A terminal accepted or merged proposal is not reopened; the next update creates a new one. + +Every publish replaces the proposal description with a complete current **agent recap**. The recap is temporary review context: it explains the latest cumulative intent before the owner reads the diffs, but it never becomes accepted project truth by itself. + +## 2. Audit + +The audit begins read-only and uses only accepted local and shared documentation. It checks internal coherence, canonical ownership, current-versus-target separation, references, and recent project-bound Codex tasks. + +Recent tasks are read through the official Codex task tools and selected by canonical project root or a verified worktree. Explicit later user decisions and corrections may justify a change. Brainstorming, agent speculation, missing evidence, and unrelated tasks do not. + +Each finding has one outcome: + +- certain durable inconsistency: invoke the maintenance workflow; +- ambiguous: report the smallest question and make no change; +- non-durable: ignore it. + +A clean audit creates nothing. The Codex desktop scheduler runs this loop for selected large projects; Context Room itself remains scheduler-independent. A scheduled shared audit uses a stable session such as `audit--nightly`, so repeated runs update one open proposal until a human completes it. + +## 3. Consume + +The working agent calls `context-room context ask` with its task and desired outcome. It may use the detected local project or explicitly target shared-only context with `--repository --project `. A new read-only Codex process uses only the deterministic documentation CLI, returns a schema-constrained packet, then exits. + +Accepted local and shared docs supply current facts. Shared-only research includes accepted project docs plus accepted project and global skills, without creating a local Context Room project. Same-session shared proposals may also be relevant after the task has updated documentation, but they stay in `pendingSessionChanges`, visibly marked **not merged**. Other sessions are excluded. The accepted shared revision and exact proposal heads are frozen for the full call. + +If the packet exposes a blocking unknown, the working agent asks the user. A durable answer then returns to loop 1. The documentation researcher itself never edits docs or improves the CLI. + +## Trust Boundary + +- Local review remains human-owned. +- Shared `main` and accepted snapshots remain read-only to agents. +- Agents may push only proposal branches; reviewed partial results go to `accepted/*` for a human pull-request merge. +- A proposal recap helps review but does not replace the diff. +- A scheduled audit may propose a correction but cannot accept or merge it. +- Only merged shared content and reviewed local content become normal accepted research input. + +## Skills + +The recommended shared global skills are: + +- `context-room-documentation`: create or update documentation through the correct local, shared project, shared global, or mixed route; +- `context-room-documentation-audit`: audit accepted documentation and recent project tasks, then invoke the maintenance skill only for certain durable inconsistencies. + +When accepted in a shared repository, Context Room links them from the immutable snapshot into `~/.codex/skills/`. Project-specific documentation skills use the same mechanism under `projects//skills/`. diff --git a/docs/features/hub-and-navigation.md b/docs/features/hub-and-navigation.md index e907c05..ccb795f 100644 --- a/docs/features/hub-and-navigation.md +++ b/docs/features/hub-and-navigation.md @@ -12,7 +12,7 @@ context_room: ## Purpose -The hub is the first screen for review-first work. It keeps the review queue visible before navigation and secondary context. +The project hub is the first screen for review-first work inside one isolated project. It keeps that project's review queue visible before navigation and secondary context. The computer-wide local/shared switcher is documented separately in [Context Hub](context-hub.md). ## Example Flow diff --git a/docs/features/index.md b/docs/features/index.md index e1aa599..b556a74 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-22 + sources: [README.md, docs/product-overview.md, bin/context-room.mjs, src/context_room.mjs, src/context_hub.mjs, src/doc_agent.mjs, src/shared_context.mjs, schemas/config.schema.json] --- # Features @@ -15,6 +15,7 @@ This folder explains Context Room by user-facing feature. Read this when changin ## Start Here - [Hub and navigation](hub-and-navigation.md) +- [Context Hub](context-hub.md) - [File explorer and editor](file-explorer-and-editor.md) - [HTML visual documents](html-visual-documents.md) - [Review queue](review-queue.md) @@ -23,7 +24,10 @@ This folder explains Context Room by user-facing feature. Read this when changin - [Startup hooks](startup-hooks.md) - [Settings](settings.md) - [Health, guard, and brief](health-guard-and-brief.md) +- [Documentation research agent](documentation-agent.md) +- [Documentation lifecycle](documentation-lifecycle.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..05721e5 --- /dev/null +++ b/docs/features/shared-context.md @@ -0,0 +1,248 @@ +--- +context_room: + kind: canonical + scope: context-room + status: current + canonical_for: shared context repositories + last_verified: 2026-07-22 + sources: [src/shared_context.mjs, src/context_hub.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 merged commit on the shared repository's default branch | Create and publish a `proposal/*` branch, review its exact commit in a dedicated Context Room, publish the selected result as `accepted/*`, then merge its pull request | + +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://unpkg.com/context-room@latest/schemas/shared-repository.schema.json", + "version": 1, + "name": "Company Shared Context", + "defaultBranch": "main", + "proposalPrefix": "proposal/", + "acceptancePrefix": "accepted/", + "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://unpkg.com/context-room@latest/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 and acceptance prefixes 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. Older version 1 manifests without `acceptancePrefix` use `accepted/`. + +## 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 preparing accepted branches 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" \ + --description "Clarify the owner-visible onboarding steps and their prerequisites." \ + --session "$CODEX_THREAD_ID" +``` + +The description is the current **agent recap** shown before the diffs. Keep it cumulative and replace it whenever the proposal changes. + +The command prints a proposal branch and a writable worktree path. With a task ID, it first looks for one open proposal with the same repository and project or global scope. It returns that worktree instead of creating a second proposal, including when the remote branch must be reattached on another local checkout. More than one matching open proposal is an explicit error. Accepted or merged proposals are terminal and are never reused. + +Make the documentation or skill changes inside the returned worktree, then publish the exact proposal: + +```bash +context-room shared publish \ + --root . \ + --proposal proposal/my-project/20260721120000-clarify-onboarding \ + --message "Clarify onboarding" +``` + +The proposal name and description are stored in the proposal commit, not only in local CLI state. When the agent changes an already published proposal, it must publish again with a current description: + +```bash +context-room shared publish \ + --root . \ + --proposal proposal/my-project/20260721120000-clarify-onboarding \ + --title "Clarify onboarding and prerequisites" \ + --description "Adds the missing prerequisite and updates the two owner-facing onboarding pages." \ + --message "Update onboarding proposal" +``` + +`--title` is optional during an update; `--description` is required. Context Room refuses an update without it, so the proposal inbox never silently keeps an older agent recap after the branch changes. + +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 current name and description, accepted-doc base, plus the source repository, branch, commit, and Codex task ID when those are available. `shared propose` reads `CODEX_THREAD_ID` automatically in Codex; `--session ` can attach an explicit identity in another agent runtime. This identity selects one open proposal per repository and project/global scope and lets Context Hub find it. It is metadata, not an authorization token. One task may legitimately own a project proposal and a separate global proposal. + +`--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. + +Every project room can open Context Hub, which aggregates every registered shared repository instead of only the repository connected to the current project. Its inbox links proposals to local projects when that relationship exists, while shared-only projects remain reviewable without a local folder. Search covers project and repository names, title, agent recap, changed paths, branch, author, commit hash, and linked Codex task ID. + +The same inbox also exposes local review work, but local files never become proposals. Opening a local item starts that project's isolated normal room and review queue. Opening a shared item creates the exact-hash proposal review described below. The overview labels the latest description as the agent recap and keeps the full task ID visible before the owner opens files. See [Context Hub](context-hub.md). + +Pressing **Open files to review** creates a dedicated exact-hash review server and worktree. The review is embedded below the proposal overview instead of replacing the cockpit URL. Several opened reviews remain mounted while the owner switches between them, so unsaved browser state is not discarded. Returning to the normal project context only hides the workspace. Reopening the same unchanged proposal reuses its exact review room; if the branch moves, Context Room presents the new hash as a separate review and the old review remains bound to the hash already examined. Local-only projects keep the existing UI without these controls. + +Context Hub records whether the current proposal hash is new, already opened, updated after an earlier review, accepted into a pull-request branch, or merged. If `main` advanced since the proposal base, the inbox shows the commit distance and a merge-conflict signal when Git can calculate it. + +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 **Prepare pull request** in the review room. The agent-facing CLI deliberately has no acceptance or merge 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. The cockpit makes this visible by showing the old exact hash and offering the branch's new hash as a separate review. + +An exact review authority is single-use after a successful acceptance. Reopen the proposal if another reviewed result is needed. + +Before publishing, Context Room fetches the latest default branch and applies only the reviewed result onto that newer commit. Unrelated merged 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 selected change remains, no commit is created. + +The result is committed and pushed to a unique `accepted//...` branch. `main` remains unchanged. For GitHub remotes, Context Room opens the compare page so the human can create and merge the pull request. The pull request diff is the final visibility layer for changes that arrived on `main` while the proposal was under review. + +## 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, accepted branch, human pull-request merge, then refresh of the read-only snapshot. + +The recommended global documentation-maintenance and audit skills are described in [Documentation lifecycle](documentation-lifecycle.md). They use the same links and proposal boundaries; they do not receive a separate path to accepted content. + +## Permission Boundary + +Context Room never pushes a shared review result to the default branch. Proposal publication writes `proposal/*`; partial acceptance writes `accepted/*`; only the Git host merges a pull request into `main`. + +For a GitHub shared repository, an owner runs this once from the shared repository or a connected project: + +```bash +context-room shared secure-github --root . +context-room shared security-check --root . +``` + +`secure-github` uses the authenticated GitHub CLI owner session to create or update an active repository ruleset for the configured default branch. It also creates a repository-specific writable deploy key under the user-local shared cache and configures the managed Git checkout to use only that key. The managed rule has no bypass actors, requires a pull request, blocks deletion and force-pushes, and requires review conversations to be resolved. It requires zero additional GitHub approvals because the owner already made the line-level decision in Context Room; merging the pull request remains a separate explicit human action. + +`security-check` reads the live GitHub rule, exits non-zero unless every required protection is present, and records the last successful remote check for `shared status`. Re-run it after repository or permission changes. If the GitHub plan does not support rulesets for that private repository, setup fails instead of claiming protection. + +The generated deploy key may push ordinary proposal and accepted branches, but GitHub rejects its direct push to `main`; it cannot administer repository rules or merge pull requests. Keep the GitHub owner browser/API credential and any GitHub connector with merge or administration permission outside the agent runtime: an agent that can operate either has crossed the owner boundary and cannot be constrained by Git branch policy alone. + +## 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..f0dd3ff 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-23 + sources: [README.md, bin/context-room.mjs, src/context_room.mjs, src/context_hub.mjs, src/doc_agent.mjs, src/shared_context.mjs, schemas/config.schema.json, schemas/doc-context.schema.json, schemas/shared-repository.schema.json, docs/agent-configuration.md] --- # Product Overview @@ -16,15 +16,19 @@ Context Room is a local browser UI for keeping project context visible, editable ## Product Loop -1. Run `context-room setup` to discover the project's documentation, write a project-aware map, and start an isolated room. +1. Run `context-room setup` for one isolated project room, or `context-room hub` for the global local-and-shared cockpit. 2. Use the truth-aware hub to find current docs, targets, records, and source areas that matter. 3. Edit safe text files inside `allowedPaths`. 4. Review watched changes from `watchAllow`, folder `watchRules`, and `reviewPaths`. -5. Run `doctor`, `guard`, or `brief` before handing work to an agent or committing. +5. Run `doctor`, `guard`, or `brief` for deterministic proof, or `context ask` when a working agent needs a task-specific documentation packet from the detected project or an explicit shared-only project target. +6. Route durable documentation updates through the local review queue or a task-scoped shared proposal; selected large projects may run a scheduled read-only-first audit. + +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`. +- Context Hub: one computer-local inbox and project switcher for local review queues and shared proposals, while each opened project keeps an isolated server and identity. - Explorer and editor: safe project text, with editing limited by `allowedPaths` and four explicit folder watch modes. - Changed files to review: Git-backed review queue, required review paths, project `AGENTS.md` files unless implicit review is disabled, and every skill exposed by Startup skills. - Startup context: project instruction files by default, with ancestor and global discovery available by opt-in. @@ -32,6 +36,9 @@ 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. +- Documentation research agent: a fresh read-only Codex researcher per request, backed by a deterministic section-level documentation CLI and a schema-constrained evidence packet. +- Documentation lifecycle: shared maintenance and audit skills, task-scoped proposal reuse, and explicit local/shared/mixed write routing. +- 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). @@ -41,12 +48,16 @@ Feature-level docs live in [Features](features/index.md). - Treat review as human-owned. Agents can surface the queue, but they should not mark docs verified for the user. - 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 documentation research isolated. `context-room context ask` may launch Codex, while every `context-room docs` command remains deterministic and read-only. +- Keep accepted and pending evidence separate. Same-task proposals may guide current work, but they never become current facts before human acceptance and merge. - 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 +67,27 @@ 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. +- `$HOME/.context-room/hub/registry.json`: local project and shared-repository catalog used by the global Context Hub. +- `/.context-room/shared-repository.json`: versioned contract for a shared repository's branch and path layout. +- `schemas/doc-context.schema.json`: structured evidence contract returned by the documentation research agent. ## 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/context_hub.mjs`: global project/shared-repository registration and single-Hub runtime discovery. +- `src/doc_agent.mjs`: documentation-only corpus, section retrieval, Codex researcher invocation, and evidence packet rendering. - `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. +- `schemas/doc-context.schema.json`: documentation research output 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. +- `test/doc_agent.test.mjs`: documentation corpus, retrieval, provenance, prompt boundaries, and Codex invocation tests. - `docs/agent-configuration.md`: detailed config guide. ## Development Loop diff --git a/examples/agentic-codebase.config.json b/examples/agentic-codebase.config.json index 3148270..9c39acc 100644 --- a/examples/agentic-codebase.config.json +++ b/examples/agentic-codebase.config.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/config.schema.json", + "$schema": "https://unpkg.com/context-room@latest/schemas/config.schema.json", "title": "Agentic Codebase", "allowedPaths": [ "README.md", diff --git a/package.json b/package.json index 0ee0224..49ec3f4 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,9 @@ { "name": "context-room", - "version": "0.1.9", + "version": "0.2.0", "description": "Local-first documentation control room for AI-assisted projects.", "type": "module", - "homepage": "https://github.com/Swarek/context-room#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/Swarek/context-room.git" - }, - "bugs": { - "url": "https://github.com/Swarek/context-room/issues" - }, + "homepage": "https://www.npmjs.com/package/context-room", "bin": { "context-room": "bin/context-room.mjs" }, @@ -32,6 +25,7 @@ "init": "node bin/context-room.mjs init", "doctor": "node bin/context-room.mjs doctor", "test": "node --check src/context_room.mjs && node --check bin/context-room.mjs && node --check scripts/update-context-rooms.mjs && node --test test/*.test.mjs", + "package:privacy": "node scripts/check-package-privacy.mjs", "update:rooms": "node scripts/update-context-rooms.mjs", "pack:dry-run": "npm pack --dry-run" }, @@ -46,7 +40,6 @@ "review-queue", "developer-tools" ], - "author": "Mathis Blanc", "license": "MIT", "dependencies": { "ws": "^8.21.1" diff --git a/schemas/config.schema.json b/schemas/config.schema.json index 6060934..51d631d 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/Swarek/context-room/main/schemas/config.schema.json", + "$id": "https://unpkg.com/context-room@latest/schemas/config.schema.json", "title": "Context Room project configuration", "description": "Configuration for the local Context Room documentation control room.", "type": "object", @@ -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/doc-context.schema.json b/schemas/doc-context.schema.json new file mode 100644 index 0000000..45b5aa1 --- /dev/null +++ b/schemas/doc-context.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://unpkg.com/context-room@latest/schemas/doc-context.schema.json", + "title": "Context Room documentation context packet", + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { + "type": "string" + }, + "currentFacts": { + "type": "array", + "items": { + "$ref": "#/$defs/evidence" + } + }, + "constraints": { + "type": "array", + "items": { + "$ref": "#/$defs/evidence" + } + }, + "decisions": { + "type": "array", + "items": { + "$ref": "#/$defs/evidence" + } + }, + "targetDifferences": { + "type": "array", + "items": { + "$ref": "#/$defs/evidence" + } + }, + "pendingSessionChanges": { + "type": "array", + "items": { + "$ref": "#/$defs/pendingEvidence" + } + }, + "unknowns": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + }, + "optionalReads": { + "type": "array", + "items": { + "$ref": "#/$defs/read" + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "properties": { + "project": { + "type": "string" + }, + "docsRevision": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "sourcesExamined": { + "type": "integer", + "minimum": 0 + }, + "pathsExamined": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["project", "docsRevision", "scope", "sourcesExamined", "pathsExamined"] + } + }, + "required": [ + "summary", + "currentFacts", + "constraints", + "decisions", + "targetDifferences", + "pendingSessionChanges", + "unknowns", + "conflicts", + "optionalReads", + "coverage" + ], + "$defs": { + "evidence": { + "type": "object", + "additionalProperties": false, + "properties": { + "claim": { + "type": "string" + }, + "path": { + "type": "string" + }, + "section": { + "type": "string" + }, + "truthState": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "contentHash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": ["claim", "path", "section", "truthState", "revision", "contentHash"] + }, + "read": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "section": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": ["path", "section", "reason"] + }, + "pendingEvidence": { + "type": "object", + "additionalProperties": false, + "properties": { + "claim": { "type": "string" }, + "path": { "type": "string", "pattern": "^_session-proposals/" }, + "repositoryPath": { "type": "string", "minLength": 1 }, + "section": { "type": "string" }, + "truthState": { "const": "proposal" }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40,64}$" }, + "contentHash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "deleted": { "type": "boolean" }, + "proposal": { + "type": "object", + "additionalProperties": false, + "properties": { + "branch": { "type": "string" }, + "head": { "type": "string", "pattern": "^[a-f0-9]{40,64}$" }, + "baseRevision": { "type": "string", "pattern": "^[a-f0-9]{40,64}$" }, + "sessionId": { "type": "string" }, + "projectId": { "type": "string" }, + "scope": { "enum": ["project", "global"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "reviewStatus": { "type": "string" }, + "hasConflict": { "type": "boolean" } + }, + "required": ["branch", "head", "baseRevision", "sessionId", "projectId", "scope", "title", "description", "reviewStatus", "hasConflict"] + } + }, + "required": ["claim", "path", "repositoryPath", "section", "truthState", "revision", "contentHash", "deleted", "proposal"] + } + } +} diff --git a/schemas/shared-projects.schema.json b/schemas/shared-projects.schema.json new file mode 100644 index 0000000..1716475 --- /dev/null +++ b/schemas/shared-projects.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://unpkg.com/context-room@latest/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..a878d86 --- /dev/null +++ b/schemas/shared-repository.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://unpkg.com/context-room@latest/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": "/$" }, + "acceptancePrefix": { "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/scripts/check-package-privacy.mjs b/scripts/check-package-privacy.mjs new file mode 100644 index 0000000..a040897 --- /dev/null +++ b/scripts/check-package-privacy.mjs @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const packed = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: packageRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, +}); + +if (packed.status !== 0) { + process.stderr.write(packed.stderr || packed.stdout || "Unable to inspect the npm package.\n"); + process.exit(packed.status || 1); +} + +let manifest; +try { + [manifest] = JSON.parse(packed.stdout); +} catch (error) { + process.stderr.write(`Unable to parse npm package manifest: ${error.message}\n`); + process.exit(1); +} + +const deniedTerms = String(process.env.CONTEXT_ROOM_PRIVACY_DENY || "") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +const findings = []; +const patterns = [ + { label: "absolute user-home path", expression: /(?:\/Users\/|\/home\/)[A-Za-z0-9._-]+|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+/g }, + { label: "email address", expression: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, ignore: (value) => value.toLowerCase().startsWith("git@") }, +]; + +for (const entry of manifest?.files || []) { + const relativePath = String(entry.path || "").replaceAll("\\", "/"); + const absolutePath = path.resolve(packageRoot, relativePath); + if (absolutePath !== packageRoot && !absolutePath.startsWith(packageRoot + path.sep)) { + findings.push(`${relativePath}: package path escapes the repository`); + continue; + } + let content; + try { content = fs.readFileSync(absolutePath, "utf8"); } catch { continue; } + for (const pattern of patterns) { + const matches = [...content.matchAll(pattern.expression)].filter((match) => !pattern.ignore?.(match[0])); + if (matches.length) findings.push(`${relativePath}: ${pattern.label}`); + } + const lowered = content.toLowerCase(); + for (const term of deniedTerms) { + if (lowered.includes(term)) findings.push(`${relativePath}: denied release term`); + } +} + +if (findings.length) { + process.stderr.write(`Package privacy check failed:\n${[...new Set(findings)].map((item) => `- ${item}`).join("\n")}\n`); + process.exit(1); +} + +process.stdout.write(`Package privacy check OK (${manifest.files.length} files).\n`); diff --git a/src/context_hub.mjs b/src/context_hub.mjs new file mode 100644 index 0000000..daa58c9 --- /dev/null +++ b/src/context_hub.mjs @@ -0,0 +1,221 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +export const CONTEXT_HUB_REGISTRY_VERSION = 1; + +function hubHome() { + return process.env.CONTEXT_ROOM_HUB_HOME + ? path.resolve(process.env.CONTEXT_ROOM_HUB_HOME) + : path.join(process.env.HOME || os.homedir(), ".context-room", "hub"); +} + +export function contextHubHostRoot() { + return path.join(hubHome(), "host"); +} + +function registryPath() { + return path.join(hubHome(), "registry.json"); +} + +function runtimePath() { + return path.join(hubHome(), "runtime.json"); +} + +function readJson(filePath, fallback = null) { + if (!fs.existsSync(filePath)) return fallback; + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, value) { + const directory = path.dirname(filePath); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + try { + fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); + } finally { + if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath); + } + fs.chmodSync(filePath, 0o600); + return value; +} + +function stableRoot(root) { + const resolved = path.resolve(root); + try { return fs.realpathSync(resolved); } catch { return resolved; } +} + +function stableProjectId(root) { + return createHash("sha256").update(stableRoot(root)).digest("hex").slice(0, 24); +} + +function cleanTitle(value, fallback) { + return String(value || "").trim().slice(0, 160) || fallback; +} + +function projectTitle(root) { + const fallback = path.basename(root) || "Local project"; + try { + const config = readJson(path.join(root, ".context-room", "config.json"), {}); + return cleanTitle(config.title, fallback); + } catch { + return fallback; + } +} + +function cleanRepository(value) { + const repository = String(value || "").trim(); + if (!repository || /[\u0000\r\n]/.test(repository)) throw new Error("Shared repository URL is required"); + return repository; +} + +function normalizedRegistry(raw = {}) { + const projects = Array.isArray(raw.projects) ? raw.projects.flatMap((entry) => { + try { + const root = stableRoot(entry.root); + const registeredAt = String(entry.registeredAt || new Date().toISOString()); + return [{ + id: stableProjectId(root), + root, + title: cleanTitle(entry.title, projectTitle(root)), + registeredAt, + lastOpenedAt: String(entry.lastOpenedAt || registeredAt), + shared: entry.shared && typeof entry.shared === "object" && entry.shared.repository && entry.shared.projectId ? { + repository: cleanRepository(entry.shared.repository), + projectId: String(entry.shared.projectId).trim(), + } : null, + }]; + } catch { + return []; + } + }) : []; + const sharedRepositories = Array.isArray(raw.sharedRepositories) ? raw.sharedRepositories.flatMap((entry) => { + try { + return [{ + repository: cleanRepository(entry.repository || entry), + addedAt: String(entry.addedAt || new Date().toISOString()), + }]; + } catch { + return []; + } + }) : []; + return { + version: CONTEXT_HUB_REGISTRY_VERSION, + projects: [...new Map(projects.map((entry) => [entry.id, entry])).values()], + sharedRepositories: [...new Map(sharedRepositories.map((entry) => [entry.repository, entry])).values()], + }; +} + +export function readContextHubRegistry() { + return normalizedRegistry(readJson(registryPath(), {})); +} + +export function registerContextHubSharedRepository(repository) { + const safeRepository = cleanRepository(repository); + const registry = readContextHubRegistry(); + const existing = registry.sharedRepositories.find((entry) => entry.repository === safeRepository); + registry.sharedRepositories = [ + ...registry.sharedRepositories.filter((entry) => entry.repository !== safeRepository), + { repository: safeRepository, addedAt: existing?.addedAt || new Date().toISOString() }, + ]; + writeJson(registryPath(), registry); + return registry.sharedRepositories.at(-1); +} + +export function registerContextHubProject(root, { title = "", shared = null } = {}) { + const projectRoot = stableRoot(root); + const configPath = path.join(projectRoot, ".context-room", "config.json"); + if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) throw new Error(`Context Hub project root does not exist: ${projectRoot}`); + if (!fs.existsSync(configPath)) throw new Error(`Context Hub project is not initialized: ${configPath}`); + const registry = readContextHubRegistry(); + const id = stableProjectId(projectRoot); + const existing = registry.projects.find((entry) => entry.id === id); + const entry = { + id, + root: projectRoot, + title: cleanTitle(title, projectTitle(projectRoot)), + registeredAt: existing?.registeredAt || new Date().toISOString(), + lastOpenedAt: new Date().toISOString(), + shared: shared?.repository && shared?.projectId ? { + repository: cleanRepository(shared.repository), + projectId: String(shared.projectId).trim(), + } : existing?.shared || null, + }; + registry.projects = [...registry.projects.filter((project) => project.id !== id), entry]; + if (entry.shared) { + const existingRepository = registry.sharedRepositories.find((item) => item.repository === entry.shared.repository); + registry.sharedRepositories = [ + ...registry.sharedRepositories.filter((item) => item.repository !== entry.shared.repository), + { repository: entry.shared.repository, addedAt: existingRepository?.addedAt || new Date().toISOString() }, + ]; + } + writeJson(registryPath(), registry); + return entry; +} + +export function listContextHubProjects() { + const registry = readContextHubRegistry(); + return registry.projects.map((entry) => { + let available = false; + try { + available = fs.statSync(entry.root).isDirectory() + && fs.existsSync(path.join(entry.root, ".context-room", "config.json")); + } catch {} + return { + ...entry, + available, + title: available ? projectTitle(entry.root) : entry.title, + }; + }).sort((left, right) => { + if (left.available !== right.available) return left.available ? -1 : 1; + return String(right.lastOpenedAt).localeCompare(String(left.lastOpenedAt)); + }); +} + +export function recordContextHubProjectOpened(projectId) { + const registry = readContextHubRegistry(); + const project = registry.projects.find((entry) => entry.id === projectId); + if (!project) throw new Error(`Unknown Context Hub project: ${projectId}`); + project.lastOpenedAt = new Date().toISOString(); + writeJson(registryPath(), registry); + return project; +} + +export function readContextHubRuntime() { + const runtime = readJson(runtimePath(), null); + if (!runtime || !Number.isInteger(Number(runtime.port)) || !runtime.url) return null; + const port = Number(runtime.port); + if (port < 1 || port > 65535) return null; + return { + pid: Number(runtime.pid) || null, + port, + root: runtime.root ? stableRoot(runtime.root) : "", + url: `http://127.0.0.1:${port}`, + startedAt: String(runtime.startedAt || ""), + }; +} + +export function writeContextHubRuntime({ pid = process.pid, port, root, url } = {}) { + return writeJson(runtimePath(), { + version: 1, + pid: Number(pid), + port: Number(port), + root: stableRoot(root), + url: String(url), + startedAt: new Date().toISOString(), + }); +} + +export function clearContextHubRuntime(pid = process.pid) { + const runtime = readContextHubRuntime(); + if (!runtime || (pid && runtime.pid && Number(pid) !== runtime.pid)) return false; + try { + fs.unlinkSync(runtimePath()); + return true; + } catch { + return false; + } +} diff --git a/src/context_room.mjs b/src/context_room.mjs index 0863aee..d3a01d6 100644 --- a/src/context_room.mjs +++ b/src/context_room.mjs @@ -15,6 +15,25 @@ import { insertFileReferenceIntoActiveCodexComposer, insertIntoActiveCodexComposer, } from "./codex_composer_bridge.mjs"; +import { + SHARED_REVIEW_CONFIG, + acceptSharedReview, + listRegisteredSharedRepositories, + listSharedRepositoryProposals, + listSharedProposals, + materializeSharedRepositoryReview, + materializeSharedReview, + readSharedProjectConnection, + readSharedReview, + sharedContextStatus, + syncSharedContext, +} from "./shared_context.mjs"; +import { + listContextHubProjects, + readContextHubRegistry, + recordContextHubProjectOpened, + registerContextHubProject, +} from "./context_hub.mjs"; export { DOC_METADATA_KINDS, DOC_METADATA_STATUSES, parseDocMetadata } from "./doc_metadata.mjs"; @@ -41,9 +60,10 @@ 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"; +const CONFIG_SCHEMA_URL = "https://unpkg.com/context-room@latest/schemas/config.schema.json"; const DOCQA_REVIEW_STATE = `${CONFIG_DIR}/review-state.json`; const DOCQA_REVIEW_BASELINES = `${CONFIG_DIR}/review-baselines`; const DOCQA_GLOBAL_REVIEW_LEDGER = `${CONFIG_DIR}/review-ledger.json`; @@ -209,6 +229,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, @@ -410,7 +432,7 @@ const DEFAULT_HUB_CARD_VISIBILITY = Object.fromEntries(DEFAULT_HUB_CARDS.map((ca const HERMES_MEMORY_FILES = [ { path: "~/.hermes/memories/USER.md", - label: "Hermes memory · Mathis", + label: "Hermes memory · user", category: "1 · injected by Hermes", impact: "Global Hermes user memory. Injected into sessions when user memory is active.", }, @@ -433,7 +455,7 @@ const HERMES_CRON_FILES = [ const CORE_FILES = [ { path: ".hermes.md", label: "Hermes · project context", category: "1 · injected by Hermes", impact: "Project context loaded automatically when Hermes works in this repo. This is the short Context Room map." }, - { path: "memory/USER.md", label: "Project context · Mathis", category: "3 · Project folders", impact: "Short user memory in the Context Room repo. Separate from global Hermes memory." }, + { path: "memory/USER.md", label: "Project context · user", category: "3 · Project folders", impact: "Short user memory in the Context Room repo. Separate from global Hermes memory." }, { path: "memory/MEMORY.md", label: "Project context · system", category: "3 · Project folders", impact: "Short system memory in the Context Room repo. Separate from global Hermes memory." }, { path: "memory/budget.md", label: "Memory budgets", category: "3 · Project folders", impact: "Size limits for Project context files." }, { path: "memory/index.csv", label: "Memory index", category: "4 · indexes & registers", impact: "Navigation index for memory files." }, @@ -577,6 +599,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 +743,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 +753,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 +838,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 +856,7 @@ export function readMemoryFile(root, relPath) { updatedAt: stats.mtime.toISOString(), chars: content.length, contentHash: hashContent(content), - readOnly: !allowed, + readOnly: !allowed || configuredReadOnly, }; } @@ -1804,7 +1844,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 +1889,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 +2096,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 +2438,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 +2457,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 +3711,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 +4989,7 @@ export function createDefaultProjectConfig({ title = "Context Room", allowedPath title, projectOnly: true, allowedPaths: sanitizePathList(allowedPaths), + readOnlyPaths: [], watchAllow: sanitizePathList(watchAllow), watchRules: [], reviewPaths: [], @@ -5073,12 +5125,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 +5580,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 +5712,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 +6230,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,14 +6980,246 @@ 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 contextHubRepositoryId(repository) { + return createHash("sha256").update(String(repository || "")).digest("hex").slice(0, 16); +} + +function contextHubLocalProjectSummary(project, currentRoot) { + let queue = []; + let queueError = ""; + if (project.available) { + try { + queue = buildAgentReviewQueue(project.root).queue || []; + } catch (error) { + queueError = error.message; + } + } + return { + ...project, + current: project.available && safeRealPath(project.root) === safeRealPath(currentRoot), + localReviewCount: queue.length, + localReviewFiles: queue.slice(0, 12).map((item) => item.path), + localReviewError: queueError, + }; +} + +export function contextHubUiState(root) { + const currentRoot = path.resolve(root); + const localProjects = listContextHubProjects().map((project) => contextHubLocalProjectSummary(project, currentRoot)); + const registry = readContextHubRegistry(); + const repositories = [...new Set([ + ...registry.sharedRepositories.map((entry) => entry.repository), + ...listRegisteredSharedRepositories(), + ...localProjects.flatMap((project) => project.shared?.repository ? [project.shared.repository] : []), + ])]; + const sharedRepositories = []; + const proposals = []; + const repositoryErrors = []; + for (const repository of repositories) { + try { + const shared = listSharedRepositoryProposals(repository, { allowOffline: true }); + const repositoryId = contextHubRepositoryId(repository); + sharedRepositories.push({ + id: repositoryId, + repository, + name: shared.repositoryName, + status: shared.status, + projects: shared.projects, + }); + for (const proposal of shared.proposals) { + proposals.push({ + ...proposal, + id: `proposal:${repositoryId}:${proposal.branch}`, + type: "shared", + repositoryId, + repository, + repositoryName: shared.repositoryName, + projectKey: `shared:${repositoryId}:${proposal.projectId}`, + }); + } + } catch (error) { + repositoryErrors.push({ repository, error: error.message }); + } + } + const projects = localProjects.map((project) => { + const sharedRepository = project.shared + ? sharedRepositories.find((repository) => repository.repository === project.shared.repository) + : null; + const sharedProject = sharedRepository?.projects.find((item) => item.id === project.shared?.projectId) || null; + const linkedProposals = project.shared + ? proposals.filter((proposal) => proposal.repository === project.shared.repository && proposal.projectId === project.shared.projectId) + : []; + return { + ...project, + projectKey: `local:${project.id}`, + mode: project.shared ? "hybrid" : "local", + sharedTitle: sharedProject?.title || "", + sharedProposalCount: linkedProposals.length, + }; + }); + for (const repository of sharedRepositories) { + for (const sharedProject of repository.projects) { + const linkedLocal = projects.find((project) => project.shared?.repository === repository.repository && project.shared?.projectId === sharedProject.id); + const proposalCount = proposals.filter((proposal) => proposal.repository === repository.repository && proposal.projectId === sharedProject.id).length; + const sharedKey = `shared:${repository.id}:${sharedProject.id}`; + for (const proposal of proposals.filter((item) => item.projectKey === sharedKey)) { + if (linkedLocal) proposal.projectKey = linkedLocal.projectKey; + } + if (linkedLocal) continue; + projects.push({ + id: sharedKey, + projectKey: sharedKey, + root: "", + title: sharedProject.title || sharedProject.id, + available: false, + current: false, + mode: "shared", + shared: { repository: repository.repository, projectId: sharedProject.id }, + sharedTitle: sharedProject.title || sharedProject.id, + sharedProposalCount: proposalCount, + localReviewCount: 0, + localReviewFiles: [], + localReviewError: "", + }); + } + } + for (const proposal of proposals) { + if (projects.some((project) => project.projectKey === proposal.projectKey)) continue; + const scopedProposals = proposals.filter((item) => item.projectKey === proposal.projectKey); + projects.push({ + id: proposal.projectKey, + projectKey: proposal.projectKey, + root: "", + title: proposal.projectTitle || (proposal.projectId === "global" ? "Global skills" : proposal.projectId), + available: false, + current: false, + mode: "shared", + shared: { repository: proposal.repository, projectId: proposal.projectId }, + sharedTitle: proposal.projectTitle || proposal.projectId, + sharedProposalCount: scopedProposals.length, + localReviewCount: 0, + localReviewFiles: [], + localReviewError: "", + }); + } + const localItems = projects.filter((project) => project.mode !== "shared").map((project) => ({ + id: `local:${project.id}`, + type: "local", + projectKey: project.projectKey, + projectId: project.id, + title: project.title, + description: project.localReviewCount + ? `${project.localReviewCount} local file${project.localReviewCount === 1 ? "" : "s"} waiting for the normal project review.` + : "The local project is connected and its review queue is clear.", + files: project.localReviewFiles, + fileCount: project.localReviewCount, + reviewStatus: project.available ? (project.localReviewCount ? "local_changes" : "clean") : "unavailable", + updatedAt: project.lastOpenedAt, + current: project.current, + available: project.available, + root: project.root, + shared: project.shared, + })); + const items = [...proposals, ...localItems].sort((left, right) => { + const rank = { updated: 0, ready: 1, local_changes: 1, in_review: 2, accepted: 3, clean: 4, merged: 5, unavailable: 6 }; + const statusRank = (rank[left.reviewStatus] ?? 4) - (rank[right.reviewStatus] ?? 4); + return statusRank || String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")); + }); + return { + enabled: true, + generatedAt: new Date().toISOString(), + currentProjectId: contextRoomProjectId(currentRoot), + projects, + sharedRepositories, + proposals, + items, + repositoryErrors, + summary: { + projects: projects.length, + localProjects: projects.filter((project) => project.mode !== "shared").length, + sharedProjects: projects.filter((project) => project.mode !== "local").length, + sharedRepositories: sharedRepositories.length, + proposals: proposals.length, + localReviews: localItems.reduce((total, item) => total + item.fileCount, 0), + }, + }; +} + +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, globalPreferencesPath = null, codexComposerInsert = insertIntoActiveCodexComposer, codexReferenceInsert = insertFileReferenceIntoActiveCodexComposer, + registerInHub = false, } = {}) { const projectId = contextRoomProjectId(root); + if (registerInHub && !process.env.NODE_TEST_CONTEXT && !fs.existsSync(path.join(path.resolve(root), SHARED_REVIEW_CONFIG))) { + try { + const connection = readSharedProjectConnection(root); + registerContextHubProject(root, { shared: connection ? { repository: connection.repository, projectId: connection.projectId } : null }); + } catch {} + } + const sharedReviewServers = new Set(); + const sharedReviewRooms = new Map(); + const contextHubProjectServers = new Set(); + const contextHubProjectRooms = new Map(); ensureBackgroundWorker(root, "files"); void readBackgroundReports(root).catch(() => {}); const lastSelectedPath = normalizeRelPath(readCollaborationSessionState(root).selectedPath || ""); @@ -6955,14 +7249,89 @@ export function createMemoryServer({ return; } try { - await routeRequest(req, res, root, globalPreferencesPath, { codexComposerInsert, codexReferenceInsert }); + await routeRequest(req, res, root, globalPreferencesPath, { + codexComposerInsert, + codexReferenceInsert, + startSharedReview: async ({ proposal, repository = "" }) => { + 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 remoteState = repository + ? listSharedRepositoryProposals(repository, { allowOffline: false }) + : { repository: readSharedProjectConnection(root)?.repository || "", proposals: listSharedProposals(root) }; + const remoteProposal = remoteState.proposals.find((item) => item.branch === proposal); + if (!remoteProposal) throw sharedRequestError(`Remote proposal not found: ${proposal}`, 404, "shared_context_proposal_not_found"); + const reviewRepository = repository || remoteState.repository || remoteProposal.repository; + const reviewKey = `${reviewRepository}\0${remoteProposal.branch}@${remoteProposal.head}`; + const existing = sharedReviewRooms.get(reviewKey); + if (existing) return existing; + const result = repository + ? materializeSharedRepositoryReview(repository, { proposal }) + : 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, registerInHub: false }); + await listenContextRoomServer(reviewRoom.server, reviewPort); + sharedReviewServers.add(reviewRoom.server); + reviewRoom.server.once("close", () => sharedReviewServers.delete(reviewRoom.server)); + const opened = { + ...result, + port: reviewPort, + url: `http://127.0.0.1:${reviewPort}`, + }; + sharedReviewRooms.set(reviewKey, opened); + reviewRoom.server.once("close", () => sharedReviewRooms.delete(reviewKey)); + return opened; + }, + startContextHubProject: async (requestedProjectId) => { + const project = listContextHubProjects().find((item) => item.id === requestedProjectId); + if (!project) throw sharedRequestError(`Unknown local project: ${requestedProjectId}`, 404, "context_hub_project_not_found"); + if (!project.available) throw sharedRequestError(`Local project is unavailable: ${project.root}`, 409, "context_hub_project_unavailable"); + recordContextHubProjectOpened(project.id); + if (safeRealPath(project.root) === safeRealPath(root)) { + return { current: true, project, port, url: "" }; + } + if (readSharedProjectConnection(project.root)) syncSharedContext(project.root, { allowOffline: true }); + const existing = contextHubProjectRooms.get(project.id); + if (existing) return existing; + const projectPort = await selectAvailableContextRoomPort(DEFAULT_PORT, { allowFallback: true }); + const projectRoom = createMemoryServer({ root: project.root, port: projectPort, globalPreferencesPath, registerInHub: false }); + await listenContextRoomServer(projectRoom.server, projectPort); + contextHubProjectServers.add(projectRoom.server); + projectRoom.server.once("close", () => contextHubProjectServers.delete(projectRoom.server)); + const opened = { + current: false, + project, + port: projectPort, + url: `http://127.0.0.1:${projectPort}`, + }; + contextHubProjectRooms.set(project.id, opened); + projectRoom.server.once("close", () => contextHubProjectRooms.delete(project.id)); + return opened; + }, + }); } 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(); + sharedReviewRooms.clear(); + for (const projectServer of contextHubProjectServers) projectServer.close(); + contextHubProjectServers.clear(); + contextHubProjectRooms.clear(); stopBackgroundWatch(); closeBackgroundWorkers(root); clearBackgroundCacheState(root); @@ -6973,6 +7342,8 @@ export function createMemoryServer({ async function routeRequest(req, res, root, globalPreferencesPath = null, { codexComposerInsert = insertIntoActiveCodexComposer, codexReferenceInsert = insertFileReferenceIntoActiveCodexComposer, + startSharedReview = null, + startContextHubProject = null, } = {}) { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); @@ -6980,6 +7351,86 @@ 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 === "GET" && url.pathname === "/api/context-hub") { + sendJson(res, 200, contextHubUiState(root)); + return; + } + if (req.method === "POST" && url.pathname === "/api/context-hub/refresh") { + sendJson(res, 200, contextHubUiState(root)); + return; + } + if (req.method === "POST" && url.pathname === "/api/context-hub/project") { + const body = await readJsonBody(req); + const requestedProjectId = String(body.projectId || "").trim(); + if (!requestedProjectId) throw sharedRequestError("projectId is required", 400, "context_hub_project_required"); + if (!startContextHubProject) throw sharedRequestError("Local project opening is unavailable", 503, "context_hub_project_open_unavailable"); + const result = await startContextHubProject(requestedProjectId); + sendJson(res, 201, result); + return; + } + if (req.method === "POST" && url.pathname === "/api/context-hub/review") { + const body = await readJsonBody(req); + const proposal = String(body.proposal || "").trim(); + const repository = String(body.repository || "").trim(); + if (!proposal) throw sharedRequestError("proposal is required", 400, "shared_context_proposal_required"); + if (!repository) throw sharedRequestError("repository is required", 400, "shared_context_repository_required"); + if (!startSharedReview) throw sharedRequestError("Shared proposal review is unavailable", 503, "shared_context_review_unavailable"); + const result = await startSharedReview({ proposal, repository }); + 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/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 +9252,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 +9366,153 @@ 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); } + .workspace-chrome { min-width: 0; display: grid; gap: 6px; } + .shared-context-controls { + min-width: 0; overflow: hidden; + display: grid; grid-template-columns: minmax(132px, 0.5fr) minmax(260px, 1.6fr) auto; + align-items: center; gap: 8px; padding: 7px; + border: 1px solid var(--line); border-radius: 8px; + background: var(--surface-floating-soft); + } + .shared-context-controls[hidden] { display: none !important; } + .shared-context-heading { min-width: 0; min-height: 42px; display: flex; align-items: center; gap: 8px; padding: 0 8px; } + .shared-context-indicator { + width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--good); + } + .shared-context-controls[data-online="false"] .shared-context-indicator { background: var(--danger); } + .shared-context-heading-copy { min-width: 0; display: grid; gap: 2px; } + .shared-context-heading-copy strong { color: var(--label-strong); font-size: 12px; line-height: 1.15; font-weight: 800; letter-spacing: -0.01em; } + .shared-context-heading .shared-context-label { display: block; min-width: 0; color: var(--muted); font: 9px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .shared-context-picker { position: relative; min-width: 0; } + .shared-context-picker > span { + position: absolute; z-index: 1; top: 7px; left: 12px; pointer-events: none; + color: var(--muted); font-size: 8px; line-height: 1; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; + } + .shared-context-picker:has(.shared-context-select[hidden]) { display: none; } + .shared-context-select { + width: 100%; min-width: 0; min-height: 44px; padding: 18px 34px 5px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 6px; + background: var(--panel-strong); color: var(--text); + font: 600 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + transition: border-color 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 180ms cubic-bezier(0.22, 1, 0.36, 1); + } + .shared-context-select:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--line)); background: color-mix(in srgb, var(--panel-strong) 94%, var(--accent) 6%); } + .shared-context-select:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent); outline-offset: 2px; } + .shared-context-actions { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; } + .shared-context-actions .dock-button { min-height: 40px; padding: 0 10px; white-space: nowrap; } + .shared-context-actions .dock-button[hidden] { display: none !important; } + .shared-context-actions .dock-button.primary { padding-inline: 13px; } + .shared-context-actions #sharedProposalBrowser, .shared-context-actions .shared-context-refresh { + border-color: transparent; background: transparent; color: var(--muted); + } + .shared-context-actions #sharedProposalBrowser:hover, .shared-context-actions .shared-context-refresh:hover { + background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--text); + } + .shared-context-actions .shared-context-refresh { width: 36px; min-width: 36px; padding: 0; font-size: 17px; } + .shared-context-actions .dock-button:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent); outline-offset: 2px; } + .shared-context-controls[data-mode="review"] { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } + .shared-context-controls[data-mode="review"] .shared-context-actions { grid-column: 2; } + @media (max-width: 760px) { + .shared-context-controls { grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 7px; } + .shared-context-picker { grid-column: 1 / -1; grid-row: 2; } + .shared-context-actions { grid-column: 2; grid-row: 1; } + } + @media (max-width: 500px) { + .shared-context-controls { grid-template-columns: 1fr; gap: 6px; padding: 7px; } + .shared-context-controls[data-mode="review"] { grid-template-columns: 1fr; } + .shared-context-heading, .shared-context-picker, .shared-context-actions { grid-column: 1; grid-row: auto; } + .shared-context-controls[data-mode="review"] .shared-context-actions { grid-column: 1; } + .shared-context-actions { display: grid; grid-template-columns: minmax(0, 1fr) 36px minmax(0, 1fr); width: 100%; } + .shared-context-actions .dock-button { min-width: 0; } + .shared-context-heading { min-height: 36px; padding: 0 5px; } + } + .shared-proposal-workspace { position: fixed; inset: 0; z-index: 80; display: grid; grid-template-rows: auto minmax(0, 1fr); background: var(--bg); color: var(--text); } + .shared-proposal-workspace[hidden] { display: none !important; } + .context-hub-open { color: var(--text); background: color-mix(in srgb, var(--accent) 9%, transparent); } + .context-hub-open::before { content: "⌘"; margin-right: 6px; color: var(--accent); font-weight: 800; } + .shared-proposal-workspace-head { display: grid; grid-template-columns: auto minmax(190px, .7fr) auto minmax(240px, 1.2fr) minmax(150px, .55fr) minmax(150px, .55fr) auto; align-items: center; gap: 8px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel-strong); } + .shared-proposal-workspace-title { min-width: 0; } + .shared-proposal-workspace-title strong { display: block; font-size: 15px; } + .shared-proposal-workspace-title span { display: block; margin-top: 2px; color: var(--muted); font-size: 10px; line-height: 1.25; } + .context-hub-views { display: inline-flex; align-items: center; min-height: 36px; padding: 3px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-sidebar); } + .context-hub-view { min-height: 28px; padding: 0 10px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 700; cursor: pointer; } + .context-hub-view:hover { color: var(--text); } + .context-hub-view.active { background: var(--panel-strong); color: var(--text); box-shadow: 0 3px 10px rgba(0,0,0,.18); } + .context-hub-view:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); outline-offset: 2px; } + .shared-proposal-search { width: 100%; min-width: 0; min-height: 36px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); } + .shared-proposal-search::placeholder { color: color-mix(in srgb, var(--muted) 92%, var(--text)); } + .shared-proposal-filter { width: 100%; min-width: 0; min-height: 36px; padding: 6px 28px 6px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); } + .shared-proposal-workspace-body { min-height: 0; display: grid; grid-template-columns: minmax(350px, 430px) minmax(0, 1fr); } + .shared-proposal-list-panel { position: relative; inset: auto; z-index: auto; width: auto; height: auto; min-height: 0; max-height: none; overflow: auto; border-right: 1px solid var(--line); background: var(--surface-sidebar); transform: none; } + .context-hub-list-status { position: sticky; top: 0; z-index: 2; min-height: 34px; padding: 9px 14px; border-bottom: 1px solid var(--line); background: var(--surface-sidebar); color: var(--muted); font-size: 10px; line-height: 1.4; } + .context-hub-list-status[data-error="true"] { color: var(--danger); } + .shared-proposal-list { display: grid; } + .context-hub-group-title { position: sticky; top: 34px; z-index: 1; padding: 8px 14px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-sidebar) 96%, var(--panel)); color: var(--muted); font-size: 10px; font-weight: 780; } + .shared-proposal-card { width: 100%; padding: 14px 16px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); text-align: left; cursor: pointer; } + .shared-proposal-card:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } + .shared-proposal-card.active { background: color-mix(in srgb, var(--accent) 11%, var(--surface-sidebar)); } + .shared-proposal-card[data-source="local"].active { background: color-mix(in srgb, var(--good) 9%, var(--surface-sidebar)); } + .shared-proposal-card:focus-visible { position: relative; outline: 2px solid color-mix(in srgb, var(--accent) 62%, transparent); outline-offset: -3px; } + .shared-proposal-card-top { display: flex; align-items: center; gap: 7px; } + .context-hub-source { display: inline-flex; align-items: center; min-height: 21px; padding: 2px 7px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); font-size: 9px; line-height: 1.2; font-weight: 800; white-space: nowrap; } + .context-hub-source[data-source="local"] { background: color-mix(in srgb, var(--good) 13%, transparent); color: color-mix(in srgb, var(--good) 82%, white); } + .shared-proposal-project { padding: 2px 6px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); font: 10px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace; } + .shared-proposal-card-title { display: block; margin-top: 9px; font-size: 14px; line-height: 1.3; font-weight: 720; } + .shared-proposal-card-description { display: -webkit-box; margin-top: 5px; overflow: hidden; color: var(--text-soft); font-size: 11px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + .shared-proposal-card-files { display: grid; gap: 3px; margin-top: 9px; } + .shared-proposal-card-file { display: block; min-width: 0; overflow: hidden; color: var(--muted); font: 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } + .shared-proposal-card-meta, .shared-proposal-card-identity { display: block; margin-top: 5px; color: var(--muted); font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } + .shared-proposal-card-state { margin-left: auto; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } + .shared-proposal-card-state[data-state="updated"], .shared-proposal-card-state[data-state="local_changes"] { color: var(--accent-2); } + .shared-proposal-card-state[data-state="accepted"], .shared-proposal-card-state[data-state="merged"], .shared-proposal-card-state[data-state="clean"] { color: var(--good); } + .shared-proposal-card-state[data-state="unavailable"] { color: var(--danger); } + .shared-proposal-empty { padding: 18px 10px; color: var(--muted); text-align: center; } + .shared-proposal-review-panel { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(220px, 1fr); background: var(--bg); } + .shared-proposal-overview { min-width: 0; padding: 18px 22px 16px; border-bottom: 1px solid var(--line); background: var(--panel); } + .shared-proposal-overview[hidden] { display: none !important; } + .shared-proposal-overview-top { display: flex; align-items: center; gap: 8px; } + .shared-proposal-overview-state { color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; } + .shared-proposal-overview h2 { margin: 10px 0 0; color: var(--text); font-size: 20px; line-height: 1.2; letter-spacing: -0.02em !important; } + .shared-proposal-recap-label { margin: 12px 0 0; color: var(--muted); font: 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.08em; text-transform: uppercase; } + .shared-proposal-description { max-width: 72ch; margin: 7px 0 0; color: var(--text-soft); font-size: 12px; line-height: 1.5; white-space: pre-wrap; } + .shared-proposal-overview-meta { display: flex; flex-wrap: wrap; gap: 5px 14px; margin-top: 11px; color: var(--muted); font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; } + .context-hub-relationship { display: flex; flex-wrap: wrap; gap: 6px; max-width: 74ch; margin-top: 11px; color: var(--text-soft); font-size: 11px; line-height: 1.45; } + .context-hub-relationship:empty { display: none; } + .context-hub-relationship span { padding: 6px 8px; border-radius: 6px; background: var(--surface-floating-soft); } + .shared-proposal-overview-actions { display: flex; align-items: center; gap: 10px; margin-top: 13px; } + .shared-proposal-overview-actions .primary { min-height: 36px; } + .shared-proposal-change-summary { color: var(--muted); font-size: 11px; } + .shared-proposal-files { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 14px; max-height: 84px; margin-top: 12px; overflow: auto; } + .shared-proposal-file { min-width: 0; overflow: hidden; color: var(--text-soft); font: 10px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } + .shared-proposal-file::before { content: "↳"; margin-right: 6px; color: var(--accent); } + .shared-proposal-review-stage { position: relative; min-width: 0; min-height: 0; } + .shared-proposal-review-empty { position: absolute; inset: 0; display: grid; place-items: center; padding: 30px; color: var(--muted); text-align: center; } + .shared-proposal-frames, .shared-proposal-frame { width: 100%; height: 100%; min-height: 0; border: 0; background: var(--bg); } + .shared-proposal-frame[hidden], .shared-proposal-review-empty[hidden] { display: none !important; } + @media (max-width: 900px) { + .shared-proposal-workspace-head { grid-template-columns: auto minmax(0, 1fr) auto auto; } + .context-hub-views { grid-column: 3; } + .shared-proposal-workspace-head > .dock-button:last-child { grid-column: 4; } + .shared-proposal-search { grid-column: 1 / 3; } + .context-hub-source-filter { grid-column: 3; } + #sharedProposalProjectFilter { grid-column: 4; } + .shared-proposal-workspace-body { grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); } + .shared-proposal-files { grid-template-columns: 1fr; } + } + @media (max-width: 680px) { + .shared-proposal-workspace-head { grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; padding: 8px; } + .context-hub-views { grid-column: 1 / 3; grid-row: 2; } + .shared-proposal-workspace-head > .dock-button:last-child { grid-column: 3; grid-row: 1; } + .shared-proposal-search { grid-column: 1 / -1; grid-row: 3; } + .context-hub-source-filter { grid-column: 1 / 2; grid-row: 4; } + #sharedProposalProjectFilter { grid-column: 2 / -1; grid-row: 4; } + .shared-proposal-workspace-body { grid-template-columns: 1fr; grid-template-rows: minmax(230px, 38vh) minmax(0, 1fr); } + .shared-proposal-list-panel { border-right: 0; border-bottom: 1px solid var(--line); } + .shared-proposal-overview { padding: 14px 16px 13px; } + .shared-proposal-overview h2 { font-size: 17px; } + .shared-proposal-description { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } + .shared-proposal-files { max-height: 56px; } + } .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; } @@ -9014,7 +9614,7 @@ export function renderAppHtml() { .hub-section-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); } } @media (max-width: 640px) { - main { grid-template-rows: minmax(0, 1fr); } + main { grid-template-rows: auto minmax(0, 1fr); } .workspace-title { display: none; } .docqa-home, .settings-page { padding: 8px; } .settings-shell { min-height: calc(100dvh - 126px); } @@ -9067,17 +9667,39 @@ export function renderAppHtml() {
-
+