From 6df50825b0489e66732c58d520ccf2a8750843d3 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Wed, 5 Aug 2026 22:16:15 +0300 Subject: [PATCH 01/11] fix(brains): scope the capture claim to the clients that have hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.md told the model "Capture is automatic … You do **not** need to call `save_chat_session`" with no client scoping. Capture is the ingest hook, and hooks do not run in claude.ai web chat — on either install path, the connector or the full plugin (support article 13837440: "Hooks and sub-agents run only in Cowork, so they appear grayed out in chat"). The sentence was therefore false wherever the hooks are absent, and it actively suppressed the one substitute that works there. It was also wrong in a shipped Claude Code configuration: brains-turn.sh exits early with no token, so capture is off for anyone who never set one — exactly where save_chat_session is the only path. Bump the core marker v=5 -> v=6 so the section is redelivered, and pin the new claim in both suites. The old text survived because nothing asserted it: the contract test's signal list covers routing and delegation but never covered capture. Add the signal plus a paraphrase-proof backstop on the unconditional form. [BRNS-MCPWEB-018] --- plugins/brains/core.md | 9 +++++---- tests/inbox-v2/run.ts | 4 ++-- tests/plugin-contract/run.ts | 13 ++++++++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/plugins/brains/core.md b/plugins/brains/core.md index f455cdc..d16e7a7 100644 --- a/plugins/brains/core.md +++ b/plugins/brains/core.md @@ -1,4 +1,4 @@ - + # brains — your memory layer You have a memory layer called **brains** (the `brains` MCP server). It holds the @@ -22,9 +22,10 @@ For schedules and agendas, use `list_calendar_events start=… end=…`; calenda page update time is not event time. Name the source page's `title` and `type`, and never invent slugs or IDs. -**Capture is automatic.** The ingest hook saves every turn to the server, which -builds the conversation page (title/summary) for you. You do **not** need to call -`save_chat_session`. +**Capture.** In Codex and Claude Code the ingest hook saves every turn and +builds the conversation page for you — do **not** call `save_chat_session` +there. Without those hooks (claude.ai web), nothing is captured unless you +call `save_chat_session` yourself. **The skills carry the detail** — load the one that fits the moment: `brains-read` (querying memory), `brains-write` (sending/creating via diff --git a/tests/inbox-v2/run.ts b/tests/inbox-v2/run.ts index 4eeaa80..2be8df3 100644 --- a/tests/inbox-v2/run.ts +++ b/tests/inbox-v2/run.ts @@ -808,10 +808,10 @@ const SCENARIOS: Scenario[] = [ const manifest = JSON.parse(readFileSync(PLUGIN_JSON_PATH, "utf8")) as { version: string }; assertEqual(report.plugin_version, manifest.version, "25 plugin_version matches manifest"); - // Sections come from the shipped core.md; this release bumps it to v5. + // Sections come from the shipped core.md; this release bumps it to v6. const sections = (report.sections ?? []) as Array<{ name: string; version: number }>; const core = sections.find((s) => s.name === "core"); - assertEqual(core?.version, 5, "25 core marker reported at v5"); + assertEqual(core?.version, 6, "25 core marker reported at v6"); // No drift → no update nudge in stdout. assertNotContains(r.stdout, "brains:update", "25 no update nudge when device is current"); diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index ff58365..93aa520 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -476,7 +476,7 @@ if (!claudeOnPath) { } } -assert(core.includes(""), "core marker must be v5"); +assert(core.includes(""), "core marker must be v6"); for (const signal of [ "Query brains reflexively", "list_calendar_events", @@ -492,9 +492,20 @@ for (const signal of [ "note the error and what you were doing", "Do not attach it to unrelated later feedback", "Once per session, when natural, mention `brains-feedback`", + // The capture rule must stay CLIENT-SCOPED. Nothing pinned it before, which is how + // "Capture is automatic … You do not need to call save_chat_session" survived here + // unqualified while being false anywhere the hooks don't run — claude.ai web runs + // none of them, on either install path (BRNS-MCPWEB-018). + "nothing is captured unless you call", ]) { assert(coreNormalized.includes(signal), `compact core is missing routing/delegation signal: ${signal}`); } +// Backstop for a rewrite that edits the signal above too: the unconditional claim is the +// one that must never return, in any paraphrase that drops the client scoping. +assert( + !/capture is automatic/i.test(core), + "core must not claim capture is automatic — it is hook-driven, and no hooks run on claude.ai web", +); assert(core.length < 3_000, "always-loaded core must stay below 3,000 characters"); // The public face is generated from the monorepo capability catalog. Verify its From 22d161b78b6a1e8beac2224a444669ebbe64336a Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Wed, 5 Aug 2026 22:17:00 +0300 Subject: [PATCH 02/11] fix(brains): stop advertising unconditional turn-by-turn capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four prose surfaces advertised capture as a flat product capability. Capture is the ingest hook; it runs in Codex and Claude Code and nowhere else, so on claude.ai the claim was simply wrong — and it is what a user reads while deciding whether to trust the plugin with their conversations. Naming the mechanism is the fix: "hook-driven" stops the phrase asserting that capture happens wherever the words are read. - README.md:4 top blurb, plus a pointer to the new web section - .claude-plugin/marketplace.json:9 the marketplace catalog card — a surface distinct from the manifest; fixing only the manifest leaves this one lying to anyone browsing - plugins/brains/.claude-plugin/plugin.json:3 - plugins/brains/.codex-plugin/plugin.json interface.longDescription, which said "automatic context and capture" while Codex capture needs a token None of these four strings is asserted by any test, which is how three of them drifted into agreement on the same false claim. Pinning them is deliberately left out of scope here; core.md now carries the pinned version of the rule. [BRNS-MCPWEB-018] --- .claude-plugin/marketplace.json | 2 +- README.md | 6 ++++-- plugins/brains/.claude-plugin/plugin.json | 2 +- plugins/brains/.codex-plugin/plugin.json | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ccd172f..e5fb4e5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "brains", - "description": "Your memory layer: Gmail/Calendar/Drive and prior Claude conversations as queryable pages — reflexive recall, turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", + "description": "Your memory layer: Gmail/Calendar/Drive and prior Claude conversations as queryable pages — reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", "source": "./plugins/brains", "category": "productivity" } diff --git a/README.md b/README.md index 1ddec6c..477d697 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # brains — Codex and Claude Code plugin Your memory layer for Codex and Claude Code: Gmail, Calendar, Drive, and prior -AI conversations as queryable pages — with reflexive recall, turn-by-turn -capture, a server-driven inbox, boards, automations, and workflows on top. +AI conversations as queryable pages — with reflexive recall, hook-driven +turn-by-turn capture, a server-driven inbox, boards, automations, and workflows +on top. The same server also backs claude.ai, where no hooks run — see +[Install for claude.ai web](#install-for-claudeai-web). The Codex and Claude packages share the same seven skills, core prompt, hook scripts, and inbox engine. Only their manifests, hook event maps, and MCP diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index 65c33fe..2aaa738 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", + "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", "version": "2.7.0", "author": { "name": "brains (ssvlabs)" diff --git a/plugins/brains/.codex-plugin/plugin.json b/plugins/brains/.codex-plugin/plugin.json index 025fff2..93bb9e1 100644 --- a/plugins/brains/.codex-plugin/plugin.json +++ b/plugins/brains/.codex-plugin/plugin.json @@ -15,7 +15,7 @@ "interface": { "displayName": "Brains", "shortDescription": "Your personal memory layer", - "longDescription": "Query your Gmail, Calendar, Drive, and prior AI conversations from Codex, with automatic context and capture plus boards, automations, workflows, and feedback flows.", + "longDescription": "Query your Gmail, Calendar, Drive, and prior AI conversations from Codex, with automatic context and hook-driven capture plus boards, automations, workflows, and feedback flows.", "developerName": "ssvlabs", "category": "Productivity", "capabilities": ["Read", "Write", "Automate"], From f5a38f7ea8dc283f82b8ded212a75d9fde50086f Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Wed, 5 Aug 2026 22:19:20 +0300 Subject: [PATCH 03/11] docs(brains): document the claude.ai web paths and what capture does there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This README had no web section at all, so a claude.ai reader had nowhere to learn that the capture the rest of the page describes does not reach them. Both install paths are named. The custom connector works on any plan; the full marketplace-sync plugin is paid-only and carries the skills. Hooks are inert in chat on both, so neither delivers core.md or the ingest hook. The capture copy is measured, not inferred (BRNS-MCPWEB-018, live on claude.ai): - an explicit "save this chat to brains" works, verified end to end - unprompted capture fired on ONE of five passive trials, with a direct replication of the success failing in the same tool-access mode - one trial ended by announcing a save it never performed That last one is why the section tells the reader how to CHECK. A user cannot distinguish a real save from a claimed one by reading the chat, so a disclaimer alone would not have been honest. The section links the install guide rather than reproducing its instruction block. That block is 23k characters and already lives in ssvlabs/brains; copying it here would make this the third hand-maintained expression of one ruleset. Guard: the Claude Code slice now ends at the web heading instead of the shared layout section. Without that the new section would inherit the Claude-Code-only rules — the version-floor bans and the `bash -n` sweep — while none of its own claims were pinned. It gets its own assertions instead, including both halves of the capture finding: drop the explicit path and the section reads "capture is broken", drop the caveat and it reads "capture just works". Each assertion was mutation-tested to confirm it fails when its claim is removed. [BRNS-MCPWEB-018] --- README.md | 24 +++++++++++ tests/plugin-contract/run.ts | 77 +++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 477d697..702c404 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,30 @@ Codex has no such option and runs the same hook scripts, so set `BRAINS_ENDPOINT you set `BRAINS_API_TOKEN` above — the shell Codex starts from, or the app's launch environment. Without it Codex capture keeps sending to `https://mcp.mybrains.ai` too. +## Install for claude.ai web + +claude.ai does not run this repo's hooks, so the capture that Codex and Claude +Code get from `hooks/` does not happen there. Two ways in, both covered step by +step at : + +- **Custom connector** — add `https://mcp.mybrains.ai/mcp` and approve the OAuth + screen. Works on any plan. +- **Full plugin** — add this repository as a marketplace and install from it. + Paid plans only; it also brings the skills. The hooks it lists stay inert. + +Recall works on both: ask about a person, project or past conversation and +Claude reaches for brains on its own. + +**Capture is different — ask for it.** On claude.ai a conversation is saved only +when Claude calls `save_chat_session`. Say "save this chat to brains" and it +does; that is the dependable way, and the way to treat anything you want kept. + +With the install guide's instruction block in place Claude also saves on its own +sometimes — but only sometimes, and in testing it once said it was saving +without actually doing so. Don't rely on it, and don't take the sentence in the +chat as proof: `list_pages type=chat_session`, or just ask brains which chats it +has, is the only real confirmation. + ## Shared layout - `.agents/plugins/marketplace.json` — Codex marketplace diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 93aa520..7486d51 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -39,6 +39,9 @@ const HOOK_ENV_ENDPOINT = "https://envvar-probe.example.test"; const CLAUDE_LOOPBACK = "127.0.0.1"; const CLAUDE_MCP_KEYS = ["type", "url"]; const CLAUDE_OPTIONAL_HEADING = "### Optional:"; +const CLAUDE_WEB_HEADING = "## Install for claude.ai web"; +// The one URL a web reader needs; the connector dialog takes it verbatim. +const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; // Approved token copy, pinned verbatim. Hand-written phrasing checks proved both evadable and // prone to false positives, so the wording itself is the contract; the regex pair further down @@ -715,15 +718,24 @@ assert( "README's optional section must keep the desktop launchctl path — a desktop app inherits no shell export", ); -// Same treatment for the Claude Code section, sliced between its own heading and the shared -// layout section. Resolve the end delimiter first for the same reason as above. +// Same treatment for the Claude Code section. It is sliced between its own heading and the +// claude.ai web heading — NOT the shared layout section. The web section sits between the two, +// and letting it fall inside this slice would subject it to the Claude-Code-specific rules below +// (the version-floor bans, the `bash -n` sweep) while leaving its own claims unpinned. Resolve +// both delimiters first, for the same reason as above. const sharedLayoutStart = readme.indexOf("## Shared layout"); -assert(sharedLayoutStart >= 0, "README must keep the shared layout section — it ends the Claude slice"); +const webStart = readme.indexOf(CLAUDE_WEB_HEADING); +assert(sharedLayoutStart >= 0, "README must keep the shared layout section — it ends the web slice"); +assert(webStart >= 0, `README must document the claude.ai web install ("${CLAUDE_WEB_HEADING}")`); assert( - sharedLayoutStart > claudeStart, - "README's shared layout section must follow the Claude Code install — the checks below slice between them", + webStart > claudeStart, + "README's claude.ai web section must follow the Claude Code install — the checks below slice between them", ); -const claudeReadme = readme.slice(claudeStart, sharedLayoutStart); +assert( + sharedLayoutStart > webStart, + "README's shared layout section must follow the claude.ai web install — the web checks slice between them", +); +const claudeReadme = readme.slice(claudeStart, webStart); // Pinned individually so a single dropped element names itself, ahead of the whole-region pin. for (const pinned of [ "claude plugin marketplace add https://github.com/ssvlabs/brains-plugins.git", @@ -787,6 +799,59 @@ assert( "README's Claude migration must sign in — updating alone leaves the user logged out", ); +// The claude.ai web section, sliced between its own heading and the shared layout section. +// +// This section exists because the rest of this README describes hook-driven capture, and a web +// reader gets none of it: hooks are inert in claude.ai chat on BOTH install paths — the custom +// connector and the full marketplace-sync plugin (support article 13837440). What replaces them +// was measured live rather than assumed (BRNS-MCPWEB-018): an explicit "save this chat to brains" +// works, while unprompted capture fired on ONE of five passive trials — including a trial that +// announced a save it never performed. The assertions below pin that distinction, because the +// tempting edit is to collapse the two into one reassuring sentence, and the whole finding is +// that they are not the same promise. +const webReadme = readme.slice(webStart, sharedLayoutStart); +assert( + webReadme.includes(CLAUDE_WEB_GUIDE), + `README's web section must link the install guide (${CLAUDE_WEB_GUIDE}) — it owns the procedure and the instruction block, which must not be forked into this file`, +); +assert( + webReadme.includes(CLAUDE_MCP_URL), + `README's web section must name ${CLAUDE_MCP_URL} — it is what the connector dialog asks for`, +); +// Both install paths. Naming only the connector would strand paid users on the route that +// carries the skills; naming only the plugin would exclude every Free-tier reader. +for (const path of ["Custom connector", "Full plugin"]) { + assert( + webReadme.includes(path), + `README's web section must name both claude.ai install paths — missing: ${path}`, + ); +} +assert( + webReadme.includes("save_chat_session"), + "README's web section must name save_chat_session — it is the only capture path on claude.ai", +); +// The measured shape, in both directions. Dropping either half re-creates the bug this ticket +// fixed: without the explicit path the section reads as "capture is broken", and without the +// unreliability caveat it reads as "capture just works". +assert( + /save this chat to brains/i.test(webReadme), + "README's web section must give the user the explicit phrasing that actually works", +); +assert( + /only sometimes/i.test(webReadme), + "README's web section must keep unprompted capture marked unreliable — it fired on one of five measured passive trials", +); +assert( + /list_pages type=chat_session|which chats it\s+has/i.test(webReadme), + "README's web section must tell the user how to VERIFY a save — a model has been observed claiming a save it did not perform", +); +// The regression that would pass every check above: re-asserting hook-driven capture on the one +// surface whose whole purpose is to say the hooks are absent. +assert( + !/\bcapture is automatic\b/i.test(webReadme), + "README's web section must not claim automatic capture — no hooks run on claude.ai, on either install path", +); + assert( turnHook.includes('CLIENT="claude"'), "shared turn hook must default Claude Code captures to the Claude CLI", From 36a821e7ee76f4be7053006c6f6758f3cb389ca6 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Wed, 5 Aug 2026 22:20:28 +0300 Subject: [PATCH 04/11] docs(brains): promote self-hosting out of the Claude Code section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosting notes were nested under "Install for Claude Code" while their last paragraph instructs Codex users: Codex has no `--config` mechanism, so `BRAINS_ENDPOINT` is the only lever a self-hosting Codex user has. A reader following the Codex path had no reason to read past that section and never saw it — their capture would keep posting to mcp.mybrains.ai while their tools talked to their own server, silently. Same shape as the capture claims this PR fixes: correct text, structurally unreachable by part of its audience. Promoted verbatim to a top-level section after "Shared layout", with a pointer from the Codex capture section where BRAINS_API_TOKEN is already being set. The body is unchanged byte-for-byte; only the heading level and position moved. [BRNS-MCPWEB-018] --- README.md | 47 +++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 702c404..5c0b471 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ launchctl setenv BRAINS_API_TOKEN "" This token is only for capture and the inbox. It is **not** how Codex authenticates the brains tools — that is `codex mcp login brains` above. +Running your own brains server? Set `BRAINS_ENDPOINT` alongside it — see +[Self-hosting](#self-hosting). + ## Install for Claude Code No token needed — Claude Code signs itself in. @@ -141,28 +144,6 @@ claude mcp login plugin:brains:brains Then run `/reload-plugins`. -### Self-hosting - -The brains tools connect to `https://mcp.mybrains.ai/mcp`; to point them at your own -server, fork this repo, set the URL in `plugins/brains/.claude-plugin/plugin.json` and -`plugins/brains/.mcp.json`, and add your fork as the marketplace. - -That moves the tools only. Conversation capture and the inbox read the `endpoint` option -instead, so set it when you install or they keep sending to `https://mcp.mybrains.ai`: - -```sh -claude plugin install brains@brains --config endpoint=https://your-server -``` - -Changing `endpoint`'s `default` in your fork does not cover this. Claude Code exports -`CLAUDE_PLUGIN_OPTION_ENDPOINT` to the hooks from the value stored in your settings, and -an option you never set has no stored value — so the hooks fall back to -`https://mcp.mybrains.ai` while your tools talk to your own server. - -Codex has no such option and runs the same hook scripts, so set `BRAINS_ENDPOINT` wherever -you set `BRAINS_API_TOKEN` above — the shell Codex starts from, or the app's launch -environment. Without it Codex capture keeps sending to `https://mcp.mybrains.ai` too. - ## Install for claude.ai web claude.ai does not run this repo's hooks, so the capture that Codex and Claude @@ -197,6 +178,28 @@ has, is the only real confirmation. - `plugins/brains/skills/` — shared skills - `plugins/brains/hooks/` — shared scripts plus client-specific event maps +## Self-hosting + +The brains tools connect to `https://mcp.mybrains.ai/mcp`; to point them at your own +server, fork this repo, set the URL in `plugins/brains/.claude-plugin/plugin.json` and +`plugins/brains/.mcp.json`, and add your fork as the marketplace. + +That moves the tools only. Conversation capture and the inbox read the `endpoint` option +instead, so set it when you install or they keep sending to `https://mcp.mybrains.ai`: + +```sh +claude plugin install brains@brains --config endpoint=https://your-server +``` + +Changing `endpoint`'s `default` in your fork does not cover this. Claude Code exports +`CLAUDE_PLUGIN_OPTION_ENDPOINT` to the hooks from the value stored in your settings, and +an option you never set has no stored value — so the hooks fall back to +`https://mcp.mybrains.ai` while your tools talk to your own server. + +Codex has no such option and runs the same hook scripts, so set `BRAINS_ENDPOINT` wherever +you set `BRAINS_API_TOKEN` above — the shell Codex starts from, or the app's launch +environment. Without it Codex capture keeps sending to `https://mcp.mybrains.ai` too. + ## License [GPL-3.0](./LICENSE) From d92429225bcfc34018e781eb67bde9240d67b3dc Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Wed, 5 Aug 2026 22:20:43 +0300 Subject: [PATCH 05/11] chore(release): bump plugin manifests 2.7.0 -> 2.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts update on SemVer precedence, not on content: without a bump this PR's core.md and copy changes merge and reach zero installed users. Both manifests move together — the contract test asserts they stay version-aligned, and the delivery guard requires forward movement on both for any change under plugins/brains/. Minor, matching 2.4.0 through 2.7.0, which were content changes of this size. Follow-up this creates, tracked on BRNS-MCPWEB-018: core.md's marker moved v=5 -> v=6, and the server's canonical section version needs a companion db/init migration in ssvlabs/brains modeled on 338_catalog_core_v5.sql. Ordering is fixed by that migration's own header — the public release must exist first, or devices get told to update to a marker the marketplace does not yet serve. So: publish this, then land the migration. [BRNS-MCPWEB-018] --- plugins/brains/.claude-plugin/plugin.json | 2 +- plugins/brains/.codex-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index 2aaa738..e39cc19 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "brains", "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", - "version": "2.7.0", + "version": "2.8.0", "author": { "name": "brains (ssvlabs)" }, diff --git a/plugins/brains/.codex-plugin/plugin.json b/plugins/brains/.codex-plugin/plugin.json index 93bb9e1..9096243 100644 --- a/plugins/brains/.codex-plugin/plugin.json +++ b/plugins/brains/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "version": "2.7.0", + "version": "2.8.0", "description": "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, then build boards, automations, and workflows.", "author": { "name": "brains (ssvlabs)", From 5113d906532c98cf701429f4de239ad5b8f3f5e6 Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Thu, 6 Aug 2026 09:40:51 +0300 Subject: [PATCH 06/11] fix(brains): close three gaps found in adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review of the committed diff found three real defects. All three were reproduced against source before fixing. 1. core.md still over-promised for a shipped configuration. The v6 paragraph said the ingest hook "saves every turn" in Codex and Claude Code and told the model not to call `save_chat_session` there. Capture is opt-in: brains-turn.sh:43 exits early with no token, and that token is `required: false`. brains-start.sh:27 cats core.md with no token check at all, so the users reading the promise are exactly the ones not getting it. Same bug class as the one this branch set out to fix — a false "automatic" claim suppressing the working substitute — in a configuration the first fix did not reach. The prohibition was also too broad: a blanket "do not call save_chat_session" suppressed the tool even when the user asked outright, which on claude.ai is the one path measured to work reliably. Now conditional on both axes, and the prohibition is narrowed to "routinely". Both conditions and the narrowed ban are pinned; the backstop regex also rejects "saves every turn". 2. The web section's nine signal assertions were paraphrase-gameable. The reviewer demonstrated it rather than asserting it: a rewrite opening "Capture is handled automatically for you … there is nothing to do" passed ALL NINE, because every keyword survived inside a trailing parenthetical while the meaning was reversed. This is the failure mode this file's own doctrine already names — "hand-written phrasing checks proved both evadable … the wording itself is the contract" — and the reason CLAUDE_INSTALL_REGION is pinned verbatim. The capture paragraphs now get the same treatment. The signal assertions stay alongside it: they give a precise failure on a legitimate edit, ahead of the whole-region diff. 3. Promoting "Self-hosting" past "## Shared layout" moved its shell block outside every slice this file sweeps, so nothing syntax-checked the one command a self-hoster copies. Widened the `bash -n` sweep to the whole README, which also covers the six Codex blocks — never swept at all, so the entire Codex install was unchecked. Also softened two claims the review flagged as unmeasured. "Works on any plan" went beyond the evidence: the research records Free-tier custom-connector access as our own guide's assertion, never product-observed, and still an open item. "Recall works on both" was measured on the connector path only. "Paid plans only" stays — that one is documented by Anthropic and verified. Each fix was mutation-tested: reverting any of the three, or softening the copy by a single clause, now fails. [BRNS-MCPWEB-018] --- README.md | 6 +-- plugins/brains/core.md | 8 ++-- tests/plugin-contract/run.ts | 80 +++++++++++++++++++++++++++++++----- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5c0b471..0f349c2 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,12 @@ Code get from `hooks/` does not happen there. Two ways in, both covered step by step at : - **Custom connector** — add `https://mcp.mybrains.ai/mcp` and approve the OAuth - screen. Works on any plan. + screen. This is the path we verified end to end. - **Full plugin** — add this repository as a marketplace and install from it. Paid plans only; it also brings the skills. The hooks it lists stay inert. -Recall works on both: ask about a person, project or past conversation and -Claude reaches for brains on its own. +Recall works: ask about a person, project or past conversation and Claude +reaches for brains on its own. **Capture is different — ask for it.** On claude.ai a conversation is saved only when Claude calls `save_chat_session`. Say "save this chat to brains" and it diff --git a/plugins/brains/core.md b/plugins/brains/core.md index d16e7a7..9f05d2e 100644 --- a/plugins/brains/core.md +++ b/plugins/brains/core.md @@ -22,10 +22,10 @@ For schedules and agendas, use `list_calendar_events start=… end=…`; calenda page update time is not event time. Name the source page's `title` and `type`, and never invent slugs or IDs. -**Capture.** In Codex and Claude Code the ingest hook saves every turn and -builds the conversation page for you — do **not** call `save_chat_session` -there. Without those hooks (claude.ai web), nothing is captured unless you -call `save_chat_session` yourself. +**Capture.** In Codex and Claude Code the ingest hook saves each turn, but only +when the user configured capture — it is off without a token, so never promise +it. Don't call `save_chat_session` routinely there; do call it when asked, and +where the hooks don't run (claude.ai web) it is the only path. **The skills carry the detail** — load the one that fits the moment: `brains-read` (querying memory), `brains-write` (sending/creating via diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 7486d51..1a20eb8 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -43,6 +43,31 @@ const CLAUDE_WEB_HEADING = "## Install for claude.ai web"; // The one URL a web reader needs; the connector dialog takes it verbatim. const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; +// Approved claude.ai capture copy, pinned VERBATIM for the same reason as CLAUDE_INSTALL_REGION +// below: keyword assertions on this section proved evadable in review. A rewrite reading +// "Capture is handled automatically for you — Claude saves your conversations on its own, so +// there is nothing to do. (Early builds fired only sometimes; if you are on one, say 'save this +// chat to brains' or call save_chat_session, and check with list_pages type=chat_session.)" +// satisfied EVERY signal check — each keyword survived inside a parenthetical while the meaning +// was fully reversed. So the wording itself is the contract here too. +// +// What the wording encodes, measured live on claude.ai (BRNS-MCPWEB-018): an explicit ask works; +// unprompted capture fired on ONE of five passive trials; one trial announced a save it never +// performed, which is why the copy tells the reader how to verify rather than merely disclaiming. +// The named signal assertions further down are kept as well — they give a precise failure on a +// legitimate edit, ahead of this whole-region diff. +const CLAUDE_WEB_CAPTURE_REGION = [ + "**Capture is different — ask for it.** On claude.ai a conversation is saved only", + "when Claude calls `save_chat_session`. Say \"save this chat to brains\" and it", + "does; that is the dependable way, and the way to treat anything you want kept.", + "", + "With the install guide's instruction block in place Claude also saves on its own", + "sometimes — but only sometimes, and in testing it once said it was saving", + "without actually doing so. Don't rely on it, and don't take the sentence in the", + "chat as proof: `list_pages type=chat_session`, or just ask brains which chats it", + "has, is the only real confirmation.", +].join("\n"); + // Approved token copy, pinned verbatim. Hand-written phrasing checks proved both evadable and // prone to false positives, so the wording itself is the contract; the regex pair further down // stays only as a backstop. @@ -495,19 +520,29 @@ for (const signal of [ "note the error and what you were doing", "Do not attach it to unrelated later feedback", "Once per session, when natural, mention `brains-feedback`", - // The capture rule must stay CLIENT-SCOPED. Nothing pinned it before, which is how - // "Capture is automatic … You do not need to call save_chat_session" survived here - // unqualified while being false anywhere the hooks don't run — claude.ai web runs - // none of them, on either install path (BRNS-MCPWEB-018). - "nothing is captured unless you call", + // The capture rule must stay conditional on BOTH axes. Nothing pinned it before, which is + // how "Capture is automatic … You do not need to call save_chat_session" survived here + // while being false in two shipped configurations at once (BRNS-MCPWEB-018): + // 1. no hooks at all — claude.ai web, on either install path; + // 2. hooks present but capture unconfigured — brains-turn.sh:43 exits early with no token, + // and that token is `required: false`. brains-start.sh:27 cats core.md with NO token + // check, so precisely those users are the ones who read the claim. + // Both axes get a signal: a rewrite dropping either one re-promises capture to a real user + // who is not getting it. + "only when the user configured capture", + "where the hooks don't run", + // And the prohibition must stay NARROW. A blanket "do not call save_chat_session" suppressed + // the tool even when the user asked for it outright — on claude.ai that ask is the one path + // measured to work reliably. + "do call it when asked", ]) { assert(coreNormalized.includes(signal), `compact core is missing routing/delegation signal: ${signal}`); } -// Backstop for a rewrite that edits the signal above too: the unconditional claim is the -// one that must never return, in any paraphrase that drops the client scoping. +// Backstop for a rewrite that edits the signals above too: the unconditional claim is the one +// that must never return, in any paraphrase that drops the scoping. assert( - !/capture is automatic/i.test(core), - "core must not claim capture is automatic — it is hook-driven, and no hooks run on claude.ai web", + !/capture is automatic|saves every turn\b/i.test(core), + "core must not promise capture unconditionally — it needs the hooks AND a configured token", ); assert(core.length < 3_000, "always-loaded core must stay below 3,000 characters"); @@ -777,11 +812,21 @@ assert( // the three-command install, where a broken line would strand a user mid-install. const claudeShellBlocks = [...claudeReadme.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]); assert(claudeShellBlocks.length > 0, "README's Claude install must keep its shell blocks"); -for (const block of claudeShellBlocks) { +// Sweep EVERY block in the file, not just this slice. Region-scoped sweeping silently lost +// coverage the moment a block moved: promoting "Self-hosting" past "## Shared layout" took its +// `--config endpoint=…` example outside every slice checked here, and the six Codex blocks were +// never swept at all — so the one command a self-hoster copies, and the whole Codex install, were +// unchecked. The syntax of a published command does not depend on which section it sits in. +const readmeShellBlocks = [...readme.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]); +assert( + readmeShellBlocks.length >= claudeShellBlocks.length, + "the whole-file shell sweep must cover at least the Claude install's blocks", +); +for (const block of readmeShellBlocks) { const parsed = spawnSync("bash", ["-n"], { input: block, encoding: "utf8" }); assert( parsed.status === 0, - `README's Claude shell block is not valid shell:\n${block}\n${parsed.stderr}`, + `README shell block is not valid shell:\n${block}\n${parsed.stderr}`, ); } @@ -851,6 +896,19 @@ assert( !/\bcapture is automatic\b/i.test(webReadme), "README's web section must not claim automatic capture — no hooks run on claude.ai, on either install path", ); +// And the strong guarantee the checks above cannot give: the two capture paragraphs, verbatim. +// Every assertion above passed a rewrite that reversed their meaning (see the constant's own +// comment), so this is what actually holds the line. Changing this copy is a deliberate two-line +// diff: CLAUDE_WEB_CAPTURE_REGION and the README together. +const webCaptureStart = webReadme.indexOf("**Capture is different"); +assert( + webCaptureStart > 0, + "README's web section must keep its capture paragraphs — they are what a web user needs most", +); +assert( + normalizeRegion(webReadme.slice(webCaptureStart)) === CLAUDE_WEB_CAPTURE_REGION, + "README's claude.ai capture copy must match the approved wording exactly (README and CLAUDE_WEB_CAPTURE_REGION must be edited together)", +); assert( turnHook.includes('CLIENT="claude"'), From 929c96947961c93743a28f42009a9a79bee4648f Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Thu, 6 Aug 2026 10:33:31 +0300 Subject: [PATCH 07/11] =?UTF-8?q?fix(brains):=20bump=202.7.1=20not=202.8.0?= =?UTF-8?q?=20=E2=80=94=20this=20PR=20is=20patch-shaped,=20and=20document?= =?UTF-8?q?=20the=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No new capability or contract change here — corrected false claims, added a docs section, strengthened test guards. Minor was picked by matching prior bumps, but that precedent doesn't hold up: several past "fix" commits bumped minor anyway, and the one clear feature bump ("New brains-build capability") went patch. The guard itself doesn't care which digit moves. Documents the convention going forward in generated-artifact-guard.sh's header so the next bump has a rule to follow instead of inconsistent history to guess from: fix -> patch, feat -> minor, breaking contract change -> major. --- plugins/brains/.claude-plugin/plugin.json | 2 +- plugins/brains/.codex-plugin/plugin.json | 2 +- scripts/generated-artifact-guard.sh | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index e39cc19..6e2e06b 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "brains", "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", - "version": "2.8.0", + "version": "2.7.1", "author": { "name": "brains (ssvlabs)" }, diff --git a/plugins/brains/.codex-plugin/plugin.json b/plugins/brains/.codex-plugin/plugin.json index 9096243..fe6dfdf 100644 --- a/plugins/brains/.codex-plugin/plugin.json +++ b/plugins/brains/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "version": "2.8.0", + "version": "2.7.1", "description": "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, then build boards, automations, and workflows.", "author": { "name": "brains (ssvlabs)", diff --git a/scripts/generated-artifact-guard.sh b/scripts/generated-artifact-guard.sh index da26bf3..77db7c8 100755 --- a/scripts/generated-artifact-guard.sh +++ b/scripts/generated-artifact-guard.sh @@ -24,6 +24,13 @@ # # Usage: scripts/generated-artifact-guard.sh [base-ref] (default origin/main) # +# Which digit to bump (convention, not enforced here — semver_gt below accepts +# any forward move, on any digit): match this repo's own commit-type prefixes. +# `fix(...)`: patch. `feat(...)`: minor. A declared breaking change to the +# plugin's contract (MCP server URL, tool/skill names, config schema): major. +# Past bumps in this repo are inconsistent with this rule — treat it as the +# convention going forward, not a description of history. +# # A guard that cannot fail loudly is not a guard. Three rules keep this one # honest, each earned from an observed false pass during review: # From 22f30a823ce3c37c92bf9826204c65390940884e Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Thu, 6 Aug 2026 14:15:18 +0300 Subject: [PATCH 08/11] fix(brains): pin the capture claims verbatim, and qualify the inbox too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from review at the PR tip. Each was reproduced independently before fixing, and each fix now fails against the probe that exposed it. F1 (blocking) — core.md's capture guard was evadable on the surface a model EXECUTES. The three keyword signals plus a backstop banning the literals "capture is automatic" and "saves every turn" all survived this, green: **Capture.** Capture happens automatically for you in Codex and Claude Code — the ingest hook saves each turn (only when the user configured capture, which is the default, so you can assume it is on). … That is the false promise this branch exists to delete, restored and passing. core.md is now pinned VERBATIM, the same treatment the web section already had. Cheap here: editing core.md is already deliberate because it forces the v= marker bump and two suites' marker assertions. The backstop is also widened — "happens automatically" walked straight past a "capture is automatic" ban. F2 (blocking) — the web pin started at the capture paragraphs, so the intro, both install paths and the "Recall works" paragraph were keyword-guarded only. Enough to smuggle the claim back beside them rather than inside them: Capture works the same way: once connected, Claude saves your conversations to brains automatically, so there is nothing for you to do. The whole section is pinned now, heading to "## Shared layout". A partial pin was also easy to overstate when describing the guarantee. F3 — core.md said capture "is off without a token", which is wrong for a shipped Codex config: brains-turn.sh:35-42 scavenges the Authorization header from `codex mcp get brains --json` when no capture token is set, so a Codex user who configured MCP auth has capture ON while core.md told the model it was off. Capturing while denying it is the worse direction. Now "only where a capture credential resolves", with a "never deny it" counterpart to the existing "never promise it", and `list_pages type=chat_session` named as the way to actually know. core.md 2,762/3,000. F4 — "a server-driven inbox" survived unqualified in the very sentences this branch rescoped. brains-inbox.sh:35-36 carries the identical credential gate, so the inbox is false in exactly the same two configurations as capture; only capture got the qualifier. Both card surfaces now say "hook-driven turn-by-turn capture and inbox delivery". These descriptions were unpinned — which is how three of them drifted into agreement on one false claim — so the qualifier is pinned now, with a ban on reintroducing the bare form. F5 — the sweep comment claimed every block while the regex matched only ```sh. Widened to sh/bash/shell/zsh, plus an allow-list assertion so a shell dialect nobody listed fails loudly instead of going unswept. Not re-touched, clean per the same review: the version bump, char cap, region anchor uniqueness, CLAUDE_INSTALL_REGION coverage after the boundary move, the Self-hosting move, hygiene. [BRNS-MCPWEB-018] --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- plugins/brains/.claude-plugin/plugin.json | 2 +- plugins/brains/core.md | 7 +- tests/plugin-contract/run.ts | 125 +++++++++++++++++++--- 5 files changed, 116 insertions(+), 22 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e5fb4e5..06b23f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "brains", - "description": "Your memory layer: Gmail/Calendar/Drive and prior Claude conversations as queryable pages — reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", + "description": "Your memory layer: Gmail/Calendar/Drive and prior Claude conversations as queryable pages — reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and boards/automations/workflows on top.", "source": "./plugins/brains", "category": "productivity" } diff --git a/README.md b/README.md index 0f349c2..ca9cf2e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Your memory layer for Codex and Claude Code: Gmail, Calendar, Drive, and prior AI conversations as queryable pages — with reflexive recall, hook-driven -turn-by-turn capture, a server-driven inbox, boards, automations, and workflows +turn-by-turn capture and inbox delivery, boards, automations, and workflows on top. The same server also backs claude.ai, where no hooks run — see [Install for claude.ai web](#install-for-claudeai-web). diff --git a/plugins/brains/.claude-plugin/plugin.json b/plugins/brains/.claude-plugin/plugin.json index 6e2e06b..a3ef14d 100644 --- a/plugins/brains/.claude-plugin/plugin.json +++ b/plugins/brains/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "brains", - "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture, a server-driven inbox, and boards/automations/workflows on top.", + "description": "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, with reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and boards/automations/workflows on top.", "version": "2.7.1", "author": { "name": "brains (ssvlabs)" diff --git a/plugins/brains/core.md b/plugins/brains/core.md index 9f05d2e..ab13d1c 100644 --- a/plugins/brains/core.md +++ b/plugins/brains/core.md @@ -23,9 +23,10 @@ page update time is not event time. Name the source page's `title` and `type`, and never invent slugs or IDs. **Capture.** In Codex and Claude Code the ingest hook saves each turn, but only -when the user configured capture — it is off without a token, so never promise -it. Don't call `save_chat_session` routinely there; do call it when asked, and -where the hooks don't run (claude.ai web) it is the only path. +where a capture credential resolves — so never promise capture and never deny +it; `list_pages type=chat_session` is the only way to know. Don't call +`save_chat_session` routinely there; do call it when asked, and where the hooks +don't run (claude.ai web) it is the only path. **The skills carry the detail** — load the one that fits the moment: `brains-read` (querying memory), `brains-write` (sending/creating via diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 1a20eb8..7edc74d 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -39,6 +39,9 @@ const HOOK_ENV_ENDPOINT = "https://envvar-probe.example.test"; const CLAUDE_LOOPBACK = "127.0.0.1"; const CLAUDE_MCP_KEYS = ["type", "url"]; const CLAUDE_OPTIONAL_HEADING = "### Optional:"; +// The qualifier both card surfaces must carry. It scopes capture and the inbox together, because +// they share one credential gate and one delivery mechanism. +const CAPTURE_QUALIFIER = "hook-driven turn-by-turn capture and inbox delivery"; const CLAUDE_WEB_HEADING = "## Install for claude.ai web"; // The one URL a web reader needs; the connector dialog takes it verbatim. const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; @@ -56,7 +59,31 @@ const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; // performed, which is why the copy tells the reader how to verify rather than merely disclaiming. // The named signal assertions further down are kept as well — they give a precise failure on a // legitimate edit, ahead of this whole-region diff. -const CLAUDE_WEB_CAPTURE_REGION = [ +// Pinned WHOLE-SECTION, heading to the shared-layout heading. Pinning only the two capture +// paragraphs left the intro, both install paths and the "Recall works" paragraph keyword-guarded +// only — and that was enough to smuggle the claim back in a paragraph of its own. Appending this +// after "Recall works" passed every check: +// +// Capture works the same way: once connected, Claude saves your conversations to +// brains automatically, so there is nothing for you to do. +// +// A partial pin also made the guarantee easy to overstate in review. The section is short and +// changing it is a two-line diff; there is no reason for any of it to be unpinned. +const CLAUDE_WEB_REGION = [ + CLAUDE_WEB_HEADING, + "", + "claude.ai does not run this repo's hooks, so the capture that Codex and Claude", + "Code get from `hooks/` does not happen there. Two ways in, both covered step by", + "step at <" + CLAUDE_WEB_GUIDE + ">:", + "", + "- **Custom connector** — add `" + CLAUDE_MCP_URL + "` and approve the OAuth", + " screen. This is the path we verified end to end.", + "- **Full plugin** — add this repository as a marketplace and install from it.", + " Paid plans only; it also brings the skills. The hooks it lists stay inert.", + "", + "Recall works: ask about a person, project or past conversation and Claude", + "reaches for brains on its own.", + "", "**Capture is different — ask for it.** On claude.ai a conversation is saved only", "when Claude calls `save_chat_session`. Say \"save this chat to brains\" and it", "does; that is the dependable way, and the way to treat anything you want kept.", @@ -199,6 +226,26 @@ assert(codexManifest.skills === "./skills/", "Codex must use the shared skills d assert(codexManifest.mcpServers === "./.mcp.json", "Codex must load its MCP declaration"); assert(!("hooks" in codexManifest), "Codex should discover the default hooks/hooks.json"); +// Capture AND the inbox are hook-driven and credential-gated: brains-turn.sh and +// brains-inbox.sh carry the same `[ -z "$TOKEN" ] && exit 0`, and neither runs in claude.ai chat. +// Both card surfaces said "hook-driven turn-by-turn capture, a server-driven inbox" — qualifying +// only the first half, which left the inbox asserted flat in exactly the two configurations where +// it is off. Nothing pinned these descriptions, which is how three of them drifted into agreement +// on the same false claim in the first place; pin the qualifier so the next drift fails loudly. +for (const [surface, description] of [ + ["Claude manifest", claudeManifest.description], + ["Claude marketplace card", claudeMarketplace.plugins[0]?.description], +] as const) { + assert( + typeof description === "string" && description.includes(CAPTURE_QUALIFIER), + `${surface} description must qualify capture AND the inbox as "${CAPTURE_QUALIFIER}" — both are hook-driven and both are off without a credential`, + ); + assert( + !/(? 0, "core must keep its capture paragraph"); +assert( + coreCaptureEnd > coreCaptureStart, + "core's capture paragraph must precede the skills pointer — the slice below depends on it", +); assert( - !/capture is automatic|saves every turn\b/i.test(core), - "core must not promise capture unconditionally — it needs the hooks AND a configured token", + normalizeRegion(core.slice(coreCaptureStart, coreCaptureEnd)) === CORE_CAPTURE_REGION, + "core's capture paragraph must match the approved wording exactly (core.md and CORE_CAPTURE_REGION must be edited together, and core.md's v= marker bumped)", +); +// Belt and braces for a rewrite that edits the constant above too. Broader than the literals it +// replaces: "Capture happens automatically" walked straight past a `capture is automatic` ban. +assert( + !/captur\w*[^.]{0,40}automatic|saves every turn\b|assume it is on/i.test(core), + "core must not promise capture unconditionally — it needs the hooks AND a credential that resolves", ); assert(core.length < 3_000, "always-loaded core must stay below 3,000 characters"); @@ -812,12 +891,27 @@ assert( // the three-command install, where a broken line would strand a user mid-install. const claudeShellBlocks = [...claudeReadme.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]); assert(claudeShellBlocks.length > 0, "README's Claude install must keep its shell blocks"); -// Sweep EVERY block in the file, not just this slice. Region-scoped sweeping silently lost +// Sweep EVERY shell block in the file, not just this slice. Region-scoped sweeping silently lost // coverage the moment a block moved: promoting "Self-hosting" past "## Shared layout" took its // `--config endpoint=…` example outside every slice checked here, and the six Codex blocks were // never swept at all — so the one command a self-hoster copies, and the whole Codex install, were // unchecked. The syntax of a published command does not depend on which section it sits in. -const readmeShellBlocks = [...readme.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]); +// +// Matching more than ```sh is deliberate: a ```bash fence would have slipped past a bare `sh` +// pattern with the comment above still claiming full coverage. The fence-language allow-list below +// closes the same hole from the other side, so a shell dialect nobody thought of fails loudly +// rather than going unswept. +const SWEPT_SHELL_FENCES = ["sh", "bash", "shell", "zsh"]; +const fenceLanguages = [...readme.matchAll(/^```([a-zA-Z0-9_-]+)$/gm)].map((match) => match[1]); +for (const language of fenceLanguages) { + assert( + !/sh$/i.test(language) || SWEPT_SHELL_FENCES.includes(language.toLowerCase()), + `README uses a shell fence this sweep does not parse: \`\`\`${language} — add it to SWEPT_SHELL_FENCES`, + ); +} +const readmeShellBlocks = [ + ...readme.matchAll(new RegExp("```(?:" + SWEPT_SHELL_FENCES.join("|") + ")\\n([\\s\\S]*?)```", "g")), +].map((match) => match[1]); assert( readmeShellBlocks.length >= claudeShellBlocks.length, "the whole-file shell sweep must cover at least the Claude install's blocks", @@ -896,18 +990,17 @@ assert( !/\bcapture is automatic\b/i.test(webReadme), "README's web section must not claim automatic capture — no hooks run on claude.ai, on either install path", ); -// And the strong guarantee the checks above cannot give: the two capture paragraphs, verbatim. -// Every assertion above passed a rewrite that reversed their meaning (see the constant's own -// comment), so this is what actually holds the line. Changing this copy is a deliberate two-line -// diff: CLAUDE_WEB_CAPTURE_REGION and the README together. -const webCaptureStart = webReadme.indexOf("**Capture is different"); +// And the strong guarantee the keyword checks above cannot give: the WHOLE section, verbatim. +// Every assertion above passed a rewrite that reversed the meaning, twice over — once inside the +// capture paragraphs and once in a new paragraph beside them (see the constant's own comment). +// Changing this copy is a deliberate two-line diff: CLAUDE_WEB_REGION and the README together. assert( - webCaptureStart > 0, + webReadme.includes("**Capture is different"), "README's web section must keep its capture paragraphs — they are what a web user needs most", ); assert( - normalizeRegion(webReadme.slice(webCaptureStart)) === CLAUDE_WEB_CAPTURE_REGION, - "README's claude.ai capture copy must match the approved wording exactly (README and CLAUDE_WEB_CAPTURE_REGION must be edited together)", + normalizeRegion(webReadme) === CLAUDE_WEB_REGION, + "README's claude.ai section must match the approved copy exactly (README and CLAUDE_WEB_REGION must be edited together)", ); assert( From 591984e02565f689022761ec04062e07643e0b5e Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Thu, 6 Aug 2026 15:49:55 +0300 Subject: [PATCH 09/11] test(brains): assert the whole hook event map, not two of its keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #14 (BRNS-CORE-038), hook-map half only. Massimo's 2026-07-28 note records the act-contract-mirror half as already landed in #13 (b4ba6f7e). Three holes, each reproduced against this branch's own tip before fixing, each one letting published behaviour reach zero users with every suite green: 1. AN EVENT COULD BE DELETED. Only PostToolUseFailure and SessionEnd were asserted for Claude — SessionStart, UserPromptSubmit and Stop never were. Deleting SessionStart passed plugin-contract AND inbox-v2, and SessionStart is the only delivery path for core.md (brains-start.sh:27 cats it). So the verbatim copy pins this branch just added proved core.md's text was correct while nothing proved it ever loads. Guard-works is not guard-runs, and the hole was directly under this PR's own work. 2. AN EVENT COULD BE GUTTED. `includes("PostToolUseFailure")` is satisfied by the key existing, so `PostToolUseFailure: []` passed with its own assertion intact — it guaranteed a name, not a hook. Setting both SessionStart and PostToolUseFailure to [] passed. 3. THE MAPPING WAS UNPINNED. Pointing SessionStart at brains-end.sh passed. Codex carried all three identically — the ticket names only the Claude map, but one flattening helper feeds both — so both are checked the same way now. Replaces the includes-list with a per-client event -> script constant, asserted as an exact set. That closes all three at once: a deletion or an addition fails the set, an empty array fails the per-event command count, and a swapped script fails the mapping. Adding an event is now a deliberate two-line edit, the same shape as the copy pins above it. The check is beside hookScripts(), not inside it, on purpose: that helper flattens every event into one list, which is what its callers want and what loses the event identity this needs. A vacuous `SessionStart: []` contributes nothing to the flattened list, so it is invisible to anything built on it — which is how hole 2 survived. The three positive `includes` assertions are gone, subsumed by set equality. The two Codex negatives stay, deliberately redundant: those absences are a capability fact, and the set check would report adding them as a set mismatch rather than saying why. Mutation matrix, 29 cases across both clients — delete each event, empty each event, mis-map each event, add a bogus event — plus three vacuous shapes (`[]`, `[{hooks: []}]`, `[{matcher}]` with no hooks key). All 32 now fail; 0 holes remain. Test-only, no version bump. [BRNS-MCPWEB-018] --- tests/plugin-contract/run.ts | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index 7edc74d..f095709 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -42,6 +42,39 @@ const CLAUDE_OPTIONAL_HEADING = "### Optional:"; // The qualifier both card surfaces must carry. It scopes capture and the inbox together, because // they share one credential gate and one delivery mechanism. const CAPTURE_QUALIFIER = "hook-driven turn-by-turn capture and inbox delivery"; + +// The COMPLETE hook event map per client: event -> the script that must run for it. Pinned as an +// exact set rather than an includes-list because three holes sat under the old assertions, and each +// one let published behaviour reach zero users with the suite green (#14): +// +// 1. AN EVENT COULD BE DELETED. Only PostToolUseFailure and SessionEnd were asserted for Claude — +// SessionStart, UserPromptSubmit and Stop never were. Deleting SessionStart passed both +// suites, and SessionStart is the ONLY delivery path for core.md (brains-start.sh:27 cats it). +// So every verbatim copy pin in this file proved the text was correct while nothing proved it +// ever loads. Guard-works is not guard-runs. +// 2. AN EVENT COULD BE GUTTED. `includes("PostToolUseFailure")` is satisfied by the KEY existing, +// so `PostToolUseFailure: []` passed with its own assertion intact — the assertion guaranteed +// a name, not a hook. +// 3. THE MAPPING WAS UNPINNED. Pointing SessionStart at brains-end.sh passed. +// +// Codex carried all three identically, so both maps are checked the same way. Adding or removing an +// event is now a deliberate two-line edit — this constant and the JSON together — the same shape as +// the copy pins above. The three positive `includes` assertions these replace are gone: exact-set +// equality subsumes them, and keeping per-event positives for some events and not others is how the +// gap formed in the first place. +const CLAUDE_HOOK_EVENTS: Record = { + SessionStart: "brains-start.sh", + UserPromptSubmit: "brains-turn.sh", + Stop: "brains-turn.sh", + SessionEnd: "brains-end.sh", + PostToolUseFailure: "brains-tool-error.sh", +}; +const CODEX_HOOK_EVENTS: Record = { + SessionStart: "brains-start.sh", + UserPromptSubmit: "brains-turn.sh", + Stop: "brains-turn.sh", + PostToolUse: "brains-tool-error.sh", +}; const CLAUDE_WEB_HEADING = "## Install for claude.ai web"; // The one URL a web reader needs; the connector dialog takes it verbatim. const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; @@ -200,6 +233,40 @@ function hookScripts(config: any): string[] { ); } +// Deliberately NOT folded into hookScripts(): that helper flattens every event into one command +// list, which is exactly what the callers below want and exactly what loses the event identity this +// check needs. A vacuous `SessionStart: []` contributes nothing to the flattened list and is +// therefore invisible to any assertion built on it — which is how hole 2 survived. +function assertHookEventMap(label: string, config: any, expected: Record): void { + const declared = Object.keys(config.hooks ?? {}).sort(); + const wanted = Object.keys(expected).sort(); + assert( + JSON.stringify(declared) === JSON.stringify(wanted), + `${label} hook events must be exactly [${wanted.join(", ")}] — got [${declared.join(", ") || "none"}]. ` + + `Adding or removing one is a two-line edit: this JSON and ${label.toUpperCase()}_HOOK_EVENTS.`, + ); + for (const [event, script] of Object.entries(expected)) { + const groups = config.hooks[event]; + assert( + Array.isArray(groups) && groups.length > 0, + `${label} ${event} declares no matcher groups — an empty array satisfies a key-existence check and runs nothing`, + ); + const commands = groups + .flatMap((group: any) => group.hooks ?? []) + .map((hook: any) => hook.command as string); + assert( + commands.length > 0, + `${label} ${event} declares no commands — an empty array satisfies a key-existence check and runs nothing`, + ); + for (const command of commands) { + assert( + command.includes(`hooks/${script}`), + `${label} ${event} must run ${script} — got: ${command}`, + ); + } + } +} + const claudeManifest = readJson(join(PLUGIN, ".claude-plugin", "plugin.json")); const codexManifest = readJson(join(PLUGIN, ".codex-plugin", "plugin.json")); const claudeMarketplace = readJson(join(ROOT, ".claude-plugin", "marketplace.json")); @@ -261,9 +328,11 @@ assert(codexMarketplace.plugins[0]?.policy?.authentication === "ON_USE", "Codex const claudeEvents = Object.keys(claudeHooks.hooks).sort(); const codexEvents = Object.keys(codexHooks.hooks).sort(); -assert(claudeEvents.includes("PostToolUseFailure"), "Claude failure hook missing"); -assert(claudeEvents.includes("SessionEnd"), "Claude session-end hook missing"); -assert(codexEvents.includes("PostToolUse"), "Codex tool-result hook missing"); +assertHookEventMap("Claude", claudeHooks, CLAUDE_HOOK_EVENTS); +assertHookEventMap("Codex", codexHooks, CODEX_HOOK_EVENTS); +// Kept, and deliberately redundant with the exact-set check above: these two absences are a +// CAPABILITY fact — Codex supports neither event — not a gap waiting to be filled. The set check +// would reject adding them but would report it as a set mismatch; these say why. assert(!codexEvents.includes("PostToolUseFailure"), "Codex does not support PostToolUseFailure"); assert(!codexEvents.includes("SessionEnd"), "Codex does not support SessionEnd"); From 9be67b85da2513a9475f4f3295a39b5bbf5deb5e Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Thu, 6 Aug 2026 16:15:39 +0300 Subject: [PATCH 10/11] chore(tests): keep board ticket IDs out of source comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalizes the last three `BRNS-*` refs in the contract test to the form already used for the hook-map work: the traceability lives in the commit body and the PR, and the comment carries what a reader can actually act on. These three sat behind a gap in the hygiene gate rather than a decision — its hard-fail list was keyed on area codes and MCPWEB was never added, so these only ever warned while a CORE ref hard-failed. The gate now matches any `BRNS-` ref on any area, which is what surfaced them. No GitHub issue exists for the board row, so there is no `#N` to substitute. The measurement date replaces it where the anchor was evidentiary — more useful in source than an opaque ID a reader cannot open — and is dropped entirely where the following lines already spell out the two configurations. Test-only, comments only. No assertion changed. --- tests/plugin-contract/run.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index f095709..bafd148 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -87,7 +87,7 @@ const CLAUDE_WEB_GUIDE = "https://app.mybrains.ai/install/claude-web"; // satisfied EVERY signal check — each keyword survived inside a parenthetical while the meaning // was fully reversed. So the wording itself is the contract here too. // -// What the wording encodes, measured live on claude.ai (BRNS-MCPWEB-018): an explicit ask works; +// What the wording encodes, measured live on claude.ai (2026-08-05): an explicit ask works; // unprompted capture fired on ONE of five passive trials; one trial announced a save it never // performed, which is why the copy tells the reader how to verify rather than merely disclaiming. // The named signal assertions further down are kept as well — they give a precise failure on a @@ -638,7 +638,7 @@ for (const signal of [ "Once per session, when natural, mention `brains-feedback`", // The capture rule must stay conditional on BOTH axes. Nothing pinned it before, which is // how "Capture is automatic … You do not need to call save_chat_session" survived here - // while being false in two shipped configurations at once (BRNS-MCPWEB-018): + // while being false in two shipped configurations at once: // 1. no hooks at all — claude.ai web, on either install path; // 2. hooks present but capture unconfigured — brains-turn.sh:43 exits early with no token, // and that token is `required: false`. brains-start.sh:27 cats core.md with NO token @@ -1012,7 +1012,7 @@ assert( // This section exists because the rest of this README describes hook-driven capture, and a web // reader gets none of it: hooks are inert in claude.ai chat on BOTH install paths — the custom // connector and the full marketplace-sync plugin (support article 13837440). What replaces them -// was measured live rather than assumed (BRNS-MCPWEB-018): an explicit "save this chat to brains" +// was measured live rather than assumed (claude.ai, 2026-08-05): an explicit "save this chat to brains" // works, while unprompted capture fired on ONE of five passive trials — including a trial that // announced a save it never performed. The assertions below pin that distinction, because the // tempting edit is to collapse the two into one reassuring sentence, and the whole finding is From 69c4b928c87a4574a48859c992fdbf3d19e868be Mon Sep 17 00:00:00 2001 From: olegshmuelov Date: Sun, 9 Aug 2026 11:26:54 +0300 Subject: [PATCH 11/11] test(brains): pin every published surface whole, prove core.md delivery, fix the trust-prompt capture claim --- README.md | 8 +- plugins/brains/hooks/brains-turn.sh | 6 +- tests/plugin-contract/run.ts | 1050 +++++++++++++++++++++++++-- 3 files changed, 989 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index ca9cf2e..3d4792d 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ expected. Codex stores the credential itself, so there is nothing to copy or keep. Confirm with `codex mcp list`: brains should read **OAuth**. Restart the ChatGPT desktop app or start a new Codex thread. The first time the -plugin loads, open `/hooks` and trust the bundled brains hooks so automatic -recall, capture, inbox delivery, and error feedback can run. +plugin loads, open `/hooks` and trust the bundled brains hooks — that is what +runs automatic recall and error feedback. Capture and inbox delivery also need a +capture credential — normally the token below. Everyday reading and writing is covered by default. For admin-gated tools or performance insights, sign in asking for them explicitly (both also need the @@ -103,7 +104,8 @@ This flow was verified on Claude Code 2.1.220. If `claude mcp login` is not a re command, update Claude Code. Restart Claude Code or start a new session. The first time the plugin loads, trust the bundled -brains hooks so automatic recall, capture, inbox delivery, and error feedback can run. +brains hooks — that is what runs automatic recall and error feedback. Capture and inbox delivery +also need the token below. For a local checkout under development: diff --git a/plugins/brains/hooks/brains-turn.sh b/plugins/brains/hooks/brains-turn.sh index 802ca21..7fca9bd 100755 --- a/plugins/brains/hooks/brains-turn.sh +++ b/plugins/brains/hooks/brains-turn.sh @@ -15,8 +15,10 @@ # mode (notifications only, no # stdout). Fires AFTER the turn. # -# Ingest is the capture path: every turn POSTs to /ingest/claude, and the server -# builds the chat_session page. No save_chat_session call needed. +# Ingest is the capture path WHERE IT RUNS, and it is credential-gated: with a +# TOKEN (see the gate below) every turn POSTs to /ingest/claude and the server +# builds the chat_session page. Without one this hook exits silently, and where +# the hooks do not run at all (claude.ai web) save_chat_session is the only path. # Claude keeps the existing fire-and-forget delivery. Codex waits for its # assistant POST during Stop so the hook process cannot finish before the # response has been handed to the ingest endpoint. diff --git a/tests/plugin-contract/run.ts b/tests/plugin-contract/run.ts index bafd148..6a521b1 100644 --- a/tests/plugin-contract/run.ts +++ b/tests/plugin-contract/run.ts @@ -39,9 +39,40 @@ const HOOK_ENV_ENDPOINT = "https://envvar-probe.example.test"; const CLAUDE_LOOPBACK = "127.0.0.1"; const CLAUDE_MCP_KEYS = ["type", "url"]; const CLAUDE_OPTIONAL_HEADING = "### Optional:"; -// The qualifier both card surfaces must carry. It scopes capture and the inbox together, because +// The qualifier the Claude surfaces must carry. It scopes capture and the inbox together, because // they share one credential gate and one delivery mechanism. const CAPTURE_QUALIFIER = "hook-driven turn-by-turn capture and inbox delivery"; +// Codex's card says it shorter: it qualifies capture and never claimed the inbox, so the same rule +// lands on a different string. Reverting this one word — back to "automatic context and capture +// plus" — was one of the two unpinned surfaces that restored the deleted claim with every gate green. +const CODEX_CAPTURE_QUALIFIER = "hook-driven capture"; + +// The four product descriptions, pinned VERBATIM. The qualifier check below is a signal, not the +// guarantee: it reads what is LEFT once the approved phrase is removed, so a rewrite that keeps the +// qualifier and then contradicts it — "…capture and inbox delivery — always on, no token or +// configuration needed, in every chat including claude.ai…" — passes it, and so does one that avoids +// both stems: "Every turn is recorded to brains and server messages reach your session on their +// own." Both were demonstrated in review. These constants are what actually hold the copy. +const CLAUDE_MANIFEST_DESCRIPTION = + "Your memory layer: Gmail, Calendar, Drive, and prior Claude conversations as queryable pages, " + + "with reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and " + + "boards/automations/workflows on top."; +const CLAUDE_MARKETPLACE_DESCRIPTION = + "Your memory layer: Gmail/Calendar/Drive and prior Claude conversations as queryable pages — " + + "reflexive recall, hook-driven turn-by-turn capture and inbox delivery, and " + + "boards/automations/workflows on top."; +const CODEX_MANIFEST_DESCRIPTION = + "Your personal memory layer for Codex: query Gmail, Calendar, Drive, and prior conversations, " + + "then build boards, automations, and workflows."; +const CODEX_LONG_DESCRIPTION = + "Query your Gmail, Calendar, Drive, and prior AI conversations from Codex, with automatic context " + + "and hook-driven capture plus boards, automations, workflows, and feedback flows."; +// The rest of the copy on the same two cards. `shortDescription` is the reason the artifacts below +// are pinned WHOLE rather than field by field: it was not in the inventory, and setting it to +// "Automatic capture, no setup" reached the Codex card with every gate green. +const CODEX_SHORT_DESCRIPTION = "Your personal memory layer"; +const CODEX_DISPLAY_NAME = "Brains"; +const CLAUDE_MARKETPLACE_BLURB = "The brains memory layer, as a Claude Code plugin."; // The COMPLETE hook event map per client: event -> the script that must run for it. Pinned as an // exact set rather than an includes-list because three holes sat under the old assertions, and each @@ -62,18 +93,133 @@ const CAPTURE_QUALIFIER = "hook-driven turn-by-turn capture and inbox delivery"; // the copy pins above. The three positive `includes` assertions these replace are gone: exact-set // equality subsumes them, and keeping per-event positives for some events and not others is how the // gap formed in the first place. -const CLAUDE_HOOK_EVENTS: Record = { - SessionStart: "brains-start.sh", - UserPromptSubmit: "brains-turn.sh", - Stop: "brains-turn.sh", - SessionEnd: "brains-end.sh", - PostToolUseFailure: "brains-tool-error.sh", +// +// The value is the COMPLETE declared group array, not a {matcher, command} subset, because three more +// holes sat under a subset check and each one was executed: +// +// 4. THE MATCHER WAS UNPINNED. Narrowing Claude's SessionStart to "resume" — or to a string that +// matches nothing — deletes core.md from every fresh session while every assertion here stays +// green. That is hole 1 wearing a different hat. +// 5. THE COMMAND WAS MATCHED BY SUBSTRING. `includes("hooks/brains-start.sh")` is satisfied by a +// MENTION, so a second hook running something else with `# hooks/brains-start.sh` in a trailing +// comment passed. Commands are pinned whole, guard prefix included. +// 6. EVERY OTHER KEY WAS INVISIBLE. `"type": "comand"` on Stop and `"timeout": 1` on SessionStart — +// a documented Claude field, which would cancel brains-start.sh mid-inbox-pull — both survived a +// subset pin AND `claude plugin validate --strict`. So the whole group array is compared, by +// canonical JSON: added, removed and retyped keys all fail. +// +// An absent matcher is asserted as an ABSENCE rather than as null: these events must run for EVERY +// occurrence, and both a matcher and an explicit null narrow them. +type HookGroup = { + matcher?: string; + hooks: { type: string; command: string; statusMessage?: string }[]; }; -const CODEX_HOOK_EVENTS: Record = { - SessionStart: "brains-start.sh", - UserPromptSubmit: "brains-turn.sh", - Stop: "brains-turn.sh", - PostToolUse: "brains-tool-error.sh", +const CLAUDE_HOOK_EVENTS: Record = { + SessionStart: [ + { + matcher: "startup|resume|clear", + hooks: [ + { + type: "command", + command: 'BRAINS_STATE_DIR="${CLAUDE_PLUGIN_DATA}" "${CLAUDE_PLUGIN_ROOT}"/hooks/brains-start.sh', + }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: 'BRAINS_STATE_DIR="${CLAUDE_PLUGIN_DATA}" "${CLAUDE_PLUGIN_ROOT}"/hooks/brains-turn.sh', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: 'BRAINS_STATE_DIR="${CLAUDE_PLUGIN_DATA}" "${CLAUDE_PLUGIN_ROOT}"/hooks/brains-turn.sh', + }, + ], + }, + ], + SessionEnd: [ + { + hooks: [ + { + type: "command", + command: 'BRAINS_STATE_DIR="${CLAUDE_PLUGIN_DATA}" "${CLAUDE_PLUGIN_ROOT}"/hooks/brains-end.sh', + }, + ], + }, + ], + PostToolUseFailure: [ + { + matcher: "mcp__brains__.*", + hooks: [ + { + type: "command", + command: 'BRAINS_STATE_DIR="${CLAUDE_PLUGIN_DATA}" "${CLAUDE_PLUGIN_ROOT}"/hooks/brains-tool-error.sh', + }, + ], + }, + ], +}; +const CODEX_HOOK_EVENTS: Record = { + SessionStart: [ + { + matcher: "startup|resume|clear|compact", + hooks: [ + { + type: "command", + command: + '[ -x "${PLUGIN_ROOT}/hooks/brains-start.sh" ] || exit 0; ' + + 'BRAINS_STATE_DIR="${PLUGIN_DATA}" "${PLUGIN_ROOT}"/hooks/brains-start.sh', + statusMessage: "Loading brains memory", + }, + ], + }, + ], + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: + '[ -x "${PLUGIN_ROOT}/hooks/brains-turn.sh" ] || exit 0; ' + + 'BRAINS_STATE_DIR="${PLUGIN_DATA}" "${PLUGIN_ROOT}"/hooks/brains-turn.sh', + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: + '[ -x "${PLUGIN_ROOT}/hooks/brains-turn.sh" ] || exit 0; ' + + 'BRAINS_STATE_DIR="${PLUGIN_DATA}" "${PLUGIN_ROOT}"/hooks/brains-turn.sh', + }, + ], + }, + ], + PostToolUse: [ + { + matcher: "mcp__brains__.*", + hooks: [ + { + type: "command", + command: + '[ -x "${PLUGIN_ROOT}/hooks/brains-tool-error.sh" ] || exit 0; ' + + 'BRAINS_STATE_DIR="${PLUGIN_DATA}" "${PLUGIN_ROOT}"/hooks/brains-tool-error.sh', + }, + ], + }, + ], }; const CLAUDE_WEB_HEADING = "## Install for claude.ai web"; // The one URL a web reader needs; the connector dialog takes it verbatim. @@ -162,6 +308,112 @@ const CLAUDE_VERSION_SHAPE = /\bv?\d+\.\d+(\.\d+)?\b/; const CLAUDE_FLOOR_VOCAB = /\b(minimum|at least|no older|requires?|or (a )?(newer|later)|and (later|up)|or above|and above)\b/i; +// The README's opening paragraphs, pinned verbatim. This is a fixed product surface exactly like the +// two cards — it is what a reader sees before any install step — and nothing pinned it, so reverting +// three lines to the pre-PR intro ("reflexive recall, turn-by-turn capture, a server-driven inbox") +// restored the deleted claim with all four gates green. +const README_INTRO_REGION = [ + "# brains — Codex and Claude Code plugin", + "", + "Your memory layer for Codex and Claude Code: Gmail, Calendar, Drive, and prior", + "AI conversations as queryable pages — with reflexive recall, hook-driven", + "turn-by-turn capture and inbox delivery, boards, automations, and workflows", + "on top. The same server also backs claude.ai, where no hooks run — see", + "[Install for claude.ai web](#install-for-claudeai-web).", + "", + "The Codex and Claude packages share the same seven skills, core prompt, hook", + "scripts, and inbox engine. Only their manifests, hook event maps, and MCP", + "authentication declarations are client-specific.", +].join("\n"); + +// The entire Codex install region, heading to its Optional heading, pinned verbatim for the same +// reason as the Claude one below. Two literal bans were all that stood here, and a false promise +// paraphrased around them passed the whole suite: "Once trusted, every conversation is captured +// automatically — no token needed." dropped in after the trust paragraph is a claim this PR exists +// to delete, on the surface a Codex user reads while deciding whether to trust the hooks. +const CODEX_INSTALL_REGION = [ + "## Install for Codex", + "", + "No token needed — Codex signs itself in.", + "", + "Needs Codex **" + CODEX_MIN_VERSION + "** or newer. Check with:", + "", + "```sh", + CODEX_CAPABILITY_PROBE, + "```", + "", + "If that errors with an unknown subcommand, run `codex update` first.", + "", + "```sh", + "codex plugin marketplace add ssvlabs/brains-plugins", + "codex plugin add brains@brains", + "codex mcp login brains", + "```", + "", + "`codex mcp login brains` opens your browser to approve the connection. The", + "approval screen says **An app on this computer** and shows a `127.0.0.1` address", + "whose port changes every time — that is Codex waiting on your machine, and it is", + "expected. Codex stores the credential itself, so there is nothing to copy or", + "keep. Confirm with `codex mcp list`: brains should read **OAuth**.", + "", + "Restart the ChatGPT desktop app or start a new Codex thread. The first time the", + "plugin loads, open `/hooks` and trust the bundled brains hooks — that is what", + "runs automatic recall and error feedback. Capture and inbox delivery also need a", + "capture credential — normally the token below.", + "", + "Everyday reading and writing is covered by default. For admin-gated tools or", + "performance insights, sign in asking for them explicitly (both also need the", + "matching access on your account):", + "", + "```sh", + "codex mcp login brains --scopes read,write,admin", + "codex mcp login brains --scopes read,write,perf_insights", + "```", + "", + "For a local checkout under development:", + "", + "```sh", + "codex plugin marketplace add /absolute/path/to/brains-plugins", + "codex plugin add brains@brains", + "codex mcp login brains", + "```", +].join("\n"); + +// The Codex token section, heading line INCLUDED. It was left unpinned on the argument that "token" +// is legitimate here — it IS the token section — so pinning would freeze docs that should stay +// editable. Two executed evasions retired that argument: the heading itself carries copy, and +// rewriting it to "### Optional setup? No — this token is required for every brains feature" left +// the `### Optional` needle counting once while inverting what the section says; and a capture +// promise with neither stem ("Conversation capture is built in. It runs by itself for every +// session, and server updates arrive there too.") passed the canary in the body. Editing stays +// possible; it is now the deliberate two-line diff every other region in this file already demands. +const CODEX_OPTIONAL_REGION = [ + "### Optional: conversation capture and the inbox", + "", + "The tools above work without this. Capture and the inbox are shell hooks that", + "authenticate separately from the MCP server and cannot read the credential Codex", + "keeps internally, so they need a brains API token of their own — find it in your", + "brains account settings. Without one they simply stay off.", + "", + "```sh", + 'export BRAINS_API_TOKEN=""', + "```", + "", + "That applies to Codex started from that shell. The macOS desktop app never", + "inherits a shell export, so set it for the app's launch environment instead and", + "restart the app:", + "", + "```sh", + 'launchctl setenv BRAINS_API_TOKEN ""', + "```", + "", + "This token is only for capture and the inbox. It is **not** how Codex", + "authenticates the brains tools — that is `codex mcp login brains` above.", + "", + "Running your own brains server? Set `BRAINS_ENDPOINT` alongside it — see", + "[Self-hosting](#self-hosting).", +].join("\n"); + // The entire region from the Claude install heading to the Optional heading, pinned verbatim. // Enumerated bans on this region kept losing to paraphrase, so the copy IS the contract. const CLAUDE_INSTALL_REGION = [ @@ -187,7 +439,8 @@ const CLAUDE_INSTALL_REGION = [ CLAUDE_VERSION_NOTE, "", "Restart Claude Code or start a new session. The first time the plugin loads, trust the bundled", - "brains hooks so automatic recall, capture, inbox delivery, and error feedback can run.", + "brains hooks — that is what runs automatic recall and error feedback. Capture and inbox delivery", + "also need the token below.", "", "For a local checkout under development:", "", @@ -198,6 +451,105 @@ const CLAUDE_INSTALL_REGION = [ "```", ].join("\n"); +// The Claude token section and the migration section, heading lines included, pinned for the same +// reason as the Codex one above. +const CLAUDE_OPTIONAL_REGION = [ + "### Optional: conversation capture and the inbox", + "", + "The tools above work without this. Capture and the inbox are shell hooks that authenticate", + "separately from the MCP server and cannot read the credential Claude Code keeps internally, so", + "they need a brains API token of their own — find it in your brains account settings. Without one", + "they simply stay off.", + "", + "Set it when you install:", + "", + "```sh", + 'claude plugin install brains@brains --config token=""', + "```", + "", + "Or change it afterwards with `/plugin` → brains → Configure.", + "", + "This token is only for capture and the inbox. It is **not** how Claude Code authenticates the", + "brains tools — that is `" + CLAUDE_MCP_LOGIN + "` above.", +].join("\n"); +const CLAUDE_MIGRATION_REGION = [ + "### Already installed?", + "", + "Plugins added before the sign-in flow carried the token in their MCP declaration and never", + "logged in. Update, then sign in:", + "", + "```sh", + "claude plugin marketplace update brains", + "claude plugin update brains", + CLAUDE_MCP_LOGIN, + "```", + "", + "Then run `/reload-plugins`.", +].join("\n"); + +// The file inventory and the licence pointer. Neither makes a claim a user acts on, but both are +// pinned anyway so that the composition assert below can cover the WHOLE file: an unpinned region, +// however inert its contents, is somewhere to write a capture promise (one was, in review). +const SHARED_LAYOUT_REGION = [ + "## Shared layout", + "", + "- `.agents/plugins/marketplace.json` — Codex marketplace", + "- `.claude-plugin/marketplace.json` — Claude Code marketplace", + "- `plugins/brains/.codex-plugin/plugin.json` — Codex manifest", + "- `plugins/brains/.claude-plugin/plugin.json` — Claude Code manifest", + "- `plugins/brains/.mcp.json` — Codex MCP declaration", + "- `plugins/brains/skills/` — shared skills", + "- `plugins/brains/hooks/` — shared scripts plus client-specific event maps", +].join("\n"); +const LICENSE_REGION = ["## License", "", "[GPL-3.0](./LICENSE)"].join("\n"); + +// The self-hosting section, pinned verbatim. Its claims are load-bearing in exactly the way the +// endpoint config's description is — it names the fallback host by name and says which halves of the +// install the `endpoint` option governs — and the endpoint probes at the bottom of this file prove +// all of that about the CODE while nothing proved the prose still agreed with it. For a self-hoster +// the failure is silent: data going to production, not an error. +const SELF_HOSTING_REGION = [ + "## Self-hosting", + "", + "The brains tools connect to `" + CLAUDE_MCP_URL + "`; to point them at your own", + "server, fork this repo, set the URL in `plugins/brains/.claude-plugin/plugin.json` and", + "`plugins/brains/.mcp.json`, and add your fork as the marketplace.", + "", + "That moves the tools only. Conversation capture and the inbox read the `endpoint` option", + "instead, so set it when you install or they keep sending to `" + CLAUDE_ENDPOINT + "`:", + "", + "```sh", + "claude plugin install brains@brains --config endpoint=https://your-server", + "```", + "", + "Changing `endpoint`'s `default` in your fork does not cover this. Claude Code exports", + "`CLAUDE_PLUGIN_OPTION_ENDPOINT` to the hooks from the value stored in your settings, and", + "an option you never set has no stored value — so the hooks fall back to", + "`" + CLAUDE_ENDPOINT + "` while your tools talk to your own server.", + "", + "Codex has no such option and runs the same hook scripts, so set `BRAINS_ENDPOINT` wherever", + "you set `BRAINS_API_TOKEN` above — the shell Codex starts from, or the app's launch", + "environment. Without it Codex capture keeps sending to `" + CLAUDE_ENDPOINT + "` too.", +].join("\n"); + +// Best-effort canary over the WHOLE README, in the shape of the core.md backstop further down: +// neither sound nor complete, but it catches the canonical regression — capture or the inbox +// presented as unconditional. Every region is pinned now, so its job is no longer to cover unpinned +// prose; it is the backstop for a rewrite that edits a region constant in the same diff, which is +// the one move every verbatim pin in this file is blind to. Kept deliberately weak-but-broad for +// that reason: it caught "Once trusted, every conversation is captured automatically — no token +// needed." while a pin would have been edited around it. It is NOT a substitute for the pins — the +// paraphrase "Conversation capture is built in. It runs by itself for every session." carries +// neither pattern and walked straight past it. +const UNCONDITIONAL_CAPTURE = [ + /captur\w*[^.]{0,60}\bautomatic/i, + /\bautomatic(ally)?\b[^.]{0,60}captur/i, + /\b(saves|records|captures)\s+(every|each)\s+(turn|conversation|chat|message)\b/i, + /\bevery\s+(turn|conversation|chat|message)\b[^.]{0,60}\b(is|are)\s+(captured|recorded|saved)\b/i, + /(captur\w*|inbox)[^.]{0,80}\b(no token|without a token|nothing to configure|no configuration|always on)\b/i, + /\b(no token|nothing to configure|always on)\b[^.]{0,80}(captur\w*|inbox)/i, +]; + class AssertionError extends Error {} function readJson(path: string): any { @@ -226,6 +578,101 @@ const normalizeCopy = (value: string): string => value.replace(/\s+/g, " ").trim // .editorconfig that would ever introduce benign churn. Line endings only. const normalizeRegion = (value: string): string => value.replace(/\r\n?/g, "\n").replace(/\n+$/, ""); +// Key ORDER is not part of a JSON contract; every other difference is. Sorting keys recursively lets +// a shape pin ignore reordering and catch an added, removed or retyped key. +const canonicalJson = (value: unknown): string => + JSON.stringify(value, (_key, val) => + val !== null && typeof val === "object" && !Array.isArray(val) + ? Object.fromEntries(Object.keys(val).sort().map((k) => [k, (val as any)[k]])) + : val, + ); + +// The named signal ahead of the verbatim description pins: a capture or inbox claim on a fixed +// product surface must carry the hook-driven qualifier, and neither stem may appear outside it. +// This is a SIGNAL, not the guarantee. It reads the RESIDUE — what is left once the approved phrase +// is removed — so it catches the pre-PR copy ("reflexive recall, turn-by-turn capture, a +// server-driven inbox") and a loose stem in any rewording that keeps one, while it is blind to a +// claim that keeps the qualifier and then contradicts it ("…— always on, no token or configuration +// needed…") and to one that avoids both stems ("Every turn is recorded to brains"). Both were +// demonstrated against it in review, which is why every caller pins its copy verbatim as well; this +// says WHICH rule a legitimate edit broke, in one line, before the reader gets a two-string diff. +// The enumerated `server-driven inbox` ban it replaces is folded in — that regex caught exactly that +// one string, and the residue covers it plus every rewording that leaves a stem loose. +// A null qualifier means the surface makes no such claim today and must not start. +function assertCaptureQualified( + surface: string, + text: unknown, + qualifier: string | null, + alsoAllowed: string[] = [], +): void { + assert(typeof text === "string" && text !== "", `${surface} must carry a description`); + const copy = normalizeCopy(text as string); + if (qualifier !== null) { + assert( + copy.includes(qualifier), + `${surface} must carry the approved qualifier "${qualifier}" — a bare capture or inbox claim is false for every user without a credential, and in every claude.ai chat`, + ); + } + let residue = copy; + for (const approved of [...(qualifier === null ? [] : [qualifier]), ...alsoAllowed]) { + residue = residue.split(approved).join(" "); + } + for (const [claim, pattern] of [["capture", /captur/i], ["the inbox", /\binbox\b/i]] as const) { + assert( + !pattern.test(residue), + `${surface} claims ${claim} outside the approved qualifier — it is hook-driven, credential-gated, and runs in no web chat. Unqualified: ${JSON.stringify(residue)}`, + ); + } +} + +// Every slice boundary in this file resolves through here, at the START OF A LINE. Raw `indexOf` +// takes any occurrence, including one glued mid-line: appending "## Shared layout" with no space to +// the last sentence of the web section ("…confirmation.## Shared layout") moved the boundary onto +// the decoy, left the pinned slice matching exactly, and put every paragraph after it outside all +// regions — while the line-anchored uniqueness count below never saw a second heading. +const headingPattern = (heading: string): RegExp => + new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "gm"); +const headingIndex = (scope: string, heading: string): number => + headingPattern(heading).exec(scope)?.index ?? -1; + +type Fence = { language: string; body: string }; + +// ONE pass over every fence, because discovery and extraction disagreeing is the bug: the fence +// allow-list lower-cased the language while the extractor did not, so ```Bash was certified as swept +// and then never parsed. Tilde fences and info strings were invisible to both — ```sh title=install +// matched neither the `^```([a-zA-Z0-9_-]+)$` discovery pattern nor the ```sh\n extractor. The lines +// OUTSIDE every fence come back too, so the container-context check further down needs no second scan. +// CommonMark, to the extent this file needs it: an opener is three or more backticks or tildes +// indented at most three spaces, the language is the FIRST word of the info string, and a closer is +// the same character, at least as long, carrying no info string. +function markdownFences(markdown: string): { fences: Fence[]; outside: string[] } { + const fences: Fence[] = []; + const outside: string[] = []; + let open: { marker: string; language: string; body: string[] } | null = null; + for (const line of markdown.split("\n")) { + const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (open) { + const closes = fence + && fence[1][0] === open.marker[0] + && fence[1].length >= open.marker.length + && fence[2].trim() === ""; + if (closes) { + fences.push({ language: open.language, body: open.body.map((body) => `${body}\n`).join("") }); + open = null; + } else { + open.body.push(line); + } + continue; + } + outside.push(line); + if (fence) { + open = { marker: fence[1], language: fence[2].trim().split(/\s+/)[0].toLowerCase(), body: [] }; + } + } + assert(open === null, "README has an unclosed code fence — the sweep cannot see inside it"); + return { fences, outside }; +} + function hookScripts(config: any): string[] { return Object.values(config.hooks ?? {}).flatMap((groups: any) => groups.flatMap((group: any) => group.hooks ?? []) @@ -237,7 +684,7 @@ function hookScripts(config: any): string[] { // list, which is exactly what the callers below want and exactly what loses the event identity this // check needs. A vacuous `SessionStart: []` contributes nothing to the flattened list and is // therefore invisible to any assertion built on it — which is how hole 2 survived. -function assertHookEventMap(label: string, config: any, expected: Record): void { +function assertHookEventMap(label: string, config: any, expected: Record): void { const declared = Object.keys(config.hooks ?? {}).sort(); const wanted = Object.keys(expected).sort(); assert( @@ -245,25 +692,68 @@ function assertHookEventMap(label: string, config: any, expected: Record 0, - `${label} ${event} declares no matcher groups — an empty array satisfies a key-existence check and runs nothing`, + Array.isArray(declaredGroups), + `${label} ${event} must declare an array of matcher groups — got ${JSON.stringify(declaredGroups)}`, ); - const commands = groups - .flatMap((group: any) => group.hooks ?? []) - .map((hook: any) => hook.command as string); assert( - commands.length > 0, - `${label} ${event} declares no commands — an empty array satisfies a key-existence check and runs nothing`, + declaredGroups.length === groups.length, + `${label} ${event} must declare exactly ${groups.length} matcher group(s) — got ${declaredGroups.length}. ` + + `An empty array satisfies a key-existence check and runs nothing; a second group is a second delivery path, and a duplicate is a doubled one.`, ); - for (const command of commands) { + groups.forEach((group, index) => { + const declaredGroup = declaredGroups[index]; assert( - command.includes(`hooks/${script}`), - `${label} ${event} must run ${script} — got: ${command}`, + declaredGroup !== null && typeof declaredGroup === "object", + `${label} ${event} group ${index} must be an object — got ${JSON.stringify(declaredGroup)}`, ); - } + if (group.matcher === undefined) { + assert( + !("matcher" in declaredGroup), + `${label} ${event} group ${index} must declare NO matcher — it runs on every ${event}, and both a matcher and an explicit null narrow it`, + ); + } else { + assert( + declaredGroup.matcher === group.matcher, + `${label} ${event} group ${index} matcher must be exactly "${group.matcher}" — got ${JSON.stringify(declaredGroup.matcher)}. ` + + `Narrowing it drops deliveries silently, and a matcher that matches nothing runs nothing.`, + ); + } + const declaredHooks = declaredGroup.hooks; + assert( + Array.isArray(declaredHooks), + `${label} ${event} group ${index} must declare an array of hooks — got ${JSON.stringify(declaredHooks)}`, + ); + for (const hook of declaredHooks) { + assert( + hook !== null && typeof hook === "object" && typeof hook.command === "string", + `${label} ${event} group ${index} declares a hook with no string command — got ${JSON.stringify(hook)}`, + ); + } + const declaredCommands = declaredHooks.map((hook: any) => hook.command as string); + const wantedCommands = group.hooks.map((hook) => hook.command); + // Named signal ahead of the two pins below: a mis-mapped event (hole 3) reads as one line here + // rather than as a two-string diff the reader has to spot for themselves. + wantedCommands.forEach((command, hookIndex) => { + const script = command.match(/hooks\/(brains-[a-z-]+\.sh)/)?.[1]; + assert(script, `${label} ${event} expectation names no brains script — fix ${label.toUpperCase()}_HOOK_EVENTS`); + assert( + declaredCommands[hookIndex]?.includes(`hooks/${script}`), + `${label} ${event} must run ${script} — got: ${declaredCommands[hookIndex]}`, + ); + }); + assert( + JSON.stringify(declaredCommands) === JSON.stringify(wantedCommands), + `${label} ${event} group ${index} must run exactly ${JSON.stringify(wantedCommands)} — got ${JSON.stringify(declaredCommands)}`, + ); + }); + assert( + canonicalJson(declaredGroups) === canonicalJson(groups), + `${label} ${event} must declare exactly this shape — a key outside matcher and command changes what runs (type, statusMessage, timeout):\n` + + `expected ${canonicalJson(groups)}\ngot ${canonicalJson(declaredGroups)}`, + ); } } @@ -275,6 +765,8 @@ const claudeHooks = readJson(join(PLUGIN, "hooks", "claude-hooks.json")); const codexHooks = readJson(join(PLUGIN, "hooks", "hooks.json")); const codexMcp = readJson(join(PLUGIN, ".mcp.json")); const claudeManifestSource = readFileSync(join(PLUGIN, ".claude-plugin", "plugin.json"), "utf8"); +const claudeHooksSource = readFileSync(join(PLUGIN, "hooks", "claude-hooks.json"), "utf8"); +const codexHooksSource = readFileSync(join(PLUGIN, "hooks", "hooks.json"), "utf8"); const turnHook = readFileSync(join(PLUGIN, "hooks", "brains-turn.sh"), "utf8"); const inboxHook = readFileSync(join(PLUGIN, "hooks", "lib", "brains-inbox.sh"), "utf8"); const readme = readFileSync(join(ROOT, "README.md"), "utf8"); @@ -295,21 +787,152 @@ assert(!("hooks" in codexManifest), "Codex should discover the default hooks/hoo // Capture AND the inbox are hook-driven and credential-gated: brains-turn.sh and // brains-inbox.sh carry the same `[ -z "$TOKEN" ] && exit 0`, and neither runs in claude.ai chat. -// Both card surfaces said "hook-driven turn-by-turn capture, a server-driven inbox" — qualifying -// only the first half, which left the inbox asserted flat in exactly the two configurations where -// it is off. Nothing pinned these descriptions, which is how three of them drifted into agreement -// on the same false claim in the first place; pin the qualifier so the next drift fails loudly. -for (const [surface, description] of [ - ["Claude manifest", claudeManifest.description], - ["Claude marketplace card", claudeMarketplace.plugins[0]?.description], +// Both Claude card surfaces said "hook-driven turn-by-turn capture, a server-driven inbox" — +// qualifying only the first half, which left the inbox asserted flat in exactly the two +// configurations where it is off. Nothing pinned any of these descriptions, which is how three of +// them drifted into agreement on the same false claim in the first place. +// +// All four are checked twice: the qualifier signal names which rule broke, then the verbatim pin +// holds the copy. The signal alone is not enough and this was demonstrated, not theorised — a +// description that carries the qualifier and then contradicts it, and one that drops both stems, +// each passed the signal with the meaning reversed. The Codex card is qualified more narrowly +// because it claims less: its longDescription says "hook-driven capture" and its description makes +// no capture or inbox claim at all, so the null arm asserts that it does not start making one. +for (const [surface, description, qualifier, approved] of [ + ["Claude manifest", claudeManifest.description, CAPTURE_QUALIFIER, CLAUDE_MANIFEST_DESCRIPTION], + ["Claude marketplace", claudeMarketplace.description, null, CLAUDE_MARKETPLACE_BLURB], + ["Claude marketplace card", claudeMarketplace.plugins[0]?.description, CAPTURE_QUALIFIER, CLAUDE_MARKETPLACE_DESCRIPTION], + ["Codex manifest", codexManifest.description, null, CODEX_MANIFEST_DESCRIPTION], + ["Codex manifest interface.longDescription", codexManifest.interface?.longDescription, CODEX_CAPTURE_QUALIFIER, CODEX_LONG_DESCRIPTION], + ["Codex manifest interface.shortDescription", codexManifest.interface?.shortDescription, null, CODEX_SHORT_DESCRIPTION], + ["Codex manifest interface.displayName", codexManifest.interface?.displayName, null, CODEX_DISPLAY_NAME], + ["Codex marketplace interface.displayName", codexMarketplace.interface?.displayName, null, CODEX_DISPLAY_NAME], ] as const) { + assertCaptureQualified(surface, description, qualifier); assert( - typeof description === "string" && description.includes(CAPTURE_QUALIFIER), - `${surface} description must qualify capture AND the inbox as "${CAPTURE_QUALIFIER}" — both are hook-driven and both are off without a credential`, + normalizeCopy(description as string) === approved, + `${surface} description must match the approved copy exactly (the card and its constant at the top of this file must be edited together)`, ); +} +// The Codex marketplace card carries NO description today. One appearing later would be a fifth +// product surface arriving unpinned — precisely the state the four above were in — so it has to +// arrive with its own constant and its own pin rather than on its own. +assert( + !("description" in (codexMarketplace.plugins[0] ?? {})), + "Codex marketplace card must carry no description — a new one is a new product surface and needs its own verbatim pin here", +); +// One plugin per marketplace. Every check that reads `plugins[0]` — the card description above, the +// source and policy checks below — is blind to a second entry, which would ship its own name, +// description and source to the same users. +for (const [label, marketplace] of [ + ["Claude", claudeMarketplace], + ["Codex", codexMarketplace], +] as const) { + assert( + Array.isArray(marketplace.plugins) && marketplace.plugins.length === 1, + `${label} marketplace must list exactly one plugin — everything here reads plugins[0], so a second entry ships unread (got ${marketplace.plugins?.length ?? "none"})`, + ); +} + +// The four published JSON artifacts, pinned WHOLE by canonical JSON. Every assertion above names a +// field somebody thought of; this names the file. `interface.shortDescription` is why: it was in +// neither the copy inventory nor any allow-list, so setting it to "Automatic capture, no setup" +// reached the Codex card with all four gates green. Field-by-field inventories only ever cover the +// fields already known — the same argument that made CORE_BODY the whole file and the README the +// composition of its regions. A key added to any of these now fails here rather than shipping +// unread, and the named assertions above stay ahead to say WHICH rule broke. +// +// `version` is read from the file rather than pinned: bumping it is the delivery guard's business, +// and the two manifests' agreement is already asserted at the top of this file. Everything else — +// author, homepage, keywords, capabilities, defaultPrompt, brandColor, the endpoint config's title, +// the marketplace schema and owner — is copy or contract that reaches a user and nothing else read. +const CLAUDE_MANIFEST = { + name: "brains", + description: CLAUDE_MANIFEST_DESCRIPTION, + version: claudeManifest.version, + author: { name: "brains (ssvlabs)" }, + homepage: "https://mybrains.ai", + hooks: "./hooks/claude-hooks.json", + userConfig: { + token: { + type: "string", + title: CLAUDE_TOKEN_TITLE, + description: CLAUDE_TOKEN_DESCRIPTION, + sensitive: true, + required: false, + }, + endpoint: { + type: "string", + title: "brains endpoint", + description: CLAUDE_ENDPOINT_DESCRIPTION, + default: CLAUDE_ENDPOINT, + }, + }, + mcpServers: { brains: { type: "http", url: CLAUDE_MCP_URL } }, +}; +const CODEX_MANIFEST = { + name: "brains", + version: codexManifest.version, + description: CODEX_MANIFEST_DESCRIPTION, + author: { name: "brains (ssvlabs)", url: "https://mybrains.ai" }, + homepage: "https://mybrains.ai", + repository: "https://github.com/ssvlabs/brains-plugins", + license: "GPL-3.0", + keywords: ["memory", "productivity", "gmail", "calendar", "drive", "automation"], + skills: "./skills/", + mcpServers: "./.mcp.json", + interface: { + displayName: CODEX_DISPLAY_NAME, + shortDescription: CODEX_SHORT_DESCRIPTION, + longDescription: CODEX_LONG_DESCRIPTION, + developerName: "ssvlabs", + category: "Productivity", + capabilities: ["Read", "Write", "Automate"], + websiteURL: "https://mybrains.ai", + defaultPrompt: [ + "What should I know about today?", + "Find what I discussed about this project.", + "Help me build a tracker from my memory.", + ], + brandColor: "#6D5EF5", + }, +}; +const CLAUDE_MARKETPLACE = { + $schema: "https://anthropic.com/claude-code/marketplace.schema.json", + name: "brains", + description: CLAUDE_MARKETPLACE_BLURB, + owner: { name: "ssvlabs" }, + plugins: [ + { + name: "brains", + description: CLAUDE_MARKETPLACE_DESCRIPTION, + source: "./plugins/brains", + category: "productivity", + }, + ], +}; +const CODEX_MARKETPLACE = { + name: "brains", + interface: { displayName: CODEX_DISPLAY_NAME }, + plugins: [ + { + name: "brains", + source: { source: "local", path: "./plugins/brains" }, + policy: { installation: "AVAILABLE", authentication: "ON_USE" }, + category: "Productivity", + }, + ], +}; +for (const [artifact, declared, approved] of [ + [".claude-plugin/plugin.json", claudeManifest, CLAUDE_MANIFEST], + [".codex-plugin/plugin.json", codexManifest, CODEX_MANIFEST], + [".claude-plugin/marketplace.json", claudeMarketplace, CLAUDE_MARKETPLACE], + [".agents/plugins/marketplace.json", codexMarketplace, CODEX_MARKETPLACE], +] as const) { assert( - !/(?", + "# brains — your memory layer", + "", + "You have a memory layer called **brains** (the `brains` MCP server). It holds the", + "user's Gmail, Calendar, Drive, and prior AI conversations as queryable pages.", + "Treat it as a first-class source of truth about the user's life and work.", + "", + "**Query brains reflexively.** If a request depends on a person, project,", + "meeting, email, document, prior discussion, or \"what did I see,\" look in brains", + "before guessing, asking the user, web search, browser fetches, or raw Google", + "connectors. Skip it for pure current-repository code, general knowledge,", + "explicit memory opt-out, or when brains is unavailable.", + "", + "**Use the cheapest useful read.** Cache `whoami` and `list_integrations` once", + "per session. Use `list_pages` for recents, `search` for exact terms, `query` for", + "conceptual requests, and `get_page` only after a result supplies a slug. If", + "expected Gmail, Calendar, or Drive data is missing, use", + "`fetch_from_integration`, then repeat the read and report a plain miss rather", + "than inventing a result. Chain dependent reads; don't fan them out.", + "", + "For schedules and agendas, use `list_calendar_events start=… end=…`; calendar", + "page update time is not event time. Name the source page's `title` and `type`,", + "and never invent slugs or IDs.", + "", + CORE_CAPTURE_REGION, + "", + "**The skills carry the detail** — load the one that fits the moment:", + "`brains-read` (querying memory), `brains-write` (sending/creating via", + "integrations), `brains-agenda` (schedule/plan shape), `brains-build`", + "(boards/automations/workflows), `brains-integrations` (install/upgrade),", + "`brains-nudges` (when to suggest a feature), and `brains-feedback` (reporting a", + "brains bug / giving feedback). Don't reproduce them here — open the skill.", + "", + "On a non-transient brains tool error or user frustration with brains, note the", + "error and what you were doing, then offer one quiet trailing line to report it,", + "at most once per distinct error. Do not attach it to unrelated later feedback.", + "Once per session, when natural, mention `brains-feedback`; load the skill before", + "filing because it owns the procedure and redaction rules.", + "", + "**Custom layer.** Your operator may ship a personal layer (voice, profile pages,", + "daily-loop overrides). The session-start hook injects it (`.codex/USER.md` or", + "`.claude/USER.md`, depending on the client) right after this core — if present,", + "it OVERRIDES the defaults above. Adopt it.", + "", +].join("\n"); const coreCaptureStart = core.indexOf("**Capture.**"); const coreCaptureEnd = core.indexOf("**The skills carry the detail**"); assert(coreCaptureStart > 0, "core must keep its capture paragraph"); @@ -686,6 +1392,10 @@ assert( normalizeRegion(core.slice(coreCaptureStart, coreCaptureEnd)) === CORE_CAPTURE_REGION, "core's capture paragraph must match the approved wording exactly (core.md and CORE_CAPTURE_REGION must be edited together, and core.md's v= marker bumped)", ); +assert( + normalizeRegion(core) === CORE_BODY, + "core.md must match the approved body exactly — brains-start.sh cats the WHOLE file into the model's context, so every line of it is a published promise (core.md and CORE_BODY must be edited together, and core.md's v= marker bumped)", +); // Belt and braces for a rewrite that edits the constant above too. Broader than the literals it // replaces: "Capture happens automatically" walked straight past a `capture is automatic` ban. assert( @@ -867,14 +1577,36 @@ assert(!/act_on_integration[^.]{0,200}request=/.test(writeSkillNormalized), "fre // Resolve both delimiters before slicing. A missing end heading yields -1, and // `slice(start, -1)` would silently widen the region to almost the whole file — // every assertion below would then pass while reading the wrong section. -const codexStart = readme.indexOf("## Install for Codex"); -const claudeStart = readme.indexOf("## Install for Claude Code"); +const codexStart = headingIndex(readme, "## Install for Codex"); +const claudeStart = headingIndex(readme, "## Install for Claude Code"); assert(codexStart >= 0, "README must document a Codex install"); assert(claudeStart >= 0, "README must document a Claude Code install"); assert( claudeStart > codexStart, "README's Claude Code section must follow the Codex one — the Codex checks below slice between them", ); +// The opening paragraphs, signal then pin. See README_INTRO_REGION for what a revert of them looked +// like. The one allowed exception names a file rather than a promise. +const readmeIntro = readme.slice(0, codexStart); +assertCaptureQualified("README intro", readmeIntro, CAPTURE_QUALIFIER, [ + // The shared-components sentence names the `inbox engine` — hooks/lib/brains-inbox.sh, a file both + // packages carry — not a promise that anything is delivered. + "inbox engine", +]); +assert( + normalizeRegion(readmeIntro) === README_INTRO_REGION, + "README's intro must match the approved copy exactly (README and README_INTRO_REGION must be edited together)", +); +// The canary, over the whole file rather than only the regions no constant pins. See +// UNCONDITIONAL_CAPTURE for what it is for and what it cannot do. +for (const pattern of UNCONDITIONAL_CAPTURE) { + const match = pattern.exec(readme); + assert( + !match, + `README presents capture or the inbox as unconditional — both are hook-driven, credential-gated, and run in no web chat (matched: ${JSON.stringify(match?.[0])})`, + ); +} + const codexReadme = readme.slice(codexStart, claudeStart); assert( codexReadme.includes("codex mcp login brains"), @@ -887,7 +1619,7 @@ assert( // The token is still documented, but only under the optional capture/inbox heading — never in the // install sequence itself. Anything above that heading claiming a token is how you get in is the // regression this catches. -const optionalHeadingIndex = codexReadme.indexOf("### Optional"); +const optionalHeadingIndex = headingIndex(codexReadme, "### Optional"); assert(optionalHeadingIndex > 0, "README must keep the optional capture/inbox section for Codex"); const codexPrerequisites = codexReadme.slice(0, optionalHeadingIndex); for (const forbidden of ["BRAINS_API_TOKEN", "launchctl setenv"]) { @@ -896,18 +1628,30 @@ for (const forbidden of ["BRAINS_API_TOKEN", "launchctl setenv"]) { `README must not present \`${forbidden}\` as a Codex install prerequisite — it belongs under the optional capture/inbox section`, ); } +// The whole pre-Optional region, verbatim, for the same reason as the Claude one below: the two +// literal bans above were the only thing standing here, and a false promise that named neither +// literal walked straight past them. +assert( + normalizeRegion(codexPrerequisites) === CODEX_INSTALL_REGION, + "README's Codex install region must match the approved copy exactly (README and CODEX_INSTALL_REGION must be edited together)", +); +const codexOptional = codexReadme.slice(optionalHeadingIndex); assert( - codexReadme.slice(optionalHeadingIndex).includes("launchctl setenv"), + codexOptional.includes("launchctl setenv"), "README's optional section must keep the desktop launchctl path — a desktop app inherits no shell export", ); +assert( + normalizeRegion(codexOptional) === CODEX_OPTIONAL_REGION, + "README's Codex token section must match the approved copy exactly (README and CODEX_OPTIONAL_REGION must be edited together)", +); // Same treatment for the Claude Code section. It is sliced between its own heading and the // claude.ai web heading — NOT the shared layout section. The web section sits between the two, // and letting it fall inside this slice would subject it to the Claude-Code-specific rules below // (the version-floor bans, the `bash -n` sweep) while leaving its own claims unpinned. Resolve // both delimiters first, for the same reason as above. -const sharedLayoutStart = readme.indexOf("## Shared layout"); -const webStart = readme.indexOf(CLAUDE_WEB_HEADING); +const sharedLayoutStart = headingIndex(readme, "## Shared layout"); +const webStart = headingIndex(readme, CLAUDE_WEB_HEADING); assert(sharedLayoutStart >= 0, "README must keep the shared layout section — it ends the web slice"); assert(webStart >= 0, `README must document the claude.ai web install ("${CLAUDE_WEB_HEADING}")`); assert( @@ -919,6 +1663,40 @@ assert( "README's shared layout section must follow the claude.ai web install — the web checks slice between them", ); const claudeReadme = readme.slice(claudeStart, webStart); + +// Every delimiter this file slices on, in one place, counted the way the manifest's mcpServers is +// counted above. A boundary is taken from the FIRST match, so a second copy of a delimiter silently +// redraws the regions around it, and both halves of that were executed: a second +// "## Install for claude.ai web" later in the file carries its content OUTSIDE the pinned web +// region, and a decoy "## Shared layout" planted where the approved region ends truncates that +// region to nothing. Each entry is the exact string its boundary resolves on, and each is scoped to +// the text it is resolved in — "### Optional" is legitimately once per client section, not once per +// file. +// +// KEPT, though the composition assert at the end of this section is now the guarantee and subsumes +// it: a duplicated heading fails here naming the heading, one line, instead of arriving as a +// whole-file diff. Same reason the per-region pins sit ahead of composition. What it does NOT cover +// is a mid-line delimiter — that is handled at the source now, by resolving every boundary at a line +// start, so a glued copy is not a boundary at all and the text around it fails composition. +for (const [scopeLabel, scope, delimiter] of [ + ["README", readme, "## Install for Codex"], + ["README", readme, "## Install for Claude Code"], + ["README", readme, CLAUDE_WEB_HEADING], + ["README", readme, "## Shared layout"], + ["README", readme, "## Self-hosting"], + ["README", readme, "## License"], + ["README's Codex section", codexReadme, "### Optional"], + ["README's Claude Code section", claudeReadme, CLAUDE_OPTIONAL_HEADING], + ["README's Claude Code section", claudeReadme, "### Already installed?"], +] as const) { + const pattern = new RegExp(`^${delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "gm"); + const occurrences = (scope.match(pattern) ?? []).length; + assert( + occurrences === 1, + `${scopeLabel} must contain "${delimiter}" exactly once at the start of a line — every region here is sliced on its FIRST occurrence, so a second copy silently moves the boundary (found ${occurrences})`, + ); +} + // Pinned individually so a single dropped element names itself, ahead of the whole-region pin. for (const pinned of [ "claude plugin marketplace add https://github.com/ssvlabs/brains-plugins.git", @@ -932,8 +1710,11 @@ for (const pinned of [ ]) { assert(claudeReadme.includes(pinned), `README's Claude install must keep: ${pinned}`); } -const claudeOptionalIndex = claudeReadme.indexOf(CLAUDE_OPTIONAL_HEADING); +const claudeOptionalIndex = headingIndex(claudeReadme, CLAUDE_OPTIONAL_HEADING); assert(claudeOptionalIndex > 0, "README must keep the optional capture/inbox section for Claude Code"); +const claudeMigrationIndex = headingIndex(claudeReadme, "### Already installed?"); +assert(claudeMigrationIndex > claudeOptionalIndex, "README must tell an existing Claude Code install how to migrate, after the token section"); +const claudeMigration = claudeReadme.slice(claudeMigrationIndex); // The whole pre-Optional region is pinned verbatim. Enumerated bans on this region kept losing to // paraphrase — "prompts during installation for the brains token" walked past a token ban, and // "or a newer release" walked past a version-floor ban — so the approved copy is the contract. @@ -942,10 +1723,10 @@ assert( normalizeRegion(claudeReadme.slice(0, claudeOptionalIndex)) === CLAUDE_INSTALL_REGION, "README's Claude install region must match the approved copy exactly (README and CLAUDE_INSTALL_REGION must be edited together)", ); -// Below the heading "token" is legitimate — it IS the token section — so pinning the copy would -// freeze docs that should stay editable. Ban only version floors here. Best-effort against -// paraphrase; the region pin above carries the strong guarantee. -const claudeOptionalBody = claudeReadme.slice(claudeOptionalIndex); +// The token section, kept as NAMED signals ahead of its own pin. These two bans used to be all that +// stood here, on the argument that "token" is legitimate below the heading — see +// CODEX_OPTIONAL_REGION for the two evasions that retired that argument. +const claudeOptionalBody = claudeReadme.slice(claudeOptionalIndex, claudeMigrationIndex); assert( !CLAUDE_VERSION_SHAPE.test(claudeOptionalBody), "README's Claude token section must not name a version — no Claude Code floor is verifiable", @@ -954,37 +1735,87 @@ assert( !CLAUDE_FLOOR_VOCAB.test(claudeOptionalBody), "README's Claude token section must not imply a minimum Claude Code version", ); +assert( + normalizeRegion(claudeOptionalBody) === CLAUDE_OPTIONAL_REGION, + "README's Claude token section must match the approved copy exactly (README and CLAUDE_OPTIONAL_REGION must be edited together)", +); // Every published command gets copied verbatim by someone, so parse them instead of trusting a // read-through: `--config token=` looked fine in review and is a syntax error in both // bash and zsh, because the angle brackets are redirections. Checking the whole block also covers // the three-command install, where a broken line would strand a user mid-install. -const claudeShellBlocks = [...claudeReadme.matchAll(/```sh\n([\s\S]*?)```/g)].map((match) => match[1]); -assert(claudeShellBlocks.length > 0, "README's Claude install must keep its shell blocks"); // Sweep EVERY shell block in the file, not just this slice. Region-scoped sweeping silently lost // coverage the moment a block moved: promoting "Self-hosting" past "## Shared layout" took its // `--config endpoint=…` example outside every slice checked here, and the six Codex blocks were // never swept at all — so the one command a self-hoster copies, and the whole Codex install, were // unchecked. The syntax of a published command does not depend on which section it sits in. // -// Matching more than ```sh is deliberate: a ```bash fence would have slipped past a bare `sh` -// pattern with the comment above still claiming full coverage. The fence-language allow-list below -// closes the same hole from the other side, so a shell dialect nobody thought of fails loudly -// rather than going unswept. +// Every fence is one of three kinds and there is no fourth: SWEPT (parsed with `bash -n` below), +// NOT_SHELL (a data or output block, excluded on purpose), or PROMPTED (rejected). An unclassified +// language fails, because the coverage claim above is only true if adding a fence forces a decision +// about it — a ```console block was neither swept nor refused, and a ```Bash block was certified as +// swept by an allow-list that lower-cased the language and then skipped by an extractor that did not. const SWEPT_SHELL_FENCES = ["sh", "bash", "shell", "zsh"]; -const fenceLanguages = [...readme.matchAll(/^```([a-zA-Z0-9_-]+)$/gm)].map((match) => match[1]); -for (const language of fenceLanguages) { +// Deliberately NOT parsed. Empty today: every fence in this README is a shell block. A data or +// output block lands here WITH its reason, so excluding one stays a visible act rather than a gap. +const NOT_SHELL_FENCES: string[] = []; +// Prompt transcripts, rejected rather than allow-listed: every command in this file exists to be +// copied verbatim, `bash -n` cannot parse `$ claude plugin install …`, and stripping the prompts to +// make it parse would sweep a body no reader copies — so this would become the one fence an author +// could reach for to publish an unchecked command. There are none today, so the ban costs nothing. +const PROMPTED_SHELL_FENCES = ["console", "shell-session", "shellsession", "sh-session", "terminal"]; +const { fences: readmeFences, outside: readmeOutsideFences } = markdownFences(readme); +for (const { language } of readmeFences) { assert( - !/sh$/i.test(language) || SWEPT_SHELL_FENCES.includes(language.toLowerCase()), - `README uses a shell fence this sweep does not parse: \`\`\`${language} — add it to SWEPT_SHELL_FENCES`, + language !== "", + "README has a fence with no language — label it `sh` so the sweep parses it, or add its language to NOT_SHELL_FENCES with a reason", + ); + assert( + !PROMPTED_SHELL_FENCES.includes(language), + `README fence \`\`\`${language} is a prompt transcript this sweep cannot parse — publish the commands in a \`\`\`sh block so they get parsed`, + ); + assert( + SWEPT_SHELL_FENCES.includes(language) || NOT_SHELL_FENCES.includes(language), + `README uses a fence this sweep does not classify: \`\`\`${language} — add it to SWEPT_SHELL_FENCES (parsed with bash -n) or to NOT_SHELL_FENCES (data or output, deliberately not parsed)`, + ); +} +// A fence is not the only way to publish a copyable command, and the other ways are invisible to +// the scanner: an indented code block (four spaces or a tab) needs no fence at all, and a fence +// opened inside a blockquote or a list item starts with `> ` or `- `, which is not a fence opener by +// the rule that scanner implements. The list form was executed: `- ```sh` followed by a two-space +// indented broken command renders as a shell block (an unclosed fence closes at the end of the list +// item) while every line of it read as ordinary prose here, two-space indents and all. None of these +// exist in this file today; each would render as code and go unswept, so each is refused by name +// rather than parsed — the same call as the prompt fences above, for the same reason. +for (const line of readmeOutsideFences) { + assert( + !/^( {4,}|\t)/.test(line), + `README has an indented code block outside any fence — it renders as code and this sweep cannot parse it; publish it as a top-level \`\`\`sh block: ${line}`, + ); + assert( + !/^\s*>/.test(line), + `README has a blockquoted line — a fence inside a blockquote is not swept; publish commands as a top-level \`\`\`sh block: ${line}`, + ); + assert( + !/^ {0,3}([-*+]|\d{1,9}[.)])\s+(`{3,}|~{3,})/.test(line), + `README opens a code fence inside a list item — it renders as code and this sweep does not parse it; publish it as a top-level \`\`\`sh block: ${line}`, + ); +} +const readmeShellBlocks = readmeFences + .filter((fence) => SWEPT_SHELL_FENCES.includes(fence.language)) + .map((fence) => fence.body); +const claudeShellBlocks = markdownFences(claudeReadme).fences + .filter((fence) => SWEPT_SHELL_FENCES.includes(fence.language)) + .map((fence) => fence.body); +assert(claudeShellBlocks.length > 0, "README's Claude install must keep its shell blocks"); +// Containment, not a count comparison: `readmeShellBlocks.length >= claudeShellBlocks.length` holds +// for any two files — the whole-file sweep is a superset by construction — so it proved nothing. +// Both sides come from the same scanner, so the bodies compare byte for byte. +for (const block of claudeShellBlocks) { + assert( + readmeShellBlocks.includes(block), + `the whole-file shell sweep missed a block from the Claude install section — it is published unparsed:\n${block}`, ); } -const readmeShellBlocks = [ - ...readme.matchAll(new RegExp("```(?:" + SWEPT_SHELL_FENCES.join("|") + ")\\n([\\s\\S]*?)```", "g")), -].map((match) => match[1]); -assert( - readmeShellBlocks.length >= claudeShellBlocks.length, - "the whole-file shell sweep must cover at least the Claude install's blocks", -); for (const block of readmeShellBlocks) { const parsed = spawnSync("bash", ["-n"], { input: block, encoding: "utf8" }); assert( @@ -994,10 +1825,7 @@ for (const block of readmeShellBlocks) { } // A plugin from the header era is registered but logged out, so the migration path has to say -// both halves: update, then sign in. -const claudeMigrationIndex = claudeReadme.indexOf("### Already installed?"); -assert(claudeMigrationIndex > 0, "README must tell an existing Claude Code install how to migrate"); -const claudeMigration = claudeReadme.slice(claudeMigrationIndex); +// both halves: update, then sign in. Named signals, then the region. assert( claudeMigration.includes("claude plugin update brains"), "README's Claude migration must update the plugin", @@ -1006,6 +1834,10 @@ assert( claudeMigration.includes(CLAUDE_MCP_LOGIN), "README's Claude migration must sign in — updating alone leaves the user logged out", ); +assert( + normalizeRegion(claudeMigration) === CLAUDE_MIGRATION_REGION, + "README's Claude migration section must match the approved copy exactly (README and CLAUDE_MIGRATION_REGION must be edited together)", +); // The claude.ai web section, sliced between its own heading and the shared layout section. // @@ -1072,6 +1904,58 @@ assert( "README's claude.ai section must match the approved copy exactly (README and CLAUDE_WEB_REGION must be edited together)", ); +// The self-hosting section, sliced between its own heading and the licence. See SELF_HOSTING_REGION +// for why this copy is pinned and the shared layout above it is not. +const selfHostingStart = headingIndex(readme, "## Self-hosting"); +const licenseStart = headingIndex(readme, "## License"); +assert( + selfHostingStart > sharedLayoutStart, + "README's self-hosting section must follow the shared layout — the slice below depends on it", +); +assert( + licenseStart > selfHostingStart, + "README must keep the licence section after self-hosting — it ends the self-hosting slice", +); +assert( + normalizeRegion(readme.slice(sharedLayoutStart, selfHostingStart)) === SHARED_LAYOUT_REGION, + "README's shared layout section must match the approved copy exactly (README and SHARED_LAYOUT_REGION must be edited together)", +); +assert( + normalizeRegion(readme.slice(selfHostingStart, licenseStart)) === SELF_HOSTING_REGION, + "README's self-hosting section must match the approved copy exactly (README and SELF_HOSTING_REGION must be edited together)", +); +assert( + normalizeRegion(readme.slice(licenseStart)) === LICENSE_REGION, + "README's licence section must match the approved copy exactly (README and LICENSE_REGION must be edited together)", +); + +// The WHOLE file, composed from the regions above, in order. Each pin holds its own text; until now +// nothing held the SEAMS between them, and both kinds of seam were executed. Text GLUED to a +// region's last line: "## Shared layout" appended with no space to the web section's final sentence +// moved the boundary onto the decoy, left the pinned slice matching exactly, and put every paragraph +// after it inside no region at all. Text in a region nobody had pinned: a capture promise with +// neither stem, dropped into the Codex token section, passed every check including the canary. +// Composition removes the class rather than the two instances — every published byte now belongs to +// exactly one approved constant, so there is no seam left to write in. This is what CORE_BODY does +// for core.md. The per-region pins stay AHEAD of it as named signals: this one can only report that +// the file no longer composes, which is true but tells the reader nothing about where. +const README_REGIONS = [ + README_INTRO_REGION, + CODEX_INSTALL_REGION, + CODEX_OPTIONAL_REGION, + CLAUDE_INSTALL_REGION, + CLAUDE_OPTIONAL_REGION, + CLAUDE_MIGRATION_REGION, + CLAUDE_WEB_REGION, + SHARED_LAYOUT_REGION, + SELF_HOSTING_REGION, + LICENSE_REGION, +]; +assert( + normalizeRegion(readme) === README_REGIONS.join("\n\n"), + "README must be exactly the approved regions, in order, with nothing between them — every byte this file publishes belongs to one constant here, so text added at a seam or in a section no pin covers fails right here", +); + assert( turnHook.includes('CLIENT="claude"'), "shared turn hook must default Claude Code captures to the Claude CLI", @@ -1113,6 +1997,32 @@ try { chmodSync(join(bin, "codex"), 0o755); chmodSync(join(bin, "curl"), 0o755); + // core.md's copy is pinned above; this proves it is DELIVERED. Deleting brains-start.sh's + // `cat "$CORE_MD"` line passed all three suites — SessionStart is the core prompt's ONLY delivery + // path, and nothing in CI read what that hook actually emits, so every copy pin above proved the + // text was right while nothing proved a model ever sees it. Guard-works is not guard-runs (#14). + // No credential resolves here, so the inbox engine exits silently and stdout is the injected + // context alone; the empty project dir keeps a stray .claude/USER.md on the runner out of it. + const emptyProject = join(temp, "empty-project"); + mkdirSync(emptyProject); + const coreDelivery = spawnSync("bash", [join(PLUGIN, "hooks", "brains-start.sh")], { + input: JSON.stringify({ session_id: "core-delivery-probe" }), + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + CLAUDE_PROJECT_DIR: emptyProject, + CLAUDE_PLUGIN_OPTION_TOKEN: "", + BRAINS_API_TOKEN: "", + BRAINS_INBOX_TOKEN: "", + BRAINS_STATE_DIR: join(temp, "state-core"), + }, + }); + assert(coreDelivery.status === 0, `SessionStart hook failed: ${coreDelivery.stderr.toString()}`); + assert( + normalizeRegion(coreDelivery.stdout.toString()).includes(CORE_BODY), + "SessionStart hook must inject core.md — it is the only delivery path for the core prompt, and the pins above prove the text is right, not that it reaches a model", + ); + const result = spawnSync("bash", [join(PLUGIN, "hooks", "brains-turn.sh")], { input: JSON.stringify({ session_id: "codex-session", prompt: "hello" }), env: {