Cortex / mcp
AGENTS.md1 starsChanged 18 days agoMITView on GitHub
An optional Node MCP server exposing Cortex over stdio, plus the ai-os CLI. The busiest part of the repo by commit count, and — like every other part — dependency-free. - No runtime dependencies, ever. A plugin install clones the repo and never runs npm install, so a declared dependency is simply absent on the user's machine. lib/stdio.js is the ~100-line MCP transport that replaced @modelcontextprotocol/sdk for exactly this reason. core/test/install.test.js fails the build if an import creeps back in.…
- Reads credentials
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,843 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository43 other files it gives its agents.
- AGENTS.mdAGENTS.md
- core/AGENTS.mdAGENTS.md
- evals/AGENTS.mdAGENTS.md
- index/AGENTS.mdAGENTS.md
- tools/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- architecture-reviewSkill
- audit-notesSkill
- challenge-planSkill
- change-impactSkill
- clarify-requestSkill
- connect-mcpSkill
- create-teamSkill
- daily-planSkill
- debug-issueSkill
- define-termsSkill
- design-specSkill
- end-of-day-summarySkill
- handover-notesSkill
- join-teamSkill
- merge-workSkill
- migrate-legacy-engineSkill
- personal-setupSkill
- quick-noteSkill
- register-projectsSkill
- repo-mapSkill
- resolve-conflictsSkill
- resume-workSkill
- retrospectiveSkill
- review-changeSkill
- set-profileSkill
- setup-repoSkill
- story-mapSkill
- suggest-skillsSkill
- summarize-filesSkill
- sync-websiteSkill
- trim-agent-docsSkill
- update-pluginSkill
- weekly-reviewSkill
- whats-changedSkill
- write-agent-docsSkill
- write-area-briefSkill
- write-context-docsSkill
The file
We split this AGENTS.md into the rules it gives an agent
5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 64
server.jsstays a thin switch: All logic lives in lib/; the transport layer is a dispatch over tool names and nothing more. - Very specific: line 207
Scrub both ways: Every text posted out and every comment pulled in passes core/scrub.js.
- Distinctive: line 232
Every answer is a floor and says so.
- Very specific: line 377
mcp/never imports fromindex/: When both need the same rule, the rule moves to core/ and index/ re-exports it. - Very specific: line 437
No install step — there is nothing to install.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
mcp/ — the live brain
An optional Node MCP server exposing Cortex over stdio, plus the ai-os CLI. The busiest part of
the repo by commit count, and — like every other part — dependency-free.
The ai-os CLI
| Command | Does | Needs |
ai-os setup-plugins | install the Cortex Core plugin bundle | nothing |
ai-os setup-plugins --status | per tier, which plugins are installed; --json, --tier, --plugins-dir. Read-only | nothing |
ai-os team init | seed and push a team-brain | a vault |
ai-os team add | write this repo's project file into the team-brain, then the connector | a vault |
ai-os team member | add or update one member (--handle, --roles, --projects, --job) in the brain's team.json; commit, push | a root, a team |
ai-os ask | send --to <role|project> --title --body as asks/<id>.md; commit, push | a root, a team |
ai-os inbox | pull, then the open asks for you and the answers to yours; --json | a root, a team |
ai-os answer | append --body to ask --id, set --status; commit, push | a root, a team |
ai-os contract publish | write --file <contract.json> as projects/<slug>/contract.json + .md; commit, push when changed | a root, a team |
ai-os contract show | one project's contract (--project, --path); --json | a root, a team |
ai-os contract who-calls | which projects call --path "[METHOD] /p", and which serve it; --json | a root, a team |
ai-os contract list | the projects that have published a contract | a root, a team |
ai-os stories publish | write --file <stories.json> as projects/<slug>/stories.json; commit, push when changed | a root, a team |
ai-os integrations list|add|remove|workflow | the trackers in team.json; workflow --write commits the scheduled sync | a root, a team |
ai-os sync | asks ↔ trackers, one commit per run; --dry-run, --status, --clone <checkout> | a root, a team |
ai-os project list | every project the brain knows, as JSON; the same as list_projects | a vault |
ai-os project check | validate the team's project files; exit 1 on an error or a withheld file | a vault, a team |
ai-os project remove | delete one project file with git rm, commit, push | a vault, a team |
ai-os digest | append a git digest to a file | nothing |
ai-os catch-up | notes and history since a date | a repo or a vault |
ai-os graph <op> | the graph tool's answer: stats, node, neighbors, cluster, hubs, bridges, path, and callers, callees, defines from index.symbols (core/symbol-calls.js); --json. Exit 2 on a missing or stale index, or no symbols for those three. Read-only | a repo, its index |
ai-os graph story|stories-for|risk | the story ops, from the team-brain; exit 2 when no links are published or this repo's are behind HEAD | a root, a team |
Invariants
- No runtime dependencies, ever. A plugin install clones the repo and never runs
npm install, so a declared dependency is simply absent on the user's machine.lib/stdio.jsis the ~100-line MCP transport that replaced@modelcontextprotocol/sdkfor exactly this reason.core/test/install.test.jsfails the build if an import creeps back in. See ADR 0004. - Only protocol messages go to stdout. A stray
console.logcorrupts the JSON-RPC stream and the client reports something unrelated. Diagnostics go to stderr, and every stderr line the server writes starts withcortex:. One path printedai-os-mcp:until #552, so one process read in a client's log as two programs.test/root-name.test.jsstarts the server every way it can start or refuse to and checks each line. - Every tool result is capped in the transport, never per tool.
capResult()inlib/stdio.jsholds a result toMAX_RESULT_CHARS(40,000 — about 10k tokens, under 14k even at 3 characters per token) and replaces an oversized one with a parseable document markedtruncated: trueplus a hint to narrow the request. Claude Code cuts MCP output at 25,000 tokens by default;recall_memoryreturned 272,000 characters on a year of memory before this. Do not raise the cap to fit a tool — give the tool a way to return less. - Memory is read through
core/memory.js, in both layouts.recall_memoryand the repo half of a catch-up callrecent()and hand on its rows: one per file, a day file (<date>.md,author: null) or one author's file of a day (<date>/<author>.md,authorthe slug from the path).daysandsincecount days, so every file of a day comes back together. Do not list.cortex/memory/here.recallin repo mode needs no rule of its own: it searches every.mdunder.cortex/through the Vault, and the path of a hit names the author. More authors mean more text for the samedays; the transport cap below is what stops it, and it says so. rememberpasses no author;core/memory.jsfinds it (CORTEX_AUTHOR, then gituser.namein the repo). The result is{ path, day, author, layout }.layoutis"author"for<date>/<author>.mdand"day"when no name was usable and the entry went to<date>.md; only then is there anotice, which is how the model learns to tell the person. Do not drop it. A test that spawns the server setsCORTEX_AUTHOR(serverEnvintest/mode.test.js), so it never reads the machine's git name.server.jsstays a thin switch. All logic lives inlib/; the transport layer is a dispatch over tool names and nothing more.ai-os.jsparses flags and prints; it decides nothing. It exports nothing and exits at module level, so a decision made in it can only be tested by starting a process. Three used to live there and are inlib/now: which sources, repo and project a catch-up reads (catchUpinlib/catchup.js), whatproject checkcounts as a problem (checkProjects), and the order of a join, project file before connector (joinProject, both inlib/project-files.js). The team a catch-up reads iscatchUpTeam, which the server calls too. A new rule goes inlib/with a test that imports it; the subprocess tests are for wiring.- Two modes, decided by the root — never configured. A root ending in
.cortexis repo mode (recall,remember,recall_memory); anything else is vault mode (the personal-brain tools). Detection keeps the plugin manifest to one env var and makes a misconfiguration visible as a changed tool list. - The root has two names, and
rootFromEnvincore/paths.jsis the only code that orders them.CORTEX_ROOTis the name to set.AI_OS_ROOTis what a registration made before #552 carries: it is read with no warning and has no removal date, because that line lives in a user's config.CORTEX_ROOTwins when both are set, an empty value counts as unset, and two different paths produce onecortex:line on stderr from whichever adapter opened the brain (brain.notices). Inmcp/,lib/resolve.jsis the one caller;brain.sources.rootsays which variable answered, andMissingRootErrornames that one. A setCORTEX_ROOTthat does not exist is refused and never swapped for a goodAI_OS_ROOT. The plugin manifest setsCORTEX_ROOTalone, so a vault path in the user's own environment cannot take over the repo's server (core/test/plugin.test.js). Not renamed, each because a command or a config a user already has would break: the fileai-os.js, the CLI command nameai-os, the package nameai-os-mcp. Itsbinmap also answers tocortex-mcp. - Vault tools are hidden and refused in repo mode. Offering
captureorcatch_me_upthere invites an agent to writeinbox/anddaily/into someone's product repository — and a client that already knows the name never readstools/list, so hiding is not refusing. Which mode a tool runs in is a field on its declaration inlib/tools.js;toolsFor()derives the advertised list andassertAvailable()derives the guard, so the two cannot disagree and a new tool cannot be added without saying where it runs.mode.test.jsasserts the exact list for both modes and that a vault tool invoked in repo mode comes back refused with nothing written. - A tool that returns someone else's text says so in its description.
recall,recall_memory,get_project_context,list_projects,catch_me_up,inbox,contractandgraphall hand the model text written outside the conversation — a teammate's committed.cortex/memory/, a note in a shared vault — and the result itself carries nothing marking it as data rather than instruction, which is the standard prompt-injection path through a retrieval tool. The description is the one place the model reliably reads, soUNTRUSTED_NOTEinlib/tools.jsis stated once and interpolated, andreturns: FOREIGN | OWNis a field on the declaration next tomode.assertWellFormed()runs at import and refuses a row that skips the question or claimsFOREIGNwithout the sentence, so the next tool cannot ship unmarked. Do not add it torememberorcapture: they take input and hand back a path, and a warning on every tool is a warning on none. - A tool that publishes is gated by its declaration, not by its caller.
writes: LOCAL | PUBLISHEDis the third field on a row inlib/tools.js, andassertPublishable()inserver.js's dispatch appliescore/scrub.jsto thecontentof everyPUBLISHEDone — the same shape asmode/assertAvailable()beside it.rememberwas gated insidecore/memory.jsbecause.cortex/memory/is committed;captureon a team commits and pushes a note to a remote other people pull and was not, becauseassertWritablehad exactly one caller and being the second was a thing you had to remember. That is the shape ADR 0007 rejected for path safety.assertWellFormed()refuses a row that omitswrites, and aPUBLISHEDrow that does not take its payload incontent— otherwise the gate reads a field that is not the one being written. A personal, non-team capture is gated too: where a capture lands is decided at runtime by a connector found by walking up from the cwd, so the call site cannot know whether it publishes, and a gate conditional on that is off exactly when nobody can see that it is off.tools.test.jsdrives the property off the table andmode.test.jsproves it over a spawned server — the unit test alone passes happily when the dispatch never calls it. The refusal stays incore/: it refuses rather than sanitises and names only the kind. The gate iscore/gate.js— the credential scan, the employer denylist onhome, personal data underCORTEX_PII— andserver.jspassesbrain.policy, so the profile comes off the brain opened once. What it returns without refusing (warnings) rides back on the tool result; a caller that drops it hides a personal-data warning from the model that should tell the person.contract.jsandproject-files.jswrite published files outside the table and apply the credential scan and the denylist themselves. - A root that does not exist is a hard exit too (
MissingRootError,lib/brain.js, #550). The server used to start on it and fail in the first tool an agent called, with a rawENOENT. In repo mode the directory that must exist is the repo, not.cortex/: the first write creates that. This holds forai-os catch-upas well. A root that is set and wrong is not the unset root it may degrade on. - A root that is not set is a hard exit, not a default: neither
CORTEX_ROOTnorAI_OS_ROOTholds a non-blank value. Guessing a vault path would write someone's notes into the wrong place.lib/resolve.jsupholds this — it throwsNoRootErrorrather than falling back, and the three-mode spec's fallback chain was rejected on exactly these grounds (ADR 0008). Two CLI commands degrade instead,ai-os catch-upfirst. It writes nothing, and with no root it reads only the git repository the cwd sits in — that repo's committed.cortex/memory/and its log — never a vault, and says inskippedwhich half it did not read. That is what a plugin install has: no vault, only the repo it was opened in, and/whats-changedfailed on its first command there. It does not open a brain with an inferred root; it opens none (open(…, { rootOptional })inai-os.js), so nothing downstream can mistake the repo for a place to write.openBrainresolves the profile before the root for this reason — a misspeltCORTEX_PROFILEstill fails at entry. A second command wanting the same exemption must also write nothing; anything that writes stays a hard exit.ai-os graphis the second: it reads one index and writes nothing, and with no root it reads the git work tree the cwd sits in (graphRepo,lib/graph.js). capture'steamargument is an override, not the switch. The team comes fromlib/resolve.js— a repo with a.cortex/connector.jsonwrites to the team brain without the caller knowing it is on a team. Requiring the agent to passteamwas the seam leaking. Do not reintroduce it as a required argument.- The connector names the team and the project in two fields:
{ team, project, teamBrainRepo }. It used to be{ slug, teamBrainRepo }, withteam addwriting the project intoslugand the resolver reading it as the team, so every connected repo captured intoteam/<project>/, a directory that was not a clone. Old connectors are still read (legacyTeaminresolve.jsmatches the clone by itsorigin); do not reintroduce a field that means two things. A fourth, optional field,source, is the team's Cortex fork:team init --sourcewrites it to the brain'steam.json,team addcopies it here (readTeamSource,lib/team.js), andcore/source.jsreads it. The resolver here ignores it. - Asks are one file per thread,
asks/<id>.md, in the team-brain (#620, the design R1–R11).lib/ask-file.jsowns the file's format,lib/asks.jsthe addressing and the inbox,lib/roster.jsteam.json'smembers.lib/sync.jsreads asks throughask-file.jsand never importsasks.js, which calls it: the two imported each other until the format moved out. The rules:tois a role some member holds or a project inprojects/, else refused with both lists. A name that is both is the role. Never accept an addressee nobody is placed to see.- The caller's handle is the memory author (
core/author.js, asked in the product repo in repo mode). A caller not inmembersstill reads the asks for the connector's project, andinboxreturns anoticenamingCORTEX_AUTHOR. Do not refuse them. ask,inboxandansweraremode: ANYand need a team: on a solo brain they throwno_team, naming/join-team. The clone is the brain'steamClone, cloned on first use from the connector'steamBrainRepo(ensureTeamClone,lib/team.js). In repo mode that is inside the product repo, so.cortex/team/is added to its.gitignorefirst, on every call, throughcore/gitignore.js—index/lib/generated.mjsuses the same writer.- The gate covers every field written into the pushed file. A row's
alsoPublishesnames arguments besidescontentthatassertPublishablescans — an ask'stitleandlinks.assertWellFormedrefuses a name the schema does not take. The CLI callsassertPublishablewith the tool's name before it callslib/, so both adapters pass the same gate. - A member's
jobis one ofcore/job.js's five words and never refuses anything. It decides what the rituals offer first. A reader keeps a member whose job is wrong, reports it inrosterErrorsand reads it asdeveloper, as it reads no job;setMemberrefuses it, and keeps the recorded job when none is passed.inboxreturns it asme.job, which is where/setup-repoand/resume-workread it. inboxpulls ff-only first, and a failed pull is returned inpull, with the local asks.- The start-up count is
instructionson theinitializeresult (asksInstructions), not a hook (ADR 0021). It reads the local clone, never pulls, never throws: a failure drops the count. - Asks and answers commit one path with
commitPathand push only whenpolicy.outwardSyncallows; onlabthe result saysoutward_sync_disabled.
- Trackers are adapters behind one interface, and sync is the only caller (#624, the design's
R17–R20).
lib/trackers/{github,servicenow,jira}.jseach givefind,create,read,comment,closeoverlib/trackers/http.js;lib/integrations.jsownsteam.json → integrations | primary | routes;lib/sync.jsowns the run. The rules:- The network is reached only when the team selected a tracker, and never on
lab.syncAsksreturns before naming an adapter whenintegrationsis empty orpolicy.outwardSyncis false — calling a tracker is publishing.trackers.test.jsruns the asks path withfetchreplaced by a function that throws (R19).fetchis looked up at call time, so that test exercises the real path. - Idempotent by construction. The link (
{ kind, state: "pending" }) is written to the ask file beforecreateis called, andfindsearches the tracker for the ask's marker ([ask:<id>]in a GitHub title,correlation_idin ServiceNow, a label in Jira) before anything is created. Comments Cortex posts end with[cortex:<ask>/<entry>], so a pull skips its own and a push skips what the tracker already holds. A run that learns nothing writes nothing, and so commits nothing; do not add a field that changes on every run. - Conflicts: the primary wins on status, and only when its status changed since the last
sync (
link.status) — an answer in the brain is not undone by a tracker it has not reached. The brain wins on the text. Pull from every tracker before pushing to any, or a comment takes two runs to cross and the second run commits. - Scrub both ways. Every text posted out and every comment pulled in passes
core/scrub.js; a comment carrying a credential is withheld and the brain records only that it was. - No credential in
team.json:checkIntegrationrefuses a key named like one and any credential-shaped value, and adapters read the environment only, naming the variable when it is missing. A missing credential leaves the linkpending; it never fails the ask. syncAfternever throws, and returns null with no integration, soask/answer/inboxresults on a team without trackers are the ones #620 shipped. No MCP tool configures trackers.- Removing an integration keeps its links in every ask; sync ignores a link whose kind is not
selected, and
sync --statuslists it as inactive. templates/team-brain/sync.ymlis rendered bylib/sync-workflow.js, printed by default and written only with--write(ADR 0009).
- The network is reached only when the team selected a tracker, and never on
- A contract is
projects/<slug>/contract.jsonpluscontract.md, andlib/contract.jsowns both (#621, the design's R12–R14).index/cortex-contract.mjsextracts it; this package publishes and reads it. The rules:- Publishing re-serialises; it never copies.
parseContractkeeps the known keys, caps every string, sorts every list and refuses a file path that is absolute or climbs out. The bytes are a function of the contract, so an unchanged republish is no commit. - The gate is in
publishContract, on the input text and on the text written. No tool row publishes a contract — it is a CLI step of/join-team— so the one function that writes is the act the guarantee attaches to (ADR 0016). Do not add a second write path around it. contractis one tool for two questions (R6):project(narrowed bypath) orcalls. There is nowho_calls. It ismode: ANY, team only,FOREIGN,LOCAL, and pulls ff-only first, returning a failed pull inpull.- Matching is the route map's, through
core/route-path.js: one normal form (/items/{}), and a call's{}meets only an endpoint's{}. A question may carry a method and a trailing slash. Every answer is a floor and says so. - Drift reads git, not the files:
contractDriftcompares HEAD with the last commit before the day (revBefore, withsinceArgturningYYYY-MM-DDinto midnight, since git reads a bare date as that day at the current time). Bounded per project and in projects.
- Publishing re-serialises; it never copies.
- Stories are mirrored by sync, and story links are published like a contract (R3 of
the role-packs design).
lib/story-file.jsownsstories/<key>.md,lib/stories.jsthe mirror,lib/story-links.jsprojects/<slug>/stories.json. What a key is lives incore/story-key.js, becauseindex/extracts the links. The rules:- The mirror runs on a full sync only, for a tracker whose entry says
stories: true, and never onlab. Its files go into the run's one commit. Anask,answerorinboxcall narrows sync to its asks and does not mirror. - No description, ever. A story file holds
FIELDSand nothing else;stories.test.jspins that a tracker's description never reaches the brain's history. - Every story file passes
core/gate.jsbefore it is written, the key first and alone, so a key the employer denylist names is refused onhomeeven when the title is clean. A refused story is counted inwithheldby check and tracker, never by key. - An assignee is personal data. With the personal-data mode at
refuse(work's default) only a roster member is written, as their handle, matched by handle or the roster'sgithublogin. Atwarnthe tracker's name is written and the run carries one warning. - Destinations are the asks' check, unchanged. The adapters'
storiescalls go throughrequestintrackers/http.js, so an unapproved tracker onworkis refused beforefetchand reported inerrorsfor that tracker; the others still run. - Story links are parsed and re-serialised, never copied, as a contract is: no commit text and
no author can ride along.
publishStoryLinksgates the input and the output withcore/gate.jsand refuses the whole file on a denylisted key; it never drops one story to publish the rest. - Mutation-tested across core, index and mcp: 30 guards mutated (confidence, the gate checks,
the description at each layer), 29 caught. The survivor is equivalent: a PR suffix left in the
text is still dropped by the last rule in
keysInMessage, that a key that is a PR is the PR.
- The mirror runs on a full sync only, for a tracker whose entry says
catch_me_uppulls (ff-only) and reads the team's notes, not only its log. A failed pull is returned inpull, never swallowed, and the local notes still come back; team notes are bounded incatchup.jsso the result fits under the transport cap rather than being cut by it. Itscontractsfield is each project's contract drift since the date.- Every vault path goes through
lib/vault.js— not throughresolveInRootdirectly. The Vault is the only module here that joins onto a vault root or callsnode:fson one; it wrapscore/paths.jsso the guard is unavoidable rather than remembered. If you need an operation it does not have, add it to the Vault — do not reach around it. The full surface islist·entries·read·append·write·remove·abs·exists·isFile·isDirectory·mtimeMs, all taking root-relative paths.removedeletes one regular file and refuses a directory, a link and a missing file; there is no recursive form, because notes live in folders.test/vault-is-the-only-door.test.jsfails the build otherwise, and it checks twice: a scan forjoin(root, …), plus an assertion that the converted modules import nonode:fsat all — becauserecallonce bypassed the guard through a closure variable without ever writing that call, so the scan alone was blind to it. The three allowlisted files join onto the install directory or a git clone, never a vault. The Vault does not scrub: secret refusal is policy and stays incore/scrub.js, so do not add filtering here. ADR 0007. modeandaudienceare different questions.lib/mode.jsanswers what kind of brain this root is (repo vs vault);lib/resolve.jsanswers who it serves (solo · team · server). They are orthogonal — a repo-mode brain can run on a server, a vault-mode brain can belong to a team. Solo and team are detected from a.cortex/connector.jsonfound by walking up from the cwd; server is declared withCORTEX_AUDIENCE=server, because it leaves no filesystem trace and declaring beats detecting. Where no connector is found,CORTEX_TEAMnames the team, andCORTEX_TEAM_REPOwhere its clone comes from on first use: Claude Desktop has no repo to walk up from, and a PM's role pack sets both. A connector still wins when there is one. The clone isteamCloneDir(root, team)like any other and is cloned through the same destination check, so neither the Vault door nor the two modes change.core/profile.jsanswers a third question (home · work · lab) and reads onlyCORTEX_PROFILE— nothing here may move it.- Every adapter opens the brain at entry, through
lib/brain.js.server.jsandai-os.jsare two adapters over the same operations, so the seam between them is real and therefore a module:openBrain({ cwd, env })returnsroot · mode · isRepo · audience · team · teamClone · project · teamBrainRepo · env · profile · policy · sources · notices · describe(), and throwsNoRootError/UnknownProfileErrorbefore a command picks a branch. Neither adapter may readenv.CORTEX_ROOTorenv.AI_OS_ROOT, or callresolve.js,mode.jsorcore/profile.jsitself —brain.test.jsscans both for that, because the way this rule broke was a second adapter re-deriving the answer by hand:ai-os catch-upread the root raw and passedargs.teamalone, so inside a repo with a.cortex/connector.jsonit consulted no connector, reached no team clone, and printedcommits: []as a success. That is thecaptureinvariant below, arriving through the other door.ai-os.jsresolving the profile insideteam initonly is the same shape:team addtook a typo ashomewhile the server exited on it. The record composes three answers and never merges the three questions — mode from the root string, audience from the connector orCORTEX_AUDIENCE, profile fromCORTEX_PROFILEalone. Three fields, never one enum; ADR 0008 and ADR 0015 stand unchanged. A command that reads no brain (digest,setup-plugins) does not open one — demanding a root there would be a new requirement wearing a fix's clothes. Skills reach the CLI asnode "${CLAUDE_PLUGIN_ROOT}/mcp/ai-os.js", never<vault>/mcp/…: a plugin install has no vault checkout, andtest/rituals-on-a-plugin-install.test.jsruns each command as its SKILL.md prints it, from a copy laid out like the plugin cache. - Every path that publishes must consult
policy.outwardSync— including the CLI.labexists to be permissive locally because it is sealed outward, so the seal is the load-bearing half;core/profile.jscalls alabthat still pushes "the leak with extra steps". For a while that is whatinitTeamBrainwas:capture()honoured the policy and the team-brain push did not, andai-os.jsimportedcore/profile.jsnowhere, so a misspeltCORTEX_PROFILEwas a hard exit in the server and a silent default in the CLI. Both adapters now take the policy off the recordlib/brain.jsopens at entry, which is where the profile is resolved once. A new publish path that does not is the same bug again — write locally, decline the push, and tell the caller which, the waycapture()does. - Every call that reaches a remote checks the destination first (
core/destinations.js, ADR 0030).pull,commitAndPushandpushHeadinlib/gitsync.jstake{ policy, env }as a required option and check every URL git resolves for the clone's remotes (remote get-url --all, with--pushfor a push) before git runs; a refusal comes back aserror: "unapproved_destination: …"in the shape a git failure already had, andcommitAndPushkeeps its commit.cloneTeamBraintakes the same option and throws before anything is created;initTeamBrainchecks its push.requestinlib/trackers/http.jschecks the URL ashttpsand never callsfetchon a refusal;io.policycomes fromsyncAsks. The env isbrain.env, the environmentopenBrainwas handed, so a lib function given the brain has both halves. A new outward call is the same bug as a new publish path that skipsoutwardSync: route it through the check, and add it totest/destinations.test.js, which runs git withGIT_ALLOW_PROTOCOL=fileso a call that slips past fails on git's own transport error, not on the network. - A project file is written, listed and removed by
lib/project-files.js, and judged bycore/project-file.js. A project file isprojects/<slug>.mdat the top of a team-brain, and a workspace is the set of them (ADR 0024). The rules that hold for every caller:- The gates run before the write, in one order: valid, allowed by the profile, free of
secrets.
judge()is the only way to a write. The first refusal throwsProjectFileRefusedand leaves both repositories as they were.ai-os team addwrites the connector last, so a repo whose project is refused has not joined. - The profile check takes the policy object, never a profile name.
profileRefusal(data, policy)readspolicy.refuses, and both adapters passbrain.policy. Do not writebrain.profile === "home"at a call site. On a profile that refuses employer material, the writer refuses the whole file andlist_projectsreturns{ slug, path, source, withheld }for it: title, links and relations are withheld, and the file is never deleted or moved. - The check is a floor. It recognises a private-network host and a tenant of a few hosted work tools. Never describe a file that passed as cleared, in a message or in a skill.
- A credential is refused, not stripped. An
origincarrying one stopsteam addwith a request for--project-repo. The text is also passed throughcore/scrub.jsbefore the write, because it is about to be pushed. - An existing file is left byte for byte unless a field flag is passed. Then
setFrontmatterchanges those lines only and the whole file is judged again. With no flag, an existing file the profile refuses stops the join: the file stays as it is and no connector is written. Reporting it as withheld and joining anyway was #586's open choice, settled 2026-10-12. - No repo, no file. A checkout with no usable
originand no--project-repostill joins; its project is listed asunregistered. A project file with norepois never written. - Removal resolves the slug against the team's
projects/directory, through the Vault'sremove. That root is what makes../xanOutsideRootErrorand not a string check. After the guard the target must be a slug, exist, and parse astype: project. Removal runsgit rmand commitsproject: remove <slug>. It never deletes the notes folder and never edits another file'srelated:line; it reports both inleft. - Every commit is pushed only when
policy.outwardSyncallows. Onlabthe commit stays local and the result saysoutward_sync_disabled, for a write and for a removal. list_projectsgrew fields, not a tool. Each entry hassource: "vault" | "team". A vault entry is a project stub or a notes folder; a team entry is a project file. The two share a filename and are never merged. A.mdunder the team'sprojects/with notype: projectis skipped and counted.ai-os project list|check|removeare the CLI's half and are refused in repo mode, because the clone lives under the vault.- A link is never fetched, here or in
core/. No title lookup and no reachability check.
- The gates run before the write, in one order: valid, allowed by the profile, free of
secrets.
setup-plugins --statusreads and never installs (#548). It starts noclaudeand writes nothing;test/rituals-on-a-plugin-install.test.jscompares the whole tree. The registry is read bycore/plugin-registry.js, andtierStatus(lib/setup-plugins.js) is handed the result, so it has no path of its own. A registry that was not read makes every tierunknownand every plugin'sinstallednull: do not turn that intofalse, which reads as "offer it". The findings offer a tier from the index alone, because the index may not read the machine (index/AGENTS.md); this is the machine's half, and/setup-repoand/connect-mcpask it before they offer one.mcp/never imports fromindex/. When both need the same rule, the rule moves tocore/andindex/re-exports it.core/route-path.jswas the first; thegraphtool is the second.- The
graphtool answers from the indexindex/wrote, throughcore/, and never spawns or imports the indexer (G3 of the code-graph design).lib/graph.jsholds the ops; its header holds the rules. The seam was a choice between three:- Chosen: the computation moved to
core/.core/graph.jsscores hubs and bridges and finds cycles;core/path-kinds.jsholds the area and fixture rules the score reads;core/index-file.jsreadsindex.jsonandenriched.jsonand decides whether the index is stale. Each depends on nothing in a leaf, so the move was a move.cortex-graph.mjs --jsonand the tool return equal hubs and bridges because they run one function, andtest/graph.test.jschecks it on a fixture repo. - Rejected: a reader in
mcp/that recomputes.node,neighbors,clusterandpathare plain reads and would have been fine, but hubs and bridges are scored, and a second copy of a score drifts the first time the score changes. The freshness rule is the same case: "stale" has one definition (index/AGENTS.md), andmcp/could not reach it inindex/lib/open.mjs. - Rejected: spawning
cortex-graph.mjs --json. It welds the server to the indexer at run time, which is the dependency the layering forbids, with a process boundary hiding it. It also pays for coverage, which reads every file's text, on every call. - The rules it keeps: the code ops need a repo, read-only,
returns: FOREIGN(a file's summary is model-written prose). A missing, unreadable, wrong-format or stale index is refused with the command that rebuilds it, never answered from; outside git, freshness isunknownand said. The question is checked before the index is read, and every file argument goes throughresolveInRoot, so a path out of the repo is anOutsideRootErrorwhatever the index's state. Lists stop atLIST_MAX(50) and carrytotalandtruncated;hubsandbridgesare the report's top ten withbridgeCount. Validated on eight cloned repos: hubs, bridges and every count equal tocortex-graph.mjs --json. 20 guards mutated (freshness, the guard, the caps), 20 test failures. - The story ops are the same tool, answered from the team-brain (
lib/graph-stories.js, R4):story,stories-forandriskread the mirror, every repo's story links and contracts, and the open asks. Sographismode: ANY: a project manager's Desktop has a vault root and no repo, and the code ops refuse a vault themselves (not_a_repo).graphFromis the one entry both adapters call for any op. The code ops' rules carry over: read-only (a ff-only pull, ascontractdoes), every list capped, every answer a floor. Freshness: in a repo, its own project's links are refused when HEAD has moved past the commit they were built at; another repo's cannot be checked from here, solinks.projectscarries each one'sasOfand commit. The path guard: in a repo a file goes throughresolveInRoot; with no repo, an absolute path or a..segment isoutside_rootbefore anything is read.
- Chosen: the computation moved to
Gotchas
lib/cortexignore.jsis a faithful port ofknowledge_files()intools/_cortex-lib.sh. The two must agree; CI diffs them incortex-init-test.yml. Change one, change both. It is also pure — it decides what the patterns mean and never reads a file; the Vault fetches the text and passes it in. The dependency only runs one way:vault.jsimports it, never the reverse.test/manifest-parity.test.jsguards a deliberate duplication:tools/cortex-init.shhardcodesCORE_PLUGINSto stay jq-free, and this test is the only thing preventing drift fromplugins/cortex-core-plugins.json.lib/gitsync.jsusesexecFileSyncwith an argument array, never a shell string, so slugged project and team names cannot inject. Keep it that way.lib/version.jsreads the repo-rootVERSIONfile. It exists becauseserver.js,package.jsonand the docs actually drifted apart once between 1.0.0 and 1.1.0.- Path handling carries Windows-specific cases; this is the primary dev platform, and CI has a Windows leg for exactly that reason.
Tests
cd mcp && npm testNo install step — there is nothing to install. smoke.test.js and mode.test.js spawn the real
server over stdio; if they time out, read the captured stderr in the failure message before
assuming a test bug. stdio.test.js drives the transport over in-memory streams instead, so the
protocol edges (notifications, framing, parse errors) are pinned without spawning anything.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

