From 26ef07ef637d0c268c57776e0862f844f07a2d40 Mon Sep 17 00:00:00 2001 From: Ashmit Biswas Date: Sun, 31 May 2026 05:33:06 +0530 Subject: [PATCH] =?UTF-8?q?docs:=203.1.0=20audit=20=E2=80=94=20README=20+?= =?UTF-8?q?=20architecture=20+=20workspace=20+=20AGENTS=20rewrites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refresh docs to reflect Wave 3.0 (slot model) and Plan 2 (feature_resume brief + GH thread mutations). Earlier docs were pinned to pre-3.0 numbers and module layouts. Rewrites - README.md (185 → 215 lines): 67 tools / 857 tests / 9 states / slot layout / canopy resume + thread mutations / updated agent-contract pitch. Failure-mode table picks up the session-re-derivation row. - docs/architecture.md (244 → 321 lines): current actions/ tree (adds slots, slot_load, slot_details, migrate_slots, last_visit, resume, thread_actions, thread_resolutions, bot_resolutions, bot_status, augments, bootstrap, conflicts, draft_replies, historian, ide_workspace, ship; drops active_feature, realign). New "Slot model internals" section covering slots.json schema, in_flight transaction marker, LRU policy. New "Plan 2 — resume + threads" callout. State files table now lists slots.json, visits.json, thread_resolutions.json, bot_resolutions.json. - docs/workspace.md (252 → 329 lines): correct .canopy/worktrees/ worktree-N// layout, all six state files documented with schemas, canopy.toml example uses [workspace] slots = N, features.json example shows a single-repo feature, alias section adds worktree-N slot id. - AGENTS.md (103 → 228 lines): adds the entire actions/ layer, current test count (857), state-files table, recipes for new action / state file / bundled skill / augment key. JSON output contract refreshed. Surgical edits - docs/architecture/providers.md: status block "lands in M5" -> "shipped in M5"; broken link fix; M3 ship status reflected in §8. - src/canopy/agent_setup/skills/augment-canopy/SKILL.md: drop "(when shipped)" / "(deferred)" qualifiers now that M3 + doctor have landed. Light audit - docs/concepts.md: verified clean (9 states match feature_state.py, slot default 2 matches config.py, resume brief shape matches resume.py, historian functions exist as described). No changes. - src/canopy/agent_setup/skills/using-canopy/SKILL.md: verified clean (67 tools, 21 doctor codes, issue_get tool registered). No changes. Cleanup - Deleted docs/test-findings.md (canopy 0.5.0 historical report) - Deleted github-issue-active-context.md (proposal superseded by slot model in 3.0.0) - Archived docs/test-plan.md -> docs/archive/test-plan.md (M0-M5 integration scaffold, pinned in time) --- AGENTS.md | 270 ++++++++++++----- README.md | 71 +++-- docs/architecture.md | 153 +++++++--- docs/architecture/providers.md | 6 +- docs/{ => archive}/test-plan.md | 0 docs/test-findings.md | 277 ------------------ docs/workspace.md | 209 ++++++++----- github-issue-active-context.md | 74 ----- .../skills/augment-canopy/SKILL.md | 6 +- 9 files changed, 513 insertions(+), 553 deletions(-) rename docs/{ => archive}/test-plan.md (100%) delete mode 100644 docs/test-findings.md delete mode 100644 github-issue-active-context.md diff --git a/AGENTS.md b/AGENTS.md index 5ade75e..2e1c9db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,102 +1,228 @@ -# Canopy — Agent Guidelines +# Canopy — Contributor Guide for AI Agents -## For AI Agents Working on This Codebase +This file is the "how to extend canopy without breaking module boundaries" guide. +It is CLAUDE.md's sibling, not its replacement: -### Before You Start +- `CLAUDE.md` — what canopy is, architecture overview, key conventions, MCP tool list +- `docs/concepts.md` — the four conceptual pillars (action framework, context contract, state machine, slot model, resume brief) +- `docs/architecture.md` — formal module reference -1. Read `CLAUDE.md` for architecture and conventions. -2. Run `pytest tests/ -v` to verify the baseline (159 tests, ~2s). +## Before You Start -### Module Boundaries +1. Read `CLAUDE.md`. It has the architecture diagram, slot model explanation, and all key conventions. +2. Read `docs/concepts.md` if you need the vocabulary for the state machine or action framework. +3. Run the test suite to confirm your baseline: `pytest tests/ -v` (857 tests, ~225s). +4. If you are adding to the slot model or switch flow, read `actions/slots.py` and `actions/switch.py` first. -**Do not break these boundaries:** +## Module Boundaries -- `git/repo.py` is the **only** file that calls `subprocess.run(["git", ...])`. If you need a new git operation, add it there. -- `git/multi.py` calls `git/repo.py` functions across multiple repos. Cross-repo logic goes here. -- `features/coordinator.py` manages feature lane lifecycle. It calls `git/multi.py` for the actual git work, and `git/repo.py` for worktree operations. -- `workspace/workspace.py` holds the `Workspace` class. It reads git state via `git/repo.py` but does not do cross-repo coordination. -- `workspace/context.py` detects where canopy is running from. It reads filesystem paths and calls `git/repo.py` for branch info. -- `cli/main.py` is a thin layer that parses args and calls the modules above. Keep business logic out of the CLI. -- `mcp/server.py` wraps the same modules as the CLI. Every tool calls the same functions — the MCP server should never have its own logic. -- `mcp/client.py` is the MCP client — it spawns external MCP servers and calls their tools. All external integrations go through this. -- `integrations/linear.py` fetches Linear issue data via `mcp/client.py`. It never calls the Linear API directly. -- `integrations/github.py` finds PRs for a branch and fetches unresolved review comments via `mcp/client.py`. It never calls the GitHub API directly. -- `integrations/precommit.py` detects and runs pre-commit hooks. It checks for `.pre-commit-config.yaml` (framework) and `.git/hooks/pre-commit` (raw git hooks). It does not go through MCP — it runs hooks locally via subprocess. +These are hard rules. Do not break them. -### Adding a New CLI Command +- **`git/repo.py` is the only file that calls `subprocess.run(["git", ...])`.** + New git operations belong here. Everything else calls functions from this module. -1. Add the command function in `cli/main.py` following the `cmd_*` pattern. -2. Add argparse config in `main()`. -3. Always support `--json` output via `_print_json()`. -4. The human-readable output should be concise, indented with 2 spaces, and use `─` for separators. -5. If the command is useful for AI agents, add a matching tool in `mcp/server.py`. +- **`git/multi.py` handles cross-repo git operations.** It calls `git/repo.py` functions; + it does not shell out to git directly. -### Adding a New MCP Tool +- **`mcp/server.py` and `cli/main.py` are thin wrappers.** + Business logic lives in `actions/`, `features/coordinator.py`, `git/multi.py`, + and `workspace/`. Neither the MCP server nor the CLI should own logic. -1. Add a `@mcp.tool()` function in `mcp/server.py`. -2. Use `_get_workspace()` to load the workspace. -3. Call existing module functions — don't put logic in the server. -4. Return dicts/lists (FastMCP handles JSON serialization). -5. Write clear docstrings — they become the tool descriptions AI agents see. +- **All actions live in `actions/`.** This is the most-modified directory. Each action + module owns one concern: `switch.py` owns slot focus, `slots.py` owns slot state + reads/writes, `drift.py` owns drift detection, `resume.py` owns the resume brief, etc. -### Adding a New Git Operation +- **Actions raise `BlockerError` for precondition failures.** + Shape: `BlockerError(code, what, expected, actual, fix_actions, details)`. + CLI renders via `cli/render.py`. MCP returns `to_dict()`. Same shape, two consumers. -1. Add the function to `git/repo.py` using `_run()` or `_run_ok()`. -2. Write a test in `tests/test_repo.py` or `tests/test_new_commands.py`. -3. Be careful with `_run_ok()` — it returns empty string on failure, which is correct for query operations but dangerous for writes. +- **Universal aliases — every read tool accepts multiple forms.** + Feature name, Linear ID, `#`, PR URL, `:`, or slot id + (`worktree-N` → slot's current occupant). Always resolve via + `actions/aliases.py:resolve_feature`. Never reimplement alias resolution inline. -### Testing Conventions +- **Per-repo branches map — never assume branch == feature name.** + Use `lane.branch_for(repo)` or `repos_for_feature(workspace, feature)`. + `FeatureLane.repos` alone does not give you branch names for legacy features. -- All tests use real temporary Git repos, not mocks. This catches real git behavior differences. -- Fixtures are in `tests/conftest.py`. Reuse them. -- Test file naming: `test_.py` or `test_.py`. -- Run tests from the `canopy/` directory: `pytest tests/ -v`. -- Worktree tests should clean up with `git worktree remove` when done. +- **All integrations go through `mcp/client.py` or the `gh` CLI fallback.** + Integration modules in `integrations/` never call external APIs directly. + `integrations/github.py` falls back to `gh api` / `gh pr` when no MCP server + is configured. If neither is available, raise `BlockerError(code='github_not_configured')`. + +- **`integrations/precommit.py` is the one exception to the MCP-only rule.** + It runs local hooks via subprocess. This is intentional — hooks run locally. + +## State Files + +All state is under `.canopy/state/`. OAuth tokens at `~/.canopy/mcp-tokens/`. + +| File | Owner | Notes | +|---|---|---| +| `heads.json` | `git/hooks.py` + post-checkout hook | Written by hook; read by `drift.py`, `historian.py` | +| `slots.json` | `actions/slots.py` | Canonical + warm slot occupancy, `last_touched` LRU, `in_flight` marker | +| `preflight.json` | `actions/preflight_state.py` | Records preflight result per feature | +| `visits.json` | `actions/last_visit.py` | Per-feature `last_visit` / `previous_visit` ISO timestamps | +| `thread_resolutions.json` | `actions/thread_resolutions.py` | Resolved GitHub review threads | +| `bot_resolutions.json` | `actions/bot_resolutions.py` | Bot-comment resolutions addressed via `commit --address` | + +All state writes use atomic temp+rename (`tmp.replace(path)`) to prevent corruption +from concurrent agents. See `actions/slots.py` for the canonical pattern. + +## Adding a New Action + +This is the most common change. + +1. Create `src/canopy/actions/.py`. Raise `BlockerError` for preconditions. + Use existing fixtures and patterns; don't re-invent error shapes. + +2. Expose via CLI in `cli/main.py`: + - Add `cmd_(args: argparse.Namespace) -> None` + - Add a subparser in `main()` + - Dispatch via the `commands` dict + - Support `--json` via `_print_json()` + +3. Expose via MCP in `mcp/server.py`: + - Add `@mcp.tool()` wrapper that calls the action function + - Write a clear docstring — it becomes the tool description agents see + +4. Add tests in `tests/test_.py` using the `workspace_with_feature` fixture + (or another fixture from `tests/conftest.py`). -### JSON Output Contract +5. If the action is user-facing: + - Update `docs/commands.md` + - Update `docs/mcp.md` + - Update the architecture box and MCP-tool-group listing in `CLAUDE.md` -Every `--json` command and MCP tool returns structured data. Key shapes: +6. If agents need to know when to prefer the new tool, update + `~/.claude/skills/using-canopy/SKILL.md` and + `src/canopy/agent_setup/skills/using-canopy/SKILL.md`. -- `workspace_status` → `WorkspaceStatus` (see `workspace.py:Workspace.to_dict()`) -- `feature_list` → `list[FeatureLane.to_dict()]` -- `feature_status` → `FeatureLane.to_dict()` (includes `repo_states` with `worktree_path`) -- `feature_diff` → dict with `repos`, `summary`, `type_overlaps` -- `workspace_context` → `CanopyContext.to_dict()` (context_type, feature, repo_names, repo_paths) -- `worktree_info` → `{features: {name: {repos: {name: {path, branch, dirty, ahead, behind}}}}, repos: {name: {main_path, worktrees: [...]}}}` -- `worktree_create` → `FeatureLane.to_dict()` + `worktree_paths` (optional `linear_issue`, `linear_title`, `linear_url`) -- `review_status` → `{feature, repos: [{name, branch, pr: {number, url, state, title}, has_unresolved_comments}], precommit: {available, passed, errors}}` -- `review_comments` → `{feature, repos: [{name, pr_number, comments: [{path, line, body, author, resolved}]}]}` -- `review_prep` → `{feature, ready, blockers: [str], repos: [{name, merge_readiness, pr_status, unresolved_comment_count, precommit_passed}]}` +## Adding a New CLI Command Only -### Integration Conventions +When a new subcommand wraps existing actions without needing a new action module: -- **All integrations go through MCP.** Canopy never calls external APIs directly. It spawns the relevant MCP server via `mcp/client.py` and calls tools. -- **MCP server configs live in `.canopy/mcps.json`.** Each key is a server name (e.g. `"linear"`) with `command`, `args`, and `env`. -- **Graceful degradation.** If an MCP server isn't configured, the feature still works — just without enrichment (e.g. Linear issue title won't be fetched, but the issue ID is still stored). +1. Add `cmd_(args)` in `cli/main.py` calling existing action functions. +2. Add subparser in `main()`, dispatch in the `commands` dict. +3. Support `--json` via `_print_json()`. +4. Human-readable output: 2-space indent, `─` for separators. +5. Update `docs/commands.md`. -### Adding a New Integration +## Adding a New MCP Tool Only -1. Add a module in `integrations/` (e.g. `integrations/github.py`). -2. Use `mcp.client.get_mcp_config()` to check if the server is configured. -3. Use `mcp.client.call_tool()` to call the server's tools. -4. Handle `McpClientError` gracefully — never fail the whole operation because an integration is down. -5. Store any linked metadata in `features.json` via `FeatureLane` fields. +When the action already exists and you just need to expose it: + +1. Add `@mcp.tool()` in `mcp/server.py`. Call the existing action function directly. +2. Return dicts/lists (FastMCP handles JSON serialization). +3. Write a docstring — it is the tool description. +4. Update `docs/mcp.md` and the tool-group listing in `CLAUDE.md`. + +## Adding a New Git Operation + +1. Add the function to `git/repo.py` using `_run()` or `_run_ok()`. + Prefer `_run()` (raises on failure) for write operations. + `_run_ok()` (returns empty string on failure) is only safe for reads. +2. Write a test in `tests/test_repo.py`. +3. Do not call `subprocess.run(["git", ...])` anywhere else. + +## Adding a New State File + +1. Create `actions/.py` with a module docstring naming the path + (e.g., `State file: .canopy/state/.json`). +2. Use the atomic temp+rename pattern from `actions/slots.py` or + `actions/thread_resolutions.py`: + ```python + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data)) + tmp.replace(path) + ``` +3. Update the state-files table in this file. +4. Update the state-files line in `CLAUDE.md`. +5. Update the state-files table in `docs/architecture.md` and + the state-files section in `docs/workspace.md`. + +## Adding a New Integration + +1. Add a module in `integrations/`. +2. Use `mcp/client.py` to call the MCP server, or `gh` CLI as fallback. +3. Check for server presence via `mcp.client.get_mcp_config()` before calling. +4. Handle `McpClientError` gracefully — never fail the whole operation because + an integration is unavailable. +5. If the integration is Linear or GitHub, link metadata into `features.json` + via `FeatureLane` fields rather than a separate sidecar file. 6. Write tests that mock the MCP call but test the data flow end-to-end. -### IDE Launcher Conventions +## Adding a New Bundled Skill + +1. Create `src/canopy/agent_setup/skills//SKILL.md`. +2. Default skills (always installed on `canopy setup-agent`) must be declared + in `agent_setup/__init__.py`. +3. Opt-in skills install via `canopy setup-agent --skill `. +4. Document the new skill in `docs/agents.md` under the skills section. +5. Foreign skills at the same install path are not overwritten without `--reinstall`. + +## Adding a New Augment Key + +1. Update `src/canopy/actions/augments.py`. + The resolver is intentionally lenient — unknown keys are silently preserved, + so adding a new key is backward-compatible. +2. Consume the new key in whichever action or integration needs it via + `repo_augments(workspace, repo_name).get("")`. +3. Document the key in the recognized-keys table in + `src/canopy/agent_setup/skills/augment-canopy/SKILL.md`. +4. Add a `canopy doctor` check if misconfiguration has a clear error form. + +## Testing Conventions + +- All tests use real temporary Git repos, not mocks. This catches real git behavior. +- Fixtures are in `tests/conftest.py`. Key fixtures: + - `workspace_dir` — bare workspace with `api/` and `ui/` repos on main + - `workspace_with_feature` — workspace with `auth-flow` branches + commits in both repos + - `canopy_toml` — workspace with a canopy.toml already written +- Test file naming: `test_.py` or `test_.py`. +- Worktree tests must clean up with `git worktree remove` when done. +- Run: `pytest tests/ -v` from the `canopy/` directory. + +## JSON Output Contract + +Every `--json` command and MCP tool returns structured data. The shape is the contract +and is defined in each action's docstring. CLI and MCP return identical bytes. -- `canopy code/cursor` generates `.code-workspace` files in `.canopy/` for multi-root workspaces. -- `canopy fork` opens each repo separately (Fork doesn't support multi-root). -- On macOS, `fork` CLI is preferred; fallback is `open -a Fork`. -- These commands use `FeatureCoordinator.resolve_paths()` to find the right directories. +Key shapes: -### Context Detection +| Tool / command | Root shape | +|---|---| +| `workspace_status` | `WorkspaceStatus` (see `Workspace.to_dict()`) | +| `feature_list` | `list[FeatureLane.to_dict()]` | +| `feature_status` | `FeatureLane.to_dict()` with `repo_states` | +| `feature_state` | 9-state machine result + `summary` + `next_actions` | +| `feature_resume` | `version: 1`, `since_last_visit`, `current_state`, `intent_hints` | +| `slots(rich=True)` | `canonical` + per-slot enriched dashboard payload | +| `triage` | priority-tiered cross-repo PR enumeration | -`workspace/context.py` detects four context types based on cwd: +## Context Detection -1. `feature_dir` — inside `.canopy/worktrees//` (all repos in scope) -2. `repo_worktree` — inside `.canopy/worktrees///` (single repo) +`workspace/context.py` distinguishes four context types based on cwd: + +1. `feature_dir` — inside `.canopy/worktrees/worktree-N/` (slot root; all repos in scope) +2. `repo_worktree` — inside `.canopy/worktrees/worktree-N//` (single repo) 3. `repo` — inside a normal workspace repo (feature = current branch if non-default) 4. `workspace_root` — at the canopy.toml level (all repos in scope) -`canopy stage` uses this to know which repos to commit to without explicit arguments. +Used by `canopy stage` and other context-sensitive commands. + +## Version Handshake + +When shipping a milestone: + +1. Bump `__version__` in `src/canopy/__init__.py`. +2. Add a section to `CHANGELOG.md`. +3. Doctor's `cli_stale` / `mcp_stale` checks compare against this version — + the handshake is only useful if the number actually moves. + +## Hooks Safety + +`git/templates/post-checkout.py` uses `fcntl.flock` and atomic rename so concurrent +fires across repos in the same workspace don't race. It chains any pre-existing user +hooks and is installed by `canopy init` (or `canopy hooks install`). Worktrees inherit +hooks via the git `commondir` mechanism. diff --git a/README.md b/README.md index 09f95eb..352bdb6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@

