Architecture

This document is for contributors and people who want to extend MissionCache. It describes how the pieces fit together, where state lives, and what invariants you need to respect if you want to add a new MCP tool, hook, or slash command without breaking anything downstream.

If you are just looking to use MissionCache, start with the README instead.

Mental model#

MissionCache is not one program. It is six small programs that agree on two files and one database:

Everything else is either a producer (hooks write heartbeats, MCP tools create files) or a consumer (the dashboard reads the DB, the statusline reads both DB and files, MissionCache Auto reads the tasks file and writes iteration logs). When you are trying to understand any component, ask: is it writing to the files, writing to the DB, or reading them?

Component map#

Component Directory What it does When it runs
MCP server mcp-server/ Exposes ~30 tools to Claude Code over stdio for managing projects, files, time, plans, and iteration logs One subprocess per Claude Code session, started on demand via uvx
missioncache-db missioncache-db/ SQLite schema, data classes, and all direct DB operations. Every other component calls into this instead of opening the DB themselves In-process library - embedded by MCP server, hooks, MissionCache Auto, and dashboard
Hooks hooks/ Short-lived Python scripts Claude Code invokes on session lifecycle events (prompt submitted, session started, context compacting, session stopped) One shot per event, all four hooks have a timeout budget between 5 and 30 seconds
Commands commands/ Slash command markdown files that describe workflows to Claude. They do not run code - Claude reads them and decides which MCP tools to call Parsed by Claude Code on plugin load, rendered when user types /missioncache:<name>
missioncache-auto missioncache-auto/ Standalone CLI that runs Claude in a loop over a task file, in either sequential or parallel-with-DAG mode A long-running user-invoked process, one execution per missioncache-auto <project> call
Dashboard missioncache-dashboard/ FastAPI backend plus a single-file HTML frontend at http://localhost:8787 for visualizing time, tasks, sessions, and MissionCache Auto runs One persistent process, usually managed by launchd
Statusline missioncache-dashboard/missioncache_dashboard/statusline.py ~1,350-line Python script that produces a 6-7 line ANSI status block shown at the bottom of the Claude Code TUI. Shipped inside the missioncache-dashboard package and exposed via the missioncache-statusline console entry point Invoked by Claude Code after every message, gets JSON on stdin, must be fast (sub-200ms target)
Rules rules/ Plain markdown files describing MissionCache conventions to Claude. Auto-installed into ~/.claude/rules/ by the SessionStart hook using a write-if-different copy. missioncache-install seeds the initial copies (symlinked in --local mode, copied with an ownership marker otherwise); the hook replaces stale symlinks on first run Refreshed on every SessionStart event

Two files at .claude-plugin/ wire the plugin into Claude Code: plugin.json registers the MCP server and metadata, and marketplace.json catalogs MissionCache as an installable plugin so the repo itself doubles as a one-plugin marketplace. End users install the full experience via uvx missioncache-install, or just the plugin core via /plugin marketplace add missioncache/missioncache followed by /plugin install missioncache@missioncache. Maintainers run uvx missioncache-install --local from a clone, which creates a separate local marketplace at ~/.claude/plugins/local-marketplace/ and installs the plugin from there as missioncache@local for fast iteration without pushing to GitHub.

Talking to the database through missioncache-db#

missioncache-db is the only place in the codebase that writes raw SQL. Everything else goes through its Python API. This is intentional - the schema has grown over time, there are triggers, and there is an invariant about where claude_session_cache lives (covered below) that you will violate the first time you try to open the DB directly from somewhere else.

The package exposes a single TaskDB class that wraps a sqlite3.Connection with WAL mode enabled. You get it by calling TaskDB() (no arguments - it finds the DB at ~/.missioncache/tasks.db). Every other component constructs its own instance; there is no global or singleton, because the consumers (hooks, MCP subprocess, MissionCache Auto, dashboard) are all different processes.

The file is ~3,400 lines, but the mental model is small:

If you find yourself wanting to add an SQL query anywhere else in the repo, add a method to TaskDB first and call it from your component. This keeps the schema refactorable.

How a Claude session flows through MissionCache#

This is the best way to build intuition for the component boundaries: follow one user prompt from the moment it is typed to the moment the dashboard reflects it.

1. Session start#

