Imported from Peccia/mitos (
AGENTS.md). Install upstream withnpx skills add Peccia/mitos. Copyright stays with the author.
Mitos — Builder Context
You are working on the Mitos repo itself: the registry and compiler that materialize an
agent organization across tools. Mitos manages itself — this file is authored at
registry/context/projects/mitos-repo.md and compiled into this repo's AGENTS.md by
selfdoc.rewrite() on every compile. Read README.md for the architecture
and workflows before making structural changes.
What this repo is
registry/is the single source of truth: the canonical home for all content — personas (identity/), domain/project context (context/), skills (skills/), harness-agnostic prompts (prompts/), the knowledge graph (graph/, schema.org JSON-LD), project manifests (projects/), and org seed templates (templates/).registry/local/is the Mitos overlay (gitignored): a user's private identity/projects/graph/skills, loaded on top of the core by last-layer-wins so the same repo can go open source without leaking personal content.connections/holds external tools: MCP server definitions + env templates. Deliberately NOT registry content — wiring doesn't compound and has no harvest story. It deploys on its own lane:deploy --lane connectionstouches only MCP wiring + env files;--lane contenttouches only prose (defaultalldoes both). Each server's config contract is defined by its upstream repo (recorded asrepo:inservers.yaml); env templates mirror the upstream-documented variables — consult the upstream README before adding or changing keys, never invent them.build/compile.pyrenders both into each tool's native format and deploys them. The compiler is disposable plumbing; the registry content is the durable asset.dist/and every deployed file (SOUL.md,CLAUDE.md,AGENTS.md, tool MCP configs) are build artifacts. Never hand-edit them — edit the registry and recompile.
Invariants (obey these mechanically)
- Prose stays prose. Personas, skills, and context are plain Markdown partials.
YAML is only for structure (manifests,
servers.yaml, machines, targets). - The registry wins at deploy. Tools may mutate their deployed copies; those
mutations surface via drift detection. Tools propose — only the maintainer commits to
the registry (via
adopt/harvest). - Never write into
registry/to propose a change — propose intoregistry/local/inbox/.registry/local/inbox/is the intake queue: one folder per candidate (payload snapshot +meta.yamlwithregistry_path,kind: drift|new|report,source,base_hash).deploycaptures overwritten drift there automatically; agents proposing new content or work reports write candidates there by hand. Seedocs/managing-state.mdfor details. Only the maintainer merges candidates into the registry. The queue lives inside the private overlay so it syncs to the mitos-local hub viamitos sync— never in the public-track repo. - Every emitted path declares a
drift_policy(protect,harvest, orgenerated) in its target spec.generatedfiles (the knowledge-graph project tree) are regenerated fromregistry/graph/every deploy and overwrite in-place edits silently — they are non-adoptable, with no registry partial to route an edit back to. - Deployed artifacts are raw context — no banners, no markers. The model reads
pure prose; scaffolding would tax every request. Provenance for
adoptlives in the lockfile (a per-section base recorded at deploy), reconstructed at adopt time — never embedded in the file. Don't reintroduce in-file markers orDO NOT EDITbanners. - Secrets never enter git. Only
*.env.exampletemplates are tracked; real values live in.local/(gitignored) and are merged at deploy time — never at compile, sodist/andregistry/local/inbox/never contain secret values. - Third-party config files get surgical merges, never whole-file overwrites.
Antigravity
config.json(json_merge, owns only its alias's entries) and Claude Desktop'sconfig.json(json_merge, owns only its alias'smcp(...)entries inside the allow list) are the patterns to copy for any new tool that keeps its own config file. First-party is the counterexample: whole files Mitos owns are written entire (kind: json), noowned_keys— this rule is about not clobbering a third party's file, not a blanket ban on whole-file config. - Deploys are machine-guarded.
deployrefuses when the host OS doesn't match the machine profile'sos:; rehearse cross-machine deploys with--root <dir>(files, lockfile, and inbox captures all land under<root>/registry/local/inbox/). - Deletion is explicit, never a side effect.
deployremoves nothing on its own: outputs no longer planned (a deselected skill, a retired project) become orphans — reported on every deploy, kept on disk and in the lockfile — untildeploy --prunedeletes them. A drifted orphan is captured toregistry/local/inbox/before deletion. - Boring beats clever. No frameworks, brokers, or chains until a concrete, recurring pain forces one. Weigh any new dependency or abstraction against that bar before proposing infrastructure.
- Network reach lives beside the compiler, never inside it. Workspace reach
(connectors, OAuth, the interactive
initwizard) and cross-machine sync (mitos sync,build/agentic/sync/) are a separate entrypoint (build/mitos.py) with lazy, optional backend deps; the deterministic verbs stay offline and import no network/credential code (the loader validates thesync:block's shape only, never importing the sync package). Connectors are producers for the onekind: graphvalve — they never writeregistry/graph/directly (invariant #3). - Every deployed tree node obeys the header taxonomy. One H1 (node identity); the
reserved H2 sections
## Navigation,## Workflows,## Tools,## Skillsin that order; connection sections headed## <Name> (key)with effort groups at###.planner.lint_node_markdownenforces it at plan time (failscompile/deployon a violation).SOUL.mdis the documented exception (all-H2 system prompt, not linted); the taxonomy is taught inside the tree by the operating root's## Navigation(registry/context/agentic-root.md), keeping SOUL lean. A project's cloned repos are local paths, so they render as a GENERATED roster inside## Navigation— never as a section of their own, and never hand-listed in prose. Seedocs/context-tree-structure.md.
To change X, edit Y
| To change… | Edit… |
|---|---|
| A persona rule / your identity | registry/identity/*.md — and mind each partial's audience:. A rule naming the context tree belongs to [context-tree] ONLY; adding a coding harness inlines it into every project's CLAUDE.md on a box that has no such tree (operating-rules.md is the whole-file example). The persona is audience-split, not shared: who-i-am.md = [context-tree]; who-i-am-coding.md = [claude-code], deliberately NO personal facts (a project's CLAUDE.md is normally committed to that repo). Both sit in targets/claude-code.yaml's context_file.sources and audience: picks exactly one — the wrong target inlines the wrong persona rather than erroring. Core prose is public and multi-user: name the owner via {{users_given_name}}, describe a store generically, never gws. Guarded by test_coding_harness_context_carries_no_assistant_persona (build/tests/test_targets.py) |
| Your name/email/location (personalization) | registry/user.yaml (core defaults) + registry/local/user.yaml (your overlay, gitignored) — a flat given_name/full_name/email/location mapping, field-level merged (loader._load_user, unknown keys rejected loudly). The single source of truth every deployed context file's placeholders expand from: {{user_given_name}}, {{users_given_name}} (possessive), {{user_full_name}}, {{user_email}}, {{user_location}} (render.expand_placeholders, applied by planner._expand_output to every text/zip output — never to yaml_merge/json_merge configs or env templates, which are machine wiring, not prose). mitos init seeds the overlay file from your answers (init.scaffold_overlay) |
The concrete paths and store names always-on prose uses ({{project_root}}, {{skills_root}}, {{returns_root}}, {{connection}}) |
the machine's paths: in machines/<name>.yaml — machine-scoped tokens expand per machine at plan time (render.expand_placeholders / _machine_value): project_root = context_root > projects_root; skills_root = <context_root>/skills; returns_root = <projects_root>/.mitos-returns; literal when unset. Reversal on adopt matches any machine's value. connection is the ONE machine token that reads document_store: rather than paths: — it expands to the stable label render.connection_label mints (<Name> (key)), comma-joined for a multi-store machine, so a skill naming the store and a tree node heading it cannot drift; it stays literal when no store is wired. Used by identity/operating-rules.md and the new-session skill so the agent is told real directories and a real store name, not abstract key names |
| Which MCP connections a machine's assistant shows as available | the machine's document_store: in machines/<name>.yaml (same field/validation as a project's) — feeds the generated connection section (render.connections_block), headed ## <Name> (key) (render.connection_label, the stable label skills reference), appended to the operating root and Assistant/AGENTS.md. Empty (no section) when unset — a machine never claims a connection it doesn't have. It is also the one signal every connection-bound output is gated on: planner._gws returns None (so antigravity/claude-app plan NO MCP wiring) and _selected_skills drops any skill declaring requires_server: — see the next row |
Whether a machine receives a connection-bound skill (gws, graph-bootstrap) |
the skill's requires_server: <server> frontmatter (loader.Skill.requires_server, validated against connections/servers.yaml keys) crossed with the machine's document_store: — a skill that is only instructions for one server's tools must not deploy where that server was never wired. Not overridable: skills: {<target>: {include:}} curation cannot smuggle back a skill whose connection is absent. planner.skill_deploy_warnings names document_store:, never "curation", as the fix. Guarded by test_requires_server_gates_skill + test_fresh_coding_machine_deploys_no_workspace_content (build/tests/test_targets.py) |
The header layout of any deployed tree node (AGENTS.md/AGENTS_DETAILS.md) |
it's a contract, not free-form — one H1 identity, reserved ## Navigation/## Workflows/## Tools/## Skills in order, ## <Name> (key) connection sections with ### efforts. Enforced by planner.lint_node_markdown; taught to the agent by the operating root's ## Navigation (registry/context/agentic-root.md); documented in docs/context-tree-structure.md. Local paths go under ## Navigation, store paths under the connection section (planner._connection_emit attaches the generated doc map beneath a curated connection section) |
| The general-skills catalog on the operating root | nothing to edit directly — render.skills_block generates a ## Skills bullet per skill selected for this machine's deployment, sourced from each skill's frontmatter description: (the only place that text lives) |
| An effort's goal (the intent line) | the effort's goal in registry/graph/<slug>.jsonld (peccia:goal on a CreativeWork node — free text, no validation set, omit-when-absent; set via the console's effort editor Goal field). Renders as a **Goal:** … line under the effort's heading in every generated view (graph._effort_goal_line), after the description and before the effort's documents |
| An effort's stable id in the deployed tree | nothing to edit — an effort's ### group heading is generated as <name> (<id>) (graph.effort_heading), the SAME identity form a project roster line uses. Emitted for EVERY effort, tagged or not, and deliberately riding the heading rather than a line of its own: a downstream harness keying a long-lived record on an effort must key it on something a rename cannot move, and the heading text is the NAME, which the owner edits freely — while a dedicated line would cost one always-on line per effort per view in a file that loads on every request. The id is always the LAST parenthesised group, so a name that itself ends in parentheses stays unambiguous. Regression-guarded by test_effort_heading_always_carries_its_id + test_effort_heading_id_is_the_last_parenthesised_group (build/tests/test_graph.py) |
| Domain or project context | registry/context/**/*.md |
| A skill | registry/skills/<name>/SKILL.md |
| A skill's supporting files (example outputs, executable scripts, references) | the subdirectories in loader._SKILL_RESOURCE_DIRS — registry/skills/<name>/examples/, scripts/, references/, templates/, resources/ (the union of the harnesses' documented conventions) — auto-discovered at load (loader._load_skill_resources), UTF-8 text only (v1; a binary file fails loudly). Deployed alongside SKILL.md on claude-code/antigravity (own per-file Outputs, inheriting the skill's drift policy) and bundled into claude-app zips (Output.zip_members). Each file's OWN path is its adopt/harvest source — an edited script routes back to itself, never SKILL.md. Console: the Skills tab's Supporting Files panel (full-replacement-set semantics — see docs/managing-state.md) |
| Which skills a tool receives | the skill's targets: frontmatter (compatibility, push — set by the skill author, core or overlay) + optional per-machine skills: {<target>: {include:/exclude:}} in machines/<name>.yaml (curation, pull — set by the machine owner). Curation is deliberately NOT a targets/*.yaml field: that file is core and not overlayable, so a curation list there would be a fork tax on every community user (loader._validate rejects include/exclude under a target spec's skills: block loudly, pointing here instead). Names validated against the registry at load (loader._validate); selection resolved by planner._selected_skills. A MANUAL target takes no curation (loader.is_manual_skill_target — the mode: zip set, i.e. claude-app): it stages a menu and the operator picks at upload time, so a curation block for it on a machine profile is REFUSED at load rather than silently ignored. requires_server: still gates it. After deselecting, deploy --prune removes deployed copies |
| A skill's global vs. project scope | the skill's scope: global | project frontmatter (default global, omit = global — loader.validate_skill_scope). global deploys to every shared/global directory its targets: offer; project deploys ONLY to the projects naming it in their manifest skills: list, on claude-code (<local_path>/.claude/skills/) and antigravity (.agents/skills/) — never a global directory. claude-app has no project-scoped surface, so it ignores scope and stays global regardless (PROJECT_SCOPE_CAPABLE_TARGETS). Console: the Skills tab's Scope section |
| A prompt (harness-agnostic) | registry/prompts/<name>.md — frontmatter: name, description, version, category, targets (optional; omit = console-only). Deployed as plain body text to any target whose targets/<tool>.yaml has a prompts: block. Always available in the console Prompt Library regardless of targets:. |
| Which prompts a tool receives | the prompt's targets: frontmatter (omit = console-only); the target's prompts: block in targets/<tool>.yaml selects them. Today only claude-code deploys prompts (per-project manifest prompts: binding → .claude/commands/<name>.md). The former antigravity prompt lane is retired — Antigravity discovers only <folder>/SKILL.md, so discoverable content belongs in a skill. |
| Favorites in the Prompt Library | registry/local/prompt-favorites.yaml — toggle via the console UI or via POST /api/prompts/favorite {"name": "<name>"} |
| Which skills a project's Claude Code checkout gets | skills: list in registry/projects/<slug>.yaml, or from the console's Project panel (Edit properties → Bound skills), which proposes a kind: project candidate; a bound skill must target claude-code or antigravity (the two targets with a project-scoped skill surface — PROJECT_SCOPE_CAPABLE_TARGETS). Deployed to <checkout>/.claude/skills/ (and .agents/skills/ if the skill also targets antigravity) |
| Author custom subagents | registry/agents/<name>.md (or registry/local/agents/<name>.md), with targets: (e.g. [claude-code]), skills:, and optional harness blocks. Claude Code agents deploy to ~/.claude/agents/<name>.md |
| Auto-clone (and keep current) a project's repo | set the project's repo: in registry/projects/<slug>.yaml — one git URL or a list. Each is cloned into its own <basename>/ and on later deploys fast-forwarded only: never reset, stashed, re-checked-out, or deleted; a dirty/detached/diverged checkout is left untouched and reported. Context tree (claude-code + context-tree + context_root) → <context_root>/Projects/<slug>/<basename>/. Workstation checkouts (local_path) are developer-managed — never auto-cloned or pulled. Basenames must be unique within a project. planner.plan_clones builds the specs; commands._git_clone/_git_pull execute them |
| The branch a repo is checked out on | repo_branches: in registry/projects/<slug>.yaml — a mapping of checkout basename → branch name, validated exactly like repo_notes: (keys must match a _repo_basename of a repo: entry). Absent = the repo's default branch. _git_clone passes --branch on the initial clone; _git_pull fast-forwards only while the checkout stays on that branch (a manual git checkout <other> makes the next deploy skip the pull rather than yank the branch back). Threaded onto CloneSpec.branch by planner._repo_branch |
| Which SSH key authenticates a repo's auto-clone/pull | repo_ssh_keys: in registry/projects/<slug>.yaml — a mapping of checkout basename → private key (bare filename or path), validated exactly like repo_branches:/repo_notes:. Absent = the ambient default git/ssh identity. Resolution (agentic.sshkey.resolve_key_path/ssh_command, shared with sync/git.py's overlay-hub key) treats a bare filename as ~/.ssh/<name> — so ONE manifest entry authenticates the clone on every machine that carries a private key under that same filename, no per-machine config needed. _git_clone passes -c core.sshCommand=... on the clone itself and persists it as the checkout's core.sshCommand afterwards; _git_pull reconciles that config (including clearing it if the entry is removed) before every fetch. A named key file missing on the deploying machine fails fast with its resolved path — reported, not fatal, same as any other clone/pull failure — instead of surfacing git's cryptic SSH permission-denied text. Threaded onto CloneSpec.ssh_key by planner._repo_ssh_key |
| A repo's one-line description in a project's generated context | repo_notes: in registry/projects/<slug>.yaml — a mapping of checkout basename (loader._repo_basename(url), never the URL — the basename is the stable identity clone-dest/roster/repo_notes all key off) to description, loader-validated against the repo: list. Rendered by render.navigation_block as the generated repo roster INSIDE the node's ## Navigation (planner._project_repo_entries resolves each note; planner._project_node_regions places the region) — the ONE place a repo roster renders, and there is no ## Workspace Layout section (it was retired into Navigation). No clone URL — deploy machinery, recoverable from .git/config, not per-request context. Editable from the console's Project panel. See docs/context-tree-structure.md |
| Where a generated region may sit inside a deployed document | anywhere, not just trailing — render.split_live_sections returns ordered (source, text) regions, never a source-keyed map, so ONE partial can contribute a region on each side of a generated one (a project node's prose split around its ## Navigation roster; a map would collapse those to whichever came last and adopt would silently drop the first). Consumers rejoin a partial's regions with render.rejoin_regions before comparing or writing: commands.route_into_registry, commands._section_aware_status, review._stale. Region labels come from the <generated…> family (render.GENERATED_SECTION, render.GENERATED_NAV), matched by render.is_generated_source. Assemble with planner._project_node_regions, emit via planner._mixed_doc_output |
| Publish/refresh a skill in claude.ai (web/Desktop) | add claude-app to the skill's targets:; deploy stages <name>.zip at the machine's claude_skills_staging path; upload is MANUAL (Customize > Skills) — a pending zip means the account copy is stale |
| Wire a LAN/HTTP MCP server into Claude Desktop | set claude_desktop_config in the machine profile; run deploy --lane connections — claude-app writes an npx mcp-remote stdio bridge directly into claude_desktop_config.json. Do not use the Desktop "Add custom connector" UI (it rejects non-https URLs and has no knowledge of servers.yaml). Restart Desktop and the connector appears automatically. Requires Node.js/npx; bridge version pinned in build/agentic/render.py (MCP_REMOTE_SPEC). See docs/targets/claude-app.md |
| An MCP server (tools, env, default url) | connections/servers.yaml |
| A server's URL as seen from one machine | urls: map in connections/servers.yaml (per-machine overrides let a host reach a server running elsewhere, e.g. over LAN) |
| Where a merged env file lands | <server>_env path key in machines/<name>.yaml |
| A project's stage / store / repo | registry/projects/<slug>.yaml |
A project's entry on the generated Project Roster (Projects/AGENTS.md) |
name: + optional description: in registry/projects/<slug>.yaml — the roster is generated at plan time (render.project_roster_block) from exactly the projects deployed as Projects/<name>/ folders in that tree; never hand-write a roster in projects-index.md |
| Which document store(s) back a project's graph init | document_store: in registry/projects/<slug>.yaml (same field and validation on a machine's machines/<name>.yaml) — a server name from connections/servers.yaml, none, OR a list of names ("multi connections": a graph may draw from several servers; NOT multi-account-per-server-type — the server key stays identity everywhere). loader._check_document_store validates each entry; loader.document_stores(raw) normalizes all three shapes to a list, so a plain string stays valid forever — no migration. Resolved per-store by connector_for_store at Stage 3; none/unset falls back to the local-file connector (no credentials needed). A store with a graph_enum: mapping uses the generic mcp connector. Scaffold with python build/mitos.py project add <slug> |
| How a document store is enumerated for the graph | graph_enum: on the server in connections/servers.yaml (list_tool, query_syntax, optional query_arg/folder_tool, and a fields: map onto {id, name, dateModified, webUrl, type} — type optional, the store's MIME/kind field). The mcp connector stays generic; query_syntax: google-drive activates Drive-specific query construction; any other store uses the generic path (scope passed verbatim). A multi-store project's mitos connect loops document_stores(document_store) — one enumeration + one kind: graph candidate PER STORE (--store <name> narrows to one) — via bootstrap_to_inbox(..., store=<name>), which threads a store: kwarg into review.propose_graph_change. --stage loops the same way (one LISTING per store, not one candidate — see the staging multi-scope row below); --store <name> narrows it too |
| How a document is tagged to its store, and why accepting one store's candidate can't delete another's docs | optional store field on graph.Document (peccia:store predicate, omit-when-absent — missing key = legacy = "the project's sole store"; only new enumerations write it). propose_graph_change's store: param tags every document in a candidate (a document's own store key, when a caller sets one, wins); omitted on re-propose, the EXISTING document's store tag is preserved (mirrors the doc_type preservation pattern), never wiped. Correctness falls out of existing mechanics, no extra filtering needed: IDs are store-native (a connector only ever returns its own store's IDs), and upsert_document/remove_document match by drive_id alone — so a store-A candidate can never carry a store-B ID and can never touch it. (Known v1 limitation: the SAME document reachable from two stores becomes two nodes with different IRIs — accepted until it actually hurts) |
How a multi-store project's generated AGENTS.md/AGENTS_DETAILS.md render |
planner._project_doc_block — loops document_stores(document_store), filters pg.documents by d.store per store, and calls the SAME render primitive (project_index_markdown/project_details_markdown/project_full_markdown) once per store, concatenating one ## <Name> (\key`)section per store (any keyless leftover docs get a trailing fallback section, never silently dropped). Header taxonomy safety (invariant #12: exactly one H1 per file): only the FIRST store's section may render at the caller's ownlevel(1 for a standalone file likeAGENTS_DETAILS.md, 2 when nested under existing prose); every later store is forced to at least level 2, so a multi-store standalone file still has exactly one H1. A machine's own document_store:list renders the same way on the operating root viarender.connections_block`, one short-form section per store |
A document's store-agnostic link (the url field) |
web_url on Document in build/agentic/graph.py, serialized as schema:url in the JSON-LD. The connector-provided URL is stored as-is (e.g. file://, https://notion.so/…). For Drive documents without an explicit URL, drive_url falls back to https://drive.google.com/open?id=<id> so existing graphs keep working |
| A project's document map (knowledge graph) | registry/graph/<slug>.jsonld — lean schema.org JSON-LD (schema:Project + schema:DigitalDocument/schema:ImageObject, IRIs under http://peccia.net/); inspect/query with python build/compile.py graph --project <slug>. See the knowledge-graph recipe in the README |
| A document's kind annotation (the tool-selection hint) | additionalType on the DigitalDocument node (doc_type on graph.Document) — optional, friendly form (spreadsheet, document, pdf); captured at enumeration from the store's MIME type (connectors.base.friendly_doc_type, graph_enum.fields.type) and rendered as (… · <type>) in the shared doc line (graph._concise_entry — identical in claude-code AGENTS.md and AGENTS_DETAILS.md) ONLY when it is not the default document (graph.DEFAULT_DOC_KIND) — a plain document spends no tokens on a label. Omit-when-absent. The console's Type dropdown offers graph.KNOWN_DOC_TYPES (served as state()["known_doc_types"]; + Doc defaults to the first, document). PNG/JPEG/GIF/WebP collapse to image (connectors.base.IMAGE_KIND), which serializes as @type: ImageObject with NO additionalType (a raster additionalType on a hand-written DigitalDocument normalizes to image on read); any image in a project adds one graph.IMAGE_HINT line after the intro in the details/full views, never the index |
| Propose a project's document mappings | the operator console's Knowledge Graph tab, or mitos connect --project <slug> — both land a kind: graph inbox candidate that accept upserts into registry/graph/. Nothing writes the graph directly (invariant #3) |
| Where a workstation's project AGENTS.md + CLAUDE.md deploys | local_path.<machine> in the project manifest — activated automatically when the machine has claude-code but not context-tree. Each project with a knowledge graph gets <local_path>/AGENTS.md (prose protect + full inline doc block generated) and <local_path>/CLAUDE.md (@AGENTS.md stub). The prose partial is read from context.assistant under the context-tree audience — no frontmatter change needed. Projects without a graph get only CLAUDE.md (existing behaviour). |
| Where the context tree deploys | context_root under paths: in machines/<name>.yaml — gated on context-tree in targets (planner._plan_graph_tree). A roster + per-project lightweight titles-only doc index generated straight from registry/graph/, drift_policy: generated — never an editing surface, silently overwritten every deploy. Full per-document detail lives in a companion AGENTS_DETAILS.md. Distinct from the operating mount below — see that row for the edit-semantics contrast. |
| Mount the context tree inside one project (context_tree) | context_tree: <subdir> in registry/projects/<slug>.yaml — the workstation-side counterpart to context_root. Renders the SAME tree _emit_tree (planner.py) produces for a context tree machine — full Navigation/Workflows/Skills, roster, dynamic branches — at <local_path>/<subdir>/ instead of a machine root, so e.g. Antigravity can operate against one project like an agentic harness. drift_policy: protect: unlike the reference mount above, edits here become drift and reconcile back into the registry via adopt. Workstation-only; validated at load time (single subdirectory name, no collision with a repo checkout basename in the same project) |
Add a custom branch to a context tree (e.g. family/) |
drop an AGENTS.md under registry/context/<branch>/ — any sibling file in that folder deploys alongside it to <mount-root>/<branch>/…, whether the mount is a machine's context_root or a project's context_tree. Auto-discovered at plan time (planner._emit_tree, keyed off the existing context/ partial scan — no loader change needed) and listed in a <generated> section on the root AGENTS.md. The only way to extend the tree without forking targets/context-tree.yaml, which is not overlayable. Branch names may not collide with a reserved top-level entry (Projects, Assistant) — rejected loudly at plan time |
| Where projects live on a machine (C:\ vs D:) | projects_root under paths: in machines/<name>.yaml — manifests' local_path entries are dir names relative to it (absolute and ~ paths pass through) |
How you invoke Mitos (the mitos shim) |
the repo-root mitos (POSIX sh) + mitos.cmd (Windows) — they locate build/.venv's interpreter and route the verb: the short list in each shim's MITOS_INTERACTIVE_VERBS goes to build/mitos.py, EVERYTHING ELSE falls through to build/compile.py, so a new deterministic verb needs no shim edit and an unknown verb gets argparse's own usage. Both prog= strings are mitos, matching what the docs tell you to type. Add an interactive verb ⇒ add it to BOTH shims, or test_cli_shim_verbs_match_mitos_py (build/tests/test_selfdoc.py) fails — an unlisted one would silently route to the compiler and answer invalid choice. The shim is a process launcher that imports nothing, so invariant #11 holds |
| What a tool emits or where it deploys | targets/<tool>.yaml |
| Which targets land on a machine | machines/<name>.yaml |
| Add a brand-new tool | new targets/<tool>.yaml (the output/deploy spec); add a render extension in build/agentic/render.py only if the tool needs a format the existing renderers don't cover. There is no build/templates/ — outputs are raw section concatenation, not .j2 templates |
| Personalize without forking (the open-source overlay) | put private content under registry/local/ (gitignored); it overrides the core by last-layer-wins — same logical name replaces, new names add, core-only remain. Absent overlay = the public default |
| Add a workspace connector backend | build/agentic/connectors/<name>.py subclassing WorkspaceConnector + register it in connectors/base.py; backend deps lazy-imported. It emits kind: graph candidates via bootstrap_to_inbox — never the graph directly, never from the compiler. For a store that already runs an MCP server, prefer describing it with a graph_enum: mapping and reusing the generic mcp connector (no new backend). The built-in backends: local (local filesystem, the default when no document_store set), mcp (any MCP server with graph_enum), mock (tests/demos) |
| Scaffold a new user / connect a workspace | python build/mitos.py init (overlay wizard — three paths: scaffold fresh, pull an existing overlay from a hub via git_clone, or use files already in registry/local/; non-destructive — scaffold_overlay never clobbers existing files, overwrite=True to force) — a separate interactive entrypoint, never compile.py |
Which questions mitos init asks, and the machine profile it writes |
the "fresh scaffold" path (_init_scaffold_fresh in build/mitos.py) asks a ROLE question for coding harnesses. _ask_coding_targets multi-selects over init.CODING_TARGETS, so any non-empty subset is reachable. It writes registry/local/machines/<name>.yaml via init.scaffold_machine, which takes EITHER a use_case preset (init.MACHINE_USE_CASES) OR an explicit targets= list; init.resolve_targets normalizes it. Never clobbers by default (overwrite=True to force). Add a target ⇒ add its path keys to init._TARGET_PATH_KEYS (+ starter values in _PATH_VALUES), or it scaffolds a profile with no paths: |
Whether mitos init binds a connection |
_ask_document_store (build/mitos.py) offers init.known_servers(root) — read from connections/servers.yaml (+ the overlay's), never a hardcoded name — plus None as the DEFAULT, and threads the answer to scaffold_machine(document_store=...). None writes no document_store: key at all (just a commented hint), which is the honest state for a box whose server isn't running: nothing connection-bound deploys and deploy says what it withheld. The old question ("Workspace backend [gws]") defaulted to a real server while wiring NOTHING — its answer only ever reached the overlay README's prose, so a user with no Google account got a profile that claimed a store it didn't have. scaffold_machine does not re-validate the name against servers.yaml — the loader owns that check. Regression-guarded by test_scaffold_machine_document_store_is_asked_not_assumed (build/tests/test_loader.py) |
| Build a project's knowledge graph (the three stages) | Stage 1 mitos project add <slug> scaffolds the manifest + optional document_store binding (offline); Stage 2 set up the document MCP server separately if needed (never in init — see docs/connectors/); Stage 3 mitos connect --project <slug> resolves the connector from document_store (defaults to the local-file connector when unset), enumerates a scoped folder, and proposes a kind: graph candidate. All three are separate, optional, and beside the compiler |
| How a machine syncs its private overlay across hosts | mitos sync keeps registry/local/ as a git repo synced to a hub (sync.git.hub in machines/<name>.yaml — any git URL, self-hosted or a private GitHub repo). Set it up once with sync --machine <name> init --hub <url> [--ssh-key <path>] (first machine) / clone --hub <url> (the rest) — both install a post-merge auto-deploy hook, record mitos.machine, and pin a chosen ssh key as the overlay's core.sshCommand (also settable via sync.git.ssh_key). Day-to-day each peer runs sync --machine <name>: pull --rebase → deploy → push, stop-on-conflict (status reports ahead/behind). Sync is git-only — no rsync/ssh/s3 transports. The flow + setup verbs are build/agentic/sync/git.py. See docs/lan-sync.md |
| Edit a project's identity/repos from the console | the Knowledge Graph tab's Project panel — name, description, stage, a repos list (URL + one-line description per row), and bound skills. Save → review.propose_project_edit() → POST /api/project/edit → a kind: project inbox candidate carrying the FULL replacement manifest YAML (fields not in the form — document_store, local_path, context, context_tree, … — pass through untouched). The console proposes any manifest edit the CLI can; nothing bypasses the Inbox. Re-validated with loader._validate against a scratch registry at BOTH propose and accept time, since the candidate sat on disk as untrusted text meanwhile — the same two-gate posture as propose_meta_edit. Lands in core or overlay, whichever layer the manifest currently loads from (review._project_file) |
| Review inbox candidates / copy one-shot prompts / edit the graph | python build/compile.py review — the operator console (localhost), four tabs: Inbox (accept routes prose into the registry, or upserts a kind: graph/new/project candidate; appends to registry/local/inbox/decisions.jsonl), Knowledge Graph (document mappings, effort editor, Project panel), Skills (card grid + ONE slide-out skill drawer), Prompt Library (+ a Ctrl/⌘K palette across all of it). The status bar can Compile and Deploy, with a plan preview before an explicit confirm; --force/--prune/scoped --lane/--target stay CLI-only. It edits the working tree, never commits. Server: build/agentic/review.py; UI: build/review_ui/. Full guide: docs/operator-console.md |
| Which skills/prompts/partials the console shows by default | nothing to edit — review._deploys_anywhere tags every prompt_index entry with deploys_here: whether ANY machine in commands.real_machines(reg) would actually receive it. It asks planner._selected_skills (so the requires_server:/document_store: connection gate counts) and Partial.visible_to rather than reimplementing the rule, which would drift from the real deploy. Nothing is filtered server-side — the tabs default to the flag and their All chip reveals the rest; on a fresh clone real_machines falls back to the shipped examples. Two target lists reach the UI and are NOT interchangeable: known_targets (every adapter — the authoring forms) vs. machine_targets (only what your machines declare — the filter chips). Guarded by test_deploys_here_* + test_state_exposes_machine_targets_separately_from_known_targets (build/tests/test_review.py) |
| The operator console's own UI (markup, behavior, styling) | build/review_ui/ — index.html, app.js (every app.js reference in this table), style.css, plus vendored marked.min.js/dompurify.min.js (provenance in VENDOR.md, whose recorded SHA-256 is of the file AS VENDORED and is enforced by test_vendored_ui_libs_match_their_recorded_hashes). The markdown preview renders with marked — snarkdown was retired because its indented-block rule matches ahead of its list rule, so it cannot render a sub-bullet at all; marked sanitizes nothing itself, so its output must keep passing through DOMPurify.sanitize(). Served by build/agentic/review.py, which owns the HTTP API the UI calls; the UI itself is plain ES modules and hand-written CSS — no build step, no framework (invariant #10) |
| Refresh the console's view of the registry mid-session | nothing to edit — the header's Reload from disk button (app.js's reloadFromDisk) POSTs /api/reload, the ONLY path that re-runs loader.load() on a running console. GET /api/state deliberately answers from the cached holder["reg"] (it's polled after every accept/propose), so an edit made to registry/local/ after startup is invisible until that button. A loader error (half-saved YAML) answers 400 and KEEPS the last good registry — the console stays up and in-browser drafts survive |
Which {{tokens}} the console asks the operator to fill when copying a prompt |
nothing to edit — the split is derived: render.user_token_map(reg) (the five personalization tokens that resolve to a value) is auto-substituted at copy time, render.machine_token_names() (project_root/skills_root) is left literal (a copied prompt goes to a chat app, not a machine deploy — no machine's paths apply), and every other token is a fillable input (app.js's promptTokens/fillPrompt/copyPrompt, fed by state()'s user_tokens/machine_tokens). Authoring a one-shot input therefore means just writing {{any_name}} in the prompt body — no new syntax. A blank field keeps its {{token}} literal rather than emptying it. Pure client-side interpolation: nothing is sent to the server |
| Whether a project can watch more than one folder/query at once | Yes — inbox/staging/<slug>.json holds a listings: array, one entry per distinct (store, folder_id, query, recursive) = staging.scope_key(scope). The identity/merge/overlap logic lives in build/agentic/staging.py, a pure zero-connector-import module used by BOTH review.py (invariant #11) and connectors/bootstrap.py, so it is defined exactly once. exclude_folders is deliberately NOT part of a scope's identity (a filter, not what's watched) — editing an exclude list refreshes the existing listing rather than forking a duplicate. stage_listing REPLACES the listing whose scope_key matches, or APPENDS a new one; a legacy pre-multi-scope file is wrapped by staging.normalize_staging, never migrated in place. --stage on a multi-store project loops one LISTING per store. See docs/operator-console.md |
| How Discovery merges multiple watched listings, and what an overlap means | staging.merge_documents(listings) unions every listing's documents by id; a doc present in more than one listing carries every scope_key that produced it (scope_keys), rendered as an "N watches" chip once stagedData.listings.length > 1 (app.js). stage_listing computes staging.overlapping_listings against the OTHER listings on every write and returns it as overlap — warn-only, NOTHING is blocked by it: _connect_one (build/mitos.py) prints note: N document(s) also appear in watch ... (staging.scope_label), the console has no equivalent surfacing yet (the strip's per-listing counts are the visible signal). Overlap exists because presence-in-any-listing is the correctness rule everywhere downstream (see the next row) — two watches sharing a document is expected, not an error |
| How Recovery knows a document left every watched scope | review._in_scope_flags (named in_scope, not in_store — with more than one watch, presence is always relative to a scope) annotates each Recovery row True/False/None against the CURRENT listings, offline: the staging listings ARE the store's truth as of their own staged_at. Presence in ANY listing wins first — a doc that dropped out of the watch that originally staged it but is still listed by another must read True. False requires proof of absence; anything else is None and is never flagged or purged. purge_dismissed re-checks server-side, so a stale tab can't purge a live doc. That precedence is the load-bearing rule the multi-scope feature depends on — pinned by test_in_scope_present_in_any_listing_wins_over_own_scope_absence (build/tests/test_review.py). Full argument: docs/operator-console.md |
| Re-enumerate ONE watched listing from the console | Discovery's watched-scopes strip gives each listing its own ↻ Refresh → POST /api/graph/refresh {slug, pool, scope_key} → review.refresh_staging, which finds the listing by scope_key (defaulting to the sole listing, refusing to guess among several) and replays ITS scope by running build/mitos.py connect --stage … as a subprocess — invariant #11 by construction, since compile.py imports no connector/OAuth code. Re-staging replaces only that listing; siblings are untouched. A listing with no recorded scope answers ok: False and the strip omits its button — also right for a store's FIRST stage, since an interactive OAuth consent has no place behind a web button |
| Stop watching a scope | Each watched-scopes strip row's Remove watch → POST /api/graph/unwatch {slug, pool, scope_key} → review.remove_watch, a pure file edit (no connector, console-ONLY — there is deliberately no CLI verb, since a second way to do a one-line JSON write isn't worth a new flag) that drops the matching listing from listings:. The file itself is kept (with an empty listings: [] if that was the last one) rather than deleted, so _dismiss_file's "does a project-specific staging file exist" pool-fallback check keeps working unchanged. Anything the listing's documents had already contributed to the graph is untouched — only what Discovery offers changes |
| Give a watched listing a human-readable name | label on the listing (inbox/staging/<slug>.json, ≤60 chars — staging.LABEL_MAX/staging.clean_label) — purely cosmetic: identity stays the derived scope_key, so renaming cannot touch which documents a watch holds or disturb a sibling. Rename → POST /api/graph/rename-watch → review.rename_watch, a pure file edit exactly like remove_watch (console-only, no CLI verb). staging.listing_label(listing) is the single "what to call this watch" resolver — the operator's name when set, else the derived scope_label. A re-stage carries the label across, so a Refresh never undoes a Rename; submitting an empty label clears it. See docs/operator-console.md |
| Create a new skill via the console | The Skills tab's + New skill form → propose_new_skill() in build/agentic/review.py → POST /api/skills/new → a kind: new inbox candidate carrying optional resources (Supporting Files) → Accept writes registry/local/skills/<name>/SKILL.md (+ its resource subdirectories) verbatim (always the overlay, never core) |
How the Inbox tab shows a kind: graph candidate |
review._doc_delta set-compares the candidate's proposed documents against the CURRENT registry/graph/<slug>.jsonld — scoped to the candidate's own store (meta["store"]) when set — surfacing doc_delta: {added, changed, removed} + a no_changes flag on load_candidates's output ("changed" = same drive_id, different name/date_modified/url/type/store). PRESENTATION ONLY: the accept/upsert path (_apply_graph_candidate) is untouched, and removed means "not in this enumeration", never an auto-delete — an actual deletion still requires an explicit removals entry (surfaced as removal_ids). The console renders the delta as the primary view with the raw line diff behind a disclosure, and de-emphasizes (never hides) Accept on a no-op |
Managing state (the core workflow)
Deploy materializes the registry; drift detection + reconciliation is the heart of the
project. The three-way compare (render vs lockfile source_hash vs disk deployed_hash),
every plan state (create/unchanged/pending/drift/conflict/resolved/merge/
orphan/clone), the three drift policies (protect/harvest/generated), capture-to-
inbox-before-overwrite, and the reconciliation verbs (diff/adopt/harvest/review/
--force/--prune) are documented end-to-end in docs/managing-state.md — keep that page
in sync when you change build/agentic/commands.py or a target's drift_policy.
A personalized partial (one whose body contains a {{user_*}} placeholder) is deployed in
EXPANDED form, so commands.route_into_registry and review._stale() fold recorded/live
text back through that partial's own placeholder tokens (render.reverse_expand_placeholders
— scoped exact-inverse, longest expanded value first) BEFORE comparing against the
registry's placeholder-form body. This runs ahead of the change/staleness check itself, not
inside _rewrite_registry_body — comparing expanded text directly against a
placeholder-bearing body would report a phantom change on every adopt/review of a
personalized file and bake real values into the registry. See render.py's "User
placeholders" section and build/tests/test_personalization.py.
Verifying changes
python build/compile.py compile— schema validation is the first test; it must pass with no unknown-partial or missing-field errors. It also rewrites this file's own artifact, the repo-rootAGENTS.md(no machine'slocal_pathpoints at a contributor's checkout, sodeploycan't) — commit it when it changes.- Run the compiler test suite with the canonical runner:
python build/tests/test_compiler.py(per-area files:test_graph.py,test_connectors.py,test_commands.py,test_loader.py,test_targets.py,test_review.py,test_compiler.py,test_staging.py,test_personalization.py; shared helpers inconftest.py). CI installs onlybuild/requirements.txtand runspython build/tests/test_compiler.py— NEVERpytest. Neverimport pytestin any test or helper. Even if running locally inside a virtualenv where pytest happens to be installed, CI does NOT have pytest installed and will fail withModuleNotFoundError: No module named 'pytest'. Useconftest.raisesortry ... except ... else: raise AssertionErrorfor exception assertions. Pinned bytest_no_test_module_imports_pytestintest_runner.py. Fixture parameters (monkeypatch,tmp_path) work under both: the runner resolves each one throughconftest.make_fixtureby signature inspection (params WITH a default are not fixtures, matching pytest's own rule). A fixture the runner can't supply fails loudly rather than passingNone— add it tomake_fixtureinstead of hand-rolling a workaround in the test. Pinned bytest_runner.py, which also scans the whole suite for unsupported fixture names. The runner also wraps the whole run inconftest.noninteractive_stdin(), matching what pytest's default capture already gives every test:sys.stdinis not a terminal and raises on read. Interactive code branches on this (mitos._pick_folderno-ops unlesssys.stdin.isatty();mitos._askturns an unreadable stdin into a clean_Abort), so without it a test inherits the ambient shell — green in CI and under captured pytest, red the moment anyone runs the runner from a real terminal. A test that drives an interactive entrypoint should ALSO use the context manager directly, stating the interactivity it expects rather than relying on the wrapper (that is what keeps it correct underpytest -s, which disables capture). python build/compile.py deploy --machine <m> --dry-run— read the action list before any real deploy.
Contribution rule
A new verb, target, or schema field lands together with its schema validation, its README section, and an acceptance test — or not at all.
