From 9c70c36c9cfc29babf13cfc438390337b44708c6 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Wed, 29 Jul 2026 02:36:33 -0400 Subject: [PATCH 1/2] feat(core): bind runtime to project profiles Signed-off-by: John Siracusa --- CLAUDE.md | 4 +- apps/desktop/src/cli/cli.mjs | 6 +- apps/desktop/src/main/settings-sync.mjs | 7 +- apps/desktop/test/settings-sync.test.mjs | 2 +- .../docs/getting-started/connect-an-agent.md | 26 + .../docs/docs/guides/capture-write-path.md | 6 + .../docs/docs/guides/promoting-concepts.md | 75 ++- .../src/content/docs/docs/reference/cli.md | 65 ++- .../content/docs/docs/reference/manifest.md | 17 +- .../content/docs/docs/reference/mcp-tools.md | 10 +- .../docs/reference/updates-and-privacy.md | 11 +- examples/team-sync-pack/README.md | 4 +- package.json | 2 +- packages/core/src/capture.mjs | 145 ++++- packages/core/src/manifest.mjs | 24 + packages/core/src/mcp-server.mjs | 98 +++- packages/core/src/profile-cli.mjs | 216 +++++++ packages/core/src/profile-runtime.mjs | 101 ++++ packages/core/src/promote.mjs | 424 ++++++++++++-- packages/core/src/resolver.mjs | 21 +- packages/core/src/sources/cache.mjs | 2 +- packages/core/src/sources/git-core.mjs | 45 +- packages/core/src/sources/git-sync.mjs | 15 +- packages/core/src/sources/mcp.mjs | 32 +- packages/core/src/write.mjs | 69 ++- packages/core/tests/capture-test.sh | 12 +- packages/core/tests/git-sync-test.sh | 8 +- packages/core/tests/manifest.test.mjs | 31 + packages/core/tests/profile-runtime-test.sh | 529 ++++++++++++++++++ packages/core/tests/smoke-test.sh | 1 + profile.mjs | 9 + .../capturing-and-promoting-context.md | 2 +- .../adversarial-review.md | 91 +++ specs/contextcake-project-profiles/design.md | 6 +- specs/contextcake-project-profiles/spec.md | 5 +- 35 files changed, 1906 insertions(+), 215 deletions(-) create mode 100644 packages/core/src/profile-cli.mjs create mode 100644 packages/core/src/profile-runtime.mjs create mode 100755 packages/core/tests/profile-runtime-test.sh create mode 100755 profile.mjs diff --git a/CLAUDE.md b/CLAUDE.md index ec5dd9c..36eb393 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,8 @@ node mcp-server.mjs --personal ~/kb-personal --shared ~/kb-shared node mcp-server.mjs --manifest layers.json --capture --telemetry --harness claude-code # Promote a live capture through the review queue (request, then approve) -node promote.mjs --from-live ~/kb-live --capture captures/investigation/ --target ~/kb-team -node promote.mjs --from-live ~/kb-live --target ~/kb-team --approve ~/kb-team/_review/promotions/.md +node promote.mjs --legacy-paths --from-live ~/kb-live --capture captures/investigation/ --target ~/kb-team +node promote.mjs --legacy-paths --from-live ~/kb-live --target ~/kb-team --approve ~/kb-team/_review/promotions/.md # Team activity dashboard data (feed + cross-brain-hit metrics) node team-activity.mjs --live-root ~/kb-live --out apps/control-surface/team-activity.json diff --git a/apps/desktop/src/cli/cli.mjs b/apps/desktop/src/cli/cli.mjs index 9ddd162..8b00d52 100644 --- a/apps/desktop/src/cli/cli.mjs +++ b/apps/desktop/src/cli/cli.mjs @@ -33,8 +33,9 @@ const COMMANDS = { resolve: { entry: 'resolver.mjs', manifest: true, blurb: 'resolve a concept across layers' }, ingest: { entry: 'ingest.mjs', manifest: false, blurb: 'classify repo events into signals' }, write: { entry: 'write.mjs', manifest: true, blurb: 'write captured signals into a layer' }, - promote: { entry: 'promote.mjs', manifest: false, blurb: 'promote a concept up one layer' }, + promote: { entry: 'promote.mjs', manifest: true, blurb: 'promote a live capture inside one profile' }, pack: { entry: 'pack-cli.mjs', manifest: false, blurb: 'inspect, install, update, and roll back local Packs' }, + profile: { entry: 'profile-cli.mjs', manifest: true, blurb: 'inspect and manage project profiles' }, } function usage() { @@ -72,7 +73,8 @@ if (!command) { } const args = [...rest] -if (command.manifest && !args.includes('--manifest') && !args.includes('--personal')) { +const isHelp = args.some((arg) => ['help', '--help', '-h'].includes(arg)) +if (command.manifest && !isHelp && !args.includes('--manifest') && !args.includes('--personal') && !args.includes('--legacy-paths')) { if (!fs.existsSync(DEFAULT_MANIFEST)) { console.error(`contextcake: no manifest at ${DEFAULT_MANIFEST}`) console.error('Open the ContextCake app to run first-time setup, or pass --manifest.') diff --git a/apps/desktop/src/main/settings-sync.mjs b/apps/desktop/src/main/settings-sync.mjs index b82eded..98f2f6f 100644 --- a/apps/desktop/src/main/settings-sync.mjs +++ b/apps/desktop/src/main/settings-sync.mjs @@ -3,7 +3,9 @@ import fs from 'node:fs' import path from 'node:path' import { isDeepStrictEqual } from 'node:util' -const SYNC_FIELDS = ['theme', 'updateCheck', 'activeProfile', 'profiles', 'sources'] +// activeProfile is deliberately local-only: remote activity must never select +// a profile that can open machine-local paths or trusted executable sources. +const SYNC_FIELDS = ['theme', 'updateCheck', 'profiles', 'sources'] const SCRUBBED = new Set(['execution', 'path', 'secret']) const SECRET_KEY = /(?:^|_)(?:password|passwd|secret|token|api_?key|authorization|cookie|credential)(?:$|_)/i const CONTEXT_KEY = /^(?:content|contents|body|document|documents|knowledge|markdown|payload|raw|resolved|sections|text)$/i @@ -128,9 +130,6 @@ function assertSettingsShape(settings) { if (settings.updateCheck !== undefined && typeof settings.updateCheck !== 'boolean') { throw new Error('Settings sync rejected an invalid update preference.') } - if (settings.activeProfile !== undefined && typeof settings.activeProfile !== 'string') { - throw new Error('Settings sync rejected an invalid active profile.') - } const assertSources = (sources) => { if (!Array.isArray(sources)) throw new Error('Settings sync rejected invalid source definitions.') for (const source of sources) { diff --git a/apps/desktop/test/settings-sync.test.mjs b/apps/desktop/test/settings-sync.test.mjs index f60e5e3..142fc25 100644 --- a/apps/desktop/test/settings-sync.test.mjs +++ b/apps/desktop/test/settings-sync.test.mjs @@ -36,7 +36,7 @@ test('scrubSettings recursively removes absolute paths and indirect secrets', () }) test('prepareSyncPayload allowlists metadata and rejects credentials or context', () => { - assert.deepEqual(prepareSyncPayload({ theme: 'light', updateCheck: true, privateNotes: 'never upload' }), { + assert.deepEqual(prepareSyncPayload({ theme: 'light', updateCheck: true, activeProfile: 'work', privateNotes: 'never upload' }), { theme: 'light', updateCheck: true, }) diff --git a/apps/site/src/content/docs/docs/getting-started/connect-an-agent.md b/apps/site/src/content/docs/docs/getting-started/connect-an-agent.md index 9e25c2e..43b815c 100644 --- a/apps/site/src/content/docs/docs/getting-started/connect-an-agent.md +++ b/apps/site/src/content/docs/docs/getting-started/connect-an-agent.md @@ -17,6 +17,11 @@ Point it at the bundled demo: node mcp-server.mjs --manifest apps/playground/manifest.json ``` +With a Project Profiles manifest, register this command once. The server selects +`--profile` when explicitly supplied; otherwise it uses the deepest project +mapping that contains the MCP process's startup working directory, then falls +back to `default`. + Or use the legacy two-layer form, no manifest file required: ```bash @@ -44,6 +49,27 @@ The server identifies itself over the MCP `initialize` handshake as `contextcake`. Any MCP-compatible client — not just Claude Code — can spawn it the same way: `command` is `node`, `args` are the script path and flags. +## Verify automatic selection + +From a mapped project folder, first confirm the CLI selection: + +```bash +cd /ABS/PATH/TO/MAPPED/PROJECT +contextcake profile current +``` + +Start a new agent session from that folder. Its MCP initialization instructions +should name the same stable profile id and `reason: project`; the display label +is available in structured initialization metadata. Then call `list_concepts` +and confirm only that profile's concepts appear. The project path itself is +never included in MCP instructions. + +Some clients do not start a global stdio server with the project as its working +directory. For those clients, automatic folder mapping is unavailable. Use the +default profile, or create a separately named opt-in registration whose command +is `contextcake mcp --profile `. Do not replace the one global automatic +registration unless that client requires it. + ## Tools | Tool | What it returns | diff --git a/apps/site/src/content/docs/docs/guides/capture-write-path.md b/apps/site/src/content/docs/docs/guides/capture-write-path.md index ea6a798..cd641b5 100644 --- a/apps/site/src/content/docs/docs/guides/capture-write-path.md +++ b/apps/site/src/content/docs/docs/guides/capture-write-path.md @@ -100,6 +100,8 @@ Flags: `--signals ` (required), `--manifest ` (required), `--target-layer ` (defaults to the highest layer level below 3 — i.e. not personal — or level 3 if that's the only layer available), `--dry-run` (prints what would be written without touching disk), `--help`. +The target must be a non-live local OKF layer; plain Markdown-folder sources +and live capture repositories are not batch-write targets. Per signal: @@ -111,6 +113,10 @@ Per signal: — pending a human decision. A signal that already resolves to an existing file is skipped rather than overwritten. +Destinations are validated as concept ids. Traversal and symlinked parents or +files are rejected before a write, so classifier output cannot escape the +selected profile's target root. + Every written or staged concept carries `draft: true`, `source: `, and an `updated` date in its frontmatter, plus a `## Context {#context}` section with the recommended action and a `## Signals {#signals}` section listing the diff --git a/apps/site/src/content/docs/docs/guides/promoting-concepts.md b/apps/site/src/content/docs/docs/guides/promoting-concepts.md index d5bebe6..86b8904 100644 --- a/apps/site/src/content/docs/docs/guides/promoting-concepts.md +++ b/apps/site/src/content/docs/docs/guides/promoting-concepts.md @@ -1,31 +1,62 @@ --- title: Promoting concepts -description: Move a concept up the stack — personal to shared. +description: Promote a live capture into a curated layer without crossing profile boundaries. --- -A concept that started as a personal note often deserves to become team -knowledge. `promote.mjs` copies a concept from a personal OKF bundle into a -shared bundle, rewrites its links, and rebuilds the shared index — it doesn't -touch the resolver or the manifest at all; it operates directly on two -bundle directories. +A useful live capture often deserves to become curated team knowledge. +`promote.mjs` uses the same Project Profile selection as MCP, resolves both +the live source and curated target from that one profile, and stages a review +before it removes anything from the live repository. ## Usage +Request a promotion: + ```bash -node promote.mjs --personal --shared --file [--dry-run] [--print-git] +contextcake promote --capture captures/investigation/ --target-layer team --dest systems/ ``` -`--personal ` and `--shared ` are the two bundle roots (required). -`--file ` is the concept to promote — a bare id like -`decisions/primary-db` or a path, with or without `.md` (required). +The command selects an explicit `--profile `, the deepest project mapping +for the current directory, or `default`, in that order. The selected profile +must contain exactly one `live: true` local OKF layer and the named curated +`--target-layer`. + +After reviewing the generated file under the curated layer's +`_review/promotions/` directory, approve it: + +```bash +contextcake promote --approve /ABS/PATH/team/_review/promotions/.md --target-layer team +``` + +The review carries the profile id, manifest revision, live-layer and +target-layer fingerprints, capture id, destination, and capture-content hash +from the request. Approval reselects the same profile and fails if configuration +or capture content changed. ContextCake holds the manifest boundary across that +check and the local mutation. Only after the curated write verifies and the +live deletion commits does it remove the review entry; a failed commit restores +the capture and leaves the review retryable. + +Live captures and review entries must be regular files inside their selected +layer roots. Symlinked files and any parent path that escapes its selected root +are rejected rather than followed. +The editable review carries an opaque binding id; its authoritative tuple is +kept in ContextCake's machine-local state, not in the team repository. This +lets people edit the proposed prose without letting the review redefine its +source capture, destination, or trust boundary. + +## Legacy raw-directory mode + +The original personal-to-shared copy and raw live-root flow remain available +for advanced compatibility. They require `--legacy-paths` so a command that +bypasses Project Profile isolation is explicit: ```bash -node promote.mjs --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db +node promote.mjs --legacy-paths --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db +node promote.mjs --legacy-paths --from-live ~/kb-live --capture captures/investigation/ --target ~/kb-team ``` -This copies `~/kb-personal/decisions/primary-db.md` to -`~/kb-team/decisions/primary-db.md`, preserving its relative path, and -rewrites the shared bundle's `index.md` to include it. +The remaining options on this page describe that legacy personal-to-shared +copy mode. ## Link rewriting @@ -54,7 +85,7 @@ the shared index authoritative without hand-maintaining it. touching disk: ```bash -node promote.mjs --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db --dry-run +node promote.mjs --legacy-paths --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db --dry-run ``` ```json @@ -74,7 +105,7 @@ node promote.mjs --personal ~/kb-personal --shared ~/kb-team --file decisions/pr — it doesn't run them for you: ```bash -node promote.mjs --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db --print-git +node promote.mjs --legacy-paths --personal ~/kb-personal --shared ~/kb-team --file decisions/primary-db --print-git ``` ``` @@ -95,13 +126,13 @@ override it. Since the shared bundle is its own git repo, promotion becomes a normal reviewable PR against team or company knowledge — nothing is written straight to a shared main branch unreviewed. -## Why directories, not a manifest +## Why the legacy mode still exists -`promote.mjs` takes `--personal`/`--shared` directories rather than -`--manifest`/`--target-layer` like `write.mjs`. Promotion is a bundle-to-bundle -file operation between two known OKF roots you already have local paths to -— it doesn't need the full source-adapter machinery that lets a manifest -layer be `mcp` or a remote clone. +Older scripts may already know two explicit bundle roots and may not use a +ContextCake manifest. `--legacy-paths` preserves that workflow, but it cannot +claim cross-profile isolation or bind an approval to a manifest revision. New +team-sync workflows should use `--manifest`/`--target-layer` (or the packaged +CLI defaults) instead. ## Next diff --git a/apps/site/src/content/docs/docs/reference/cli.md b/apps/site/src/content/docs/docs/reference/cli.md index 42610a9..bf85b0f 100644 --- a/apps/site/src/content/docs/docs/reference/cli.md +++ b/apps/site/src/content/docs/docs/reference/cli.md @@ -16,13 +16,14 @@ merge, level precedence, provenance, and per-section conflicts — and prints it JSON. ``` -node resolver.mjs --manifest --concept +node resolver.mjs --manifest --concept [--profile ] ``` | Flag | Required | Meaning | |------|----------|---------| | `--manifest ` | yes | Path to the [layers manifest](/docs/reference/manifest). | | `--concept ` | yes | Concept ID to resolve, e.g. `decisions/primary-db`. | +| `--profile ` | no | Select this profile before inspecting the working directory. | | `--help`, `-h` | no | Print usage and exit. | ```bash @@ -41,13 +42,14 @@ cascade mode (a manifest) or the legacy two-layer mode (explicit personal + shar directories). ``` -node mcp-server.mjs --manifest +node mcp-server.mjs --manifest [--profile ] node mcp-server.mjs --personal --shared ``` | Flag | Required | Meaning | |------|----------|---------| | `--manifest ` | one mode | Cascade mode: resolve the full stack in the manifest. | +| `--profile ` | no | Explicit profile override. Otherwise the server uses its startup working directory, then `default`. | | `--personal ` | one mode | Legacy mode: personal bundle directory (level 3). Use with `--shared`. | | `--shared ` | one mode | Legacy mode: shared bundle directory (level 0). Use with `--personal`. | | `--help`, `-h` | no | Print usage and exit. | @@ -62,6 +64,32 @@ The server speaks MCP over stdin/stdout. See [MCP tools](/docs/reference/mcp-too for the tools it exposes and [Connect an agent](/docs/getting-started/connect-an-agent) for wiring it into a client. +The MCP initialization instructions identify the stable profile id and reason +(`explicit`, `project`, `default`, or `legacy-default`) without including a +local project path. The human-readable label is returned as structured +initialization metadata rather than instruction prose, so synced display text +cannot become an agent directive. Selection is fixed for that process lifetime. + +## profile + +Inspects and manages local Project Profiles without opening source adapters: + +```bash +contextcake profile current [--profile ] [--json] +contextcake profile list [--json] +contextcake profile create