When Claude Code starts a new session in a repo, it fires the SessionStart hook. hooks/session_start.py does four things, in order:

  1. Resolves the session ID from CLAUDE_SESSION_ID or stdin JSON.
  2. Writes a term-session mapping file so that the statusline can later map terminal IDs back to session IDs.
  3. Calls db.find_task_for_cwd(cwd, session_id) to see if the current directory belongs to a tracked MissionCache project.
  4. If a task is found, writes ~/.claude/hooks/state/projects/<session-id>.json - the per-session project pointer used both by the statusline (to render the active project name) and by TaskDB.find_task_for_cwd on subsequent prompts (to resolve which task a heartbeat belongs to). (A legacy pending-task.json file used to be written here but was removed in mcp-orbit 0.2.13; see CHANGELOG.)

The hook also prints a short "Active Task Detected" block to stdout, which Claude Code injects into the conversation context. This is how Claude learns which project it is working on without the user having to say so.

2. User submits a prompt#

Every time the user hits enter, Claude Code fires UserPromptSubmit. MissionCache registers two independent scripts on this hook:

Both hooks have a 5-second budget. If no task matches the current directory or session, they both exit silently; there is no penalty for running MissionCache in a repo that has no projects.

3. Claude uses MCP tools#

When the user (or a slash command) asks Claude to do something that touches MissionCache state - "mark task 3 complete", "give me the current project status", "record an iteration" - Claude calls one of the mcp__plugin_missioncache_pm__* tools. Those tools live in the MCP server subprocess that was started by the plugin manifest.

The MCP server is stdio-based and very thin. mcp-server/src/mcp_missioncache/server.py is 30 lines: it imports five tool modules, each of which registers its tools against a shared FastMCP instance from app.py. Every tool:

  1. Opens a TaskDB via a lazy get_db() helper.
  2. Calls a handful of TaskDB methods.
  3. Returns a Pydantic-validated dict.
  4. Catches MissionCacheError and returns it as a structured error dict.

No tool writes to the DB on its own. No tool opens a second database file. No tool spawns subprocesses. If a new tool needs to do something weird, the fix is usually to add a method to TaskDB or a helper to helpers.py, not to deviate from this pattern.

The MCP tool call is what turns "user intent expressed in English" into "row inserted in SQLite". This is the primary write path for projects, task updates, completion, and MissionCache file creation.

4. Context compaction#

Claude Code fires PreCompact when it is about to compress the conversation to fit in the context window. hooks/pre_compact.py has 30 seconds to do three things:

  1. Find the task for the current cwd.
  2. Open <project>-context.md, update the **Last Updated:** line, and add a compaction note under ## Recent Changes.
  3. Call db.process_heartbeats() to aggregate unprocessed heartbeats into the sessions table.

This is the load-bearing piece of MissionCache's memory story. Without it, the context file would go stale between sessions and Claude would lose the thread on resume. The time budget is larger than the other hooks because heartbeat processing is not free on large DBs.

5. Session stop#

hooks/stop.py runs when the session ends. It reads the transcript file Claude Code points it at, checks whether any Write or Edit tool calls happened during the session, and if there is an active task with MissionCache files, prints a stderr reminder to run /missioncache:save. It does not touch the DB - its job is purely to nudge the user.

6. Dashboard reflection#