Python 3.10+ - Tests - MCP Tools + Tests + MCP Tools VSCode Extension License MIT

@@ -25,28 +25,31 @@ If you work across multiple repos, you've felt this: - Your AI agent shells `cd /wrong/repo && command` because shell state doesn't persist between its tool calls. - PR review comments pile up across repos and the agent burns context re-deriving "is this still actionable?" -Canopy was built around one constraint: an AI agent has to be able to drive multi-repo work safely — typed inputs, structured outputs, recoverable errors. Get that right, and you can hand the agent real authority over feature lifecycles. The CLI you get for free, because the same primitives work for human hands. The detail table is below — first, the verb that does the lifting. +Canopy was built around one constraint: an AI agent has to be able to drive multi-repo work safely — typed inputs, structured outputs, recoverable errors. Get that right, and you can hand the agent real authority over feature lifecycles. The CLI you get for free, because the same primitives work for human hands. The detail table is below — first, the verbs that do the lifting.

canopy switch sin-7-empty-state

-**`canopy switch `** promotes a feature into the canonical slot — checks it out in your main directory across every repo it touches, parks the previously-focused feature to a warm worktree, preserves dirty work via stash. Multi-repo focus, one verb, no `cd`. +**`canopy switch `** promotes a feature into the canonical slot — checks it out in your main directory across every repo it touches, parks the previously-focused feature to a warm slot, preserves dirty work via stash. Multi-repo focus, one verb, no `cd`. -Everything else — preflight, status, triage, review, commit, push — is in service of that switch. Each command has a typed `mcp__canopy__*` equivalent returning the same JSON. **One primitive at the center, two surfaces.** The CLI is the surface humans like. The MCP server is the surface that makes canopy load-bearing. +**`canopy resume `** is the session-start primitive. Hand it a Linear ID, feature name, PR URL, or `worktree-N` slot id and canopy does the rest: resolves the alias, switches to canonical if needed, refreshes GitHub + Linear state, returns a structured brief of what changed since you were last on this feature, plus `intent_hints` for the most likely next actions. One call gets you (or your agent) back in business. + +Everything else — preflight, status, triage, review, commit, push — is in service of those two. Each command has a typed `mcp__canopy__*` equivalent returning the same JSON. **Two primitives at the center, two surfaces.** The CLI is the surface humans like. The MCP server is the surface that makes canopy load-bearing. ## Why it's load-bearing -Multi-repo work breaks in specific, predictable ways. `canopy switch` and its accessories close each: +Multi-repo work breaks in specific, predictable ways. Canopy closes each: | Failure mode | Canopy's fix | |---|---| | You switch one repo's branch, forget the other; next push goes to the wrong place. | `canopy switch ` is atomic across every participating repo. Drift in the meantime is detected in real time by a post-checkout hook and surfaced via `canopy drift` / `canopy state`. | -| You're juggling 2–3 features at once; switching loses your in-progress work or buries it in a stash you forget. | `canopy switch` runs in **active rotation** by default — the previously-focused feature evacuates to a warm worktree (dirty work follows via stash → pop). Switching back is instant. | -| You start using `git worktree add` to keep features parallel. By feature 4 you have a scatter of `~/work/auth-feature-api/` / `~/scratch/api-newauth/` dirs with no naming convention. Cleanup means remembering paths to `git worktree remove`, then `git branch -D` per repo. | Canopy enforces `.canopy/worktrees///` — predictable layout, one place to look. `canopy list` shows every feature with its worktree paths. `canopy done ` removes every worktree and deletes every branch across all repos in one verb. | -| You're on `dev` in main with two warm feature worktrees parked alongside. You want to commit, run preflight, or push on a feature without `cd`-ing into its worktree directory. Raw git makes you switch your shell's `cwd` first. | `canopy commit --feature ` / `canopy preflight --feature ` / `canopy push --feature ` operate against the warm worktree directly — your `cwd` doesn't move. Or `canopy switch ` to promote the warm feature into the canonical slot first (dev evacuates to its own worktree, dirty work follows via stash → pop). | +| You're juggling 2–3 features at once; switching loses your in-progress work or buries it in a stash you forget. | `canopy switch` runs in **active rotation** by default — the previously-focused feature evacuates to a warm slot (dirty work follows via stash → pop). Switching back is instant. | +| You start using `git worktree add` to keep features parallel. By feature 4 you have a scatter of directories with no naming convention. Cleanup means remembering paths to `git worktree remove`, then `git branch -D` per repo. | Canopy uses generic numbered slots: `.canopy/worktrees/worktree-N//`. Slot identity is stable across feature swaps; feature occupancy is transient. `canopy list` shows every feature and its slot. `canopy done ` clears every worktree and branch across all repos in one verb. | +| You're on `dev` in main with warm feature slots parked alongside. You want to commit, run preflight, or push on a feature without `cd`-ing into its slot directory. | `canopy commit --feature ` / `canopy preflight --feature ` / `canopy push --feature ` operate against the warm slot directly — your `cwd` doesn't move. Or `canopy switch ` to promote the warm feature into the canonical slot first. | +| You return to a feature after a day away and spend five minutes re-reading PRs, re-checking CI, re-classifying which threads are still open. | `canopy resume ` compares current GitHub + Linear state against a last-visit anchor (`.canopy/state/visits.json`) and returns a structured brief: new commits, freshly opened threads, CI changes. Session-start in one call. | | Your AI agent shells `cd /wrong/repo && command` because shell state doesn't persist between tool calls. | Every canopy tool takes `feature` / `repo` as parameters; path resolution lives inside canopy. The agent has no surface area for the mistake. | -| Your agent re-derives PR state on every run because nothing it learned in the previous turn persists. It re-parses `gh pr list`, re-walks `git status` per repo, re-classifies threads — burning context on bookkeeping. | `mcp__canopy__triage` and `mcp__canopy__feature_state` return cached structured data: PR numbers, review state, dirty counts, per-repo paths. Agent reads, doesn't re-derive. Same JSON across runs. | +| Your agent re-derives PR state on every run because nothing it learned in the previous turn persists. | `mcp__canopy__triage` and `mcp__canopy__feature_state` return cached structured data: PR numbers, review state, dirty counts, per-repo paths. Agent reads, doesn't re-derive. Same JSON across runs. | | Drift happens silently *between* the agent's tool calls — it ran `git checkout X` in one repo, the next call assumes alignment, things go sideways. | Per-repo post-checkout hooks write `.canopy/state/heads.json` atomically (fcntl-locked). `mcp__canopy__drift` reads cached state in <50ms. The agent sees the misalignment that happened between calls — even when it didn't cause it. | | You and your agent see different views of workspace state. You're looking at `canopy status`; the agent's reading some other JSON it cached three turns ago. Decisions diverge. | The CLI and MCP server are thin wrappers over the same actions. `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. Single source of truth, two surfaces. | | PR review comments pile up across repos; the agent burns context re-deriving "is this still actionable?". | `canopy review ` returns threads pre-classified as `actionable` vs `likely_resolved`. The temporal classifier filters out comments addressed in subsequent commits. | @@ -57,13 +60,13 @@ Multi-repo work breaks in specific, predictable ways. `canopy switch` and its ac ## The agent contract -Other multi-repo helpers — raw `git worktree add`, monorepo-specific bash wrappers, per-team scripts that wrap them — are built for humans at a terminal. Agents can't use them safely: shell state evaporates between tool calls, paths get constructed wrong, errors come back as stderr text the agent has to interpret. +Other multi-repo helpers — raw `git worktree add`, monorepo-specific bash wrappers, per-team scripts — are built for humans at a terminal. Agents can't use them safely: shell state evaporates between tool calls, paths get constructed wrong, errors come back as stderr text the agent has to interpret. -Canopy exposes **43 typed MCP tools**. Each takes `feature` / `repo` as parameters, returns JSON, fails with a structured `BlockerError(code, what, expected, actual, fix_actions)`. The agent never specifies a path, never parses stderr, never re-derives state. +Canopy exposes **67 typed MCP tools**. Each takes `feature` / `repo` as parameters, returns JSON, fails with a structured `BlockerError(code, what, expected, actual, fix_actions)`. The agent never specifies a path, never parses stderr, never re-derives state. ```python # Brittle — agent constructs the path, parses stderr, hopes for the best: -bash("cd /Users/me/projects/canopy-test/.canopy/worktrees/sin-7/test-api && git status") +bash("cd /Users/me/projects/canopy-test/.canopy/worktrees/worktree-1/test-api && git status") # Path-safe — canopy owns resolution and returns structured data: mcp__canopy__feature_status(feature="sin-7-empty-state") @@ -93,6 +96,7 @@ If you don't have pipx: `brew install pipx && pipx ensurepath`. ## What you do every day ```bash +canopy resume # session start — switch if needed + brief of what changed canopy switch # focus — promote to the canonical slot canopy status # where am I across repos? canopy preflight # run per-repo hooks before committing @@ -112,15 +116,39 @@ Every CLI command has an `mcp__canopy__*` equivalent for the agent side, returni `canopy switch` operates in two modes: -- **Active rotation (default).** The previously-focused feature evacuates to a warm worktree at `.canopy/worktrees///`, with stash → checkout → pop. Switching back is one command and instant. +- **Active rotation (default).** The previously-focused feature evacuates to a numbered warm slot at `.canopy/worktrees/worktree-N//`, with stash → checkout → pop. Slot identity (`worktree-1`, `worktree-2`, ...) is stable across feature swaps — the slot keeps its id when a new feature moves in. Switching back is one command and instant. - **Wind-down (`--release-current`).** The previously-focused feature goes cold (just the branch + a feature-tagged stash for any dirty work). Use when you're parking it or done with it. ```bash canopy switch sin-7-empty-state # active rotation canopy switch sin-7-empty-state --release-current # wind-down +canopy switch sin-7-empty-state --to-slot worktree-2 # target a specific slot +canopy switch sin-7-empty-state --evict-to worktree-1 # evict current to a specific slot +``` + +`slots` (default 2) in `canopy.toml` caps how many warm slots co-exist alongside the canonical slot. When the cap fires, `switch` returns a structured `BlockerError` with explicit fix actions — evict LRU to cold, switch in wind-down mode, finish a feature, or raise the cap. No silent eviction. The old `max_worktrees` key now raises a `ConfigError` pointing at `canopy migrate-slots`. + +Beyond switch, the slot primitives are available directly: + +```bash +canopy slot load --slot worktree-2 # load a feature into a specific slot +canopy slot clear worktree-1 # vacate a slot (stash + remove worktree) +canopy slot swap worktree-1 worktree-2 # swap two slots' occupants ``` -`max_worktrees` (default 2) caps how many warm worktrees co-exist alongside the canonical slot. When the cap fires, `switch` returns a structured `BlockerError` with explicit fix actions — evict LRU to cold, switch in wind-down mode, finish a feature, or raise the cap. No silent eviction. +## Closing review threads + +Once you've addressed a comment in code, close the loop without leaving GitHub: + +```bash +canopy resolve # resolve + log +canopy reply --body "Done in abc1234" # reply (optionally with --resolve) +canopy commit -m "fix: handle null" --address --resolve-thread +``` + +The `--address` flag records the bot comment as resolved against the commit SHA. `--resolve-thread` marks the corresponding GitHub review thread closed. Set `auto_resolve_threads_on_address = true` in `[augments]` to make `--resolve-thread` the default whenever `--address` is used. + +Resolved threads are logged to `.canopy/state/thread_resolutions.json` and surfaced in the `canopy resume` brief so nothing slips through. ## Triage and review @@ -132,7 +160,7 @@ After you switch, canopy tells you what's worth your attention: `canopy triage` enumerates active features by review-state priority. `canopy review ` shows actionable PR threads only. -`canopy state ` returns one of 8 states (`drifted`, `needs_work`, `in_progress`, `ready_to_commit`, `ready_to_push`, `awaiting_review`, `approved`, `no_prs`) plus a `next_actions` array. The agent reads the array; you read the colored output. Same JSON. +`canopy state ` returns one of 9 states (`drifted`, `needs_work`, `awaiting_bot_resolution`, `in_progress`, `ready_to_commit`, `ready_to_push`, `awaiting_review`, `approved`, `no_prs`) plus a `next_actions` array. The agent reads the array; you read the colored output. Same JSON.

