From cef51275eef63cc216af0c852227cdf4a2bf6561 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 3 Jul 2026 08:38:45 +0000 Subject: [PATCH 1/3] feat(claude): ship a Claude Code plugin bundle + sync skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the config-managed Claude integration (hand-patched MCP entry, 5 hooks, loose subagents in ~/.claude/) with a real Claude Code plugin bundle installed via a local `directory` marketplace under ~/.claude/plugins/marketplaces/tracedecay/. - new claude-plugin/ bundle: .claude-plugin/{plugin.json,marketplace.json}, .mcp.json, hooks/hooks.json (5 events), agents/ (3 subagents), commands/ (13 slash commands), and skills/ - full Claude Code surface: MCP + hooks + subagents + slash commands + skills - installer deploys the embedded bundle, registers the directory marketplace, and sets enabledPlugins["tracedecay@tracedecay"]; migrates existing installs off the old config-managed MCP/hooks/loose-subagents while preserving the tool-permission allowlist, CLAUDE.md rules, and any foreign entries - cross-surface skill sync: all three bundling surfaces (claude/codex/cursor) now ship the same 30 skills — 13 foundational + 13 tracedecay-* workflow + 4 new memory skills (storing/retrieving-project-memory, managing-session-context, retrieving-cached-context), byte-identical where each host's format allows; CURSOR_PLUGIN_SKILLS updated to the 17 model-invocable set - doctor validates the deployed bundle + warns on stale config-managed leftovers Co-Authored-By: Claude Fable 5 --- claude-plugin/.claude-plugin/marketplace.json | 16 + claude-plugin/.claude-plugin/plugin.json | 19 + claude-plugin/README.md | 39 + claude-plugin/agents/code-explorer.md | 28 + claude-plugin/agents/code-health-auditor.md | 28 + claude-plugin/agents/session-historian.md | 29 + claude-plugin/commands/audit-safety.md | 18 + claude-plugin/commands/check-health.md | 18 + claude-plugin/commands/clean-dead-code.md | 14 + claude-plugin/commands/compare-branches.md | 16 + claude-plugin/commands/curate-memory.md | 17 + claude-plugin/commands/draft-commit.md | 15 + claude-plugin/commands/find-impact.md | 16 + claude-plugin/commands/fix-build.md | 14 + claude-plugin/commands/map-architecture.md | 16 + claude-plugin/commands/port-code.md | 16 + claude-plugin/commands/recall-memory.md | 16 + claude-plugin/commands/review-diff.md | 17 + claude-plugin/commands/test-changes.md | 16 + claude-plugin/hooks/hooks.json | 72 + .../skills/assessing-impact/SKILL.md | 71 + claude-plugin/skills/code-health/SKILL.md | 100 + .../skills/curating-project-memory/SKILL.md | 73 + claude-plugin/skills/editing-safely/SKILL.md | 97 + claude-plugin/skills/exploring-code/SKILL.md | 99 + .../fixing-build-and-type-errors/SKILL.md | 26 + .../skills/inspecting-managed-skills/SKILL.md | 32 + .../skills/managing-session-context/SKILL.md | 76 + .../skills/recalling-project-memory/SKILL.md | 34 + .../skills/recalling-session-context/SKILL.md | 32 + .../skills/retrieving-cached-context/SKILL.md | 60 + .../skills/retrieving-project-memory/SKILL.md | 69 + .../skills/reviewing-changes/SKILL.md | 84 + .../skills/storing-project-memory/SKILL.md | 82 + .../skills/tracedecay-audit-safety/SKILL.md | 15 + .../skills/tracedecay-check-health/SKILL.md | 15 + .../tracedecay-clean-dead-code/SKILL.md | 15 + .../tracedecay-compare-branches/SKILL.md | 15 + .../skills/tracedecay-curate-memory/SKILL.md | 16 + .../skills/tracedecay-draft-commit/SKILL.md | 15 + .../skills/tracedecay-find-impact/SKILL.md | 15 + .../skills/tracedecay-fix-build/SKILL.md | 15 + .../tracedecay-map-architecture/SKILL.md | 15 + .../skills/tracedecay-port-code/SKILL.md | 15 + .../skills/tracedecay-recall-memory/SKILL.md | 16 + .../skills/tracedecay-review-diff/SKILL.md | 15 + .../skills/tracedecay-test-changes/SKILL.md | 15 + .../skills/tracing-functions/SKILL.md | 27 + claude-plugin/skills/using-the-cli/SKILL.md | 42 + .../skills/using-tracedecay/SKILL.md | 56 + .../skills/managing-session-context/SKILL.md | 76 + .../skills/retrieving-cached-context/SKILL.md | 60 + .../skills/retrieving-project-memory/SKILL.md | 69 + .../skills/storing-project-memory/SKILL.md | 82 + .../skills/tracedecay-audit-safety/SKILL.md | 15 + .../skills/tracedecay-check-health/SKILL.md | 15 + .../tracedecay-clean-dead-code/SKILL.md | 15 + .../tracedecay-compare-branches/SKILL.md | 15 + .../skills/tracedecay-curate-memory/SKILL.md | 16 + .../skills/tracedecay-draft-commit/SKILL.md | 15 + .../skills/tracedecay-find-impact/SKILL.md | 15 + .../skills/tracedecay-fix-build/SKILL.md | 15 + .../tracedecay-map-architecture/SKILL.md | 15 + .../skills/tracedecay-port-code/SKILL.md | 15 + .../skills/tracedecay-recall-memory/SKILL.md | 16 + .../skills/tracedecay-review-diff/SKILL.md | 15 + .../skills/tracedecay-test-changes/SKILL.md | 15 + .../skills/managing-session-context/SKILL.md | 76 + .../skills/retrieving-cached-context/SKILL.md | 60 + .../skills/retrieving-project-memory/SKILL.md | 69 + .../skills/storing-project-memory/SKILL.md | 82 + src/agents/claude.rs | 2489 +++++++---------- src/agents/codex.rs | 115 +- src/agents/cursor.rs | 16 + src/hooks/steering.rs | 16 +- tests/agent_suite/agent_test.rs | 209 +- tests/agent_suite/claude_agent_test.rs | 201 +- .../agent_suite/claude_plugin_bundle_test.rs | 486 ++++ tests/agent_suite/main.rs | 1 + tests/agent_suite/plugin_bundle_sync_test.rs | 67 +- tests/agent_suite/update_plugin_test.rs | 48 +- tests/agent_suite/upgrade_refresh_test.rs | 11 +- 82 files changed, 4558 insertions(+), 1569 deletions(-) create mode 100644 claude-plugin/.claude-plugin/marketplace.json create mode 100644 claude-plugin/.claude-plugin/plugin.json create mode 100644 claude-plugin/README.md create mode 100644 claude-plugin/agents/code-explorer.md create mode 100644 claude-plugin/agents/code-health-auditor.md create mode 100644 claude-plugin/agents/session-historian.md create mode 100644 claude-plugin/commands/audit-safety.md create mode 100644 claude-plugin/commands/check-health.md create mode 100644 claude-plugin/commands/clean-dead-code.md create mode 100644 claude-plugin/commands/compare-branches.md create mode 100644 claude-plugin/commands/curate-memory.md create mode 100644 claude-plugin/commands/draft-commit.md create mode 100644 claude-plugin/commands/find-impact.md create mode 100644 claude-plugin/commands/fix-build.md create mode 100644 claude-plugin/commands/map-architecture.md create mode 100644 claude-plugin/commands/port-code.md create mode 100644 claude-plugin/commands/recall-memory.md create mode 100644 claude-plugin/commands/review-diff.md create mode 100644 claude-plugin/commands/test-changes.md create mode 100644 claude-plugin/hooks/hooks.json create mode 100644 claude-plugin/skills/assessing-impact/SKILL.md create mode 100644 claude-plugin/skills/code-health/SKILL.md create mode 100644 claude-plugin/skills/curating-project-memory/SKILL.md create mode 100644 claude-plugin/skills/editing-safely/SKILL.md create mode 100644 claude-plugin/skills/exploring-code/SKILL.md create mode 100644 claude-plugin/skills/fixing-build-and-type-errors/SKILL.md create mode 100644 claude-plugin/skills/inspecting-managed-skills/SKILL.md create mode 100644 claude-plugin/skills/managing-session-context/SKILL.md create mode 100644 claude-plugin/skills/recalling-project-memory/SKILL.md create mode 100644 claude-plugin/skills/recalling-session-context/SKILL.md create mode 100644 claude-plugin/skills/retrieving-cached-context/SKILL.md create mode 100644 claude-plugin/skills/retrieving-project-memory/SKILL.md create mode 100644 claude-plugin/skills/reviewing-changes/SKILL.md create mode 100644 claude-plugin/skills/storing-project-memory/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-audit-safety/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-check-health/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-clean-dead-code/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-compare-branches/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-curate-memory/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-draft-commit/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-find-impact/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-fix-build/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-map-architecture/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-port-code/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-recall-memory/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-review-diff/SKILL.md create mode 100644 claude-plugin/skills/tracedecay-test-changes/SKILL.md create mode 100644 claude-plugin/skills/tracing-functions/SKILL.md create mode 100644 claude-plugin/skills/using-the-cli/SKILL.md create mode 100644 claude-plugin/skills/using-tracedecay/SKILL.md create mode 100644 codex-plugin/skills/managing-session-context/SKILL.md create mode 100644 codex-plugin/skills/retrieving-cached-context/SKILL.md create mode 100644 codex-plugin/skills/retrieving-project-memory/SKILL.md create mode 100644 codex-plugin/skills/storing-project-memory/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-audit-safety/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-check-health/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-clean-dead-code/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-compare-branches/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-curate-memory/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-draft-commit/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-find-impact/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-fix-build/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-map-architecture/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-port-code/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-recall-memory/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-review-diff/SKILL.md create mode 100644 codex-plugin/skills/tracedecay-test-changes/SKILL.md create mode 100644 cursor-plugin/skills/managing-session-context/SKILL.md create mode 100644 cursor-plugin/skills/retrieving-cached-context/SKILL.md create mode 100644 cursor-plugin/skills/retrieving-project-memory/SKILL.md create mode 100644 cursor-plugin/skills/storing-project-memory/SKILL.md create mode 100644 tests/agent_suite/claude_plugin_bundle_test.rs diff --git a/claude-plugin/.claude-plugin/marketplace.json b/claude-plugin/.claude-plugin/marketplace.json new file mode 100644 index 000000000..3a11457bc --- /dev/null +++ b/claude-plugin/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "tracedecay", + "owner": { + "name": "ScriptedAlchemy" + }, + "plugins": [ + { + "name": "tracedecay", + "source": "./", + "description": "Semantic code intelligence, code graph, and project memory for Claude Code.", + "category": "productivity", + "homepage": "https://github.com/ScriptedAlchemy/tracedecay" + } + ] +} diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 000000000..651506e03 --- /dev/null +++ b/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "tracedecay", + "version": "0.0.0", + "description": "Claude Code integration for TraceDecay semantic code intelligence: MCP server plus a `tracedecay tool` CLI fallback exposing the same tools when MCP is unavailable.", + "author": { + "name": "ScriptedAlchemy" + }, + "homepage": "https://github.com/ScriptedAlchemy/tracedecay", + "repository": "https://github.com/ScriptedAlchemy/tracedecay", + "license": "MIT", + "keywords": [ + "code-graph", + "mcp", + "code-search", + "token-savings", + "call-graph", + "code-health" + ] +} diff --git a/claude-plugin/README.md b/claude-plugin/README.md new file mode 100644 index 000000000..6b7334800 --- /dev/null +++ b/claude-plugin/README.md @@ -0,0 +1,39 @@ +# TraceDecay for Claude Code + +This plugin bundles the TraceDecay MCP server, a suite of workflow skills, and +lifecycle hooks for code-graph, impact, recall, and context-saving workflows in +Claude Code. + +## What it ships + +- **MCP server** (`.mcp.json`): the `tracedecay` stdio server exposing the code + graph, search, call-graph, impact, memory, and session-recall tools. +- **Skills** (`skills/`): one skill per common workflow — searching for code, + reading code cheaply, mapping architecture, impact analysis, reviewing diffs, + recalling project memory and session context, and more. Claude Code + auto-discovers each `SKILL.md` by its `name`/`description` frontmatter and + loads the body only when the workflow matches. +- **Lifecycle hooks** (`hooks/hooks.json`): `SessionStart`, + `UserPromptSubmit`, `Stop`, `PreToolUse`, and `PostToolUse` handlers that + inject index status and tool-routing steering, keep the graph/session store + warm, and redirect explore-agent calls toward the tracedecay tools. + +## Install + +Install the plugin (and register its hooks and MCP server) with: + +``` +tracedecay install --agent claude +``` + +The installer resolves the absolute path of the `tracedecay` binary and writes +it into the managed hooks, so the plugin works even when tracedecay lives on a +path with spaces. + +## CLI fallback + +Every MCP tool is also available from the shell as `tracedecay tool ` +(`tracedecay tool` lists all tools; `tracedecay tool --help` shows a +tool's parameters). The bundled skills and injected steering point agents at +that CLI fallback when the MCP transport errors or times out, instead of +querying `.tracedecay` databases directly or giving up on tracedecay. diff --git a/claude-plugin/agents/code-explorer.md b/claude-plugin/agents/code-explorer.md new file mode 100644 index 000000000..dd4d33276 --- /dev/null +++ b/claude-plugin/agents/code-explorer.md @@ -0,0 +1,28 @@ +--- +name: code-explorer +description: Read-only code exploration agent powered by the TraceDecay code graph. Use PROACTIVELY for codebase research — how/where/what questions, symbol lookup, callers/callees tracing, call chains, and impact analysis — whenever TraceDecay MCP tools are available. Also use to parallelize codebase research or isolate a deep exploration from the main thread. Never edits files. +model: inherit +tools: Read, Grep, Glob, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_store, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary, mcp__tracedecay__tracedecay_lcm_doctor +--- + +# Code explorer (read-only) + +You are a read-only exploration subagent. You investigate the repository and return findings; you never edit files or run mutating tools. + +## Method + +1. Start with `tracedecay_context` (add `keywords` for concepts). **Respect the per-project call budget shown in the tool description.** Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`. +2. Narrow with `tracedecay_search` / `tracedecay_find_exact_symbol` / `tracedecay_body` / `tracedecay_outline`. +3. Trace with `tracedecay_callers` / `tracedecay_callees` / `tracedecay_call_chain`; assess reach with `tracedecay_impact`. +4. Fall back to Grep/Read only for non-indexed content or after TraceDecay pinpoints files. + +## Rules + +- Read-only: never edit files, run test runners or diagnostics, or write memory. Mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Do not spawn nested subagents unless explicitly asked. + +## Return + +- A concise answer plus the concrete files + qualified symbol names and key relationships found. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/agents/code-health-auditor.md b/claude-plugin/agents/code-health-auditor.md new file mode 100644 index 000000000..c85e5bed9 --- /dev/null +++ b/claude-plugin/agents/code-health-auditor.md @@ -0,0 +1,28 @@ +--- +name: code-health-auditor +description: Read-only code-health audit agent powered by the TraceDecay code graph. Use PROACTIVELY when asked for a health audit, tech-debt report, code-quality scorecard, or the worst complexity, duplication, coupling, doc, and test-risk offenders. Also use to run a health audit in isolation or parallelize a large-repo review. Never edits files. +model: inherit +tools: Read, Grep, Glob, Skill, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_store, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary, mcp__tracedecay__tracedecay_lcm_doctor +--- + +# Code-health auditor (read-only) + +You are a read-only audit subagent. You score and rank code health and return findings; you never edit files, run the toolchain, or write memory. + +## Method + +1. Start with `tracedecay_health` (`details: true`) and let the weak dimensions drive the drill-down. +2. Drill only into weak dimensions or explicit asks: complexity/size -> `tracedecay_complexity`, `tracedecay_gini`, `tracedecay_god_class`, `tracedecay_largest`, `tracedecay_hotspots`; structure -> `tracedecay_coupling`, `tracedecay_dependency_depth`, `tracedecay_dsm`, `tracedecay_circular`, `tracedecay_recursion`; quality -> `tracedecay_redundancy`, `tracedecay_doc_coverage`, `tracedecay_unsafe_patterns`, `tracedecay_test_risk`. +3. Keep expensive scans scoped (`path`, `limit`, `max_pairs`) and stop once the ranked findings are actionable. +4. If the `tracedecay:code-health` skill is available, follow its full workflow. + +## Rules + +- Read-only: never edit files, run test runners or diagnostics, write session baselines, or write memory. Mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Keep `path`/`max_pairs` tight on `tracedecay_redundancy` (first call can be slow). Do not spawn nested subagents unless asked. + +## Return + +- The composite score, weak dimensions, ranked offenders, and a prioritized fix list with concrete files + qualified symbol names. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/agents/session-historian.md b/claude-plugin/agents/session-historian.md new file mode 100644 index 000000000..292631d54 --- /dev/null +++ b/claude-plugin/agents/session-historian.md @@ -0,0 +1,29 @@ +--- +name: session-historian +description: Read-only session-recall agent powered by TraceDecay's transcript index and LCM store. Use PROACTIVELY for "what did we decide/do/discuss previously" questions — message search, lossless session replay, summary-DAG drill-down, and durable fact search. Use to recover prior context without polluting the main thread. Never edits files or mutates memory. +model: inherit +tools: Read, Grep, Glob, Skill, mcp__tracedecay +disallowedTools: mcp__tracedecay__tracedecay_str_replace, mcp__tracedecay__tracedecay_multi_str_replace, mcp__tracedecay__tracedecay_insert_at, mcp__tracedecay__tracedecay_insert_at_symbol, mcp__tracedecay__tracedecay_replace_symbol, mcp__tracedecay__tracedecay_ast_grep_rewrite, mcp__tracedecay__tracedecay_run_affected_tests, mcp__tracedecay__tracedecay_diagnostics, mcp__tracedecay__tracedecay_session_start, mcp__tracedecay__tracedecay_session_end, mcp__tracedecay__tracedecay_fact_feedback, mcp__tracedecay__tracedecay_memory_status, mcp__tracedecay__tracedecay_lcm_compress, mcp__tracedecay__tracedecay_lcm_preflight, mcp__tracedecay__tracedecay_lcm_session_boundary +--- + +# Session historian (read-only) + +You are a read-only recall subagent. You retrieve what past sessions said, did, and decided for this project; you never edit files, mutate memory, or run lifecycle tools. + +## Method + +1. Start with `tracedecay_message_search` (fast FTS over ingested transcripts; note the session ids on hits). +2. Narrow with `tracedecay_lcm_grep` (scope/role/time filters), then replay with `tracedecay_lcm_load_session` (paginate via `after_store_id`, never dump whole sessions). +3. Drill into summaries with `tracedecay_lcm_describe` / `tracedecay_lcm_expand` / `tracedecay_lcm_expand_query`; inspect the store with `tracedecay_lcm_status`. +4. For durable decisions/facts, search `tracedecay_fact_store` (`action: "search"`, plus `"probe"`/`"reason"` when useful). +5. If the `tracedecay:recalling-session-context` skill is available, follow its full ladder. + +## Rules + +- Read-only: use `tracedecay_fact_store` only with read actions (`search`, `probe`, `reason`, `related`, `get`, `list`) — never `add`, `update`, or `remove`. Use `tracedecay_lcm_doctor` only in check mode — never repair/clean modes. Other mutating TraceDecay tools are disabled for this agent; do not attempt to work around that. +- Do not spawn nested subagents unless explicitly asked. + +## Return + +- A concise answer with the supporting quotes/decisions, each cited by session id + timestamp (and fact id where applicable). +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/commands/audit-safety.md b/claude-plugin/commands/audit-safety.md new file mode 100644 index 000000000..49c1dddf9 --- /dev/null +++ b/claude-plugin/commands/audit-safety.md @@ -0,0 +1,18 @@ +--- +description: Audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, and untested high-risk symbols. +argument-hint: "[path]" +--- + +# Audit safety + +Run a read-only ship-readiness sweep over the whole repo, or `$ARGUMENTS` if a directory was given. Report findings; do not fix them here. + +1. Panic & unsafe sites → `tracedecay_unsafe_patterns` (use `kinds` to narrow to `unwrap`/`unsafe`, `exclude_tests: true` for production-only, `path` to scope). Each hit carries file, line, kind, enclosing symbol, `in_test`. +2. Unfinished work → `tracedecay_todos` (`kinds: ["FIXME","HACK","XXX","UNIMPLEMENTED"]`). +3. Unreachable code → `tracedecay_dead_code` (`include_public: true` for workspace-internal audits) and `tracedecay_unused_imports`. +4. Risky and untested → `tracedecay_test_risk`: high-complexity, high-fan-in symbols with weak coverage. +5. Rank: production panic/unsafe in hot paths first (cross-check fan-in with `tracedecay_callers`), then UNIMPLEMENTED/HACK markers, then untested high-risk symbols, then dead code and imports. + +`unwrap`/`panic!` inside tests is normal — respect `exclude_tests`/`in_test` before flagging. An `unsafe { }` block is a review-attention site, not automatically a finding. + +Output: findings grouped Critical / Warning / Note with file + enclosing symbol, and a prioritized follow-up list. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/check-health.md b/claude-plugin/commands/check-health.md new file mode 100644 index 000000000..3d1a8a98f --- /dev/null +++ b/claude-plugin/commands/check-health.md @@ -0,0 +1,18 @@ +--- +description: Check code health for the repo or a directory, including worst offenders and a prioritized fix list. +argument-hint: "[path]" +--- + +# Check health + +Produce a read-only code-health scorecard for the whole repo, or `$ARGUMENTS` if a directory was given. Lead with the one composite signal, then drill only into the weak dimensions — don't run every tool by reflex. + +1. Composite signal → `tracedecay_health` (`details: true`, optional `path`): the 0–10000 score plus the 5-dimension breakdown (acyclicity, depth, equality, redundancy, modularity) and the `coverage_discipline` penalty. The weak dimensions choose the drill-downs. +2. Inequality / god files → `tracedecay_gini` (`metric`, `scope`, optional `path`). +3. Complexity & size offenders: `tracedecay_complexity`, `tracedecay_largest`, `tracedecay_god_class`, `tracedecay_hotspots`. +4. Structure drill-downs matched to the weak dimension: acyclicity → `tracedecay_circular` + `tracedecay_recursion`; modularity → `tracedecay_dsm` + `tracedecay_coupling`; depth → `tracedecay_dependency_depth` + `tracedecay_inheritance_depth`. +5. Duplication → `tracedecay_redundancy`; doc gaps → `tracedecay_doc_coverage`; panic sites → `tracedecay_unsafe_patterns`; test gaps → `tracedecay_test_risk`. + +This reports and prioritizes; it does not edit. + +Output: the composite score + weak dimensions, the worst offenders (complexity, duplication, god files, doc gaps, panic sites, test-risk), and a prioritized fix list. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/clean-dead-code.md b/claude-plugin/commands/clean-dead-code.md new file mode 100644 index 000000000..dcd8d4340 --- /dev/null +++ b/claude-plugin/commands/clean-dead-code.md @@ -0,0 +1,14 @@ +--- +description: Find and safely remove dead code, unused imports, and duplication via the TraceDecay code graph. +argument-hint: "[path]" +--- + +# Clean dead code + +Find and safely remove dead code across the whole repo, or `$ARGUMENTS` if a directory was given. + +1. Discover with `tracedecay_dead_code` / `tracedecay_unused_imports` / `tracedecay_redundancy`; focused pass → `tracedecay_simplify_scan` (`files`). +2. Before deleting anything, confirm zero real callers with `tracedecay_callers` / `tracedecay_rename_preview`. Be conservative with `pub` items (they may be used outside the indexed scope). Never delete a symbol whose callers/references are non-empty. +3. Apply edits via the anchored primitives (`tracedecay_str_replace`, `tracedecay_multi_str_replace`, `tracedecay_replace_symbol`); verify with `tracedecay_diagnostics` and the affected tests (`tracedecay_run_affected_tests` / `tracedecay_affected`). Optionally bracket the cleanup with a `tracedecay_session_start` / `tracedecay_session_end` health delta. + +Output: removed/consolidated items and the before/after health or test result. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/compare-branches.md b/claude-plugin/commands/compare-branches.md new file mode 100644 index 000000000..1488046bf --- /dev/null +++ b/claude-plugin/commands/compare-branches.md @@ -0,0 +1,16 @@ +--- +description: Compare or search another git branch's code graph without switching your checkout. +argument-hint: "[branch | base head]" +--- + +# Compare branches + +Interpret `$ARGUMENTS` as either a single target branch to compare against the current branch, or " " to diff two branches. If absent, start with `tracedecay_branch_list` and ask what to search or compare. + +1. What's tracked → `tracedecay_branch_list`. +2. Search another branch → `tracedecay_branch_search` (`branch`, `query`). +3. Compare branches → `tracedecay_branch_diff` (`base`, `head`, optional `file`, `kind`) — added / removed / changed symbols, read-only and never touching your checkout. + +Branch tracking is opt-in per branch. If a target branch isn't tracked, tell the user to run `tracedecay branch add ` in the terminal first. A branch-fallback `WARNING` prefix means results came from the nearest tracked ancestor — surface that to the user. + +Output: the cross-branch search hits or the added/removed/changed symbol lists, with any branch-fallback warning surfaced. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/curate-memory.md b/claude-plugin/commands/curate-memory.md new file mode 100644 index 000000000..a6d3f512e --- /dev/null +++ b/claude-plugin/commands/curate-memory.md @@ -0,0 +1,17 @@ +--- +description: Curate, update, delete, or inspect TraceDecay memory facts and dashboard curation from an explicit slash workflow. +argument-hint: "[subject]" +--- + +# Curate memory + +Interpret `$ARGUMENTS` as the fact, entity, query, or curation action to review. If absent, ask what memory scope to curate before mutating anything. + +1. Resolve scope: confirm the active project root/store with `tracedecay_active_project` before touching memory. +2. Start read-only with `tracedecay_fact_store` (`action`: `search` / `list` / `get` / `probe` / `related` / `reason` / `contradict`) or `tracedecay_memory_status` (only when the user asks for counts/health, since it may repair vectors/banks). Open `tracedecay_dashboard` (`action: "start"`) only when the user wants visual curation. +3. Inventory candidates into add, update, merge/dedupe, stale, contradiction, secret-like, and possible-delete buckets, keeping fact ids, source, trust, tags, and evidence with each. +4. Apply narrowly with `tracedecay_fact_store` `action: "add"` / `"update"`. Prefer update/merge over removal when useful provenance should survive. +5. Hard-delete guardrail: require explicit approval immediately before every `action: "remove"` or dashboard hard delete, showing fact id, content/source summary, reason, and a permanent-delete warning — unless the user already gave an exact deletion instruction. Deletion is permanent; there is no undo. Never store secrets, credentials, or PII. +6. Verify read-only: re-run search/list/probe/get and report final facts changed, skipped, or still needing judgment. + +Output: memory facts inspected or changed, confirmations requested, and the final verification search/list result. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/draft-commit.md b/claude-plugin/commands/draft-commit.md new file mode 100644 index 000000000..f951cde8e --- /dev/null +++ b/claude-plugin/commands/draft-commit.md @@ -0,0 +1,15 @@ +--- +description: Draft a commit message, PR description, or changelog from semantic changes; drafts text only and never commits or pushes. +--- + +# Draft commit + +Interpret `$ARGUMENTS` as the target (e.g. "pr", "changelog", a base ref, or "staged"). If absent, draft a commit message for the working-tree/staged changes. + +1. Commit message → `tracedecay_commit_context` (`staged_only`): changed symbols + file roles + recent commit style. +2. PR description → `tracedecay_pr_context` (`base_ref`, `head_ref`): Summary / Impact / Tests. +3. Release notes → `tracedecay_changelog` (`from_ref`, `to_ref`); sanity-check with `tracedecay_branch_diff`. + +This drafts text only — leave `git commit` / `gh pr create` to the user unless they explicitly ask. + +Output: the drafted commit / PR / changelog text. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/find-impact.md b/claude-plugin/commands/find-impact.md new file mode 100644 index 000000000..d5ded5400 --- /dev/null +++ b/claude-plugin/commands/find-impact.md @@ -0,0 +1,16 @@ +--- +description: Find the blast radius of a change, including impacted symbols, files, and the tests to run. +argument-hint: "[symbol | path]" +--- + +# Find impact + +Interpret `$ARGUMENTS` as the symbol, file, or change to analyze. If absent, use the current working-tree diff. This identifies impact read-only; it does not run tests. + +1. Resolve the target to a node ID with `tracedecay_search` / `tracedecay_find_exact_symbol` / `tracedecay_by_qualified_name`. +2. Symbol blast radius → `tracedecay_impact` (`node_id`, small `max_depth` first, widen only if incomplete): direct + transitive dependents. +3. File-level fan-in → `tracedecay_file_dependents`; already have changed paths → `tracedecay_diff_context` (`files`): modified symbols + dependents + affected tests in one call. +4. Test set → `tracedecay_affected` (`files`) for every test that can see the change; `tracedecay_test_map` for direct coverage of one symbol/file. +5. Structural fragility (optional) → `tracedecay_coupling` / `tracedecay_dependency_depth` to see if the target is a high-fan-in hub. + +Output: impacted symbols + files, the test set to run, and any hub/coupling risk. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/fix-build.md b/claude-plugin/commands/fix-build.md new file mode 100644 index 000000000..d2d6a5786 --- /dev/null +++ b/claude-plugin/commands/fix-build.md @@ -0,0 +1,14 @@ +--- +description: Fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing. +--- + +# Fix build + +Interpret `$ARGUMENTS`: if it contains pasted `cargo`/`clippy`/`rustc` output, route it to `tracedecay_diagnose`; otherwise run `tracedecay_diagnostics` (scoped to a directory if one was given). Prefer pasted output when available. + +1. Already have raw output → `tracedecay_diagnose` (`cargo_output` required, optional `severity`, `include_callers`, `max_diagnostics`): each diagnostic maps to the smallest containing node with up to 5 callers pre-attached. No toolchain run — cheap and safe. +2. Need fresh diagnostics → `tracedecay_diagnostics` (`scope`: `workspace` | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings each mapped to the enclosing graph node. This runs the toolchain (the first run on a fresh tree can take minutes) — respect approval/run-mode. +3. Understand the failing code with the exploring-code ladder; widen blast radius with `tracedecay_impact` if a fix is risky. +4. Apply the fix with the anchored edit primitives, then re-check with the cheapest applicable diagnostic path. + +Output: grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/map-architecture.md b/claude-plugin/commands/map-architecture.md new file mode 100644 index 000000000..b8772caa5 --- /dev/null +++ b/claude-plugin/commands/map-architecture.md @@ -0,0 +1,16 @@ +--- +description: Map repo or directory architecture, including layered modules, dependency hotspots, and structural risks. +argument-hint: "[path]" +--- + +# Map architecture + +Map the architecture of the whole repo, or `$ARGUMENTS` if a directory was given. Read-only. + +1. Shape & size: `tracedecay_status` (node/edge/file counts), `tracedecay_files` + `tracedecay_distribution` (what lives where). +2. Public surface: `tracedecay_module_api` per top-level directory. +3. Dependency structure: `tracedecay_dsm` (clusters and layering violations), `tracedecay_coupling` (`fan_in`/`fan_out` hubs), `tracedecay_circular` (cycles), `tracedecay_dependency_depth` (fragile long chains). + +This reports and prioritizes; it does not edit. + +Output: a layered module map, dependency hotspots/violations, and a prioritized risk list. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/port-code.md b/claude-plugin/commands/port-code.md new file mode 100644 index 000000000..cabdc3daa --- /dev/null +++ b/claude-plugin/commands/port-code.md @@ -0,0 +1,16 @@ +--- +description: Port or migrate code between directories in dependency-safe order and track progress. +argument-hint: "[source_dir target_dir]" +--- + +# Port code + +Interpret `$ARGUMENTS` as " ". If absent, ask for the source and target directories. Port leaves first; never port a symbol before its dependencies. + +1. Baseline → `tracedecay_port_status` (`source_dir`, `target_dir`, `kinds`); order → `tracedecay_port_order`: topological sort — port leaves first, dependents after. +2. Per symbol: pull source with `tracedecay_body`, map dependencies with `tracedecay_callees` / `tracedecay_callers`, confirm the contract with `tracedecay_signature`, apply with the anchored edit primitives (`tracedecay_str_replace`, `tracedecay_insert_at`, `tracedecay_replace_symbol`). +3. After each batch: re-run `tracedecay_port_status`; typecheck with `tracedecay_diagnostics`. Cross-branch parity → `tracedecay_branch_diff` / `tracedecay_changelog`. + +Edit primitives and toolchain runs mutate state — respect approval/run-mode. + +Output: updated port status (done / remaining) and the per-batch typecheck result. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/recall-memory.md b/claude-plugin/commands/recall-memory.md new file mode 100644 index 000000000..2123c7b2a --- /dev/null +++ b/claude-plugin/commands/recall-memory.md @@ -0,0 +1,16 @@ +--- +description: Recall prior decisions, durable facts, and past session conversations for this project. +argument-hint: "[subject]" +--- + +# Recall memory + +Interpret `$ARGUMENTS` as the question or topic to recall. If absent, ask what to look up. Recall memory before reaching for external or web search — a prior session may already have answered it. + +1. Durable decisions/facts → `tracedecay_fact_store` with `action: "search"` (or `"probe"` / `"reason"`), plus `query` and `min_trust`. +2. Past conversations → `tracedecay_message_search` (`query`, optional `provider`, `limit`) over ingested transcripts; drill deeper with the LCM ladder (`tracedecay_lcm_grep`, `tracedecay_lcm_load_session`) when role/time/session precision is needed. +3. If the user rates a recalled fact → `tracedecay_fact_feedback` (`helpful` / `unhelpful`). + +If the user asks to update, delete, merge, or prune stored facts, switch to `/tracedecay:curate-memory`. + +Output: the recalled decisions/messages with their sources (fact, session id, timestamp). If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/review-diff.md b/claude-plugin/commands/review-diff.md new file mode 100644 index 000000000..bcb101143 --- /dev/null +++ b/claude-plugin/commands/review-diff.md @@ -0,0 +1,17 @@ +--- +description: Review the current PR or diff for impact, risk, and quality via the TraceDecay code graph. +--- + +# Review diff + +Review the current working-tree diff, or the base ref / PR named in `$ARGUMENTS` if one was given. Read-only: no edits or test runs. + +1. Get changed files — working tree, or `git diff --name-only ...HEAD` (default base `main`). +2. Semantic change summary: working tree / file list → `tracedecay_diff_context` (`files`): modified symbols + dependents + affected tests; ref-to-ref PR → `tracedecay_pr_context` (`base_ref`, `head_ref`). +3. Go deeper only if needed: `tracedecay_impact` (`node_id`) to widen the blast radius on a high-risk changed symbol; `tracedecay_affected` (`files`) only when the test set is not enough. +4. Quality scan of just the changed files → `tracedecay_simplify_scan` (`files`): duplications, dead code, coupling, complexity hotspots. +5. Risk surfacing: `tracedecay_test_risk` on changed paths; `tracedecay_unsafe_patterns` on changed files. + +To verify behavior, hand off to `/tracedecay:test-changes`. + +Output: findings grouped Critical / Warning / Note, the impacted areas, and the test set to run. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/commands/test-changes.md b/claude-plugin/commands/test-changes.md new file mode 100644 index 000000000..adba667fb --- /dev/null +++ b/claude-plugin/commands/test-changes.md @@ -0,0 +1,16 @@ +--- +description: Test current changes by running only affected tests and mapping failures back to source. +--- + +# Test changes + +Interpret `$ARGUMENTS` as explicit changed paths. If absent, use the current working tree. Preview scope read-only first, then run. + +1. Preview affected tests → `tracedecay_diff_context` (`files`) or `tracedecay_affected` (`files`): the test set that can see the change. +2. Run → `tracedecay_run_affected_tests` (`changed_paths`, `max_tests`, `profile`, `timeout_secs`): pass/fail per test, with the source nodes each test covers. Cargo-backed — respect approval/run-mode. +3. On compile/type failure → `tracedecay_diagnose` for captured cargo stderr, or run `tracedecay_diagnostics`. +4. Where the next test goes → `tracedecay_test_risk` (`path`, `limit`): prioritized coverage gaps. + +Coverage is structural (call/use edges): integration tests that reach code indirectly can be missed, so an empty result is strong but not absolute evidence of "untested". + +Output: pass/fail summary, failing-symbol mapping, and suggested missing tests. If any result includes a `tracedecay_metrics:` line, report the savings. diff --git a/claude-plugin/hooks/hooks.json b/claude-plugin/hooks/hooks.json new file mode 100644 index 000000000..2e078c3bb --- /dev/null +++ b/claude-plugin/hooks/hooks.json @@ -0,0 +1,72 @@ +{ + "description": "TraceDecay lifecycle hooks: inject index status and tool-routing steering, keep the graph/session store warm, and redirect explore-agent calls toward tracedecay tools.", + "hooks": { + "PreToolUse": [ + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "args": [ + "hook-pre-tool-use" + ] + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "args": [ + "hook-prompt-submit" + ] + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "args": [ + "hook-stop" + ] + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "args": [ + "hook-claude-session-start" + ] + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write|NotebookEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "__TRACEDECAY_BIN__", + "args": [ + "hook-claude-post-tool-use" + ] + } + ] + } + ] + } +} diff --git a/claude-plugin/skills/assessing-impact/SKILL.md b/claude-plugin/skills/assessing-impact/SKILL.md new file mode 100644 index 000000000..cb3362912 --- /dev/null +++ b/claude-plugin/skills/assessing-impact/SKILL.md @@ -0,0 +1,71 @@ +--- +name: assessing-impact +description: 'Use when estimating blast radius, finding what depends on a symbol or file, choosing or running affected tests, checking whether code is tested, or verifying a change without a full suite. Use before guessing tests, running broad suites, or declaring a change safe.' +--- + +# Assessing impact + +## Blast radius + +1. **Resolve the target → node ID** with `tracedecay_search` / + `tracedecay_find_exact_symbol` / `tracedecay_by_qualified_name` (resolver + ladder: `tracedecay:exploring-code`). +2. **Symbol blast radius → `tracedecay_impact`** (`node_id`, small `max_depth` + first, widen only if the picture is incomplete): all direct + transitive + dependents. +3. **File-level fan-in → `tracedecay_file_dependents`** (every file importing + the changed file). +4. **Already have changed paths → `tracedecay_diff_context`** (`files`): + modified symbols + dependents + affected tests in one call — prefer it + over separate lookups. +5. **Structural fragility (optional):** `tracedecay_coupling` / + `tracedecay_dependency_depth` to see if the target is a high-fan-in hub. + +## Coverage intelligence (read-only) + +1. **Symbol/file → its tests → `tracedecay_test_map`** (`file` or `node_id`): + direct coverage edges; an empty result means no test reaches it through + the indexed graph. +2. **Changed files → affected tests → `tracedecay_affected`** (`files`): + dependency-graph traversal to every test file that can see the change. +3. **Where the next test goes → `tracedecay_test_risk`** (`path?`, `limit?`): + risk = (complexity + 1) × (fan_in + 1) × untested-multiplier — the + prioritized gap list. + +## Running the impacted tests + +1. **Run → `tracedecay_run_affected_tests`** (`changed_paths`, `max_tests`, + `profile`, `timeout_secs`): pass/fail per test, with the source nodes each + test covers. Cargo-only; for non-Rust repos use `tracedecay_diagnostics` + (tsc/pyright) and the project's own test runner. +2. **On compile/type failure → `tracedecay_diagnose`** for captured cargo + stderr, or the `tracedecay:fixing-build-and-type-errors` skill. + +## Guardrails + +- Everything except `tracedecay_run_affected_tests` and + `tracedecay_diagnostics` is read-only and safe to run first to preview + scope. The cargo-backed tools run toolchains (the first `diagnostics` build + can take minutes; forced target dir + `/tmp/tracedecay-target//diagnostics`) — respect Cursor + approval/run-mode and avoid duplicate runs. +- Coverage is structural (call/use edges): integration tests that reach code + indirectly (through a binary, fixture, or IO boundary) can be missed — an + empty `test_map` is strong but not absolute evidence of "untested". +- Start with a shallow `max_depth` and widen only when incomplete. +- For broad changes, use scoped read-only subagents per changed file group or + subsystem; require cited dependents, affected tests, and tool parameters — + the parent agent owns the final blast-radius and test-set synthesis. + +## Handoff + +- Mechanical refactor where impact analysis becomes an edit checklist → + `tracedecay:editing-safely`. Reviewing a whole diff → `tracedecay:reviewing-changes`. + +## Output + +- (a) impacted symbols + files, (b) the test set to run (or the pass/fail + summary with failing-symbol mapping), (c) any hub/coupling risk and ranked + coverage gaps. +- If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/claude-plugin/skills/code-health/SKILL.md b/claude-plugin/skills/code-health/SKILL.md new file mode 100644 index 000000000..c7ae9f8f6 --- /dev/null +++ b/claude-plugin/skills/code-health/SKILL.md @@ -0,0 +1,100 @@ +--- +name: code-health +description: 'Use when producing a code-health scorecard or tech-debt ranking, mapping repo/module architecture and dependency layers, bracketing a refactor with a before/after health delta, or checking project identity, index freshness, config values, TODO markers, or server runtime.' +--- + +# Code health, architecture & status + +Lead with the one composite signal, then drill only into the weak dimensions +and the specific scans the user asked for — don't run every tool by reflex. + +## Quality scorecard + +1. **Composite signal → `tracedecay_health`** (`details: true`, optional + `path`): the 0–10000 score plus the 5-dimension breakdown (acyclicity, + depth, equality, redundancy, modularity) and the `coverage_discipline` + penalty. The weak dimensions choose the drill-downs. +2. **Inequality / god files → `tracedecay_gini`** (`metric`: + `complexity`|`lines`|`fan_in`|`fan_out`|`members`, `scope`, `path?`). +3. **Complexity & size offenders:** `tracedecay_complexity`, + `tracedecay_largest`, `tracedecay_god_class`, `tracedecay_hotspots`. +4. **Structure drill-downs (match the weak dimension):** acyclicity → + `tracedecay_circular` + `tracedecay_recursion`; modularity → + `tracedecay_dsm` (`format`: `stats`|`clusters`|`matrix`) + + `tracedecay_coupling`; depth → `tracedecay_dependency_depth` + + `tracedecay_inheritance_depth`; relationships → `tracedecay_rank` + (`edge_kind` required); kind mix → `tracedecay_distribution`. +5. **Duplication → `tracedecay_redundancy`**; near-duplicate names → + `tracedecay_similar`. **Doc gaps → `tracedecay_doc_coverage`**. **Panic + sites → `tracedecay_unsafe_patterns`**. **Risk-weighted test gaps → + `tracedecay_test_risk`**. **Changed-files-only pass → + `tracedecay_simplify_scan`** (`files`). + +## Architecture map + +1. **Shape & size:** `tracedecay_status` (node/edge/file counts), + `tracedecay_files` + `tracedecay_distribution` (what lives where). +2. **Public surface:** `tracedecay_module_api` per top-level directory. +3. **Dependency structure:** `tracedecay_dsm` (clusters and layering + violations), `tracedecay_coupling` (`fan_in`/`fan_out` hubs), + `tracedecay_circular` (cycles), `tracedecay_dependency_depth` (fragile + long chains). + +## Session health delta + +1. **Before the first edit → `tracedecay_session_start`** (no args): + snapshots current health as the baseline + (`.tracedecay/session_baseline.json`). +2. **After the work → `tracedecay_session_end`**: the per-dimension diff — + what improved, what degraded — and clears the baseline. A dropped + dimension names the follow-up (redundancy fell → `tracedecay_redundancy`; + acyclicity fell → `tracedecay_circular`). +3. Bracket only work where a before/after delta is wanted; a second + `session_start` silently overwrites the baseline. + +## Project & index status + +1. **Active project → `tracedecay_active_project`** (no args): resolved + project root, scope prefix, branch identity, and the resolved active project store + backing this session. Use this before describing where data lives. +2. **Storage status → `tracedecay_storage_status`** (no args): resolved + active project store health, graph DB path, writability, branch-fallback + warnings — instead of probing `.tracedecay` or direct SQLite checks. +3. **Project registry → `tracedecay_project_list` / + `tracedecay_project_search` / `tracedecay_project_context`** when the user + asks about another project or workspace. +4. **Index status → `tracedecay_status`**: node/edge/file counts, DB size, + active branch + fallback warning, tokens saved. +5. **Config lookups → `tracedecay_config`** (`key` required, plus `path` or + `glob`): query TOML/JSON by dotted key — works even before `tracedecay init`. +6. **Outstanding work → `tracedecay_todos`** (`kinds?`, `path?`, `limit?`). +7. **Server triage → `tracedecay_runtime`** (PID, memory, CPU%, DB sizes) when + TraceDecay seems to hog CPU or RAM. **Visual → `tracedecay_dashboard`** + (`action`: `start`|`stop`): hand the URL to the user. + +## Guardrails + +- Discovery/analysis tools are read-only and parallel-safe. + `tracedecay_session_start`/`session_end` write/remove the baseline file and + `tracedecay_dashboard` starts/stops a local server — use them only when + relevant and respect Cursor approval/run-mode. +- `tracedecay_redundancy` is computed lazily and cached; the first call on a + fresh index can be slow — keep `path`/`max_pairs` tight. +- For large audits, use scoped read-only subagents by path, weak health + dimension, or top-level directory; keep `session_start`/`session_end` in + the parent agent. +- This skill reports and prioritizes; it does not edit. Hand fixes to + `tracedecay:editing-safely` / `tracedecay:reviewing-changes`, verification + to `tracedecay:assessing-impact`. Memory recall belongs to + `tracedecay:recalling-project-memory`; past-session recall to + `tracedecay:recalling-session-context`. + +## Output + +- The composite score + weak dimensions with ranked worst offenders and a + prioritized fix list; the layered module map with dependency + hotspots/violations; the per-dimension session delta; or the status + numbers, config values (with file + line), marker list, or runtime snapshot + the user asked for. Pairs with the `docs-canvas` plugin if installed. +- If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/claude-plugin/skills/curating-project-memory/SKILL.md b/claude-plugin/skills/curating-project-memory/SKILL.md new file mode 100644 index 000000000..47f66b636 --- /dev/null +++ b/claude-plugin/skills/curating-project-memory/SKILL.md @@ -0,0 +1,73 @@ +--- +name: curating-project-memory +description: 'Use when reviewing, updating, merging, deleting, pruning, or repairing tracedecay memory facts; handling stale, contradictory, duplicate, or secret-like facts; inspecting memory health; or opening the dashboard curation UI.' +--- + +# Curating project memory + +Destructive curation is a parent-agent responsibility. Use subagents only for scoped inspection or recommendation work, with explicit project selectors and non-overlapping ownership; do not delegate delete/apply/merge/retention actions to subagents. TraceDecay should progressively expose registered-project selectors in its own MCP and CLI surfaces, so this skill documents the workflow rather than being the sole routing mechanism. + +This skill owns memory lifecycle changes. For read-only recall, start with `tracedecay:recalling-project-memory`. For autonomous curation, begin read-only, gather evidence, propose a mutation plan, then write only narrow durable changes. The installed plugin ships this skill as the required operator runbook, so follow the workflow below without depending on external `docs/` files. + +## Workflow + +1. **Resolve scope:** confirm the active project root/store before touching memory. Project-bound profiles use the user-level TraceDecay store scoped to the current project by default. +2. **Start read-mostly:** use TraceDecay MCP context/search first for code/session orientation, then `tracedecay_fact_store` with `action: "get"`, `"contradict"`, `"search"`, `"list"`, `"probe"`, `"related"`, or `"reason"`; note that search/list/probe/related/reason may update retrieval/access metadata. Use `tracedecay_memory_status` only when the user asks for memory counts/health because it may repair vectors/banks. Use `tracedecay_dashboard` (`action: "start"`) only when they want visual curation. +3. **Run native dry-run:** prefer `tracedecay memory curate` or `POST /api/plugins/holographic/curate` with `{"dry_run": true}`. Dry-run is the default and returns `actions`, `hygiene_candidates`, `counts`, `coverage`, `provider`, and `mode`. +4. **Inventory candidates:** group facts into add, update, merge/dedupe, stale, contradiction, secret-like, transient, supersession, and possible hard-delete buckets. Keep fact ids, source/provenance, trust, tags, entities, evidence links, and counterevidence with each candidate. +5. **Research gaps:** use TraceDecay graph/search plus LCM/session/message tools to mine past sessions, raw messages, summary DAGs, branch/PR context, docs, and tests. For multi-step evidence gathering, scoped subagents may research bounded read-only questions only; the parent agent is the sole memory writer and must review raw findings before trusting them. +6. **Propose changes:** summarize durable additions, stale-fact updates, trust/tag/source changes, dedupe merges, and delete candidates. Prefer update/merge over removal when useful provenance should survive. +7. **Apply narrowly:** add/update only facts supported by evidence. Use `POST /api/plugins/holographic/curate/apply` or `tracedecay memory curate --llm-ops --apply` only for reviewed operations. Require explicit approval immediately before every `action: "remove"`, dashboard hard delete, or merge loser removal, showing fact id, content/source summary, reason, and permanent-delete warning. +8. **Verify read-only:** re-run search/list/probe/related/contradict/get as appropriate, inspect apply results/oplog when used, and report final facts changed, skipped, or still needing human judgment. + +## Guardrails + +- `get` and `contradict` are non-destructive recall. Search/list/probe/related/reason are read-mostly but can update access/retrieval counters. Add/update/remove, feedback, memory status repair, and dashboard start/stop mutate state or launch a local process; respect host approval/run-mode. +- Deletion is permanent: there is no archive, soft-delete, restore, or undo path. Prefer update/merge when useful provenance should survive; delete only approved stale, duplicate, wrong, secret-like, or user-requested facts. +- Never store secrets, credentials, API keys, or PII. Do not lower trust merely because a fact is old; cite the newer evidence or contradiction. +- Dashboard curation can apply hard deletes. Use preview/dry-run first when available and surface high-risk delete/merge operations before applying them. `POST /api/plugins/holographic/curate` with `dry_run=false` applies deterministic duplicate deletion; `POST /api/plugins/holographic/curate/apply` applies explicit delete/merge ops. +- Do not let subagents call add/update/remove/feedback tools, apply curation ops, start dashboard mutation flows, or run memory health repair. Ask them for cited evidence, candidate facts, suspected duplicates, and stale/conflicting claims, then perform parent-agent validation before writing. +- Default autonomous grooming output is report-only. If a tool or dashboard action mutates unexpectedly, disclose it and verify state before continuing. +- Hygiene candidates (`secret_like`, `transient`, `supersession`) are review evidence, not deterministic apply operations. +- External LLM plans must use strict JSON `{"ops": [...]}` and pass through the TraceDecay evidence guard; rejected low-confidence or out-of-scope ops must stay skipped. + +## Dry-run report + +Before any mutation, produce a compact report with these sections: + +- `scope`: project root/store, tool/API used, dry-run timestamp, and whether memory health repair or dashboard start/stop was invoked. +- `native_plan`: `mode`, `provider`, `coverage`, `counts`, action count, and hygiene-candidate counts from `tracedecay memory curate` or `POST /api/plugins/holographic/curate`. +- `adds`: candidate durable facts with source spans, category, entities, trust, and duplicate-search result. +- `updates`: fact ids, old/new summary, evidence, confidence, and why update beats add. +- `merges`: winner/loser ids, similarity evidence, retained provenance, optional `merged_content`, and why separate facts are redundant. +- `deletes`: fact ids, content/source summary, permanent-delete reason, risk, surviving fact if any, and explicit approval status. +- `skipped`: rejected transient, secret-like, unsupported, stale-but-uncertain, or duplicate candidates. +- `verification_plan`: exact read-only checks to run after apply. + +Map native curation fields into those sections as follows: + +- `actions`: deterministic similarity-dedup delete proposals; list them under `deletes` unless operator review converts them into a safer `merge`. +- `hygiene_candidates`: review-only evidence; list confirmed candidates under `deletes`, `updates`, or `merges`, and unconfirmed candidates under `skipped`. +- `llm_review`: bounded external-review request; use `clusters`, `hygiene_candidates`, `allowed_fact_ids`, and `min_confidence` as evidence constraints. +- `llm_apply`: validated external ops and rejected ops; list valid dry-run ops under `merges`/`deletes`, and rejected ops under `skipped`. + +## Memorize a subject + +Use only when the user explicitly asks to memorize or remember a subject, code area, branch, PR, or decision set. + +1. **Research read-only:** use TraceDecay graph/search, LCM/session/message tools, docs, existing fact searches, and relevant branch/PR context. Scoped research agents may gather evidence but the parent agent is the only memory writer. +2. **Filter:** keep durable, scoped facts with citations. Reject secrets, credentials, PII, large code blobs, transient branch state, unsupported claims, and uncited speculation. +3. **Calibrate trust:** use `0.85+` for independently verified decisions/observations, about `0.7` for ordinary well-sourced facts, and about `0.5` for plausible but uncertain facts. Do not ask for approval solely because trust is low. +4. **Dedupe before writing:** search `tracedecay_fact_store` with the subject plus candidate, matching category, `limit: 10`, and `min_trust: 0.5`; skip near-duplicates and ask before replacing contradictory facts. +5. **Store accepted facts:** propose the candidate set, then call `tracedecay_fact_store` `action: "add"` with content, category, source, tags, entities, trust, and metadata containing subject/confidence/citations. +6. **Read add diffs:** act on `near_duplicate`, `possible_conflict`, and `rejected_secret_like`; never rephrase a rejected secret to bypass filtering. + +## Handoff + +- Need raw session messages or summary-DAG replay -> `tracedecay:recalling-session-context`. +- Need only index/server status, not memory mutation -> `tracedecay:code-health`. + +## Output + +- Facts searched/changed, confirmations requested, final verification result, and any skipped high-risk candidates. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/editing-safely/SKILL.md b/claude-plugin/skills/editing-safely/SKILL.md new file mode 100644 index 000000000..f33680122 --- /dev/null +++ b/claude-plugin/skills/editing-safely/SKILL.md @@ -0,0 +1,97 @@ +--- +name: editing-safely +description: 'Use when editing or writing source: before adding any new helper (duplicate probe), for anchored/structural edits, mechanical refactors like renames or signature/field changes, or porting code between modules or languages. Missed call sites and duplicate helpers start here.' +--- + +# Editing safely + +Three phases: probe for existing code before writing, recon every affected +site before the first edit, then apply with anchored primitives that re-index +the graph in place. + +## Pre-write duplicate probe (always cheap, run before any new helper) + +1. **By name/keyword → `tracedecay_search`**; **fuzzy twins → + `tracedecay_similar`** (`parse_cfg` vs `config_parse`). +2. **By shape → `tracedecay_signature_search`** (return type / param + substring / async): "any fn taking `&Path` and returning `Result`?" +3. **By concept → `tracedecay_context`** (one call, `task` = what the helper + should do) when naming guesses fail. +4. **Deeper audit → `tracedecay_redundancy`** (`min_lines?`, + `similarity_threshold?`, `path?`, `max_pairs?`): buckets `definite` / + `likely` / `naming_only`. Trust `definite`; verify `likely`; treat + `naming_only` as a hint. Lazily computed and cached — keep `path` / + `max_pairs` tight on large repos. +5. **Found one?** Inspect with `tracedecay_body` and reuse or extend it. When + consolidating, keep the better-tested copy (check `tracedecay_test_map` on + both). + +## Refactor recon (read-only, before the first edit) + +1. **Resolve the target → node ID** (`tracedecay:exploring-code` ladder). +2. **Recon by refactor type:** + - Rename / move → `tracedecay_rename_preview` (`node_id`): every edge + where it appears as source or target (preview only — nothing renames). + - Signature change → `tracedecay_callers` (every call site must adapt) + plus `tracedecay_signature_search` for shape-twins. + - Field rename/remove/new invariant → `tracedecay_field_sites` + (`Struct::field`): write sites are the blast radius. + - Newly required field → `tracedecay_constructors`: every struct-literal + site with missing-field lists. + - Post-rename name collisions → `tracedecay_similar`. +3. **Risk check → `tracedecay_impact`** (shallow `max_depth` first) when the + target is widely depended on. The recon output is the edit checklist. + +## Apply with anchored primitives + +1. **Unique string swap → `tracedecay_str_replace`** (`path`, `old_str`, + `new_str`): fails unless `old_str` matches exactly once — the safest + default; use instead of sed/awk. +2. **Several swaps, all-or-nothing → `tracedecay_multi_str_replace`** + (`path`, `replacements` as `[[old, new], …]`): every pair must match + exactly once or the whole edit aborts. +3. **Insert at an anchor → `tracedecay_insert_at`** (`path`, `anchor` = + unique string or 1-indexed line, `content`, `before?`); **around a symbol + → `tracedecay_insert_at_symbol`** (`symbol`, `content`, `position`). +4. **Rewrite a whole symbol → `tracedecay_replace_symbol`** (`symbol`, + `new_source` including the declaration line). Refused on unresolved + ambiguity — disambiguate rather than forcing. +5. **Structural pattern rewrite → `tracedecay_ast_grep_rewrite`** (`path`, + `pattern`, `rewrite`, ast-grep SGPattern syntax): rewrite every match of a + syntactic pattern in one file (e.g. `foo($A)` → `bar(foo($A))`); repeat + per file for multi-file rewrites. + +## Porting code + +1. **Baseline → `tracedecay_port_status`** (`source_dir`, `target_dir`, + `kinds`); **order → `tracedecay_port_order`**: topological sort — port + leaves first, dependents after; never port a symbol before its + dependencies. +2. Per symbol: pull source with `tracedecay_body`, map dependencies with + `tracedecay_callees` / `tracedecay_callers`, confirm the contract with + `tracedecay_signature`, apply with the primitives above. +3. After each batch: re-run `tracedecay_port_status`; typecheck with + `tracedecay_diagnostics`. Cross-branch parity → `tracedecay_branch_diff` / + `tracedecay_changelog`. + +## Guardrails + +- Probe and recon steps are read-only; the edit primitives mutate files and + trigger an in-place re-index — invoke them only when an edit is relevant + and respect Cursor approval/run-mode. +- Recon sees only the indexed scope: `pub` items may have external users, and + macro-generated or string-keyed references won't appear in the graph — grep + once for the bare name before declaring the checklist complete. +- `tracedecay_ast_grep_rewrite` shells out to the external `ast-grep` binary + and is only registered when it is on PATH; if absent, tell the user to + install ast-grep rather than approximating the rewrite with regex. +- **Verify after editing:** typecheck via + `tracedecay:fixing-build-and-type-errors`, then run the affected tests via + `tracedecay:assessing-impact`. + +## Output + +- The recon checklist (sites grouped by file), the files/symbols changed, the + helper reused (or its confirmed absence), and the verification result. +- If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/claude-plugin/skills/exploring-code/SKILL.md b/claude-plugin/skills/exploring-code/SKILL.md new file mode 100644 index 000000000..fa27c29e6 --- /dev/null +++ b/claude-plugin/skills/exploring-code/SKILL.md @@ -0,0 +1,99 @@ +--- +name: exploring-code +description: 'Use when searching the codebase, locating a symbol, exploring how a feature works, reading or opening any source file, answering type/trait questions, or checking code on another git branch — the graph answers before Grep/Glob/Read in an indexed project.' +--- + +# Exploring code + +Use the TraceDecay code graph before Grep/Glob/file reads. Pick the cheapest +tool that answers the question and stop. If the task says "trace", "find +callers", or "what depends on X", switch to `tracedecay:tracing-functions` +after resolving the symbol. + +## Finding it + +1. **Conceptual / "how does X work" / names unknown → `tracedecay_context`.** + `task` = the question; add `keywords` to expand synonyms (auth → + `["login","session","token"]`). Set `include_code: true` only when you need + snippets; `mode: "plan"` when scoping an implementation. Pass prior + `seen_node_ids` via `exclude_node_ids` to dedupe across calls. +2. **Exact name known → `tracedecay_find_exact_symbol`** (cheapest probe) or + **`tracedecay_body`** (name → full source in one shot; ranks matches when + ambiguous). +3. **Ranked discovery by name/keyword → `tracedecay_search`.** +4. **Half-remembered name → `tracedecay_similar`** (fuzzy/substring); + **stable cross-run identity → `tracedecay_by_qualified_name`**. +5. **By shape, not name → `tracedecay_signature_search`** (return type / + param substring / `async` / path), e.g. "every fn returning `Result<_, MyError>`". + +## Reading it cheaply + +Climb this ladder and stop at the first rung that answers the question: + +1. **Orient in a file → `tracedecay_outline`** (`path`, optional `kinds`): + every top-level symbol with line numbers, no bodies. +2. **API surface only → `tracedecay_signature`** (qualified name); bulk + per-file variant: `tracedecay_read` with `mode: "signatures"`. +3. **One symbol's source → `tracedecay_body`** or `tracedecay_node` (by node + ID, with metadata) — never open a whole file for one function. +4. **A specific region → `tracedecay_read`** (`mode: "lines"`, e.g. `"120-180"`). +5. **Whole file (last resort) → `tracedecay_read`** (`mode: "full"`): + cross-session cached — unchanged files return a tiny `unchanged: true` + stub, so prefer it over the plain Read tool. +6. **Module/directory surface → `tracedecay_module_api`** (all `pub` symbols); + enumerate files with `tracedecay_files` (`path?`, `pattern?`). + +## Types & traits + +1. **Who implements a trait / every body of a method → `tracedecay_implementations`** + (`trait` form: implementing types + impl-block methods; `method` form: + every function named X grouped by enclosing type, with bodies). +2. **Impl blocks by trait, type, or both → `tracedecay_impls`** (avoid the + no-filter form — it returns every impl in the graph). +3. **Recursive hierarchy → `tracedecay_type_hierarchy`**; deepest + extends-chains → `tracedecay_inheritance_depth`. +4. **"Where does this method come from?" → `tracedecay_derives`**: the + `#[derive(...)]` macros on a type and the methods each synthesizes — check + before concluding `.clone()` / `.eq()` has no definition. +5. **Construction sites → `tracedecay_constructors`** (every struct-literal + site with present and missing fields); **field usage → + `tracedecay_field_sites`** (`field` or `Struct::field`): every read/write + site with file, line, and enclosing symbol. + +## Other branches + +1. **What's tracked → `tracedecay_branch_list`**; **search another branch → + `tracedecay_branch_search`** (`branch`, `query`); **compare branches → + `tracedecay_branch_diff`** (`base?`, `head?`, `file?`, `kind?`) — all + read-only, never touching your checkout. +2. Branch tracking is opt-in per branch (`tracedecay branch add ` in + the terminal; the hooks auto-track branches you visit). A branch-fallback + `WARNING` prefix means results came from the nearest tracked ancestor — + surface that to the user. + +## Guardrails + +- Everything here is read-only and parallel-safe. +- Only fall back to Grep/Glob/Read for non-indexed content (string literals, + comments, prose, config bodies — or `tracedecay_config` for TOML/JSON keys) + or after TraceDecay pinpoints exact files. If results look empty or stale, + check `tracedecay_status` before falling back to raw reads. +- Prefer one well-formed `tracedecay_context` call over many narrow searches. +- `tracedecay_constructors` is best-effort for Rust (ignores `match` arms); + `tracedecay_field_sites` pattern-matches `.`, so prefer the + `Struct::field` form to narrow. +- For several independent questions, use scoped read-only subagents with one + bounded target each and a strict no-writes instruction; require cited + file/symbol ids and tool names, and synthesize in the parent agent. +- If a response is truncated with a `handle`, narrow the query first; call + `tracedecay_retrieve` with the `handle` only when the omitted details are + needed. +- About to write a new helper because the search came up empty? Run the + `tracedecay:editing-safely` pre-write duplicate probe first. + +## Output + +- The file + symbol the user needs (path, qualified name, signature), the + outline/snippet that answers the question, and how you found it. +- If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/claude-plugin/skills/fixing-build-and-type-errors/SKILL.md b/claude-plugin/skills/fixing-build-and-type-errors/SKILL.md new file mode 100644 index 000000000..0ae8a27ba --- /dev/null +++ b/claude-plugin/skills/fixing-build-and-type-errors/SKILL.md @@ -0,0 +1,26 @@ +--- +name: fixing-build-and-type-errors +description: 'Use when diagnosing or fixing compiler/type-checker errors, cargo/clippy output, tsc/pyright failures, mapped diagnostics, or build failures that need graph-anchored context.' +--- + +# Fixing build & type errors + +Use this when build or type diagnostics are relevant to the task. Prefer pasted output when available; respect Cursor approval/run-mode before running fresh toolchain checks. + +## Workflow + +1. **Already have raw output? → `tracedecay_diagnose`** (`cargo_output` required, `severity?`: `error`|`warning`|`all`, `include_callers?`, `max_diagnostics?`): paste full `cargo check`/`clippy`/`rustc` stderr; each diagnostic maps to the smallest containing node with up to 5 callers pre-attached. No toolchain run — cheap and safe. +2. **Need fresh diagnostics → `tracedecay_diagnostics`** (`scope`: `workspace` (default) | `package` (needs `name`) | `file` (needs `path`)): structured errors/warnings, each mapped to the enclosing graph node. Forces target dir `/tmp/tracedecay-target//diagnostics`; the **first** run on a fresh tree can take minutes, later calls are sub-second. +3. **Understand the failing code:** resolve/inspect with the `tracedecay:exploring-code` ladder; widen blast radius with `tracedecay_impact` if a fix is risky. +4. **Apply the fix → `tracedecay:editing-safely`** (or your normal edit tools). +5. **Re-check** with the cheapest applicable diagnostic path, then verify behavior via `tracedecay:assessing-impact`. + +## Guardrails + +- `tracedecay_diagnostics` runs `cargo`/`tsc`/`pyright` and is the only heavyweight call here; `tracedecay_diagnose` only parses text you provide — prefer it when you already captured the output. +- `tracedecay_diagnostics` is multi-language (cargo/tsc/pyright); `tracedecay_diagnose` is Rust/cargo-specific. + +## Output + +- The grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/inspecting-managed-skills/SKILL.md b/claude-plugin/skills/inspecting-managed-skills/SKILL.md new file mode 100644 index 000000000..e9cff032e --- /dev/null +++ b/claude-plugin/skills/inspecting-managed-skills/SKILL.md @@ -0,0 +1,32 @@ +--- +name: inspecting-managed-skills +description: 'Use when listing or reading agent-managed automation skills, viewing automation run artifacts, or inspecting Hermes-owned profile skills and pending skill approvals without mutating them.' +--- + +# Inspecting managed skills and automation output + +The daemon automation loop (skill writer, memory curator, session reflector) drafts managed skills and records durable run artifacts. This skill is the read-only window into that state; every lifecycle change (approve, disable, archive, install) goes through the `tracedecay automation` CLI or the dashboard instead. + +## Workflow + +1. **List managed skills → `tracedecay_skill_list`** (`state?`: filter by lifecycle state, `include_body?`): metadata, lifecycle state, usage summary, and stale/archive/improvement evidence for every agent-managed skill in the active profile. Start here to see what automation has produced. +2. **Read one skill → `tracedecay_skill_view`** (`id` required, `include_support_files?`): full metadata, body markdown, usage summary, and support files for a single managed skill. Use before recommending approval, edits, or archival. +3. **Read a run artifact → `tracedecay_automation_run_artifact_view`** (`run_id`, `kind`: e.g. `traces`, `feedback`, `generated_evals`, `validation_gate`, `optimizer_diagnosis`, `codex_handoff`): the hash-verified JSON payload of one durable automation run artifact. Find run ids via `tracedecay automation runs list` when needed. +4. **Hermes-owned profile skills → `tracedecay_hermes_skill_bridge`** (`hermes_home` absolute path required, `include_skill_bodies?`, `include_pending_payloads?`): skill summaries, pending approval records, usage telemetry, and archive counts from a Hermes profile. Hermes owns that lifecycle — report state, never promise to mutate it. + +## Guardrails + +- All four tools are read-only; none of them approve, edit, or delete anything. For lifecycle changes hand the user the matching CLI commands: `tracedecay automation skills approve|disable|archive|restore ` and `tracedecay automation skills install --target --output `. +- Managed skills are distinct from this bundled skill set: they live in the TraceDecay profile store, not in the plugin. Do not edit bundled plugin skills based on managed-skill evidence. +- `tracedecay_hermes_skill_bridge` requires an absolute `hermes_home`; never guess the path — ask or derive it from `tracedecay doctor` output. + +## Handoff + +- Running or configuring the automation jobs themselves → `tracedecay automation run` / `tracedecay automation config` (CLI). +- Reviewing session-reflection fact proposals → `tracedecay automation facts list|view|apply|reject` (CLI). +- Memory fact curation → `tracedecay:curating-project-memory`. + +## Output + +- The requested skill list, skill body, artifact payload, or Hermes bridge report, plus the exact CLI command for any lifecycle action the user should take next. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/managing-session-context/SKILL.md b/claude-plugin/skills/managing-session-context/SKILL.md new file mode 100644 index 000000000..7553e2dd0 --- /dev/null +++ b/claude-plugin/skills/managing-session-context/SKILL.md @@ -0,0 +1,76 @@ +--- +name: managing-session-context +description: 'Use when driving the LCM compression lifecycle for a host — preflight, compression, session-boundary reporting, or diagnosing/repairing the LCM store. For past-session recall see recalling-session-context.' +--- + +# Managing session context + +This skill owns the **LCM compression and maintenance lifecycle** — the write +and health side of the session store. It is the counterpart to +`tracedecay:recalling-session-context`, which owns retrieval (grep, replay, +summary-DAG expansion). These lifecycle tools are **host-agent integration +tools**: invoke them when the host is managing its own context window or when +the user explicitly asks to compress, repair, or inspect the LCM store — not +casually during recall. + +## Lifecycle tools + +All take `--provider` and (except doctor/status) `--session-id`. All default to +`storage_scope: "project_local"`; pass `hermes_profile` with an absolute +`hermes_home` only when the user targets a Hermes profile store. + +1. **Preflight → `tracedecay_lcm_preflight`** (`provider`, `session-id`, plus + token knobs like `current-tokens`, `threshold-tokens`, `context-length`, + `reserve-tokens-floor`, `max-assembly-tokens`, `fresh-tail-count`): decide + *whether* compression should run before doing it. Read-only planning call. +2. **Compress → `tracedecay_lcm_compress`** (same core args plus + `focus-topic`, `summarizer`, `expected-current-frontier-store-id` as an + optimistic guard): advance the compression lifecycle. **Mutates** the store. + Use `expected-current-frontier-store-id` to no-op safely if the frontier + moved under you. +3. **Session boundary → `tracedecay_lcm_session_boundary`** (`provider`, + `session-id`, `old-session-id`, `bound-session-id`, `boundary-reason`): + report that the host crossed a compression boundary. A mismatch between the + bound and old session skips carry-over and starts a short cooldown. +4. **Doctor → `tracedecay_lcm_doctor`** (`provider`, `mode`: + `diagnose`|`repair`|`retention`|`clean`|`gc`, `apply`, optional + `session-id`): bounded diagnostics and safe repairs. `diagnose`/`retention` + are read-only; `repair`/`clean`/`gc` **mutate only with `apply: true`** and + are further gated by safety flags/env for clean and gc. +5. **Status → `tracedecay_lcm_status`** (optional `provider`, `session-id`, + `deep`): schema/message/summary/payload counts, token estimates, summary + depth distribution + compression ratio, payload byte totals, and GC status. + Read-only; `deep: true` adds an on-disk integrity sweep. + +## Typical flow + +Preflight → (if it requests compression) compress → status to confirm the ratio +moved. On a real host session change, call session_boundary. If counts look +wrong (missing sessions, stale FTS, orphaned payloads) run doctor +`mode: "diagnose"` first, review, then repair/clean/gc with `apply: true` only +on explicit user intent. + +## Guardrails + +- `preflight`, `status`, and doctor `diagnose`/`retention` are read-only. + `compress`, `session_boundary`, and doctor `repair`/`clean`/`gc` + `apply` + **mutate** durable session state — run them only with clear lifecycle or user + intent, never speculatively. +- `provider` is required and `all` is rejected for these lifecycle tools; target + one provider at a time. +- Do not let subagents drive compression, boundaries, or repair; those are + parent-agent/host responsibilities. +- Keep token knobs conservative; over-aggressive compression loses replay + fidelity that `tracedecay:recalling-session-context` depends on. + +## Handoff + +- Retrieving past-session content (grep, replay, summary-DAG expansion) → `tracedecay:recalling-session-context`. +- CLI fallback when MCP transport fails → `tracedecay:using-the-cli`. + +## Output + +- The lifecycle action taken (preflight decision, compression result, boundary + outcome, or store counts), whether it was read-only or mutating, and the + resulting compression ratio / health signals. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/recalling-project-memory/SKILL.md b/claude-plugin/skills/recalling-project-memory/SKILL.md new file mode 100644 index 000000000..7b5379d7e --- /dev/null +++ b/claude-plugin/skills/recalling-project-memory/SKILL.md @@ -0,0 +1,34 @@ +--- +name: recalling-project-memory +description: 'Use when recalling prior decisions, durable facts, user/project preferences, or past project context before answering or planning; use curating-project-memory for updating or deleting stored facts.' +--- + +# Recalling project memory + +Prefer TraceDecay-native registered-project selectors whenever a recall spans or targets a project other than the active checkout. Codex skill guidance may describe how to choose selectors, but selector support should live progressively in TraceDecay MCP and CLI tools themselves. + + +Recall memory **before** reaching for external or web search — prior sessions often already answered the question, and a memory hit is cheaper and project-specific. + +## Workflow + +1. **Past conversations → `tracedecay_message_search`** (`query`, optional `provider`, `limit`) over ingested Cursor/Codex/agent transcripts (active project FTS index). +2. **Durable facts → `tracedecay_fact_store`** with `action: "search"` (or `"probe"` / `"reason"`), plus `query` and `min_trust`. +3. **If the user asks to inspect or repair memory health → `tracedecay_memory_status`** (repairs derived vectors/banks; returns fact/entity counts + trust distribution). +4. **If the user rates a recalled fact → `tracedecay_fact_feedback`** (`helpful` / `unhelpful`) to tune its trust score. +5. **Persist a new durable decision → `tracedecay_fact_store`** `action: "add"` (`content`, `category`, `tags`, `trust`) proactively whenever a durable decision, user preference, correction, or pitfall surfaces — do not wait for the user to ask. The add path already rejects secrets and reports near-duplicates/conflicts. + +## Guardrails + +- `tracedecay_message_search` and `fact_store` searches are read-only. `fact_feedback` and `memory_status` mutate memory state; use them for explicit user ratings or health checks. +- Do NOT capture: secrets/credentials, transient errors, environment-specific failures, one-off narratives, task progress, or soon-stale session outcomes — recover those from transcripts instead. + +## Handoff + +- For raw conversation recall beyond FTS — scoped/role/time-filtered grep, lossless session replay, or summary-DAG drill-down — use `tracedecay:recalling-session-context`. +- For stale, contradictory, duplicate, or user-requested fact updates/deletes — use `tracedecay:curating-project-memory`. + +## Output + +- The relevant prior context/decisions found, with source. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/recalling-session-context/SKILL.md b/claude-plugin/skills/recalling-session-context/SKILL.md new file mode 100644 index 000000000..43236ddb8 --- /dev/null +++ b/claude-plugin/skills/recalling-session-context/SKILL.md @@ -0,0 +1,32 @@ +--- +name: recalling-session-context +description: 'Use when retrieving what happened in past agent sessions: full-text transcript recall, scoped/time-filtered grep, lossless session replay, summary-DAG drill-down, or compaction recovery.' +--- + +# Recalling session context + +Climb this ladder cheapest-first; stop as soon as the question is answered. For durable *decisions and facts* (rather than raw conversation), start with `tracedecay:recalling-project-memory` instead. + +## Retrieval ladder + +1. **Fast full-text recall → `tracedecay_message_search`** (`query`, optional `provider`, `scope`: `all`|`parents_only`|`subagents_only`, `limit`): FTS over ingested transcripts; returns messages with their session ids — the entry point for everything below. +2. **Scoped/filtered grep → `tracedecay_lcm_grep`** (`query`, `scope`: `current`|`session`|`all` — `current`/`session` require `session_id`; `role`, `source`, `start_time`/`end_time`, `sort`: `recency`|`relevance`|`hybrid`): bounded raw-message snippets plus summary text when FTS recall needs role/time/session precision. +3. **Lossless replay → `tracedecay_lcm_load_session`** (`session_id`, `after_store_id` + `limit` for stable pagination, `roles`, `content_offset`/`content_limit`): ordered raw messages of one session; page with `next_cursor` instead of asking for everything at once. +4. **Summary-DAG drill-down:** `tracedecay_lcm_describe` (`session_id`) for the session's raw/summary shape; `tracedecay_lcm_expand` (`target.kind`: `raw_message`|`summary_node`|`external_payload`) to open one node, paging sources via `source_offset`/`source_limit`; `tracedecay_lcm_expand_query` (`query`) to assemble bounded retrieval context for a prompt in one call. +5. **Store inspection → `tracedecay_lcm_status`** (counts, token estimates, DAG depth/compression ratio) when you need to know what the store contains before searching it. + +## Guardrails + +- Steps 1–5 are read-only. `tracedecay_lcm_compress`, `tracedecay_lcm_preflight`, and `tracedecay_lcm_session_boundary` are **lifecycle-integration tools for host agents** — never invoke them casually during recall. +- For multi-step recall, dispatch scoped read-only subagents by session id, time window, provider, role, or query variant. Subagents must not call lifecycle or repair tools; the parent agent validates cited messages/summaries and produces the final timeline. +- If the LCM store itself looks wrong (missing sessions, broken FTS, stale counts) → `tracedecay_lcm_doctor` (`mode: "diagnose"` first; `repair`/`clean` mutate and need explicit user intent). +- All LCM tools default to `storage_scope: "project_local"`; only pass `hermes_profile` (with an absolute `hermes_home`) when the user asks about a Hermes profile store. + +## Handoff + +- Durable decisions/facts and persisting new ones → `tracedecay:recalling-project-memory`. + +## Output + +- The recalled messages/summaries with session ids and timestamps, and which rung answered the question. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/retrieving-cached-context/SKILL.md b/claude-plugin/skills/retrieving-cached-context/SKILL.md new file mode 100644 index 000000000..ac20655ce --- /dev/null +++ b/claude-plugin/skills/retrieving-cached-context/SKILL.md @@ -0,0 +1,60 @@ +--- +name: retrieving-cached-context +description: 'Use when a tracedecay response was truncated with a handle and the missing detail is needed — dereference the cached original with tracedecay_retrieve instead of re-running, or expand one LCM node.' +--- + +# Retrieving cached context + +TraceDecay truncates large tool responses and emits a **handle** envelope +instead of the full body. The original text is cached in the active-project +store; you dereference it with `tracedecay_retrieve` rather than re-running the +source tool. This skill covers that handle/caching mechanic and the related +single-node expansion via `tracedecay_lcm_expand`. + +## When to retrieve vs re-run + +- A prior response ended with a `handle` (e.g. `rh_…`) and the missing details + are actually needed to answer the user → **retrieve the handle**. Do not + re-run the broad query, guess, or read a file again. +- You do NOT need the truncated tail → leave it; retrieval costs tokens. +- The result was truncated because the query was too broad → also consider + narrowing the original query next time (see `tracedecay:using-tracedecay`). + +## Tools + +1. **Dereference a handle → `tracedecay_retrieve`** (`handle` required, copied + exactly from the truncated envelope). It returns the **exact cached original + text** — it does not re-run the source tool or re-read a file/session/node. + Handles are scoped to the active project store, expire automatically, and + never reference remote storage. If the truncated response used a + `project-id`/`project-path` selector, pass the same selector to `retrieve`. +2. **Expand one LCM node → `tracedecay_lcm_expand`** (`provider`, `session-id`, + `target` with `kind`: `raw_message`|`summary_node`|`external_payload`): + opens a single session node through the bounded LCM query API. Page a summary + node's sources with `source-offset`/`source-limit`, and page long content + with `content-offset`/`content-limit`. If a returned source has + `content_truncated: true`, continue via `target.kind: "raw_message"` for that + source's `store_id` and `content_offset`. + +## Guardrails + +- Both tools are **read-only**; they surface already-cached content and never + mutate state. +- Retrieve only what you need — handles and node expansion are bounded on + purpose; do not dump the full cached body when a slice answers the question. +- Handles expire; if `retrieve` reports an expired/unknown handle, re-run the + original tool with a narrower query rather than retrying the stale handle. +- Handles are local and project-scoped — never treat them as durable + references to store or reuse across sessions. + +## Handoff + +- Finding which session node to expand (grep, replay, summary-DAG shape) → `tracedecay:recalling-session-context`. +- Driving compression that produces those summary nodes → `tracedecay:managing-session-context`. +- General "narrow the query instead of re-running" guidance → `tracedecay:using-tracedecay`. + +## Output + +- The retrieved cached text or expanded node content, and a note that it came + from a handle/cache rather than a fresh query. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/retrieving-project-memory/SKILL.md b/claude-plugin/skills/retrieving-project-memory/SKILL.md new file mode 100644 index 000000000..18da30278 --- /dev/null +++ b/claude-plugin/skills/retrieving-project-memory/SKILL.md @@ -0,0 +1,69 @@ +--- +name: retrieving-project-memory +description: 'Use when querying or reasoning over stored tracedecay memory facts — searching, probing by entity, multi-fact reasoning, or fetching a fact with trust history. For recall framing see recalling-project-memory.' +--- + +# Retrieving project memory + +This skill owns the **read/reason mechanics** of the holographic fact store: +the exact `tracedecay_fact_store` retrieval actions plus +`tracedecay_memory_status`. It is the mechanical counterpart to +`tracedecay:recalling-project-memory` (which frames memory recall around a task +or decision and starts from transcripts). When the question is "what does the +fact store know about X and how do the facts relate," start here. + +## Retrieval actions (`tracedecay_fact_store`) + +All read-mostly; they may update access/retrieval metadata but do not add or +delete facts. Read-only project selectors (`project-id` / `project-path`) are +supported for these actions. + +1. **search** (`query`, optional `category`, `limit` default 20 / max 200, + `min_trust`) — phase-vector similarity search; the default entry point for + "find facts about X." +2. **probe** (`entity` / `query`) — probe memory around a single named entity. +3. **related** (`entity`) — facts connected to an entity via stored relations. +4. **reason** (`query`, `entities`) — assemble and reason over multiple facts + for a query, following entity relations rather than returning a flat list. +5. **get** (`fact-id`) — the full fact plus its `trust_history`, so you can + answer *why* a trust score is what it is. +6. **list** (optional `category`, `min_trust`, `limit`) — enumerate stored + facts for review. +7. **contradict** (`threshold`) — scan for contradictory facts; non-destructive. + +## Memory health + +- **`tracedecay_memory_status`** — fact/entity counts, trust distribution, + below-threshold and missing-vector signals, capacity-per-bank, and repair + stats. Note it **repairs** derived vectors/banks as a side effect, so call it + when the user asks for memory counts/health, not on every recall. + +## How to query and reason + +- Prefer `search`/`probe` to locate candidates, then `reason` (or `related`) + when the answer spans several linked facts. +- Use `min_trust` to filter out low-confidence facts; use `get` on a specific + `fact-id` when the user challenges a fact or asks why its trust changed. +- Keep retrieval bounded and token-aware: set `limit` deliberately and narrow + `query`/`category` rather than pulling the whole store with a broad `list`. + +## Guardrails + +- search/probe/related/reason/get/list/contradict are read-only recall (they + may touch access counters); they never mutate fact content. +- `tracedecay_memory_status` mutates derived state (vector/bank repair) — treat + it as a health action, not a passive read. +- Recall memory before external or web search — a prior session likely already + answered the question, cheaper and project-specific. + +## Handoff + +- Task/decision recall that should start from transcripts → `tracedecay:recalling-project-memory`. +- Persisting a new durable fact → `tracedecay:storing-project-memory`. +- Fixing stale/contradictory/duplicate facts → `tracedecay:curating-project-memory`. + +## Output + +- The facts found/reasoned over with their ids, trust, and source, plus which + action answered the question. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/reviewing-changes/SKILL.md b/claude-plugin/skills/reviewing-changes/SKILL.md new file mode 100644 index 000000000..63ac8c564 --- /dev/null +++ b/claude-plugin/skills/reviewing-changes/SKILL.md @@ -0,0 +1,84 @@ +--- +name: reviewing-changes +description: 'Use when reviewing a PR, branch, or working-tree diff, auditing ship-blocking risk (panic/unsafe/todo sites, dead code, untested hotspots), cleaning up dead or duplicate code, or drafting commit messages, PR descriptions, and changelogs from semantic diff context.' +--- + +# Reviewing changes + +## Diff review + +1. **Get changed files** — working tree, or `git diff --name-only + ...HEAD` (default base `main`). +2. **Semantic change summary:** working tree / file list → + `tracedecay_diff_context` (`files`): modified symbols + dependents + + affected tests; ref-to-ref PR → `tracedecay_pr_context` (`base_ref`, + `head_ref`). +3. **Go deeper only if needed:** `tracedecay_impact` (`node_id`) to widen the + blast radius on a high-risk changed symbol; `tracedecay_affected` + (`files`) only when step 2's test set is not enough. +4. **Quality scan of just the changed files → `tracedecay_simplify_scan`** + (`files`): duplications, dead code, coupling, complexity hotspots. +5. **Risk surfacing:** `tracedecay_test_risk` on changed paths; + `tracedecay_unsafe_patterns` on changed files. + +## Safety audit (ship-readiness sweep) + +1. **Panic & unsafe sites → `tracedecay_unsafe_patterns`** (`kinds?` to + narrow to `unwrap`/`unsafe`, `exclude_tests: true` for production-only, + `path?`): each hit carries file, line, kind, enclosing symbol, `in_test`. +2. **Unfinished work → `tracedecay_todos`** (`kinds: + ["FIXME","HACK","XXX","UNIMPLEMENTED"]`). +3. **Unreachable code → `tracedecay_dead_code`** (`include_public: true` for + workspace-internal audits) and **`tracedecay_unused_imports`**. +4. **Risky and untested → `tracedecay_test_risk`**: high-complexity, + high-fan-in symbols with weak coverage. +5. **Rank:** production panic/unsafe in hot paths first (cross-check fan-in + with `tracedecay_callers`), then UNIMPLEMENTED/HACK markers, then untested + high-risk symbols, then dead code and imports. + +## Dead-code cleanup + +1. Discover with `tracedecay_dead_code` / `tracedecay_unused_imports` / + `tracedecay_redundancy`; focused pass → `tracedecay_simplify_scan` (`files`). +2. **Before deleting anything → confirm zero real callers** with + `tracedecay_callers` / `tracedecay_rename_preview`. Be conservative with + `pub` items (they may be used outside the indexed scope). Never delete a + symbol whose callers/references are non-empty. +3. Apply edits via `tracedecay:editing-safely`; verify with + `tracedecay_diagnostics` and the affected tests + (`tracedecay:assessing-impact`). Optionally bracket the cleanup with the + session-health delta in `tracedecay:code-health`. + +## Drafting commit & PR text + +1. **Commit message → `tracedecay_commit_context`** (`staged_only`): changed + symbols + file roles + recent commit style. +2. **PR description → `tracedecay_pr_context`** (`base_ref`, `head_ref`): + Summary / Impact / Tests. +3. **Release notes → `tracedecay_changelog`** (`from_ref`, `to_ref`); + sanity-check with `tracedecay_branch_diff`. +4. Drafts text only — leave `git commit` / `gh pr create` to the user or a + dedicated git workflow. + +## Guardrails + +- Review and audit are read-only; do not edit or run tests from those flows — + hand edits to `tracedecay:editing-safely` and verification to + `tracedecay:assessing-impact`. +- `unwrap`/`panic!` inside tests is normal — respect `exclude_tests` / + `in_test` before flagging. An `unsafe { }` block is a review-attention + site, not automatically a finding to "fix". +- For large diffs, use scoped read-only subagents by file group or risk + category; require cited findings — the parent agent owns severity, + deduplication, and the final call. +- If diff context is truncated with a `handle`, narrow by file/symbol first; + call `tracedecay_retrieve` only when the omitted risk detail is needed. + +## Output + +- Findings grouped **Critical / Warning / Note** with file + enclosing + symbol, the impacted areas and test set, removed/consolidated items, or the + drafted commit/PR/changelog text. Pairs with the `pr-review-canvas` plugin + if installed. +- If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/claude-plugin/skills/storing-project-memory/SKILL.md b/claude-plugin/skills/storing-project-memory/SKILL.md new file mode 100644 index 000000000..cda1d454a --- /dev/null +++ b/claude-plugin/skills/storing-project-memory/SKILL.md @@ -0,0 +1,82 @@ +--- +name: storing-project-memory +description: 'Use when writing a durable fact to tracedecay memory — persisting a decision, preference, correction, pitfall, or entity relation, and handling near-duplicate/conflict/secret write diffs. For cleanup see curating-project-memory.' +--- + +# Storing project memory + +This skill owns the **write path** into holographic memory: turning a durable +decision or fact into a stored `tracedecay_fact_store` record. It is the +narrow "add/update/relate" counterpart to `tracedecay:curating-project-memory` +(dedup, merge, delete, whole-subject memorization) and +`tracedecay:retrieving-project-memory` (read/reason). Store proactively +whenever a durable decision, user preference, correction, or pitfall surfaces — +do not wait for the user to ask. + +## When to store vs not + +Store only **durable, project-scoped** facts: + +- Store: architectural/design decisions, user or project preferences, hard-won + corrections, recurring pitfalls, stable conventions, entity relationships. +- Do NOT store: secrets/credentials/API keys/PII, transient errors, + environment-specific failures, task progress, one-off narratives, or anything + that goes stale when the session ends — recover those from transcripts via + `tracedecay:recalling-session-context` instead. + +## Workflow + +1. **Dedupe first (read-only):** search before you write with + `tracedecay_fact_store` `action: "search"` (`query` = subject + candidate, + optional `category`, `limit: 10`, `min_trust: 0.5`). If a near-match exists, + prefer an update over a second add. +2. **Add a fact → `tracedecay_fact_store`** `action: "add"` with `content` + (the durable claim), `category`, `source` (provenance label), `tags`, + `entities` (named entities the fact concerns), `trust`, and optional + `metadata` (subject/confidence/citations). The add result carries a + write-time diff — always read it (see below). +3. **Update an existing fact → `tracedecay_fact_store`** `action: "update"` + with `fact-id` plus the changed `content`/`trust`/`tags`/`category`. Prefer + update when correcting or refining a fact so provenance survives. +4. **Relate entities → `tracedecay_fact_store`** `action: "relate"` (with + `entities` / `entity`) to record a relationship between named entities the + facts concern. +5. **Calibrate trust deliberately** — do not default high. Aim for a spread: + `>=0.85` for independently verified/durable decisions, `~0.7` for ordinary + well-sourced facts, `~0.5` for plausible-but-unsure. Do not lower trust + merely because a fact is old; cite newer evidence instead. + +## Reading the add diff + +Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / +`reason`. Act on it, never ignore it: + +- `near_duplicate` — a very similar fact exists; prefer `action: "update"` on + `closest_fact_id` rather than storing a second copy. +- `possible_conflict` — a negation/state-change cue suggests supersession; + confirm which fact is current before leaving both in place (hand off to + `tracedecay:curating-project-memory` if a merge/delete is needed). +- `rejected_secret_like` — credential-like content was **NOT** stored. Never + rephrase or obfuscate a rejected secret to bypass the filter. + +## Guardrails + +- `search` is read-only; `add`, `update`, and `relate` **mutate** memory state. + `search`/`probe`/`related`/`reason` may update access/retrieval counters. +- Deletion is permanent and lives in `tracedecay:curating-project-memory`, not + here — prefer update/relate over creating removable clutter. +- Never store secrets, credentials, keys, or PII; rely on the built-in + `rejected_secret_like` filter as a backstop, not a first line. +- Only the parent agent should call `add`/`update`/`relate`. Subagents may + gather cited evidence and candidate facts; the parent validates and writes. + +## Handoff + +- Dedup, merge, delete, or memorize a whole subject → `tracedecay:curating-project-memory`. +- Read, probe, or reason over stored facts → `tracedecay:retrieving-project-memory`. + +## Output + +- The fact(s) stored/updated with their ids, the trust assigned, and any + `near_duplicate` / `possible_conflict` / `rejected_secret_like` diff acted on. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/tracedecay-audit-safety/SKILL.md b/claude-plugin/skills/tracedecay-audit-safety/SKILL.md new file mode 100644 index 000000000..bfad20c61 --- /dev/null +++ b/claude-plugin/skills/tracedecay-audit-safety/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-audit-safety +description: 'Use to audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, and untested high-risk symbols.' +--- + +# Audit safety + +Use when asked to audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, or untested high-risk symbols. + +Route this through the `tracedecay:reviewing-changes` skill. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow that skill's read-only workflow and guardrails: report findings, do not fix them here. + +Output: findings grouped Critical / Warning / Note with file + enclosing symbol, and a prioritized follow-up list. diff --git a/claude-plugin/skills/tracedecay-check-health/SKILL.md b/claude-plugin/skills/tracedecay-check-health/SKILL.md new file mode 100644 index 000000000..9fc103ee4 --- /dev/null +++ b/claude-plugin/skills/tracedecay-check-health/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-check-health +description: 'Use to check code health for the repo or a directory, including worst offenders and a prioritized fix list.' +--- + +# Check health + +Use when asked to check code health for the repo or a directory, including worst offenders and a prioritized fix list. + +Route this through the `tracedecay:code-health` skill. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow that skill's read-only workflow and guardrails: lead with `tracedecay_health` and drill only into weak dimensions. + +Output: the composite health score + weak dimensions, the worst offenders (complexity, duplication, god files, doc gaps, panic sites, test-risk), and a prioritized fix list. diff --git a/claude-plugin/skills/tracedecay-clean-dead-code/SKILL.md b/claude-plugin/skills/tracedecay-clean-dead-code/SKILL.md new file mode 100644 index 000000000..19f0ca26b --- /dev/null +++ b/claude-plugin/skills/tracedecay-clean-dead-code/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-clean-dead-code +description: 'Use to find and safely remove dead code, unused imports, and duplication via the TraceDecay code graph.' +--- + +# Clean dead code + +Use when asked to find and safely remove dead code, unused imports, or duplication. + +Route this through the `tracedecay:reviewing-changes` skill to identify candidates, then apply `tracedecay:editing-safely` for any removals. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow those skills' guardrails: confirm zero real callers before deleting anything, be conservative with `pub` items, and verify with a build/test re-check after edits. + +Output: removed/consolidated items and the before/after health or test result. diff --git a/claude-plugin/skills/tracedecay-compare-branches/SKILL.md b/claude-plugin/skills/tracedecay-compare-branches/SKILL.md new file mode 100644 index 000000000..ab7953637 --- /dev/null +++ b/claude-plugin/skills/tracedecay-compare-branches/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-compare-branches +description: 'Use to compare or search another git branch''s code graph without switching your checkout.' +--- + +# Compare branches + +Use when asked to compare or search another git branch's code graph without switching your checkout. + +Route this through the `tracedecay:exploring-code` skill, using the cross-branch tools (`tracedecay_branch_list`, `tracedecay_branch_diff`, `tracedecay_branch_search`). + +- **Target:** a single branch to compare against the current branch, or " " to diff two branches. If none is given, start with `tracedecay_branch_list` and ask what to search or compare. +- Follow that skill's read-only workflow. If a target branch isn't tracked, tell the user to run `tracedecay branch add ` in the terminal first, and surface any branch-fallback warning. + +Output: the cross-branch search hits or the added/removed/changed symbol lists. diff --git a/claude-plugin/skills/tracedecay-curate-memory/SKILL.md b/claude-plugin/skills/tracedecay-curate-memory/SKILL.md new file mode 100644 index 000000000..ea5235b35 --- /dev/null +++ b/claude-plugin/skills/tracedecay-curate-memory/SKILL.md @@ -0,0 +1,16 @@ +--- +name: tracedecay-curate-memory +description: 'Use to curate, update, delete, or inspect TraceDecay memory facts and dashboard curation from an explicit slash workflow.' +--- + +# Curate memory + +Use when asked to curate, update, delete, or inspect TraceDecay memory facts, or to do dashboard curation. + +Route this through the `tracedecay:curating-project-memory` skill. + +- **Scope:** the fact, entity, query, or curation action to review. If none is given, ask what memory scope to curate before mutating anything. +- Start read-only with `tracedecay_fact_store` search/list/probe/reason/contradict or `tracedecay_memory_status`; open `tracedecay_dashboard` only when the user wants visual curation. +- Follow the hard-delete guardrail: confirm fact ids and reasons before `remove` unless the user already gave an exact deletion instruction. + +Output: memory facts inspected or changed, confirmations requested, and the final verification search/list result. diff --git a/claude-plugin/skills/tracedecay-draft-commit/SKILL.md b/claude-plugin/skills/tracedecay-draft-commit/SKILL.md new file mode 100644 index 000000000..fec4ef4b4 --- /dev/null +++ b/claude-plugin/skills/tracedecay-draft-commit/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-draft-commit +description: 'Use to draft a commit message, PR description, or changelog from semantic changes; drafts text only and never commits or pushes.' +--- + +# Draft commit + +Use when asked to draft a commit message, PR description, or changelog from the current semantic changes. + +Route this through the `tracedecay:reviewing-changes` skill to read the diff and its impact. + +- **Target:** the artifact to draft (e.g. "pr", "changelog", a base ref, or "staged"). If none is given, draft a commit message for the working-tree/staged changes. +- Follow that skill's guardrails: this drafts text only — leave `git commit` / `gh pr create` to the user unless they explicitly ask. + +Output: the drafted commit / PR / changelog text. diff --git a/claude-plugin/skills/tracedecay-find-impact/SKILL.md b/claude-plugin/skills/tracedecay-find-impact/SKILL.md new file mode 100644 index 000000000..7d87163dd --- /dev/null +++ b/claude-plugin/skills/tracedecay-find-impact/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-find-impact +description: 'Use to find the blast radius of a change, including impacted symbols, files, and the tests to run.' +--- + +# Find impact + +Use when asked to find the blast radius of a change, including impacted symbols, files, and the tests to run. + +Route this through the `tracedecay:assessing-impact` skill. + +- **Target:** the symbol, file, or change to analyze. If none is given, use the current working-tree diff. +- Follow that skill's read-only workflow and guardrails: shallow `max_depth` first; it identifies impact, it does not run tests. + +Output: impacted symbols + files, the test set to run, and any hub/coupling risk. diff --git a/claude-plugin/skills/tracedecay-fix-build/SKILL.md b/claude-plugin/skills/tracedecay-fix-build/SKILL.md new file mode 100644 index 000000000..f0be4d26f --- /dev/null +++ b/claude-plugin/skills/tracedecay-fix-build/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-fix-build +description: 'Use to fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing.' +--- + +# Fix build + +Use when asked to fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing. + +Route this through the `tracedecay:fixing-build-and-type-errors` skill. + +- **Input:** if the user pasted `cargo`/`clippy` output, route it to `tracedecay_diagnose`; otherwise run `tracedecay_diagnostics` (scoped to a directory if one is named). +- Follow that skill's guardrails: prefer pasted output when available; `tracedecay_diagnostics` runs the toolchain, so confirm before running long checks. + +Output: grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. diff --git a/claude-plugin/skills/tracedecay-map-architecture/SKILL.md b/claude-plugin/skills/tracedecay-map-architecture/SKILL.md new file mode 100644 index 000000000..7b659dd4a --- /dev/null +++ b/claude-plugin/skills/tracedecay-map-architecture/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-map-architecture +description: 'Use to map repo or directory architecture, including layered modules, dependency hotspots, and structural risks.' +--- + +# Map architecture + +Use when asked to map the repo or a directory's architecture, including layered modules, dependency hotspots, and structural risks. + +Route this through the `tracedecay:exploring-code` skill for structure, and `tracedecay:code-health` for dependency hotspots and structural risk. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow those skills' read-only workflow and guardrails. + +Output: a layered module map, dependency hotspots/violations, and a prioritized risk list. diff --git a/claude-plugin/skills/tracedecay-port-code/SKILL.md b/claude-plugin/skills/tracedecay-port-code/SKILL.md new file mode 100644 index 000000000..1769f1c22 --- /dev/null +++ b/claude-plugin/skills/tracedecay-port-code/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-port-code +description: 'Use to port or migrate code between directories in dependency-safe order and track progress.' +--- + +# Port code + +Use when asked to port or migrate code between directories in dependency-safe order and track progress. + +Route this through the `tracedecay:editing-safely` skill, using the port tools (`tracedecay_port_order`, `tracedecay_port_status`). + +- **Args:** " ". If absent, ask for the source and target directories. +- Follow that skill's dependency-safe workflow and guardrails: port leaves first, and confirm before edits and toolchain runs. + +Output: updated port status (done / remaining) and the per-batch typecheck result. diff --git a/claude-plugin/skills/tracedecay-recall-memory/SKILL.md b/claude-plugin/skills/tracedecay-recall-memory/SKILL.md new file mode 100644 index 000000000..2bb0b181b --- /dev/null +++ b/claude-plugin/skills/tracedecay-recall-memory/SKILL.md @@ -0,0 +1,16 @@ +--- +name: tracedecay-recall-memory +description: 'Use to recall prior decisions, durable facts, and past session conversations for this project.' +--- + +# Recall memory + +Use when asked to recall prior decisions, durable facts, or past session conversations for this project. + +Route durable decisions/facts through the `tracedecay:recalling-project-memory` skill, and raw conversation recall through the `tracedecay:recalling-session-context` skill. + +- **Target:** the question or topic to recall. If none is given, ask what to look up. +- Route durable decisions/facts through `fact_store` search; route "what happened in that session" through `tracedecay_message_search` and the LCM retrieval ladder. Follow both skills' read-only guardrails. +- If the user asks to update, delete, merge, or prune stored facts, switch to `tracedecay:curating-project-memory`. + +Output: the recalled decisions/messages with their sources (fact, session id, timestamp). diff --git a/claude-plugin/skills/tracedecay-review-diff/SKILL.md b/claude-plugin/skills/tracedecay-review-diff/SKILL.md new file mode 100644 index 000000000..dc5be51f1 --- /dev/null +++ b/claude-plugin/skills/tracedecay-review-diff/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-review-diff +description: 'Use to review the current PR or diff for impact, risk, and quality via the TraceDecay code graph.' +--- + +# Review diff + +Use when asked to review the current PR or diff for impact, risk, and quality. + +Route this through the `tracedecay:reviewing-changes` skill. + +- **Scope:** the current working-tree diff, or the base ref / PR named if one is given. +- Follow that skill's read-only workflow and guardrails: no edits or test runs; to verify behavior, hand off to `tracedecay:assessing-impact`. + +Output: findings grouped Critical / Warning / Note, the impacted areas, and the test set to run. diff --git a/claude-plugin/skills/tracedecay-test-changes/SKILL.md b/claude-plugin/skills/tracedecay-test-changes/SKILL.md new file mode 100644 index 000000000..0431351f0 --- /dev/null +++ b/claude-plugin/skills/tracedecay-test-changes/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-test-changes +description: 'Use to test current changes by running only affected tests and mapping failures back to source.' +--- + +# Test changes + +Use when asked to test current changes by running only the affected tests and mapping failures back to source. + +Route this through the `tracedecay:assessing-impact` skill, using the affected-tests tools (`tracedecay_run_affected_tests`, `tracedecay_diagnostics`). + +- **Input:** explicit changed paths if given; otherwise use the current working tree. +- Follow that skill's workflow and guardrails: `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, so confirm before running; preview scope read-only first. + +Output: pass/fail summary, failing-symbol mapping, and suggested missing tests. diff --git a/claude-plugin/skills/tracing-functions/SKILL.md b/claude-plugin/skills/tracing-functions/SKILL.md new file mode 100644 index 000000000..6314a5f9f --- /dev/null +++ b/claude-plugin/skills/tracing-functions/SKILL.md @@ -0,0 +1,27 @@ +--- +name: tracing-functions +description: 'Use when tracing call relationships: find callers/callees, who calls a function, what it calls, what depends on a symbol or fixture/helper, shortest call paths, references for rename prep, recursion, hubs, or dynamic dispatch. Use before grep/file reads for "trace this function" tasks.' +--- + +# Tracing functions + +## Workflow + +1. **Resolve symbol(s) → node ID(s)** with `tracedecay_find_exact_symbol` for exact names, `tracedecay_search` for ranked discovery, or `tracedecay_by_qualified_name` for stable identities (see `tracedecay:exploring-code` for the full resolver ladder). +2. **Upstream (callers) → `tracedecay_callers`** (`node_id`, `max_depth` 1–2 first). For many symbols at once → `tracedecay_callers_for` (`node_ids[]`, one round-trip). +3. **Downstream (callees) → `tracedecay_callees`** (resolves trait dispatch; watch for `dispatch_via_trait: true` / `dispatch_from`). Pass `resolve_dispatch: false` for direct edges only. +4. **Path between two symbols → `tracedecay_call_chain`** (`from_id`, `to_id`, `max_depth`). +5. **Polymorphism → `tracedecay_implementations`** for a quick "every implementor / every body of this method"; the full type-level toolkit (impl blocks, hierarchies, derives, construction/field sites) is `tracedecay:exploring-code`. +6. **All references (rename prep) → `tracedecay_rename_preview`** (`node_id`): every edge where the node is source or target. For the full recon-then-edit rename workflow, use `tracedecay:editing-safely`. +7. **Cycles / hubs:** `tracedecay_recursion`, `tracedecay_hotspots`, `tracedecay_rank`. + +## Guardrails + +- Read-only and parallel-safe. For tasks like "find callers of setup_project", "which tests still depend on this fixture", or "trace this function", resolve the symbol and call `tracedecay_callers` / `tracedecay_callees` before running grep or opening files. Keep `max_depth` small (1–2) first; widen only when the chain is not yet clear. `tracedecay_rename_preview` only previews references — it does not rename. +- For several independent symbols or call paths, use scoped read-only subagents per symbol, direction, or path hypothesis. Require node ids, depth/tool parameters, and dispatch notes; the parent agent owns the final trace. +- If a trace response is truncated and includes a `handle`, narrow depth or target set first when possible; call `tracedecay_retrieve` with that `handle` when the omitted chain details are needed. + +## Output + +- The caller/callee tree or the resolved path, with dispatch targets noted. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/using-the-cli/SKILL.md b/claude-plugin/skills/using-the-cli/SKILL.md new file mode 100644 index 000000000..11fe7a069 --- /dev/null +++ b/claude-plugin/skills/using-the-cli/SKILL.md @@ -0,0 +1,42 @@ +--- +name: using-the-cli +description: 'Use when a tracedecay MCP call fails, times out, or the server is disconnected or unconfigured — every MCP tool is also a shell command, `tracedecay tool` plus the tool name. Switch to the CLI instead of querying .tracedecay databases directly or abandoning tracedecay.' +--- + +# Using the tracedecay CLI + +The `tracedecay` binary exposes every MCP tool as a shell command. MCP and CLI hit the same project store and return the same payloads, so an MCP transport failure (timeout, disconnect, missing server config) loses nothing: run the same tool with the same arguments via `tracedecay tool ` and keep following whatever `tracedecay:*` skill you were in. + +## Discovery + +1. **List every tool → `tracedecay tool`** (no name): all tools grouped by category with one-line summaries. +2. **One tool's parameters → `tracedecay tool --help`**: the tool's full description, a ready-to-copy usage line with its required flags, and each parameter with its type and required/optional flag. +3. **Everything else → `tracedecay --help`**: the non-tool subcommands (`init`, `sync`, `status`, `doctor`, `daemon`, `sessions`, `dashboard`, …) plus a quick-start trailer that restates this discovery flow. Every subcommand's own `--help` carries an `Examples:` section with real flag combinations and `Related:` cross-references — read it before improvising flags. + +## Invocation + +- Arguments are alternating `--key value` flags: `tracedecay tool search --query "parse config" --limit 10`. +- Tool names work with or without the `tracedecay_` prefix (`tool search` ≡ `tool tracedecay_search`). +- `--json` prints raw JSON; `--args '{"key":"value"}'` passes a whole JSON argument object; any value starting with `@` is read from that file (handy for multi-line replacement bodies, e.g. `--new-body @/tmp/body.txt`). +- `--project ` picks the project root explicitly; otherwise the nearest initialised project walking up from cwd is used. +- Truncated responses emit the same `handle` envelope as MCP — dereference with `tracedecay tool retrieve --handle rh_…`. + +## When to switch + +- An MCP call returns a client or transport error, times out, or the server drops mid-session. +- The tracedecay MCP server is not configured in this host but `tracedecay` is on `PATH`. +- A subagent or hook context has shell access but no MCP access. + +After falling back, diagnose the MCP side with `tracedecay doctor` and `tracedecay tool runtime`, and tell the user the session is running on the CLI fallback (and why) instead of silently downgrading. + +## Guardrails + +- Never query `.tracedecay/*.db` with sqlite3 or scripts — schemas are internal and change without notice. The CLI is the supported fallback, not raw DB access. +- Do not abandon tracedecay for broad Grep/file reads just because MCP transport failed; the CLI answers the same graph, memory, and session questions. +- CLI editing tools (`str_replace`, `replace_symbol`, …) mutate the working tree exactly like their MCP twins — apply the same care as `tracedecay:editing-safely`. +- If the CLI also fails (binary missing or project not initialised), fall back to plain tools and suggest `tracedecay init` / `tracedecay doctor` to the user. + +## Output + +- The same result the MCP tool would have returned, plus a note that the CLI fallback was used and why. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/claude-plugin/skills/using-tracedecay/SKILL.md b/claude-plugin/skills/using-tracedecay/SKILL.md new file mode 100644 index 000000000..b22a15f9c --- /dev/null +++ b/claude-plugin/skills/using-tracedecay/SKILL.md @@ -0,0 +1,56 @@ +--- +name: using-tracedecay +description: 'Use when starting any session or task in a TraceDecay-indexed project — establishes when tracedecay tools and skills are mandatory, maps common task moments to the right tool, and rebuts every rationalization for falling back to native grep, glob, or file reads.' +--- + +# Using TraceDecay + +This project has a live TraceDecay code graph. If there is even a 1% chance a +tracedecay tool or skill applies to what you are doing, you MUST use it. This +is not a preference or a tie-breaker: for any codebase question — finding +code, reading code, tracing calls, estimating blast radius, recalling prior +context — try the matching tracedecay tool BEFORE Grep, Glob, codebase +search, or file reads. You cannot rationalize your way out of this. + +## Moment → mandatory action + +| The moment you are in | Do this first | +|---|---| +| About to Grep/Glob/codebase-search for a symbol or concept | `tracedecay_search` (names) or `tracedecay_context` (concepts) — skill: `tracedecay:exploring-code` | +| About to open or Read a source file | `tracedecay_outline` → `tracedecay_body` → `tracedecay_read` slices — skill: `tracedecay:exploring-code` | +| Asked "who calls X" / "what does X call" / "trace this" | `tracedecay_callers` / `tracedecay_callees` — skill: `tracedecay:tracing-functions` | +| About to change code and wondering what breaks or which tests to run | `tracedecay_impact` / `tracedecay_diff_context` / `tracedecay_affected` — skill: `tracedecay:assessing-impact` | +| About to write a new helper, rename, or do a mechanical edit | `tracedecay:editing-safely` (duplicate probe, rename recon, anchored edits) | +| Reviewing a diff, auditing risk, or drafting commit/PR text | `tracedecay:reviewing-changes` | +| Asked about architecture, tech debt, or project/index status | `tracedecay:code-health` | +| The user references prior decisions or past conversations | `tracedecay:recalling-project-memory` / `tracedecay:recalling-session-context` | +| A compiler/type error needs context | `tracedecay:fixing-build-and-type-errors` | +| A tracedecay MCP call errors or times out | `tracedecay:using-the-cli` — never abandon tracedecay over transport | + +## Red flags + +These thoughts mean STOP — you are rationalizing: + +| Thought | Reality | +|---|---| +| "Grep is faster for this" | `tracedecay_search` is one call and pre-ranked. | +| "I'll just read the whole file" | `tracedecay_outline` / `tracedecay_body` answer at a fraction of the tokens. | +| "This is a simple lookup" | Simple lookups are exactly what the graph is for. | +| "I already know this codebase" | The graph is fresher than your memory. Check it. | +| "The MCP call might fail" | The CLI fallback (`tracedecay tool `) always works. | +| "I'll explore first, then use the skill" | The skills tell you HOW to explore. Check first. | +| "The skill is overkill here" | Simple things become complex. Use it. | + +## Procedure + +1. On every task (including questions), check the moment table above BEFORE + the first tool call. If a row matches, follow it. +2. Announce which skill you are following ("Using `tracedecay:exploring-code` + to …") so the choice is visible and deliberate. +3. Fall back to plain Grep/Glob/Read only for content the graph does not + index (comments, string literals, prose, config bodies) or after + tracedecay has pinpointed the exact files. +4. If a response is truncated with a `handle`, narrow the query or call + `tracedecay_retrieve` — do not re-run broad queries or guess. +5. If any result includes a `tracedecay_metrics:` line, report the savings to + the user. diff --git a/codex-plugin/skills/managing-session-context/SKILL.md b/codex-plugin/skills/managing-session-context/SKILL.md new file mode 100644 index 000000000..7553e2dd0 --- /dev/null +++ b/codex-plugin/skills/managing-session-context/SKILL.md @@ -0,0 +1,76 @@ +--- +name: managing-session-context +description: 'Use when driving the LCM compression lifecycle for a host — preflight, compression, session-boundary reporting, or diagnosing/repairing the LCM store. For past-session recall see recalling-session-context.' +--- + +# Managing session context + +This skill owns the **LCM compression and maintenance lifecycle** — the write +and health side of the session store. It is the counterpart to +`tracedecay:recalling-session-context`, which owns retrieval (grep, replay, +summary-DAG expansion). These lifecycle tools are **host-agent integration +tools**: invoke them when the host is managing its own context window or when +the user explicitly asks to compress, repair, or inspect the LCM store — not +casually during recall. + +## Lifecycle tools + +All take `--provider` and (except doctor/status) `--session-id`. All default to +`storage_scope: "project_local"`; pass `hermes_profile` with an absolute +`hermes_home` only when the user targets a Hermes profile store. + +1. **Preflight → `tracedecay_lcm_preflight`** (`provider`, `session-id`, plus + token knobs like `current-tokens`, `threshold-tokens`, `context-length`, + `reserve-tokens-floor`, `max-assembly-tokens`, `fresh-tail-count`): decide + *whether* compression should run before doing it. Read-only planning call. +2. **Compress → `tracedecay_lcm_compress`** (same core args plus + `focus-topic`, `summarizer`, `expected-current-frontier-store-id` as an + optimistic guard): advance the compression lifecycle. **Mutates** the store. + Use `expected-current-frontier-store-id` to no-op safely if the frontier + moved under you. +3. **Session boundary → `tracedecay_lcm_session_boundary`** (`provider`, + `session-id`, `old-session-id`, `bound-session-id`, `boundary-reason`): + report that the host crossed a compression boundary. A mismatch between the + bound and old session skips carry-over and starts a short cooldown. +4. **Doctor → `tracedecay_lcm_doctor`** (`provider`, `mode`: + `diagnose`|`repair`|`retention`|`clean`|`gc`, `apply`, optional + `session-id`): bounded diagnostics and safe repairs. `diagnose`/`retention` + are read-only; `repair`/`clean`/`gc` **mutate only with `apply: true`** and + are further gated by safety flags/env for clean and gc. +5. **Status → `tracedecay_lcm_status`** (optional `provider`, `session-id`, + `deep`): schema/message/summary/payload counts, token estimates, summary + depth distribution + compression ratio, payload byte totals, and GC status. + Read-only; `deep: true` adds an on-disk integrity sweep. + +## Typical flow + +Preflight → (if it requests compression) compress → status to confirm the ratio +moved. On a real host session change, call session_boundary. If counts look +wrong (missing sessions, stale FTS, orphaned payloads) run doctor +`mode: "diagnose"` first, review, then repair/clean/gc with `apply: true` only +on explicit user intent. + +## Guardrails + +- `preflight`, `status`, and doctor `diagnose`/`retention` are read-only. + `compress`, `session_boundary`, and doctor `repair`/`clean`/`gc` + `apply` + **mutate** durable session state — run them only with clear lifecycle or user + intent, never speculatively. +- `provider` is required and `all` is rejected for these lifecycle tools; target + one provider at a time. +- Do not let subagents drive compression, boundaries, or repair; those are + parent-agent/host responsibilities. +- Keep token knobs conservative; over-aggressive compression loses replay + fidelity that `tracedecay:recalling-session-context` depends on. + +## Handoff + +- Retrieving past-session content (grep, replay, summary-DAG expansion) → `tracedecay:recalling-session-context`. +- CLI fallback when MCP transport fails → `tracedecay:using-the-cli`. + +## Output + +- The lifecycle action taken (preflight decision, compression result, boundary + outcome, or store counts), whether it was read-only or mutating, and the + resulting compression ratio / health signals. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/codex-plugin/skills/retrieving-cached-context/SKILL.md b/codex-plugin/skills/retrieving-cached-context/SKILL.md new file mode 100644 index 000000000..ac20655ce --- /dev/null +++ b/codex-plugin/skills/retrieving-cached-context/SKILL.md @@ -0,0 +1,60 @@ +--- +name: retrieving-cached-context +description: 'Use when a tracedecay response was truncated with a handle and the missing detail is needed — dereference the cached original with tracedecay_retrieve instead of re-running, or expand one LCM node.' +--- + +# Retrieving cached context + +TraceDecay truncates large tool responses and emits a **handle** envelope +instead of the full body. The original text is cached in the active-project +store; you dereference it with `tracedecay_retrieve` rather than re-running the +source tool. This skill covers that handle/caching mechanic and the related +single-node expansion via `tracedecay_lcm_expand`. + +## When to retrieve vs re-run + +- A prior response ended with a `handle` (e.g. `rh_…`) and the missing details + are actually needed to answer the user → **retrieve the handle**. Do not + re-run the broad query, guess, or read a file again. +- You do NOT need the truncated tail → leave it; retrieval costs tokens. +- The result was truncated because the query was too broad → also consider + narrowing the original query next time (see `tracedecay:using-tracedecay`). + +## Tools + +1. **Dereference a handle → `tracedecay_retrieve`** (`handle` required, copied + exactly from the truncated envelope). It returns the **exact cached original + text** — it does not re-run the source tool or re-read a file/session/node. + Handles are scoped to the active project store, expire automatically, and + never reference remote storage. If the truncated response used a + `project-id`/`project-path` selector, pass the same selector to `retrieve`. +2. **Expand one LCM node → `tracedecay_lcm_expand`** (`provider`, `session-id`, + `target` with `kind`: `raw_message`|`summary_node`|`external_payload`): + opens a single session node through the bounded LCM query API. Page a summary + node's sources with `source-offset`/`source-limit`, and page long content + with `content-offset`/`content-limit`. If a returned source has + `content_truncated: true`, continue via `target.kind: "raw_message"` for that + source's `store_id` and `content_offset`. + +## Guardrails + +- Both tools are **read-only**; they surface already-cached content and never + mutate state. +- Retrieve only what you need — handles and node expansion are bounded on + purpose; do not dump the full cached body when a slice answers the question. +- Handles expire; if `retrieve` reports an expired/unknown handle, re-run the + original tool with a narrower query rather than retrying the stale handle. +- Handles are local and project-scoped — never treat them as durable + references to store or reuse across sessions. + +## Handoff + +- Finding which session node to expand (grep, replay, summary-DAG shape) → `tracedecay:recalling-session-context`. +- Driving compression that produces those summary nodes → `tracedecay:managing-session-context`. +- General "narrow the query instead of re-running" guidance → `tracedecay:using-tracedecay`. + +## Output + +- The retrieved cached text or expanded node content, and a note that it came + from a handle/cache rather than a fresh query. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/codex-plugin/skills/retrieving-project-memory/SKILL.md b/codex-plugin/skills/retrieving-project-memory/SKILL.md new file mode 100644 index 000000000..18da30278 --- /dev/null +++ b/codex-plugin/skills/retrieving-project-memory/SKILL.md @@ -0,0 +1,69 @@ +--- +name: retrieving-project-memory +description: 'Use when querying or reasoning over stored tracedecay memory facts — searching, probing by entity, multi-fact reasoning, or fetching a fact with trust history. For recall framing see recalling-project-memory.' +--- + +# Retrieving project memory + +This skill owns the **read/reason mechanics** of the holographic fact store: +the exact `tracedecay_fact_store` retrieval actions plus +`tracedecay_memory_status`. It is the mechanical counterpart to +`tracedecay:recalling-project-memory` (which frames memory recall around a task +or decision and starts from transcripts). When the question is "what does the +fact store know about X and how do the facts relate," start here. + +## Retrieval actions (`tracedecay_fact_store`) + +All read-mostly; they may update access/retrieval metadata but do not add or +delete facts. Read-only project selectors (`project-id` / `project-path`) are +supported for these actions. + +1. **search** (`query`, optional `category`, `limit` default 20 / max 200, + `min_trust`) — phase-vector similarity search; the default entry point for + "find facts about X." +2. **probe** (`entity` / `query`) — probe memory around a single named entity. +3. **related** (`entity`) — facts connected to an entity via stored relations. +4. **reason** (`query`, `entities`) — assemble and reason over multiple facts + for a query, following entity relations rather than returning a flat list. +5. **get** (`fact-id`) — the full fact plus its `trust_history`, so you can + answer *why* a trust score is what it is. +6. **list** (optional `category`, `min_trust`, `limit`) — enumerate stored + facts for review. +7. **contradict** (`threshold`) — scan for contradictory facts; non-destructive. + +## Memory health + +- **`tracedecay_memory_status`** — fact/entity counts, trust distribution, + below-threshold and missing-vector signals, capacity-per-bank, and repair + stats. Note it **repairs** derived vectors/banks as a side effect, so call it + when the user asks for memory counts/health, not on every recall. + +## How to query and reason + +- Prefer `search`/`probe` to locate candidates, then `reason` (or `related`) + when the answer spans several linked facts. +- Use `min_trust` to filter out low-confidence facts; use `get` on a specific + `fact-id` when the user challenges a fact or asks why its trust changed. +- Keep retrieval bounded and token-aware: set `limit` deliberately and narrow + `query`/`category` rather than pulling the whole store with a broad `list`. + +## Guardrails + +- search/probe/related/reason/get/list/contradict are read-only recall (they + may touch access counters); they never mutate fact content. +- `tracedecay_memory_status` mutates derived state (vector/bank repair) — treat + it as a health action, not a passive read. +- Recall memory before external or web search — a prior session likely already + answered the question, cheaper and project-specific. + +## Handoff + +- Task/decision recall that should start from transcripts → `tracedecay:recalling-project-memory`. +- Persisting a new durable fact → `tracedecay:storing-project-memory`. +- Fixing stale/contradictory/duplicate facts → `tracedecay:curating-project-memory`. + +## Output + +- The facts found/reasoned over with their ids, trust, and source, plus which + action answered the question. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/codex-plugin/skills/storing-project-memory/SKILL.md b/codex-plugin/skills/storing-project-memory/SKILL.md new file mode 100644 index 000000000..cda1d454a --- /dev/null +++ b/codex-plugin/skills/storing-project-memory/SKILL.md @@ -0,0 +1,82 @@ +--- +name: storing-project-memory +description: 'Use when writing a durable fact to tracedecay memory — persisting a decision, preference, correction, pitfall, or entity relation, and handling near-duplicate/conflict/secret write diffs. For cleanup see curating-project-memory.' +--- + +# Storing project memory + +This skill owns the **write path** into holographic memory: turning a durable +decision or fact into a stored `tracedecay_fact_store` record. It is the +narrow "add/update/relate" counterpart to `tracedecay:curating-project-memory` +(dedup, merge, delete, whole-subject memorization) and +`tracedecay:retrieving-project-memory` (read/reason). Store proactively +whenever a durable decision, user preference, correction, or pitfall surfaces — +do not wait for the user to ask. + +## When to store vs not + +Store only **durable, project-scoped** facts: + +- Store: architectural/design decisions, user or project preferences, hard-won + corrections, recurring pitfalls, stable conventions, entity relationships. +- Do NOT store: secrets/credentials/API keys/PII, transient errors, + environment-specific failures, task progress, one-off narratives, or anything + that goes stale when the session ends — recover those from transcripts via + `tracedecay:recalling-session-context` instead. + +## Workflow + +1. **Dedupe first (read-only):** search before you write with + `tracedecay_fact_store` `action: "search"` (`query` = subject + candidate, + optional `category`, `limit: 10`, `min_trust: 0.5`). If a near-match exists, + prefer an update over a second add. +2. **Add a fact → `tracedecay_fact_store`** `action: "add"` with `content` + (the durable claim), `category`, `source` (provenance label), `tags`, + `entities` (named entities the fact concerns), `trust`, and optional + `metadata` (subject/confidence/citations). The add result carries a + write-time diff — always read it (see below). +3. **Update an existing fact → `tracedecay_fact_store`** `action: "update"` + with `fact-id` plus the changed `content`/`trust`/`tags`/`category`. Prefer + update when correcting or refining a fact so provenance survives. +4. **Relate entities → `tracedecay_fact_store`** `action: "relate"` (with + `entities` / `entity`) to record a relationship between named entities the + facts concern. +5. **Calibrate trust deliberately** — do not default high. Aim for a spread: + `>=0.85` for independently verified/durable decisions, `~0.7` for ordinary + well-sourced facts, `~0.5` for plausible-but-unsure. Do not lower trust + merely because a fact is old; cite newer evidence instead. + +## Reading the add diff + +Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / +`reason`. Act on it, never ignore it: + +- `near_duplicate` — a very similar fact exists; prefer `action: "update"` on + `closest_fact_id` rather than storing a second copy. +- `possible_conflict` — a negation/state-change cue suggests supersession; + confirm which fact is current before leaving both in place (hand off to + `tracedecay:curating-project-memory` if a merge/delete is needed). +- `rejected_secret_like` — credential-like content was **NOT** stored. Never + rephrase or obfuscate a rejected secret to bypass the filter. + +## Guardrails + +- `search` is read-only; `add`, `update`, and `relate` **mutate** memory state. + `search`/`probe`/`related`/`reason` may update access/retrieval counters. +- Deletion is permanent and lives in `tracedecay:curating-project-memory`, not + here — prefer update/relate over creating removable clutter. +- Never store secrets, credentials, keys, or PII; rely on the built-in + `rejected_secret_like` filter as a backstop, not a first line. +- Only the parent agent should call `add`/`update`/`relate`. Subagents may + gather cited evidence and candidate facts; the parent validates and writes. + +## Handoff + +- Dedup, merge, delete, or memorize a whole subject → `tracedecay:curating-project-memory`. +- Read, probe, or reason over stored facts → `tracedecay:retrieving-project-memory`. + +## Output + +- The fact(s) stored/updated with their ids, the trust assigned, and any + `near_duplicate` / `possible_conflict` / `rejected_secret_like` diff acted on. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/codex-plugin/skills/tracedecay-audit-safety/SKILL.md b/codex-plugin/skills/tracedecay-audit-safety/SKILL.md new file mode 100644 index 000000000..bfad20c61 --- /dev/null +++ b/codex-plugin/skills/tracedecay-audit-safety/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-audit-safety +description: 'Use to audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, and untested high-risk symbols.' +--- + +# Audit safety + +Use when asked to audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, or untested high-risk symbols. + +Route this through the `tracedecay:reviewing-changes` skill. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow that skill's read-only workflow and guardrails: report findings, do not fix them here. + +Output: findings grouped Critical / Warning / Note with file + enclosing symbol, and a prioritized follow-up list. diff --git a/codex-plugin/skills/tracedecay-check-health/SKILL.md b/codex-plugin/skills/tracedecay-check-health/SKILL.md new file mode 100644 index 000000000..9fc103ee4 --- /dev/null +++ b/codex-plugin/skills/tracedecay-check-health/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-check-health +description: 'Use to check code health for the repo or a directory, including worst offenders and a prioritized fix list.' +--- + +# Check health + +Use when asked to check code health for the repo or a directory, including worst offenders and a prioritized fix list. + +Route this through the `tracedecay:code-health` skill. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow that skill's read-only workflow and guardrails: lead with `tracedecay_health` and drill only into weak dimensions. + +Output: the composite health score + weak dimensions, the worst offenders (complexity, duplication, god files, doc gaps, panic sites, test-risk), and a prioritized fix list. diff --git a/codex-plugin/skills/tracedecay-clean-dead-code/SKILL.md b/codex-plugin/skills/tracedecay-clean-dead-code/SKILL.md new file mode 100644 index 000000000..19f0ca26b --- /dev/null +++ b/codex-plugin/skills/tracedecay-clean-dead-code/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-clean-dead-code +description: 'Use to find and safely remove dead code, unused imports, and duplication via the TraceDecay code graph.' +--- + +# Clean dead code + +Use when asked to find and safely remove dead code, unused imports, or duplication. + +Route this through the `tracedecay:reviewing-changes` skill to identify candidates, then apply `tracedecay:editing-safely` for any removals. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow those skills' guardrails: confirm zero real callers before deleting anything, be conservative with `pub` items, and verify with a build/test re-check after edits. + +Output: removed/consolidated items and the before/after health or test result. diff --git a/codex-plugin/skills/tracedecay-compare-branches/SKILL.md b/codex-plugin/skills/tracedecay-compare-branches/SKILL.md new file mode 100644 index 000000000..ab7953637 --- /dev/null +++ b/codex-plugin/skills/tracedecay-compare-branches/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-compare-branches +description: 'Use to compare or search another git branch''s code graph without switching your checkout.' +--- + +# Compare branches + +Use when asked to compare or search another git branch's code graph without switching your checkout. + +Route this through the `tracedecay:exploring-code` skill, using the cross-branch tools (`tracedecay_branch_list`, `tracedecay_branch_diff`, `tracedecay_branch_search`). + +- **Target:** a single branch to compare against the current branch, or " " to diff two branches. If none is given, start with `tracedecay_branch_list` and ask what to search or compare. +- Follow that skill's read-only workflow. If a target branch isn't tracked, tell the user to run `tracedecay branch add ` in the terminal first, and surface any branch-fallback warning. + +Output: the cross-branch search hits or the added/removed/changed symbol lists. diff --git a/codex-plugin/skills/tracedecay-curate-memory/SKILL.md b/codex-plugin/skills/tracedecay-curate-memory/SKILL.md new file mode 100644 index 000000000..ea5235b35 --- /dev/null +++ b/codex-plugin/skills/tracedecay-curate-memory/SKILL.md @@ -0,0 +1,16 @@ +--- +name: tracedecay-curate-memory +description: 'Use to curate, update, delete, or inspect TraceDecay memory facts and dashboard curation from an explicit slash workflow.' +--- + +# Curate memory + +Use when asked to curate, update, delete, or inspect TraceDecay memory facts, or to do dashboard curation. + +Route this through the `tracedecay:curating-project-memory` skill. + +- **Scope:** the fact, entity, query, or curation action to review. If none is given, ask what memory scope to curate before mutating anything. +- Start read-only with `tracedecay_fact_store` search/list/probe/reason/contradict or `tracedecay_memory_status`; open `tracedecay_dashboard` only when the user wants visual curation. +- Follow the hard-delete guardrail: confirm fact ids and reasons before `remove` unless the user already gave an exact deletion instruction. + +Output: memory facts inspected or changed, confirmations requested, and the final verification search/list result. diff --git a/codex-plugin/skills/tracedecay-draft-commit/SKILL.md b/codex-plugin/skills/tracedecay-draft-commit/SKILL.md new file mode 100644 index 000000000..fec4ef4b4 --- /dev/null +++ b/codex-plugin/skills/tracedecay-draft-commit/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-draft-commit +description: 'Use to draft a commit message, PR description, or changelog from semantic changes; drafts text only and never commits or pushes.' +--- + +# Draft commit + +Use when asked to draft a commit message, PR description, or changelog from the current semantic changes. + +Route this through the `tracedecay:reviewing-changes` skill to read the diff and its impact. + +- **Target:** the artifact to draft (e.g. "pr", "changelog", a base ref, or "staged"). If none is given, draft a commit message for the working-tree/staged changes. +- Follow that skill's guardrails: this drafts text only — leave `git commit` / `gh pr create` to the user unless they explicitly ask. + +Output: the drafted commit / PR / changelog text. diff --git a/codex-plugin/skills/tracedecay-find-impact/SKILL.md b/codex-plugin/skills/tracedecay-find-impact/SKILL.md new file mode 100644 index 000000000..7d87163dd --- /dev/null +++ b/codex-plugin/skills/tracedecay-find-impact/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-find-impact +description: 'Use to find the blast radius of a change, including impacted symbols, files, and the tests to run.' +--- + +# Find impact + +Use when asked to find the blast radius of a change, including impacted symbols, files, and the tests to run. + +Route this through the `tracedecay:assessing-impact` skill. + +- **Target:** the symbol, file, or change to analyze. If none is given, use the current working-tree diff. +- Follow that skill's read-only workflow and guardrails: shallow `max_depth` first; it identifies impact, it does not run tests. + +Output: impacted symbols + files, the test set to run, and any hub/coupling risk. diff --git a/codex-plugin/skills/tracedecay-fix-build/SKILL.md b/codex-plugin/skills/tracedecay-fix-build/SKILL.md new file mode 100644 index 000000000..f0be4d26f --- /dev/null +++ b/codex-plugin/skills/tracedecay-fix-build/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-fix-build +description: 'Use to fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing.' +--- + +# Fix build + +Use when asked to fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing. + +Route this through the `tracedecay:fixing-build-and-type-errors` skill. + +- **Input:** if the user pasted `cargo`/`clippy` output, route it to `tracedecay_diagnose`; otherwise run `tracedecay_diagnostics` (scoped to a directory if one is named). +- Follow that skill's guardrails: prefer pasted output when available; `tracedecay_diagnostics` runs the toolchain, so confirm before running long checks. + +Output: grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. diff --git a/codex-plugin/skills/tracedecay-map-architecture/SKILL.md b/codex-plugin/skills/tracedecay-map-architecture/SKILL.md new file mode 100644 index 000000000..7b659dd4a --- /dev/null +++ b/codex-plugin/skills/tracedecay-map-architecture/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-map-architecture +description: 'Use to map repo or directory architecture, including layered modules, dependency hotspots, and structural risks.' +--- + +# Map architecture + +Use when asked to map the repo or a directory's architecture, including layered modules, dependency hotspots, and structural risks. + +Route this through the `tracedecay:exploring-code` skill for structure, and `tracedecay:code-health` for dependency hotspots and structural risk. + +- **Scope:** the whole repo, or a specific directory if one is named. +- Follow those skills' read-only workflow and guardrails. + +Output: a layered module map, dependency hotspots/violations, and a prioritized risk list. diff --git a/codex-plugin/skills/tracedecay-port-code/SKILL.md b/codex-plugin/skills/tracedecay-port-code/SKILL.md new file mode 100644 index 000000000..1769f1c22 --- /dev/null +++ b/codex-plugin/skills/tracedecay-port-code/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-port-code +description: 'Use to port or migrate code between directories in dependency-safe order and track progress.' +--- + +# Port code + +Use when asked to port or migrate code between directories in dependency-safe order and track progress. + +Route this through the `tracedecay:editing-safely` skill, using the port tools (`tracedecay_port_order`, `tracedecay_port_status`). + +- **Args:** " ". If absent, ask for the source and target directories. +- Follow that skill's dependency-safe workflow and guardrails: port leaves first, and confirm before edits and toolchain runs. + +Output: updated port status (done / remaining) and the per-batch typecheck result. diff --git a/codex-plugin/skills/tracedecay-recall-memory/SKILL.md b/codex-plugin/skills/tracedecay-recall-memory/SKILL.md new file mode 100644 index 000000000..2bb0b181b --- /dev/null +++ b/codex-plugin/skills/tracedecay-recall-memory/SKILL.md @@ -0,0 +1,16 @@ +--- +name: tracedecay-recall-memory +description: 'Use to recall prior decisions, durable facts, and past session conversations for this project.' +--- + +# Recall memory + +Use when asked to recall prior decisions, durable facts, or past session conversations for this project. + +Route durable decisions/facts through the `tracedecay:recalling-project-memory` skill, and raw conversation recall through the `tracedecay:recalling-session-context` skill. + +- **Target:** the question or topic to recall. If none is given, ask what to look up. +- Route durable decisions/facts through `fact_store` search; route "what happened in that session" through `tracedecay_message_search` and the LCM retrieval ladder. Follow both skills' read-only guardrails. +- If the user asks to update, delete, merge, or prune stored facts, switch to `tracedecay:curating-project-memory`. + +Output: the recalled decisions/messages with their sources (fact, session id, timestamp). diff --git a/codex-plugin/skills/tracedecay-review-diff/SKILL.md b/codex-plugin/skills/tracedecay-review-diff/SKILL.md new file mode 100644 index 000000000..dc5be51f1 --- /dev/null +++ b/codex-plugin/skills/tracedecay-review-diff/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-review-diff +description: 'Use to review the current PR or diff for impact, risk, and quality via the TraceDecay code graph.' +--- + +# Review diff + +Use when asked to review the current PR or diff for impact, risk, and quality. + +Route this through the `tracedecay:reviewing-changes` skill. + +- **Scope:** the current working-tree diff, or the base ref / PR named if one is given. +- Follow that skill's read-only workflow and guardrails: no edits or test runs; to verify behavior, hand off to `tracedecay:assessing-impact`. + +Output: findings grouped Critical / Warning / Note, the impacted areas, and the test set to run. diff --git a/codex-plugin/skills/tracedecay-test-changes/SKILL.md b/codex-plugin/skills/tracedecay-test-changes/SKILL.md new file mode 100644 index 000000000..0431351f0 --- /dev/null +++ b/codex-plugin/skills/tracedecay-test-changes/SKILL.md @@ -0,0 +1,15 @@ +--- +name: tracedecay-test-changes +description: 'Use to test current changes by running only affected tests and mapping failures back to source.' +--- + +# Test changes + +Use when asked to test current changes by running only the affected tests and mapping failures back to source. + +Route this through the `tracedecay:assessing-impact` skill, using the affected-tests tools (`tracedecay_run_affected_tests`, `tracedecay_diagnostics`). + +- **Input:** explicit changed paths if given; otherwise use the current working tree. +- Follow that skill's workflow and guardrails: `tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks, so confirm before running; preview scope read-only first. + +Output: pass/fail summary, failing-symbol mapping, and suggested missing tests. diff --git a/cursor-plugin/skills/managing-session-context/SKILL.md b/cursor-plugin/skills/managing-session-context/SKILL.md new file mode 100644 index 000000000..7553e2dd0 --- /dev/null +++ b/cursor-plugin/skills/managing-session-context/SKILL.md @@ -0,0 +1,76 @@ +--- +name: managing-session-context +description: 'Use when driving the LCM compression lifecycle for a host — preflight, compression, session-boundary reporting, or diagnosing/repairing the LCM store. For past-session recall see recalling-session-context.' +--- + +# Managing session context + +This skill owns the **LCM compression and maintenance lifecycle** — the write +and health side of the session store. It is the counterpart to +`tracedecay:recalling-session-context`, which owns retrieval (grep, replay, +summary-DAG expansion). These lifecycle tools are **host-agent integration +tools**: invoke them when the host is managing its own context window or when +the user explicitly asks to compress, repair, or inspect the LCM store — not +casually during recall. + +## Lifecycle tools + +All take `--provider` and (except doctor/status) `--session-id`. All default to +`storage_scope: "project_local"`; pass `hermes_profile` with an absolute +`hermes_home` only when the user targets a Hermes profile store. + +1. **Preflight → `tracedecay_lcm_preflight`** (`provider`, `session-id`, plus + token knobs like `current-tokens`, `threshold-tokens`, `context-length`, + `reserve-tokens-floor`, `max-assembly-tokens`, `fresh-tail-count`): decide + *whether* compression should run before doing it. Read-only planning call. +2. **Compress → `tracedecay_lcm_compress`** (same core args plus + `focus-topic`, `summarizer`, `expected-current-frontier-store-id` as an + optimistic guard): advance the compression lifecycle. **Mutates** the store. + Use `expected-current-frontier-store-id` to no-op safely if the frontier + moved under you. +3. **Session boundary → `tracedecay_lcm_session_boundary`** (`provider`, + `session-id`, `old-session-id`, `bound-session-id`, `boundary-reason`): + report that the host crossed a compression boundary. A mismatch between the + bound and old session skips carry-over and starts a short cooldown. +4. **Doctor → `tracedecay_lcm_doctor`** (`provider`, `mode`: + `diagnose`|`repair`|`retention`|`clean`|`gc`, `apply`, optional + `session-id`): bounded diagnostics and safe repairs. `diagnose`/`retention` + are read-only; `repair`/`clean`/`gc` **mutate only with `apply: true`** and + are further gated by safety flags/env for clean and gc. +5. **Status → `tracedecay_lcm_status`** (optional `provider`, `session-id`, + `deep`): schema/message/summary/payload counts, token estimates, summary + depth distribution + compression ratio, payload byte totals, and GC status. + Read-only; `deep: true` adds an on-disk integrity sweep. + +## Typical flow + +Preflight → (if it requests compression) compress → status to confirm the ratio +moved. On a real host session change, call session_boundary. If counts look +wrong (missing sessions, stale FTS, orphaned payloads) run doctor +`mode: "diagnose"` first, review, then repair/clean/gc with `apply: true` only +on explicit user intent. + +## Guardrails + +- `preflight`, `status`, and doctor `diagnose`/`retention` are read-only. + `compress`, `session_boundary`, and doctor `repair`/`clean`/`gc` + `apply` + **mutate** durable session state — run them only with clear lifecycle or user + intent, never speculatively. +- `provider` is required and `all` is rejected for these lifecycle tools; target + one provider at a time. +- Do not let subagents drive compression, boundaries, or repair; those are + parent-agent/host responsibilities. +- Keep token knobs conservative; over-aggressive compression loses replay + fidelity that `tracedecay:recalling-session-context` depends on. + +## Handoff + +- Retrieving past-session content (grep, replay, summary-DAG expansion) → `tracedecay:recalling-session-context`. +- CLI fallback when MCP transport fails → `tracedecay:using-the-cli`. + +## Output + +- The lifecycle action taken (preflight decision, compression result, boundary + outcome, or store counts), whether it was read-only or mutating, and the + resulting compression ratio / health signals. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/cursor-plugin/skills/retrieving-cached-context/SKILL.md b/cursor-plugin/skills/retrieving-cached-context/SKILL.md new file mode 100644 index 000000000..ac20655ce --- /dev/null +++ b/cursor-plugin/skills/retrieving-cached-context/SKILL.md @@ -0,0 +1,60 @@ +--- +name: retrieving-cached-context +description: 'Use when a tracedecay response was truncated with a handle and the missing detail is needed — dereference the cached original with tracedecay_retrieve instead of re-running, or expand one LCM node.' +--- + +# Retrieving cached context + +TraceDecay truncates large tool responses and emits a **handle** envelope +instead of the full body. The original text is cached in the active-project +store; you dereference it with `tracedecay_retrieve` rather than re-running the +source tool. This skill covers that handle/caching mechanic and the related +single-node expansion via `tracedecay_lcm_expand`. + +## When to retrieve vs re-run + +- A prior response ended with a `handle` (e.g. `rh_…`) and the missing details + are actually needed to answer the user → **retrieve the handle**. Do not + re-run the broad query, guess, or read a file again. +- You do NOT need the truncated tail → leave it; retrieval costs tokens. +- The result was truncated because the query was too broad → also consider + narrowing the original query next time (see `tracedecay:using-tracedecay`). + +## Tools + +1. **Dereference a handle → `tracedecay_retrieve`** (`handle` required, copied + exactly from the truncated envelope). It returns the **exact cached original + text** — it does not re-run the source tool or re-read a file/session/node. + Handles are scoped to the active project store, expire automatically, and + never reference remote storage. If the truncated response used a + `project-id`/`project-path` selector, pass the same selector to `retrieve`. +2. **Expand one LCM node → `tracedecay_lcm_expand`** (`provider`, `session-id`, + `target` with `kind`: `raw_message`|`summary_node`|`external_payload`): + opens a single session node through the bounded LCM query API. Page a summary + node's sources with `source-offset`/`source-limit`, and page long content + with `content-offset`/`content-limit`. If a returned source has + `content_truncated: true`, continue via `target.kind: "raw_message"` for that + source's `store_id` and `content_offset`. + +## Guardrails + +- Both tools are **read-only**; they surface already-cached content and never + mutate state. +- Retrieve only what you need — handles and node expansion are bounded on + purpose; do not dump the full cached body when a slice answers the question. +- Handles expire; if `retrieve` reports an expired/unknown handle, re-run the + original tool with a narrower query rather than retrying the stale handle. +- Handles are local and project-scoped — never treat them as durable + references to store or reuse across sessions. + +## Handoff + +- Finding which session node to expand (grep, replay, summary-DAG shape) → `tracedecay:recalling-session-context`. +- Driving compression that produces those summary nodes → `tracedecay:managing-session-context`. +- General "narrow the query instead of re-running" guidance → `tracedecay:using-tracedecay`. + +## Output + +- The retrieved cached text or expanded node content, and a note that it came + from a handle/cache rather than a fresh query. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/cursor-plugin/skills/retrieving-project-memory/SKILL.md b/cursor-plugin/skills/retrieving-project-memory/SKILL.md new file mode 100644 index 000000000..18da30278 --- /dev/null +++ b/cursor-plugin/skills/retrieving-project-memory/SKILL.md @@ -0,0 +1,69 @@ +--- +name: retrieving-project-memory +description: 'Use when querying or reasoning over stored tracedecay memory facts — searching, probing by entity, multi-fact reasoning, or fetching a fact with trust history. For recall framing see recalling-project-memory.' +--- + +# Retrieving project memory + +This skill owns the **read/reason mechanics** of the holographic fact store: +the exact `tracedecay_fact_store` retrieval actions plus +`tracedecay_memory_status`. It is the mechanical counterpart to +`tracedecay:recalling-project-memory` (which frames memory recall around a task +or decision and starts from transcripts). When the question is "what does the +fact store know about X and how do the facts relate," start here. + +## Retrieval actions (`tracedecay_fact_store`) + +All read-mostly; they may update access/retrieval metadata but do not add or +delete facts. Read-only project selectors (`project-id` / `project-path`) are +supported for these actions. + +1. **search** (`query`, optional `category`, `limit` default 20 / max 200, + `min_trust`) — phase-vector similarity search; the default entry point for + "find facts about X." +2. **probe** (`entity` / `query`) — probe memory around a single named entity. +3. **related** (`entity`) — facts connected to an entity via stored relations. +4. **reason** (`query`, `entities`) — assemble and reason over multiple facts + for a query, following entity relations rather than returning a flat list. +5. **get** (`fact-id`) — the full fact plus its `trust_history`, so you can + answer *why* a trust score is what it is. +6. **list** (optional `category`, `min_trust`, `limit`) — enumerate stored + facts for review. +7. **contradict** (`threshold`) — scan for contradictory facts; non-destructive. + +## Memory health + +- **`tracedecay_memory_status`** — fact/entity counts, trust distribution, + below-threshold and missing-vector signals, capacity-per-bank, and repair + stats. Note it **repairs** derived vectors/banks as a side effect, so call it + when the user asks for memory counts/health, not on every recall. + +## How to query and reason + +- Prefer `search`/`probe` to locate candidates, then `reason` (or `related`) + when the answer spans several linked facts. +- Use `min_trust` to filter out low-confidence facts; use `get` on a specific + `fact-id` when the user challenges a fact or asks why its trust changed. +- Keep retrieval bounded and token-aware: set `limit` deliberately and narrow + `query`/`category` rather than pulling the whole store with a broad `list`. + +## Guardrails + +- search/probe/related/reason/get/list/contradict are read-only recall (they + may touch access counters); they never mutate fact content. +- `tracedecay_memory_status` mutates derived state (vector/bank repair) — treat + it as a health action, not a passive read. +- Recall memory before external or web search — a prior session likely already + answered the question, cheaper and project-specific. + +## Handoff + +- Task/decision recall that should start from transcripts → `tracedecay:recalling-project-memory`. +- Persisting a new durable fact → `tracedecay:storing-project-memory`. +- Fixing stale/contradictory/duplicate facts → `tracedecay:curating-project-memory`. + +## Output + +- The facts found/reasoned over with their ids, trust, and source, plus which + action answered the question. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/cursor-plugin/skills/storing-project-memory/SKILL.md b/cursor-plugin/skills/storing-project-memory/SKILL.md new file mode 100644 index 000000000..cda1d454a --- /dev/null +++ b/cursor-plugin/skills/storing-project-memory/SKILL.md @@ -0,0 +1,82 @@ +--- +name: storing-project-memory +description: 'Use when writing a durable fact to tracedecay memory — persisting a decision, preference, correction, pitfall, or entity relation, and handling near-duplicate/conflict/secret write diffs. For cleanup see curating-project-memory.' +--- + +# Storing project memory + +This skill owns the **write path** into holographic memory: turning a durable +decision or fact into a stored `tracedecay_fact_store` record. It is the +narrow "add/update/relate" counterpart to `tracedecay:curating-project-memory` +(dedup, merge, delete, whole-subject memorization) and +`tracedecay:retrieving-project-memory` (read/reason). Store proactively +whenever a durable decision, user preference, correction, or pitfall surfaces — +do not wait for the user to ask. + +## When to store vs not + +Store only **durable, project-scoped** facts: + +- Store: architectural/design decisions, user or project preferences, hard-won + corrections, recurring pitfalls, stable conventions, entity relationships. +- Do NOT store: secrets/credentials/API keys/PII, transient errors, + environment-specific failures, task progress, one-off narratives, or anything + that goes stale when the session ends — recover those from transcripts via + `tracedecay:recalling-session-context` instead. + +## Workflow + +1. **Dedupe first (read-only):** search before you write with + `tracedecay_fact_store` `action: "search"` (`query` = subject + candidate, + optional `category`, `limit: 10`, `min_trust: 0.5`). If a near-match exists, + prefer an update over a second add. +2. **Add a fact → `tracedecay_fact_store`** `action: "add"` with `content` + (the durable claim), `category`, `source` (provenance label), `tags`, + `entities` (named entities the fact concerns), `trust`, and optional + `metadata` (subject/confidence/citations). The add result carries a + write-time diff — always read it (see below). +3. **Update an existing fact → `tracedecay_fact_store`** `action: "update"` + with `fact-id` plus the changed `content`/`trust`/`tags`/`category`. Prefer + update when correcting or refining a fact so provenance survives. +4. **Relate entities → `tracedecay_fact_store`** `action: "relate"` (with + `entities` / `entity`) to record a relationship between named entities the + facts concern. +5. **Calibrate trust deliberately** — do not default high. Aim for a spread: + `>=0.85` for independently verified/durable decisions, `~0.7` for ordinary + well-sourced facts, `~0.5` for plausible-but-unsure. Do not lower trust + merely because a fact is old; cite newer evidence instead. + +## Reading the add diff + +Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / +`reason`. Act on it, never ignore it: + +- `near_duplicate` — a very similar fact exists; prefer `action: "update"` on + `closest_fact_id` rather than storing a second copy. +- `possible_conflict` — a negation/state-change cue suggests supersession; + confirm which fact is current before leaving both in place (hand off to + `tracedecay:curating-project-memory` if a merge/delete is needed). +- `rejected_secret_like` — credential-like content was **NOT** stored. Never + rephrase or obfuscate a rejected secret to bypass the filter. + +## Guardrails + +- `search` is read-only; `add`, `update`, and `relate` **mutate** memory state. + `search`/`probe`/`related`/`reason` may update access/retrieval counters. +- Deletion is permanent and lives in `tracedecay:curating-project-memory`, not + here — prefer update/relate over creating removable clutter. +- Never store secrets, credentials, keys, or PII; rely on the built-in + `rejected_secret_like` filter as a backstop, not a first line. +- Only the parent agent should call `add`/`update`/`relate`. Subagents may + gather cited evidence and candidate facts; the parent validates and writes. + +## Handoff + +- Dedup, merge, delete, or memorize a whole subject → `tracedecay:curating-project-memory`. +- Read, probe, or reason over stored facts → `tracedecay:retrieving-project-memory`. + +## Output + +- The fact(s) stored/updated with their ids, the trust assigned, and any + `near_duplicate` / `possible_conflict` / `rejected_secret_like` diff acted on. +- If any result includes a `tracedecay_metrics:` line, report the savings to the user. diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 46f8f53e4..770530a3d 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -1,20 +1,34 @@ // Rust guideline compliant 2025-10-17 //! Claude Code agent integration. //! -//! Handles registration of the tracedecay MCP server in Claude Code's config -//! files (`~/.claude.json`, `~/.claude/settings.json`), tool permissions, -//! lifecycle hooks (`PreToolUse`, `UserPromptSubmit`, `Stop`, `SessionStart`, -//! `PostToolUse`), CLAUDE.md prompt rules, and health checks. +//! tracedecay installs into Claude Code as a first-class **plugin bundle** +//! (the authored `claude-plugin/` tree) via a local `directory` marketplace, +//! rather than by hand-editing Claude's shared MCP/hook config. The bundle +//! ships its own `.mcp.json`, `hooks/hooks.json`, subagents, skills, and slash +//! commands; the installer only has to: +//! +//! 1. Deploy the embedded bundle to a stable marketplace dir +//! (`~/.claude/plugins/marketplaces/tracedecay/`), stamping the plugin +//! version and substituting the resolved tracedecay binary path. +//! 2. Register that dir as a `directory` marketplace in +//! `~/.claude/plugins/known_marketplaces.json`. +//! 3. Enable `tracedecay@tracedecay` in `~/.claude/settings.json`. +//! +//! It also migrates users off the previous config-managed integration +//! (loose `~/.claude.json` MCP entry, tracedecay hooks in `settings.json`, +//! loose `~/.claude/agents/*.md`) which the plugin now provides. The MCP +//! tool-permission allowlist and the CLAUDE.md steering block have no plugin +//! equivalent and are preserved. use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::json; use crate::errors::{Result, TraceDecayError}; use super::{ - backup_and_write_json, backup_config_file, expected_tool_perms, load_json_file_strict, + backup_and_write_json, expected_tool_perms, load_json_file, load_json_file_strict, safe_write_json_file, safe_write_text_file, write_json_file, AgentIntegration, DoctorCounters, HealthcheckContext, InstallContext, UpdatePluginOutcome, }; @@ -34,31 +48,39 @@ impl AgentIntegration for ClaudeIntegration { fn install(&self, ctx: &InstallContext) -> Result<()> { let claude_dir = ctx.home.join(".claude"); let settings_path = claude_dir.join("settings.json"); - let claude_json_path = ctx.home.join(".claude.json"); let claude_md_path = claude_dir.join("CLAUDE.md"); - install_mcp_server(&claude_json_path, &ctx.tracedecay_bin)?; - ensure_claude_dir(&claude_dir)?; + + // Deploy the plugin bundle + register the marketplace + enable it. + let deploy_dir = deploy_plugin_bundle(&ctx.home, &ctx.tracedecay_bin)?; + register_marketplace(&ctx.home, &deploy_dir)?; + let mut settings = load_json_file_strict(&settings_path)?; - install_migrate_old_mcp(&mut settings, &settings_path); - install_hook(&mut settings, &ctx.tracedecay_bin); + enable_plugin(&mut settings); + // Preserve MCP tool auto-approval: removing it would reintroduce + // per-tool prompts even though the server is now plugin-provided. install_permissions(&mut settings, &ctx.tool_permissions); write_json_file(&settings_path, &settings)?; + // Migrate off the old config-managed integration (idempotent). + migrate_off_config_managed(&ctx.home); + install_claude_md_rules(&claude_md_path)?; super::install_managed_skill_prompt_index( &ctx.home, &claude_md_path, crate::automation::skill_targets::SkillInstallTarget::Claude, )?; - install_subagents(&claude_dir)?; install_clean_local_config(); eprintln!(); eprintln!("Setup complete. Next steps:"); eprintln!(" 1. cd into your project and run: tracedecay init"); - eprintln!(" 2. Start a new Claude Code session — TraceDecay tools are now available"); + eprintln!( + " 2. The tracedecay plugin is installed and enabled — restart Claude Code so it \ + loads the plugin (MCP server, hooks, subagents, skills, and slash commands)" + ); Ok(()) } @@ -67,66 +89,77 @@ impl AgentIntegration for ClaudeIntegration { } fn install_local(&self, ctx: &InstallContext, project_path: &Path) -> Result<()> { + // Claude Code plugins are global (they live under `~/.claude/plugins` + // and are enabled per-user). There is no robust project-scoped plugin + // install that mirrors the codex repo bundle, so a `--local` install + // ensures the global plugin is present and adds the project-scoped + // CLAUDE.md steering rules, which are the genuinely project-local part. + self.install(ctx)?; + let claude_dir = project_path.join(".claude"); - let settings_path = claude_dir.join("settings.json"); let claude_md_path = claude_dir.join("CLAUDE.md"); - - install_mcp_server(&project_path.join(".mcp.json"), &ctx.tracedecay_bin)?; - ensure_claude_dir(&claude_dir)?; - let mut settings = load_json_file_strict(&settings_path)?; - install_hook(&mut settings, &ctx.tracedecay_bin); - install_permissions(&mut settings, &ctx.tool_permissions); - write_json_file(&settings_path, &settings)?; - install_claude_md_rules(&claude_md_path)?; super::install_managed_skill_prompt_index( &ctx.home, &claude_md_path, crate::automation::skill_targets::SkillInstallTarget::Claude, - )?; - install_subagents(&claude_dir) + ) } fn uninstall(&self, ctx: &InstallContext) -> Result<()> { let claude_dir = ctx.home.join(".claude"); let settings_path = claude_dir.join("settings.json"); - let claude_json_path = ctx.home.join(".claude.json"); let claude_md_path = claude_dir.join("CLAUDE.md"); - uninstall_mcp_server(&claude_json_path); + // Remove the plugin: marketplace registration, enablement, deployed dir. + unregister_marketplace(&ctx.home)?; uninstall_settings(&settings_path); + remove_deployed_bundle(&ctx.home)?; + super::remove_managed_skill_prompt_index( &ctx.home, &claude_md_path, crate::automation::skill_targets::SkillInstallTarget::Claude, )?; uninstall_claude_md_rules(&claude_md_path); - uninstall_subagents(&claude_dir); eprintln!(); eprintln!("Uninstall complete. TraceDecay has been removed from Claude Code."); - eprintln!("Start a new Claude Code session for changes to take effect."); + eprintln!("Restart Claude Code for changes to take effect."); Ok(()) } fn update_plugin(&self, ctx: &InstallContext) -> Result { - let refreshed = refresh_installed_subagents(&ctx.home.join(".claude"))?; - if refreshed.is_empty() { - // MCP entry, hooks, permissions, and CLAUDE.md rules are all - // shared-config surfaces; `tracedecay reinstall` reconciles those. - Ok(UpdatePluginOutcome::ConfigOnly) - } else { - Ok(UpdatePluginOutcome::Refreshed(refreshed)) + let claude_dir = ctx.home.join(".claude"); + let settings_path = claude_dir.join("settings.json"); + + if !plugin_marketplace_manifest_path(&ctx.home).exists() + && !has_config_managed_leftovers(&ctx.home) + { + return Ok(UpdatePluginOutcome::NotInstalled); } + + // Redeploy the bundle at the current version, refresh the marketplace + // path, ensure enablement, and re-run migration. + let deploy_dir = deploy_plugin_bundle(&ctx.home, &ctx.tracedecay_bin)?; + register_marketplace(&ctx.home, &deploy_dir)?; + + let mut settings = load_json_file_strict(&settings_path)?; + enable_plugin(&mut settings); + write_json_file(&settings_path, &settings)?; + + migrate_off_config_managed(&ctx.home); + + Ok(UpdatePluginOutcome::Refreshed(vec![deploy_dir])) } fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { eprintln!("\n\x1b[1mClaude Code integration\x1b[0m"); - doctor_check_claude_json(dc, &ctx.home); - doctor_check_settings_json(dc, &ctx.home); + doctor_check_plugin(dc, &ctx.home); + doctor_check_permissions_json(dc, &ctx.home); doctor_check_claude_md(dc, &ctx.home); - doctor_check_subagents(dc, &ctx.home); + doctor_check_config_managed_leftovers(dc, &ctx.home); doctor_check_local_config(dc, &ctx.project_path); } @@ -154,7 +187,15 @@ impl AgentIntegration for ClaudeIntegration { profile_root: &Path, ) -> Result> { let claude_md_path = project_root.join(".claude").join("CLAUDE.md"); - if !local_mcp_has_tracedecay(project_root) || !claude_md_path.exists() { + // Only refresh a project that is actually tracedecay-managed. A project + // qualifies when its local `.mcp.json` declares the tracedecay server + // (the install/init signal) or its `.claude/CLAUDE.md` references + // tracedecay. An unrelated project `.claude/CLAUDE.md` with neither + // signal must not become an export destination. + if !claude_md_path.exists() + || !(local_mcp_has_tracedecay(project_root) + || claude_md_references_tracedecay(&claude_md_path)) + { return Ok(Vec::new()); } Ok(vec![ @@ -171,262 +212,631 @@ impl AgentIntegration for ClaudeIntegration { } fn primary_config_path(&self, home: &Path) -> Option { - Some(home.join(".claude.json")) + Some(plugin_marketplace_manifest_path(home)) } fn has_tracedecay(&self, home: &Path) -> bool { - let claude_json = home.join(".claude.json"); - if !claude_json.exists() { - return false; - } - let json = super::load_json_file(&claude_json); - json.get("mcpServers") - .and_then(|v| v.get("tracedecay")) - .is_some() + // Installed as a plugin (marketplace manifest deployed), or still on + // the legacy config-managed path (loose ~/.claude.json MCP entry). + plugin_marketplace_manifest_path(home).exists() || config_managed_mcp_present(home) } } +/// True when the legacy loose MCP server entry is still present in +/// `~/.claude.json`. +fn config_managed_mcp_present(home: &Path) -> bool { + let claude_json = home.join(".claude.json"); + if !claude_json.exists() { + return false; + } + let json = load_json_file(&claude_json); + json.get("mcpServers") + .and_then(|v| v.get("tracedecay")) + .is_some() +} + +/// True when a project's local `.mcp.json` declares the tracedecay MCP server, +/// marking the project as a tracedecay-managed Claude workspace (the signal +/// `tracedecay init` writes, independent of CLAUDE.md content). fn local_mcp_has_tracedecay(project_root: &Path) -> bool { let mcp_path = project_root.join(".mcp.json"); if !mcp_path.exists() { return false; } - let json = super::load_json_file(&mcp_path); + let json = load_json_file(&mcp_path); json.get("mcpServers") .and_then(|servers| servers.get("tracedecay")) .is_some() } // --------------------------------------------------------------------------- -// Install helpers +// Plugin bundle: embedding + deploy // --------------------------------------------------------------------------- -fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { - std::fs::create_dir_all(claude_dir).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to create Claude settings directory {}: {e}", - claude_dir.display() - ), - }) +/// The marketplace name (matches the plugin name `tracedecay`), yielding the +/// `tracedecay@tracedecay` plugin identifier Claude Code enables by. +const MARKETPLACE_NAME: &str = "tracedecay"; +const PLUGIN_IDENTIFIER: &str = "tracedecay@tracedecay"; + +/// Placeholder in `hooks/hooks.json` replaced with the resolved absolute +/// tracedecay binary path at deploy time. +const TRACEDECAY_BIN_PLACEHOLDER: &str = "__TRACEDECAY_BIN__"; + +/// Every file under `claude-plugin/`, embedded so released binaries can deploy +/// the bundle without shipping the source tree. Paths are relative to the +/// deploy dir; the deploy loop preserves them and creates parent dirs. +/// +/// Coverage of the whole `claude-plugin/` tree is enforced by +/// `claude_embedded_file_list_covers_the_whole_source_bundle`. +const CLAUDE_EMBEDDED_PLUGIN_FILES: &[(&str, &str)] = &[ + ( + ".claude-plugin/marketplace.json", + include_str!("../../claude-plugin/.claude-plugin/marketplace.json"), + ), + ( + ".claude-plugin/plugin.json", + include_str!("../../claude-plugin/.claude-plugin/plugin.json"), + ), + (".mcp.json", include_str!("../../claude-plugin/.mcp.json")), + ("README.md", include_str!("../../claude-plugin/README.md")), + ( + "agents/code-explorer.md", + include_str!("../../claude-plugin/agents/code-explorer.md"), + ), + ( + "agents/code-health-auditor.md", + include_str!("../../claude-plugin/agents/code-health-auditor.md"), + ), + ( + "agents/session-historian.md", + include_str!("../../claude-plugin/agents/session-historian.md"), + ), + ( + "commands/audit-safety.md", + include_str!("../../claude-plugin/commands/audit-safety.md"), + ), + ( + "commands/check-health.md", + include_str!("../../claude-plugin/commands/check-health.md"), + ), + ( + "commands/clean-dead-code.md", + include_str!("../../claude-plugin/commands/clean-dead-code.md"), + ), + ( + "commands/compare-branches.md", + include_str!("../../claude-plugin/commands/compare-branches.md"), + ), + ( + "commands/curate-memory.md", + include_str!("../../claude-plugin/commands/curate-memory.md"), + ), + ( + "commands/draft-commit.md", + include_str!("../../claude-plugin/commands/draft-commit.md"), + ), + ( + "commands/find-impact.md", + include_str!("../../claude-plugin/commands/find-impact.md"), + ), + ( + "commands/fix-build.md", + include_str!("../../claude-plugin/commands/fix-build.md"), + ), + ( + "commands/map-architecture.md", + include_str!("../../claude-plugin/commands/map-architecture.md"), + ), + ( + "commands/port-code.md", + include_str!("../../claude-plugin/commands/port-code.md"), + ), + ( + "commands/recall-memory.md", + include_str!("../../claude-plugin/commands/recall-memory.md"), + ), + ( + "commands/review-diff.md", + include_str!("../../claude-plugin/commands/review-diff.md"), + ), + ( + "commands/test-changes.md", + include_str!("../../claude-plugin/commands/test-changes.md"), + ), + ( + "hooks/hooks.json", + include_str!("../../claude-plugin/hooks/hooks.json"), + ), + ( + "skills/assessing-impact/SKILL.md", + include_str!("../../claude-plugin/skills/assessing-impact/SKILL.md"), + ), + ( + "skills/code-health/SKILL.md", + include_str!("../../claude-plugin/skills/code-health/SKILL.md"), + ), + ( + "skills/curating-project-memory/SKILL.md", + include_str!("../../claude-plugin/skills/curating-project-memory/SKILL.md"), + ), + ( + "skills/editing-safely/SKILL.md", + include_str!("../../claude-plugin/skills/editing-safely/SKILL.md"), + ), + ( + "skills/exploring-code/SKILL.md", + include_str!("../../claude-plugin/skills/exploring-code/SKILL.md"), + ), + ( + "skills/fixing-build-and-type-errors/SKILL.md", + include_str!("../../claude-plugin/skills/fixing-build-and-type-errors/SKILL.md"), + ), + ( + "skills/inspecting-managed-skills/SKILL.md", + include_str!("../../claude-plugin/skills/inspecting-managed-skills/SKILL.md"), + ), + ( + "skills/managing-session-context/SKILL.md", + include_str!("../../claude-plugin/skills/managing-session-context/SKILL.md"), + ), + ( + "skills/recalling-project-memory/SKILL.md", + include_str!("../../claude-plugin/skills/recalling-project-memory/SKILL.md"), + ), + ( + "skills/recalling-session-context/SKILL.md", + include_str!("../../claude-plugin/skills/recalling-session-context/SKILL.md"), + ), + ( + "skills/retrieving-cached-context/SKILL.md", + include_str!("../../claude-plugin/skills/retrieving-cached-context/SKILL.md"), + ), + ( + "skills/retrieving-project-memory/SKILL.md", + include_str!("../../claude-plugin/skills/retrieving-project-memory/SKILL.md"), + ), + ( + "skills/reviewing-changes/SKILL.md", + include_str!("../../claude-plugin/skills/reviewing-changes/SKILL.md"), + ), + ( + "skills/storing-project-memory/SKILL.md", + include_str!("../../claude-plugin/skills/storing-project-memory/SKILL.md"), + ), + ( + "skills/tracedecay-audit-safety/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-audit-safety/SKILL.md"), + ), + ( + "skills/tracedecay-check-health/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-check-health/SKILL.md"), + ), + ( + "skills/tracedecay-clean-dead-code/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-clean-dead-code/SKILL.md"), + ), + ( + "skills/tracedecay-compare-branches/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-compare-branches/SKILL.md"), + ), + ( + "skills/tracedecay-curate-memory/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-curate-memory/SKILL.md"), + ), + ( + "skills/tracedecay-draft-commit/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-draft-commit/SKILL.md"), + ), + ( + "skills/tracedecay-find-impact/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-find-impact/SKILL.md"), + ), + ( + "skills/tracedecay-fix-build/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-fix-build/SKILL.md"), + ), + ( + "skills/tracedecay-map-architecture/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-map-architecture/SKILL.md"), + ), + ( + "skills/tracedecay-port-code/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-port-code/SKILL.md"), + ), + ( + "skills/tracedecay-recall-memory/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-recall-memory/SKILL.md"), + ), + ( + "skills/tracedecay-review-diff/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-review-diff/SKILL.md"), + ), + ( + "skills/tracedecay-test-changes/SKILL.md", + include_str!("../../claude-plugin/skills/tracedecay-test-changes/SKILL.md"), + ), + ( + "skills/tracing-functions/SKILL.md", + include_str!("../../claude-plugin/skills/tracing-functions/SKILL.md"), + ), + ( + "skills/using-the-cli/SKILL.md", + include_str!("../../claude-plugin/skills/using-the-cli/SKILL.md"), + ), + ( + "skills/using-tracedecay/SKILL.md", + include_str!("../../claude-plugin/skills/using-tracedecay/SKILL.md"), + ), +]; + +/// The stable marketplace/deploy root. It contains +/// `.claude-plugin/marketplace.json` plus the plugin component dirs at root +/// (plugin source is `"./"`), so it doubles as the plugin dir. +fn plugin_deploy_dir(home: &Path) -> PathBuf { + home.join(".claude/plugins/marketplaces/tracedecay") } -/// Register MCP server in ~/.claude.json. -fn install_mcp_server(claude_json_path: &Path, tracedecay_bin: &str) -> Result<()> { - let backup = backup_config_file(claude_json_path)?; - let mut claude_json = match load_json_file_strict(claude_json_path) { - Ok(v) => v, - Err(e) => { - if let Some(ref b) = backup { - eprintln!(" Backup preserved at: {}", b.display()); - } - return Err(e); - } - }; +/// The deployed marketplace manifest — presence signals a plugin install. +fn plugin_marketplace_manifest_path(home: &Path) -> PathBuf { + plugin_deploy_dir(home).join(".claude-plugin/marketplace.json") +} - claude_json["mcpServers"]["tracedecay"] = json!({ - "command": tracedecay_bin, - "args": ["serve"] - }); +/// `~/.claude/plugins/known_marketplaces.json`. +fn known_marketplaces_path(home: &Path) -> PathBuf { + home.join(".claude/plugins/known_marketplaces.json") +} - safe_write_json_file(claude_json_path, &claude_json, backup.as_deref())?; +/// Deploy every embedded bundle file into the stable marketplace dir, +/// stamping the plugin version and substituting the tracedecay binary path. +/// Returns the deploy dir. +fn deploy_plugin_bundle(home: &Path, tracedecay_bin: &str) -> Result { + let deploy_dir = plugin_deploy_dir(home); + for &(relative, contents) in CLAUDE_EMBEDDED_PLUGIN_FILES { + let rendered = render_plugin_file(relative, contents, tracedecay_bin)?; + safe_write_text_file(&deploy_dir.join(relative), &rendered, None)?; + } eprintln!( - "\x1b[32m✔\x1b[0m Added tracedecay MCP server to {}", - claude_json_path.display() + "\x1b[32m✔\x1b[0m Deployed tracedecay plugin bundle to {}", + deploy_dir.display() ); - Ok(()) + Ok(deploy_dir) } -/// Remove stale MCP server from old location in settings.json. -/// -/// Removes the tracedecay key from the old settings location. -fn install_migrate_old_mcp(settings: &mut serde_json::Value, settings_path: &Path) { - if let Some(servers) = settings - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - { - if servers.remove("tracedecay").is_some() { - if servers.is_empty() { - settings.as_object_mut().map(|o| o.remove("mcpServers")); - } +/// Apply per-file deploy-time substitutions: +/// - `plugin.json`: stamp `version` from the crate version. +/// - `.mcp.json`: set the server `command` to the absolute binary path. +/// - `hooks/hooks.json`: replace the `__TRACEDECAY_BIN__` placeholder. +fn render_plugin_file(relative: &str, contents: &str, tracedecay_bin: &str) -> Result { + match relative { + ".claude-plugin/plugin.json" => stamp_plugin_version(contents), + ".mcp.json" => set_mcp_command(contents, tracedecay_bin), + "hooks/hooks.json" => Ok(contents.replace(TRACEDECAY_BIN_PLACEHOLDER, tracedecay_bin)), + _ => Ok(contents.to_string()), + } +} + +/// Stamp the plugin manifest `version` with the crate version. +fn stamp_plugin_version(raw: &str) -> Result { + let mut manifest: serde_json::Value = serde_json::from_str(raw)?; + manifest["version"] = json!(env!("CARGO_PKG_VERSION")); + Ok(format!("{}\n", serde_json::to_string_pretty(&manifest)?)) +} + +/// Set the plugin `.mcp.json` server command to the resolved absolute binary +/// path, so the plugin does not rely on `tracedecay` being on PATH. +fn set_mcp_command(raw: &str, tracedecay_bin: &str) -> Result { + let mut mcp: serde_json::Value = serde_json::from_str(raw)?; + mcp["mcpServers"]["tracedecay"]["command"] = json!(tracedecay_bin); + Ok(format!("{}\n", serde_json::to_string_pretty(&mcp)?)) +} + +/// Remove the deployed bundle dir (idempotent; only touches the tracedecay +/// marketplace dir). +fn remove_deployed_bundle(home: &Path) -> Result<()> { + let deploy_dir = plugin_deploy_dir(home); + match std::fs::remove_dir_all(&deploy_dir) { + Ok(()) => { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from old location ({})", - settings_path.display() + "\x1b[32m✔\x1b[0m Removed deployed plugin bundle at {}", + deploy_dir.display() ); + Ok(()) } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(TraceDecayError::Config { + message: format!("failed to remove {}: {e}", deploy_dir.display()), + }), } } -/// Add all tracedecay hooks (idempotent). Prints progress messages. -fn install_hook(settings: &mut serde_json::Value, tracedecay_bin: &str) { - install_hook_inner(settings, tracedecay_bin, false); +// --------------------------------------------------------------------------- +// Plugin bundle: marketplace registration + enablement +// --------------------------------------------------------------------------- + +/// Merge the tracedecay `directory` marketplace entry into +/// `known_marketplaces.json`, preserving any existing marketplaces. Idempotent. +fn register_marketplace(home: &Path, deploy_dir: &Path) -> Result<()> { + let path = known_marketplaces_path(home); + let mut known = load_json_file_strict(&path)?; + if !known.is_object() { + known = json!({}); + } + known[MARKETPLACE_NAME] = json!({ + "source": { + "source": "directory", + "path": deploy_dir.to_string_lossy(), + } + }); + write_json_file(&path, &known)?; + eprintln!( + "\x1b[32m✔\x1b[0m Registered tracedecay marketplace in {}", + path.display() + ); + Ok(()) } -/// Add all tracedecay hooks silently (for post-upgrade migration). -fn install_hook_quiet(settings: &mut serde_json::Value, tracedecay_bin: &str) { - install_hook_inner(settings, tracedecay_bin, true); +/// Remove the tracedecay marketplace entry from `known_marketplaces.json`, +/// preserving every other marketplace. Idempotent. +fn unregister_marketplace(home: &Path) -> Result<()> { + let path = known_marketplaces_path(home); + if !path.exists() { + return Ok(()); + } + let mut known = load_json_file_strict(&path)?; + let removed = known + .as_object_mut() + .is_some_and(|obj| obj.remove(MARKETPLACE_NAME).is_some()); + if !removed { + return Ok(()); + } + let is_empty = known.as_object().is_some_and(serde_json::Map::is_empty); + if is_empty { + std::fs::remove_file(&path).ok(); + eprintln!("\x1b[32m✔\x1b[0m Removed {} (was empty)", path.display()); + } else { + safe_write_json_file(&path, &known, None)?; + eprintln!( + "\x1b[32m✔\x1b[0m Removed tracedecay marketplace from {}", + path.display() + ); + } + Ok(()) } -struct ManagedHook { - event: &'static str, - subcommand: &'static str, - matcher: Option String>, +/// Merge `enabledPlugins.tracedecay@tracedecay = true` into settings, +/// preserving existing keys. Idempotent. +fn enable_plugin(settings: &mut serde_json::Value) { + settings["enabledPlugins"][PLUGIN_IDENTIFIER] = json!(true); + eprintln!("\x1b[32m✔\x1b[0m Enabled plugin {PLUGIN_IDENTIFIER}"); } -impl ManagedHook { - fn matcher_value(&self) -> Option { - self.matcher.map(|build| build()) +/// Remove the `enabledPlugins.tracedecay@tracedecay` entry (idempotent). +/// Returns true if modified. +fn disable_plugin(settings: &mut serde_json::Value) -> bool { + let Some(enabled) = settings + .get_mut("enabledPlugins") + .and_then(|v| v.as_object_mut()) + else { + return false; + }; + if enabled.remove(PLUGIN_IDENTIFIER).is_none() { + return false; + } + if enabled.is_empty() { + settings.as_object_mut().map(|o| o.remove("enabledPlugins")); } + eprintln!("\x1b[32m✔\x1b[0m Disabled plugin {PLUGIN_IDENTIFIER}"); + true } -/// Only Agent tool calls are screened for explore-agent redirection. -fn pre_tool_use_matcher() -> String { - "Agent".to_string() -} +// --------------------------------------------------------------------------- +// Migration off the old config-managed integration +// --------------------------------------------------------------------------- -/// Every managed hook event, in registration order. -const MANAGED_HOOKS: &[ManagedHook] = &[ - ManagedHook { - event: "PreToolUse", - subcommand: "hook-pre-tool-use", - matcher: Some(pre_tool_use_matcher), - }, - ManagedHook { - event: "UserPromptSubmit", - subcommand: "hook-prompt-submit", - matcher: None, - }, - ManagedHook { - event: "Stop", - subcommand: "hook-stop", - matcher: None, - }, - ManagedHook { - event: "SessionStart", - subcommand: "hook-claude-session-start", - matcher: None, - }, - ManagedHook { - event: "PostToolUse", - subcommand: "hook-claude-post-tool-use", - matcher: Some(crate::hooks::claude_post_tool_use_matcher), - }, +/// Old subcommands whose hook entries the migration must strip from +/// `settings.json` (now provided by the plugin's `hooks/hooks.json`). Every +/// tracedecay hook command contains `"tracedecay"`, so a substring match on +/// the command is the actual removal predicate; this list documents the five +/// events the old installer wrote across. +const LEGACY_HOOK_EVENTS: &[&str] = &[ + "PreToolUse", + "UserPromptSubmit", + "Stop", + "SessionStart", + "PostToolUse", ]; -fn install_hook_inner(settings: &mut serde_json::Value, tracedecay_bin: &str, quiet: bool) { - for hook in MANAGED_HOOKS { - install_single_hook( - settings, - hook.event, - tracedecay_bin, - hook.subcommand, - hook.matcher_value().as_deref(), - quiet, - ); - } -} - -/// Install a single hook entry under `settings.hooks.` (idempotent). -/// -/// Writes the modern Claude Code shape `{type, command, args}`, where the exe -/// path is the entire `command` and the subcommand is the only entry in -/// `args`. This sidesteps Claude Code's whitespace-splitter so install paths -/// containing spaces work unchanged. -fn install_single_hook( - settings: &mut serde_json::Value, - event: &str, - tracedecay_bin: &str, - subcommand: &str, - matcher: Option<&str>, - quiet: bool, -) { - let hooks_arr = settings["hooks"][event] - .as_array() - .cloned() - .unwrap_or_default(); +/// Loose subagent files the old installer dropped into `~/.claude/agents/`. +/// The plugin now ships these under its own `agents/` dir. +const LEGACY_SUBAGENT_FILES: &[&str] = &[ + "code-explorer.md", + "code-health-auditor.md", + "session-historian.md", +]; - let has_hook = hooks_arr - .iter() - .any(|h| hook_entry_command(h).is_some_and(|c| c.contains("tracedecay"))); - - if !has_hook { - let mut new_hooks = hooks_arr; - let mut entry = json!({ - "hooks": [{ - "type": "command", - "command": tracedecay_bin, - "args": [subcommand], - }] - }); - if let Some(m) = matcher { - entry["matcher"] = json!(m); - } - new_hooks.push(entry); - settings["hooks"][event] = serde_json::Value::Array(new_hooks); - if !quiet { - eprintln!("\x1b[32m✔\x1b[0m Added {event} hook"); - } - } else if !quiet { - eprintln!(" {event} hook already present, skipping"); - } +/// Run the full migration off the config-managed integration (idempotent): +/// strip the loose MCP entry, the tracedecay hooks, and the loose subagents. +/// Keeps the permission allowlist and CLAUDE.md rules (no plugin equivalent). +fn migrate_off_config_managed(home: &Path) { + migrate_remove_loose_mcp(&home.join(".claude.json")); + migrate_remove_hooks(&home.join(".claude/settings.json")); + migrate_remove_loose_subagents(&home.join(".claude/agents")); } -/// Extract the `command` string from a hook event entry (the wrapper that -/// holds an `"hooks": [{...}]` array). Returns the first inner command. -fn hook_entry_command(entry: &serde_json::Value) -> Option<&str> { - entry - .get("hooks")? - .as_array()? - .iter() - .find_map(|c| c.get("command").and_then(|v| v.as_str())) +/// Remove `mcpServers.tracedecay` from `~/.claude.json` (now plugin-provided). +fn migrate_remove_loose_mcp(claude_json_path: &Path) { + if !claude_json_path.exists() { + return; + } + let Ok(mut claude_json) = load_json_file_strict(claude_json_path) else { + return; + }; + let Some(servers) = claude_json + .get_mut("mcpServers") + .and_then(|v| v.as_object_mut()) + else { + return; + }; + if servers.remove("tracedecay").is_none() { + return; + } + if servers.is_empty() { + claude_json.as_object_mut().map(|o| o.remove("mcpServers")); + } + if backup_and_write_json(claude_json_path, &claude_json) { + eprintln!( + "\x1b[32m✔\x1b[0m Migrated: removed config-managed MCP server from {}", + claude_json_path.display() + ); + } } -/// Parse a hook inner-entry into `(bin, subcommand)`. -/// -/// Accepts both the modern `{command, args: [subcmd]}` shape and the legacy -/// single-string `"bin subcmd"` shape (which is broken for paths with -/// spaces). The legacy variant is returned so callers can detect it and -/// rewrite, but the subcommand split is intentionally best-effort. -fn parse_hook_command(cmd_entry: &serde_json::Value) -> Option<(String, String)> { - let command = cmd_entry.get("command")?.as_str()?; - if let Some(args) = cmd_entry.get("args").and_then(|a| a.as_array()) { - let sub = args.iter().find_map(|v| v.as_str()).unwrap_or(""); - return Some((command.to_string(), sub.to_string())); - } - // Legacy single-string shape — best-effort split on first space. - let mut parts = command.splitn(2, char::is_whitespace); - let bin = parts.next().unwrap_or("").to_string(); - let sub = parts.next().unwrap_or("").to_string(); - Some((bin, sub)) +/// Remove every tracedecay hook (command contains `"tracedecay"`) from the +/// five events in `settings.json`, leaving non-tracedecay hooks intact. +fn migrate_remove_hooks(settings_path: &Path) { + if !settings_path.exists() { + return; + } + let Ok(mut settings) = load_json_file_strict(settings_path) else { + return; + }; + if remove_tracedecay_hooks(&mut settings) && backup_and_write_json(settings_path, &settings) { + eprintln!( + "\x1b[32m✔\x1b[0m Migrated: removed config-managed hooks from {}", + settings_path.display() + ); + } } -/// Find the first tracedecay hook entry under an event and return -/// `(bin, subcommand, is_legacy_shape)`. `is_legacy_shape` is true -/// when the entry uses the broken single-string command shape and needs -/// rewriting. -fn find_tracedecay_hook( - settings: &serde_json::Value, - event: &str, -) -> Option<(String, String, bool)> { - let arr = settings["hooks"][event].as_array()?; - arr.iter().find_map(|wrapper| { - let cmd_entry = wrapper.get("hooks")?.as_array()?.first()?; - let raw_command = cmd_entry.get("command").and_then(|c| c.as_str())?; - if !raw_command.contains("tracedecay") { - return None; - } - let (bin, sub) = parse_hook_command(cmd_entry)?; - let is_legacy = cmd_entry.get("args").is_none(); - Some((bin, sub, is_legacy)) - }) +/// Strip tracedecay hook entries from all managed events. Returns true if +/// anything was removed. Shared by migration and uninstall. +fn remove_tracedecay_hooks(settings: &mut serde_json::Value) -> bool { + let mut modified = false; + for event in LEGACY_HOOK_EVENTS { + modified |= remove_tracedecay_hooks_for_event(settings, event); + } + modified } -/// Add MCP tool permissions (idempotent). -fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[String]) { - let existing: Vec = settings["permissions"]["allow"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) +/// Remove tracedecay entries from a single hook event. Returns true if +/// modified. Prunes empty events (and the `hooks` key when it empties). +fn remove_tracedecay_hooks_for_event(settings: &mut serde_json::Value, event: &str) -> bool { + let Some(arr) = settings["hooks"][event].as_array().cloned() else { + return false; + }; + let before = arr.len(); + let filtered: Vec = arr + .into_iter() + .filter(|wrapper| !hook_wrapper_is_tracedecay(wrapper)) + .collect(); + if filtered.len() == before { + return false; + } + if filtered.is_empty() { + if let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) { + hooks.remove(event); + if hooks.is_empty() { + settings.as_object_mut().map(|o| o.remove("hooks")); + } + } + } else { + settings["hooks"][event] = serde_json::Value::Array(filtered); + } + true +} + +/// True when a hook-event wrapper (`{ "hooks": [{...}] }`) has any inner +/// handler whose command mentions tracedecay. +fn hook_wrapper_is_tracedecay(wrapper: &serde_json::Value) -> bool { + wrapper + .get("hooks") + .and_then(|a| a.as_array()) + .is_some_and(|arr| { + arr.iter().any(|entry| { + entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("tracedecay")) + }) + }) +} + +/// Remove the loose tracedecay-managed subagent files. A same-named file that +/// does not reference tracedecay is user-authored and left untouched. +fn migrate_remove_loose_subagents(agents_dir: &Path) { + let mut removed = 0usize; + for &file_name in LEGACY_SUBAGENT_FILES { + let path = agents_dir.join(file_name); + if path.exists() + && subagent_file_is_tracedecay_managed(&path) + && std::fs::remove_file(&path).is_ok() + { + removed += 1; + } + } + if removed > 0 { + std::fs::remove_dir(agents_dir).ok(); // only if now empty + eprintln!("\x1b[32m✔\x1b[0m Migrated: removed {removed} loose tracedecay subagent(s)"); + } +} + +/// True when a subagent file was written by tracedecay (references the tool) +/// and is therefore safe to remove. +fn subagent_file_is_tracedecay_managed(path: &Path) -> bool { + std::fs::read_to_string(path).is_ok_and(|contents| contents.contains("tracedecay")) +} + +/// True when any config-managed leftover remains (used to keep `update-plugin` +/// running the migration for users mid-upgrade, and to drive a doctor warning). +fn has_config_managed_leftovers(home: &Path) -> bool { + config_managed_mcp_present(home) + || settings_has_tracedecay_hooks(&home.join(".claude/settings.json")) + || loose_subagents_present(&home.join(".claude/agents")) +} + +fn settings_has_tracedecay_hooks(settings_path: &Path) -> bool { + if !settings_path.exists() { + return false; + } + let settings = load_json_file(settings_path); + let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else { + return false; + }; + hooks.values().any(|groups| { + groups + .as_array() + .is_some_and(|arr| arr.iter().any(hook_wrapper_is_tracedecay)) + }) +} + +fn loose_subagents_present(agents_dir: &Path) -> bool { + LEGACY_SUBAGENT_FILES.iter().any(|&file_name| { + let path = agents_dir.join(file_name); + path.exists() && subagent_file_is_tracedecay_managed(&path) + }) +} + +// --------------------------------------------------------------------------- +// Shared install helpers (permissions + CLAUDE.md) +// --------------------------------------------------------------------------- + +fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { + std::fs::create_dir_all(claude_dir).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create Claude settings directory {}: {e}", + claude_dir.display() + ), + }) +} + +/// Add MCP tool permissions (idempotent). Kept: auto-approval is orthogonal to +/// how the MCP server is registered. +fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[String]) { + let existing: Vec = settings["permissions"]["allow"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(std::string::ToString::to_string)) .collect() }) .unwrap_or_default(); @@ -462,6 +872,13 @@ const CLAUDE_MD_RECONCILE_MARKERS: &[&str] = &[ CLAUDE_MD_CODEGRAPH_MARKER, ]; +/// True when a `CLAUDE.md` is a tracedecay-managed Claude config (references +/// tracedecay), so a lifecycle skill export may refresh it. An unrelated +/// project `CLAUDE.md` must not become an export destination. +fn claude_md_references_tracedecay(claude_md_path: &Path) -> bool { + std::fs::read_to_string(claude_md_path).is_ok_and(|contents| contents.contains("tracedecay")) +} + /// Byte range of the tracedecay-managed CLAUDE.md rules block. fn claude_md_rules_block_range(contents: &str, markers: &[&str]) -> Option> { let (start, marker_end) = markers.iter().find_map(|marker| { @@ -588,114 +1005,8 @@ fn install_claude_md_rules(claude_md_path: &Path) -> Result<()> { Ok(()) } -/// Claude Code custom subagent definitions installed to -/// `/agents/.md`. Ported from `cursor-plugin/agents/` so -/// both hosts ship the same read-only tracedecay subagents. -const CLAUDE_MANAGED_AGENTS: &[(&str, &str)] = &[ - ( - "code-explorer.md", - include_str!("claude_agents/code-explorer.md"), - ), - ( - "code-health-auditor.md", - include_str!("claude_agents/code-health-auditor.md"), - ), - ( - "session-historian.md", - include_str!("claude_agents/session-historian.md"), - ), -]; - -/// True when an existing agent file was written by tracedecay and is safe to -/// replace or remove. All managed agent bodies reference tracedecay tools, so -/// a same-named file without any tracedecay mention is user-authored. -fn subagent_file_is_tracedecay_managed(path: &Path) -> bool { - std::fs::read_to_string(path).is_ok_and(|contents| contents.contains("tracedecay")) -} - -/// Write the managed subagent definitions under `/agents/`, -/// skipping any same-named file the user authored themselves. -fn install_subagents(claude_dir: &Path) -> Result<()> { - let agents_dir = claude_dir.join("agents"); - let mut installed = 0usize; - for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { - let path = agents_dir.join(file_name); - if path.exists() && !subagent_file_is_tracedecay_managed(&path) { - eprintln!( - " Skipping {} — an existing non-tracedecay agent uses that name", - path.display() - ); - continue; - } - safe_write_text_file(&path, contents, None)?; - installed += 1; - } - if installed > 0 { - eprintln!( - "\x1b[32m✔\x1b[0m Installed {installed} Claude subagent(s) in {}", - agents_dir.display() - ); - } - Ok(()) -} - -/// Remove the managed subagent definitions (managed copies only). -fn uninstall_subagents(claude_dir: &Path) { - let agents_dir = claude_dir.join("agents"); - let mut removed = 0usize; - for &(file_name, _) in CLAUDE_MANAGED_AGENTS { - let path = agents_dir.join(file_name); - if path.exists() - && subagent_file_is_tracedecay_managed(&path) - && std::fs::remove_file(&path).is_ok() - { - removed += 1; - } - } - if removed > 0 { - std::fs::remove_dir(&agents_dir).ok(); // only removes if now empty - eprintln!("\x1b[32m✔\x1b[0m Removed {removed} Claude subagent(s)"); - } -} - -/// Rewrite managed subagent files that are already installed, without -/// creating new ones — the config-free refresh used by `update-plugin`. -fn refresh_installed_subagents(claude_dir: &Path) -> Result> { - let agents_dir = claude_dir.join("agents"); - let mut refreshed = Vec::new(); - for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { - let path = agents_dir.join(file_name); - if path.exists() && subagent_file_is_tracedecay_managed(&path) { - safe_write_text_file(&path, contents, None)?; - refreshed.push(path); - } - } - Ok(refreshed) -} - -/// Check the managed subagent definitions are installed. -fn doctor_check_subagents(dc: &mut DoctorCounters, home: &Path) { - let agents_dir = home.join(".claude/agents"); - let missing: Vec<&str> = CLAUDE_MANAGED_AGENTS - .iter() - .filter_map(|&(file_name, _)| (!agents_dir.join(file_name).exists()).then_some(file_name)) - .collect(); - if missing.is_empty() { - dc.pass(&format!( - "All {} tracedecay subagents installed in {}", - CLAUDE_MANAGED_AGENTS.len(), - agents_dir.display() - )); - } else { - dc.warn(&format!( - "tracedecay subagent(s) missing in {}: {} — run `tracedecay install`", - agents_dir.display(), - missing.join(", ") - )); - } -} - -/// Clean up local project config (.mcp.json and settings.local.json). +/// Clean up local project config (.mcp.json and settings.local.json) so a +/// tracedecay MCP server only lives in the plugin, never in per-project config. fn install_clean_local_config() { let project_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); @@ -712,10 +1023,10 @@ fn install_clean_local_config() { if servers.is_empty() { std::fs::remove_file(&mcp_json_path).ok(); eprintln!( - "\x1b[32m✔\x1b[0m Removed local .mcp.json (using global config only)" + "\x1b[32m✔\x1b[0m Removed local .mcp.json (plugin provides the MCP server)" ); } else if backup_and_write_json(&mcp_json_path, &mcp_val) { - eprintln!("\x1b[32m✔\x1b[0m Removed tracedecay from local .mcp.json (using global config only)"); + eprintln!("\x1b[32m✔\x1b[0m Removed tracedecay from local .mcp.json (plugin provides the MCP server)"); } } } @@ -778,7 +1089,7 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { if is_empty { if std::fs::remove_file(local_settings_path).is_ok() { eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (tracedecay should only be in global config)", + "\x1b[32m✔\x1b[0m Removed {} (tracedecay should only be in the plugin)", local_settings_path.display() ); let claude_dir = project_path.join(".claude"); @@ -786,7 +1097,7 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { } } else if backup_and_write_json(local_settings_path, &local_val) { eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay entries from {} (should only be in global config)", + "\x1b[32m✔\x1b[0m Removed tracedecay entries from {} (should only be in the plugin)", local_settings_path.display() ); } @@ -796,63 +1107,20 @@ fn clean_local_settings_file(project_path: &Path, local_settings_path: &Path) { // Uninstall helpers // --------------------------------------------------------------------------- -/// Remove MCP server from ~/.claude.json. -fn uninstall_mcp_server(claude_json_path: &Path) { - if !claude_json_path.exists() { - return; - } - let Ok(contents) = std::fs::read_to_string(claude_json_path) else { - return; - }; - let Ok(mut claude_json) = serde_json::from_str::(&contents) else { - return; - }; - let Some(servers) = claude_json - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - else { - return; - }; - let removed = servers.remove("tracedecay").is_some(); - if !removed { - eprintln!(" No tracedecay MCP server in ~/.claude.json, skipping"); - return; - } - if servers.is_empty() { - claude_json.as_object_mut().map(|o| o.remove("mcpServers")); - } - let is_empty = claude_json - .as_object() - .is_some_and(serde_json::Map::is_empty); - if is_empty { - std::fs::remove_file(claude_json_path).ok(); - eprintln!( - "\x1b[32m✔\x1b[0m Removed {} (was empty)", - claude_json_path.display() - ); - } else if backup_and_write_json(claude_json_path, &claude_json) { - eprintln!( - "\x1b[32m✔\x1b[0m Removed tracedecay MCP server from {}", - claude_json_path.display() - ); - } -} - -/// Remove hook, permissions, and stale MCP from settings.json. +/// Remove plugin enablement, tracedecay tool permissions, any stale MCP +/// server, and any leftover tracedecay hooks from settings.json. fn uninstall_settings(settings_path: &Path) { if !settings_path.exists() { return; } - let Ok(contents) = std::fs::read_to_string(settings_path) else { - return; - }; - let Ok(mut settings) = serde_json::from_str::(&contents) else { + let Ok(mut settings) = load_json_file_strict(settings_path) else { return; }; let mut modified = false; + modified |= disable_plugin(&mut settings); modified |= uninstall_stale_mcp(&mut settings); - modified |= uninstall_hook(&mut settings); + modified |= remove_tracedecay_hooks(&mut settings); modified |= uninstall_permissions(&mut settings); if modified && backup_and_write_json(settings_path, &settings) { @@ -877,57 +1145,6 @@ fn uninstall_stale_mcp(settings: &mut serde_json::Value) -> bool { false } -/// Remove all tracedecay hooks. Returns true if modified. -fn uninstall_hook(settings: &mut serde_json::Value) -> bool { - let mut modified = false; - for hook in MANAGED_HOOKS { - modified |= uninstall_single_hook(settings, hook.event); - } - modified -} - -/// Remove tracedecay entries from a single hook event. -/// Returns true if modified. -fn uninstall_single_hook(settings: &mut serde_json::Value, event: &str) -> bool { - let Some(arr) = settings["hooks"][event].as_array().cloned() else { - return false; - }; - let filtered: Vec = arr - .into_iter() - .filter(|h| { - !h.get("hooks") - .and_then(|a| a.as_array()) - .is_some_and(|arr| { - arr.iter().any(|entry| { - entry - .get("command") - .and_then(|c| c.as_str()) - .is_some_and(|c| c.contains("tracedecay")) - }) - }) - }) - .collect(); - if filtered.len() - >= settings["hooks"][event] - .as_array() - .map_or(0, std::vec::Vec::len) - { - return false; - } - if filtered.is_empty() { - if let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) { - hooks.remove(event); - if hooks.is_empty() { - settings.as_object_mut().map(|o| o.remove("hooks")); - } - } - } else { - settings["hooks"][event] = serde_json::Value::Array(filtered); - } - eprintln!("\x1b[32m✔\x1b[0m Removed {event} hook"); - true -} - /// Remove tracedecay tool permissions. Returns true if modified. fn uninstall_permissions(settings: &mut serde_json::Value) -> bool { let Some(arr) = settings["permissions"]["allow"].as_array().cloned() else { @@ -1002,228 +1219,152 @@ fn uninstall_claude_md_rules(claude_md_path: &Path) { // Healthcheck helpers // --------------------------------------------------------------------------- -/// Check ~/.claude.json MCP server registration. -fn doctor_check_claude_json(dc: &mut DoctorCounters, home: &Path) { - let claude_json_path = home.join(".claude.json"); - if !claude_json_path.exists() { - dc.fail("~/.claude.json not found — run `tracedecay install`"); +/// Check the deployed plugin bundle, marketplace registration, and enablement. +fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) { + let deploy_dir = plugin_deploy_dir(home); + let manifest_path = plugin_marketplace_manifest_path(home); + if !manifest_path.exists() { + if has_config_managed_leftovers(home) { + dc.warn( + "Claude uses a legacy config-managed tracedecay install — run `tracedecay install` to install the plugin bundle", + ); + } else { + dc.warn(&format!( + "{} not found — run `tracedecay install` if you use Claude Code", + manifest_path.display() + )); + } return; } - let claude_json_ok = std::fs::read_to_string(&claude_json_path) - .ok() - .and_then(|c| serde_json::from_str::(&c).ok()); - - let Some(claude_json) = claude_json_ok else { - dc.fail("Could not parse ~/.claude.json"); - return; - }; dc.pass(&format!( - "Global MCP config: {}", - claude_json_path.display() + "Plugin bundle deployed at {}", + deploy_dir.display() + )); + dc.pass(&format!( + "Plugin marketplace manifest present in {}", + manifest_path.display() )); - let mcp_entry = &claude_json["mcpServers"]["tracedecay"]; - if !mcp_entry.is_object() { - dc.fail("MCP server NOT registered in ~/.claude.json — run `tracedecay install`"); - return; + // plugin.json version check. + let plugin_manifest = load_json_file(&deploy_dir.join(".claude-plugin/plugin.json")); + match plugin_manifest.get("version").and_then(|v| v.as_str()) { + Some(env!("CARGO_PKG_VERSION")) => dc.pass("Deployed plugin version matches tracedecay"), + Some(version) => dc.warn(&format!( + "Deployed plugin version {version} does not match tracedecay {} — run `tracedecay update-plugin`", + env!("CARGO_PKG_VERSION") + )), + None => dc.warn("Deployed plugin.json does not contain a version"), + } + + // Bundle component presence. + for (label, relative) in [ + ("MCP server (.mcp.json)", ".mcp.json"), + ("hooks (hooks/hooks.json)", "hooks/hooks.json"), + ] { + if deploy_dir.join(relative).exists() { + dc.pass(&format!("Plugin {label} present")); + } else { + dc.fail(&format!( + "Plugin {label} missing in {} — run `tracedecay install`", + deploy_dir.display() + )); + } + } + for (label, dir) in [ + ("subagents (agents/)", "agents"), + ("skills (skills/)", "skills"), + ("commands (commands/)", "commands"), + ] { + if deploy_dir.join(dir).is_dir() { + dc.pass(&format!("Plugin {label} present")); + } else { + dc.fail(&format!( + "Plugin {label} missing in {} — run `tracedecay install`", + deploy_dir.display() + )); + } } - dc.pass("MCP server registered in ~/.claude.json"); - doctor_check_mcp_binary(dc, mcp_entry); - let args_ok = mcp_entry["args"] - .as_array() - .is_some_and(|a| a.first().and_then(|v| v.as_str()) == Some("serve")); - if args_ok { - dc.pass("MCP server args include \"serve\""); + // Marketplace registration. + let known = load_json_file(&known_marketplaces_path(home)); + let registered = known + .get(MARKETPLACE_NAME) + .and_then(|m| m.get("source")) + .and_then(|s| s.get("source")) + .and_then(|v| v.as_str()) + == Some("directory"); + if registered { + dc.pass(&format!( + "Marketplace registered in {}", + known_marketplaces_path(home).display() + )); } else { - dc.fail("MCP server args missing \"serve\" — run `tracedecay install`"); + dc.warn(&format!( + "Marketplace not registered in {} — run `tracedecay install`", + known_marketplaces_path(home).display() + )); } -} -/// Validate MCP binary path and match against current executable. -fn doctor_check_mcp_binary(dc: &mut DoctorCounters, mcp_entry: &serde_json::Value) { - let Some(mcp_cmd) = mcp_entry["command"].as_str() else { - dc.fail("MCP server entry missing \"command\" field — run `tracedecay install`"); - return; - }; - let mcp_bin = Path::new(mcp_cmd); - if !mcp_bin.exists() { - dc.fail(&format!( - "MCP binary not found: {mcp_cmd} — run `tracedecay install`" + // Plugin enablement. + let settings = load_json_file(&home.join(".claude/settings.json")); + let enabled = settings + .get("enabledPlugins") + .and_then(|p| p.get(PLUGIN_IDENTIFIER)) + .and_then(serde_json::Value::as_bool) + == Some(true); + if enabled { + dc.pass(&format!( + "Plugin {PLUGIN_IDENTIFIER} enabled in settings.json" + )); + } else { + dc.warn(&format!( + "Plugin {PLUGIN_IDENTIFIER} not enabled in settings.json — run `tracedecay install`" )); - return; } - dc.pass(&format!("MCP binary exists: {mcp_cmd}")); +} - if let Ok(current_exe) = std::env::current_exe() { - let current = current_exe.canonicalize().unwrap_or(current_exe); - let registered = mcp_bin.canonicalize().unwrap_or(mcp_bin.to_path_buf()); - if current == registered { - dc.pass("MCP binary matches current executable"); - } else { - dc.warn(&format!( - "MCP binary differs from current executable\n\ - \x1b[33m registered:\x1b[0m {mcp_cmd}\n\ - \x1b[33m running:\x1b[0m {}", - current.display() - )); - } +/// Warn if stale config-managed tracedecay entries remain after migration. +fn doctor_check_config_managed_leftovers(dc: &mut DoctorCounters, home: &Path) { + // Only relevant once the plugin is deployed — otherwise the plugin-missing + // path already advised the user to install. + if !plugin_marketplace_manifest_path(home).exists() { + return; + } + let mut leftovers = Vec::new(); + if config_managed_mcp_present(home) { + leftovers.push("MCP server in ~/.claude.json"); + } + if settings_has_tracedecay_hooks(&home.join(".claude/settings.json")) { + leftovers.push("hooks in settings.json"); + } + if loose_subagents_present(&home.join(".claude/agents")) { + leftovers.push("loose subagents in ~/.claude/agents"); + } + if !leftovers.is_empty() { + dc.warn(&format!( + "Stale config-managed tracedecay entries remain ({}) — run `tracedecay install` or `tracedecay update-plugin` to finish migrating to the plugin", + leftovers.join(", ") + )); } } -/// Check ~/.claude/settings.json for hook, permissions, and stale entries. -/// Auto-repairs missing hooks when a tracedecay binary can be determined. -fn doctor_check_settings_json(dc: &mut DoctorCounters, home: &Path) { +/// Check tool permissions and detect stale ones. +fn doctor_check_permissions_json(dc: &mut DoctorCounters, home: &Path) { let settings_path = home.join(".claude").join("settings.json"); - - // Check for stale MCP server in old location - if settings_path.exists() { - if let Some(settings) = std::fs::read_to_string(&settings_path) - .ok() - .and_then(|c| serde_json::from_str::(&c).ok()) - { - if settings["mcpServers"]["tracedecay"].is_object() - || settings["mcpServers"]["tracedecay"].is_object() - { - dc.warn("Stale MCP server entry in ~/.claude/settings.json — run `tracedecay install` to migrate"); - } - } - } - if !settings_path.exists() { - dc.fail("~/.claude/settings.json not found — run `tracedecay install`"); + dc.warn("~/.claude/settings.json not found — run `tracedecay install`"); return; } - - let settings_ok = std::fs::read_to_string(&settings_path) + let Some(settings) = std::fs::read_to_string(&settings_path) .ok() - .and_then(|c| serde_json::from_str::(&c).ok()); - - let Some(settings) = settings_ok else { + .and_then(|c| serde_json::from_str::(&c).ok()) + else { dc.fail("Could not parse settings.json"); return; }; - dc.pass(&format!("Settings: {}", settings_path.display())); - doctor_check_hook(dc, &settings); - doctor_fix_hooks(dc, &settings_path, &settings); - doctor_check_permissions(dc, &settings); -} -/// Expected subcommand for each supported hook event. -fn expected_hook_subcommand(event: &str) -> Option<&'static str> { - MANAGED_HOOKS - .iter() - .find(|hook| hook.event == event) - .map(|hook| hook.subcommand) -} - -/// Check all tracedecay hooks in settings. -fn doctor_check_hook(dc: &mut DoctorCounters, settings: &serde_json::Value) { - for hook in MANAGED_HOOKS { - doctor_check_single_hook(dc, settings, hook.event); - } -} - -/// Check a single hook event for a tracedecay entry. -/// Validates that the subcommand is correct for this event. -fn doctor_check_single_hook(dc: &mut DoctorCounters, settings: &serde_json::Value, event: &str) { - let Some((bin, sub, is_legacy)) = find_tracedecay_hook(settings, event) else { - dc.fail(&format!("{event} hook NOT installed")); - return; - }; - - let Some(expected_sub) = expected_hook_subcommand(event) else { - dc.fail(&format!( - "Unsupported Claude hook event in settings.json: {event}" - )); - return; - }; - if is_legacy { - dc.fail(&format!( - "{event} hook uses legacy single-string shape (breaks on paths with spaces) — will be auto-repaired" - )); - return; - } - if sub != expected_sub { - dc.fail(&format!( - "{event} hook has wrong subcommand: \"{sub}\" (expected \"{expected_sub}\")" - )); - return; - } - - dc.pass(&format!("{event} hook installed")); - - if Path::new(&bin).exists() { - dc.pass(&format!("Hook binary exists: {bin}")); - } else { - dc.fail(&format!( - "Hook binary not found: {bin} — run `tracedecay install`" - )); - } -} - -/// Auto-repair missing or misconfigured hooks. Only touches hooks that are -/// actually wrong — correctly configured hooks are left untouched. -/// -/// Bin resolution per event: -/// - missing → use `current_exe()` -/// - legacy single-string shape → use `current_exe()` (the embedded path -/// cannot be parsed unambiguously when it contains spaces — issue #81) -/// - modern shape with wrong subcommand → reuse the existing bin -fn doctor_fix_hooks(dc: &mut DoctorCounters, settings_path: &Path, settings: &serde_json::Value) { - let current_exe = std::env::current_exe() - .ok() - .and_then(|p| p.to_str().map(String::from)); - - let mut settings = settings.clone(); - let mut repaired = false; - - for hook in MANAGED_HOOKS { - let current = find_tracedecay_hook(&settings, hook.event); - let correct = current - .as_ref() - .is_some_and(|(_, s, legacy)| !*legacy && s == hook.subcommand); - if correct { - continue; - } - - let bin = match ¤t { - // Modern shape with wrong subcommand: keep user's bin path. - Some((b, _, false)) => Some(b.clone()), - // Legacy shape or missing: only repair if we know our own path. - _ => current_exe.clone(), - }; - let Some(bin) = bin else { - continue; - }; - - if current.is_some() { - uninstall_single_hook(&mut settings, hook.event); - } - install_single_hook( - &mut settings, - hook.event, - &bin, - hook.subcommand, - hook.matcher_value().as_deref(), - true, - ); - repaired = true; - } - - if repaired { - if backup_and_write_json(settings_path, &settings) { - dc.pass("Auto-repaired hook(s)"); - } else { - dc.fail("Could not write settings.json to repair hooks"); - } - } -} - -/// Check tool permissions and detect stale ones. -fn doctor_check_permissions(dc: &mut DoctorCounters, settings: &serde_json::Value) { let installed: Vec<&str> = settings["permissions"]["allow"] .as_array() .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) @@ -1292,9 +1433,9 @@ fn doctor_check_local_config(dc: &mut DoctorCounters, project_path: &Path) { } if !local_cleaned && !mcp_json_path.exists() && !local_settings_path.exists() { - dc.pass("No local MCP config found (correct — global only)"); + dc.pass("No local MCP config found (correct — plugin only)"); } else if !local_cleaned { - dc.pass("No tracedecay in local config (correct — global only)"); + dc.pass("No tracedecay in local config (correct — plugin only)"); } } @@ -1318,13 +1459,13 @@ fn doctor_clean_local_mcp_json(dc: &mut DoctorCounters, mcp_json_path: &Path) -> if servers.is_empty() { if std::fs::remove_file(mcp_json_path).is_ok() { dc.warn(&format!( - "Removed {} (tracedecay should only be in global config)", + "Removed {} (tracedecay should only be in the plugin)", mcp_json_path.display() )); } } else if backup_and_write_json(mcp_json_path, &mcp_val) { dc.warn(&format!( - "Removed tracedecay entry from {} (should only be in global config)", + "Removed tracedecay entry from {} (should only be in the plugin)", mcp_json_path.display() )); } @@ -1383,7 +1524,7 @@ fn doctor_clean_local_settings( if is_empty { if std::fs::remove_file(local_settings_path).is_ok() { dc.warn(&format!( - "Removed {} (tracedecay should only be in global config)", + "Removed {} (tracedecay should only be in the plugin)", local_settings_path.display() )); let claude_dir = project_path.join(".claude"); @@ -1391,7 +1532,7 @@ fn doctor_clean_local_settings( } } else if backup_and_write_json(local_settings_path, &local_val) { dc.warn(&format!( - "Removed tracedecay entries from {} (should only be in global config)", + "Removed tracedecay entries from {} (should only be in the plugin)", local_settings_path.display() )); } @@ -1422,37 +1563,45 @@ fn clean_orphaned_local_mcp_keys(local_val: &mut serde_json::Value) { } } -/// Best-effort check: warn if `install` needs re-running. -/// Reads ~/.claude/settings.json and compares installed permissions -/// against what the current version expects. Silent on any error. +/// Best-effort stale-install check run on ordinary CLI invocations. /// -/// Also silently backfills any missing hooks (post-upgrade migration) -/// and normalizes Windows backslash paths in hook commands — both in the -/// user-level settings and in the current project's `.claude/settings.json` -/// / `.claude/settings.local.json`, so broken project-scope hooks self-heal. +/// Now that tracedecay ships as a Claude plugin, this migrates users off any +/// leftover config-managed integration (loose MCP entry, tracedecay hooks in +/// settings.json) it finds in the user-level and current-project config, so an +/// upgraded install self-heals toward the plugin without an explicit reinstall. +/// It never touches the plugin dir, the permission allowlist, or CLAUDE.md. pub fn check_install_stale() { let Some(home) = super::home_dir() else { return; }; - // --- user-level settings: permissions warning + hook backfill --- + // Only self-heal once the plugin is actually deployed — otherwise a fresh + // machine with no tracedecay install must not have its config rewritten. + if !plugin_marketplace_manifest_path(&home).exists() { + // Still warn if the current version expects permissions not present. + let user_settings_path = home.join(".claude").join("settings.json"); + if let Ok(contents) = std::fs::read_to_string(&user_settings_path) { + if let Ok(settings) = serde_json::from_str::(&contents) { + warn_missing_permissions(&settings); + } + } + return; + } + + // --- user-level: permissions warning + config-managed migration --- let user_settings_path = home.join(".claude").join("settings.json"); if let Ok(contents) = std::fs::read_to_string(&user_settings_path) { if let Ok(settings) = serde_json::from_str::(&contents) { warn_missing_permissions(&settings); } } - normalize_and_backfill_settings_file(&user_settings_path); + migrate_off_config_managed(&home); - // --- project-level settings: hook backfill only --- - // Fixes issue #38: a project opened with pre-fix backslash paths in - // .claude/settings.json never self-healed because we only scanned the - // user-level file. Scanning the cwd covers the common case of Claude - // Code invoking a project-scoped hook. + // --- project-level: strip any tracedecay hooks a project pinned --- if let Ok(cwd) = std::env::current_dir() { let project_claude = cwd.join(".claude"); - normalize_and_backfill_settings_file(&project_claude.join("settings.json")); - normalize_and_backfill_settings_file(&project_claude.join("settings.local.json")); + migrate_remove_hooks(&project_claude.join("settings.json")); + migrate_remove_hooks(&project_claude.join("settings.local.json")); } } @@ -1477,103 +1626,16 @@ fn warn_missing_permissions(settings: &serde_json::Value) { } } -/// Load `path`, normalize any backslashed tracedecay hook commands, -/// backfill missing hook events, and write back if anything changed. Silent on -/// any error (missing file, unparseable JSON, write failure). Safe no-op when -/// no tracedecay hook is present in the file. -fn normalize_and_backfill_settings_file(path: &Path) { - let Ok(contents) = std::fs::read_to_string(path) else { - return; - }; - let Ok(mut settings) = serde_json::from_str::(&contents) else { - return; - }; - // Only touch files that already reference tracedecay so unrelated project - // settings stay untouched. - let Some(bin) = extract_tracedecay_bin_from_hooks(&settings) else { - return; - }; - let before = serde_json::to_string(&settings).unwrap_or_default(); - normalize_hook_command_paths(&mut settings); - install_hook_quiet(&mut settings, &bin); - let after = serde_json::to_string(&settings).unwrap_or_default(); - if before != after { - backup_and_write_json(path, &settings); - } -} - -/// Rewrite any tracedecay hook command containing a -/// backslash to use forward slashes. Fixes pre-v4.0.x Windows installs where -/// backslashed paths got mangled by `bash -c` (see issue #38). Only touches -/// commands that mention `tracedecay` so unrelated hooks are left alone. -fn normalize_hook_command_paths(settings: &mut serde_json::Value) { - let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) else { - return; - }; - for entries in hooks.values_mut() { - let Some(arr) = entries.as_array_mut() else { - continue; - }; - for entry in arr.iter_mut() { - let Some(cmds) = entry.get_mut("hooks").and_then(|a| a.as_array_mut()) else { - continue; - }; - for cmd in cmds.iter_mut() { - let Some(command_val) = cmd.get_mut("command") else { - continue; - }; - let Some(command) = command_val.as_str() else { - continue; - }; - if command.contains("tracedecay") && command.contains('\\') { - *command_val = serde_json::Value::String(command.replace('\\', "/")); - } - } - } - } -} - -/// Extracts the tracedecay binary path from any existing -/// hook command. -/// -/// Scans all hook events for a command containing "tracedecay" and returns the -/// binary path. Handles both the modern `{command, args}` shape and the legacy -/// single-string shape. Returns `None` if no managed hook is found. -fn extract_tracedecay_bin_from_hooks(settings: &serde_json::Value) -> Option { - let hooks = settings.get("hooks")?.as_object()?; - for entries in hooks.values() { - let Some(arr) = entries.as_array() else { - continue; - }; - for entry in arr { - let Some(cmds) = entry.get("hooks").and_then(|a| a.as_array()) else { - continue; - }; - for cmd in cmds { - let Some(raw) = cmd.get("command").and_then(|c| c.as_str()) else { - continue; - }; - if !raw.contains("tracedecay") { - continue; - } - let bin = if cmd.get("args").is_some() { - raw.to_string() - } else { - raw.split_whitespace().next().unwrap_or(raw).to_string() - }; - return Some(bin.replace('\\', "/")); - } - } - } - None -} - #[cfg(test)] -#[allow(clippy::unwrap_used)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use serde_json::json; + fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() + } + fn install_ctx(home: &Path) -> InstallContext { InstallContext { home: home.to_path_buf(), @@ -1585,111 +1647,224 @@ mod tests { } } - /// Build a settings value with every managed tracedecay hook installed - /// (modern `{command, args}` shape). - fn settings_with_all_hooks(bin: &str) -> serde_json::Value { - let mut settings = json!({ - "permissions": { - "allow": ["mcp__tracedecay__search", "mcp__tracedecay__lookup"] - } - }); - for hook in MANAGED_HOOKS { - let mut entry = json!({ - "hooks": [{ "type": "command", "command": bin, "args": [hook.subcommand] }] - }); - if let Some(matcher) = hook.matcher_value() { - entry["matcher"] = json!(matcher); + fn relative_paths_under(root: &Path) -> Vec { + fn walk(root: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else { + out.push(path); + } } - settings["hooks"][hook.event] = json!([entry]); } - settings - } - - /// Build a settings value with the legacy single-string command shape - /// (broken for paths with spaces — used to test migration/repair). - fn settings_with_legacy_hooks(bin: &str) -> serde_json::Value { - json!({ - "hooks": { - "PreToolUse": [{ - "matcher": "Agent", - "hooks": [{ "type": "command", "command": format!("{bin} hook-pre-tool-use") }] - }], - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": format!("{bin} hook-prompt-submit") }] - }], - "Stop": [{ - "hooks": [{ "type": "command", "command": format!("{bin} hook-stop") }] - }] - } - }) + let mut files = Vec::new(); + walk(root, &mut files); + let mut paths: Vec = files + .iter() + .map(|path| { + path.strip_prefix(root) + .expect("collected paths live under root") + .to_string_lossy() + .replace('\\', "/") + }) + .collect(); + paths.sort(); + paths } - // ----------------------------------------------------------------------- - // Uninstall tests - // ----------------------------------------------------------------------- + /// The embedded writer is the single source of truth for released installs + /// (the binary ships without the repo `claude-plugin/` tree), so the list + /// must cover every file actually present in the source bundle — otherwise + /// a freshly added skill/command/agent would silently never reach users. + #[test] + fn claude_embedded_file_list_covers_the_whole_source_bundle() { + let on_disk = relative_paths_under(&repo_root().join("claude-plugin")); + let mut expected: Vec = CLAUDE_EMBEDDED_PLUGIN_FILES + .iter() + .map(|&(relative, _)| relative.to_string()) + .collect(); + expected.sort(); + assert_eq!( + on_disk, expected, + "CLAUDE_EMBEDDED_PLUGIN_FILES must cover every claude-plugin file" + ); + } + /// Deploy stamps the crate version into plugin.json, substitutes the + /// binary path into hooks.json and .mcp.json, and leaves no placeholder. #[test] - fn uninstall_hook_removes_all_managed_events() { - let mut settings = settings_with_all_hooks("/usr/bin/tracedecay"); - let modified = uninstall_hook(&mut settings); - assert!(modified); - // Every managed hook event should be gone. + fn deploy_stamps_version_and_binary_path() { + let home = tempfile::tempdir().unwrap(); + let deploy_dir = deploy_plugin_bundle(home.path(), "/abs/bin/tracedecay").unwrap(); + + let plugin: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(deploy_dir.join(".claude-plugin/plugin.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + plugin["version"].as_str().unwrap(), + env!("CARGO_PKG_VERSION") + ); + + let hooks = std::fs::read_to_string(deploy_dir.join("hooks/hooks.json")).unwrap(); assert!( - settings.get("hooks").is_none() || settings["hooks"].as_object().unwrap().is_empty() + !hooks.contains(TRACEDECAY_BIN_PLACEHOLDER), + "placeholder must be substituted" + ); + assert!(hooks.contains("/abs/bin/tracedecay")); + + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(deploy_dir.join(".mcp.json")).unwrap()) + .unwrap(); + assert_eq!( + mcp["mcpServers"]["tracedecay"]["command"].as_str().unwrap(), + "/abs/bin/tracedecay" ); } + /// Running install twice must yield byte-identical config files. #[test] - fn uninstall_hook_removes_user_prompt_submit() { - let mut settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": "tracedecay hook-prompt-submit" }] - }] - } - }); - let modified = uninstall_single_hook(&mut settings, "UserPromptSubmit"); - assert!(modified); - assert!( - settings.get("hooks").is_none(), - "hooks key should be removed when empty" + fn install_is_idempotent() { + let home = tempfile::tempdir().unwrap(); + let ctx = install_ctx(home.path()); + + ClaudeIntegration.install(&ctx).unwrap(); + let read = |p: &Path| std::fs::read_to_string(p).ok(); + let settings_path = home.path().join(".claude/settings.json"); + let known_path = home.path().join(".claude/plugins/known_marketplaces.json"); + let plugin_path = home + .path() + .join(".claude/plugins/marketplaces/tracedecay/.claude-plugin/plugin.json"); + let s1 = read(&settings_path); + let k1 = read(&known_path); + let p1 = read(&plugin_path); + + ClaudeIntegration.install(&ctx).unwrap(); + assert_eq!(s1, read(&settings_path), "settings.json must be stable"); + assert_eq!( + k1, + read(&known_path), + "known_marketplaces.json must be stable" ); + assert_eq!(p1, read(&plugin_path), "plugin.json must be stable"); } + /// `register_marketplace` merges without clobbering existing marketplaces. #[test] - fn uninstall_preserves_non_tracedecay_hooks() { - let mut settings = json!({ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [{ "type": "command", "command": "tracedecay hook-prompt-submit" }] - }, - { - "hooks": [{ "type": "command", "command": "other-tool do-something" }] - } - ], - "Stop": [{ - "hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Submarine.aiff" }] - }] - } - }); - uninstall_hook(&mut settings); - // The non-tracedecay UserPromptSubmit entry should survive. - let arr = settings["hooks"]["UserPromptSubmit"].as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert!(arr[0]["hooks"][0]["command"] + fn register_marketplace_preserves_existing() { + let home = tempfile::tempdir().unwrap(); + let path = known_marketplaces_path(home.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#"{"claude-plugins-official":{"source":{"source":"github","repo":"x/y"}}}"#, + ) + .unwrap(); + + register_marketplace(home.path(), &plugin_deploy_dir(home.path())).unwrap(); + + let known: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(known.get("claude-plugins-official").is_some()); + assert_eq!( + known["tracedecay"]["source"]["source"].as_str().unwrap(), + "directory" + ); + } + + /// `enable_plugin` merges into existing `enabledPlugins` without dropping keys. + #[test] + fn enable_plugin_preserves_other_plugins() { + let mut settings = json!({ "enabledPlugins": { "other@mkt": true } }); + enable_plugin(&mut settings); + assert_eq!(settings["enabledPlugins"]["other@mkt"], json!(true)); + assert_eq!(settings["enabledPlugins"][PLUGIN_IDENTIFIER], json!(true)); + } + + /// Migration strips the loose MCP entry, the tracedecay hooks (all events), + /// and the loose subagents — but leaves non-tracedecay siblings intact. + #[test] + fn migration_removes_config_managed_but_keeps_foreign_entries() { + let home = tempfile::tempdir().unwrap(); + let claude_dir = home.path().join(".claude"); + std::fs::create_dir_all(claude_dir.join("agents")).unwrap(); + + std::fs::write( + home.path().join(".claude.json"), + r#"{"mcpServers":{"tracedecay":{"command":"tracedecay"},"other":{"command":"x"}}}"#, + ) + .unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + r#"{"hooks":{"Stop":[ + {"hooks":[{"type":"command","command":"tracedecay hook-stop"}]}, + {"hooks":[{"type":"command","command":"other-tool"}]} + ]}}"#, + ) + .unwrap(); + // A tracedecay-managed subagent plus a user file squatting on a name. + std::fs::write( + claude_dir.join("agents/code-explorer.md"), + "managed tracedecay agent", + ) + .unwrap(); + std::fs::write( + claude_dir.join("agents/session-historian.md"), + "my own agent, unrelated", + ) + .unwrap(); + + migrate_off_config_managed(home.path()); + + let claude_json: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(home.path().join(".claude.json")).unwrap(), + ) + .unwrap(); + assert!(claude_json["mcpServers"].get("tracedecay").is_none()); + assert!(claude_json["mcpServers"].get("other").is_some()); + + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(), + ) + .unwrap(); + let stop = settings["hooks"]["Stop"].as_array().unwrap(); + assert_eq!(stop.len(), 1, "only the foreign hook should survive"); + assert!(stop[0]["hooks"][0]["command"] .as_str() .unwrap() .contains("other-tool")); - // The Stop event (no tracedecay) should survive. - assert!(settings["hooks"]["Stop"].is_array()); + + assert!( + !claude_dir.join("agents/code-explorer.md").exists(), + "managed subagent removed" + ); + assert!( + claude_dir.join("agents/session-historian.md").exists(), + "user subagent preserved" + ); } #[test] - fn uninstall_noop_when_no_hooks() { - let mut settings = json!({ "permissions": { "allow": [] } }); - let modified = uninstall_hook(&mut settings); - assert!(!modified); + fn migration_is_idempotent() { + let home = tempfile::tempdir().unwrap(); + let claude_dir = home.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::write( + home.path().join(".claude.json"), + r#"{"mcpServers":{"tracedecay":{"command":"tracedecay"}}}"#, + ) + .unwrap(); + + migrate_off_config_managed(home.path()); + let after_first = std::fs::read_to_string(home.path().join(".claude.json")).ok(); + migrate_off_config_managed(home.path()); + let after_second = std::fs::read_to_string(home.path().join(".claude.json")).ok(); + assert_eq!(after_first, after_second); + assert!(!config_managed_mcp_present(home.path())); } #[test] @@ -1715,177 +1890,32 @@ mod tests { assert_eq!(remaining, vec!["Bash", "Read"]); } - // ----------------------------------------------------------------------- - // Install tests - // ----------------------------------------------------------------------- - #[test] - fn subagents_install_refresh_and_uninstall_respect_user_files() { - let dir = tempfile::tempdir().unwrap(); - let claude_dir = dir.path().join(".claude"); - let agents_dir = claude_dir.join("agents"); - - // A user-authored agent squatting on a managed name must survive - // install, refresh, and uninstall untouched. - std::fs::create_dir_all(&agents_dir).unwrap(); - let user_agent = agents_dir.join("code-explorer.md"); - std::fs::write(&user_agent, "my own agent, nothing to do with the tool").unwrap(); - - install_subagents(&claude_dir).unwrap(); - assert_eq!( - std::fs::read_to_string(&user_agent).unwrap(), - "my own agent, nothing to do with the tool" - ); - assert!(agents_dir.join("code-health-auditor.md").exists()); - assert!(agents_dir.join("session-historian.md").exists()); + fn uninstall_removes_plugin_and_marketplace() { + let home = tempfile::tempdir().unwrap(); + let ctx = install_ctx(home.path()); + ClaudeIntegration.install(&ctx).unwrap(); + assert!(plugin_marketplace_manifest_path(home.path()).exists()); - // Refresh rewrites only installed managed copies. - std::fs::write( - agents_dir.join("session-historian.md"), - "stale tracedecay copy", - ) - .unwrap(); - let refreshed = refresh_installed_subagents(&claude_dir).unwrap(); - assert_eq!( - refreshed.len(), - 2, - "two managed copies exist: {refreshed:?}" - ); + ClaudeIntegration.uninstall(&ctx).unwrap(); assert!( - std::fs::read_to_string(agents_dir.join("session-historian.md")) - .unwrap() - .contains("tracedecay_message_search"), - "refresh must rewrite stale managed copies" + !plugin_deploy_dir(home.path()).exists(), + "deploy dir removed" ); - - uninstall_subagents(&claude_dir); - assert!(user_agent.exists(), "user agent must survive uninstall"); - assert!(!agents_dir.join("code-health-auditor.md").exists()); - assert!(!agents_dir.join("session-historian.md").exists()); - } - - #[test] - fn managed_subagent_definitions_have_valid_frontmatter() { - for &(file_name, contents) in CLAUDE_MANAGED_AGENTS { - let stem = file_name.trim_end_matches(".md"); - let lines: Vec<&str> = contents.lines().collect(); - assert_eq!( - lines.first().copied(), - Some("---"), - "{file_name} must open YAML frontmatter" - ); - let expected_name = format!("name: {stem}"); - assert!( - lines.contains(&expected_name.as_str()), - "{file_name} frontmatter name must match its filename" - ); - assert!( - lines.iter().any(|line| line.starts_with("description: ")), - "{file_name} must carry a description for delegation" - ); - assert!( - contents.contains("tracedecay"), - "{file_name} must reference tracedecay so it is recognized as managed" - ); - } - } - - /// The `PostToolUse` matcher is derived from the hook handler's tool list, - /// so the installed matcher can never accept tools the handler ignores. - #[test] - fn post_tool_use_matcher_comes_from_the_hook_handler_tool_list() { - let Some(matcher) = MANAGED_HOOKS - .iter() - .find(|hook| hook.event == "PostToolUse") - .and_then(ManagedHook::matcher_value) - else { - panic!("PostToolUse must register a matcher"); - }; - assert_eq!(matcher, crate::hooks::claude_post_tool_use_matcher()); - assert!(matcher.contains("Edit") && matcher.contains("Bash")); - } - - #[test] - fn install_adds_all_managed_hooks() { - let mut settings = json!({}); - install_hook(&mut settings, "/usr/bin/tracedecay"); - for hook in MANAGED_HOOKS { - assert!( - settings["hooks"][hook.event].is_array(), - "{} hook should be installed", - hook.event - ); - } - } - - #[test] - fn install_is_idempotent() { - let mut settings = json!({}); - install_hook(&mut settings, "/usr/bin/tracedecay"); - let snapshot = settings.clone(); - install_hook(&mut settings, "/usr/bin/tracedecay"); - assert_eq!(settings, snapshot, "second install should be a no-op"); - } - - #[test] - fn install_preserves_existing_hooks() { - let mut settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": "other-tool" }] - }] - } - }); - install_hook(&mut settings, "/usr/bin/tracedecay"); - // Should have both entries in UserPromptSubmit. - let arr = settings["hooks"]["UserPromptSubmit"].as_array().unwrap(); - assert_eq!(arr.len(), 2); - } - - /// Regression for issue #81: paths with spaces must not be concatenated - /// into the `command` field — Claude Code whitespace-splits it. - #[test] - fn install_uses_args_array_for_paths_with_spaces() { - let bin = "C:/Path With Spaces/tracedecay.exe"; - let mut settings = json!({}); - install_hook(&mut settings, bin); - - for hook in MANAGED_HOOKS { - let (event, expected_sub) = (hook.event, hook.subcommand); - let inner = &settings["hooks"][event][0]["hooks"][0]; - assert_eq!( - inner["command"].as_str().unwrap(), - bin, - "{event}: command must be the exe path alone — no concatenated subcommand" - ); - assert_eq!( - inner["args"].as_array().unwrap(), - &vec![json!(expected_sub)], - "{event}: subcommand must live in args[]" - ); - } - } - - #[test] - fn install_is_idempotent_for_legacy_shape() { - // A legacy single-string install must not get a second entry added - // for its events — the doctor is what rewrites it, not a re-run of - // install. Events the legacy install never had are still backfilled. - let mut settings = settings_with_legacy_hooks("/usr/bin/tracedecay"); - let before = settings.clone(); - install_hook(&mut settings, "/usr/bin/tracedecay"); - for event in ["PreToolUse", "UserPromptSubmit", "Stop"] { - assert_eq!( - settings["hooks"][event], before["hooks"][event], - "{event}: existing legacy entry must not be duplicated" - ); - } - for event in ["SessionStart", "PostToolUse"] { - assert!( - settings["hooks"][event].is_array(), - "{event}: missing event must be backfilled" - ); + let known = known_marketplaces_path(home.path()); + if known.exists() { + let val: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&known).unwrap()).unwrap(); + assert!(val.get(MARKETPLACE_NAME).is_none()); } + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap(), + ) + .unwrap(); + assert!(settings + .get("enabledPlugins") + .and_then(|p| p.get(PLUGIN_IDENTIFIER)) + .is_none()); } #[test] @@ -1905,24 +1935,6 @@ mod tests { ); } - #[test] - fn doctor_check_single_hook_reports_unknown_event_instead_of_panicking() { - let mut settings = settings_with_all_hooks("/usr/bin/tracedecay"); - settings["hooks"]["SessionEnd"] = json!([{ - "hooks": [{ - "type": "command", - "command": "/usr/bin/tracedecay", - "args": ["hook-session-end"] - }] - }]); - let mut dc = DoctorCounters::new(); - - doctor_check_single_hook(&mut dc, &settings, "SessionEnd"); - - assert_eq!(dc.issues, 1); - assert_eq!(dc.warnings, 0); - } - #[cfg(target_os = "linux")] #[test] fn install_claude_md_rules_surfaces_append_failures() { @@ -1934,412 +1946,37 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // doctor_fix_hooks tests (issue #81) - // ----------------------------------------------------------------------- - - /// Issue #81: legacy single-string shape with a path-with-spaces cannot - /// be parsed unambiguously. Repair must rewrite to the modern `args` - /// shape using `current_exe()` (the binary that's actually running), - /// not a whitespace-split of the legacy command. This is what breaks - /// the doctor → install loop. + /// Every managed subagent definition the plugin ships must have valid + /// frontmatter and reference tracedecay so migration recognizes copies. #[test] - fn doctor_repairs_legacy_shape_to_args_array() { - let legacy_bin = "C:/Path With Spaces/tracedecay.exe"; - let settings_dir = tempfile::tempdir().unwrap(); - let settings_path = settings_dir.path().join("settings.json"); - let settings = settings_with_legacy_hooks(legacy_bin); - std::fs::write(&settings_path, serde_json::to_string(&settings).unwrap()).unwrap(); - - let mut dc = DoctorCounters::default(); - doctor_fix_hooks(&mut dc, &settings_path, &settings); - - let after: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - let expected_bin = std::env::current_exe() - .unwrap() - .to_str() - .unwrap() - .to_string(); - for hook in MANAGED_HOOKS { - let (event, expected_sub) = (hook.event, hook.subcommand); - let inner = &after["hooks"][event][0]["hooks"][0]; - assert_eq!( - inner["command"].as_str().unwrap(), - expected_bin, - "{event}: must use current_exe (legacy path cannot be parsed safely)" - ); + fn managed_subagent_definitions_have_valid_frontmatter() { + for &file_name in LEGACY_SUBAGENT_FILES { + let contents = CLAUDE_EMBEDDED_PLUGIN_FILES + .iter() + .find_map(|&(relative, body)| { + (relative == format!("agents/{file_name}")).then_some(body) + }) + .expect("plugin must ship each managed subagent"); + let stem = file_name.trim_end_matches(".md"); + let lines: Vec<&str> = contents.lines().collect(); assert_eq!( - inner["args"].as_array().unwrap(), - &vec![json!(expected_sub)], - "{event}: subcommand must move into args[]" + lines.first().copied(), + Some("---"), + "{file_name} must open YAML frontmatter" ); + let expected_name = format!("name: {stem}"); assert!( - !inner["command"].as_str().unwrap().contains(expected_sub), - "{event}: subcommand must not be embedded in the command string" + lines.contains(&expected_name.as_str()), + "{file_name} frontmatter name must match its filename" ); - } - } - - #[test] - fn doctor_is_noop_on_correctly_installed_hooks() { - let bin = "/usr/bin/tracedecay"; - let settings_dir = tempfile::tempdir().unwrap(); - let settings_path = settings_dir.path().join("settings.json"); - let settings = settings_with_all_hooks(bin); - std::fs::write(&settings_path, serde_json::to_string(&settings).unwrap()).unwrap(); - - let mut dc = DoctorCounters::default(); - doctor_fix_hooks(&mut dc, &settings_path, &settings); - - let after: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - assert_eq!(after, settings); - } - - // ----------------------------------------------------------------------- - // extract_tracedecay_bin_from_hooks tests - // ----------------------------------------------------------------------- - - #[test] - fn extract_bin_from_any_hook_event() { - let settings = json!({ - "hooks": { - "Stop": [{ - "hooks": [{ "type": "command", "command": "/opt/bin/tracedecay hook-stop" }] - }] - } - }); - assert_eq!( - extract_tracedecay_bin_from_hooks(&settings), - Some("/opt/bin/tracedecay".to_string()) - ); - } - - #[test] - fn extract_bin_returns_none_without_hooks() { - let settings = json!({ "permissions": {} }); - assert_eq!(extract_tracedecay_bin_from_hooks(&settings), None); - } - - #[test] - fn extract_bin_normalizes_windows_backslashes() { - let settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": "C:\\Users\\dev\\scoop\\shims\\tracedecay.exe hook-prompt-submit" }] - }] - } - }); - assert_eq!( - extract_tracedecay_bin_from_hooks(&settings), - Some("C:/Users/dev/scoop/shims/tracedecay.exe".to_string()) - ); - } - - // ----------------------------------------------------------------------- - // normalize_hook_command_paths tests (issue #38) - // ----------------------------------------------------------------------- - - #[test] - fn normalize_rewrites_backslashed_tracedecay_commands() { - let mut settings = json!({ - "hooks": { - "Stop": [{ - "hooks": [{ - "type": "command", - "command": "C:\\Users\\alkam\\scoop\\apps\\tracedecay\\current\\tracedecay.exe hook-stop" - }] - }] - } - }); - normalize_hook_command_paths(&mut settings); - assert_eq!( - settings["hooks"]["Stop"][0]["hooks"][0]["command"] - .as_str() - .unwrap(), - "C:/Users/alkam/scoop/apps/tracedecay/current/tracedecay.exe hook-stop" - ); - } - - #[test] - fn normalize_leaves_non_tracedecay_hooks_alone() { - let mut settings = json!({ - "hooks": { - "Stop": [{ - "hooks": [{ - "type": "command", - "command": "C:\\Windows\\System32\\other.exe --flag" - }] - }] - } - }); - let before = settings.clone(); - normalize_hook_command_paths(&mut settings); - assert_eq!(settings, before); - } - - #[test] - fn normalize_is_noop_when_already_forward_slashed() { - let mut settings = settings_with_all_hooks("C:/Users/dev/scoop/shims/tracedecay.exe"); - let before = settings.clone(); - normalize_hook_command_paths(&mut settings); - assert_eq!(settings, before); - } - - #[test] - fn normalize_and_backfill_rewrites_project_settings_file() { - use std::io::Write as _; - // `tempfile::TempDir` gives a per-test unique path; the previous - // PID + nanos scheme collided when the two `normalize_and_backfill_*` - // tests ran in parallel under coarse-resolution clocks. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("settings.json"); - let contents = r#"{ - "hooks": { - "Stop": [{ - "hooks": [{ "type": "command", "command": "C:\\Users\\u\\tracedecay.exe hook-stop" }] - }] - } -} -"#; - std::fs::File::create(&path) - .unwrap() - .write_all(contents.as_bytes()) - .unwrap(); - - normalize_and_backfill_settings_file(&path); - - let after = std::fs::read_to_string(&path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&after).unwrap(); - assert_eq!( - parsed["hooks"]["Stop"][0]["hooks"][0]["command"] - .as_str() - .unwrap(), - "C:/Users/u/tracedecay.exe hook-stop" - ); - // Every managed event should now be present (backfill). - for hook in MANAGED_HOOKS { assert!( - parsed["hooks"][hook.event].is_array(), - "{} hook should be backfilled", - hook.event + lines.iter().any(|line| line.starts_with("description: ")), + "{file_name} must carry a description for delegation" ); - } - } - - #[test] - fn normalize_and_backfill_skips_file_without_tracedecay_hook() { - use std::io::Write as _; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("settings.json"); - let contents = r#"{"permissions": {"allow": ["Bash"]}} -"#; - std::fs::File::create(&path) - .unwrap() - .write_all(contents.as_bytes()) - .unwrap(); - - normalize_and_backfill_settings_file(&path); - - let after = std::fs::read_to_string(&path).unwrap(); - assert_eq!( - after, contents, - "file without tracedecay hook must be untouched" - ); - } - - // ----------------------------------------------------------------------- - // Doctor check tests - // ----------------------------------------------------------------------- - - #[test] - fn doctor_detects_missing_user_prompt_submit() { - let mut dc = DoctorCounters::new(); - let settings = json!({ - "hooks": { - "PreToolUse": [{ - "hooks": [{ "type": "command", "command": "tracedecay hook-pre-tool-use" }] - }] - } - }); - doctor_check_single_hook(&mut dc, &settings, "UserPromptSubmit"); - assert!(dc.issues > 0, "should report missing UserPromptSubmit hook"); - } - - #[test] - fn doctor_passes_when_user_prompt_submit_present() { - let mut dc = DoctorCounters::new(); - let bin = std::env::current_exe() - .unwrap() - .to_str() - .unwrap() - .to_string(); - let settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ - "type": "command", - "command": bin, - "args": ["hook-prompt-submit"], - }] - }] - } - }); - doctor_check_single_hook(&mut dc, &settings, "UserPromptSubmit"); - assert_eq!( - dc.issues, 0, - "should pass when UserPromptSubmit hook is present" - ); - } - - #[test] - fn doctor_detects_wrong_subcommand() { - let mut dc = DoctorCounters::new(); - let settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": "tracedecay invalidcommand" }] - }] - } - }); - doctor_check_single_hook(&mut dc, &settings, "UserPromptSubmit"); - assert!(dc.issues > 0, "should report wrong subcommand"); - } - - #[test] - fn doctor_detects_wrong_subcommand_on_stop() { - let mut dc = DoctorCounters::new(); - let settings = json!({ - "hooks": { - "Stop": [{ - "hooks": [{ "type": "command", "command": "tracedecay hook-pre-tool-use" }] - }] - } - }); - doctor_check_single_hook(&mut dc, &settings, "Stop"); - assert!(dc.issues > 0, "should report wrong subcommand for Stop"); - } - - #[test] - fn doctor_detects_missing_subcommand() { - let mut dc = DoctorCounters::new(); - let settings = json!({ - "hooks": { - "UserPromptSubmit": [{ - "hooks": [{ "type": "command", "command": "tracedecay" }] - }] - } - }); - doctor_check_single_hook(&mut dc, &settings, "UserPromptSubmit"); - assert!(dc.issues > 0, "should report missing subcommand"); - } - - // ----------------------------------------------------------------------- - // Doctor fix tests - // ----------------------------------------------------------------------- - - #[test] - fn doctor_fix_adds_missing_hooks() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - // Start with only Stop hook. - let settings = json!({ - "hooks": { - "Stop": [{ - "hooks": [{ "type": "command", "command": "/usr/bin/tracedecay hook-stop" }] - }] - } - }); - std::fs::write( - &settings_path, - serde_json::to_string_pretty(&settings).unwrap(), - ) - .unwrap(); - - let mut dc = DoctorCounters::new(); - doctor_fix_hooks(&mut dc, &settings_path, &settings); - - // Re-read and verify every managed hook is present. - let fixed: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - for hook in MANAGED_HOOKS { assert!( - fixed["hooks"][hook.event].is_array(), - "{} hook should be repaired in", - hook.event + contents.contains("tracedecay"), + "{file_name} must reference tracedecay so it is recognized as managed" ); } } - - #[test] - fn doctor_fix_replaces_wrong_subcommand() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - // Modern shape with a wrong subcommand on UserPromptSubmit. - let settings = json!({ - "hooks": { - "PreToolUse": [{ - "matcher": "Agent", - "hooks": [{ - "type": "command", - "command": "/usr/bin/tracedecay", - "args": ["hook-pre-tool-use"], - }] - }], - "UserPromptSubmit": [{ - "hooks": [{ - "type": "command", - "command": "/usr/bin/tracedecay", - "args": ["invalidcommand"], - }] - }], - "Stop": [{ - "hooks": [{ - "type": "command", - "command": "/usr/bin/tracedecay", - "args": ["hook-stop"], - }] - }] - } - }); - std::fs::write( - &settings_path, - serde_json::to_string_pretty(&settings).unwrap(), - ) - .unwrap(); - - let mut dc = DoctorCounters::new(); - doctor_fix_hooks(&mut dc, &settings_path, &settings); - - let fixed: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - let inner = &fixed["hooks"]["UserPromptSubmit"][0]["hooks"][0]; - assert_eq!( - inner["args"].as_array().unwrap(), - &vec![json!("hook-prompt-submit")], - "should have correct subcommand in args[]" - ); - // Should keep the original bin path on a modern-shape repair. - assert_eq!(inner["command"].as_str().unwrap(), "/usr/bin/tracedecay"); - } - - #[test] - fn doctor_fix_noop_when_all_present() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let settings = settings_with_all_hooks("/usr/bin/tracedecay"); - let pretty = serde_json::to_string_pretty(&settings).unwrap(); - std::fs::write(&settings_path, &pretty).unwrap(); - - let mut dc = DoctorCounters::new(); - doctor_fix_hooks(&mut dc, &settings_path, &settings); - - // File should be unchanged. - let after = std::fs::read_to_string(&settings_path).unwrap(); - assert_eq!( - after, pretty, - "should not modify file when all hooks present" - ); - } } diff --git a/src/agents/codex.rs b/src/agents/codex.rs index b3b644b44..566552c84 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -269,11 +269,15 @@ const CODEX_EMBEDDED_PLUGIN_FILES: &[(&str, &str)] = &[ include_str!("../../codex-plugin/hooks/hooks.json"), ), // Codex auto-discovers every `SKILL.md` under the manifest `skills/` dir by - // its `name`/`description` frontmatter. The Codex bundle mirrors the - // model-invocable Cursor skills (`hooks::CURSOR_PLUGIN_SKILLS`) so both - // hosts steer agents toward the same consolidated tracedecay workflows; the - // parity is enforced by `codex_skills_match_the_cursor_source_for_parity`. - // Cursor-only slash dispatchers (`tracedecay-*`) are intentionally omitted. + // its `name`/`description` frontmatter. The Codex bundle ships the full + // 30-skill set: the 13 foundational + 4 memory skills are byte-identical to + // the model-invocable Cursor skills (`hooks::CURSOR_PLUGIN_SKILLS`), and the + // 13 `tracedecay-*` workflow skills ship in their canonical (model-invocable) + // form — byte-identical to the Claude bundle. Cursor keeps its own dispatcher + // form of those 13 workflow skills (disable-model-invocation), so Codex and + // Cursor diverge on the workflow content by design. Parity is enforced by + // `codex_skills_match_the_cursor_source_for_parity` and + // `codex_bundle_ships_exactly_the_model_invocable_cursor_skills`. ( "skills/assessing-impact/SKILL.md", include_str!("../../codex-plugin/skills/assessing-impact/SKILL.md"), @@ -302,6 +306,10 @@ const CODEX_EMBEDDED_PLUGIN_FILES: &[(&str, &str)] = &[ "skills/inspecting-managed-skills/SKILL.md", include_str!("../../codex-plugin/skills/inspecting-managed-skills/SKILL.md"), ), + ( + "skills/managing-session-context/SKILL.md", + include_str!("../../codex-plugin/skills/managing-session-context/SKILL.md"), + ), ( "skills/recalling-project-memory/SKILL.md", include_str!("../../codex-plugin/skills/recalling-project-memory/SKILL.md"), @@ -310,10 +318,74 @@ const CODEX_EMBEDDED_PLUGIN_FILES: &[(&str, &str)] = &[ "skills/recalling-session-context/SKILL.md", include_str!("../../codex-plugin/skills/recalling-session-context/SKILL.md"), ), + ( + "skills/retrieving-cached-context/SKILL.md", + include_str!("../../codex-plugin/skills/retrieving-cached-context/SKILL.md"), + ), + ( + "skills/retrieving-project-memory/SKILL.md", + include_str!("../../codex-plugin/skills/retrieving-project-memory/SKILL.md"), + ), ( "skills/reviewing-changes/SKILL.md", include_str!("../../codex-plugin/skills/reviewing-changes/SKILL.md"), ), + ( + "skills/storing-project-memory/SKILL.md", + include_str!("../../codex-plugin/skills/storing-project-memory/SKILL.md"), + ), + ( + "skills/tracedecay-audit-safety/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-audit-safety/SKILL.md"), + ), + ( + "skills/tracedecay-check-health/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-check-health/SKILL.md"), + ), + ( + "skills/tracedecay-clean-dead-code/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-clean-dead-code/SKILL.md"), + ), + ( + "skills/tracedecay-compare-branches/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-compare-branches/SKILL.md"), + ), + ( + "skills/tracedecay-curate-memory/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-curate-memory/SKILL.md"), + ), + ( + "skills/tracedecay-draft-commit/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-draft-commit/SKILL.md"), + ), + ( + "skills/tracedecay-find-impact/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-find-impact/SKILL.md"), + ), + ( + "skills/tracedecay-fix-build/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-fix-build/SKILL.md"), + ), + ( + "skills/tracedecay-map-architecture/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-map-architecture/SKILL.md"), + ), + ( + "skills/tracedecay-port-code/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-port-code/SKILL.md"), + ), + ( + "skills/tracedecay-recall-memory/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-recall-memory/SKILL.md"), + ), + ( + "skills/tracedecay-review-diff/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-review-diff/SKILL.md"), + ), + ( + "skills/tracedecay-test-changes/SKILL.md", + include_str!("../../codex-plugin/skills/tracedecay-test-changes/SKILL.md"), + ), ( "skills/tracing-functions/SKILL.md", include_str!("../../codex-plugin/skills/tracing-functions/SKILL.md"), @@ -1638,11 +1710,16 @@ mod tests { } /// Codex auto-discovers skills by description (it has no slash-command or - /// `disable-model-invocation` surface), so the Codex bundle ships exactly - /// the *model-invocable* Cursor skills — the same set the Cursor plugin - /// advertises via [`crate::hooks::CURSOR_PLUGIN_SKILLS`]. The Cursor-only - /// slash dispatchers (`tracedecay-*`) are intentionally not mirrored: - /// their workflows are covered by these skills. + /// `disable-model-invocation` surface), so every skill it ships is + /// model-invocable. The Codex bundle therefore ships the full 30-skill set: + /// the 17 model-invocable Cursor skills (the same set the Cursor plugin + /// advertises via [`crate::hooks::CURSOR_PLUGIN_SKILLS`] — 13 foundational + + /// 4 memory) plus the 13 `tracedecay-*` workflow skills in their canonical + /// model-invocable form. On Cursor those 13 workflows are slash dispatchers + /// (`disable-model-invocation: true`, excluded from `CURSOR_PLUGIN_SKILLS`); + /// Codex ships the canonical bodies instead, which is why the two hosts + /// diverge on workflow content (byte-identity for the 17 is enforced by + /// `codex_skills_match_the_cursor_source_for_parity`). #[test] fn codex_bundle_ships_exactly_the_model_invocable_cursor_skills() { let mut shipped: Vec = CODEX_EMBEDDED_PLUGIN_FILES @@ -1655,14 +1732,30 @@ mod tests { }) .collect(); shipped.sort(); + // The 13 `tracedecay-*` workflow skills ship on Codex in canonical form + // (on Cursor they are slash dispatchers, hence absent from + // CURSOR_PLUGIN_SKILLS). Expected = the model-invocable Cursor set plus + // those 13 workflow skills. let mut expected: Vec = crate::hooks::CURSOR_PLUGIN_SKILLS .iter() .map(|skill| (*skill).to_string()) .collect(); + for &(relative, _) in CODEX_EMBEDDED_PLUGIN_FILES { + if let Some(name) = relative + .strip_prefix("skills/") + .and_then(|rest| rest.strip_suffix("/SKILL.md")) + { + if name.starts_with("tracedecay-") { + expected.push(name.to_string()); + } + } + } expected.sort(); + expected.dedup(); assert_eq!( shipped, expected, - "Codex must embed exactly the model-invocable Cursor skills for parity" + "Codex must embed the model-invocable Cursor skills plus the canonical \ + `tracedecay-*` workflow skills" ); } diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 14fc01882..d224df352 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -254,14 +254,30 @@ const EMBEDDED_PLUGIN_FILES: &[(&str, &str)] = &[ "skills/recalling-project-memory/SKILL.md", include_str!("../../cursor-plugin/skills/recalling-project-memory/SKILL.md"), ), + ( + "skills/managing-session-context/SKILL.md", + include_str!("../../cursor-plugin/skills/managing-session-context/SKILL.md"), + ), ( "skills/recalling-session-context/SKILL.md", include_str!("../../cursor-plugin/skills/recalling-session-context/SKILL.md"), ), + ( + "skills/retrieving-cached-context/SKILL.md", + include_str!("../../cursor-plugin/skills/retrieving-cached-context/SKILL.md"), + ), + ( + "skills/retrieving-project-memory/SKILL.md", + include_str!("../../cursor-plugin/skills/retrieving-project-memory/SKILL.md"), + ), ( "skills/reviewing-changes/SKILL.md", include_str!("../../cursor-plugin/skills/reviewing-changes/SKILL.md"), ), + ( + "skills/storing-project-memory/SKILL.md", + include_str!("../../cursor-plugin/skills/storing-project-memory/SKILL.md"), + ), // Slash-command dispatcher skills (`disable-model-invocation: true`). // Slugs keep the `tracedecay-` prefix (so `/tracedecay` lists them all) with // a verb-phrase suffix, because Cursor uses the humanized slug as the diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs index 7b74c31e7..1721acd5b 100644 --- a/src/hooks/steering.rs +++ b/src/hooks/steering.rs @@ -8,10 +8,14 @@ use serde_json::Value; use super::now_unix_secs; -/// Model-invocable workflow skills shipped in the tracedecay Cursor plugin's -/// `skills/` directory (slash dispatchers with `disable-model-invocation: -/// true` are excluded). Kept as one constant so the session steering context -/// and the bundle coverage test in `agents::cursor` stay in sync. +/// Model-invocable skills shipped in the tracedecay Cursor plugin's `skills/` +/// directory (slash dispatchers with `disable-model-invocation: true` — the 13 +/// `tracedecay-*` workflow dispatchers — are excluded). This covers the 13 +/// foundational skills plus the 4 memory skills (`managing-session-context`, +/// `retrieving-cached-context`, `retrieving-project-memory`, +/// `storing-project-memory`), which are canonical model-invocable skills. Kept +/// as one constant so the session steering context and the bundle coverage +/// test in `agents::cursor` stay in sync. pub const CURSOR_PLUGIN_SKILLS: &[&str] = &[ "assessing-impact", "code-health", @@ -20,9 +24,13 @@ pub const CURSOR_PLUGIN_SKILLS: &[&str] = &[ "exploring-code", "fixing-build-and-type-errors", "inspecting-managed-skills", + "managing-session-context", "recalling-project-memory", "recalling-session-context", + "retrieving-cached-context", + "retrieving-project-memory", "reviewing-changes", + "storing-project-memory", "tracing-functions", "using-the-cli", "using-tracedecay", diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 52cb7770b..5ca5d1e55 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -2943,10 +2943,11 @@ fn assert_local_install_writes_project_paths(agent: &str, paths: &[&str]) { #[test] fn test_local_install_claude_writes_project_paths() { - assert_local_install_writes_project_paths( - "claude", - &[".mcp.json", ".claude/settings.json", ".claude/CLAUDE.md"], - ); + // Claude Code plugins are global (deployed under ~/.claude/plugins), so a + // `--local` install ensures the global plugin is present and only writes + // the genuinely project-scoped part: the CLAUDE.md steering rules. It does + // not write a project `.mcp.json` or `.claude/settings.json`. + assert_local_install_writes_project_paths("claude", &[".claude/CLAUDE.md"]); } #[test] @@ -3073,29 +3074,35 @@ fn test_claude_install_creates_config() { let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); - // Check ~/.claude.json exists and has mcpServers.tracedecay - let claude_json = home.join(".claude.json"); + // The plugin bundle is deployed to the stable marketplace dir; the MCP + // server now lives in the plugin's own .mcp.json (not ~/.claude.json). + let marketplace_manifest = + home.join(".claude/plugins/marketplaces/tracedecay/.claude-plugin/marketplace.json"); assert!( - claude_json.exists(), - "~/.claude.json should exist after install" + marketplace_manifest.exists(), + "plugin marketplace manifest should be deployed after install" ); - let content: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&claude_json).unwrap()).unwrap(); + let plugin_mcp = home.join(".claude/plugins/marketplaces/tracedecay/.mcp.json"); + assert!(plugin_mcp.exists(), "plugin .mcp.json should be deployed"); + let mcp: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&plugin_mcp).unwrap()).unwrap(); assert!( - content.get("mcpServers").is_some(), - "mcpServers key should exist" + mcp["mcpServers"]["tracedecay"].is_object(), + "plugin .mcp.json should define the tracedecay MCP server" ); - assert!( - content["mcpServers"]["tracedecay"].is_object(), - "mcpServers.tracedecay should be an object" + + // The marketplace is registered in known_marketplaces.json as a directory. + let known: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(home.join(".claude/plugins/known_marketplaces.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + known["tracedecay"]["source"]["source"].as_str(), + Some("directory"), + "known_marketplaces.json should register tracedecay as a directory marketplace" ); - // Verify args contain "serve" - let args = content["mcpServers"]["tracedecay"]["args"] - .as_array() - .unwrap(); - assert!(args.iter().any(|v| v.as_str() == Some("serve"))); - // Check ~/.claude/settings.json exists with hook and permissions + // settings.json enables the plugin and carries the MCP tool permissions. let settings_path = home.join(".claude/settings.json"); assert!( settings_path.exists(), @@ -3103,10 +3110,10 @@ fn test_claude_install_creates_config() { ); let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); - // Check hook - assert!( - settings["hooks"]["PreToolUse"].is_array(), - "PreToolUse hook should be an array" + assert_eq!( + settings["enabledPlugins"]["tracedecay@tracedecay"], + serde_json::json!(true), + "settings.json should enable the tracedecay plugin" ); // Check permissions assert!( @@ -3122,6 +3129,20 @@ fn test_claude_install_creates_config() { ); } + // The old config-managed ~/.claude.json MCP entry must NOT be written. + let claude_json = home.join(".claude.json"); + if claude_json.exists() { + let content: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&claude_json).unwrap()).unwrap(); + assert!( + content + .get("mcpServers") + .and_then(|v| v.get("tracedecay")) + .is_none(), + "install must not write the legacy config-managed MCP entry to ~/.claude.json" + ); + } + // Check CLAUDE.md exists with tracedecay rules let claude_md = home.join(".claude/CLAUDE.md"); assert!(claude_md.exists(), "CLAUDE.md should exist after install"); @@ -4066,27 +4087,43 @@ fn test_claude_install_then_uninstall() { let home = dir.path(); let ctx = make_install_ctx(home); - // Install + let marketplace_manifest = + home.join(".claude/plugins/marketplaces/tracedecay/.claude-plugin/marketplace.json"); + + // Install deploys the plugin bundle + registers the marketplace. ClaudeIntegration.install(&ctx).unwrap(); - assert!(home.join(".claude.json").exists()); + assert!( + marketplace_manifest.exists(), + "plugin marketplace manifest should exist after install" + ); - // Uninstall + // Uninstall removes the deployed bundle, unregisters the marketplace, and + // disables the plugin. ClaudeIntegration.uninstall(&ctx).unwrap(); - // ~/.claude.json should be removed (was only tracedecay) - // It may be removed entirely or have mcpServers removed - if home.join(".claude.json").exists() { - let content: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(home.join(".claude.json")).unwrap()) - .unwrap(); - // Should not have tracedecay anymore - let has_tracedecay = content - .get("mcpServers") - .and_then(|v| v.get("tracedecay")) - .is_some(); + assert!( + !marketplace_manifest.exists(), + "deployed plugin bundle should be removed after uninstall" + ); + let known_path = home.join(".claude/plugins/known_marketplaces.json"); + if known_path.exists() { + let known: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&known_path).unwrap()).unwrap(); assert!( - !has_tracedecay, - "tracedecay should be removed from .claude.json after uninstall" + known.get("tracedecay").is_none(), + "tracedecay marketplace should be unregistered after uninstall" + ); + } + let settings_path = home.join(".claude/settings.json"); + if settings_path.exists() { + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert!( + settings + .get("enabledPlugins") + .and_then(|v| v.get("tracedecay@tracedecay")) + .is_none(), + "plugin should be disabled after uninstall" ); } } @@ -4367,15 +4404,58 @@ fn assert_install_backs_up_and_preserves( #[test] fn test_claude_install_preserves_existing_config() { + // The Claude plugin install merges into settings.json and + // known_marketplaces.json rather than owning a single user-editable config + // file, so preservation is checked against those two files directly: a + // foreign settings key and a foreign registered marketplace must survive. let dir = TempDir::new().unwrap(); - let original = r#"{ - "theme": "solarized", - "mcpServers": { - "other": { "command": "other-bin", "args": ["--flag"] } - } -} -"#; - assert_install_backs_up_and_preserves(&ClaudeIntegration, dir.path(), original, "solarized"); + let home = dir.path(); + let claude_dir = home.join(".claude"); + let plugins_dir = claude_dir.join("plugins"); + std::fs::create_dir_all(&plugins_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + r#"{ "theme": "solarized" }"#, + ) + .unwrap(); + std::fs::write( + plugins_dir.join("known_marketplaces.json"), + r#"{ "other": { "source": { "source": "directory", "path": "/somewhere" } } }"#, + ) + .unwrap(); + + ClaudeIntegration + .install(&make_install_ctx(home)) + .expect("install should succeed"); + + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(claude_dir.join("settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["theme"].as_str(), + Some("solarized"), + "existing settings.json key must be preserved" + ); + assert_eq!( + settings["enabledPlugins"]["tracedecay@tracedecay"], + serde_json::json!(true), + "install must still enable the plugin alongside the existing key" + ); + + let known: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(plugins_dir.join("known_marketplaces.json")).unwrap(), + ) + .unwrap(); + assert!( + known.get("other").is_some(), + "existing foreign marketplace must be preserved" + ); + assert_eq!( + known["tracedecay"]["source"]["source"].as_str(), + Some("directory"), + "install must register the tracedecay marketplace alongside the foreign one" + ); } #[test] @@ -5555,10 +5635,20 @@ fn test_claude_install_idempotent() { ClaudeIntegration.install(&ctx).unwrap(); ClaudeIntegration.install(&ctx).unwrap(); - // Config should still be valid - let claude_json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(home.join(".claude.json")).unwrap()).unwrap(); - assert!(claude_json["mcpServers"]["tracedecay"].is_object()); + // The plugin should remain installed and enabled (idempotent). + assert!( + home.join(".claude/plugins/marketplaces/tracedecay/.claude-plugin/marketplace.json") + .exists(), + "marketplace manifest should still be deployed after a second install" + ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(home.join(".claude/settings.json")).unwrap()) + .unwrap(); + assert_eq!( + settings["enabledPlugins"]["tracedecay@tracedecay"], + serde_json::json!(true), + "plugin should stay enabled after a second install" + ); } #[test] @@ -5605,7 +5695,9 @@ fn test_claude_install_preserves_existing_claude_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); - // Pre-populate .claude.json with other data + // Pre-populate .claude.json with a foreign MCP server and a custom key. + // The plugin model no longer writes tracedecay into ~/.claude.json, and the + // install's config-managed migration must leave unrelated entries intact. let claude_json_path = home.join(".claude.json"); std::fs::write( &claude_json_path, @@ -5618,9 +5710,12 @@ fn test_claude_install_preserves_existing_claude_json() { let content: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&claude_json_path).unwrap()).unwrap(); - // tracedecay added - assert!(content["mcpServers"]["tracedecay"].is_object()); - // existing server preserved + // tracedecay must NOT be added to ~/.claude.json (plugin provides the server) + assert!( + content["mcpServers"].get("tracedecay").is_none(), + "install must not write tracedecay into ~/.claude.json" + ); + // existing foreign server preserved assert!(content["mcpServers"]["other-server"].is_object()); // custom key preserved assert_eq!(content["customKey"], 42); diff --git a/tests/agent_suite/claude_agent_test.rs b/tests/agent_suite/claude_agent_test.rs index b1edccbe0..dcddae1d8 100644 --- a/tests/agent_suite/claude_agent_test.rs +++ b/tests/agent_suite/claude_agent_test.rs @@ -53,19 +53,22 @@ fn read_json(path: &Path) -> serde_json::Value { // =========================================================================== #[test] -fn test_install_creates_claude_json_with_mcp_server() { +fn test_install_deploys_plugin_mcp_server() { let dir = TempDir::new().unwrap(); let home = dir.path(); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); - let claude_json = read_json(&home.join(".claude.json")); - let ts = &claude_json["mcpServers"]["tracedecay"]; + // The MCP server now lives in the deployed plugin's .mcp.json (rendered with + // the resolved absolute binary path), not in ~/.claude.json. + let plugin_mcp = home.join(".claude/plugins/marketplaces/tracedecay/.mcp.json"); + let mcp = read_json(&plugin_mcp); + let ts = &mcp["mcpServers"]["tracedecay"]; assert!(ts.is_object(), "mcpServers.tracedecay should be an object"); assert_eq!( ts["command"].as_str().unwrap(), "/usr/local/bin/tracedecay", - "command should match the bin path" + "command should be rendered with the resolved bin path" ); let args: Vec<&str> = ts["args"] .as_array() @@ -77,14 +80,18 @@ fn test_install_creates_claude_json_with_mcp_server() { } #[test] -fn test_install_creates_settings_with_hook() { +fn test_install_deploys_plugin_hooks() { let dir = TempDir::new().unwrap(); let home = dir.path(); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); - let settings = read_json(&home.join(".claude/settings.json")); - let hooks = settings["hooks"]["PreToolUse"] + // Hooks are now provided by the plugin's own hooks/hooks.json (deployed with + // the __TRACEDECAY_BIN__ placeholder rendered), not written into + // ~/.claude/settings.json. + let hooks_json = + read_json(&home.join(".claude/plugins/marketplaces/tracedecay/hooks/hooks.json")); + let hooks = hooks_json["hooks"]["PreToolUse"] .as_array() .expect("PreToolUse should be an array"); @@ -104,16 +111,17 @@ fn test_install_creates_settings_with_hook() { }); assert!( tracedecay_hook.is_some(), - "PreToolUse should contain a hook with matcher=Agent and command containing tracedecay" + "plugin PreToolUse should contain a hook with matcher=Agent and command containing tracedecay" ); - // Verify the hook command format (issue #81: modern args[] shape). + // Verify the hook command format (issue #81: modern args[] shape) and that + // the binary placeholder was rendered to the resolved bin path. let hook = tracedecay_hook.unwrap(); let inner = &hook["hooks"][0]; let cmd = inner["command"].as_str().unwrap(); - assert!( - cmd.contains("tracedecay"), - "hook command should be the tracedecay exe path, got: {cmd}" + assert_eq!( + cmd, "/usr/local/bin/tracedecay", + "hook command should be the rendered tracedecay exe path, got: {cmd}" ); let args: Vec<&str> = inner["args"] .as_array() @@ -126,6 +134,13 @@ fn test_install_creates_settings_with_hook() { vec!["hook-pre-tool-use"], "subcommand must live in args[], not concatenated into command" ); + + // The old config-managed settings.json must not carry a tracedecay hook. + let settings = read_json(&home.join(".claude/settings.json")); + assert!( + settings.get("hooks").is_none(), + "install must not write tracedecay hooks into settings.json (plugin provides them)" + ); } #[test] @@ -277,9 +292,13 @@ fn test_install_preserves_existing_claude_json() { "bar", "existing key 'foo' should be preserved" ); + // The plugin model no longer writes tracedecay into ~/.claude.json. assert!( - claude_json["mcpServers"]["tracedecay"].is_object(), - "mcpServers.tracedecay should be added alongside existing keys" + claude_json + .get("mcpServers") + .and_then(|v| v.get("tracedecay")) + .is_none(), + "install must not add a config-managed MCP entry to ~/.claude.json" ); } @@ -312,64 +331,122 @@ fn test_install_preserves_existing_settings() { let settings = read_json(&claude_dir.join("settings.json")); let hooks = settings["hooks"]["PreToolUse"].as_array().unwrap(); - // Should have both the existing Bash hook and the new Agent hook + // The user's existing (non-tracedecay) Bash hook must be preserved, and the + // plugin must be enabled. Install no longer injects a tracedecay Agent hook + // into settings.json — the plugin's own hooks.json provides it. let has_bash = hooks .iter() .any(|h| h.get("matcher").and_then(|m| m.as_str()) == Some("Bash")); - let has_agent = hooks - .iter() - .any(|h| h.get("matcher").and_then(|m| m.as_str()) == Some("Agent")); assert!(has_bash, "existing Bash hook should be preserved"); - assert!(has_agent, "new Agent hook should be added"); + let has_tracedecay_hook = hooks.iter().any(|h| { + h.get("hooks") + .and_then(|a| a.as_array()) + .is_some_and(|arr| { + arr.iter().any(|entry| { + entry + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("tracedecay")) + }) + }) + }); + assert!( + !has_tracedecay_hook, + "install must not add a tracedecay hook to settings.json (plugin provides it)" + ); + assert_eq!( + settings["enabledPlugins"]["tracedecay@tracedecay"], + serde_json::json!(true), + "install should enable the plugin" + ); } #[test] -fn test_install_migrates_old_mcp_from_settings() { +fn test_install_migrates_off_config_managed_integration() { let dir = TempDir::new().unwrap(); let home = dir.path(); - - // Pre-populate settings.json with old-location MCP server let claude_dir = home.join(".claude"); std::fs::create_dir_all(&claude_dir).unwrap(); + + // Seed a legacy config-managed install: loose MCP entry in ~/.claude.json, + // a tracedecay hook in settings.json, and a loose managed subagent file. std::fs::write( - claude_dir.join("settings.json"), + home.join(".claude.json"), r#"{ "mcpServers": { - "tracedecay": { - "command": "/old/path/tracedecay", - "args": ["serve"] - } + "tracedecay": { "command": "/old/path/tracedecay", "args": ["serve"] }, + "other": { "command": "keep-me" } + } +}"#, + ) + .unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Agent", + "hooks": [{"type": "command", "command": "/old/path/tracedecay hook-pre-tool-use"}] + } + ] } }"#, ) .unwrap(); + let agents_dir = claude_dir.join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + std::fs::write( + agents_dir.join("code-explorer.md"), + "---\nname: code-explorer\n---\nUse tracedecay for exploration.\n", + ) + .unwrap(); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); - // settings.json should NOT have mcpServers.tracedecay anymore - let settings = read_json(&claude_dir.join("settings.json")); - let has_stale = settings - .get("mcpServers") - .and_then(|v| v.get("tracedecay")) - .is_some(); + // The loose MCP tracedecay entry is migrated away; the foreign server stays. + let claude_json = read_json(&home.join(".claude.json")); + assert!( + claude_json + .get("mcpServers") + .and_then(|v| v.get("tracedecay")) + .is_none(), + "legacy loose MCP tracedecay entry should be migrated away" + ); assert!( - !has_stale, - "tracedecay MCP server should be removed from settings.json (old location)" + claude_json["mcpServers"]["other"].is_object(), + "foreign MCP server must be preserved during migration" ); - // .claude.json should have it in the new location - let claude_json = read_json(&home.join(".claude.json")); + // The tracedecay hook is migrated out of settings.json. + let settings = read_json(&claude_dir.join("settings.json")); + let has_tracedecay_hook = settings + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|v| v.as_array()) + .is_some_and(|arr| { + arr.iter().any(|w| { + w.get("hooks") + .and_then(|a| a.as_array()) + .is_some_and(|inner| { + inner.iter().any(|e| { + e.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("tracedecay")) + }) + }) + }) + }); assert!( - claude_json["mcpServers"]["tracedecay"].is_object(), - "tracedecay MCP server should exist in .claude.json (new location)" + !has_tracedecay_hook, + "legacy tracedecay hook should be migrated out of settings.json" ); - assert_eq!( - claude_json["mcpServers"]["tracedecay"]["command"] - .as_str() - .unwrap(), - "/usr/local/bin/tracedecay", - "MCP command should use the new bin path, not the old one" + + // The loose managed subagent is removed. + assert!( + !agents_dir.join("code-explorer.md").exists(), + "loose tracedecay-managed subagent should be migrated away" ); } @@ -401,21 +478,35 @@ fn test_uninstall_removes_mcp_from_claude_json() { } #[test] -fn test_uninstall_removes_empty_claude_json() { +fn test_uninstall_removes_deployed_bundle_and_lone_marketplace_file() { let dir = TempDir::new().unwrap(); let home = dir.path(); let ctx = make_install_ctx(home); - // Install (creates .claude.json with only mcpServers.tracedecay) + // Install deploys the plugin bundle and registers the marketplace. ClaudeIntegration.install(&ctx).unwrap(); - assert!(home.join(".claude.json").exists()); + let deploy_dir = home.join(".claude/plugins/marketplaces/tracedecay"); + let known_path = home.join(".claude/plugins/known_marketplaces.json"); + assert!( + deploy_dir.exists(), + "bundle should be deployed after install" + ); + assert!( + known_path.exists(), + "known_marketplaces.json should exist after install" + ); ClaudeIntegration.uninstall(&ctx).unwrap(); - // Since the only content was tracedecay, file should be deleted + // The deployed bundle dir is removed entirely. assert!( - !home.join(".claude.json").exists(), - ".claude.json should be deleted when it becomes empty after uninstall" + !deploy_dir.exists(), + "deployed plugin bundle should be removed after uninstall" + ); + // known_marketplaces.json held only tracedecay, so it should be deleted. + assert!( + !known_path.exists(), + "known_marketplaces.json should be deleted when tracedecay was its only entry" ); } @@ -583,7 +674,7 @@ fn test_uninstall_preserves_other_claude_md_content() { // =========================================================================== #[test] -fn test_healthcheck_detects_missing_claude_json() { +fn test_healthcheck_detects_missing_plugin() { let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -593,9 +684,11 @@ fn test_healthcheck_detects_missing_claude_json() { project_path: home.to_path_buf(), }; ClaudeIntegration.healthcheck(&mut dc, &hctx); + // With nothing installed, the plugin manifest is absent — the doctor flags + // it (as a warning to install the plugin) plus missing CLAUDE.md. assert!( - dc.issues > 0, - "healthcheck should detect missing .claude.json" + dc.issues > 0 || dc.warnings > 0, + "healthcheck should detect the missing plugin bundle" ); } diff --git a/tests/agent_suite/claude_plugin_bundle_test.rs b/tests/agent_suite/claude_plugin_bundle_test.rs new file mode 100644 index 000000000..794b93db5 --- /dev/null +++ b/tests/agent_suite/claude_plugin_bundle_test.rs @@ -0,0 +1,486 @@ +//! Filesystem validation and parity contract tests for the Claude Code plugin +//! bundle at `claude-plugin/`. +//! +//! These mirror the sibling bundle tests (`plugin_manifest_schema_test.rs`, +//! `plugin_config_schema_test.rs`, `plugin_skill_contract_test.rs`, +//! `plugin_bundle_sync_test.rs`) but operate purely on the on-disk bundle, +//! asserting the manifests, MCP config, lifecycle hooks, skills, commands, and +//! agents are shaped correctly and stay in sync with their single sources of +//! truth (`src/agents/claude_agents/` for agents, `codex-plugin/skills/` for +//! skills). +//! +//! The embedded-file-list coverage check (asserting a Rust `const` registry +//! matches the on-disk tree) is intentionally omitted here; it is handled with +//! the installer that owns that registry. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::plugin_validation_support::{body_after_frontmatter, read_json_file, repo_path}; +use tracedecay::automation::skill_frontmatter::parse_skill_frontmatter; + +/// The Claude Code plugin bundle root. +fn bundle_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("claude-plugin") +} + +/// The 30 skills the bundle ships (also the codex-plugin skill set): the 13 +/// foundational model-invocable skills, the 4 memory skills, plus the 13 +/// `tracedecay-*` workflow skills, kept in sync across every skill-bundling +/// surface. +const EXPECTED_SKILLS: &[&str] = &[ + // 13 foundational + "assessing-impact", + "code-health", + "curating-project-memory", + "editing-safely", + "exploring-code", + "fixing-build-and-type-errors", + "inspecting-managed-skills", + "recalling-project-memory", + "recalling-session-context", + "reviewing-changes", + "tracing-functions", + "using-the-cli", + "using-tracedecay", + // 4 memory + "managing-session-context", + "retrieving-cached-context", + "retrieving-project-memory", + "storing-project-memory", + // 13 workflow + "tracedecay-audit-safety", + "tracedecay-check-health", + "tracedecay-clean-dead-code", + "tracedecay-compare-branches", + "tracedecay-curate-memory", + "tracedecay-draft-commit", + "tracedecay-find-impact", + "tracedecay-fix-build", + "tracedecay-map-architecture", + "tracedecay-port-code", + "tracedecay-recall-memory", + "tracedecay-review-diff", + "tracedecay-test-changes", +]; + +/// The 13 slash commands the bundle ships. +const EXPECTED_COMMANDS: &[&str] = &[ + "audit-safety", + "check-health", + "clean-dead-code", + "compare-branches", + "curate-memory", + "draft-commit", + "find-impact", + "fix-build", + "map-architecture", + "port-code", + "recall-memory", + "review-diff", + "test-changes", +]; + +/// The 3 subagent definitions, byte-identical to `src/agents/claude_agents/`. +const EXPECTED_AGENTS: &[&str] = &[ + "code-explorer.md", + "code-health-auditor.md", + "session-historian.md", +]; + +/// Reads a required scalar frontmatter field from a `---`-fenced markdown file, +/// asserting it is present and non-empty. Mirrors the frontmatter approach in +/// `plugin_skill_contract_test.rs` (manual parse via `parse_skill_frontmatter`, +/// no new YAML dependency). +fn required_scalar(raw: &str, field: &str, path: &Path) -> String { + let frontmatter = parse_skill_frontmatter(raw) + .unwrap_or_else(|err| panic!("{}: failed to parse frontmatter: {err}", path.display())); + let value = frontmatter + .get(field) + .unwrap_or_else(|| panic!("{} is missing frontmatter `{field}`", path.display())) + .as_scalar() + .unwrap_or_else(|| { + panic!( + "{} frontmatter `{field}` must be an inline scalar", + path.display() + ) + }); + assert!( + !value.trim().is_empty(), + "{} frontmatter `{field}` cannot be empty", + path.display() + ); + value.to_string() +} + +/// Sorted set of subdirectory names directly under `dir`. +fn sorted_subdir_names(dir: &Path) -> Vec { + let mut names = fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .map(|entry| entry.expect("read dir entry").path()) + .filter(|path| path.is_dir()) + .map(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .expect("directory name should be utf-8") + .to_string() + }) + .collect::>(); + names.sort(); + names +} + +/// Sorted set of file names directly under `dir` matching `extension`. +fn sorted_file_names(dir: &Path, extension: &str) -> Vec { + let mut names = fs::read_dir(dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + .map(|entry| entry.expect("read dir entry").path()) + .filter(|path| path.is_file()) + .filter(|path| { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == extension) + }) + .map(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .expect("file name should be utf-8") + .to_string() + }) + .collect::>(); + names.sort(); + names +} + +#[test] +fn claude_bundle_manifest_declares_the_expected_plugin_metadata() { + let manifest_path = bundle_root().join(".claude-plugin/plugin.json"); + let manifest = read_json_file(&manifest_path); + + assert_eq!( + manifest["name"], + "tracedecay", + "{} name must be tracedecay", + manifest_path.display() + ); + for field in ["version", "description", "license", "homepage"] { + let value = manifest.get(field).and_then(Value::as_str); + assert!( + value.is_some_and(|value| !value.trim().is_empty()), + "{} must declare a non-empty `{field}`", + manifest_path.display() + ); + } + let author_name = manifest + .get("author") + .and_then(|author| author.get("name")) + .and_then(Value::as_str); + assert!( + author_name.is_some_and(|name| !name.trim().is_empty()), + "{} must declare a non-empty author.name", + manifest_path.display() + ); +} + +#[test] +fn claude_bundle_marketplace_lists_the_tracedecay_plugin() { + let marketplace_path = bundle_root().join(".claude-plugin/marketplace.json"); + let marketplace = read_json_file(&marketplace_path); + + assert_eq!( + marketplace["name"], + "tracedecay", + "{} name must be tracedecay", + marketplace_path.display() + ); + assert!( + marketplace.get("owner").is_some(), + "{} must declare an owner", + marketplace_path.display() + ); + + let plugins = marketplace + .get("plugins") + .and_then(Value::as_array) + .unwrap_or_else(|| { + panic!( + "{} must declare a plugins array", + marketplace_path.display() + ) + }); + let entry = plugins + .iter() + .find(|plugin| plugin.get("name").and_then(Value::as_str) == Some("tracedecay")) + .unwrap_or_else(|| { + panic!( + "{} plugins[] must contain a tracedecay entry", + marketplace_path.display() + ) + }); + assert_eq!( + entry["source"], + "./", + "{} tracedecay plugin source must be \"./\"", + marketplace_path.display() + ); +} + +#[test] +fn claude_bundle_mcp_config_declares_the_tracedecay_server() { + let mcp_path = bundle_root().join(".mcp.json"); + let mcp = read_json_file(&mcp_path); + + // Matches the codex-plugin/.mcp.json shape: mcpServers.tracedecay. + let server = mcp + .get("mcpServers") + .and_then(|servers| servers.get("tracedecay")) + .unwrap_or_else(|| panic!("{} must declare mcpServers.tracedecay", mcp_path.display())); + assert_eq!( + server["command"], + "tracedecay", + "{} tracedecay server command must be tracedecay", + mcp_path.display() + ); +} + +#[test] +fn claude_bundle_hooks_wire_the_expected_lifecycle_events() { + let hooks_path = bundle_root().join("hooks/hooks.json"); + let config = read_json_file(&hooks_path); + + let hooks = config + .get("hooks") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("{} must declare a hooks object", hooks_path.display())); + + // (event, expected subcommand, expected matcher). + let expected: &[(&str, &str, Option<&str>)] = &[ + ("PreToolUse", "hook-pre-tool-use", Some("Agent")), + ("UserPromptSubmit", "hook-prompt-submit", None), + ("Stop", "hook-stop", None), + ("SessionStart", "hook-claude-session-start", None), + ( + "PostToolUse", + "hook-claude-post-tool-use", + Some("Edit|MultiEdit|Write|NotebookEdit|Bash"), + ), + ]; + + let actual_events: BTreeSet = hooks.keys().cloned().collect(); + let expected_events: BTreeSet = expected + .iter() + .map(|(event, ..)| event.to_string()) + .collect(); + assert_eq!( + actual_events, + expected_events, + "{} must declare exactly the 5 expected lifecycle events", + hooks_path.display() + ); + + for (event, subcommand, matcher) in expected { + let entries = hooks + .get(*event) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{} {event} must be an array", hooks_path.display())); + assert_eq!( + entries.len(), + 1, + "{} {event} must have exactly one entry", + hooks_path.display() + ); + let entry = &entries[0]; + + match matcher { + Some(expected_matcher) => assert_eq!( + entry.get("matcher").and_then(Value::as_str), + Some(*expected_matcher), + "{} {event} matcher must be {expected_matcher}", + hooks_path.display() + ), + None => assert!( + entry.get("matcher").is_none(), + "{} {event} must not declare a matcher", + hooks_path.display() + ), + } + + let inner = entry + .get("hooks") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{} {event} must declare hooks[]", hooks_path.display())); + assert_eq!( + inner.len(), + 1, + "{} {event} must declare exactly one hook", + hooks_path.display() + ); + let hook = &inner[0]; + assert_eq!( + hook["command"], + "__TRACEDECAY_BIN__", + "{} {event} hook command must be the __TRACEDECAY_BIN__ placeholder", + hooks_path.display() + ); + let args = hook + .get("args") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{} {event} hook must declare args[]", hooks_path.display())); + assert_eq!( + args.len(), + 1, + "{} {event} hook args must be a single-element array", + hooks_path.display() + ); + assert_eq!( + args[0], + *subcommand, + "{} {event} hook subcommand must be {subcommand}", + hooks_path.display() + ); + } +} + +#[test] +fn claude_bundle_ships_exactly_the_expected_skills() { + let skills_root = bundle_root().join("skills"); + let mut expected: Vec = EXPECTED_SKILLS.iter().map(|s| s.to_string()).collect(); + expected.sort(); + assert_eq!( + sorted_subdir_names(&skills_root), + expected, + "claude-plugin/skills must contain exactly the expected 26 skill directories" + ); +} + +#[test] +fn claude_bundle_skills_have_valid_frontmatter_and_body() { + let skills_root = bundle_root().join("skills"); + for skill in EXPECTED_SKILLS { + let path = skills_root.join(skill).join("SKILL.md"); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + + let name = required_scalar(&raw, "name", &path); + assert_eq!( + &name, + skill, + "{} frontmatter name must match its directory", + path.display() + ); + required_scalar(&raw, "description", &path); + + assert!( + !body_after_frontmatter(&raw).trim().is_empty(), + "{} must have a non-empty body", + path.display() + ); + } +} + +#[test] +fn claude_bundle_ships_exactly_the_expected_commands() { + let commands_root = bundle_root().join("commands"); + let mut expected: Vec = EXPECTED_COMMANDS + .iter() + .map(|command| format!("{command}.md")) + .collect(); + expected.sort(); + assert_eq!( + sorted_file_names(&commands_root, "md"), + expected, + "claude-plugin/commands must contain exactly the expected 13 command files" + ); +} + +#[test] +fn claude_bundle_commands_have_valid_frontmatter_and_body() { + let commands_root = bundle_root().join("commands"); + for command in EXPECTED_COMMANDS { + let path = commands_root.join(format!("{command}.md")); + let raw = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + + required_scalar(&raw, "description", &path); + assert!( + !body_after_frontmatter(&raw).trim().is_empty(), + "{} must have a non-empty body", + path.display() + ); + } +} + +#[test] +fn claude_bundle_agents_are_byte_identical_to_the_source_of_truth() { + let bundle_agents = bundle_root().join("agents"); + let source_agents = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/agents/claude_agents"); + + // The bundle ships exactly the expected agent set. + let mut expected: Vec = EXPECTED_AGENTS.iter().map(|a| a.to_string()).collect(); + expected.sort(); + assert_eq!( + sorted_file_names(&bundle_agents, "md"), + expected, + "claude-plugin/agents must contain exactly the expected agent files" + ); + + for agent in EXPECTED_AGENTS { + let bundle_path = bundle_agents.join(agent); + let source_path = source_agents.join(agent); + let bundle_bytes = fs::read(&bundle_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", bundle_path.display())); + let source_bytes = fs::read(&source_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", source_path.display())); + assert!( + bundle_bytes == source_bytes, + "{} must be a byte-identical copy of the single source of truth {}", + bundle_path.display(), + source_path.display() + ); + + let raw = String::from_utf8(bundle_bytes) + .unwrap_or_else(|err| panic!("{} is not utf-8: {err}", bundle_path.display())); + required_scalar(&raw, "name", &bundle_path); + required_scalar(&raw, "description", &bundle_path); + } +} + +#[test] +fn claude_bundle_skills_stay_byte_identical_to_the_codex_source() { + let claude_skills_root = bundle_root().join("skills"); + let codex_skills_root = repo_path("codex-plugin/skills"); + + let claude_skills: BTreeSet = sorted_subdir_names(&claude_skills_root) + .into_iter() + .collect(); + let codex_skills: BTreeSet = sorted_subdir_names(&codex_skills_root) + .into_iter() + .collect(); + + // The two surfaces ship the identical synced skill set. + assert_eq!( + claude_skills, codex_skills, + "claude-plugin skills {claude_skills:?} must equal codex-plugin skills {codex_skills:?} (skills are synced across surfaces)" + ); + + // Every skill's SKILL.md is byte-identical across surfaces; lock them in sync. + for skill in &claude_skills { + let claude_path = claude_skills_root.join(skill).join("SKILL.md"); + let codex_path = codex_skills_root.join(skill).join("SKILL.md"); + let claude_bytes = fs::read(&claude_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", claude_path.display())); + let codex_bytes = fs::read(&codex_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", codex_path.display())); + assert!( + claude_bytes == codex_bytes, + "{} must be byte-identical to the codex source {}", + claude_path.display(), + codex_path.display() + ); + } +} diff --git a/tests/agent_suite/main.rs b/tests/agent_suite/main.rs index 5008d351e..dcc2f2a82 100644 --- a/tests/agent_suite/main.rs +++ b/tests/agent_suite/main.rs @@ -11,6 +11,7 @@ mod common; mod agent_test; mod claude_agent_test; +mod claude_plugin_bundle_test; mod copilot_agent_test; mod kiro_agent_test; mod managed_skill_archive_test; diff --git a/tests/agent_suite/plugin_bundle_sync_test.rs b/tests/agent_suite/plugin_bundle_sync_test.rs index 421456bd7..557ed7758 100644 --- a/tests/agent_suite/plugin_bundle_sync_test.rs +++ b/tests/agent_suite/plugin_bundle_sync_test.rs @@ -131,10 +131,22 @@ const TOP_LEVEL_MANIFEST: &[(&str, TopLevelPolicy)] = &[ enum SkillSyncRule { /// Shipped only in the listed bundles (must match actual presence /// exactly, so a stale exception fails). + /// + /// Part of the exception vocabulary and handled by every `match` over + /// `SkillSyncRule`, but not currently constructed by any live entry in + /// `SKILL_SYNC_EXCEPTIONS` (today's only exception is `ContentDiverges`). + #[allow(dead_code)] OnlyIn { bundles: &'static [&'static str], reason: &'static str, }, + /// Shipped in the listed bundles, but the SKILL.md content diverges by + /// design (the presence set must match exactly; content is not + /// byte-compared across bundles). + ContentDiverges { + bundles: &'static [&'static str], + reason: &'static str, + }, } /// Documented exceptions to "every skill ships in every bundle, @@ -157,11 +169,14 @@ const SKILL_SYNC_EXCEPTIONS: &[(&[&str], SkillSyncRule)] = &[( "tracedecay-review-diff", "tracedecay-test-changes", ], - SkillSyncRule::OnlyIn { - bundles: &["cursor"], - reason: "Cursor-only slash dispatchers (disable-model-invocation: true) that \ - hand off to the shared workflow skills; Codex auto-discovers the \ - workflow skills directly", + SkillSyncRule::ContentDiverges { + bundles: &["cursor", "codex"], + reason: "The 13 `tracedecay-*` workflow skills ship in both bundles but in \ + divergent forms by design: Cursor ships slash dispatchers \ + (disable-model-invocation: true, slash H1) for its native \ + explicit-dispatch surface, while Codex (like Claude) ships the \ + canonical model-invocable form. Presence is enforced in both; \ + content is intentionally not byte-compared across the two.", }, )]; @@ -210,6 +225,16 @@ fn skills_are_synced_across_bundles_or_declared_exceptions() { in {shipped_in:?}; fix the bundles or the exception" ); } + Some(SkillSyncRule::ContentDiverges { bundles, reason }) => { + let declared: BTreeSet<&'static str> = bundles.iter().copied().collect(); + assert_eq!( + shipped_in, &declared, + "skill `{skill}` is declared ContentDiverges across {declared:?} \ + ({reason}) but ships in {shipped_in:?}; fix the bundles or the \ + exception" + ); + // Content intentionally not byte-compared: the divergence is the point. + } None => { assert_eq!( shipped_in, &every_bundle, @@ -222,14 +247,26 @@ fn skills_are_synced_across_bundles_or_declared_exceptions() { } } -/// The cross-bundle shared skill set must equal the runtime skill index the -/// hooks advertise (`hooks::CURSOR_PLUGIN_SKILLS`). +/// The cross-bundle shared *and byte-identical* skill set must equal the +/// runtime skill index the hooks advertise (`hooks::CURSOR_PLUGIN_SKILLS`). +/// The 13 `tracedecay-*` workflow skills are also shared by every bundle now, +/// but they diverge in content by design (Cursor dispatchers vs Codex canonical +/// form — see `SKILL_SYNC_EXCEPTIONS`), so they are excluded here: the runtime +/// index only lists the model-invocable skills whose bodies are identical +/// everywhere. #[test] fn skills_shared_by_every_bundle_match_the_runtime_skill_index() { let bundle_count = BUNDLES.len(); + let exceptions = skill_exception_index(); let shared: Vec = skill_presence_by_bundle() .into_iter() - .filter(|(_, shipped_in)| shipped_in.len() == bundle_count) + .filter(|(skill, shipped_in)| { + shipped_in.len() == bundle_count + && !matches!( + exceptions.get(skill.as_str()), + Some(SkillSyncRule::ContentDiverges { .. }) + ) + }) .map(|(skill, _)| skill) .collect(); let mut expected: Vec = CURSOR_PLUGIN_SKILLS @@ -239,8 +276,8 @@ fn skills_shared_by_every_bundle_match_the_runtime_skill_index() { expected.sort(); assert_eq!( shared, expected, - "the skills shared by every bundle must be exactly \ - hooks::CURSOR_PLUGIN_SKILLS (the model-invocable workflow set)" + "the skills shared and byte-identical across every bundle must be exactly \ + hooks::CURSOR_PLUGIN_SKILLS (the model-invocable set)" ); } @@ -262,7 +299,8 @@ fn every_sync_exception_documents_a_reason() { } for (skills, rule) in SKILL_SYNC_EXCEPTIONS { let reason = match rule { - SkillSyncRule::OnlyIn { reason, .. } => reason, + SkillSyncRule::OnlyIn { reason, .. } + | SkillSyncRule::ContentDiverges { reason, .. } => reason, }; assert!( !reason.trim().is_empty(), @@ -298,7 +336,8 @@ fn assert_only_in_lists_name_real_bundles() { } for (skills, rule) in SKILL_SYNC_EXCEPTIONS { match rule { - SkillSyncRule::OnlyIn { bundles, .. } => { + SkillSyncRule::OnlyIn { bundles, .. } + | SkillSyncRule::ContentDiverges { bundles, .. } => { check(format!("SKILL_SYNC_EXCEPTIONS entry {skills:?}"), bundles); } } @@ -413,8 +452,8 @@ fn assert_skill_md_synced( reference_file.display() ); } - Some(SkillSyncRule::OnlyIn { .. }) => { - unreachable!("OnlyIn skills are never content-compared across bundles") + Some(SkillSyncRule::OnlyIn { .. }) | Some(SkillSyncRule::ContentDiverges { .. }) => { + unreachable!("excepted skills are never content-compared across bundles") } } } diff --git a/tests/agent_suite/update_plugin_test.rs b/tests/agent_suite/update_plugin_test.rs index 9cf44cce7..024d288b9 100644 --- a/tests/agent_suite/update_plugin_test.rs +++ b/tests/agent_suite/update_plugin_test.rs @@ -306,6 +306,53 @@ fn cursor_update_plugin_reports_not_installed_without_a_bundle() { assert!(!home.path().join(".cursor/plugins").exists()); } +#[test] +fn claude_update_plugin_refreshes_bundle_and_preserves_user_config() { + let home = TempDir::new().unwrap(); + let claude = get_integration("claude").unwrap(); + + // User-owned Claude config that update-plugin must never destroy. + let user_claude_json = home.path().join(".claude.json"); + std::fs::write( + &user_claude_json, + "{\n \"mcpServers\": { \"other\": { \"command\": \"other-bin\" } }\n}\n", + ) + .unwrap(); + + claude.install(&ctx(home.path(), OLD_BIN)).unwrap(); + let deploy_dir = home.path().join(".claude/plugins/marketplaces/tracedecay"); + let user_json_before = bytes(&user_claude_json); + + let outcome = claude.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + let UpdatePluginOutcome::Refreshed(paths) = outcome else { + panic!("expected claude update_plugin to refresh the deployed bundle"); + }; + assert_eq!(paths, vec![deploy_dir.clone()]); + + // Foreign user config is byte-identical after the refresh. + assert_eq!(bytes(&user_claude_json), user_json_before); + + // The deployed bundle is re-rendered with the new bin path and version. + assert!(text(&deploy_dir.join(".mcp.json")).contains(NEW_BIN)); + assert!(text(&deploy_dir.join("hooks/hooks.json")).contains(NEW_BIN)); + assert!( + text(&deploy_dir.join(".claude-plugin/plugin.json")).contains(env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn claude_update_plugin_reports_not_installed_without_a_bundle() { + let home = TempDir::new().unwrap(); + std::fs::create_dir_all(home.path().join(".claude")).unwrap(); + let claude = get_integration("claude").unwrap(); + let outcome = claude.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); + assert!(matches!(outcome, UpdatePluginOutcome::NotInstalled)); + assert!(!home + .path() + .join(".claude/plugins/marketplaces/tracedecay") + .exists()); +} + // --------------------------------------------------------------------------- // Codex // --------------------------------------------------------------------------- @@ -644,7 +691,6 @@ fn config_only_integrations_report_config_only_and_write_nothing() { // config files (MCP entries, hook blocks, prompt rules); update-plugin // must not create or modify a single file for them. let config_only = [ - "claude", "opencode", "gemini", "copilot", diff --git a/tests/agent_suite/upgrade_refresh_test.rs b/tests/agent_suite/upgrade_refresh_test.rs index c336cda78..f5b72ef8a 100644 --- a/tests/agent_suite/upgrade_refresh_test.rs +++ b/tests/agent_suite/upgrade_refresh_test.rs @@ -57,8 +57,15 @@ fn claude_json_upgrade_refresh_is_idempotent_and_preserves_unknown_keys() { serde_json::json!([1, 2, 3]) ); assert!(parsed["mcpServers"]["someOtherServer"].is_object()); + // The plugin model no longer writes tracedecay into ~/.claude.json; the MCP + // server is now provided by the deployed plugin bundle, and any legacy loose + // entry is migrated away on install. A refresh must therefore leave no + // config-managed tracedecay entry in ~/.claude.json. assert!( - parsed["mcpServers"]["tracedecay"].is_object(), - "the tracedecay MCP entry must be present after refresh" + parsed + .get("mcpServers") + .and_then(|v| v.get("tracedecay")) + .is_none(), + "refresh must not add a config-managed MCP entry to ~/.claude.json" ); } From 947f9846208e705d868c109352b7caec5129eaa4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 3 Jul 2026 08:48:40 +0000 Subject: [PATCH 2/3] fix(claude): track claude-plugin/.mcp.json for clean-checkout build .gitignore ignores .mcp.json globally with a negation only for codex-plugin; claude-plugin/.mcp.json was therefore untracked, so include_str!("../../claude-plugin/.mcp.json") in src/agents/claude.rs failed to compile on a fresh clone (CI). Add the negation and commit the file (byte-identical to codex-plugin/.mcp.json). Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + claude-plugin/.mcp.json | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 claude-plugin/.mcp.json diff --git a/.gitignore b/.gitignore index 70dad8734..23c9ba4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .tracedecay .mcp.json !codex-plugin/.mcp.json +!claude-plugin/.mcp.json .claude/ .cursor/ docs/superpowers diff --git a/claude-plugin/.mcp.json b/claude-plugin/.mcp.json new file mode 100644 index 000000000..8974ff5c1 --- /dev/null +++ b/claude-plugin/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "tracedecay": { + "type": "stdio", + "command": "tracedecay", + "args": [ + "serve" + ], + "env": { + "TRACEDECAY_ENABLE_GLOBAL_DB": "1" + } + } + } +} From 893d00d971da674f96db8775b033e1b52bf041c2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 3 Jul 2026 18:37:32 +0000 Subject: [PATCH 3/3] fix(ci): force LF for claude-plugin bundle files The skill-hygiene test asserts the claude-plugin bundle files are byte-identical to the codex source, but .gitattributes only forced LF for codex-plugin/ and cursor-plugin/. On Windows the claude-plugin tree checks out CRLF, so claude_bundle_skills_stay_byte_identical_to_the_codex_source fails. Cover claude-plugin/** too. Co-Authored-By: Claude Fable 5 --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitattributes b/.gitattributes index aa17efac7..75c978e70 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,11 @@ tests/fixtures/** text eol=lf # them before the lint runs. codex-plugin/skills/** text eol=lf cursor-plugin/skills/** text eol=lf +claude-plugin/** text eol=lf +# Force LF for the retained agent source of truth; the bundle byte-identity +# test compares claude-plugin agents against these files and Windows checkout +# otherwise rewrites them to CRLF. +src/agents/claude_agents/** text eol=lf # Force LF for embedded Hermes plugin template assets — they are pulled into # the binary via include_str! and the generated-plugin snapshot test asserts # their exact bytes.