Separately from the per-session flow above, the dashboard is a persistent process reading from a DuckDB copy of the SQLite DB. At startup (or on explicit GET /api/sync) it copies tables from ~/.missioncache/tasks.db into ~/.missioncache/tasks.duckdb, which it then uses for fast aggregate queries. The dashboard also reads MissionCache files directly from disk to render task modals, and it reads ~/.claude/projects/<cwd>/*.jsonl files (Claude Code's own transcript store) to render untracked activity.

See The dual-database pattern for the details of why there are two DB files and what trade-offs it makes.

The fork path#

The six steps above are one linear trace of a prompt, and a fork is not a step in it. It is a second shape the project state can take, so it is worth naming here.

A project created with /missioncache:fork carries a **Fork of:** <parent> line in its context header. The child is a full project with its own plan, context, tasks, and clock. What it shares is the parent's context file, which becomes the knowledge layer every child and the parent's own sessions read. Tasks are never shared or copied.

The header is the durable link. tasks.parent_id is derived from it by the scan's reconcile pass, not authored directly. get_context_digest on a fork returns a parent_digest block so /missioncache:load can tell you when a sibling session changed the shared layer, using a per-session marker at ~/.claude/hooks/state/shared-seen/<session-id>.json that the statusline also reads. Full detail in forks.md.

Storage#

MissionCache's state lives in three places:

  1. Structured data in SQLite at ~/.missioncache/tasks.db, plus the DuckDB analytics mirror at ~/.missioncache/tasks.duckdb.
  2. Human-readable project state in markdown files under ~/.missioncache/{active,completed}/<project>/.
  3. Ephemeral hook state in JSON files under ~/.claude/hooks/state/.

SQLite schema#

The canonical tables are all defined in missioncache-db/missioncache_db/__init__.py. There is one more table (claude_session_cache) that is created by the dashboard, which is why this table list has two sections.

Defined by missioncache-db:

Table Purpose Key columns
repositories Tracked git repos id, path (UNIQUE), short_name, active, last_scanned_at
tasks Projects (both coding and non-coding) id, repo_id, name, full_path, parent_id, status, type, tags (JSON), category, jira_key, branch, pr_url, last_worked_on
task_updates Append-only progress notes for non-coding projects task_id, note, created_at
heartbeats WakaTime-style activity pings, one per non-skipped user prompt task_id, timestamp, session_id, processed
sessions Aggregated work sessions, computed from heartbeats by process_heartbeats() task_id, session_id, start_time, end_time, duration_seconds, heartbeat_count
config Key-value settings (idle timeout, assumed work seconds, prune threshold, tag keywords) key, value
auto_executions One row per missioncache-auto run task_id, started_at, completed_at, status, mode, worker_count, total_subtasks, completed_subtasks, failed_subtasks
auto_execution_logs Streaming log lines from MissionCache Auto workers execution_id, worker_id, subtask_id, level, message, timestamp

Triggers keep updated_at fresh on every row update and set completed_at/archived_at when status transitions into the corresponding state. The tasks table has a UNIQUE(repo_id, full_path) constraint that matters during scan - rescanning the same repo should not produce duplicate rows.

tasks.parent_id is the fork linkage (see forks.md). You never author it directly. It is derived from the **Fork of:** <parent> line in the child's context header: the scan's reconcile pass sets it, re-heals it when the link was lost, and clears it when the header goes away. The header is the source of truth, the column is the index - idx_tasks_parent is what makes the children lookup fast. The FK is ON DELETE SET NULL, which is why delete_task refuses to delete a parent that still has children rather than silently unlinking them.

Created by the dashboard (also in ~/.missioncache/tasks.db):

Table Purpose Key columns
claude_session_cache Cache of Claude Code's JSONL session transcripts, populated by missioncache-dashboard/missioncache_dashboard/lib/analytics_db.py. Used to compute "untracked" sessions (Claude activity with no MissionCache project loaded) and to merge JSONL time with MissionCache heartbeat time session_id, file_path, date, hour, cwd, git_branch, project_path, message_count, tool_call_count, input_tokens, output_tokens, duration_seconds

Invariant: claude_session_cache and sessions must be in the same SQLite file. The dashboard's "show me untracked Claude activity" query is a LEFT JOIN ... WHERE s.session_id IS NULL anti-join between the two tables. If you split them into separate databases, the anti-join silently returns empty and all untracked sessions disappear from the dashboard.

This is the thing that will bite you first if you refactor the storage layer. There is a note in the context file about it and it is worth respecting.

The dual-database pattern#

The dashboard reads from DuckDB (~/.missioncache/tasks.duckdb), not from SQLite directly. The trade-off is explicit:

The practical consequence is that the dashboard is always slightly stale. A heartbeat recorded ten seconds ago will not show up until the next sync. In exchange, the "Activity History" screen renders 90 days of data in under a second.

If you are adding a new dashboard endpoint: do the read against DuckDB via analytics_db.py. If you are adding a new MCP tool: do the write against SQLite via missioncache-db. Cross the streams only if you have a very specific reason.

Files on disk#

Projects have their own directory on disk under ~/.missioncache/:

~/.missioncache/
├── active/
│   └── <project-name>/
│       ├── <project-name>-plan.md       # Optional: implementation plan
│       ├── <project-name>-context.md    # Key decisions, gotchas, waiting-on, next steps
│       ├── <project-name>-journal.md    # Recent Changes overflow (oldest first), auto-managed by the 12-entry cap
│       ├── <project-name>-tasks.md      # Checkboxes - source of truth for progress
│       ├── <project-name>-auto-log.md   # Written by missioncache-auto during runs (optional)
│       └── prompts/                     # Optional: optimized per-subtask prompts
│           ├── README.md
│           ├── task-01-prompt.md
│           └── ...
└── completed/
    └── <project-name>/                  # Same layout, moved on /missioncache:done

The full_path column in the tasks table stores "active/<name>" or "completed/<name>", which is how the DB stays in sync with the on-disk status. /missioncache:done moves the directory and updates the row in one step.

There is also a legacy layout under <repo>/dev/{active,completed}/ that older projects still use. Dashboard and hooks fall back to it if the centralized path does not exist. If you are touching path resolution, check both mcp-server/src/mcp_missioncache/helpers.py (for MCP writes) and parse_missioncache_progress() in missioncache-dashboard/missioncache_dashboard/server.py (for dashboard reads). They are independent implementations - keep them consistent.

Ephemeral hook state#

~/.claude/hooks/state/ holds small JSON files that coordinate between hooks and the statusline:

File Written by Read by Purpose
projects/<session-id>.json session_start.py, /missioncache:load, /missioncache:new (via get_task / create_missioncache_files server-side binding), /missioncache:done (removes) statusline.py, TaskDB.find_task_for_cwd Which project is active for a given session. Read on both the statusline rendering path (to show the project name) and the heartbeat path (to attribute time to the right task when cwd does not match a known task directory)
term-sessions/<term-id> session_start.py statusline.py Maps terminal-emulator session IDs back to Claude session IDs so mid-session lookups work from any tab
pending-project.json (nothing) TaskDB.find_task_for_cwd priority-1 branch Inverse legacy: read but never written. The priority-1 branch in find_task_for_cwd is effectively dead code - task resolution always falls through to the projects/<session-id>.json branch
~~pending-task.json~~ (removed in mcp-orbit 0.2.13) (never read) Removed. Old files left on disk from pre-0.2.13 installs are harmless; the rename-sweep also stopped maintaining them. Safe to delete by hand

These files are deliberately plain JSON and deliberately per-session. Early versions of MissionCache used a single shared project file that race-conditioned badly once multiple Claude sessions ran concurrently. If you add new state here, shard by session ID by default.

Process model#

It is easy to get confused about what is running where, because MissionCache has a mix of long-running processes, stdio subprocesses, per-event short-lived scripts, and an embedded library. Here is the full picture:

Runs Process type Lifetime Triggered by
Dashboard Long-running (launchd-managed in practice) Until stopped Manual start or launchd
MCP server Stdio subprocess One per Claude Code session Claude Code on plugin load
Hooks (session_start, pre_compact, stop, activity_tracker, task_tracker) Short-lived one-shot Single event Claude Code hook events
missioncache-auto User-invoked CLI Until task complete or failed Manual missioncache-auto <project>
missioncache-auto workers Subprocesses of missioncache-auto One per subtask in parallel mode parallel.py
Statusline Very short-lived ~50-200ms per invocation Claude Code after every message

Rules and slash commands are not processes - they are files read by Claude Code.

Two implications worth keeping in mind:

  1. There is no MissionCache daemon. Everything except the dashboard runs on demand. If the dashboard is down, heartbeats still accumulate in SQLite; the dashboard will just look stale until it syncs.
  2. There is no IPC except the database. Hooks do not call the MCP server. The statusline does not call the dashboard (well, it does fetch usage data from a separate endpoint, but that is unrelated to MissionCache state). Dashboards and MCP tools do not coordinate. All of them read and write the same SQLite file (plus, optionally, the files on disk).

This shape was a deliberate choice. It makes the system easy to reason about and easy to run partially (you can skip the dashboard, skip the statusline, even skip the hooks and still have a working MissionCache), at the cost of losing some real-time-ness that a centralized daemon would give you for free.

Extension points#

Here is how to do the six most common things you might want to extend MissionCache to do.

1. Add a new MCP tool#

Pick the tool module that matches your use case:

Then follow the pattern every existing tool uses:

from typing import Annotated
from pydantic import Field

from .app import mcp
from .db import get_db
from .errors import MissionCacheError

@mcp.tool()
async def my_tool(
    param: Annotated[str, Field(description="What this parameter is for")],
) -> dict:
    """One-line summary shown in Claude's tool picker. Keep it short."""
    db = get_db()
    try:
        return {"success": True, ...}
    except MissionCacheError as e:
        return e.to_dict()
    except Exception as e:
        logger.exception("Error in my_tool")
        return {"error": True, "message": str(e)}

The @mcp.tool() decorator registers your function against the shared mcp instance. As long as the containing module is imported by server.py, the tool will show up as mcp__plugin_missioncache_pm__my_tool in Claude Code. If you add a new module file, add the from . import tools_mything line to server.py too.

Important: MCP tool docstrings and parameter descriptions are the only thing Claude sees when picking tools. A vague docstring is a bug - the tool will never get called or will be called at the wrong time. Write the docstring like you are writing a commit title.

After adding the tool, reinstall the plugin. If you are hacking on MissionCache locally from a clone, the fast path is the local marketplace that uvx missioncache-install --local set up:

claude plugins install missioncache@local

If you are iterating against a marketplace-installed copy instead, push your changes to GitHub and run claude plugins update missioncache@missioncache. Either way, restart the Claude Code session afterwards - MCP tool registration is cached at plugin load time.

2. Add a new hook#

Decide which Claude Code hook event you want to react to. The supported events are documented in Claude Code's hook reference - MissionCache currently uses four: SessionStart, UserPromptSubmit, PreCompact, Stop.

Write a Python script in hooks/ that:

Add it to hooks/hooks.json under the corresponding event:

"UserPromptSubmit": [
  {
    "hooks": [
      { "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/my_hook.py", "timeout": 5 }
    ]
  }
]

Multiple hooks on the same event run in the order they are listed. They share no state other than what they write to disk. If you need to coordinate with another hook, use a file under ~/.claude/hooks/state/.

Reinstall the plugin and restart Claude Code for the hook definition changes to take effect.

3. Add a new slash command#

Slash commands are markdown files in commands/. There is no code. Create commands/<name>.md:

---
description: "Short description shown in /help"
argument-hint: "[optional args]"
---

# What this command does

<instructions for Claude, written as prose>

The frontmatter is parsed by Claude Code for the help menu. The body is prose instructions that Claude follows when the user types /missioncache:<name>. You can reference MCP tools by their mcp__plugin_missioncache_pm__* names and Claude will call them in order.

If your command needs to be explicit about when to call which tool, write a numbered workflow with MCP tool names inline. See commands/load.md for a good example of a multi-step workflow with conditional branches.

Reinstall the plugin (or use /reload-plugins for command-only changes) to pick up new commands.

4. Add a new missioncache-auto mode or worker behavior#

missioncache-auto is structured around three files you will probably want to touch:

If you add a new mode (say, a "review mode" that spawns a code reviewer after every task), put the top-level orchestration in a new module parallel to sequential.py and parallel.py, and route to it from cli.py. Do not fork worker.py unless you have a very good reason - the completion-detection logic (<what_worked>, <promise>COMPLETE</promise>) is subtle and worth sharing.

db_logger.py is what writes to auto_executions and auto_execution_logs. If your new mode needs to be visible in the dashboard, log through it.

5. Customize the statusline#

missioncache-dashboard/missioncache_dashboard/statusline.py is a single ~1,350-line file because it is performance-sensitive - every import costs milliseconds at the bottom of every Claude message. It ships inside the missioncache-dashboard pip package and is exposed as the missioncache-statusline console entry point. The layout is:

If you want to add a new line, add a rendering function and call it from main() before the final print. If you want to change what an existing line shows, find the matching rendering function by grep-ing for its label.

The statusline has its own environment-variable-based configuration (search for STATUSLINE_ in the file). Document any new variable at the top of the file next to the existing ones.

Do not add dependencies. The statusline uses stdlib only because installing packages into the wrong Python would silently break it for every user. If you absolutely need a third-party library, wire it up via missioncache-db or analytics_db.py instead and call that.

6. Run the dashboard somewhere non-default#

The dashboard reads from ~/.missioncache/tasks.db and ~/.missioncache/tasks.duckdb and listens on port 8787 by default. To change:

launchd config lives at ~/Library/LaunchAgents/com.missioncache.dashboard.plist. It explicitly points at /opt/homebrew/bin/python3.11 because MissionCache's DuckDB install is Python-version-specific. Do not switch it to system Python or python3 without reinstalling DuckDB under that Python.

Invariants and gotchas#

These are the things that will trip you up if you are new to the codebase, collected from bugs that have actually shipped.

Where to go from here#

This doc is the foundation; the other component docs assume you have read it.