2026-06-28-remove-active-project-design.md
docs/superpowers/specs/2026-06-28-remove-active-project-design.md
Remove the active-project concept — design
Date: 2026-06-28 Status: proposed
Goal: delete the registry's
activefield so "all registered projects are active". Replace the single active-project fallback with a deterministic per-project resolver (cwd path-ancestor → explicit--project→ error) and let query ops default to the existing all-registered / federated scopes.
1. How ingest is handled across multiple registered projects today
There is no concept of "the right project" for a path. Ingest picks ONE target wiki and dumps the content into it, regardless of where the path lives:
- CLI
tesserae ingest(tesserae/cli.py:2718,2728): target is purely--projectwithdefault="."(cwd). It callsProjectWiki.load(args.project)directly — no registry, no active project. Multi-project only works if youcdinto (or pass--projectfor) each project's root. - MCP
ingesttool (tesserae/mcp_server.py:1704): target is resolved via_resolve_project_root_for_ask(args), whose fallback chain isgraph_path→project(registry alias) →active_graph_path()→default_graph_path(mcp_server.py:2469-2505). So an MCP clip with no explicit project lands in whatever happens to be active. - Where the bytes go (
tesserae/ingest/orchestrator.py:16-33,tesserae/clip.py:151-156): once a wiki is chosen,_ensure_in_corpusdoespath.resolve(), and if the path is underwiki.project_rootit's used in place; otherwise it isshutil.copy2'd into<target>/data/ingested/. Web clips always write to<wiki.project_root>/data/ingested/.
Sharp edges
- Wrong-project ingest (MCP). With several projects registered and no explicit
project, the clip silently lands in the active one — which may not be the project the user is looking at. There is no cwd/ancestor check. - External paths get copied, not routed. A path outside every project root is copied into the chosen project's
data/ingested/. The system never asks "which registered project does this path belong to?" —iter_registered_projectsexists (mcp_server.py:279-285) but is not consulted on the ingest path. - CLI vs MCP divergence. CLI ingest = cwd; MCP ingest = active fallback. Same logical operation, two different target-selection rules.
compile/status/serve/contextalready use cwd (--project=".",cli.py:1915,1936,1958,2026) — they never readactive. So ingest (MCP) is the only per-project write op that depends onactive, making it the odd one out.
2. Full blast radius of removing active-project
Registry storage / ProjectRegistry (tesserae/mcp_server.py)
:166,172—load()seeds{"version":1,"active":None,"projects":{}}andsetdefault("active", None). Drop theactivekey.:193-200—activate()method. Delete.:202-210—unregister()clearsactiveif matched. Remove the cleanup branch.:212-220—list_projects()returns{"active": ..., "projects": [...]}. Dropactivefrom the payload.:227-233—active_graph_path()method. Delete (callers rewired in §3).
MCP resolution chains (tesserae/mcp_server.py)
:2493-2497—_resolve_project_root_for_ask()active fallback. Remove; rewrite per §3 (ask → all-registered default; ingest → cwd-ancestor).:2727-2735—_load_requested_graph_with_root()active fallback. Remove; read tools must take explicitproject/graph_pathor hitdefault_graph_path.:2502-2505, 2740-2743— error strings that say "activate a project". Reword.
MCP tool surface (tesserae/mcp_server.py)
:708-715—activate_projecttool schema. Delete.:1578-1582—activate_projectcall handler. Delete.:448,449,691,709,735,976— tool/graph_pathdescriptions referencing "active project". Reword.:1157-1183,1213-1231,1238-1273— resource templates /read_resourcefortesserae://graph/summaryandtesserae://lint-reportdescribed as "Active project — …" and wired by calling tools with empty args (relying on active). Decide: drop these resources, or bind them todefault_graph_pathonly.:1298-1369— prompts ("the active Tesserae project"). Reword.
CLI (tesserae/cli.py)
:321-344—_top_level_ask_handleractive fallback branch. Replace with the new default (all-registered) per §3.:325-330— error message namingprojects activate. Reword.:519-533—projects listprintsActive:line and*marker. Drop.:542-549—register --activatebranch in_wiki_command_handler. Delete.:551-558—activatebranch in_wiki_command_handler. Delete.:3276-3278—_handle_projects_activatedispatcher. Delete.:3317—register --activateflag. Delete.:3324-3326—projects activatesubparser. Delete.:3294,3300,3320—projectshelp/epilog mentioning activate. Reword.:726-727,775— top-levelaskparser description/scope help mentioning "active project". Reword.
MCP client surface (mcp__plugin_tesserae_tesserae__activate_project)
- The exposed MCP tool
activate_projectdisappears. Breaking change for any client that calls it.
Tests
tests/test_mcp_registry.py:71-74(activeis None for empty),:165-174(test_activate_project_sets_active_in_registry),:177-180(activate_project_unknown_name_raises),:198-207(unregister_active_project_clears_active),:225-233(tool_call_falls_back_to_active_project).tests/test_cli_top_level_ask.py:95-114(uses_active_project_when_no_args),:117-127(error mentions active),:169-184(list showsActive:/*),:187-202(payload['active']is None),:226-240(wiki_activate_command).tests/test_mcp_ingest.py:1-5,120-126(docstring "active project"; asserts ValueError when no project resolvable — keep but update message/behaviour).tests/conftest.py:63-79— test isolation fixtures for the registry/active.
Registry migration
- Existing
~/.tesserae/registry.jsonfiles contain anactivekey.load()should simply ignore an unknownactivekey (don't crash); no rewrite needed unlesssave()is called, which naturally drops it. Backward-compatible read.
3. Resolution model (replacement for active-project)
Two operation classes, two rules.
A. Per-project ops — compile, ingest, status, serve, context
These act on exactly one project. Resolution order:
- Explicit
--project <path>/projectarg (registry alias or path) — highest priority, unchanged. - cwd path-ancestor match — walk
Path.cwd()upward; the nearest ancestor that is a registered project root (or simply contains.tesserae/graph.json) wins. This is the new default and replaces both the CLIdefault="."literal and the MCP active fallback. A new helper:
`` def resolve_project_by_cwd(registry, start=None) -> Path | None: """Nearest ancestor of start (default cwd) that is a registered project root; fall back to nearest ancestor containing .tesserae/graph.json. Return None if none found.""" ``
Matching against registered roots (via iter_registered_projects, mcp_server.py:279-285) is what makes "all registered projects are active": you're always operating on the registered project you're standing in.
- Ambiguity / none → clear error. If cwd is under two registered roots (nested projects), error and require
--project. If under none and no--project, error: "Not inside a registered Tesserae project. Pass --project <path> or cd into one. Registered: a, b, c."
For ingest specifically, also use the path being ingested as a tiebreaker: when an input path is under exactly one registered root, route the ingest to that project (closing sharp-edge #2). cwd is the fallback when the path is external.
Today compile/status/serve/context use default="." which already means "cwd as a literal path" — upgrading them to resolve_project_by_cwd is mostly a no-op for the common case (you're in the project root) but adds ancestor-walk and a real error instead of a confusing FileNotFoundError.
B. Query ops — ask, context (read), MCP read tools
These can legitimately span projects, and the machinery already exists: --scope current|all-registered|federated in the CLI (cli.py:415-508,376-412) and the scope arg in MCP ask (mcp_server.py:1543-1591).
askwith no--project/--wiki/--scope→ default toall-registered(fan-out over every registered project). This is the literal meaning of "all registered projects are active" and reuses_top_level_ask_scope_all_registered/_mcp_ask_all_registeredunchanged.--scope currentkeeps single-project semantics but now resolves the single project via the §3A rule (cwd-ancestor →--project) instead ofactive.federatedis unchanged (requires explicit--scope-aliases).- MCP read tools (
search_nodes,graph_summary, …) that call_load_requested_graph_with_rootwith noproject: since they each return one graph, default them to the cwd-ancestor project (server-side cwd is the process cwd) and fall back todefault_graph_path; if neither resolves, raise the reworded "pass project/graph_path" error. (They cannot fan out without a response-shape change, so they stay single-graph.)
Reconciliation with existing --scope
Nothing new is invented for query ops — we only change the default from current(+active) to all-registered, and re-point current's single-project lookup at the cwd-ancestor resolver. all-registered and federated already ignore active entirely, so they are untouched.
4. The one design decision that needs the user
What should bare tesserae ask "q" (no flags, no scope) default to?
- Option A — all-registered fan-out (recommended): matches "all registered projects are active" literally; reuses existing code; but a bare question now queries every project (slower, noisier) and changes long-standing single-answer behaviour.
- Option B — cwd-ancestor single project, error if not inside one: keeps
askcheap and local, symmetric with compile/ingest/status; but then "all projects active" is only true for explicit--scope all-registered.
This is the only genuine fork — everything else (deleting activate, the cwd resolver for per-project ops, federated) is mechanical.
5. Staged implementation plan
- Add the resolver. Implement
resolve_project_by_cwd(registry, start)and an ingest path-tiebreaker helper intesserae/mcp_server.py(next toProjectRegistry), with unit tests for: in-root, ancestor, nested-ambiguous (error), and not-found (None). No behaviour change yet. - Rewire per-project ops to the resolver. Replace
--project default="."handling incompile/status/serve/context/ingestCLI handlers (cli.py:1135,1584,2041,2115,2728) to resolve via cwd-ancestor when--projectis omitted, with the explicit-ambiguity error. Update MCPingest(mcp_server.py:1704) to use the new resolver + path tiebreaker instead of_resolve_project_root_for_ask. - Change query defaults. Make bare
askuse the §4-chosen default (CLI_top_level_ask_handlercli.py:321-344; MCP askmcp_server.py:1543-1591). Re-point--scope currentsingle-project lookup at the new resolver. Update_load_requested_graph_with_root(:2727-2735) and_resolve_project_root_for_ask(:2493-2497) to drop the active fallback. - Delete the active machinery. Remove
activate()/active_graph_path()(mcp_server.py:193-200,227-233), theactivekey fromload()/list_projects()(:166,172,212-220), the unregister cleanup (:207-208), theactivate_projectMCP tool+handler (:708-715,1578-1582), and the CLIprojects activatesubparser/handler +register --activate(cli.py:551-558,3276-3278,3317,3324-3326,542-549). - Reword copy. All "active project" strings in tool descriptions, resource templates, prompts,
projects listoutput, andaskhelp (mcp_server.py:448,691,735,976,1157-1273,1298-1369;cli.py:519-533,726-727,775,3294-3320). - Migration safety. Confirm
load()ignores a strayactivekey in old registries;save()naturally drops it. No destructive migration. - Tests. Delete/rewrite the active-coupled tests (§2), add tests for the cwd-ancestor resolver, ambiguity error, ingest path-routing, and the new bare
askdefault. Updatetests/conftest.py:63-79fixtures to stop seedingactive. - Docs. Update AGENTS.md / any user docs that mention
projects activate.