canopy state @@ -148,11 +176,13 @@ After you switch, canopy tells you what's worth your attention: ## For your AI agent -Canopy ships with a [`using-canopy`](src/canopy/agent_setup/skill.md) skill (installed by `canopy init`) and an MCP server with 43 tools. The skill teaches the agent: *use canopy MCP for path-safe multi-repo ops*. After install, an agent will: +Canopy ships with a [`using-canopy`](src/canopy/agent_setup/skills/using-canopy/SKILL.md) skill (installed by `canopy init`) and an MCP server with 67 tools. The skill teaches the agent: *use canopy MCP for path-safe multi-repo ops*. After install, an agent will: +- Call `mcp__canopy__feature_resume(alias='SIN-42')` at session start to get a brief of what changed and the likeliest next actions — no manual re-derivation. - Call `mcp__canopy__triage` instead of parsing `gh pr list` output across repos. Each result carries `is_canonical` + `physical_state` + per-repo `path` so the agent knows whether to switch first or just operate. -- Call `mcp__canopy__switch(feature='SIN-42')` instead of `cd repo && git checkout` per repo. The previously-focused feature evacuates to a warm worktree, preserving work-in-progress. +- Call `mcp__canopy__switch(feature='SIN-42')` instead of `cd repo && git checkout` per repo. The previously-focused feature evacuates to a warm slot, preserving work-in-progress. - Call `mcp__canopy__run(repo='backend', command='pytest tests/')` instead of `cd /path && pytest`. +- Call `mcp__canopy__resolve_thread(thread_id='...')` or `mcp__canopy__reply_to_thread(...)` to close review threads without leaving the agent loop. - Read `mcp__canopy__feature_state(feature).next_actions` to know what to do next. Linear MCP works via OAuth (browser flow once, no API key). GitHub works via `gh` CLI fallback when MCP isn't configured. See [docs/agents.md](docs/agents.md) for the full integration story. @@ -163,12 +193,13 @@ Same operations are also available via a [VSCode extension](https://marketplace. ## Docs -- [Concepts](docs/concepts.md) — the action framework, agent context contract, 8-state machine +- [Concepts](docs/concepts.md) — the action framework, agent context contract, 9-state machine - [Agents](docs/agents.md) — skill, `setup-agent`, integration recipes - [Commands](docs/commands.md) — full CLI reference, organized by workflow stage - [MCP](docs/mcp.md) — server tool list, client transports (stdio + HTTP/OAuth), gh fallback - [Workspace](docs/workspace.md) — `canopy.toml`, `features.json`, state files, mcp.json - [Architecture](docs/architecture.md) — module boundaries and design rules +- [Architecture / Providers](docs/architecture/providers.md) — provider injection and transport layer ## Develop @@ -176,7 +207,7 @@ Same operations are also available via a [VSCode extension](https://marketplace. git clone https://github.com/ashmitb95/canopy.git ~/projects/canopy cd ~/projects/canopy pip install -e ".[dev]" -pytest tests/ -v # 436 tests, ~80s, all use real temporary Git repos +pytest tests/ -v # 857 tests, ~225s, all use real temporary Git repos ``` ## License diff --git a/docs/architecture.md b/docs/architecture.md index 9470696..a67b96a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,61 +1,85 @@ # Architecture +Canopy 3.1.0. + ``` src/canopy/ ├── cli/ -│ ├── main.py # argparse CLI — thin layer, no business logic +│ ├── main.py # argparse CLI — all commands; thin wrapper, no business logic │ ├── ui.py # rich terminal output (theme, spinners, colors) │ └── render.py # structured-error renderer (BlockerError → multi-line CLI) ├── workspace/ │ ├── config.py # canopy.toml parser (RepoConfig, WorkspaceConfig) │ ├── discovery.py # auto-detect repos + worktrees, generate toml -│ ├── context.py # context detection from cwd +│ ├── context.py # context detection (feature_dir, repo_worktree, repo, workspace_root) │ └── workspace.py # Workspace class, RepoState dataclass ├── git/ │ ├── repo.py # ALL git subprocess calls (single-repo only) │ ├── multi.py # cross-repo operations (calls repo.py) -│ ├── hooks.py # install/uninstall post-checkout hook + state file reader +│ ├── hooks.py # install/uninstall post-checkout hook + heads.json reader │ └── templates/ -│ └── post-checkout.py # hook script template (CANOPY_REPO + CANOPY_WORKSPACE_ROOT subbed in) +│ └── post-checkout.py # hook script (Python; fcntl-locked; never blocks git) ├── features/ -│ └── coordinator.py # FeatureLane + lifecycle (status, switch, diff, done, review_*) +│ └── coordinator.py # FeatureLane + FeatureCoordinator; branches map for per-repo branches ├── actions/ # Wave 2+: action layer — completion-driven recipes over primitives │ ├── errors.py # ActionError / BlockerError / FailedError / FixAction -│ ├── aliases.py # universal alias resolver (feature, repo#n, repo:branch, URL) -│ ├── active_feature.py # .canopy/state/active_feature.json reader/writer + last_touched LRU -│ ├── drift.py # detect_drift + assert_aligned (cached path) +│ ├── aliases.py # universal alias resolver (feature, repo#n, repo:branch, URL, worktree-N) +│ ├── augments.py # M2: per-workspace augment resolver (preflight_cmd, review_bots, …) +│ ├── bootstrap.py # M6: env-file copy + install_cmd + IDE workspace gen for worktrees +│ ├── bot_resolutions.py # M3: persistent log of bot comments addressed via `commit --address` +│ ├── bot_status.py # M3: per-feature bot-comment rollup +│ ├── commit.py # commit action (per-repo staging + conventional-commit support) +│ ├── conflicts.py # M12: cross-feature file/line overlap detection +│ ├── doctor.py # diagnostic checks + fix hints (21-code recovery primitive) +│ ├── draft_replies.py # M9: file-history-based addressed-comment classifier + reply templates +│ ├── drift.py # detect_drift + assert_aligned (cached path via heads.json) │ ├── evacuate.py # per-repo evacuate primitive (stash → wt-add → pop) -│ ├── feature_state.py # 8-state machine + next_actions (dashboard backend, worktree-aware) +│ ├── feature_state.py # 9-state machine + next_actions (dashboard backend, worktree-aware) +│ ├── historian.py # M4: cross-session feature memory at .canopy/memory/.md +│ ├── ide_workspace.py # M6: pure renderer for .code-workspace files +│ ├── last_visit.py # Plan 2: per-feature last-visit anchor (visits.json get/mark/reset) +│ ├── migrate_slots.py # Wave 3.0: one-shot pre-3.0 → 3.0 layout migration │ ├── preflight_state.py # .canopy/state/preflight.json read/write + freshness check -│ ├── reads.py # linear_get_issue / github_get_pr / github_get_branch / github_get_pr_comments -│ ├── realign.py # internal helper used by switch (deprecated from CLI/MCP in Wave 2.9) +│ ├── push.py # push action (per-repo upstream + force-with-lease) +│ ├── reads.py # alias-aware read primitives (linear, github PR/branch/comments) +│ ├── resume.py # Plan 2: feature_resume compound action + resume_summary (counts-only) │ ├── review_filter.py # temporal classifier (actionable vs likely_resolved threads) +│ ├── ship.py # M8: PR open/update orchestrator with cross-repo body links +│ ├── slot_details.py # Wave 3.0: rich slot shape (PR/CI/bots/linear per slot + canonical) +│ ├── slot_load.py # Wave 3.0: slot_load / slot_clear / slot_swap primitives +│ ├── slots.py # Wave 3.0: slots.json reader/writer + path resolution + LRU │ ├── stash.py # feature-tagged stash save/list/pop -│ ├── switch.py # canonical-slot focus primitive (active rotation + wind-down) -│ ├── switch_preflight.py # predictable-failure detection for switch -│ └── triage.py # cross-repo PR enumeration + canonical-slot enrichment +│ ├── switch.py # Wave 3.0: slot-model focus primitive (+ --to-slot / --evict-to) +│ ├── switch_preflight.py # predictable-failure detection for switch (cap, locks, leftover paths) +│ ├── thread_actions.py # Plan 2: GH thread resolve/reply via GraphQL + local resolution log +│ ├── thread_resolutions.py # Plan 2: thread_resolutions.json load/record/filter_since +│ └── triage.py # cross-repo PR enumeration + priority tiers (slot-enriched) ├── agent/ │ └── runner.py # canopy_run — directory-safe shell exec (no path management) -├── agent_setup/ # ships the using-canopy skill + sets up MCP per workspace -│ ├── __init__.py # install_skill / install_mcp / setup_agent / check_status -│ └── skill.md # the skill content (canonical source; copies to ~/.claude/skills/) +├── agent_setup/ # ships bundled skills + setup_agent installer +│ ├── __init__.py # install_skill / install_mcp / check_status +│ └── skills/ +│ ├── using-canopy/SKILL.md # default skill, always installed +│ └── augment-canopy/SKILL.md # opt-in via --skill augment-canopy ├── integrations/ │ ├── linear.py # Linear issue fetching (via mcp/client.py) │ ├── github.py # GitHub PR + review comments (MCP or gh CLI fallback) -│ └── precommit.py # detect + run pre-commit hooks (framework or git hooks) +│ └── precommit.py # detect + run pre-commit hooks └── mcp/ - ├── server.py # MCP server — 41 tools, stdio transport + ├── server.py # MCP server — 67 tools, stdio transport └── client.py # MCP client — stdio + HTTP+OAuth transports ``` ## Key boundaries -- **`git/repo.py` is the only module that calls `subprocess.run(["git", ...])`.** Everything else goes through it. The git layer stays replaceable and testable. +- **`git/repo.py` is the only module that calls `subprocess.run(["git", ...])`.** Everything else routes through it. The git layer stays replaceable and testable. - **`mcp/server.py` and `cli/main.py` are thin wrappers.** Business logic lives in `actions/`, `features/coordinator.py`, `git/multi.py`, and `workspace/`. Adding a CLI command + MCP tool is mostly registering an existing function in two places. -- **All external integrations go through `mcp/client.py` (or `gh` CLI fallback).** No direct API calls anywhere in the codebase. +- **All external integrations go through `mcp/client.py` (or `gh` CLI fallback).** No direct API calls anywhere in the codebase. When no `github` MCP server is configured, `integrations/github.py` falls back to `gh api` / `gh pr` for the same return shapes. - **Actions wrap primitives.** An `actions/*.py` function composes `git/`, `integrations/`, and `workspace/` calls into a verified workflow. Actions return structured `BlockerError` / dict; never `print()`. The CLI / MCP layers do their own rendering. - **The agent context contract.** Every action that takes multi-repo state takes semantic inputs (`feature`, `repo`, alias). Path resolution lives inside `workspace/` and `actions/aliases.py`. See [concepts.md](concepts.md#2-the-agent-context-contract). -- **State persistence is split.** Cached state (`.canopy/state/heads.json`, `.canopy/state/preflight.json`) is for fast paths (drift, state machine warm-up). Live git is the source of truth for write actions and `feature_state`. OAuth tokens cache in `~/.canopy/mcp-tokens/` (per-user, not per-workspace). +- **Per-repo branches map.** `FeatureLane.branches: dict[repo, branch]` overrides "branch == feature name" for legacy mismatched-naming features. Use `lane.branch_for(repo)` or `repos_for_feature(workspace, feature)` everywhere — never recompute as `[r for r in feature.repos]` with feature name as branch. +- **State persistence is split.** Cached state (`.canopy/state/heads.json`, `slots.json`, etc.) supports fast paths and state machine warm-up. Live git is the source of truth for write actions and `feature_state`. OAuth tokens cache in `~/.canopy/mcp-tokens/` (per-user, not per-workspace). +- **Feature-aware stash tagging.** `stash save --feature` writes `[canopy @ ] `. The parser tolerates git's `On : ` auto-prefix. Feature stashes survive branch switches and are listed per-feature by `stash_list_grouped`. ## Module dependency direction @@ -85,8 +109,18 @@ A typical session through canopy MCP. Every arrow is one MCP call. Note the agen triage() ─→ gh.list_open_prs per repo (MCP or gh CLI) group by feature lane classify priority via temporal filter + enrich with slot occupancy from slots.json ←─ features ordered by priority + feature_resume(feature) ─→ resolve_feature → canonical name + switch(feature) if not already canonical + refresh: historian + bot_status + review_filter + + pr_checks + linear + compose resume brief (since last_visit anchor) + mark_visited (single bump per resume call) + ←─ brief {state, since_ts, commits_delta, + open_threads, bot_threads, checks, intent_hints} + feature_state(feature) ─→ live git.current_branch per repo git.divergence per repo gh.get_review_comments + classify @@ -99,15 +133,15 @@ A typical session through canopy MCP. Every arrow is one MCP call. Note the agen switch(feature) ─→ switch_preflight (no state change): branch existence, leftover paths, git lock, cap-reached prediction - per repo (Wave 2.9 canonical-slot): - if Y warm → worktree_remove(Y) + per repo (slot model): + if Y warm → remove worktree if X exists → evacuate_repo(X): git.stash (if dirty) git.checkout(target Y) - git.worktree_add(X) + git.worktree_add(X slot) git.stash_pop in worktree else → git.stash + git.checkout - active_feature.write (canonical + last_touched) + slots.write (canonical + last_touched) ←─ {feature, mode, per_repo_paths, previously_canonical, eviction?, branches_created?} @@ -117,7 +151,7 @@ A typical session through canopy MCP. Every arrow is one MCP call. Note the agen ── agent edits files via Read/Edit/Write ── ── or runs path-safe shell via run(repo, command) ── - preflight(feature) ─→ precommit hooks per repo (sequential v1) + preflight(feature) ─→ precommit hooks per repo (sequential) preflight_state.record_result() ←─ per-repo {passed, output} @@ -129,7 +163,7 @@ Path resolution lives entirely in `actions/aliases.py` (`resolve_feature`, `repo ### feature_state composition -`feature_state` is a thin shell over many primitives — same pattern other actions follow, but the most-composed example. Decision tree: +`feature_state` is a thin shell over many primitives — same pattern other actions follow, but the most-composed example. Decision tree across the 9 states: ``` feature_state(f) @@ -149,18 +183,23 @@ Path resolution lives entirely in `actions/aliases.py` (`resolve_feature`, `repo │ ├─ gh.find_pull_request → review_decision, draft, … │ └─ gh.get_review_comments + classify_threads → actionable, likely_resolved │ + ├─ bot_status(f) unresolved bot comments → awaiting_bot_resolution? + │ ├─ preflight_state.is_fresh(repos) compares recorded sha vs current HEAD │ └─ _decide_state(facts, summary, preflight_fresh, preflight_entry): ├─ dirty + fresh-passed-preflight → ready_to_commit ├─ dirty → in_progress ├─ clean + ahead > 0 → ready_to_push - ├─ clean + actionable | CHANGES_REQUESTED → needs_work + ├─ clean + CHANGES_REQUESTED → needs_work + ├─ clean + bot threads unresolved → awaiting_bot_resolution ├─ clean + all PRs APPROVED → approved ├─ clean + no PRs → no_prs └─ clean + PRs open + nothing actionable → awaiting_review ``` +The ninth state (`awaiting_bot_resolution`) is reached when open bot-authored review threads exist but no human CHANGES_REQUESTED is present — bot threads alone route here, not to `needs_work`. See [concepts.md](concepts.md#3-the-feature-state-machine) for the full state table. + ### Drift detection: two pathways Two paths exist because they answer different questions and have different costs. @@ -226,19 +265,57 @@ Every action follows a fixed three-phase structure. Errors flow back as `Blocker CLI renders the error via `cli/render.py` (multi-line with `fix_actions` and `safe`/`needs review` tags). MCP returns `BlockerError.to_dict()` directly. Same shape, two consumers — the agent and the human read the same JSON, just rendered differently. +## Slot model internals + +The slot model is the runtime guarantee that at most one canonical checkout and `N` warm worktrees exist at any time. `switch` is the only public entry point; `slots.py`, `slot_load.py`, and `switch_preflight.py` are its internal implementation. + +**`slots.json` schema:** + +``` +{ + "canonical": {feature, activated_at, per_repo_paths} | null, + "previous_canonical": str | null, + "slots": { + "worktree-1": {feature, occupied_at} | null, + "worktree-2": {feature, occupied_at} | null + }, + "last_touched": {feature: ISO, ...}, + "in_flight": {feature_being_promoted, previously_canonical, ...} | null +} +``` + +**Transaction safety.** `in_flight` is set atomically before a multi-repo switch starts and cleared on success. If the process is interrupted mid-flight, subsequent `switch()` calls detect a non-null `in_flight` and raise `BlockerError(code='slot_state_inconsistent')`. Recovery is via `canopy doctor`, which inspects actual worktree paths and reconstructs a consistent state. + +**LRU eviction policy.** When the slot cap is reached and the caller did not pass `--evict-to`, canopy raises `BlockerError(code='worktree_cap_reached')` with the LRU candidate in `details`. Canopy never silently evicts — the human or the agent must explicitly choose. The LRU ordering is computed from `last_touched` timestamps; the slot with the oldest entry is the eviction candidate. + +**Slot identity is stable; feature occupancy is transient.** Slot directories (`worktree-1/`, `worktree-2/`) persist across feature swaps. A slot keeps its numbered id; features move in and out. This means pre-built worktrees re-use their node_modules, venvs, and build artifacts when a feature rotates back into the same slot. + +## Plan 2 — resume and threads + +`feature_resume` (via `actions/resume.py`) is the session-start primitive for returning to a feature. It orchestrates: alias resolution, `switch` if not already canonical, data refresh (historian + bot_status + review_filter + pr_checks + linear), and brief section composition. The result is a structured `{state, since_ts, commits_delta, open_threads, bot_threads, checks, intent_hints}` snapshot scoped to activity since the last visit. + +**Single-bump invariant.** Exactly one `mark_visited` call happens per `feature_resume` invocation — either inside `switch` (if a slot transition occurred) or at the end of `resume` itself. The `visits.json` anchor never moves twice for the same resume. + +**Thread round-trip.** `actions/thread_actions.py` and `actions/thread_resolutions.py` close the GitHub review-thread loop: canopy can resolve threads and reply via GraphQL, with attribution logged locally to `thread_resolutions.json`. `filter_since` scopes the log to the current visit window, so the resume brief can report "N threads resolved by canopy since last visit" without re-reading all history. + ## State files What state lives where, who writes it, who reads it: | Path | Writer | Readers | Purpose | |---|---|---|---| -| `canopy.toml` | `canopy init` | all canopy commands | workspace definition (which repos) | -| `.canopy/features.json` | `feature_create` / `link_linear` / `done` | most actions | feature lanes + Linear links + branches map | -| `.canopy/state/heads.json` | post-checkout hook | `drift`, `hook_status` | drift fast path | +| `canopy.toml` | `canopy init` | all canopy commands | workspace definition (repos, slots cap, augments) | +| `.canopy/features.json` | `feature_create` / `link_linear` / `done` | most actions | feature lanes + Linear links + per-repo branches map | +| `.canopy/state/heads.json` | post-checkout hook | `drift`, `doctor` | drift fast path | | `.canopy/state/heads.json.lock` | post-checkout hook | (fcntl flock) | concurrent-fire safety | -| `.canopy/state/preflight.json` | `review_prep` / `cmd_preflight --feature` | `feature_state` | IN_PROGRESS vs READY_TO_COMMIT | -| `.mcp.json` | `canopy init` / `setup-agent` | MCP-aware clients (Claude Code, Cursor) | server registry | -| `~/.canopy/mcp-tokens/.{client,tokens}.json` | `mcp/client.py` OAuth provider | `mcp/client.py` on subsequent calls | OAuth token cache | -| `~/.claude/skills/using-canopy/SKILL.md` | `canopy init` / `setup-agent` | Claude Code (auto-loaded) | agent integration skill | - -All workspace state lives under `.canopy/`; agent / per-user state lives under `~/`. The split lets you share workspace state via git (commit `.canopy/features.json` if you want; ignore `.canopy/state/`), while OAuth tokens and skill never leave the user's machine. +| `.canopy/state/preflight.json` | `preflight` / `review_prep` | `feature_state` | in_progress vs ready_to_commit | +| `.canopy/state/slots.json` | `switch` / `slot_load` / `slot_clear` / `slot_swap` | `triage`, `slots`, `doctor` | canonical + slot occupancy + last_touched LRU + in_flight marker | +| `.canopy/state/visits.json` | `last_visit.mark_visited` | `resume`, `draft_replies` | per-feature `{last_visit, previous_visit}` anchor | +| `.canopy/state/thread_resolutions.json` | `thread_resolutions.record` | `resume`, `draft_replies` | GH threads canopy resolved: `{thread_id: {resolved_by_canopy_at, feature, …}}` | +| `.canopy/state/bot_resolutions.json` | `bot_resolutions.record_resolution` | `bot_status`, `feature_state` | per-comment resolution log for bot-authored comments | +| `.canopy/memory/.md` | `historian` | `feature_resume` | cross-session feature memory (plain markdown) | +| `.mcp.json` | `canopy init` / `setup-agent` | MCP-aware clients | server registry | +| `~/.canopy/mcp-tokens/.{client,tokens}.json` | `mcp/client.py` OAuth provider | `mcp/client.py` | OAuth token cache (per-user) | +| `~/.claude/skills//SKILL.md` | `canopy init` / `setup-agent` | Claude Code (auto-loaded) | agent integration skills (using-canopy, augment-canopy) | + +All workspace state lives under `.canopy/`; agent and per-user state lives under `~/`. The split lets you share workspace state via git (commit `.canopy/features.json` if you want; ignore `.canopy/state/`), while OAuth tokens and skills never leave the user's machine. diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md index d722482..30d5a0e 100644 --- a/docs/architecture/providers.md +++ b/docs/architecture/providers.md @@ -1,7 +1,7 @@ # Provider Injection — Issue Providers -> **Status:** v1 design reference. Code lands in M5 (see [`docs/plans/INDEX.md`](../plans/INDEX.md)). -> **Plan:** [`docs/plans/archive/providers-arch.md`](../plans/archive/providers-arch.md) (the historical spec; this doc is the live artifact). +> **Status:** Implemented in M5 (Linear + GitHub Issues providers; see CHANGELOG.md). +> **Historical design:** The M0 design doc is archived. This doc is the live artifact. > **Scope:** issue providers only in v1. Other use cases named in §8 but not specified. Canopy's read tools today are tightly coupled to specific external services — `linear_get_issue`, `linear_my_issues`, etc. all call directly into [`src/canopy/integrations/linear.py`](../../src/canopy/integrations/linear.py). That works while we use exactly those services, but the moment we want GitHub Issues (or JIRA, or anything else) instead of Linear, every action that touches issue context has to branch on which integration to call. That branching ages badly and bleeds Linear-shaped assumptions into the contract. @@ -357,7 +357,7 @@ class GitHubIssuesProvider: The following could adopt the same provider-injection shape if implementation drops in seamlessly. **None are scheduled here.** Effort cap on retrofitting any of them: < 5% of the M5 implementation effort. If retrofitting requires non-trivial refactor, leave the existing handling alone. -- **Bot-author detection** — currently a hardcoded `author_type == "Bot"` substring check. Could become a `BotAuthorDetector` provider with per-team rules (CodeRabbit-style only, JIRA-bot, custom regex, etc.). M3 (bot-tracking) introduces `review_bots` augment as the v1 mechanism; provider-ifying is a future option. +- **Bot-author detection** — M3 (bot-tracking, shipped) introduced `review_bots` augment in canopy.toml for per-team configuration. `author_type == "Bot"` checks are already provider-aware via GitHub Issues. Future: could extend to a full `BotAuthorDetector` provider with custom rules (regex, allowlist, etc.), but `review_bots` meets current needs. - **CI providers** (GitHub Actions, CircleCI, Buildkite) — deferred to the [ci-status plan](../plans/ci-status.md). Same shape would apply: a `CIProvider` protocol with `get_check_runs(pr)` etc. Don't build until that plan exists. - **Code-review platforms** (GitHub, GitLab, Bitbucket) — `gh` fallback works today via [`integrations/github.py`](../../src/canopy/integrations/github.py). A `ReviewPlatformProvider` could unify, but the existing gh-or-MCP pattern handles current needs. - **IDE workspace formats** (VS Code `.code-workspace`, JetBrains `.idea/`, Cursor) — [worktree-bootstrap plan](../plans/worktree-bootstrap.md) defers this. Could become an `IDEWorkspaceWriter` provider. diff --git a/docs/test-plan.md b/docs/archive/test-plan.md similarity index 100% rename from docs/test-plan.md rename to docs/archive/test-plan.md diff --git a/docs/test-findings.md b/docs/test-findings.md deleted file mode 100644 index 20a2cb5..0000000 --- a/docs/test-findings.md +++ /dev/null @@ -1,277 +0,0 @@ -# Test Run — 2026-05-02 (canopy 0.5.0 against canopy-test) - -First end-to-end pass against [`docs/test-plan.md`](test-plan.md). Workspace: `~/projects/canopy-test` (test-api + test-ui, GitHub-backed, Linear MCP wired, 8 features in `features.json`). - -**Environment:** canopy 0.5.0 (editable install in `~/projects/canopy/.venv/`), Python 3.14, gh authenticated, Linear MCP token cached at `~/.canopy/mcp-tokens/linear.tokens.json` (last refreshed 2026-04-27). - -**Status legend:** ✅ pass · ⚠️ pass with finding · ❌ fail · ⏭️ skipped/blocked · 🟨 in-progress - ---- - -## Section results - -| Section | Result | Notes | -|---|---|---| -| §0 Preconditions (5) | ✅ 5/5 | Found pre-existing `__version__` drift (`0.1.0` though M0–M5 shipped) — fixed pre-test in [PR #16](https://github.com/ashmitb95/canopy/pull/16). | -| §1 Doctor (5) | ⚠️ 5/5 with 2 findings | Doctor surfaces 8 real workspace issues (all `auto_fixable: true`); 2 minor CLI bugs noted (F-1, F-2). | -| §2a Linear provider | ⚠️ partial | CLI `canopy issue SIN-5` works but exposes raw Linear state ("Todo") instead of canonical ("todo"). MCP `issue_get` correct. F-5 (no plural CLI). F-4 (headless OAuth). | -| §2b GitHub Issues provider | ❌ CLI broken | MCP `issue_get` works perfectly with provider-swapped config. CLI `canopy issue 5` / `#5` / `owner/repo#5` all fail with `unknown_alias` — alias resolver is Linear-only. **F-7 is the headline bug.** | -| §3 Augments (6) | ✅ 6/6 with F-9 | Workspace `preflight_cmd`, per-repo override (per-repo wins), failing-augment graceful, augment-canopy skill installs. F-9: `--check` only reports default skill. | -| §4 Bot tracking | ⏭️ blocked | Needs CodeRabbit set up on `ashmitb95/canopy-test-api` PRs — external setup. Throwaway issues #5/#6 used + closed. | -| §5 Historian (11) | ✅ 9/11; 2 blocked | switch ↔ memory round-trip works; decision dedup works; pause + render work; `.gitignore` auto-written; compact noop + drop both work. §5.7 (commit --address auto-mirror) + §5.8 (review_comments auto-mirror) blocked behind §4. | - ---- - -## Findings - -### F-0: `__version__` drift (FIXED pre-test) - -`src/canopy/__init__.py` was stuck at `"0.1.0"` despite M0–M5 shipping. The doctor's `cli_stale` / `mcp_stale` checks compare against this constant — they were silently a no-op for ~6 months of work. - -- **Fix:** [PR #16](https://github.com/ashmitb95/canopy/pull/16) — bumped to `0.5.0`, added `CHANGELOG.md`, added a CLAUDE.md guard. -- **Lesson:** version bump should happen in the same PR as the milestone it represents. Going forward, the CLAUDE.md note covers it. - -### F-1: "no canopy.toml found" error is unhelpful - -Running any workspace-scoped command (e.g. `canopy setup-agent --check`, `canopy state`, `canopy preflight`) from outside a workspace prints `Error: No canopy.toml found in current directory or any parent.` That's technically true, but doesn't tell a new user *what* a workspace is or *why* canopy can't proceed. - -- **Repro:** `cd / && canopy setup-agent --check` → terse "No canopy.toml found" error. -- **Decision (per user):** **don't gracefully degrade** (e.g. partial setup-agent reports without MCP). Fail loud and clear with an error message that explains canopy's mental model: - > Canopy needs to be run from a **canopy workspace** — a non-git directory that contains `canopy.toml` plus the participating repos as subdirectories. Run `canopy init` in such a directory to create one. -- **Severity:** low individually; medium for new-user friction (this is the first error a fresh install hits). -- **Fix:** centralize the "no canopy.toml" error rendering in one place (`cli/render.py` or a small helper in `cli/main.py`) so every command that depends on a workspace prints the same, helpful message — and exits non-zero (see F-2). - -### F-2: error path returns exit code 0 ~~(INVALID — measurement error)~~ - -**Retracted.** Re-verified after the test run: `canopy setup-agent --check` exits 1, and `canopy issue --json` (BlockerError JSON output) also exits 1. My original `echo $?` was capturing a later step in the bash chain, not the canopy command. Exit codes are already correct. - -Lesson for future test runs: capture exit codes inline (`canopy ... ; EC=$?`), not after intervening commands. - -### F-3: stale `canopy-mcp` processes accumulate ~~(partially misread)~~ - -Original observation was 8+ canopy-mcp processes in `ps`. **On closer inspection most are not orphans** — their PPIDs are still alive (live VSCode / Cursor / Claude Desktop windows, each with its own canopy-mcp). One MCP per editor window is normal. The genuine bug is the *true* orphan case: an editor crashes hard, its MCP child gets reparented to PID 1, and nothing reaps it. - -- **Fix shipped:** `mcp_orphans` doctor check (severity: info, auto-fixable). Detects PPID=1 canopy-mcp processes, reaps with SIGTERM then SIGKILL after a 2s grace. The check explicitly skips multi-editor-window cases (parent alive → not an orphan, doesn't fire). -- **Lesson for future test runs:** "lots of processes" ≠ "orphans". Check PPID before claiming a bug. - -### F-4: Linear MCP from a headless Python invocation hangs - -Running `python -c "from canopy.mcp.server import issue_list_my_issues; issue_list_my_issues()"` from a script never returned. Likely the OAuth flow attempts a browser open + waits for the redirect, with no terminal. - -- **Severity:** low for users (the MCP client is meant to be invoked through Claude Code / canopy CLI, both of which have stdio); medium for testing (we can't headlessly assert the MCP path works against live Linear). -- **Workaround:** for tests, exercise the `LinearProvider` class directly with mocked `call_tool` (already done in the unit suite). - -### F-6: CLI `canopy issue` exposes raw provider state, MCP returns canonical - -`linear_get_issue` in `actions/reads.py` (the legacy wrapper backing `cmd_issue`) intentionally exposes `raw.state` for "backward compatibility." Concretely: - -| Surface | `state` for SIN-5 | -|---|---| -| `canopy issue SIN-5 --json` | `"Todo"` (raw Linear) | -| `mcp__canopy__issue_get(alias="SIN-5")` | `"todo"` (canonical M5 mapping) | - -Same workspace, same issue, two different responses depending on which surface you call. Also: CLI shape is `{alias, issue_id, title, state, url, description, raw}`; MCP shape is `{id, identifier, title, description, state, url, assignee, labels, priority, raw}`. Different fields entirely. - -- **Severity:** medium — back-compat reasoning is dated (no current callers actually depend on raw state); the inconsistency is a footgun for anyone scripting against the CLI vs the agent talking via MCP. -- **Fix:** retire the legacy shape; have `cmd_issue` render `Issue.to_dict()` directly (matching the MCP tool). Update `docs/commands.md` accordingly. - -### F-7: alias resolver is Linear-only — `canopy issue` broken for GitHub Issues provider - -With `[issue_provider] name = "github_issues"` set in canopy.toml and a real GH issue (#5) on the configured repo, **none of these CLI invocations work:** - -``` -canopy issue 5 → BlockerError unknown_alias -canopy issue '#5' → BlockerError unknown_alias -canopy issue 'ashmitb95/canopy-test-api#5' → BlockerError unknown_alias -``` - -The MCP equivalent (`mcp__canopy__issue_get(alias="5")`) returns the issue correctly — the `GitHubIssuesProvider` itself works. The bug is in `actions/aliases.py:resolve_linear_id`, which is hardcoded to look for Linear-shaped IDs (`SIN-N`) or feature-lane names. It doesn't know that for `github_issues`, a bare number is the canonical id form. - -This is a **major M5 integration gap**: M5 added the Provider Protocol + registry, but the alias resolution layer above it is still Linear-shaped. The CLI surface for any non-Linear provider is dead. - -- **Severity:** high — `canopy issue` is the primary user surface for the issue provider abstraction; it doesn't work for the second backend M5 was supposed to ship. -- **Workaround:** call MCP tool directly (works in agent contexts; not for CLI users). -- **Fix:** rewrite `resolve_linear_id` (rename to `resolve_issue_id`) to consult the active provider for what shapes it accepts. GitHub Issues: bare number, `#N`, `owner/repo#N`, full URL. Linear: `-`, feature names. Provider can expose a `parse_alias(s) -> str | None` method (returns canonical id if accepted, else None); resolver tries provider first, falls back to feature-name lookup. -- **Adjacent:** Phil's branch has `actions/issue_resolver.py` with auto-detect logic (`SIN-N` → Linear, `owner/repo#N` → GitHub, etc.) — could be ported when his PR rebases onto M5. - -### F-2 generalized ~~(INVALID — same measurement error as F-2)~~ - -Retracted along with F-2. Verified `canopy issue 999 --json` exits 1 on BlockerError output. - -### F-10: `gh search issues` query construction is malformed - -`GitHubIssuesProvider.list_my_issues` builds a query string with qualifiers (`repo:foo`, `is:open`, `assignee:@me`, `label:"bug"`) and passes it positionally to `gh search issues`. But `gh search issues` treats positional args as search *text*, so the whole string gets quoted and the GitHub API responds with `Invalid search query`. - -The unit tests for `list_my_issues` mock `_gh_json` and assert on the constructed args — so they happily passed even though the args were nonsense to the real CLI. - -- **Severity:** medium — the entire `canopy issues` / `mcp__canopy__issue_list_my_issues` flow was broken for the GitHub Issues backend on real `gh` invocations. -- **Fix (this PR):** switch to `gh issue list --repo ... --state open --assignee @me --label ...` (the right verb form). Phil's branch already had this pattern. -- **Lesson:** mocking at `_gh_json` proves the call wiring but not the CLI grammar. A small live-call smoke test (skipped if `gh` isn't authenticated) would have caught this. - -### F-9: `setup-agent --check` only reports the default skill - -`canopy setup-agent --check --json` returns `{skill: {...}, mcp: {...}}` — but `skill` is hardcoded to `using-canopy`. After installing `augment-canopy` via `--skill augment-canopy`, the `--check` output still only reports `using-canopy`'s state. - -- **Severity:** low — install side works correctly; `--check` is just incomplete reporting. -- **Fix:** `check_status` should iterate `available_skills()` and return `skills: [...]` parallel to the install-side report. - -### F-5: no `canopy issues` (plural) CLI command - -The test plan's §2.1 assumed `canopy issues` lists the user's open issues. It doesn't exist — only `canopy issue ` (singular, fetches one). The MCP tool `issue_list_my_issues` exists, but there's no CLI mirror. - -Phil's `extension-rewrite` branch added `canopy issues --json` for exactly this reason (his extension calls it via subprocess for the issue picker). - -- **Severity:** medium — gap between MCP + CLI surface; the test plan + the agent skill both implicitly assume the CLI form exists. -- **Fix candidates:** (a) add `cmd_issues` in `cli/main.py` calling `issue_list_my_issues`; (b) wait for Phil's PR which already has it. - -### Real workspace issues caught by doctor (validation pass for M1) - -Doctor reported 8 issues in `~/projects/canopy-test`, all `auto_fixable: true`: - -| Code | Severity | What | -|---|---|---| -| `heads_stale` × 2 | warn | `heads.json` out of sync for test-api + test-ui (post-checkout hook didn't fire after manual git ops) | -| `worktree_missing` × 4 | error | `features.json` references worktree paths that don't exist on disk (deleted manually): `demo-parallel/test-{api,ui}`, `sin-5-search/test-ui`, `sin-7-empty-state/test-ui` | -| `preflight_stale` | info | preflight result for `doc-1001-paired` test-api is stale | -| `vsix_duplicates` | info | 4 canopy vsix install dirs found in `~/.vscode/extensions/` | - -This is the recovery scenario M1 was built for. Detection works end-to-end against a real workspace. **Did not** run `--fix` yet (4 of these would recreate worktrees on disk; defer until intentional cleanup). - ---- - -## Action items from this run - -- [x] **F-7 fix (P0)** — provider-aware alias resolver via `IssueProvider.parse_alias()`. CLI now works for any provider with bare/hash/owner-repo/URL alias forms. Shipped in fix/issue-alias-resolver PR. -- [x] **F-10 fix (incidental)** — `GitHubIssuesProvider.list_my_issues` was using malformed `gh search issues` query (qualifiers got quoted as text → `Invalid search query`). Switched to `gh issue list --repo ... --assignee @me`. Found while smoke-testing F-5; unit tests had mocked the boundary so they didn't catch it. Shipped in same PR. -- [x] **F-5 fix (P2)** — added `cmd_issues` for parity with MCP `issue_list_my_issues`. Shipped in same PR. -- [ ] **F-6 fix (P1)** — make `cmd_issue` render `Issue.to_dict()` directly so CLI + MCP agree. Drop the legacy raw-state shape. Update docs/commands.md. ~30 min. -- [ ] **F-1 fix (P1)** — improve the "no canopy.toml" error message to explain canopy's mental model (workspace = non-git directory holding repos + canopy.toml). ~10 min. -- [ ] **F-5 fix (P2)** — add `cmd_issues` for parity with the MCP `issue_list_my_issues`, OR defer to Phil's PR (which has it). -- [x] **F-3** — doctor `mcp_orphans` check + reaper. Detects PPID=1 canopy-mcp processes; SIGTERM with 2s grace then SIGKILL. Auto-fixable (info severity). Original observation was partially misread — see updated F-3 above. -- [x] **F-4** — `docs/mcp.md` "Heads up — OAuth needs a TTY" callout under the HTTP+OAuth section. Documents the headless-hang failure mode + workaround. -- [ ] **`canopy doctor --fix`** — on a follow-up session, intentionally clean canopy-test's real drift (heads.json + missing worktrees + vsix duplicates) to validate the repair side end-to-end. - -## Test-data cleanup - -- ✅ Throwaway issues #5 + #6 on `ashmitb95/canopy-test-api` closed at end of run. -- ✅ canopy-test workspace canopy.toml restored to original; README.md edits in test-api/test-ui reset. - ---- - -# Test Run 2 — 2026-05-03 (canopy 0.5.0, post-Run-1 fixes) - -Second pass after [PR #16](https://github.com/ashmitb95/canopy/pull/16)/[#17](https://github.com/ashmitb95/canopy/pull/17)/[#18](https://github.com/ashmitb95/canopy/pull/18)/[#19](https://github.com/ashmitb95/canopy/pull/19)/[#20](https://github.com/ashmitb95/canopy/pull/20) landed. Extended test-plan.md with §6–§12 covering pre-M0 and cross-cutting flows that Run 1 didn't touch (switch, state machine, commit/push semantics, drift detection, universal aliases, stash family, doctor `--fix`). - -## Section results (Run 2) - -| Section | Result | Notes | -|---|---|---| -| §6 switch (6) | ✅ 6/6 | Linear ID + feature alias both resolve; `--release-current` mode works; switch back from warm is fast (~110 ms); fresh feature creates branches. | -| §7 state machine (9) | ✅ 5/9, ⏭️ 4 | drifted, in_progress, ready_to_commit, ready_to_push, needs_work all reachable. awaiting_review/approved/awaiting_bot_resolution/no_prs blocked on real PR setup. | -| §8 commit/push (8) | ✅ 4/8, ⏭️ 4 | empty_message + wrong_branch blockers fire; per-repo subset works; bad path correctly fails (intentional). Push tests skipped to avoid creating remote branches. | -| §9 drift (4) | ✅ 4/4 | Live + cached drift agree; switch recovers; doctor surfaces heads_stale during drift. | -| §10 universal aliases (6) | ✅ 6/6 | Feature name, Linear ID, `#`, PR URL, bare GH issue (with provider swap), unknown alias all behave as designed. | -| §11 stash family (5) | ✅ 4/5 | All feature-tagged operations work via `--feature` flag; one initial confusion about syntax (see retraction). | -| §12 doctor --fix (6) | ✅ 5/6 | Real workspace went from 6 errors / 2 warnings / 1 info → 0 / 0 / 1 (vsix gated). 12.4 (`--clean-vsix`) skipped to avoid touching live extension installs. | - -## New findings - -### F-11: `canopy preflight` (no feature arg) doesn't persist a record (FIXED — this PR) - -When run from the workspace root (vs. from inside a worktree), `canopy preflight` skipped the `record_result` call because `ctx.feature` was None — even though `active_feature.json` had a canonical feature whose repos matched. Result: `feature_state` couldn't transition `in_progress → ready_to_commit` for the canonical feature without an explicit `--feature` arg, breaking the most natural workflow. - -**Fix:** in `cmd_preflight`, fall back to `active_feature.read_active(ws).feature` when `ctx.feature` is None. Verified: `canopy preflight` from workspace root now writes the record; `canopy state ` immediately reports `ready_to_commit`. - -### F-12: drifted state surfaces deprecated `realign` for main-tree (FIXED — this PR) - -Per CLAUDE.md, `realign` was deprecated from CLI/MCP in Wave 2.9 — replaced by the canonical-slot `switch` primitive which handles main-tree and worktree-backed features uniformly. But `_drifted_result` in `feature_state.py` still surfaced `realign` as the primary CTA for main-tree features (the worktree branch already used `switch`). - -**Fix:** main-tree drifted now also surfaces `switch` (with the same per-repo preview text). `tests/test_feature_state.py:test_drift_state_supersedes_everything` assertion updated. - -### F-13: misread — feature stash variants exist via `--feature` flag - -Initial run of §11 used `canopy stash save-feature` / `list-grouped` / `pop-feature` and got argparse errors. **Retracted on second look** — the feature variants are accessed via `--feature ` on the regular subcommands (`canopy stash save --feature ...` routes internally to `cmd_stash_save_feature`). Functionality works; my test plan had the wrong syntax. Plan §11 updated. - -### Plan-expectation corrections (not bugs) - -- **§8.5** — bad `--paths` filter produces `failed` (git add errors fatally on missing pathspec), not `nothing`. Original plan expectation was wrong; updated to reflect intentional fail-loud behavior. -- **§11 JSON shape** — actual response shape is `{by_feature: {...}, untagged: [...]}`, not `{: [...]}`. Plan updated. -- **§12.6 syntax** — flag is `--fix-category `, not `--fix=`. Plan updated. -- **§6 disjoint-repo evacuation** — when a feature spans only some repos, switching to a different feature with disjoint repos leaves the unaffected repos on whatever branch they were on, with no warm-tree evacuation (`evacuation: None`). Sensible behavior, not a bug; worth noting for users who expect more. - -## Headline - -Run 2 found **2 real bugs (F-11, F-12), both fixed in the same PR**. The MCP/agent surface continues to be healthy; the bugs were both in CLI ergonomics. Combined with Run 1's findings, the cumulative tally is: - -- 13 findings filed (F-0 through F-12, plus F-13 retracted) -- 11 fixed -- 2 retracted (F-2, F-13 — measurement / syntax errors on the test side) - -Test-plan.md now covers §0–§13 (excluding the deferred §4 bot-tracking and §6 of the old composite scenario). §6–§12 are first-time tests for pre-M0 + cross-cutting flows. The plan is repeat-runnable; suggested cadence is once per release. -- 🟡 Memory file `~/projects/canopy-test/.canopy/memory/sin-7-empty-state.{md,jsonl}` left in place — it's gitignored per M4's auto-write, harmless. Cleanup instruction: `rm -rf ~/projects/canopy-test/.canopy/memory/` if a fully fresh state is wanted. - -## Headline takeaway - -**The MCP/agent-facing surface is healthy across M0–M5; the CLI/human-facing surface has 2 important bugs (F-6, F-7) and 4 small ones (F-1, F-2, F-5, F-9).** None are catastrophic — agents using the MCP tools get correct, canonical responses. But human users hitting the CLI directly get raw provider strings, broken alias resolution for non-Linear providers, and exit codes that lie about success. The asymmetry was invisible in the unit suite because every tested code path went through the action layer's MCP shape — the CLI rendering bugs only surface when you actually type the commands. - ---- - ---- - -# Test Run 3 — 2026-05-03 (canopy 0.5.0 on main, post all fix PRs) - -Clean re-run of the full plan after [PR #21](https://github.com/ashmitb95/canopy/pull/21) landed. Goal: confirm every Run-1+Run-2 fix holds on main and exercise the previously-deferred §8.6–§8.8 push tests now that "remote branches OK" is acceptable. - -## Section results (Run 3) - -| Section | Result | Notes | -|---|---|---| -| §0 | ✅ 5/5 | canopy 0.5.0; MCP wired; gh + Linear authenticated; workspace parses to `ready_to_commit`. | -| §1 | ✅ 5/5 | doctor surfaces 0 errors / 0 warnings / 1 info (only `vsix_duplicates`, gated). F-9 fix confirmed: `--check` reports both skills. | -| §2 | ✅ all paths | F-6: `canopy issue SIN-5` returns canonical state `todo`. F-7: `canopy issue 5` (with github_issues) returns issue. F-5: `canopy issues` plural exists. | -| §3 | ✅ baseline | preflight runs hooks correctly; augments not exercised (already verified Run 1). | -| §5 | ✅ | historian show + compact noop both work. | -| §6 | ✅ | switch + memory present; Linear ID + feature alias resolution. | -| §7 / §9 | ✅ + **F-12 verified** | drifted → primary CTA `switch` (was `realign` pre-fix). | -| **§8.6 / §8.7 / §8.8** | ✅ **first-time pass** | Push lifecycle end-to-end against real remote branches (see below). | -| §10 | ✅ | feature-name + Linear-ID aliases both resolve. | -| §11 | ✅ | stash save/list/pop with `--feature` round-trip works. | -| §12 | ✅ + **F-3 real-world hit** | doctor `--fix` reaped a real orphan canopy-mcp (PID 31992 from `~/.canopy-vscode/venv/`); workspace ended at 0 errors / 0 warnings / 1 info. | - -## §8.6 / §8.7 / §8.8 (push lifecycle) — first execution - -Created an ephemeral feature `run3-push-test`, made a commit, then walked the lifecycle: - -- **§8.6** `canopy push` (no upstream) → `BlockerError(code='no_upstream')`, `fix_actions[0]` carries `set_upstream: True`. Exit 1. ✓ -- **§8.7** `canopy push --set-upstream` → both repos `ok`, remote branches created (verified via `gh api repos/.../branches/run3-push-test`). ✓ -- **§8.8** repeat `canopy push` → both repos `up_to_date`. ✓ - -Cleanup: deleted local + remote branches + worktree paths + features.json entry. Workspace returned to baseline. - -## F-3 mcp_orphans — first real-world hit - -Run 2 verified F-3 detection logic with mocked `ps` output. Run 3 caught it firing on a *real* orphan: PID 31992, PPID 1, `/Users/ashmit/.canopy-vscode/venv/bin/canopy-mcp` — the venv-bin reveals it was spawned by a VSCode extension session whose parent process exited without cleanly closing stdin. `canopy doctor --fix` reaped it (`SIGTERM`); `ps -p 31992` afterward returned empty. End-to-end repair verified. - -## Bonus finding (not a bug — UX observation worth noting) - -**`switch` to a feature whose warm worktree is dirty correctly blocks** with `BlockerError(code='warm_worktree_dirty_on_promote')`. The fix-actions surface both `commit` and `stash_save_feature` as recovery paths. Encountered this naturally during cleanup when the warm worktree for `doc-1001-paired/test-api` had a stray edit from earlier in the session. Block + recovery path both worked as designed. - -## Cumulative tally after Run 3 - -- **14 findings filed** total (F-0 through F-13) -- **12 fixed** across PRs #16, #18, #19, #20, #21 -- **2 retracted** (F-2 + F-13 — both measurement/syntax errors on the test side) -- **0 new bugs in Run 3** -- Test suite holding at 651 passing -- canopy-test workspace fully clean post-fix (errors:0, warnings:0, info:1 [vsix gated]) - -The plan is now repeat-runnable end-to-end. Suggested cadence: once per release tag. - ---- - -## How to interpret this doc - -- Findings labeled `F-N` are bugs / gaps surfaced by a test run. Each has severity + suggested fix. -- "Action items" sections are the to-do list for the next session. -- Pass-with-finding (⚠️) means the surface works but reveals a quality issue worth noting. -- This file is the *test run record* (one section per pass — Run 1, Run 2, Run 3 …); the static plan to re-run is at [test-plan.md](test-plan.md). diff --git a/docs/workspace.md b/docs/workspace.md index 332f832..90b90f7 100644 --- a/docs/workspace.md +++ b/docs/workspace.md @@ -4,45 +4,53 @@ ``` my-product/ -├── canopy.toml ← workspace definition (which repos, roles, languages) +├── canopy.toml ← workspace definition (repos, slots, augments, issue provider) ├── .mcp.json ← MCP server registry (canopy + linear/github if configured) -├── backend/ ← main working tree (canonical slot) -├── frontend/ ← main working tree (canonical slot) +├── backend/ ← canonical slot (whichever feature is currently in focus) +├── frontend/ ← canonical slot └── .canopy/ ├── features.json ← feature lanes, linear links, per-repo branches map ├── mcps.json ← OPTIONAL: external MCP servers (alternative to .mcp.json) + ├── memory/ + │ └── .md ← historian per-feature persistent memory ├── state/ - │ ├── heads.json ← post-checkout hook records HEAD per repo (drift backend) + │ ├── heads.json ← post-checkout hook records HEAD per repo (drift fast path) │ ├── heads.json.lock ← fcntl lock for concurrent hook fires │ ├── preflight.json ← last preflight result per feature (state machine input) - │ └── active_feature.json ← current canonical + per-repo paths + last_touched LRU map - └── worktrees/ ← warm worktrees (Wave 2.9 canonical-slot model) - ├── SIN-12-search/ - │ ├── backend/ ← linked worktree on the feature branch - │ └── frontend/ - └── SIN-13-empty-state/ - └── frontend/ ← single-repo warm worktree + │ ├── slots.json ← canonical + warm-slot occupancy + LRU + in_flight marker + │ ├── visits.json ← {feature: {last_visit, previous_visit}} for resume brief + │ ├── thread_resolutions.json ← canopy-driven GitHub thread closure log + │ └── bot_resolutions.json ← per-comment bot-resolution log (commit --address) + └── worktrees/ + ├── worktree-1/ ← slot 1 — currently hosts feature X + │ ├── backend/ ← X's backend checkout + │ └── frontend/ ← X's frontend checkout + └── worktree-2/ ← slot 2 — currently hosts feature Y + └── backend/ ← Y is API-only; only one repo present ``` -The number of warm worktrees is bounded by `max_worktrees` (default **2**); features beyond the cap live as cold branches with no on-disk worktree dir. See [concepts.md §4](concepts.md#4-the-canonical-slot-model). +The number of warm slots is bounded by `[workspace] slots` (default **2** — so 1 canonical + 2 warm = 3 live trees max). Features beyond the cap live as cold branches. Slot identity (`worktree-1`, `worktree-2`, ...) is stable across feature swaps; feature occupancy is transient. See [concepts.md §4](concepts.md#4-the-slot-model). Plus, outside the workspace: -- `~/.claude/skills/using-canopy/SKILL.md` — agent integration skill (per-user) + - `~/.canopy/mcp-tokens/.{client,tokens}.json` — OAuth token cache (per-user, per server) +- `~/.claude/skills/using-canopy/SKILL.md` — agent integration skill (per-user mirror) +- `~/.claude/skills/augment-canopy/SKILL.md` — opt-in augment skill (per-user mirror) ## canopy.toml ```toml [workspace] name = "my-product" -max_worktrees = 2 # optional: warm-slot cap for switch (default 2) - # — also caps explicit worktree_create calls (0 = unlimited there) +slots = 2 # warm-slot cap for switch (default 2) + # pre-3.0 max_worktrees raises ConfigError — run canopy migrate-slots [[repos]] name = "backend" path = "./backend" role = "backend" lang = "python" +default_branch = "main" # optional; defaults to "main" [[repos]] name = "frontend" @@ -60,10 +68,11 @@ api_key_env = "LINEAR_API_KEY" # repo = "owner/repo" # labels_filter = ["bug", "feature"] # optional — restrict list_my_issues -[augments] # optional — per-workspace behavioral overrides (M2) +[augments] # optional — per-workspace behavioral overrides preflight_cmd = "make check" # overrides pre-commit auto-detection -test_cmd = "pytest" # reserved for future `canopy test` -review_bots = ["coderabbit", "korbit"] # case-insensitive substring; consumed by M3 bot-tracking +test_cmd = "pytest" # reserved for future canopy test +review_bots = ["coderabbit", "korbit"] # case-insensitive substring; M3 bot-tracking +auto_resolve_threads_on_address = false # if true, resolve the GitHub thread when commit --address fires ``` Generated by `canopy init`. Worktrees are detected automatically — canopy distinguishes `.git` directories (normal repos) from `.git` files (linked worktrees) via `discovery.py`, tagging them with `is_worktree` and `worktree_main`. @@ -79,15 +88,16 @@ Selects which issue tracker backs `canopy switch `, the `linear_my_issues Omit the block entirely to keep pre-M5 behavior (Linear). Only one backend is active per workspace; per-repo overrides are reserved for a future plan. -### `[augments]` — per-workspace behavioral overrides (M2) +### `[augments]` — per-workspace behavioral overrides Customize how canopy operations behave for this workspace without changing any code. Keys recognized today: | Key | Type | Consumer | Notes | |---|---|---|---| -| `preflight_cmd` | string | `canopy preflight` (and the `review_prep` path) | Runs via `sh -c`, so pipes / `&&` chains work. Falls back to auto-detected pre-commit framework or `git hook run pre-commit` when absent. | +| `preflight_cmd` | string | `canopy preflight` (and the `review_prep` path) | Runs via `sh -c`, so pipes and `&&` chains work. Falls back to auto-detected pre-commit framework when absent. | | `test_cmd` | string | future `canopy test` (not yet implemented) | Schema-reserved. Safe to set now; consumed when the command lands. | -| `review_bots` | list[string] | M3 bot-comment tracking (when shipped) | Case-insensitive author substrings. Workspace-level only — per-repo overrides are intentionally ignored for this key (the same CodeRabbit account comments across all repos). | +| `review_bots` | list[string] | M3 bot-comment tracking | Case-insensitive author substrings. Workspace-level only — the same bot account comments across all repos. | +| `auto_resolve_threads_on_address` | bool | `commit --address` | When true, also resolves the GitHub thread when a bot comment is addressed. Default false. | **Per-repo overrides** apply to any key by adding an `augments` table to the matching `[[repos]]` entry. Per-repo wins on collision: @@ -116,12 +126,11 @@ The augment block is **not** reachable through `canopy config` in v1 — that co "linear_title": "Add /search endpoint with shared filter types" }, "SIN-13-fixes": { - "repos": ["backend", "frontend"], + "repos": ["backend"], "status": "active", "created_at": "2026-04-25T17:00:00Z", "branches": { - "backend": "SIN-13-fixes", - "frontend": "SIN-13-fixes-v2" + "backend": "SIN-13-fixes-v2" } } } @@ -129,12 +138,102 @@ The augment block is **not** reachable through `canopy config` in v1 — that co | Field | Notes | |---|---| -| `repos` | List of canopy-registered repo names participating in the lane. Single-repo features are first-class. | +| `repos` | List of canopy-registered repo names participating in the lane. Single-repo features are first-class — a UI-only feature lists only `["frontend"]`. | | `status` | `active` / `merged` / `archived`. `canopy done` flips to `archived`. | -| `linear_issue` / `linear_url` / `linear_title` | Optional Linear link. Powers alias resolution + integration. | -| `branches` | **Optional per-repo branch override.** When the same feature uses different branch names per repo (legacy, mismatched naming), set this. Without it, the branch name is assumed to equal the feature name. See [concepts.md](concepts.md#universal-aliases). | +| `linear_issue` / `linear_url` / `linear_title` | Optional Linear link. Powers alias resolution and integration. | +| `branches` | **Optional per-repo branch override.** When a feature uses different branch names per repo (legacy, mismatched naming), set this map. Without it, the branch name is assumed to equal the feature name. Use `lane.branch_for(repo)` — never assume branch == feature name. See [concepts.md](concepts.md#universal-aliases). | | `created_at` | ISO 8601 timestamp. | -| `worktree_paths` / `use_worktrees` | Set when the feature was created with `--worktree`. | + +## .canopy/state/slots.json + +Written by `canopy switch`, `slot load`, `slot clear`, and `slot swap`. Single source of truth for which feature is canonical and which features occupy warm slots. + +```json +{ + "version": 1, + "slot_count": 2, + "canonical": { + "feature": "SIN-12-search", + "activated_at": "2026-04-25T17:34:21Z", + "per_repo_paths": { + "backend": "/Users/x/projects/my-product/backend", + "frontend": "/Users/x/projects/my-product/frontend" + } + }, + "previous_canonical": "SIN-11-old", + "slots": { + "worktree-1": {"feature": "SIN-13-fixes", "occupied_at": "2026-04-25T15:02:55Z"}, + "worktree-2": {"feature": "SIN-14-cache", "occupied_at": "2026-04-21T08:14:09Z"} + }, + "last_touched": { + "SIN-12-search": "2026-04-25T17:34:21Z", + "SIN-13-fixes": "2026-04-25T15:02:55Z", + "SIN-14-cache": "2026-04-21T08:14:09Z" + }, + "in_flight": null +} +``` + +| Field | Notes | +|---|---| +| `canonical` | The feature currently checked out in the main repo dirs. `per_repo_paths` is the source of truth for `canopy_run` and IDE openers. Staleness check on read: any missing path clears only the canonical pointer — slots and last_touched are preserved. | +| `previous_canonical` | Feature name that was canonical before the last switch. | +| `slots` | Map from slot id (`"worktree-1"`, `"worktree-2"`, ...) to `{feature, occupied_at}`. Slot ids are stable; feature occupancy is transient. Missing slot dirs are silently dropped on read. | +| `last_touched` | ISO timestamp per feature; used by switch's LRU eviction when the cap fires. | +| `in_flight` | Non-null during a switch transaction. Carries `{feature_being_promoted, previously_canonical, started_at, per_repo_completed, failed_repo, error_what}`. A non-null marker means the previous switch may have partially completed; canopy rolls back on the next operation. See [docs/architecture.md](architecture.md) for the rollback protocol. | + +Replaces the old `active_feature.json` from pre-3.0 layouts. Run `canopy migrate-slots` for a one-shot idempotent migration. + +## .canopy/state/visits.json + +Written by `canopy switch` (bumped for the incoming feature) and by `feature_resume` when no switch was needed. Provides the time-window anchor for the resume brief's `since_last_visit` delta. + +```json +{ + "SIN-12-search": { + "last_visit": "2026-05-29T15:30:00Z", + "previous_visit": "2026-05-28T10:00:00Z" + } +} +``` + +Atomic temp+rename writes. The anchor advances exactly once per `feature_resume` call (the single-bump invariant — see [concepts.md §5](concepts.md#5-returning-to-a-feature--the-resume-brief)). + +## .canopy/state/thread_resolutions.json + +Append-only log of GitHub review threads that canopy itself resolved (via `canopy resolve`, `commit --address`, or `reply_resolve`). The resume brief uses this to distinguish "resolved by canopy" from "resolved by a human on GitHub directly." + +```json +{ + "PRRT_abc123": { + "resolved_by_canopy_at": "2026-05-29T12:00:00Z", + "feature": "SIN-12-search", + "via_command": "commit_address", + "via_commit_sha": "1367190a" + } +} +``` + +`via_command` is one of `"resolve"`, `"commit_address"`, or `"reply_resolve"`. Atomic temp+rename writes. + +## .canopy/state/bot_resolutions.json + +Append-only log of bot review comments addressed via `canopy commit --address `. Read by `feature_state` to subtract resolved bot comments from the `actionable_bot_count` — resolved bot comments drop out of bot tracking on the next `feature_state` call. + +```json +{ + "123456": { + "feature": "SIN-12-search", + "repo": "backend", + "commit_sha": "abc123de", + "addressed_at": "2026-05-02T17:30:00Z", + "comment_title": "rename hit_rate to cache_hit_rate", + "comment_url": "https://github.com/owner/repo/pull/142#discussion_r123456" + } +} +``` + +Keys are stringified GitHub comment IDs. Atomic temp+rename writes. ## .canopy/state/heads.json @@ -143,20 +242,20 @@ Written by the post-checkout hook on every branch checkout in any registered rep ```json { "backend": { - "branch": "SIN-12-search", - "sha": "1367190ac97a...", + "branch": "SIN-12-search", + "sha": "1367190ac97a...", "prev_sha": "fda11998caa2...", - "ts": "2026-04-25T17:43:00Z" + "ts": "2026-04-25T17:43:00Z" }, - "frontend": { ... } + "frontend": { "..." : "..." } } ``` -Powers `canopy drift` (the cached, fast path). The state machine in `feature_state` uses live git instead so it's correct even when the hook hasn't fired. +Powers `canopy drift` (the fast cached path). The state machine in `feature_state` uses live git instead, so it is correct even when the hook has not fired. ## .canopy/state/preflight.json -Written by `canopy preflight ` (or `canopy review`'s underlying `review_prep`). Schema: +Written by `canopy preflight `. Schema: ```json { @@ -164,7 +263,7 @@ Written by `canopy preflight ` (or `canopy review`'s underlying `review "passed": true, "ran_at": "2026-04-25T17:58:41Z", "head_sha_per_repo": { - "backend": "1367190a...", + "backend": "1367190a...", "frontend": "e8a21503..." }, "summary": "preflight passed" @@ -172,30 +271,7 @@ Written by `canopy preflight ` (or `canopy review`'s underlying `review } ``` -Freshness check: each repo's recorded sha must equal current HEAD for the expected branch. Any moved HEAD makes the result stale, and `feature_state` falls back from `ready_to_commit` to `in_progress` (with a `preflight_stale` warning). - -## .canopy/state/active_feature.json - -Written by `canopy switch` (the focus primitive). Tells everything else which feature is currently in the canonical slot, where each repo's working tree lives, and the recency map used by switch's LRU eviction. Schema: - -```json -{ - "feature": "SIN-12-search", - "activated_at": "2026-04-25T17:34:21Z", - "previous_feature": "SIN-11-old", - "per_repo_paths": { - "backend": "/Users/x/projects/my-product/backend", - "frontend": "/Users/x/projects/my-product/frontend" - }, - "last_touched": { - "SIN-12-search": "2026-04-25T17:34:21Z", - "SIN-13-empty": "2026-04-25T15:02:55Z", - "SIN-14-cache": "2026-04-21T08:14:09Z" - } -} -``` - -`per_repo_paths` is the source of truth for `canopy_run` (without `--feature`) and IDE openers. `last_touched` lets switch pick which warm worktree to evict (least-recently-touched wins) when the cap fires. Validated on read: if any per-repo path no longer exists, the file is treated as stale and callers fall back to defaults. See [concepts.md §4](concepts.md#4-the-canonical-slot-model). +Freshness check: each repo's recorded sha must equal current HEAD. Any moved HEAD makes the result stale; `feature_state` falls back from `ready_to_commit` to `in_progress` with a `preflight_stale` warning. ## .mcp.json @@ -222,17 +298,18 @@ See [mcp.md](mcp.md) for transport details (stdio, HTTP+OAuth). ## Alias resolution -Every command + MCP tool that takes a feature accepts an alias. Resolution order: +Every command and MCP tool that takes a feature accepts an alias. Resolution order: 1. **Exact match** — alias matches a feature name in `features.json`. -2. **Per-repo branches map match** — alias matches a value in any lane's `branches` map (e.g., passing `SIN-1003-fixes-v2` resolves to `sin-1003`). +2. **Per-repo branches map match** — alias matches a value in any lane's `branches` map. 3. **Implicit multi-repo feature** — alias matches a branch present in 2+ repos (no features.json entry needed). -4. **Implicit single-repo feature** — alias matches a branch present in any single repo (no features.json entry needed). +4. **Implicit single-repo feature** — alias matches a branch present in any single repo. 5. **Linear ID match** — alias matches the `linear_issue` field of any lane. +6. **Slot id** — `worktree-N` resolves to that slot's current occupant. If multiple matches exist, canopy raises `ambiguous_alias` listing the candidates. If none match, raises `unknown_alias` with `expected: {explicit_features, implicit_features}`. -Read tools (`pr`, `branch info`, `comments`) also accept *specific* forms that bypass feature lookup: +Read tools (`pr`, `branch info`, `comments`) also accept specific forms that bypass feature lookup: - `#` — specific PR by number - PR URL — `https://github.com/owner/repo/pull/1287` @@ -240,12 +317,12 @@ Read tools (`pr`, `branch info`, `comments`) also accept *specific* forms that b ## Context detection -`canopy preflight` (without `--feature`) and other context-aware commands work by detecting where you are in the filesystem: +`canopy preflight` (without `--feature`) and other context-aware commands detect where you are in the filesystem: | Context type | Detection | Scope | |---|---|---| -| `feature_dir` | Inside `.canopy/worktrees//` | All repos in the feature | -| `repo_worktree` | Inside `.canopy/worktrees///` | Single repo | +| `feature_dir` | Inside `.canopy/worktrees/worktree-N/` | All repos in the slot's feature | +| `repo_worktree` | Inside `.canopy/worktrees/worktree-N//` | Single repo | | `repo` | Inside a workspace repo directory | Single repo (feature inferred from current branch when non-default) | | `workspace_root` | At the `canopy.toml` level | All repos | diff --git a/github-issue-active-context.md b/github-issue-active-context.md deleted file mode 100644 index b28454e..0000000 --- a/github-issue-active-context.md +++ /dev/null @@ -1,74 +0,0 @@ -# GitHub Issue: Active Feature Context - -**Title:** `canopy switch` should set active context so subsequent commands don't need the feature name - -**Labels:** `enhancement`, `ux` - ---- - -## Problem - -After running `canopy switch doc-3028`, every subsequent command still requires the feature name: - -```bash -canopy switch doc-3028 # switch context -canopy code doc-3028 # why do I need to type this again? -canopy fork doc-3028 # and again? -canopy preflight # this one works (context detection from cwd) but only if you cd first -``` - -Canopy already has context detection (`workspace/context.py`) that works when you're physically inside a worktree directory. But `canopy switch` doesn't change your cwd — it just checks out branches. So you're left at the workspace root with no implicit context, and every command needs the feature name repeated. - -## Proposal - -Introduce an **active feature** that `canopy switch` sets automatically. Commands that accept a feature name should fall back to the active feature when none is provided. - -### Implementation - -1. **Store active feature** in `.canopy/active` (plain text file, just the feature name). Simple, no JSON overhead, easy to `cat`. - -2. **`canopy switch `** writes the feature name to `.canopy/active` after switching. - -3. **Resolution order** for commands that accept a feature target (`code`, `cursor`, `fork`, `done`, `review`, `feature status`, `feature diff`, etc.): - - Explicit argument → use it - - No argument → read `.canopy/active` → use it - - No argument, no active feature → error with helpful message - -4. **`canopy switch` with no args** could show the current active feature (or clear it). - -5. **`canopy done `** should clear `.canopy/active` if the completed feature was the active one. - -6. **`canopy worktree` dashboard** could highlight the active feature. - -### Files to change - -- **`workspace/context.py`** — add `get_active_feature()` / `set_active_feature()` helpers that read/write `.canopy/active` -- **`features/coordinator.py`** — `switch()` calls `set_active_feature()` after checkout; `done()` clears if active -- **`cli/main.py`** — commands that take a `target` argument: make it optional, fall back to active feature via `get_active_feature()` - - `cmd_code`, `cmd_cursor`, `cmd_fork` — target becomes optional - - `cmd_switch` — writes active after switch - - `cmd_done` — clears active if matching - - `cmd_feature_status`, `cmd_feature_diff`, `cmd_feature_switch` — fall back to active - - `cmd_review` — fall back to active -- **`mcp/server.py`** — MCP tools that accept a feature name: fall back to active feature when not provided -- **Tests** — new test file `tests/test_active_context.py`: - - switch sets active - - commands resolve active when no arg given - - done clears active - - explicit arg overrides active - - no active + no arg = clear error - -### UX detail - -When a command resolves via active context, show it: -``` - (active: doc-3028 → doc-3028-document-summarizer) -``` - -Same pattern as alias resolution — user always sees what resolved. - -### Edge cases - -- **Stale active feature**: active file references a feature that was cleaned up externally (branch deleted outside canopy). `get_active_feature()` should validate the feature still exists, return `None` if not. -- **Multiple terminals**: `.canopy/active` is workspace-global. If you switch in one terminal, the other terminal picks it up. This is probably fine — you're focusing on one feature at a time, which is the whole point. -- **`canopy preflight`**: Already works via cwd-based context detection. Active feature is a separate, complementary mechanism — cwd detection should take priority when available. diff --git a/src/canopy/agent_setup/skills/augment-canopy/SKILL.md b/src/canopy/agent_setup/skills/augment-canopy/SKILL.md index 649297c..c21627c 100644 --- a/src/canopy/agent_setup/skills/augment-canopy/SKILL.md +++ b/src/canopy/agent_setup/skills/augment-canopy/SKILL.md @@ -47,7 +47,7 @@ augments = { preflight_cmd = "uv run pytest tests/fast" } # api-only override |---|---|---|---| | `preflight_cmd` | string | `canopy preflight` (and `review_prep` path inside `coordinator.py`) | Runs via `sh -c` so pipes / `&&` chains work | | `test_cmd` | string | future `canopy test` (not v1) | Schema-reserved; safe to set | -| `review_bots` | list[string] | M3 bot-comment tracking (when shipped) | Workspace-level only; per-repo overrides ignored for this key | +| `review_bots` | list[string] | M3 bot-comment tracking | Workspace-level only; per-repo overrides ignored for this key | | `auto_resolve_threads_on_address` | bool | `canopy commit --address ` | When true, `canopy commit --address ` auto-resolves the corresponding GH review thread after push. `--no-resolve-thread` overrides. Default: false. | Unknown keys are silently preserved by the parser — future augments don't require schema migration. @@ -86,7 +86,7 @@ Agent should: > - `augments.preflight_cmd = "ruff check . && pyright"` > - `augments.review_bots = ["coderabbit", "korbit"]` > - > The next `canopy preflight` will run the new command. Bot-comment tracking will pick up the `review_bots` list once M3 ships. + > The next `canopy preflight` will run the new command. Bot-comment tracking uses the `review_bots` list. ## Per-repo override example @@ -111,6 +111,6 @@ Atomic write, confirm: ## Don't -- Don't add validation logic here — the parser is intentionally lenient. Validation (typo detection, unknown-key warnings) lives in `canopy doctor` (deferred). +- Don't add validation logic here — the parser is intentionally lenient. Validation (typo detection, unknown-key warnings) lives in `canopy doctor`. - Don't introduce nested-key syntax via `canopy config augments.preflight_cmd` — that's a future refactor of `cmd_config`. v1 writes TOML directly. - Don't alter `[issue_provider]` or `[[repos]]` structural fields from this skill — those are different concerns.