diff --git a/build.rs b/build.rs index f7a23f86b..3ffa6fe7f 100644 --- a/build.rs +++ b/build.rs @@ -315,7 +315,101 @@ fn emit_dashboard_asset_inputs() -> String { format!("{:016x}", hasher.finish()) } +/// Recursively collects every file under `root`, relative to `root`, using +/// forward-slash separators. Returns sorted paths so codegen is deterministic. +fn collect_files_relative(root: &Path) -> Vec { + fn walk(base: &Path, dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(base, &path, out); + } else if path.is_file() { + if let Ok(relative) = path.strip_prefix(base) { + out.push(relative.to_string_lossy().replace('\\', "/")); + } + } + } + } + let mut files = Vec::new(); + walk(root, root, &mut files); + files.sort(); + files +} + +/// True when `path` is a readable UTF-8 text file. Used to fail the skill +/// bundle codegen early with a clear message when a binary support file would +/// otherwise break `include_str!` with an opaque compile error. +fn is_probably_utf8_text(path: &Path) -> bool { + match fs::read(path) { + Ok(bytes) => std::str::from_utf8(&bytes).is_ok(), + // Unreadable files fall through to include_str!'s own error. + Err(_) => true, + } +} + +/// Generates `$OUT_DIR/plugin_bundle_generated.rs`: a recursive manifest of +/// every file under `plugin/skills/` (SKILL.md *and* any `references/`, +/// `scripts/`, `assets/` support files), embedded via `include_str!` at compile +/// time. Moving embedding off a hand-maintained flat list lets skills ship +/// support files without a matching table edit; the coverage tests assert the +/// generated set equals the on-disk tree. +/// +/// Each entry's deploy path equals its `plugin/`-relative source path +/// (`skills//`), which is identical for every host, so a single +/// generated slice serves Claude, Codex, and Cursor (Cursor filters out the +/// `tracedecay-*` dispatcher skills at compose time). +fn generate_skill_bundle() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let skills_root = Path::new(&manifest_dir).join("plugin/skills"); + println!("cargo::rerun-if-changed=plugin/skills"); + + let mut code = String::new(); + code.push_str( + "// @generated by build.rs (generate_skill_bundle). Do not edit.\n\ + /// Every file under `plugin/skills/` (SKILL.md + support files), embedded\n\ + /// recursively at compile time. `relative` is the deploy path (identical to\n\ + /// the `plugin/`-relative source path).\n\ + pub const GENERATED_SKILL_FILES: &[PluginFile] = &[\n", + ); + for relative in collect_files_relative(&skills_root) { + // rerun on each individual file so edits re-trigger codegen even if the + // directory mtime does not change. + println!("cargo::rerun-if-changed=plugin/skills/{relative}"); + // Every embedded file goes through `include_str!`, which only accepts + // UTF-8. A binary support file (e.g. `assets/*.png`) would otherwise + // fail to compile with an opaque "stream did not contain valid UTF-8" + // error pointing at the generated file, not the offending asset. Guard + // it here with a clear message; binary support files are not embeddable + // yet (add `include_bytes!` handling when that becomes a requirement). + let abs = skills_root.join(&relative); + if !is_probably_utf8_text(&abs) { + panic!( + "plugin/skills/{relative} is not a UTF-8 text file. The skill bundle codegen \ + embeds every file via include_str!, so binary support files (e.g. images) are \ + not embeddable yet. Remove the binary file or add include_bytes! support to \ + build.rs (generate_skill_bundle)." + ); + } + let deploy = format!("skills/{relative}"); + let source = format!("skills/{relative}"); + code.push_str(&format!( + " PluginFile {{ relative: {deploy:?}, contents: include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/plugin/{source}\")) }},\n" + )); + } + code.push_str("];\n"); + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR"); + let out_path = Path::new(&out_dir).join("plugin_bundle_generated.rs"); + if let Err(e) = fs::write(&out_path, code) { + panic!("failed to write {}: {e}", out_path.display()); + } +} + fn main() { + generate_skill_bundle(); let out_path = Path::new("src/resources/logo.ansi"); let logo_bytes = include_bytes!("src/resources/logo.png"); let ansi = logo_art::image_to_ansi(logo_bytes, 90); diff --git a/docs/AGENT-MEMORY-INTERCEPTION.md b/docs/AGENT-MEMORY-INTERCEPTION.md index d96a56603..d4bbfbe0d 100644 --- a/docs/AGENT-MEMORY-INTERCEPTION.md +++ b/docs/AGENT-MEMORY-INTERCEPTION.md @@ -10,6 +10,16 @@ instead of — or layered on top of — each agent's native memory mechanism. All file paths below were verified on this machine (Codex CLI 0.142.4, Cursor with hooks + plugins, tracedecay plugin v0.0.23 installed for both agents). +> **Note (as of 2026-07-03):** the per-host `cursor-plugin/` / `codex-plugin/` +> source trees have since collapsed into a single shared `plugin/` tree, so +> skill/command/agent sources now live under `plugin/skills/…`, +> `plugin/commands/…`, and `plugin/agents/…` (Cursor-only surfaces under +> `plugin/overlays/cursor/…`). The `recalling-project-memory` and +> `curating-project-memory` skills were also merged into a single +> `project-memory` skill. References below to the old paths/slugs are retained +> as historical design context; the current locations are the shared-tree +> equivalents. + --- ## 1. How Codex reads memory today @@ -171,9 +181,9 @@ to `~/.cursor/plugins/local/tracedecay/`: `src/hooks/`). - **`rules/tracedecay.mdc`** — always-applied rule; its **Recall** bullet steers models to `tracedecay_message_search` / `tracedecay_fact_store` - search and the `recalling-project-memory` skill. -- **`skills/`** — 25+ workflow skills incl. `recalling-project-memory`, - `curating-project-memory`, `recalling-session-context`; plus an + search and the `project-memory` skill. +- **`skills/`** — 25+ workflow skills incl. `project-memory` (the merged + recall+curate memory skill) and `recalling-session-context`; plus an agent-managed skill overlay (`install_cursor_managed_skill_overlay`). - **`agents/`** — `code-explorer`, `code-health-auditor`, `session-historian` subagent definitions. @@ -277,9 +287,9 @@ surfaces on a schedule," which design D reuses for memory. | --- | --- | --- | | Facts stored | ✅ fact store (9 facts here) + LCM transcripts | same store | | Model *can* recall | ✅ MCP `tracedecay_fact_store` search + skill | ✅ same | -| Model is *told* to recall | ⚠️ soft steering in SessionStart/UserPromptSubmit context; `recalling-project-memory` skill matches only when the model thinks "recall" | ⚠️ one Recall bullet in `tracedecay.mdc`; same skill-match dependency | +| Model is *told* to recall | ⚠️ soft steering in SessionStart/UserPromptSubmit context; `project-memory` skill matches only when the model thinks "recall" | ⚠️ one Recall bullet in `tracedecay.mdc`; same skill-match dependency | | Facts *pushed* into context | ❌ none — hook context is index status + hints only | ❌ none | -| Automatic storage | ⚠️ session_reflector exists but disabled by default; skills say "add facts **only when the user asks**" (`recalling-project-memory` guardrail) | same | +| Automatic storage | ⚠️ session_reflector exists but disabled by default; skills say "add facts **only when the user asks**" (`project-memory` guardrail) | same | | Native memory overlap | ⚠️ Codex memories **on** (`features.memories=true`), learning from the same threads in parallel | Unknown toggle state; server-side, uninspectable | The delta is precisely: **nothing proactively retrieves facts at @@ -323,7 +333,7 @@ Implementation pointers: `src/hooks/codex.rs`, `src/hooks/cursor.rs`, `src/memory/retrieval.rs` (`FactRetriever::search/probe`), analytics via `record_hint_analytics` so injection quality is measurable. No plugin schema change; hook hashes change → users re-trust via `/hooks` (already documented -in `codex-plugin/README.md`). +in the Codex plugin README). ### B. Cursor session-start injection + a materialized memory rule — **do with A** @@ -359,11 +369,10 @@ doctor checks cover it. *Effort: XS. Effect: medium. Risk: memory spam (mitigated by write-time dedupe).* -Today `recalling-project-memory`'s guardrail says add facts "**only when the +Today `project-memory`'s guardrail says add facts "**only when the user asks**" — the opposite of agent-memory behavior. Change the instruction -(rule Recall bullet + skill in `cursor-plugin/rules/tracedecay.mdc`, -`cursor-plugin/skills/recalling-project-memory/SKILL.md`, codex-plugin -mirrors) to: +(rule Recall bullet in `plugin/overlays/cursor/rules/tracedecay.mdc` + the +`plugin/skills/project-memory/SKILL.md` skill, shared across every host) to: - *Recall:* "before starting non-trivial work, search `tracedecay_fact_store` for prior decisions" (currently phrased as fallback, not default). @@ -458,7 +467,7 @@ alongside D and reusing the managed-file conventions from the skill overlay. analytics. 2. **B2 + C** (materialized Cursor memory rule + proactive storage wording) — one PR in `src/agents/cursor.rs` embedded files + plugin rule/skill text - (mirrored in `codex-plugin/` skill text). + (shared skill text under `plugin/skills/`). 3. **D** (reflector enablement UX) — config/doctor/dashboard nudge. 4. **E** (coexistence policy + optional Codex-memories harvest importer). 5. **F** (generalized AGENTS.md materialization across all 15 agent diff --git a/docs/PLUGIN-VALIDATION.md b/docs/PLUGIN-VALIDATION.md index 75a186389..8df742f4f 100644 --- a/docs/PLUGIN-VALIDATION.md +++ b/docs/PLUGIN-VALIDATION.md @@ -7,11 +7,14 @@ > `plugin/.claude-plugin/`; per-host hooks are `plugin/hooks/hooks-.json`; > MCP configs are `plugin/.mcp.json` (Claude/Codex) and `plugin/mcp-cursor.json` > (Cursor, deployed as `mcp.json`); READMEs are `plugin/README-.md`; and -> Cursor's 13 slash dispatchers live as an overlay at -> `plugin/overlays/cursor/skills/`. The composed per-host deploy set is owned by -> `src/agents/plugin_bundle.rs`. Each host still installs a byte-identical tree -> to before. Sections below that describe cross-bundle *parity/mirroring* are -> historical — with one shared tree there is nothing to keep in sync. +> Cursor's 13 workflow slugs ship as native Cursor 1.6+ slash commands +> (`plugin/overlays/cursor/commands/*.md`, deployed to `commands/` and declared +> by the manifest's `commands` key), *not* as `disable-model-invocation` +> dispatcher skills. Cursor's shared skill set is therefore the 17 canonical +> model-invocable skills, byte-identical to Claude/Codex. The composed per-host +> deploy set is owned by `src/agents/plugin_bundle.rs`. Sections below that +> describe cross-bundle *parity/mirroring* are historical — with one shared tree +> there is nothing to keep in sync. How the bundled agent plugins (shared `plugin/` tree) and their skills are validated, where each check runs, and how to extend the system @@ -72,39 +75,52 @@ schema plus exactly that one extra key, derived in the test. ### 2. Skill contract tests (cargo test) -`tests/agent_suite/plugin_skill_contract_test.rs` enforces the per-host skill -contract over every `SKILL.md` in both bundles: - -- **Frontmatter allowlists per host.** Codex skills may only use the keys - accepted by Codex's `quick_validate.py` (`name`, `description`, - `allowed-tools`, `license`, `metadata`); Cursor skills additionally allow - `disable-model-invocation`. `name` and `description` are required - everywhere. -- **Size budgets.** Skill bodies stay under 500 lines; descriptions stay under - 320 characters / 45 words; a bundle's total preloaded name+description - metadata stays under 6,000 characters so skill discovery never crowds the - host's context window. -- **Trigger-first descriptions.** Every description must contain trigger - language ("Use when …"), because hosts only show agents the metadata before - the body is loaded. A body-only "When to Use" section is rejected. -- **Supported resource layout.** A skill directory may only contain - `SKILL.md` plus the supported resource directories (`agents/`, `scripts/`, - `references/`, `assets/`). -- **Byte-copy install parity.** Installing the Cursor or Codex integration - into a temp home must produce a byte-identical copy of the source skill - tree — this catches install-time mutation and missing `include_str!` - registrations (see [Adding a skill](#adding-a-skill-correctly)). - -On top of the shared contract, `tests/agent_suite/skill_lint_cursor_test.rs` lints the -Cursor bundle with rules adapted from community SKILL.md linters (skillmark, +There is one shared `plugin/skills/` tree, so the generic per-file contract is +validated **once** by `tests/agent_suite/shared_skill_contract_test.rs` against +the **intersection contract** — the rules a `SKILL.md` must satisfy to install +cleanly on Claude, Codex, *and* Cursor: + +- **Intersection frontmatter whitelist.** Keys ⊆ `{name, description, + allowed-tools, license, metadata}` (the keys every host's validator accepts). + `name` matches the directory (kebab-case, ≤64 chars, no reserved + `claude`/`anthropic` prefix); `name` and `description` are required. A + Cursor-only key (`disable-model-invocation`/`paths`) would break Codex/Claude, + so it fails here — that surface belongs in the native commands overlay. +- **Description budget.** 50–320 characters, ≤45 words, trigger-first ("Use + …"), ends with a period, no angle brackets, unique across the set. +- **Body rules.** Exactly one plain-title H1 (never `# /slug`), no skipped + heading levels, no body-only `## When to Use` section, ≤500 lines. +- **Hygiene + layout.** No BOM/CRLF/tabs/trailing whitespace, one trailing + newline, balanced fences, non-empty body; a skill dir holds only `SKILL.md` + plus the supported resource dirs (`agents/`, `scripts/`, `references/`, + `assets/`) and no auxiliary docs (`README.md`, `CHANGELOG.md`, …). + +The same test validates the host-extra surfaces **separately**: the Cursor +native commands (`plugin/overlays/cursor/commands/*.md`, each a `# /slug` H1 +matching its file name) and the Cursor agent overlay. + +`tests/agent_suite/plugin_skill_contract_test.rs` now owns only what is *not* +in the intersection: the aggregate 6,000-char metadata budget, the optional +`agents/openai.yaml` marketplace contract, the per-host frontmatter allowances +(Codex `quick_validate.py`; Cursor's `disable-model-invocation`/`paths`), and +**byte-copy install parity** (installing the Cursor or Codex integration into a +temp home must produce a byte-identical copy of the source skill tree — catches +install-time mutation and missing embeds; see +[Adding a skill](#adding-a-skill-correctly)). + +`tests/agent_suite/skill_lint_cursor_test.rs` keeps only the Cursor-specific +reference-integrity rules (skill/tool/link resolution, `paths` glob scoping) and +the native-command lint, adapted from community SKILL.md linters (skillmark, skilldoctor, skillkit) and Cursor's skills docs: - **File hygiene:** no BOM, CRLF, tabs, or trailing whitespace; exactly one trailing newline; balanced code fences; no placeholder text; non-empty body. -- **Heading conventions:** exactly one H1; no skipped heading levels; a - slash-form H1 (`# /slug`) must match the skill `name` and requires - `disable-model-invocation: true`. +- **Heading conventions:** exactly one H1; no skipped heading levels; + model-invocable skills use a plain-title H1 (never the slash form). The + Cursor native commands (`plugin/overlays/cursor/commands/*.md`) are linted + separately: each must open with a `# /slug` H1 matching its file name and + reference only bundled skills and live MCP tools. - **Name/description quality:** no reserved `claude`/`anthropic` prefixes; descriptions ≥ 50 chars, unique across the bundle, ending in terminal punctuation, with no angle brackets. @@ -261,32 +277,36 @@ official schema rejects.) ## Adding a skill correctly -1. **Create the Cursor source skill:** a new directory - `cursor-plugin/skills//SKILL.md` with `name` and `description` +There is now one shared skill tree; there is no per-bundle mirroring to +maintain, and skill files are embedded **recursively** by `build.rs` — you do +not hand-register `include_str!` entries. + +1. **Create the source skill:** a new directory + `plugin/skills//SKILL.md` with `name` and `description` frontmatter. Keep the description trigger-first ("Use when …"), under 320 characters and 45 words; keep the body under 500 lines. Use only allowed - frontmatter keys (see layer 2 above). Slash-command skills set - `disable-model-invocation: true` and use a `tracedecay-` slug. -2. **Register the embed:** add the file to the `EMBEDDED_PLUGIN_FILES` - `include_str!` list in `src/agents/cursor.rs`, and — if the skill is - model-invocable — to `hooks::CURSOR_PLUGIN_SKILLS` in `src/hooks.rs`. The - byte-copy parity test fails if the installed bundle and the source tree - diverge. -3. **Mirror to Codex (if model-invocable):** add the skill to - `codex-plugin/skills/` and to the `include_str!` list in - `src/agents/codex.rs`. Keep it byte-identical to the Cursor source unless - you add an entry to the divergence allowlist in - `codex_skills_match_the_cursor_source_for_parity` with a reason. -4. **Watch the metadata budget:** the summed name+description metadata per - bundle must stay under 6,000 characters. If your addition tips it over, - tighten descriptions rather than raising the budget. -5. **Run the checks:** + frontmatter keys (see layer 2 above). A skill directory may additionally + carry `scripts/`, `references/`, and `assets/` support files — these are + embedded automatically by the recursive `build.rs` codegen + (`GENERATED_SKILL_FILES`), so no table edit is needed. +2. **Wire it into the model-invocable index (if model-invocable):** add the + slug to `hooks::CURSOR_PLUGIN_SKILLS` in `src/hooks/steering.rs`. Workflow + dispatch that should be explicit-invoke lives as a Cursor native command + under `plugin/overlays/cursor/commands/.md`, not as a skill. +3. **Watch the metadata budget:** the summed name+description metadata must + stay under 6,000 characters. If your addition tips it over, tighten + descriptions rather than raising the budget. +4. **Run the checks:** ```bash cargo nextest run -E 'binary(=agent_suite)' - cargo nextest run codex_skills_match_the_cursor_source_for_parity + cargo test --lib covers_the_whole_source_bundle ``` + The recursive-embed coverage tests fail if any file under + `plugin/skills/` is not embedded, and the byte-copy install tests fail if + the installed tree diverges from the source. + --- ## Adding a new ecosystem bundle @@ -327,12 +347,11 @@ To ship a bundle for another agent host (the way `codex-plugin/` mirrors |---|---|---| | Plugin manifest schema + component paths | `tests/agent_suite/plugin_manifest_schema_test.rs` + `tests/fixtures/cursor-schemas/` | `cargo test` (and thereby CI) | | mcp.json / hooks.json schema validation | `tests/agent_suite/plugin_config_schema_test.rs` | `cargo test` | -| Skill frontmatter contract + size budgets | `tests/agent_suite/plugin_skill_contract_test.rs` | `cargo test` | -| Install byte-copy parity | `tests/agent_suite/plugin_skill_contract_test.rs` | `cargo test` | -| Cursor→Codex skill parity | `src/agents/codex.rs` unit tests | `cargo test` | -| Cross-bundle disk-level sync | `tests/agent_suite/plugin_bundle_sync_test.rs` | `cargo test` | +| Unified intersection skill contract (frontmatter/description/body/hygiene/layout) + Cursor commands + agent overlay | `tests/agent_suite/shared_skill_contract_test.rs` | `cargo test` | +| Metadata budget + openai.yaml + per-host frontmatter + install byte-copy parity | `tests/agent_suite/plugin_skill_contract_test.rs` | `cargo test` | +| Recursive-embed coverage (skill tree fully embedded) | `src/agents/{claude,codex,cursor,plugin_bundle}.rs` unit tests | `cargo test` | | Manifest path + rendered output | `tests/agent_suite/agent_test.rs`, `tests/agent_suite/update_plugin_test.rs` | `cargo test` | -| Cursor skill lint rules | `tests/agent_suite/skill_lint_cursor_test.rs` | `cargo test` | +| Cursor reference-integrity + native-command lint | `tests/agent_suite/skill_lint_cursor_test.rs` | `cargo test` | | Claude Code portability rules | `tests/agent_suite/skill_lint_claude_test.rs` | `cargo test` | | Schema-validation workflow (ajv) | `.github/workflows/plugin-validation.yml` | CI only | | MCP conformance smoke | `scripts/mcp-conformance-smoke.sh` | manual + CI (`plugin-validation.yml`) | diff --git a/plugin/.cursor-plugin/plugin.json b/plugin/.cursor-plugin/plugin.json index 98cb0f27f..927ca8fa7 100644 --- a/plugin/.cursor-plugin/plugin.json +++ b/plugin/.cursor-plugin/plugin.json @@ -21,6 +21,7 @@ "rules": [ "rules/tracedecay.mdc" ], + "commands": "commands/", "skills": "skills/", "agents": "agents/" } diff --git a/plugin/overlays/cursor/skills/tracedecay-audit-safety/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-audit-safety.md similarity index 50% rename from plugin/overlays/cursor/skills/tracedecay-audit-safety/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-audit-safety.md index 525a5733f..abc6db528 100644 --- a/plugin/overlays/cursor/skills/tracedecay-audit-safety/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-audit-safety.md @@ -1,14 +1,12 @@ --- -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.' -disable-model-invocation: true +description: Audit the repo or a directory for ship-blocking risk, panic sites, risk markers, dead code, and untested high-risk symbols. --- # /tracedecay-audit-safety Apply the `tracedecay:reviewing-changes` skill. -- **Scope:** the whole repo, or the directory named after the command if one was given. +- **Scope:** the whole repo, or the directory named in `$ARGUMENTS` if one was given. - Follow that skill's read-only workflow and guardrails; report findings, don't fix them here. Output: findings grouped Critical / Warning / Note with file + enclosing symbol, and a prioritized follow-up list. diff --git a/plugin/overlays/cursor/skills/tracedecay-check-health/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-check-health.md similarity index 60% rename from plugin/overlays/cursor/skills/tracedecay-check-health/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-check-health.md index 48947d0ed..381211c54 100644 --- a/plugin/overlays/cursor/skills/tracedecay-check-health/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-check-health.md @@ -1,14 +1,12 @@ --- -name: tracedecay-check-health -description: 'Use to check code health for the repo or a directory, including worst offenders and a prioritized fix list.' -disable-model-invocation: true +description: Check code health for the repo or a directory, including worst offenders and a prioritized fix list. --- # /tracedecay-check-health Apply the `tracedecay:code-health` skill. -- **Scope:** the whole repo, or the directory named after the command if one was given. +- **Scope:** the whole repo, or the directory named in `$ARGUMENTS` if one was given. - Follow that skill's read-only workflow and guardrails; lead with `tracedecay_health` and drill only into weak dimensions. Don't restate the tool ladder here. 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/plugin/overlays/cursor/skills/tracedecay-clean-dead-code/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-clean-dead-code.md similarity index 57% rename from plugin/overlays/cursor/skills/tracedecay-clean-dead-code/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-clean-dead-code.md index b1eb7742c..fd70722e8 100644 --- a/plugin/overlays/cursor/skills/tracedecay-clean-dead-code/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-clean-dead-code.md @@ -1,14 +1,12 @@ --- -name: tracedecay-clean-dead-code -description: 'Use to find and safely remove dead code, unused imports, and duplication via the TraceDecay code graph.' -disable-model-invocation: true +description: Find and safely remove dead code, unused imports, and duplication via the TraceDecay code graph. --- # /tracedecay-clean-dead-code Apply the `tracedecay:reviewing-changes` skill. -- **Scope:** the whole repo, or the directory named after the command if one was given. +- **Scope:** the whole repo, or the directory named in `$ARGUMENTS` if one was given. - Follow that skill's workflow and guardrails: confirm zero real callers before deleting anything, be conservative with `pub` items, and respect Cursor approval/run-mode for edits and verification runs. Output: removed/consolidated items and the before/after health or test result. diff --git a/plugin/overlays/cursor/commands/tracedecay-compare-branches.md b/plugin/overlays/cursor/commands/tracedecay-compare-branches.md new file mode 100644 index 000000000..3672119ba --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-compare-branches.md @@ -0,0 +1,12 @@ +--- +description: Compare or search another git branch's code graph without switching your checkout. +--- + +# /tracedecay-compare-branches + +Apply the `tracedecay:exploring-code` skill. + +- **Args:** 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/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. + +Output: the cross-branch search hits or the added/removed/changed symbol lists, with any branch-fallback warning surfaced. diff --git a/plugin/overlays/cursor/skills/tracedecay-curate-memory/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-curate-memory.md similarity index 52% rename from plugin/overlays/cursor/skills/tracedecay-curate-memory/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-curate-memory.md index 33e5033f1..fa7b06702 100644 --- a/plugin/overlays/cursor/skills/tracedecay-curate-memory/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-curate-memory.md @@ -1,14 +1,12 @@ --- -name: tracedecay-curate-memory -description: 'Use to curate, update, delete, or inspect TraceDecay memory facts and dashboard curation from an explicit slash workflow.' -disable-model-invocation: true +description: Curate, update, delete, or inspect TraceDecay memory facts and dashboard curation from an explicit slash workflow. --- # /tracedecay-curate-memory -Apply the `tracedecay:curating-project-memory` skill. +Apply the `tracedecay:project-memory` skill. -- **Args:** interpret the text after the command as the fact, entity, query, or curation action to review; if absent, ask what memory scope to curate before mutating anything. +- **Args:** interpret `$ARGUMENTS` as the fact, entity, query, or curation action to review; if absent, 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. diff --git a/plugin/overlays/cursor/commands/tracedecay-draft-commit.md b/plugin/overlays/cursor/commands/tracedecay-draft-commit.md new file mode 100644 index 000000000..276ac1019 --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-draft-commit.md @@ -0,0 +1,12 @@ +--- +description: Draft a commit message, PR description, or changelog from semantic changes; drafts text only and never commits or pushes. +--- + +# /tracedecay-draft-commit + +Apply the `tracedecay:reviewing-changes` skill. + +- **Args:** 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. +- Follow that skill's guardrails: it 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/plugin/overlays/cursor/commands/tracedecay-find-impact.md b/plugin/overlays/cursor/commands/tracedecay-find-impact.md new file mode 100644 index 000000000..7a46dc035 --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-find-impact.md @@ -0,0 +1,12 @@ +--- +description: Find the blast radius of a change, including impacted symbols, files, and the tests to run. +--- + +# /tracedecay-find-impact + +Apply the `tracedecay:assessing-impact` skill. + +- **Args:** interpret `$ARGUMENTS` as the symbol, file, or change to analyze; if absent, 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/plugin/overlays/cursor/commands/tracedecay-fix-build.md b/plugin/overlays/cursor/commands/tracedecay-fix-build.md new file mode 100644 index 000000000..68ba2bf52 --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-fix-build.md @@ -0,0 +1,12 @@ +--- +description: Fix build and type errors by running or parsing diagnostics, mapping them to symbols with callers, then fixing. +--- + +# /tracedecay-fix-build + +Apply the `tracedecay:fixing-build-and-type-errors` skill. + +- **Args:** if `$ARGUMENTS` contains pasted `cargo`/`clippy` output, route it to `tracedecay_diagnose`; otherwise run `tracedecay_diagnostics` (scoped to a directory if one was given). +- Follow that skill's guardrails: prefer pasted output when available; `tracedecay_diagnostics` runs the toolchain, so respect Cursor approval/run-mode. + +Output: grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. diff --git a/plugin/overlays/cursor/commands/tracedecay-map-architecture.md b/plugin/overlays/cursor/commands/tracedecay-map-architecture.md new file mode 100644 index 000000000..b6a162d6c --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-map-architecture.md @@ -0,0 +1,12 @@ +--- +description: Map repo or directory architecture, including layered modules, dependency hotspots, and structural risks. +--- + +# /tracedecay-map-architecture + +Apply the `tracedecay:code-health` skill. + +- **Scope:** the whole repo, or the directory named in `$ARGUMENTS` if one was given. +- Follow that skill's read-only workflow and guardrails; don't restate the tool ladder here. + +Output: a layered module map, dependency hotspots/violations, and a prioritized risk list. diff --git a/plugin/overlays/cursor/skills/tracedecay-port-code/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-port-code.md similarity index 50% rename from plugin/overlays/cursor/skills/tracedecay-port-code/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-port-code.md index 93fc2bcd7..ece36ae2a 100644 --- a/plugin/overlays/cursor/skills/tracedecay-port-code/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-port-code.md @@ -1,14 +1,12 @@ --- -name: tracedecay-port-code -description: 'Use to port or migrate code between directories in dependency-safe order and track progress.' -disable-model-invocation: true +description: Port or migrate code between directories in dependency-safe order and track progress. --- # /tracedecay-port-code Apply the `tracedecay:editing-safely` skill. -- **Args:** interpret the text after the command as " "; if absent, ask for the source and target directories. +- **Args:** interpret `$ARGUMENTS` as " "; if absent, ask for the source and target directories. - Follow that skill's dependency-safe workflow and guardrails (port leaves first; respect Cursor approval/run-mode for edits and toolchain runs). Output: updated port status (done / remaining) and the per-batch typecheck result. diff --git a/plugin/overlays/cursor/commands/tracedecay-recall-memory.md b/plugin/overlays/cursor/commands/tracedecay-recall-memory.md new file mode 100644 index 000000000..3365e87ab --- /dev/null +++ b/plugin/overlays/cursor/commands/tracedecay-recall-memory.md @@ -0,0 +1,13 @@ +--- +description: Recall prior decisions, durable facts, and past session conversations for this project. +--- + +# /tracedecay-recall-memory + +Apply the `tracedecay:project-memory` skill, and for raw conversation recall the `tracedecay:recalling-session-context` skill. + +- **Args:** interpret `$ARGUMENTS` as the question or topic to recall; if absent, 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-curate-memory` / `tracedecay:project-memory`. + +Output: the recalled decisions/messages with their sources (fact, session id, timestamp). diff --git a/plugin/overlays/cursor/skills/tracedecay-review-diff/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-review-diff.md similarity index 64% rename from plugin/overlays/cursor/skills/tracedecay-review-diff/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-review-diff.md index 6ee705d7f..498ed92f1 100644 --- a/plugin/overlays/cursor/skills/tracedecay-review-diff/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-review-diff.md @@ -1,14 +1,12 @@ --- -name: tracedecay-review-diff -description: 'Use to review the current PR or diff for impact, risk, and quality via the TraceDecay code graph.' -disable-model-invocation: true +description: Review the current PR or diff for impact, risk, and quality via the TraceDecay code graph. --- # /tracedecay-review-diff Apply the `tracedecay:reviewing-changes` skill. -- **Scope:** the current working-tree diff, or the base ref / PR named after the command if one was given. +- **Scope:** the current working-tree diff, or the base ref / PR named in `$ARGUMENTS` if one was 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/plugin/overlays/cursor/skills/tracedecay-test-changes/SKILL.md b/plugin/overlays/cursor/commands/tracedecay-test-changes.md similarity index 56% rename from plugin/overlays/cursor/skills/tracedecay-test-changes/SKILL.md rename to plugin/overlays/cursor/commands/tracedecay-test-changes.md index 7902b9fda..ac9004028 100644 --- a/plugin/overlays/cursor/skills/tracedecay-test-changes/SKILL.md +++ b/plugin/overlays/cursor/commands/tracedecay-test-changes.md @@ -1,14 +1,12 @@ --- -name: tracedecay-test-changes -description: 'Use to test current changes by running only affected tests and mapping failures back to source.' -disable-model-invocation: true +description: Test current changes by running only affected tests and mapping failures back to source. --- # /tracedecay-test-changes Apply the `tracedecay:assessing-impact` skill. -- **Args:** interpret the text after the command as explicit changed paths; if absent, use the current working tree. +- **Args:** interpret `$ARGUMENTS` as explicit changed paths; if absent, use the current working tree. - Follow that skill's workflow and guardrails (`tracedecay_run_affected_tests` and `tracedecay_diagnostics` run cargo-backed checks — respect Cursor approval/run-mode; preview scope read-only first). Output: pass/fail summary, failing-symbol mapping, and suggested missing tests. diff --git a/plugin/overlays/cursor/skills/tracedecay-compare-branches/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-compare-branches/SKILL.md deleted file mode 100644 index e52c719dc..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-compare-branches/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: tracedecay-compare-branches -description: 'Use to compare or search another git branch''s code graph without switching your checkout.' -disable-model-invocation: true ---- - -# /tracedecay-compare-branches - -Apply the `tracedecay:exploring-code` skill. - -- **Args:** interpret the text after the command 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/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. - -Output: the cross-branch search hits or the added/removed/changed symbol lists, with any branch-fallback warning surfaced. diff --git a/plugin/overlays/cursor/skills/tracedecay-draft-commit/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-draft-commit/SKILL.md deleted file mode 100644 index 973638fdf..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-draft-commit/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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.' -disable-model-invocation: true ---- - -# /tracedecay-draft-commit - -Apply the `tracedecay:reviewing-changes` skill. - -- **Args:** interpret the text after the command as the target (e.g. "pr", "changelog", a base ref, or "staged"); if absent, draft a commit message for the working tree/staged changes. -- Follow that skill's guardrails: it 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/plugin/overlays/cursor/skills/tracedecay-find-impact/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-find-impact/SKILL.md deleted file mode 100644 index ed924aefc..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-find-impact/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: tracedecay-find-impact -description: 'Use to find the blast radius of a change, including impacted symbols, files, and the tests to run.' -disable-model-invocation: true ---- - -# /tracedecay-find-impact - -Apply the `tracedecay:assessing-impact` skill. - -- **Args:** interpret the text after the command as the symbol, file, or change to analyze; if absent, 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/plugin/overlays/cursor/skills/tracedecay-fix-build/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-fix-build/SKILL.md deleted file mode 100644 index 070477e90..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-fix-build/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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.' -disable-model-invocation: true ---- - -# /tracedecay-fix-build - -Apply the `tracedecay:fixing-build-and-type-errors` skill. - -- **Args:** if the text after the command contains pasted `cargo`/`clippy` output, route it to `tracedecay_diagnose`; otherwise run `tracedecay_diagnostics` (scoped to a directory if one was given). -- Follow that skill's guardrails: prefer pasted output when available; `tracedecay_diagnostics` runs the toolchain, so respect Cursor approval/run-mode. - -Output: grouped diagnostics with enclosing symbols + callers, the applied fix, and a clean re-check. diff --git a/plugin/overlays/cursor/skills/tracedecay-map-architecture/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-map-architecture/SKILL.md deleted file mode 100644 index fe0147ee6..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-map-architecture/SKILL.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: tracedecay-map-architecture -description: 'Use to map repo or directory architecture, including layered modules, dependency hotspots, and structural risks.' -disable-model-invocation: true ---- - -# /tracedecay-map-architecture - -Apply the `tracedecay:code-health` skill. - -- **Scope:** the whole repo, or the directory named after the command if one was given. -- Follow that skill's read-only workflow and guardrails; don't restate the tool ladder here. - -Output: a layered module map, dependency hotspots/violations, and a prioritized risk list. diff --git a/plugin/overlays/cursor/skills/tracedecay-recall-memory/SKILL.md b/plugin/overlays/cursor/skills/tracedecay-recall-memory/SKILL.md deleted file mode 100644 index b8e1b6e9c..000000000 --- a/plugin/overlays/cursor/skills/tracedecay-recall-memory/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: tracedecay-recall-memory -description: 'Use to recall prior decisions, durable facts, and past session conversations for this project.' -disable-model-invocation: true ---- - -# /tracedecay-recall-memory - -Apply the `tracedecay:recalling-project-memory` skill, and for raw conversation recall the `tracedecay:recalling-session-context` skill. - -- **Args:** interpret the text after the command as the question or topic to recall; if absent, 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-curate-memory` / `tracedecay:curating-project-memory`. - -Output: the recalled decisions/messages with their sources (fact, session id, timestamp). diff --git a/plugin/rules/tracedecay.mdc b/plugin/rules/tracedecay.mdc index be558bf78..8832bdc11 100644 --- a/plugin/rules/tracedecay.mdc +++ b/plugin/rules/tracedecay.mdc @@ -14,7 +14,7 @@ If the workspace is indexed, TraceDecay is the first stop for codebase questions - **About to add a helper or edit source:** use `tracedecay:editing-safely` for duplicate probes, refactor recon, and anchored edits. - **Reviewing a diff, auditing risk, or drafting change text:** use `tracedecay:reviewing-changes`. - **Architecture, health, status, config, TODOs, runtime:** use `tracedecay:code-health`. -- **Prior decisions or past conversations:** use `tracedecay:recalling-project-memory` / `tracedecay:recalling-session-context`; mutate facts only through `tracedecay:curating-project-memory`. +- **Prior decisions or past conversations:** use `tracedecay:project-memory` for durable facts (recall and curation) and `tracedecay:recalling-session-context` for raw transcript recall; fact mutation lives in `tracedecay:project-memory`. - **Durable memory:** when a durable decision, user preference, correction, or pitfall surfaces, store it proactively with `tracedecay_fact_store` (action "add") with calibrated trust. Do not store secrets/credentials, transient errors, environment-specific failures, one-off narratives, or task progress. - **Active project/store questions:** use `tracedecay_active_project` or `tracedecay_storage_status` for resolved active project routing instead of inferring from marker files or direct DB paths. - **Truncated MCP responses:** if a response has `truncated: true` plus `handle`, narrow the query first; call `tracedecay_retrieve` only when omitted details are needed. diff --git a/plugin/skills/code-health/SKILL.md b/plugin/skills/code-health/SKILL.md index c7ae9f8f6..fb0b50915 100644 --- a/plugin/skills/code-health/SKILL.md +++ b/plugin/skills/code-health/SKILL.md @@ -86,7 +86,7 @@ and the specific scans the user asked for — don't run every tool by reflex. - 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:project-memory`; past-session recall to `tracedecay:recalling-session-context`. ## Output diff --git a/plugin/skills/curating-project-memory/SKILL.md b/plugin/skills/curating-project-memory/SKILL.md deleted file mode 100644 index 47f66b636..000000000 --- a/plugin/skills/curating-project-memory/SKILL.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -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/plugin/skills/editing-safely/SKILL.md b/plugin/skills/editing-safely/SKILL.md index f33680122..c5a0d6f3e 100644 --- a/plugin/skills/editing-safely/SKILL.md +++ b/plugin/skills/editing-safely/SKILL.md @@ -42,6 +42,9 @@ the graph in place. 3. **Risk check → `tracedecay_impact`** (shallow `max_depth` first) when the target is widely depended on. The recon output is the edit checklist. +Run this read-only recon in one shot for a symbol or `Struct::field` with +[scripts/safe-edit-sequence.sh](scripts/safe-edit-sequence.sh). + ## Apply with anchored primitives 1. **Unique string swap → `tracedecay_str_replace`** (`path`, `old_str`, diff --git a/plugin/skills/editing-safely/scripts/safe-edit-sequence.sh b/plugin/skills/editing-safely/scripts/safe-edit-sequence.sh new file mode 100755 index 000000000..67ab9231b --- /dev/null +++ b/plugin/skills/editing-safely/scripts/safe-edit-sequence.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# safe-edit-sequence.sh — the read-only recon half of tracedecay:editing-safely, +# run in one shot via the `tracedecay tool` CLI. Given a symbol (or a struct +# field as `Struct::field`), it prints the recon checklist an agent should +# assemble before the first mutating edit: duplicate/shape twins, every call +# site, rename edges, field write sites, constructor gaps, and shallow impact. +# +# Nothing here mutates the working tree — it only reads the graph. Apply edits +# with the anchored primitives (str_replace / multi_str_replace / insert_at / +# replace_symbol / ast_grep_rewrite), then verify with +# tracedecay:fixing-build-and-type-errors and tracedecay:assessing-impact. +# +# Usage: +# scripts/safe-edit-sequence.sh # e.g. parse_config +# scripts/safe-edit-sequence.sh # e.g. Config::timeout +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +target="$1" +td() { tracedecay tool "$@"; } + +echo "== duplicate / shape twins (reuse before writing) ==" +td similar --query "$target" || true +td signature_search --query "$target" || true + +if [[ "$target" == *"::"* ]]; then + echo "== field write sites (blast radius of a field change) ==" + td field_sites --field "$target" || true + echo "== constructor sites (missing-field lists for a newly required field) ==" + td constructors --struct "${target%%::*}" || true +else + echo "== call sites (every caller must adapt to a signature change) ==" + td callers --symbol "$target" || true + echo "== rename edges (preview only — nothing renames) ==" + td rename_preview --symbol "$target" || true +fi + +echo "== shallow impact (widen only if widely depended on) ==" +td impact --symbol "$target" --max-depth 1 || true + +echo +echo "Recon complete. Assemble the checklist above, then apply anchored edits" +echo "and verify — see SKILL.md (Apply / Guardrails)." diff --git a/plugin/skills/fixing-build-and-type-errors/SKILL.md b/plugin/skills/fixing-build-and-type-errors/SKILL.md index 0ae8a27ba..8f1bd0253 100644 --- a/plugin/skills/fixing-build-and-type-errors/SKILL.md +++ b/plugin/skills/fixing-build-and-type-errors/SKILL.md @@ -15,6 +15,8 @@ Use this when build or type diagnostics are relevant to the task. Prefer pasted 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`. +Map a specific error class to the cheapest anchoring tool with [references/error-class-to-tool.md](references/error-class-to-tool.md). + ## 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. diff --git a/plugin/skills/fixing-build-and-type-errors/references/error-class-to-tool.md b/plugin/skills/fixing-build-and-type-errors/references/error-class-to-tool.md new file mode 100644 index 000000000..1ccb3acf5 --- /dev/null +++ b/plugin/skills/fixing-build-and-type-errors/references/error-class-to-tool.md @@ -0,0 +1,23 @@ +# Error class → tracedecay tool + +A lookup table mapping a build/type-error class to the cheapest tracedecay tool +that anchors it to the graph. Prefer parsing pasted output (`tracedecay_diagnose`) +over running a fresh toolchain (`tracedecay_diagnostics`) whenever you already +captured the compiler's stderr. + +| Error class | Signal | Start with | Then | +|---|---|---|---| +| Pasted `cargo`/`clippy`/`rustc` stderr on hand | You already ran the build | `tracedecay_diagnose` (`cargo_output`, `include_callers`) | Inspect the mapped node with `tracedecay_body` | +| No fresh diagnostics yet | Need to run the toolchain | `tracedecay_diagnostics` (`scope: workspace\|package\|file`) | Narrow `scope` to `file`/`package` on re-check | +| Undefined / unresolved symbol | "cannot find `X`", "no method named" | `tracedecay_search` then `tracedecay_signature` | `tracedecay_context` when name guesses fail | +| Signature / arity / type mismatch | "expected N args", "expected type T" | `tracedecay_signature` on the callee | `tracedecay_callers` to fix every call site | +| Missing struct field / bad initializer | "missing field", "no field `f`" | `tracedecay_constructors` (struct-literal sites) | `tracedecay_field_sites` for read/write sites | +| Trait not implemented / bound not satisfied | "the trait bound `T: Tr` is not satisfied" | `tracedecay_impls` / `tracedecay_implementations` | `tracedecay_type_hierarchy` for the trait tree | +| Borrow / lifetime error | "does not live long enough", "borrowed" | `tracedecay_body` on the enclosing fn | `tracedecay_callees` to see what escapes | +| Rename left dangling references | Post-refactor "cannot find" cascade | `tracedecay_rename_preview` (edges) | `tracedecay_similar` for post-rename collisions | +| Import / module path broken | "unresolved import", "module not found" | `tracedecay_module_api` | `tracedecay_file_dependents` for the ripple | +| Risky fix on a hub symbol | A fix touches a widely-used node | `tracedecay_impact` (shallow first) | `tracedecay_affected` for the test set | + +After applying the fix (via `tracedecay:editing-safely`), re-check with the +cheapest applicable path above, then verify behavior with +`tracedecay:assessing-impact`. diff --git a/plugin/skills/inspecting-managed-skills/SKILL.md b/plugin/skills/inspecting-managed-skills/SKILL.md index e9cff032e..fa89e78c4 100644 --- a/plugin/skills/inspecting-managed-skills/SKILL.md +++ b/plugin/skills/inspecting-managed-skills/SKILL.md @@ -24,7 +24,7 @@ The daemon automation loop (skill writer, memory curator, session reflector) dra - 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`. +- Memory fact curation → `tracedecay:project-memory`. ## Output diff --git a/plugin/skills/project-memory/SKILL.md b/plugin/skills/project-memory/SKILL.md new file mode 100644 index 000000000..0a1cbefe1 --- /dev/null +++ b/plugin/skills/project-memory/SKILL.md @@ -0,0 +1,144 @@ +--- +name: project-memory +description: 'Use when recalling prior decisions, durable facts, user/project preferences, or past project context before answering or planning; or when reviewing, updating, merging, deleting, pruning, or repairing tracedecay memory facts and dashboard curation.' +--- + +# Project memory + +One skill for both halves of project memory. **Recall** is read-only and where +you start; **Curate** mutates stored facts and requires explicit approval before +any destructive action. Prefer TraceDecay-native registered-project selectors +whenever a recall or curation spans or targets a project other than the active +checkout. + +## Recall (read-only, start here) + +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. + +1. **Durable 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 Cursor/Codex/agent transcripts (active + project FTS index). This skill owns the **FTS → fact** lane: use + `message_search` to surface durable project facts. For raw conversation + recall — scoped/role/time-filtered grep, lossless replay, or summary-DAG + drill-down — hand off to `tracedecay:recalling-session-context`, which owns + the **FTS → LCM** lane. +3. **If the user rates a recalled fact → `tracedecay_fact_feedback`** + (`helpful` / `unhelpful`) to tune its trust score. +4. **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. + +Do NOT capture: secrets/credentials, transient errors, environment-specific +failures, one-off narratives, task progress, or soon-stale session outcomes — +recover those from transcripts via `tracedecay:recalling-session-context`. + +## Curate (mutation, requires approval) + +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. Begin read-only, gather evidence, propose a mutation plan, then +write only narrow durable changes. + +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:** `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. 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 → `tracedecay_fact_store`** `action: "add"` / `"update"` / + `"remove"` for reviewed operations (or `POST + /api/plugins/holographic/curate/apply` / `tracedecay memory curate + --llm-ops --apply`). Require explicit approval immediately before + every `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 + +- `tracedecay_message_search` and `fact_store` search/get/contradict are + read-only recall. Search/list/probe/related/reason are read-mostly but can + update access/retrieval counters. `fact_store` add/update/remove, + `fact_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. +- Hygiene candidates (`secret_like`, `transient`, `supersession`) are review + evidence, not deterministic apply operations. External LLM plans must use + strict JSON `{"ops": [...]}` and pass the TraceDecay evidence guard; rejected + low-confidence or out-of-scope ops must stay 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:** TraceDecay graph/search, LCM/session/message tools, + docs, existing fact searches, and relevant branch/PR context. +2. **Filter:** keep durable, scoped facts with citations. Reject secrets, + credentials, PII, large code blobs, transient branch state, and uncited + speculation. +3. **Calibrate trust:** `0.85+` for independently verified decisions, about + `0.7` for ordinary well-sourced facts, 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`, `min_trust: 0.5`; skip + near-duplicates and ask before replacing contradictory facts. +5. **Store accepted facts → `tracedecay_fact_store`** `action: "add"` with + content, category, source, tags, entities, trust, and metadata containing + subject/confidence/citations. Act on `near_duplicate`, `possible_conflict`, + and `rejected_secret_like`; never rephrase a rejected secret to bypass + filtering. + +## Handoff + +- Raw session messages, scoped grep, or summary-DAG replay → + `tracedecay:recalling-session-context`. +- Index/server status without memory mutation → `tracedecay:code-health`. + +## Output + +- Recall: the relevant prior context/decisions/messages found, with source. +- Curate: 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/plugin/skills/recalling-project-memory/SKILL.md b/plugin/skills/recalling-project-memory/SKILL.md deleted file mode 100644 index 7b5379d7e..000000000 --- a/plugin/skills/recalling-project-memory/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -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/plugin/skills/recalling-session-context/SKILL.md b/plugin/skills/recalling-session-context/SKILL.md index 43236ddb8..f426fc045 100644 --- a/plugin/skills/recalling-session-context/SKILL.md +++ b/plugin/skills/recalling-session-context/SKILL.md @@ -5,11 +5,13 @@ description: 'Use when retrieving what happened in past agent sessions: full-tex # 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. +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:project-memory` instead. + +This skill owns the **FTS → LCM** lane of `tracedecay_message_search`: `message_search` is the entry point into raw-message grep, lossless replay, and summary-DAG drill-down (the ladder below). When `message_search` is instead the entry point into durable *facts*, that is `tracedecay:project-memory`'s FTS → fact lane. ## 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. +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 the LCM ladder 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. @@ -24,7 +26,7 @@ Climb this ladder cheapest-first; stop as soon as the question is answered. For ## Handoff -- Durable decisions/facts and persisting new ones → `tracedecay:recalling-project-memory`. +- Durable decisions/facts and persisting new ones → `tracedecay:project-memory`. ## Output diff --git a/plugin/skills/retrieving-project-memory/SKILL.md b/plugin/skills/retrieving-project-memory/SKILL.md index 18da30278..93630716f 100644 --- a/plugin/skills/retrieving-project-memory/SKILL.md +++ b/plugin/skills/retrieving-project-memory/SKILL.md @@ -1,6 +1,6 @@ --- 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.' +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 project-memory.' --- # Retrieving project memory @@ -8,7 +8,7 @@ description: 'Use when querying or reasoning over stored tracedecay memory facts 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 +`tracedecay: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. @@ -58,9 +58,8 @@ supported for these actions. ## Handoff -- Task/decision recall that should start from transcripts → `tracedecay:recalling-project-memory`. +- Task/decision recall that should start from transcripts, or fixing stale/contradictory/duplicate facts → `tracedecay:project-memory`. - Persisting a new durable fact → `tracedecay:storing-project-memory`. -- Fixing stale/contradictory/duplicate facts → `tracedecay:curating-project-memory`. ## Output diff --git a/plugin/skills/storing-project-memory/SKILL.md b/plugin/skills/storing-project-memory/SKILL.md index cda1d454a..4e79520ff 100644 --- a/plugin/skills/storing-project-memory/SKILL.md +++ b/plugin/skills/storing-project-memory/SKILL.md @@ -1,13 +1,13 @@ --- 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.' +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 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` +narrow "add/update/relate" counterpart to `tracedecay: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 — @@ -55,7 +55,7 @@ Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / `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). + `tracedecay: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. @@ -63,7 +63,7 @@ Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / - `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 +- Deletion is permanent and lives in `tracedecay: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. @@ -72,7 +72,7 @@ Every `action: "add"` returns `diff` / `closest_fact_id` / `similarity` / ## Handoff -- Dedup, merge, delete, or memorize a whole subject → `tracedecay:curating-project-memory`. +- Dedup, merge, delete, or memorize a whole subject → `tracedecay:project-memory`. - Read, probe, or reason over stored facts → `tracedecay:retrieving-project-memory`. ## Output diff --git a/plugin/skills/tracedecay-curate-memory/SKILL.md b/plugin/skills/tracedecay-curate-memory/SKILL.md index ea5235b35..16adeded9 100644 --- a/plugin/skills/tracedecay-curate-memory/SKILL.md +++ b/plugin/skills/tracedecay-curate-memory/SKILL.md @@ -7,7 +7,7 @@ description: 'Use to curate, update, delete, or inspect TraceDecay memory facts 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. +Route this through the `tracedecay: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. diff --git a/plugin/skills/tracedecay-recall-memory/SKILL.md b/plugin/skills/tracedecay-recall-memory/SKILL.md index 2bb0b181b..8712126e7 100644 --- a/plugin/skills/tracedecay-recall-memory/SKILL.md +++ b/plugin/skills/tracedecay-recall-memory/SKILL.md @@ -7,10 +7,10 @@ description: 'Use to recall prior decisions, durable facts, and past session con 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. +Route durable decisions/facts through the `tracedecay: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`. +- If the user asks to update, delete, merge, or prune stored facts, switch to `tracedecay:project-memory`. Output: the recalled decisions/messages with their sources (fact, session id, timestamp). diff --git a/plugin/skills/using-the-cli/SKILL.md b/plugin/skills/using-the-cli/SKILL.md index 11fe7a069..ddbbf606e 100644 --- a/plugin/skills/using-the-cli/SKILL.md +++ b/plugin/skills/using-the-cli/SKILL.md @@ -20,6 +20,7 @@ The `tracedecay` binary exposes every MCP tool as a shell command. MCP and CLI h - `--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_…`. +- The required/optional flags for the common tools are catalogued in [references/tool-arg-catalog.md](references/tool-arg-catalog.md). ## When to switch diff --git a/plugin/skills/using-the-cli/references/tool-arg-catalog.md b/plugin/skills/using-the-cli/references/tool-arg-catalog.md new file mode 100644 index 000000000..d1a549dc5 --- /dev/null +++ b/plugin/skills/using-the-cli/references/tool-arg-catalog.md @@ -0,0 +1,72 @@ +# `tracedecay tool` argument catalog + +Every MCP tool is also a shell command: `tracedecay tool --key value`. +This is the CLI fallback grammar plus the required flags for the +highest-traffic tools, so you can invoke them without a round-trip through +`--help`. The source of truth is always `tracedecay tool --help`; regen +this file if a tool's parameters drift. + +## Invocation grammar + +``` +tracedecay tool --key value [--key value ...] [--json] +``` + +- Tool names work with or without the `tracedecay_` prefix + (`tool search` ≡ `tool tracedecay_search`). +- `--key value` flags are the tool's parameters in kebab-case + (`--max-depth 1` ↔ the `max_depth` parameter). +- `--args '{"key":"value"}'` passes a whole JSON argument object instead of + individual flags. +- Any value starting with `@` is read from that file + (`--new-source @/tmp/body.txt`) — use for multi-line payloads. +- `--json` prints raw JSON; `--format json` is the per-tool equivalent. +- `--project ` pins the project root; otherwise the nearest initialised + project walking up from cwd is used. +- Truncated responses return a `handle` envelope — dereference with + `tracedecay tool retrieve --handle rh_…`. + +## Reserved / global flags + +`--json`, `--project `, `--args `, `-h`/`--help`. + +## Tool categories + +`tracedecay tool` (no name) lists every tool grouped by category: +`always-loaded`, `analysis`, `edit`, `git & history`, `graph`, `health`, +`info`, `memory & session`, `workflow`. + +## Required flags for common tools + +| Tool | Required flags | Common optional flags | +|---|---|---| +| `search` | `--query` | `--limit`, `--format` | +| `context` | `--task` | `--keywords`, `--include-code`, `--max-nodes` | +| `body` | `--node-id` (or `--symbol`) | — | +| `callers` / `callees` | `--symbol` (or `--node-id`) | `--max-depth` | +| `impact` | `--symbol` (or `--node-id`) | `--max-depth` | +| `signature` | `--symbol` | — | +| `signature_search` | `--query` | — | +| `similar` | `--query` | `--threshold` | +| `field_sites` | `--field` (`Struct::field`) | — | +| `constructors` | `--struct` | — | +| `rename_preview` | `--symbol` (or `--node-id`) | — | +| `str_replace` | `--path`, `--old-str`, `--new-str` | — | +| `multi_str_replace` | `--path`, `--replacements` (`[[old,new],…]`) | — | +| `insert_at` | `--path`, `--anchor`, `--content` | `--before` | +| `replace_symbol` | `--symbol`, `--new-source` | — | +| `ast_grep_rewrite` | `--path`, `--pattern`, `--rewrite` | — | +| `diagnostics` | — | `--scope`, `--name`, `--path` | +| `diagnose` | `--cargo-output` | `--severity`, `--include-callers` | +| `affected` | `--files` | — | +| `diff_context` | `--files` | — | +| `pr_context` | `--base-ref`, `--head-ref` | — | +| `fact_store` | `--action`, `--query` (for search) | `--min-trust` | +| `message_search` | `--query` | `--provider`, `--limit` | +| `retrieve` | `--handle` | — | + +## Non-tool subcommands + +`tracedecay --help` lists the rest: `init`, `sync`, `status`, `doctor`, +`daemon`, `sessions`, `dashboard`, …. Each carries its own `Examples:` and +`Related:` sections — read those before improvising flags. diff --git a/plugin/skills/using-tracedecay/SKILL.md b/plugin/skills/using-tracedecay/SKILL.md index 3cd9dda7b..58dc9dd4b 100644 --- a/plugin/skills/using-tracedecay/SKILL.md +++ b/plugin/skills/using-tracedecay/SKILL.md @@ -33,7 +33,7 @@ search, or file reads. You cannot rationalize your way out of this. | 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` | +| The user references prior decisions or past conversations | `tracedecay: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 | diff --git a/src/agents/claude.rs b/src/agents/claude.rs index 21a54eeff..a662abfce 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -292,6 +292,13 @@ fn known_marketplaces_path(home: &Path) -> PathBuf { /// Returns the deploy dir. fn deploy_plugin_bundle(home: &Path, tracedecay_bin: &str) -> Result { let deploy_dir = plugin_deploy_dir(home); + // Clean-replace: wipe the tracedecay-owned deploy dir before writing the + // fresh bundle, so a file the bundle no longer ships (e.g. a retired skill + // dir) does not linger across upgrades. Only remove a directory we + // exclusively own — confirmed by the deployed marketplace/plugin manifest + // naming tracedecay — so an unrelated dir squatting on the path is never + // nuked. + clean_replace_owned_deploy_dir(&deploy_dir)?; 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)?; @@ -303,6 +310,40 @@ fn deploy_plugin_bundle(home: &Path, tracedecay_bin: &str) -> Result { Ok(deploy_dir) } +/// True when a deployed marketplace dir is tracedecay-owned: its plugin or +/// marketplace manifest names the tracedecay plugin. A fresh (missing) dir is +/// trivially safe to write into. +fn deploy_dir_is_tracedecay(deploy_dir: &Path) -> bool { + let names_tracedecay = |manifest: &Path| { + load_json_file(manifest) + .get("name") + .and_then(|v| v.as_str()) + == Some("tracedecay") + }; + names_tracedecay(&deploy_dir.join(".claude-plugin/plugin.json")) + || names_tracedecay(&deploy_dir.join(".claude-plugin/marketplace.json")) +} + +/// Remove the tracedecay-owned deploy dir so the next write is a clean replace. +/// No-op when the dir is missing. Refuses (errors) when the dir exists but is +/// not tracedecay-owned, so an unrelated directory is never deleted. +fn clean_replace_owned_deploy_dir(deploy_dir: &Path) -> Result<()> { + if !deploy_dir.exists() { + return Ok(()); + } + if !deploy_dir_is_tracedecay(deploy_dir) { + return Err(TraceDecayError::Config { + message: format!( + "refusing to replace non-tracedecay plugin directory {}", + deploy_dir.display() + ), + }); + } + std::fs::remove_dir_all(deploy_dir).map_err(|e| TraceDecayError::Config { + message: format!("failed to remove {}: {e}", deploy_dir.display()), + }) +} + /// 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. @@ -311,11 +352,43 @@ fn render_plugin_file(relative: &str, contents: &str, tracedecay_bin: &str) -> R 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)), + "hooks/hooks.json" => set_hook_commands(contents, tracedecay_bin), _ => Ok(contents.to_string()), } } +/// Replace the `__TRACEDECAY_BIN__` placeholder in every hook `command` field +/// via serde, so a binary path containing a JSON-special character (`"`, a +/// control char) is escaped instead of producing invalid JSON. Mirrors +/// [`set_mcp_command`]'s parse/set/re-serialize approach. +fn set_hook_commands(raw: &str, tracedecay_bin: &str) -> Result { + let mut hooks: serde_json::Value = serde_json::from_str(raw)?; + if let Some(events) = hooks.get_mut("hooks").and_then(|v| v.as_object_mut()) { + for entries in events.values_mut().filter_map(|v| v.as_array_mut()) { + for entry in entries { + if let Some(inner) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) { + for handler in inner { + substitute_command_placeholder(handler, tracedecay_bin); + } + } + // Also handle the flat schema where the entry itself carries a + // `command` field. + substitute_command_placeholder(entry, tracedecay_bin); + } + } + } + Ok(format!("{}\n", serde_json::to_string_pretty(&hooks)?)) +} + +/// Set `value["command"]` to `tracedecay_bin` when it is exactly the +/// placeholder string. Assigning a `serde_json::Value` string escapes any +/// JSON-special characters on re-serialization. +fn substitute_command_placeholder(value: &mut serde_json::Value, tracedecay_bin: &str) { + if value.get("command").and_then(|c| c.as_str()) == Some(TRACEDECAY_BIN_PLACEHOLDER) { + value["command"] = json!(tracedecay_bin); + } +} + /// 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)?; @@ -407,6 +480,12 @@ fn unregister_marketplace(home: &Path) -> Result<()> { /// Merge `enabledPlugins.tracedecay@tracedecay = true` into settings, /// preserving existing keys. Idempotent. fn enable_plugin(settings: &mut serde_json::Value) { + // `Value`'s `IndexMut<&str>` panics if the parent is a non-object, + // non-null value (e.g. a user `settings.json` with `"enabledPlugins": "x"`). + // Coerce it to an object first, mirroring the `register_marketplace` guard. + if !settings["enabledPlugins"].is_object() && !settings["enabledPlugins"].is_null() { + settings["enabledPlugins"] = json!({}); + } settings["enabledPlugins"][PLUGIN_IDENTIFIER] = json!(true); eprintln!("\x1b[32m✔\x1b[0m Enabled plugin {PLUGIN_IDENTIFIER}"); } @@ -649,6 +728,12 @@ fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[Str } allow.sort(); allow.dedup(); + // Coerce a non-object `permissions` parent (e.g. a user `settings.json` + // with `"permissions": []`) to an object before indexing, so the + // assignment below never panics on `Value`'s `IndexMut`. + if !settings["permissions"].is_object() && !settings["permissions"].is_null() { + settings["permissions"] = json!({}); + } settings["permissions"]["allow"] = serde_json::Value::Array(allow.into_iter().map(serde_json::Value::String).collect()); eprintln!("\x1b[32m✔\x1b[0m Added tool permissions"); @@ -656,6 +741,12 @@ fn install_permissions(settings: &mut serde_json::Value, tool_permissions: &[Str /// Marker heading of the tracedecay-managed CLAUDE.md rules block. const CLAUDE_MD_MARKER: &str = "## MANDATORY: No Explore Agents When Tracedecay Is Available"; +/// The one `## ` sub-heading the managed block owns (see +/// [`claude_md_rules_text`]). The block range extends across exactly this +/// heading — never any arbitrary line containing "tracedecay", which would +/// wrongly absorb a user's own `## …tracedecay…` heading on uninstall. +const CLAUDE_MD_OWNED_SUBHEADING: &str = + "## When you spawn an Explore agent in a tracedecay-enabled project"; /// Display-case marker written by older versions. const CLAUDE_MD_DISPLAY_MARKER: &str = "## MANDATORY: No Explore Agents When TraceDecay Is Available"; @@ -697,7 +788,11 @@ fn claude_md_rules_block_range(contents: &str, markers: &[&str]) -> Option Vec { + fn walk(base: &Path, dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir) + .expect("skills dir readable") + .flatten() + { + let path = entry.path(); + if path.is_dir() { + walk(base, &path, out); + } else if path.is_file() { + out.push( + path.strip_prefix(base) + .expect("under base") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + } + let mut files = Vec::new(); + walk(root, root, &mut files); + files.sort(); + files + } + fn install_ctx(home: &Path) -> InstallContext { InstallContext { home: home.to_path_buf(), @@ -1472,12 +1593,15 @@ mod tests { .collect(); let skills = plugin_subdir_names("skills"); - assert_eq!(skills.len(), 30, "expected 30 shared skill dirs"); - for skill in &skills { - let expected = format!("skills/{skill}/SKILL.md"); + assert_eq!(skills.len(), 29, "expected 29 shared skill dirs"); + // Every file under plugin/skills/ (SKILL.md *and* any support files) is + // deployed — the recursive embed leaves nothing on disk unwired. + let skills_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin/skills"); + for relative in plugin_skill_tree_files(&skills_root) { + let expected = format!("skills/{relative}"); assert!( deploy.contains(&expected), - "Claude deploy set is missing shared skill {expected}" + "Claude deploy set is missing skill file {expected}" ); } @@ -1487,9 +1611,6 @@ mod tests { ".mcp.json", "hooks/hooks.json", "README.md", - "agents/code-explorer.md", - "agents/code-health-auditor.md", - "agents/session-historian.md", ] { assert!( deploy.contains(expected), @@ -1497,6 +1618,19 @@ mod tests { ); } + // Every agent on disk under plugin/agents is deployed — dir-walk rather + // than hardcode, so a future agent added to the shared source tree but + // not wired into Claude's deploy set is caught here. + let agents_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin/agents"); + for entry in std::fs::read_dir(&agents_root).expect("plugin/agents readable") { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + let expected = format!("agents/{name}"); + assert!( + deploy.contains(&expected), + "Claude deploy set is missing agent {expected}" + ); + } + // Every command in plugin/commands is deployed. let commands_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin/commands"); for entry in std::fs::read_dir(&commands_root).expect("plugin/commands readable") { @@ -1541,6 +1675,81 @@ mod tests { ); } + /// A binary path carrying a JSON-special char must be escaped via serde so + /// the deployed hooks.json stays valid JSON (regression: a raw + /// `str::replace` into the JSON text produced invalid output). + #[test] + fn deploy_escapes_special_chars_in_binary_path() { + let home = tempfile::tempdir().unwrap(); + let weird_bin = "/opt/td \"quote\"/tracedecay"; + let deploy_dir = deploy_plugin_bundle(home.path(), weird_bin).unwrap(); + + let hooks_raw = std::fs::read_to_string(deploy_dir.join("hooks/hooks.json")).unwrap(); + // Must parse — a raw replace would have produced invalid JSON here. + let hooks: serde_json::Value = serde_json::from_str(&hooks_raw) + .expect("hooks.json must stay valid JSON after binary-path substitution"); + assert!( + !hooks_raw.contains(TRACEDECAY_BIN_PLACEHOLDER), + "placeholder must be fully substituted" + ); + let command = hooks["hooks"]["Stop"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert_eq!(command, weird_bin, "command must be the exact binary path"); + } + + /// Redeploy must be a CLEAN REPLACE of the owned marketplace dir: a stale + /// file the current bundle no longer ships (e.g. a retired skill dir) is + /// gone after a redeploy, while the fresh bundle is present. + #[test] + fn deploy_is_a_clean_replace_dropping_stale_files() { + let home = tempfile::tempdir().unwrap(); + let deploy_dir = deploy_plugin_bundle(home.path(), "/bin/tracedecay").unwrap(); + // A stale skill dir the current bundle does not ship. + let stale = deploy_dir.join("skills/totally-retired-skill"); + std::fs::create_dir_all(&stale).unwrap(); + std::fs::write(stale.join("SKILL.md"), "stale skill").unwrap(); + + // Redeploy (the install/update path). + deploy_plugin_bundle(home.path(), "/bin/tracedecay").unwrap(); + + assert!( + !stale.exists(), + "a stale skill dir must be gone after a clean-replace redeploy" + ); + assert!( + deploy_dir.join(".claude-plugin/plugin.json").exists(), + "the fresh bundle must be present after redeploy" + ); + } + + /// The clean replace must refuse to delete a marketplace dir tracedecay + /// does not own (no tracedecay plugin/marketplace manifest), so an + /// unrelated dir squatting on the path is never nuked. + #[test] + fn deploy_refuses_to_replace_non_tracedecay_dir() { + let home = tempfile::tempdir().unwrap(); + let deploy_dir = plugin_deploy_dir(home.path()); + std::fs::create_dir_all(deploy_dir.join(".claude-plugin")).unwrap(); + std::fs::write( + deploy_dir.join(".claude-plugin/plugin.json"), + r#"{"name":"someone-elses-plugin"}"#, + ) + .unwrap(); + std::fs::write(deploy_dir.join("user-file.txt"), "keep me").unwrap(); + + let err = deploy_plugin_bundle(home.path(), "/bin/tracedecay") + .expect_err("must refuse a non-tracedecay dir"); + assert!( + err.to_string().contains("non-tracedecay"), + "unexpected error: {err}" + ); + assert!( + deploy_dir.join("user-file.txt").exists(), + "an unowned dir must be left untouched" + ); + } + /// Running install twice must yield byte-identical config files. #[test] fn install_is_idempotent() { @@ -1591,6 +1800,52 @@ mod tests { ); } + /// A settings.json whose `enabledPlugins`/`permissions` parents are the + /// wrong JSON type (a string / an array) must not panic install — the + /// guards coerce them to objects. Regression for `Value`'s `IndexMut` + /// panicking on a non-object parent. + #[test] + fn install_handles_malformed_settings_parents() { + let home = tempfile::tempdir().unwrap(); + let claude_dir = home.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + r#"{"enabledPlugins":"nope","permissions":[]}"#, + ) + .unwrap(); + + ClaudeIntegration + .install(&install_ctx(home.path())) + .expect("install must handle malformed settings parents gracefully"); + + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["enabledPlugins"][PLUGIN_IDENTIFIER], json!(true)); + assert!(settings["permissions"]["allow"].is_array()); + } + + /// `enable_plugin` guards a non-object `enabledPlugins` parent. + #[test] + fn enable_plugin_coerces_non_object_parent() { + let mut settings = json!({ "enabledPlugins": "garbage" }); + enable_plugin(&mut settings); + assert_eq!(settings["enabledPlugins"][PLUGIN_IDENTIFIER], json!(true)); + } + + /// `install_permissions` guards a non-object `permissions` parent. + #[test] + fn install_permissions_coerces_non_object_parent() { + let mut settings = json!({ "permissions": [] }); + install_permissions(&mut settings, &["mcp__tracedecay__search".to_string()]); + assert_eq!( + settings["permissions"]["allow"], + json!(["mcp__tracedecay__search"]) + ); + } + /// `enable_plugin` merges into existing `enabledPlugins` without dropping keys. #[test] fn enable_plugin_preserves_other_plugins() { @@ -1682,6 +1937,39 @@ mod tests { assert!(!config_managed_mcp_present(home.path())); } + /// The managed-block range must extend across only its own owned + /// sub-heading, not a user's own `## …tracedecay…` heading placed after + /// the block — otherwise uninstall would swallow the user's section. + #[test] + fn uninstall_preserves_user_tracedecay_heading_after_block() { + let home = tempfile::tempdir().unwrap(); + let claude_md = home.path().join("CLAUDE.md"); + install_claude_md_rules(&claude_md).unwrap(); + + // Append a user-authored heading whose text contains "tracedecay". + let user_section = + "\n## Using tracedecay in CI\n\nRun `tracedecay serve` in the pipeline.\n"; + let mut contents = std::fs::read_to_string(&claude_md).unwrap(); + contents.push_str(user_section); + std::fs::write(&claude_md, &contents).unwrap(); + + uninstall_claude_md_rules(&claude_md); + + let after = std::fs::read_to_string(&claude_md).unwrap(); + assert!( + after.contains("## Using tracedecay in CI"), + "the user's own tracedecay heading must survive uninstall" + ); + assert!( + after.contains("Run `tracedecay serve` in the pipeline."), + "the user's own section body must survive uninstall" + ); + assert!( + !after.contains(CLAUDE_MD_MARKER), + "the managed block itself must be removed" + ); + } + #[test] fn uninstall_permissions_removes_tracedecay_entries() { let mut settings = json!({ diff --git a/src/agents/codex.rs b/src/agents/codex.rs index 87affbd2e..24c55df0e 100644 --- a/src/agents/codex.rs +++ b/src/agents/codex.rs @@ -1542,11 +1542,26 @@ mod tests { /// via `codex_files`) must cover every shared model-invocable skill and the /// 13 canonical `tracedecay-*` workflow dispatchers, plus Codex's manifest, /// `.mcp.json`, hooks, and README. Codex has no slash-command or - /// `disable-model-invocation` surface, so it ships all 30 skills in their + /// `disable-model-invocation` surface, so it ships all 29 skills in their /// canonical (model-invocable) form. The single shared tree means there is /// no cross-bundle parity to enforce anymore — this replaces the old /// `codex_skills_match_the_cursor_source_for_parity` / /// `codex_bundle_ships_exactly_the_model_invocable_cursor_skills` checks. + /// Every file under a skills root, relative to it, forward-slashed. + fn skill_tree_files(root: &Path) -> Vec { + let mut files: Vec = collect_regular_files(root) + .expect("skills dir readable") + .into_iter() + .filter_map(|path| { + path.strip_prefix(root) + .ok() + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + }) + .collect(); + files.sort(); + files + } + #[test] fn codex_embedded_file_list_covers_the_whole_source_bundle() { let deploy: std::collections::BTreeSet = codex_embedded_plugin_files() @@ -1554,21 +1569,23 @@ mod tests { .map(|(relative, _)| relative.to_string()) .collect(); - // Every skill dir under plugin/skills is deployed by Codex (all 30). + // Every skill dir under plugin/skills is deployed by Codex (all 29). let skills_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin/skills"); - let mut on_disk: Vec = std::fs::read_dir(&skills_root) + let mut skill_dirs: Vec = std::fs::read_dir(&skills_root) .expect("plugin/skills should be readable") .flatten() .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir())) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .collect(); - on_disk.sort(); - assert_eq!(on_disk.len(), 30, "expected 30 shared skill dirs"); - for skill in &on_disk { - let expected = format!("skills/{skill}/SKILL.md"); + skill_dirs.sort(); + assert_eq!(skill_dirs.len(), 29, "expected 29 shared skill dirs"); + // Every file under plugin/skills/ (SKILL.md *and* any support files) is + // deployed — the recursive embed leaves nothing on disk unwired. + for relative in skill_tree_files(&skills_root) { + let expected = format!("skills/{relative}"); assert!( deploy.contains(&expected), - "Codex deploy set is missing shared skill {expected}" + "Codex deploy set is missing skill file {expected}" ); } diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index deec675bf..44f1434a2 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -302,53 +302,6 @@ fn cursor_plugin_hooks(raw: &str, tracedecay_bin: &str) -> Result { Ok(format!("{}\n", serde_json::to_string_pretty(&hooks)?)) } -/// Bundle directories shipped by older tracedecay plugin versions that no -/// longer exist in the current bundle. Swept during replace/uninstall so -/// upgrades don't strand stale surfaces (managed-path removal only covers -/// files the *current* bundle ships). `commands/` was migrated to slash -/// skills (`disable-model-invocation: true`) when Cursor deprecated the -/// standalone Commands surface. The `skills/tracedecay-*` entries are -/// legacy dispatcher slugs renamed to -/// verb-phrase slugs because Cursor displays the humanized slug as the skill -/// title. The other `skills/*` entries are model-invoked workflow skills -/// retired by the consolidated skill catalog. -const LEGACY_PLUGIN_DIRS: &[&str] = &[ - "commands", - "skills/architecture-overview", - "skills/assessing-test-coverage", - "skills/atomic-code-edits", - "skills/auditing-code-safety", - "skills/cleaning-up-dead-code", - "skills/code-health-report", - "skills/cross-branch-investigation", - "skills/drafting-commit-and-pr", - "skills/exploring-types-and-traits", - "skills/finding-duplicate-logic", - "skills/finding-impacted-areas", - "skills/memorize-subject", - "skills/memorizing-subject", - "skills/porting-code", - "skills/project-status", - "skills/reading-code-cheaply", - "skills/refactoring-safely", - "skills/reviewing-a-diff", - "skills/running-impacted-tests", - "skills/searching-for-code", - "skills/tracking-session-health", - "skills/tracedecay-arch", - "skills/tracedecay-audit", - "skills/tracedecay-branch", - "skills/tracedecay-clean", - "skills/tracedecay-commit", - "skills/tracedecay-diagnose", - "skills/tracedecay-health", - "skills/tracedecay-impact", - "skills/tracedecay-port", - "skills/tracedecay-recall", - "skills/tracedecay-review", - "skills/tracedecay-test", -]; - fn remove_cursor_plugin_install(install_dir: &Path) -> Result<()> { let Ok(metadata) = std::fs::symlink_metadata(install_dir) else { return Ok(()); @@ -375,15 +328,13 @@ fn remove_cursor_plugin_install(install_dir: &Path) -> Result<()> { ), }); } - // The directory is tracedecay-owned: sweep bundle dirs that older versions - // shipped, so they don't count as "unmanaged" leftovers below and linger - // across upgrades. - for legacy in LEGACY_PLUGIN_DIRS { - let path = install_dir.join(legacy); - if path.is_dir() && cursor_legacy_plugin_dir_is_tracedecay_owned(legacy, &path) { - std::fs::remove_dir_all(&path).ok(); - } - } + // The directory is tracedecay-owned. Sweep every skill dir the *current* + // bundle no longer ships (retired dispatcher/workflow/memory skills), then + // remove the managed skill overlay. Deriving the keep-set from the live + // bundle means a newly retired skill is swept automatically — no + // hand-maintained legacy list to fall out of date. User-added files + // outside `skills/` (and any non-tracedecay skill dir) are preserved. + sweep_retired_bundle_skill_dirs(install_dir); remove_cursor_managed_skill_overlay(install_dir); if cursor_plugin_dir_has_only_managed_files(install_dir) { std::fs::remove_dir_all(install_dir).map_err(|e| TraceDecayError::Config { @@ -401,16 +352,50 @@ fn remove_cursor_managed_skill_overlay(install_dir: &Path) { std::fs::remove_dir_all(install_dir.join("skills/agent-managed")).ok(); } -fn cursor_legacy_plugin_dir_is_tracedecay_owned(relative: &str, path: &Path) -> bool { - if !relative.starts_with("skills/") { - return true; - } - if relative.starts_with("skills/tracedecay-") { - return true; +/// Remove every `skills/` under the tracedecay plugin dir that the current +/// bundle does not ship. The keep-set is derived from the live embedded bundle, +/// so any retired skill (dispatcher, workflow, or merged-away memory skill) is +/// swept on upgrade without a hand-maintained legacy list. The `agent-managed` +/// overlay is preserved here (removed separately) and never counted as retired. +/// +/// Only tracedecay-owned skill dirs are swept: a same-name user-authored skill +/// whose `SKILL.md` carries no tracedecay marker is left untouched, so an +/// upgrade never deletes a user's private workflow that happens to collide with +/// a retired bundle slug. +fn sweep_retired_bundle_skill_dirs(install_dir: &Path) { + let skills_root = install_dir.join("skills"); + let Ok(entries) = std::fs::read_dir(&skills_root) else { + return; + }; + let shipped: std::collections::BTreeSet = embedded_plugin_files() + .into_iter() + .filter_map(|(relative, _)| { + relative + .strip_prefix("skills/") + .and_then(|rest| rest.split('/').next()) + .map(str::to_string) + }) + .collect(); + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + // The managed overlay is handled separately; never treat it as retired. + if name == "agent-managed" || shipped.contains(&name) { + continue; + } + // Preserve user-authored skills that reuse a retired slug: only sweep a + // non-shipped dir that is demonstrably tracedecay-owned. + if !skill_file_has_tracedecay_marker(&entry.path().join("SKILL.md")) { + continue; + } + std::fs::remove_dir_all(entry.path()).ok(); } - skill_file_has_tracedecay_marker(&path.join("SKILL.md")) } +/// True when a Cursor `SKILL.md` carries a tracedecay authorship marker, marking +/// the skill dir as tracedecay-owned (and therefore safe to sweep when retired). fn skill_file_has_tracedecay_marker(skill_file: &Path) -> bool { std::fs::read_to_string(skill_file).is_ok_and(|contents| { contents.lines().map(str::trim).any(|line| { @@ -483,8 +468,8 @@ fn legacy_project_cursor_has_tracedecay(cursor_dir: &Path) -> bool { /// `tracedecay install --local` wrote the MCP server entry, lifecycle hooks, /// and the steering rule into `/.cursor/`; the user-level plugin /// owns all three surfaces now. This is the project-level counterpart of the -/// [`LEGACY_PLUGIN_DIRS`] sweep: detection-gated so projects without legacy -/// artifacts are untouched, and only tracedecay-owned entries are removed — +/// user-level plugin-dir clean replace: detection-gated so projects without +/// legacy artifacts are untouched, and only tracedecay-owned entries are removed — /// user-authored config (other MCP servers, custom hooks and rules, and /// `permissions.json` allowlists, which the plugin README still recommends /// per-repo) is preserved. @@ -931,10 +916,18 @@ mod tests { subdir_names(&plugin_source_root().join("skills")) } - /// Directory names under `plugin/overlays/cursor/skills/` (the Cursor - /// dispatcher overlay slugs). - fn cursor_overlay_dispatcher_dirs() -> Vec { - subdir_names(&plugin_source_root().join("overlays/cursor/skills")) + /// File names under `plugin/overlays/cursor/commands/` (the Cursor native + /// slash-command markdown files). + fn cursor_command_files() -> Vec { + let root = plugin_source_root().join("overlays/cursor/commands"); + let mut names: Vec = std::fs::read_dir(&root) + .expect("plugin cursor commands dir should be readable") + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names } fn subdir_names(root: &Path) -> Vec { @@ -948,6 +941,21 @@ mod tests { names } + /// Every file under a single skill dir, relative to it, forward-slashed. + fn skill_dir_tree_files(skill_dir: &Path) -> Vec { + let mut files: Vec = collect_regular_files(skill_dir) + .expect("skill dir readable") + .into_iter() + .filter_map(|path| { + path.strip_prefix(skill_dir) + .ok() + .map(|rel| rel.to_string_lossy().replace('\\', "/")) + }) + .collect(); + files.sort(); + files + } + /// The doctor's expected-hooks list is parsed from the embedded bundle /// template; a parse regression would silently disable the hook checks. #[test] @@ -986,8 +994,8 @@ mod tests { assert!(install_dir.join("hooks/hooks.json").exists()); assert!(install_dir.join("rules/tracedecay.mdc").exists()); - // A representative skill, the agent, and a dispatcher skill also ship, - // so released installs are no longer missing the bundle that the + // A representative skill, the agent, and a native slash command also + // ship, so released installs are no longer missing the bundle that the // symlink path provides. assert!( install_dir.join("skills/exploring-code/SKILL.md").exists(), @@ -999,13 +1007,17 @@ mod tests { ); assert!( install_dir - .join("skills/tracedecay-map-architecture/SKILL.md") + .join("commands/tracedecay-map-architecture.md") .exists(), - "a representative slash-dispatcher skill should be embedded" + "a representative native slash command should be embedded" ); + // Cursor no longer ships the `tracedecay-*` dispatcher *skills* — those + // slugs are native commands now. assert!( - !install_dir.join("commands").exists(), - "the deprecated commands surface must not ship" + !install_dir + .join("skills/tracedecay-map-architecture/SKILL.md") + .exists(), + "the retired dispatcher skill must not ship" ); // Every embedded file is also a managed path so uninstall can clean it. @@ -1019,10 +1031,11 @@ mod tests { } /// The Cursor deploy set (composed from the shared `plugin/` tree) must - /// cover every shared model-invocable skill, every dispatcher slug in its - /// Cursor overlay form, and Cursor's manifest/rules/agents — with no - /// on-disk skill left unwired. The source paths under `plugin/` differ from - /// the deploy paths, so this checks the *composition*, not a raw dir walk. + /// cover every shared *model-invocable* skill (all non-`tracedecay-*` + /// slugs), every native slash command, and Cursor's manifest/rules/agents — + /// with no on-disk skill left unwired. The source paths under `plugin/` + /// differ from the deploy paths, so this checks the *composition*, not a raw + /// dir walk. #[test] fn embedded_file_list_covers_the_whole_source_bundle() { let deploy: std::collections::BTreeSet = embedded_plugin_files() @@ -1030,20 +1043,35 @@ mod tests { .map(|(relative, _)| relative.to_string()) .collect(); - // Every shared skill dir on disk must be deployed by Cursor. + // Every file under each shared model-invocable skill dir (SKILL.md and + // any support files) must be deployed by Cursor. The `tracedecay-*` + // dispatcher skills are NOT shipped to Cursor — they are native commands + // there. + let skills_root = plugin_source_root().join("skills"); for skill in shared_skill_dirs() { - let expected = format!("skills/{skill}/SKILL.md"); - assert!( - deploy.contains(&expected), - "Cursor deploy set is missing shared skill {expected}" - ); + let skill_dir = skills_root.join(&skill); + if skill.starts_with("tracedecay-") { + let dispatcher = format!("skills/{skill}/SKILL.md"); + assert!( + !deploy.contains(&dispatcher), + "Cursor deploy set must NOT ship dispatcher skill {dispatcher}" + ); + continue; + } + for relative in skill_dir_tree_files(&skill_dir) { + let expected = format!("skills/{skill}/{relative}"); + assert!( + deploy.contains(&expected), + "Cursor deploy set is missing skill file {expected}" + ); + } } - // Every Cursor dispatcher overlay must be deployed. - for skill in cursor_overlay_dispatcher_dirs() { - let expected = format!("skills/{skill}/SKILL.md"); + // Every Cursor native slash command must be deployed. + for command in cursor_command_files() { + let expected = format!("commands/{command}"); assert!( deploy.contains(&expected), - "Cursor deploy set is missing dispatcher overlay {expected}" + "Cursor deploy set is missing native command {expected}" ); } // Cursor's manifest surfaces. @@ -1054,15 +1082,25 @@ mod tests { "README.md", "rules/tracedecay.mdc", "rules/tracedecay-memory.mdc", - "agents/code-explorer.md", - "agents/code-health-auditor.md", - "agents/session-historian.md", ] { assert!( deploy.contains(expected), "Cursor deploy set is missing {expected}" ); } + + // Every agent in the Cursor agent overlay on disk is deployed — + // dir-walk rather than hardcode, so a future overlay agent that is not + // wired into Cursor's deploy set is caught here. + let agents_root = plugin_source_root().join("overlays/cursor/agents"); + for entry in std::fs::read_dir(&agents_root).expect("cursor agent overlay readable") { + let name = entry.unwrap().file_name().to_string_lossy().into_owned(); + let expected = format!("agents/{name}"); + assert!( + deploy.contains(&expected), + "Cursor deploy set is missing agent {expected}" + ); + } } /// Every `tracedecay_*` token mentioned anywhere in the embedded plugin @@ -1239,26 +1277,37 @@ mod tests { ); } - /// Upgrading over an older install must sweep bundle directories the - /// current bundle no longer ships (the deprecated `commands/` surface), - /// instead of stranding them as unmanaged leftovers forever. + /// Upgrading over an install that shipped the `tracedecay-*` dispatcher + /// *skills* (now re-expressed as native `commands/` slash commands) must + /// sweep those retired skill dirs instead of stranding them as unmanaged + /// leftovers, so Cursor does not list both the retired dispatcher skill and + /// the new native command. #[test] - fn reinstall_sweeps_legacy_commands_dir() { + fn reinstall_sweeps_retired_dispatcher_skill_dirs() { let tmp = TempDir::new().unwrap(); let install_dir = tmp.path().join("tracedecay"); write_embedded_plugin(&install_dir, "tracedecay").expect("embedded install should succeed"); - // Simulate a pre-migration install that shipped commands/. - std::fs::create_dir_all(install_dir.join("commands")).unwrap(); + // Simulate a pre-migration install that shipped the dispatcher skill. + std::fs::create_dir_all(install_dir.join("skills/tracedecay-review-diff")).unwrap(); + std::fs::write( + install_dir.join("skills/tracedecay-review-diff/SKILL.md"), + "---\nname: tracedecay-review-diff\n---\nApply the `tracedecay:reviewing-changes` skill.\n", + ) + .unwrap(); + // Also simulate a released install that still ships one of the retired + // memory skills merged into `project-memory`; the clean replace must + // sweep it too. + std::fs::create_dir_all(install_dir.join("skills/recalling-project-memory")).unwrap(); std::fs::write( - install_dir.join("commands/tracedecay-arch.md"), - "legacy command", + install_dir.join("skills/recalling-project-memory/SKILL.md"), + "---\nname: recalling-project-memory\n---\nRecall facts with `tracedecay_fact_store`.\n", ) .unwrap(); remove_cursor_plugin_install(&install_dir).expect("replace should succeed"); assert!( !install_dir.exists(), - "legacy commands/ must be swept so the tracedecay-only dir is fully removed" + "retired dispatcher skill dirs must be swept so the tracedecay-only dir is fully removed" ); } @@ -1275,7 +1324,7 @@ mod tests { std::fs::create_dir_all(install_dir.join("skills/tracedecay-arch")).unwrap(); std::fs::write( install_dir.join("skills/tracedecay-arch/SKILL.md"), - "legacy dispatcher skill", + "---\nname: tracedecay-arch\n---\nApply the `tracedecay:code-health` skill.\n", ) .unwrap(); @@ -1286,6 +1335,57 @@ mod tests { ); } + /// A reinstall must be a CLEAN REPLACE of the tracedecay-owned dir: a stale + /// file the current bundle no longer ships is gone afterward, while the + /// fresh bundle is present. Exercises the full write → remove → write path. + #[test] + fn reinstall_is_a_clean_replace_dropping_stale_files() { + let tmp = TempDir::new().unwrap(); + let install_dir = tmp.path().join("tracedecay"); + write_embedded_plugin(&install_dir, "tracedecay").expect("first install should succeed"); + // A stale skill dir the current bundle does not ship. + std::fs::create_dir_all(install_dir.join("skills/totally-retired-skill")).unwrap(); + std::fs::write( + install_dir.join("skills/totally-retired-skill/SKILL.md"), + "---\nname: totally-retired-skill\n---\nRun `tracedecay_search` first.\n", + ) + .unwrap(); + + // A clean replace: remove the owned dir, then write the fresh bundle. + remove_cursor_plugin_install(&install_dir).expect("clean replace should succeed"); + write_embedded_plugin(&install_dir, "tracedecay").expect("re-install should succeed"); + + assert!( + !install_dir.join("skills/totally-retired-skill").exists(), + "a stale skill dir must be gone after a clean-replace reinstall" + ); + assert!( + install_dir.join("skills/exploring-code/SKILL.md").exists(), + "the current bundle must be present after reinstall" + ); + } + + /// The clean replace must refuse to delete a directory tracedecay does not + /// own (no tracedecay plugin manifest), so it never nukes an unrelated dir. + #[test] + fn clean_replace_refuses_unmanaged_dir() { + let tmp = TempDir::new().unwrap(); + let install_dir = tmp.path().join("tracedecay"); + std::fs::create_dir_all(&install_dir).unwrap(); + std::fs::write(install_dir.join("user-file.txt"), "not tracedecay").unwrap(); + + let err = remove_cursor_plugin_install(&install_dir) + .expect_err("must refuse an unmanaged directory"); + assert!( + err.to_string().contains("unmanaged"), + "unexpected error: {err}" + ); + assert!( + install_dir.join("user-file.txt").exists(), + "an unmanaged dir must be left untouched" + ); + } + /// The project-local legacy sweep must remove exactly the tracedecay-owned /// entries pre-plugin installs wrote (`mcp.json` server entry, /// `hook-cursor-*` hooks, the steering rule) while preserving everything diff --git a/src/agents/kiro.rs b/src/agents/kiro.rs index 1b145c712..b2a5301b9 100644 --- a/src/agents/kiro.rs +++ b/src/agents/kiro.rs @@ -31,6 +31,12 @@ use super::{ pub struct KiroIntegration; const PROMPT_MARKER: &str = "## TraceDecay: mandatory tool routing"; +/// Heading an older tracedecay version wrote for the same steering block. An +/// existing install carries this marker (with the same [`PROMPT_END_MARKER`]), +/// so install/uninstall/doctor must recognize it too — otherwise a reinstall +/// appends the new block and strands the old one (duplicate steering), and +/// uninstall never removes it. +const PROMPT_MARKER_LEGACY: &str = "## Prefer tracedecay MCP tools"; const PROMPT_END_MARKER: &str = ""; const KIRO_AGENT_NAME: &str = "tracedecay"; const OWNED_AGENT_DESCRIPTION: &str = @@ -557,7 +563,7 @@ fn install_steering_rules(path: &Path) -> Result<()> { eprintln!(" Kiro steering already contains tracedecay rules, skipping"); return Ok(()); } - if existing.contains(PROMPT_MARKER) { + if contains_prompt_marker(&existing) { if let Some(range) = tracedecay_prompt_block_range(&existing) { let mut new_contents = String::with_capacity(existing.len() + block.len()); new_contents.push_str(&existing[..range.start]); @@ -572,10 +578,16 @@ fn install_steering_rules(path: &Path) -> Result<()> { ); return Ok(()); } - // Legacy block without the owned end marker: fall back to the - // heading-based strip the other hosts use, then append fresh rules. + // Marker present but no owned end marker: fall back to the heading-based + // strip the other hosts use (trying both the current and legacy + // heading), then append fresh rules. + let marker = if existing.contains(PROMPT_MARKER) { + PROMPT_MARKER + } else { + PROMPT_MARKER_LEGACY + }; let stripped = - super::prompt_rules::strip_heading_block(&existing, PROMPT_MARKER).unwrap_or_default(); + super::prompt_rules::strip_heading_block(&existing, marker).unwrap_or_default(); return super::prompt_rules::write_refreshed(path, &stripped, &block); } if let Some(parent) = path.parent() { @@ -700,7 +712,7 @@ fn remove_steering_rules(path: &Path) { let Ok(contents) = std::fs::read_to_string(path) else { return; }; - if !contents.contains(PROMPT_MARKER) { + if !contains_prompt_marker(&contents) { eprintln!(" Kiro steering does not contain tracedecay rules, skipping"); return; } @@ -810,8 +822,21 @@ fn is_owned_agent_config(config: &serde_json::Value) -> bool { == Some(OWNED_AGENT_DESCRIPTION) } +/// True when the steering file carries either the current or the legacy +/// tracedecay block marker. +fn contains_prompt_marker(contents: &str) -> bool { + contents.contains(PROMPT_MARKER) || contents.contains(PROMPT_MARKER_LEGACY) +} + +/// Byte range of the tracedecay steering block, starting at whichever marker +/// (current or legacy) appears first and running to the owned end marker. The +/// legacy block carries the same [`PROMPT_END_MARKER`], so a legacy install is +/// spliced/removed in place exactly like a current one. fn tracedecay_prompt_block_range(contents: &str) -> Option> { - let start = contents.find(PROMPT_MARKER)?; + let start = [PROMPT_MARKER, PROMPT_MARKER_LEGACY] + .iter() + .filter_map(|marker| contents.find(marker)) + .min()?; let marker = PROMPT_END_MARKER; let end_marker = contents[start..].find(marker)?; let end = start + end_marker + marker.len(); @@ -955,7 +980,7 @@ fn doctor_check_steering(dc: &mut DoctorCounters, home: &Path) { return; } let contents = std::fs::read_to_string(&path).unwrap_or_default(); - if !contents.contains(PROMPT_MARKER) { + if !contains_prompt_marker(&contents) { dc.fail( "Kiro global tracedecay.md missing tracedecay rules -- run `tracedecay install --agent kiro`", ); diff --git a/src/agents/plugin_bundle.rs b/src/agents/plugin_bundle.rs index 1fc3d8148..f4cf30571 100644 --- a/src/agents/plugin_bundle.rs +++ b/src/agents/plugin_bundle.rs @@ -7,12 +7,13 @@ //! view that each installer deploys. //! //! Layout of `plugin/`: -//! - `plugin/skills/*/SKILL.md` — the 17 shared model-invocable skills **plus** -//! the 13 canonical (`claude`/`codex`) workflow dispatchers. -//! - `plugin/overlays/cursor/skills/tracedecay-*/SKILL.md` — the Cursor-only -//! dispatcher form (`disable-model-invocation: true`, `/slug` H1). Cursor -//! deploys these **in place of** the canonical dispatcher form, at the same -//! `skills/tracedecay-*/SKILL.md` deploy path. +//! - `plugin/skills/*/SKILL.md` — the 16 shared model-invocable skills **plus** +//! the 13 canonical (`claude`/`codex`) workflow dispatcher skills (29 total). +//! Cursor deploys only the 16 model-invocable skills (not the dispatcher +//! skills); its explicit dispatch is native commands (below). +//! - `plugin/overlays/cursor/commands/tracedecay-*.md` — Cursor 1.6+ native +//! slash commands, one per workflow slug, deployed to `commands/.md`. +//! These replace the old Cursor dispatcher *skills*. //! - `plugin/agents/*.md` — Claude-form subagents (deployed by Claude). //! - `plugin/overlays/cursor/agents/*.md` — Cursor-form subagents. //! - `plugin/commands/*.md` — Claude slash commands. @@ -32,7 +33,8 @@ //! whose path may differ from the deploy path (e.g. Cursor's //! `hooks/hooks.json` is sourced from `plugin/hooks/hooks-cursor.json`). //! -//! Composed per-host view = `CANONICAL_PLUGIN_FILES ∪ _MANIFEST_FILES`. +//! Composed per-host view = `GENERATED_SKILL_FILES` (recursively embedded from +//! `plugin/skills/`, filtered per host) ∪ `_MANIFEST_FILES` and extras. /// One embedded plugin file: `relative` is its deploy path (unchanged from the /// legacy per-host bundles), `contents` is embedded from the shared `plugin/` @@ -52,188 +54,89 @@ macro_rules! plugin_file { }; } -/// The 17 model-invocable skills shared byte-for-byte by every host. These are -/// the "canonical" set every installer deploys unchanged. -pub const CANONICAL_PLUGIN_FILES: &[PluginFile] = &[ - plugin_file!( - "skills/assessing-impact/SKILL.md", - "skills/assessing-impact/SKILL.md" - ), - plugin_file!("skills/code-health/SKILL.md", "skills/code-health/SKILL.md"), - plugin_file!( - "skills/curating-project-memory/SKILL.md", - "skills/curating-project-memory/SKILL.md" - ), - plugin_file!( - "skills/editing-safely/SKILL.md", - "skills/editing-safely/SKILL.md" - ), - plugin_file!( - "skills/exploring-code/SKILL.md", - "skills/exploring-code/SKILL.md" - ), - plugin_file!( - "skills/fixing-build-and-type-errors/SKILL.md", - "skills/fixing-build-and-type-errors/SKILL.md" - ), - plugin_file!( - "skills/inspecting-managed-skills/SKILL.md", - "skills/inspecting-managed-skills/SKILL.md" - ), - plugin_file!( - "skills/managing-session-context/SKILL.md", - "skills/managing-session-context/SKILL.md" - ), - plugin_file!( - "skills/recalling-project-memory/SKILL.md", - "skills/recalling-project-memory/SKILL.md" - ), - plugin_file!( - "skills/recalling-session-context/SKILL.md", - "skills/recalling-session-context/SKILL.md" - ), - plugin_file!( - "skills/retrieving-cached-context/SKILL.md", - "skills/retrieving-cached-context/SKILL.md" - ), - plugin_file!( - "skills/retrieving-project-memory/SKILL.md", - "skills/retrieving-project-memory/SKILL.md" - ), - plugin_file!( - "skills/reviewing-changes/SKILL.md", - "skills/reviewing-changes/SKILL.md" - ), - plugin_file!( - "skills/storing-project-memory/SKILL.md", - "skills/storing-project-memory/SKILL.md" - ), - plugin_file!( - "skills/tracing-functions/SKILL.md", - "skills/tracing-functions/SKILL.md" - ), - plugin_file!( - "skills/using-the-cli/SKILL.md", - "skills/using-the-cli/SKILL.md" - ), - plugin_file!( - "skills/using-tracedecay/SKILL.md", - "skills/using-tracedecay/SKILL.md" - ), -]; +// `GENERATED_SKILL_FILES`: every file under `plugin/skills/` (all 29 skill +// SKILL.md files **plus** any `references/`/`scripts/`/`assets/` support files), +// embedded recursively at compile time by `build.rs`. This replaced the two +// hand-maintained flat `include_str!` tables so skills can ship support files +// without a matching table edit. +include!(concat!(env!("OUT_DIR"), "/plugin_bundle_generated.rs")); -/// The 13 workflow dispatchers in their canonical (`claude`/`codex`) -/// model-invocable form, sourced from `plugin/skills/tracedecay-*`. -const CANONICAL_DISPATCHER_FILES: &[PluginFile] = &[ - plugin_file!( - "skills/tracedecay-audit-safety/SKILL.md", - "skills/tracedecay-audit-safety/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-check-health/SKILL.md", - "skills/tracedecay-check-health/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-clean-dead-code/SKILL.md", - "skills/tracedecay-clean-dead-code/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-compare-branches/SKILL.md", - "skills/tracedecay-compare-branches/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-curate-memory/SKILL.md", - "skills/tracedecay-curate-memory/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-draft-commit/SKILL.md", - "skills/tracedecay-draft-commit/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-find-impact/SKILL.md", - "skills/tracedecay-find-impact/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-fix-build/SKILL.md", - "skills/tracedecay-fix-build/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-map-architecture/SKILL.md", - "skills/tracedecay-map-architecture/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-port-code/SKILL.md", - "skills/tracedecay-port-code/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-recall-memory/SKILL.md", - "skills/tracedecay-recall-memory/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-review-diff/SKILL.md", - "skills/tracedecay-review-diff/SKILL.md" - ), - plugin_file!( - "skills/tracedecay-test-changes/SKILL.md", - "skills/tracedecay-test-changes/SKILL.md" - ), -]; +/// Prefix of the dispatcher skills that Cursor does **not** deploy (they are +/// native commands on Cursor). Claude/Codex deploy every skill. +const CURSOR_EXCLUDED_SKILL_PREFIX: &str = "skills/tracedecay-"; + +/// Every skill file (all 29 skills' SKILL.md + support files) — the set +/// Claude and Codex deploy unchanged. +fn all_skill_files() -> impl Iterator { + GENERATED_SKILL_FILES.iter() +} -/// Cursor's dispatcher overlay: the same 13 slugs, in Cursor slash-dispatcher -/// form (`disable-model-invocation: true`). Deployed **at the same paths** as -/// the canonical dispatchers, overriding them for Cursor only. -const CURSOR_DISPATCHER_FILES: &[PluginFile] = &[ +/// The Cursor skill subset: every skill file *except* the `tracedecay-*` +/// dispatcher skills (those slugs are native commands on Cursor). +fn cursor_skill_files() -> impl Iterator { + GENERATED_SKILL_FILES + .iter() + .filter(|file| !file.relative.starts_with(CURSOR_EXCLUDED_SKILL_PREFIX)) +} + +/// Cursor's native slash commands: the same 13 workflow slugs, re-expressed as +/// Cursor 1.6+ `commands/` entries (no `disable-model-invocation` skill — these +/// are commands, not skills). Cursor deploys these to `commands/.md` and +/// ships the shared skill set *without* the canonical `tracedecay-*` dispatcher +/// skills, so Cursor's shared skills are byte-identical to Claude/Codex and its +/// explicit dispatch is native commands. +const CURSOR_COMMAND_FILES: &[PluginFile] = &[ plugin_file!( - "skills/tracedecay-audit-safety/SKILL.md", - "overlays/cursor/skills/tracedecay-audit-safety/SKILL.md" + "commands/tracedecay-audit-safety.md", + "overlays/cursor/commands/tracedecay-audit-safety.md" ), plugin_file!( - "skills/tracedecay-check-health/SKILL.md", - "overlays/cursor/skills/tracedecay-check-health/SKILL.md" + "commands/tracedecay-check-health.md", + "overlays/cursor/commands/tracedecay-check-health.md" ), plugin_file!( - "skills/tracedecay-clean-dead-code/SKILL.md", - "overlays/cursor/skills/tracedecay-clean-dead-code/SKILL.md" + "commands/tracedecay-clean-dead-code.md", + "overlays/cursor/commands/tracedecay-clean-dead-code.md" ), plugin_file!( - "skills/tracedecay-compare-branches/SKILL.md", - "overlays/cursor/skills/tracedecay-compare-branches/SKILL.md" + "commands/tracedecay-compare-branches.md", + "overlays/cursor/commands/tracedecay-compare-branches.md" ), plugin_file!( - "skills/tracedecay-curate-memory/SKILL.md", - "overlays/cursor/skills/tracedecay-curate-memory/SKILL.md" + "commands/tracedecay-curate-memory.md", + "overlays/cursor/commands/tracedecay-curate-memory.md" ), plugin_file!( - "skills/tracedecay-draft-commit/SKILL.md", - "overlays/cursor/skills/tracedecay-draft-commit/SKILL.md" + "commands/tracedecay-draft-commit.md", + "overlays/cursor/commands/tracedecay-draft-commit.md" ), plugin_file!( - "skills/tracedecay-find-impact/SKILL.md", - "overlays/cursor/skills/tracedecay-find-impact/SKILL.md" + "commands/tracedecay-find-impact.md", + "overlays/cursor/commands/tracedecay-find-impact.md" ), plugin_file!( - "skills/tracedecay-fix-build/SKILL.md", - "overlays/cursor/skills/tracedecay-fix-build/SKILL.md" + "commands/tracedecay-fix-build.md", + "overlays/cursor/commands/tracedecay-fix-build.md" ), plugin_file!( - "skills/tracedecay-map-architecture/SKILL.md", - "overlays/cursor/skills/tracedecay-map-architecture/SKILL.md" + "commands/tracedecay-map-architecture.md", + "overlays/cursor/commands/tracedecay-map-architecture.md" ), plugin_file!( - "skills/tracedecay-port-code/SKILL.md", - "overlays/cursor/skills/tracedecay-port-code/SKILL.md" + "commands/tracedecay-port-code.md", + "overlays/cursor/commands/tracedecay-port-code.md" ), plugin_file!( - "skills/tracedecay-recall-memory/SKILL.md", - "overlays/cursor/skills/tracedecay-recall-memory/SKILL.md" + "commands/tracedecay-recall-memory.md", + "overlays/cursor/commands/tracedecay-recall-memory.md" ), plugin_file!( - "skills/tracedecay-review-diff/SKILL.md", - "overlays/cursor/skills/tracedecay-review-diff/SKILL.md" + "commands/tracedecay-review-diff.md", + "overlays/cursor/commands/tracedecay-review-diff.md" ), plugin_file!( - "skills/tracedecay-test-changes/SKILL.md", - "overlays/cursor/skills/tracedecay-test-changes/SKILL.md" + "commands/tracedecay-test-changes.md", + "overlays/cursor/commands/tracedecay-test-changes.md" ), ]; @@ -320,56 +223,60 @@ pub const CODEX_MANIFEST_FILES: &[PluginFile] = &[ plugin_file!("hooks/hooks.json", "hooks/hooks-codex.json"), ]; -/// Compose a host's full deploy set as `(relative, contents)` tuples, in a -/// deterministic order matching the legacy per-host embed tables' shape. +/// Compose a host's full deploy set as `(relative, contents)` tuples: the +/// host's manifest/agent/command/rule sections first, then its skill files +/// (from the recursively-embedded `GENERATED_SKILL_FILES`). /// -/// Order mirrors what each installer historically iterated: manifest/mcp/hooks -/// pieces first, then the shared skills, dispatchers, and host extras. Exact -/// ordering does not affect the deployed tree (each file is written by its -/// deploy `relative` path), but a stable order keeps tests deterministic. -fn compose(sections: &[&[PluginFile]]) -> Vec<(&'static str, &'static str)> { +/// Exact ordering does not affect the deployed tree (each file is written by +/// its deploy `relative` path), but a stable order keeps tests deterministic. +fn compose( + sections: &[&'static [PluginFile]], + skills: impl Iterator, +) -> Vec<(&'static str, &'static str)> { sections .iter() .flat_map(|section| section.iter()) + .chain(skills) .map(|file| (file.relative, file.contents)) .collect() } -/// Files Claude deploys: manifest + canonical skills + canonical dispatchers + -/// Claude agents + Claude commands. +/// Files Claude deploys: manifest + Claude agents + Claude commands + every +/// skill file (all 29 skills incl. dispatchers, plus any support files). pub fn claude_files() -> Vec<(&'static str, &'static str)> { - compose(&[ - CLAUDE_MANIFEST_FILES, - CLAUDE_AGENT_FILES, - CLAUDE_COMMAND_FILES, - CANONICAL_PLUGIN_FILES, - CANONICAL_DISPATCHER_FILES, - ]) + compose( + &[ + CLAUDE_MANIFEST_FILES, + CLAUDE_AGENT_FILES, + CLAUDE_COMMAND_FILES, + ], + all_skill_files(), + ) } -/// Files Cursor deploys: manifest + canonical skills + Cursor dispatcher -/// overlay + Cursor agents + Cursor rules. +/// Files Cursor deploys: manifest + Cursor rules + Cursor agents + Cursor +/// native commands + the shared skill files *without* the `tracedecay-*` +/// dispatcher skills (those slugs are native commands on Cursor). pub fn cursor_files() -> Vec<(&'static str, &'static str)> { - compose(&[ - CURSOR_MANIFEST_FILES, - CURSOR_RULE_FILES, - CURSOR_AGENT_FILES, - CANONICAL_PLUGIN_FILES, - CURSOR_DISPATCHER_FILES, - ]) + compose( + &[ + CURSOR_MANIFEST_FILES, + CURSOR_RULE_FILES, + CURSOR_AGENT_FILES, + CURSOR_COMMAND_FILES, + ], + cursor_skill_files(), + ) } -/// Files Codex deploys: manifest + canonical skills + canonical dispatchers. -/// Codex ships no agents, commands, or rules. +/// Files Codex deploys: manifest + every skill file (all 29 skills incl. +/// dispatchers, plus any support files). Codex ships no agents/commands/rules. pub fn codex_files() -> Vec<(&'static str, &'static str)> { - compose(&[ - CODEX_MANIFEST_FILES, - CANONICAL_PLUGIN_FILES, - CANONICAL_DISPATCHER_FILES, - ]) + compose(&[CODEX_MANIFEST_FILES], all_skill_files()) } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use std::collections::BTreeSet; @@ -411,27 +318,76 @@ mod tests { #[test] fn each_host_composes_the_expected_file_count() { - // 17 canonical skills + 13 dispatchers = 30 skills, common to all hosts. - // Claude: 30 skills + 5 manifest (2 dot + mcp + hooks + README) + 3 - // agents + 13 commands = 51. - assert_eq!(claude_files().len(), 51); - // Cursor: 30 skills + 4 manifest (dot + mcp + hooks + README) + 2 rules - // + 3 agents = 39. - assert_eq!(cursor_files().len(), 39); - // Codex: 30 skills + 4 manifest (dot + mcp + hooks + README) = 34. - assert_eq!(codex_files().len(), 34); + // Skill files are embedded recursively (SKILL.md + support files), so + // the skill count is derived from the generated set rather than a + // frozen literal. Cursor drops the `tracedecay-*` dispatcher skills. + let all_skills = GENERATED_SKILL_FILES.len(); + let cursor_skills = cursor_skill_files().count(); + + // Claude: skills + 5 manifest (2 dot + mcp + hooks + README) + 3 agents + // + 13 commands. + assert_eq!(claude_files().len(), all_skills + 5 + 3 + 13); + // Cursor: cursor-subset skills + 4 manifest (dot + mcp + hooks + + // README) + 2 rules + 3 agents + 13 native commands. + assert_eq!(cursor_files().len(), cursor_skills + 4 + 2 + 3 + 13); + // Codex: skills + 4 manifest (dot + mcp + hooks + README). + assert_eq!(codex_files().len(), all_skills + 4); } - /// Every model-invocable canonical skill maps to an on-disk source dir. + /// Every embedded skill file maps to an on-disk source under `plugin/`. #[test] - fn canonical_skills_have_source_dirs() { + fn generated_skill_files_have_source_paths() { let root = plugin_source_root(); - for file in CANONICAL_PLUGIN_FILES { + assert!( + !GENERATED_SKILL_FILES.is_empty(), + "generated skill file set is empty" + ); + for file in GENERATED_SKILL_FILES { assert!( root.join(file.relative).exists(), - "canonical source missing: plugin/{}", + "skill source missing: plugin/{}", file.relative ); } } + + /// The recursive embed must cover the on-disk skill tree exactly — every + /// file under `plugin/skills/` is embedded, and nothing extra. + #[test] + fn generated_skill_files_cover_the_skill_tree_exactly() { + let skills_root = plugin_source_root().join("skills"); + let mut on_disk = BTreeSet::new(); + collect_relative(&skills_root, &skills_root, &mut on_disk); + + let embedded: BTreeSet = GENERATED_SKILL_FILES + .iter() + .map(|file| { + file.relative + .strip_prefix("skills/") + .expect("skill deploy path is under skills/") + .to_string() + }) + .collect(); + + assert_eq!( + embedded, on_disk, + "GENERATED_SKILL_FILES must match every file under plugin/skills/ exactly" + ); + } + + fn collect_relative(base: &Path, dir: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("read skills dir").flatten() { + let path = entry.path(); + if path.is_dir() { + collect_relative(base, &path, out); + } else if path.is_file() { + out.insert( + path.strip_prefix(base) + .expect("under base") + .to_string_lossy() + .replace('\\', "/"), + ); + } + } + } } diff --git a/src/analytics.rs b/src/analytics.rs index 26cb6e095..7fc706264 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -744,7 +744,7 @@ mod tests { assert_usage_event( &events, UsageKind::Skill, - "tracedecay:curating-project-memory", + "tracedecay:project-memory", UsageCategory::TraceDecayWorkflowSkill, ); assert_usage_event( diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs index 197c0734f..44491034d 100644 --- a/src/hooks/codex.rs +++ b/src/hooks/codex.rs @@ -32,7 +32,7 @@ whole-file reads, `tracedecay:tracing-functions` with `tracedecay_find_exact_sym `tracedecay_callers`, and `tracedecay_callees` when asked to trace functions, find callers, \ or inspect setup/helper/fixture dependencies, `tracedecay:assessing-impact` with \ `tracedecay_affected` and `tracedecay_test_map` before guessing affected tests, \ -`tracedecay:recalling-project-memory` when project decisions/preferences matter, and \ +`tracedecay:project-memory` when project decisions/preferences matter, and \ `tracedecay:recalling-session-context` with `tracedecay_message_search`, \ `tracedecay_lcm_expand_query`, and `tracedecay_lcm_describe` when prior conversation context \ may be missing."; diff --git a/src/hooks/steering.rs b/src/hooks/steering.rs index 49c19c15b..639c041b7 100644 --- a/src/hooks/steering.rs +++ b/src/hooks/steering.rs @@ -8,24 +8,23 @@ use serde_json::Value; use super::now_unix_secs; -/// 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. +/// Model-invocable skills that Cursor ships in its `skills/` directory. The 13 +/// `tracedecay-*` workflow slugs are native Cursor commands (not skills), so +/// they are excluded. This covers the foundational skills plus the memory +/// skills (`project-memory` — the merged recall+curate skill — +/// `managing-session-context`, `retrieving-cached-context`, +/// `retrieving-project-memory`, `storing-project-memory`). 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", - "curating-project-memory", "editing-safely", "exploring-code", "fixing-build-and-type-errors", "inspecting-managed-skills", "managing-session-context", - "recalling-project-memory", + "project-memory", "recalling-session-context", "retrieving-cached-context", "retrieving-project-memory", diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index dc72e4d47..3e7a6a979 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -495,9 +495,9 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str, expect .is_some_and(|keywords| !keywords.is_empty()), "plugin manifest should carry keywords" ); - assert!( - manifest.get("commands").is_none(), - "the deprecated commands surface must not be referenced by the manifest" + assert_eq!( + manifest["commands"], "commands/", + "the manifest must declare the native Cursor commands surface" ); assert!( manifest["rules"] @@ -596,7 +596,7 @@ fn test_cursor_plugin_bundle_files_are_valid() { #[test] fn generated_guidance_prefers_resolved_active_project_store() { - // The 17 model-invocable skills are now shared byte-for-byte across hosts + // The 16 model-invocable skills are now shared byte-for-byte across hosts // in `plugin/skills/`, so cursor and codex read the same source file. let shared_status = include_str!("../../plugin/skills/code-health/SKILL.md"); let cursor_rule = include_str!("../../plugin/rules/tracedecay.mdc"); @@ -635,9 +635,9 @@ fn generated_guidance_prefers_resolved_active_project_store() { #[test] fn generated_plugin_skill_descriptions_are_yaml_quoted() { - // Shared skills plus the Cursor dispatcher overlay (which carries its own - // descriptions) — the single `plugin/` tree replaced the per-host bundles. - for root in ["plugin/skills", "plugin/overlays/cursor/skills"] { + // The shared skill set — one `plugin/skills/` tree for every host. Cursor's + // workflow slugs are native commands now, not skills. + for root in ["plugin/skills"] { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(root); for entry in std::fs::read_dir(&root).unwrap() { let skill_path = entry.unwrap().path().join("SKILL.md"); diff --git a/tests/agent_suite/claude_plugin_bundle_test.rs b/tests/agent_suite/claude_plugin_bundle_test.rs index 6228b1bb3..0539ee1d7 100644 --- a/tests/agent_suite/claude_plugin_bundle_test.rs +++ b/tests/agent_suite/claude_plugin_bundle_test.rs @@ -29,20 +29,21 @@ fn bundle_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("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 +/// The 29 skills the bundle ships (also the codex skill set): the 12 +/// foundational model-invocable skills (recalling+curating-project-memory +/// merged into `project-memory`), the 4 memory skills, plus the 13 /// `tracedecay-*` workflow skills, kept in sync across every skill-bundling /// surface. const EXPECTED_SKILLS: &[&str] = &[ - // 13 foundational + // 12 foundational (recalling+curating-project-memory merged into + // project-memory) "assessing-impact", "code-health", - "curating-project-memory", "editing-safely", "exploring-code", "fixing-build-and-type-errors", "inspecting-managed-skills", - "recalling-project-memory", + "project-memory", "recalling-session-context", "reviewing-changes", "tracing-functions", @@ -354,7 +355,7 @@ fn claude_bundle_ships_exactly_the_expected_skills() { assert_eq!( sorted_subdir_names(&skills_root), expected, - "claude-plugin/skills must contain exactly the expected 26 skill directories" + "claude-plugin/skills must contain exactly the expected 29 skill directories" ); } diff --git a/tests/agent_suite/kiro_agent_test.rs b/tests/agent_suite/kiro_agent_test.rs index b685bac03..4673ac816 100644 --- a/tests/agent_suite/kiro_agent_test.rs +++ b/tests/agent_suite/kiro_agent_test.rs @@ -371,6 +371,85 @@ fn test_uninstall_preserves_user_steering_after_tracedecay_block() { assert!(!uninstalled.contains("## TraceDecay: mandatory tool routing")); } +/// An existing install seeded with the OLD steering marker (`## Prefer +/// tracedecay MCP tools`, carrying the same owned end marker) must be REPLACED +/// by install (net one block, no duplicate steering) and fully REMOVED by +/// uninstall. Without legacy-marker fallback the old block is invisible: +/// reinstall appends the new block leaving the old one, and uninstall never +/// removes it. +#[test] +fn test_install_replaces_legacy_marker_block_and_uninstall_removes_it() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let ctx = make_ctx(home); + + let steering_path = home.join(".kiro/steering/tracedecay.md"); + std::fs::create_dir_all(steering_path.parent().unwrap()).unwrap(); + // A legacy block: old heading + the owned end marker. + let legacy_block = "## Prefer tracedecay MCP tools\n\n\ + Old steering text from a released version.\n\n\ + \n"; + std::fs::write(&steering_path, legacy_block).unwrap(); + + KiroIntegration.install(&ctx).unwrap(); + + let installed = std::fs::read_to_string(&steering_path).unwrap(); + // The new block is present, and the legacy heading is gone (replaced, not + // appended alongside). + assert!( + installed.contains("## TraceDecay: mandatory tool routing"), + "install must write the current steering block" + ); + assert!( + !installed.contains("## Prefer tracedecay MCP tools"), + "install must replace the legacy marker block, not leave it stranded" + ); + assert!( + !installed.contains("Old steering text from a released version."), + "legacy block body must be gone" + ); + // Exactly one end marker => exactly one block. + assert_eq!( + installed.matches("").count(), + 1, + "there must be exactly one steering block after install" + ); + + KiroIntegration.uninstall(&ctx).unwrap(); + assert!( + !steering_path.exists(), + "uninstall must remove the steering file when only the tracedecay block remained" + ); +} + +/// A legacy-marker block with a user's own heading appended after it must be +/// removed by uninstall while preserving the user content. +#[test] +fn test_uninstall_removes_legacy_marker_block_preserving_user_content() { + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let ctx = make_ctx(home); + + let steering_path = home.join(".kiro/steering/tracedecay.md"); + std::fs::create_dir_all(steering_path.parent().unwrap()).unwrap(); + std::fs::write( + &steering_path, + "## Prefer tracedecay MCP tools\n\n\ + Old steering text.\n\n\ + \n\n\ + ## My own guidance\n\nKeep this.\n", + ) + .unwrap(); + + KiroIntegration.uninstall(&ctx).unwrap(); + + let after = std::fs::read_to_string(&steering_path).unwrap(); + assert!(after.contains("## My own guidance")); + assert!(after.contains("Keep this.")); + assert!(!after.contains("## Prefer tracedecay MCP tools")); + assert!(!after.contains("Old steering text.")); +} + #[test] fn test_uninstall_removes_tracedecay_and_preserves_other_mcp_servers() { let dir = TempDir::new().unwrap(); diff --git a/tests/agent_suite/main.rs b/tests/agent_suite/main.rs index 6075b13e0..eddd21344 100644 --- a/tests/agent_suite/main.rs +++ b/tests/agent_suite/main.rs @@ -23,6 +23,7 @@ mod plugin_manifest_schema_test; mod plugin_skill_contract_test; mod plugin_validation_support; mod prompt_rules_parity_test; +mod shared_skill_contract_test; mod skill_lint_claude_test; mod skill_lint_cursor_test; mod skill_targets_test; diff --git a/tests/agent_suite/plugin_manifest_schema_test.rs b/tests/agent_suite/plugin_manifest_schema_test.rs index 6956e39c4..c25c81412 100644 --- a/tests/agent_suite/plugin_manifest_schema_test.rs +++ b/tests/agent_suite/plugin_manifest_schema_test.rs @@ -118,6 +118,7 @@ fn cursor_bundle_manifest_matches_the_official_cursor_plugin_schema() { ("mcp-cursor.json", "mcp.json"), ("hooks/hooks-cursor.json", "hooks/hooks.json"), ("rules", "rules"), + ("overlays/cursor/commands", "commands"), ("skills", "skills"), ("agents", "agents"), ]); diff --git a/tests/agent_suite/plugin_skill_contract_test.rs b/tests/agent_suite/plugin_skill_contract_test.rs index 84a56f6c4..ff9a77063 100644 --- a/tests/agent_suite/plugin_skill_contract_test.rs +++ b/tests/agent_suite/plugin_skill_contract_test.rs @@ -1,12 +1,13 @@ //! Contract tests for the shared plugin skills: frontmatter schema per host, //! plus the shared skill-creator design-advice checks. //! -//! The three host bundles now share one `plugin/` tree. Codex deploys all 30 +//! The three host bundles now share one `plugin/` tree. Codex deploys all 29 //! skills from `plugin/skills/` (canonical, model-invocable form). Cursor -//! deploys the 17 shared skills from `plugin/skills/` plus the 13 dispatcher -//! slugs in their Cursor overlay form from `plugin/overlays/cursor/skills/`. -//! Each host's deployed skill *source* set is staged into a temp dir below so -//! the contract and byte-copy checks run over exactly what that host installs. +//! deploys only the 16 shared model-invocable skills from `plugin/skills/` +//! (the `tracedecay-*` workflow slugs are native commands on Cursor, not +//! skills). Each host's deployed skill *source* set is staged into a temp dir +//! below so the contract and byte-copy checks run over exactly what that host +//! installs. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -21,21 +22,12 @@ use tempfile::TempDir; use tracedecay::agents::{expected_tool_perms, get_integration, InstallContext}; use tracedecay::config::USER_DATA_DIR_ENV; -/// Codex deploys every skill under `plugin/skills/` (all 30, canonical form). +/// Codex deploys every skill under `plugin/skills/` (all 29, canonical form). const CODEX_SKILL_ROOT: &str = "plugin/skills"; -/// Cursor's dispatcher overlay (13 `tracedecay-*` slugs in slash-dispatcher -/// form). Cursor deploys these *in place of* the canonical dispatcher form. -const CURSOR_OVERLAY_SKILL_ROOT: &str = "plugin/overlays/cursor/skills"; -// Size budgets: the 500-line body cap and the "concise, trigger-first -// description" rule come from Anthropic's skill-creator design advice. The -// numeric description and metadata caps are house budgets chosen when these -// bundles were written: one description stays scannable at roughly two -// sentences (320 chars / 45 words), and a bundle's preloaded name+description -// metadata stays under 6,000 chars (~1.5k tokens) so skill discovery never -// crowds an agent host's context window. -const MAX_SKILL_MD_LINES: usize = 500; -const MAX_DESCRIPTION_CHARS: usize = 320; -const MAX_DESCRIPTION_WORDS: usize = 45; +// Metadata budget: a bundle's preloaded name+description metadata stays under +// 6,000 chars (~1.5k tokens) so skill discovery never crowds an agent host's +// context window. The per-skill size budgets (500-line body, 320-char / +// 45-word description) now live in shared_skill_contract_test.rs. const MAX_BUNDLED_SKILL_METADATA_CHARS: usize = 6_000; const CODEX_QUICK_VALIDATE_ALLOWED_FRONTMATTER: &[&str] = &[ "allowed-tools", @@ -122,8 +114,14 @@ fn generated_cursor_plugin_skills_are_byte_copies_of_the_source_bundle() { assert_skill_trees_byte_identical(source_root, &installed_root); } +/// The per-file design rules (trigger-first / length / word / 500-line / +/// no-`## When to Use` / supported-file layout) now live once in +/// `shared_skill_contract_test.rs` over the single `plugin/skills/` tree, which +/// is the same set Codex ships and a superset of Cursor's. This test keeps only +/// what is NOT in that intersection contract: the aggregate metadata budget and +/// the optional `agents/openai.yaml` marketplace contract. #[test] -fn produced_plugin_skills_follow_skill_creator_design_advice() { +fn produced_plugin_skills_meet_the_metadata_budget_and_openai_contract() { let codex_skills = load_skill_docs(CODEX_SKILL_ROOT); let cursor_staged = staged_cursor_skill_source(); let cursor_skills = load_skill_docs_from(cursor_staged.path()); @@ -134,37 +132,7 @@ fn produced_plugin_skills_follow_skill_creator_design_advice() { }); for skill in codex_skills.iter().chain(cursor_skills.iter()) { - let description = required_scalar_field(skill, "description"); - assert!( - has_trigger_language(description), - "{} description must include trigger language because agents only see metadata before loading the body", - skill.path.display() - ); - assert!( - description.len() <= MAX_DESCRIPTION_CHARS, - "{} description is too long for the shared skills metadata budget", - skill.path.display() - ); - assert!( - description.split_whitespace().count() <= MAX_DESCRIPTION_WORDS, - "{} description has too many words for the shared skills metadata budget", - skill.path.display() - ); - - let line_count = skill.raw.lines().count(); - assert!( - line_count <= MAX_SKILL_MD_LINES, - "{} has {line_count} lines; split details into direct references before exceeding {MAX_SKILL_MD_LINES}", - skill.path.display() - ); - assert!( - !skill.raw.to_ascii_lowercase().contains("\n## when to use"), - "{} must keep trigger guidance in description metadata, not a body-only When to Use section", - skill.path.display() - ); - let skill_dir = skill.path.parent().expect("skill path has parent"); - assert_skill_tree_uses_supported_files(skill_dir); assert_openai_yaml_contract_if_present(skill_dir); } } @@ -192,24 +160,19 @@ fn install_ctx(home: &Path) -> InstallContext { } } -/// Stages the composed Cursor skill *source* tree into a temp dir: the shared -/// model-invocable skills from `plugin/skills/` (all non-`tracedecay-*` slugs) -/// plus the 13 Cursor dispatcher overlays. This mirrors exactly what Cursor -/// deploys — the canonical `tracedecay-*` bodies never reach Cursor. +/// Stages the Cursor skill *source* tree into a temp dir: the 17 shared +/// model-invocable skills from `plugin/skills/` (all non-`tracedecay-*` slugs). +/// This mirrors exactly what Cursor deploys — the `tracedecay-*` workflow slugs +/// are native commands on Cursor, not skills. fn staged_cursor_skill_source() -> TempDir { let staged = TempDir::new().expect("temp cursor skill source"); let shared = repo_path("plugin/skills"); for name in skill_dir_names(&shared) { if name.starts_with("tracedecay-") { - // Cursor ships the overlay form of these, added below. continue; } copy_dir(&shared.join(&name), &staged.path().join(&name)); } - let overlay = repo_path(CURSOR_OVERLAY_SKILL_ROOT); - for name in skill_dir_names(&overlay) { - copy_dir(&overlay.join(&name), &staged.path().join(&name)); - } staged } @@ -423,51 +386,10 @@ fn assert_scalar(field: &str, value: &str, path: &Path) { ); } -/// Agents choose skills from metadata alone, so each description must carry -/// an imperative "Use ..." trigger sentence: either leading the description -/// or following a short capability summary (e.g. "Find code by concept ... -/// Use when searching the codebase"). -fn has_trigger_language(description: &str) -> bool { - description.starts_with("Use ") || description.contains(". Use ") -} - fn is_cursor_explicit_invoke_only(skill: &SkillDoc) -> bool { scalar_field(skill, "disable-model-invocation") == Some("true") } -fn assert_skill_tree_uses_supported_files(skill_dir: &Path) { - let allowed_resource_dirs = ["agents", "scripts", "references", "assets"]; - let forbidden_doc_files = [ - "README.md", - "CHANGELOG.md", - "INSTALLATION_GUIDE.md", - "QUICK_REFERENCE.md", - ]; - for relative in relative_files_under(skill_dir) { - let first = relative - .components() - .next() - .and_then(|component| component.as_os_str().to_str()) - .expect("relative component"); - let file_name = relative - .file_name() - .and_then(|name| name.to_str()) - .expect("skill file name should be utf-8"); - assert!( - !forbidden_doc_files.contains(&file_name), - "{} contains auxiliary documentation file {}; keep skill folders lean", - skill_dir.display(), - file_name - ); - assert!( - relative == Path::new("SKILL.md") || allowed_resource_dirs.contains(&first), - "{} contains unsupported top-level entry {}", - skill_dir.display(), - relative.display() - ); - } -} - fn assert_openai_yaml_contract_if_present(skill_dir: &Path) { let openai_yaml = skill_dir.join("agents/openai.yaml"); if !openai_yaml.exists() { diff --git a/tests/agent_suite/shared_skill_contract_test.rs b/tests/agent_suite/shared_skill_contract_test.rs new file mode 100644 index 000000000..9fab66c36 --- /dev/null +++ b/tests/agent_suite/shared_skill_contract_test.rs @@ -0,0 +1,437 @@ +//! Unified contract for the single shared `plugin/skills/` tree. +//! +//! Since the three host bundles collapsed into one `plugin/` tree, there is one +//! model-invocable skill set that every host (Claude, Codex, Cursor) ships +//! byte-identically. This test validates that one set against the **intersection +//! contract** — the rules a SKILL.md must satisfy to install cleanly on *all* +//! three hosts — plus each host's extra allowances. It supersedes the shared +//! frontmatter/description/heading/hygiene checks that previously lived split +//! across `plugin_skill_contract_test.rs` and `skill_lint_cursor_test.rs`. +//! +//! Covered here (the intersection contract, over `plugin/skills/`): +//! - Frontmatter keys ⊆ {name, description, allowed-tools, license, metadata}; +//! `name` matches the directory and is kebab-case; `name`/`description` +//! required and non-empty. +//! - `description`: 50–320 chars, ≤45 words, trigger-first ("Use …"), ends with +//! a period, no angle brackets, unique across the set. +//! - Body: exactly one plain-title H1 (never the slash form), no skipped +//! heading levels, no `## When to Use` section, ≤500 lines. +//! - Hygiene: no BOM, LF-only, exactly one trailing newline, no trailing +//! whitespace/tabs, balanced code fences, non-empty body. +//! - Support-file layout: only SKILL.md + scripts/references/assets/agents. +//! +//! Also validated **separately** (host-extra surfaces): +//! - Cursor native commands (`plugin/overlays/cursor/commands/*.md`): a +//! `# /` H1 matching the file name, and hygiene. +//! - Cursor agent overlay (`plugin/overlays/cursor/agents/*.md`): present and +//! hygienic. +//! - Host-extra frontmatter: Codex is spec-strict (intersection only); Cursor +//! additionally tolerates `disable-model-invocation` / `paths` (none are used +//! in the shared set today, but the allowance is asserted so a future +//! Cursor-only key does not silently pass the strict intersection). +//! +//! Install-time byte-parity (`generated_*_plugin_skills_are_byte_copies_*`) and +//! the metadata/openai.yaml budgets stay in `plugin_skill_contract_test.rs`; +//! this file owns the pure per-file contract over the single source tree. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::collections::{BTreeMap, BTreeSet}; + +use tracedecay::automation::skill_frontmatter::SkillFrontmatterValue; + +use crate::plugin_validation_support::{ + is_kebab_case_skill_name, load_skill_docs, relative_files_under, repo_path, SkillDoc, +}; + +/// The one shared model-invocable skill tree every host ships. +const SHARED_SKILL_ROOT: &str = "plugin/skills"; +/// Cursor native slash commands (the 13 `tracedecay-*` workflow slugs). +const CURSOR_COMMAND_ROOT: &str = "plugin/overlays/cursor/commands"; +/// Cursor agent overlay. +const CURSOR_AGENT_OVERLAY_ROOT: &str = "plugin/overlays/cursor/agents"; + +/// The intersection frontmatter whitelist: the keys accepted by *every* host's +/// validator (Codex `quick_validate.py` ∩ Cursor ∩ Claude Agent Skills spec). +const INTERSECTION_FRONTMATTER: &[&str] = &[ + "allowed-tools", + "description", + "license", + "metadata", + "name", +]; + +/// Cursor tolerates two extra keys on top of the intersection. None are used in +/// the shared set today (workflow dispatch is native commands), but the +/// allowance is documented so the strict intersection check below can point at +/// it if a Cursor-only key ever appears. +const CURSOR_EXTRA_FRONTMATTER: &[&str] = &["disable-model-invocation", "paths"]; + +const MIN_DESCRIPTION_CHARS: usize = 50; +const MAX_DESCRIPTION_CHARS: usize = 320; +const MAX_DESCRIPTION_WORDS: usize = 45; +const MAX_SKILL_MD_LINES: usize = 500; + +/// skillmark E037: reserved vendor prefixes a skill name must not claim. +const RESERVED_NAME_PREFIXES: &[&str] = &["claude", "anthropic"]; + +/// skillmark W006: placeholder fragments that mark unfinished authoring. +const PLACEHOLDER_FRAGMENTS: &[&str] = &["{{", "}}", "tktk", "lorem ipsum", "(skill: &'a SkillDoc, field: &str) -> Option<&'a str> { + skill + .frontmatter + .get(field) + .and_then(SkillFrontmatterValue::as_scalar) +} + +/// ATX headings outside code fences, as (level, text-after-hashes). +fn unfenced_headings(body: &str) -> Vec<(usize, String)> { + let mut in_fence = false; + let mut headings = Vec::new(); + for line in body.lines() { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + continue; + } + if in_fence || !line.starts_with('#') { + continue; + } + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + if level <= 6 { + if let Some(text) = line[level..].strip_prefix(' ') { + headings.push((level, text.trim().to_string())); + } + } + } + headings +} + +#[test] +fn shared_skills_pass_the_intersection_frontmatter_contract() { + let skills = load_skill_docs(SHARED_SKILL_ROOT); + assert!(!skills.is_empty(), "expected shared skills"); + let mut violations = Vec::new(); + + for skill in &skills { + let at = skill.path.display(); + // Frontmatter keys ⊆ the intersection whitelist. A Cursor-only key + // (disable-model-invocation / paths) would break Codex/Claude, so the + // shared set must not carry it. + for key in skill.frontmatter.keys() { + if !INTERSECTION_FRONTMATTER.contains(&key.as_str()) { + let hint = if CURSOR_EXTRA_FRONTMATTER.contains(&key.as_str()) { + " (Cursor-only key; move this surface to plugin/overlays/cursor/commands/)" + } else { + "" + }; + violations.push(format!( + "{at}: frontmatter key {key:?} is outside the intersection whitelist \ + {INTERSECTION_FRONTMATTER:?}{hint}" + )); + } + } + + // name required, matches dir, kebab-case, ≤64 chars. + match scalar(skill, "name") { + None => violations.push(format!("{at}: missing name")), + Some(name) => { + if name != skill.name { + violations.push(format!( + "{at}: name {name:?} must match folder {:?}", + skill.name + )); + } + if !is_kebab_case_skill_name(name) { + violations.push(format!("{at}: name {name:?} must be kebab-case")); + } + if name.len() > 64 { + violations.push(format!("{at}: name exceeds 64 chars")); + } + for prefix in RESERVED_NAME_PREFIXES { + if name.starts_with(prefix) { + violations.push(format!("{at}: name uses reserved prefix {prefix:?}")); + } + } + } + } + if scalar(skill, "description").is_none() { + violations.push(format!("{at}: missing description")); + } + } + assert_no_violations("frontmatter", &violations); +} + +#[test] +fn shared_skill_descriptions_meet_the_intersection_budget() { + let skills = load_skill_docs(SHARED_SKILL_ROOT); + let mut violations = Vec::new(); + let mut seen: BTreeMap = BTreeMap::new(); + + for skill in &skills { + let at = skill.path.display(); + let Some(description) = scalar(skill, "description") else { + continue; // missing-description already flagged + }; + let chars = description.chars().count(); + if chars < MIN_DESCRIPTION_CHARS { + violations.push(format!( + "{at}: description under {MIN_DESCRIPTION_CHARS} chars" + )); + } + if chars > MAX_DESCRIPTION_CHARS { + violations.push(format!( + "{at}: description over {MAX_DESCRIPTION_CHARS} chars" + )); + } + if description.split_whitespace().count() > MAX_DESCRIPTION_WORDS { + violations.push(format!( + "{at}: description over {MAX_DESCRIPTION_WORDS} words" + )); + } + // Trigger-first: agents route on metadata alone, so a "Use …" trigger + // must lead or follow a short capability summary. + if !(description.starts_with("Use ") || description.contains(". Use ")) { + violations.push(format!( + "{at}: description must be trigger-first (\"Use …\")" + )); + } + if !description.ends_with('.') { + violations.push(format!("{at}: description must end with a period")); + } + if description.contains(['<', '>']) { + violations.push(format!("{at}: description contains angle brackets")); + } + if let Some(other) = seen.insert(description.to_string(), skill.name.clone()) { + violations.push(format!("{at}: description duplicates skill {other:?}")); + } + } + assert_no_violations("description budget", &violations); +} + +#[test] +fn shared_skill_bodies_follow_the_intersection_body_rules() { + let skills = load_skill_docs(SHARED_SKILL_ROOT); + let mut violations = Vec::new(); + + for skill in &skills { + let at = skill.path.display(); + let headings = unfenced_headings(&skill.body); + + // Exactly one H1, plain-title form (never `# /slug`). + let h1s: Vec<&String> = headings + .iter() + .filter(|(level, _)| *level == 1) + .map(|(_, text)| text) + .collect(); + if h1s.len() != 1 { + violations.push(format!( + "{at}: expected exactly one H1, found {}", + h1s.len() + )); + } + if let Some(title) = h1s.first() { + if title.starts_with('/') { + violations.push(format!( + "{at}: model-invocable skill must use a plain-title H1, not {title:?}" + )); + } + } + + // The first content line after the frontmatter must be that H1: a + // single plain-title H1 opens the body (restores the retired + // `cursor_skill_bodies_follow_heading_conventions` check). + match skill.body.lines().find(|line| !line.trim().is_empty()) { + Some(first) if first.starts_with("# ") => {} + Some(first) => violations.push(format!( + "{at}: body must open with a plain-title H1 (`# …`), found {first:?}" + )), + None => {} // empty-body already flagged by the hygiene test + } + + // No skipped heading levels. + let mut prev = 0usize; + for (level, text) in &headings { + if prev > 0 && *level > prev + 1 { + violations.push(format!("{at}: heading {text:?} skips h{prev}→h{level}")); + } + prev = *level; + } + + // Trigger lives in the description, never a body `## When to Use`. + if skill.raw.to_ascii_lowercase().contains("\n## when to use") { + violations.push(format!( + "{at}: body must not carry a `## When to Use` section" + )); + } + + // ≤500 lines. + let lines = skill.raw.lines().count(); + if lines > MAX_SKILL_MD_LINES { + violations.push(format!("{at}: {lines} lines exceeds {MAX_SKILL_MD_LINES}")); + } + } + assert_no_violations("body rules", &violations); +} + +#[test] +fn shared_skill_files_are_hygienic_and_use_supported_layout() { + let skills = load_skill_docs(SHARED_SKILL_ROOT); + let allowed_resource_dirs = ["agents", "scripts", "references", "assets"]; + let mut violations = Vec::new(); + + for skill in &skills { + let at = skill.path.display(); + let bytes = std::fs::read(&skill.path).expect("re-read skill bytes"); + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + violations.push(format!("{at}: starts with a UTF-8 BOM")); + } + if skill.raw.contains('\r') { + violations.push(format!("{at}: contains CRLF line endings")); + } + if !skill.raw.ends_with('\n') { + violations.push(format!("{at}: missing trailing newline")); + } + if skill.raw.ends_with("\n\n") { + violations.push(format!("{at}: ends with blank lines")); + } + if skill.body.trim().is_empty() { + violations.push(format!("{at}: instruction body is empty")); + } + for (idx, line) in skill.raw.lines().enumerate() { + if line.ends_with(' ') || line.ends_with('\t') || line.contains('\t') { + violations.push(format!("{at}:{}: trailing whitespace or tab", idx + 1)); + } + } + let fences = skill + .raw + .lines() + .filter(|line| line.trim_start().starts_with("```")) + .count(); + if fences % 2 != 0 { + violations.push(format!("{at}: unbalanced ``` code fences")); + } + for fragment in PLACEHOLDER_FRAGMENTS { + if skill.raw.to_ascii_lowercase().contains(fragment) { + violations.push(format!("{at}: contains placeholder text {fragment:?}")); + } + } + + // Support-file layout: only SKILL.md + the allowed resource dirs, and + // no auxiliary documentation files (keep skill folders lean). + let forbidden_doc_files = [ + "README.md", + "CHANGELOG.md", + "INSTALLATION_GUIDE.md", + "QUICK_REFERENCE.md", + ]; + let skill_dir = skill.path.parent().expect("skill path has parent"); + for relative in relative_files_under(skill_dir) { + let first = relative + .components() + .next() + .and_then(|c| c.as_os_str().to_str()) + .expect("relative component"); + let file_name = relative + .file_name() + .and_then(|name| name.to_str()) + .expect("skill file name should be utf-8"); + if forbidden_doc_files.contains(&file_name) { + violations.push(format!( + "{}: auxiliary documentation file {file_name} not allowed", + skill_dir.display() + )); + } + if relative != std::path::Path::new("SKILL.md") + && !allowed_resource_dirs.contains(&first) + { + violations.push(format!( + "{}: unsupported top-level entry {}", + skill_dir.display(), + relative.display() + )); + } + } + } + assert_no_violations("hygiene + layout", &violations); +} + +/// Cursor native commands are a separate surface from the shared skills: they +/// carry the slash-form H1 the model-invocable skills must NOT use. +#[test] +fn cursor_native_commands_are_hygienic_slash_commands() { + let command_dir = repo_path(CURSOR_COMMAND_ROOT); + let mut entries: Vec<_> = std::fs::read_dir(&command_dir) + .expect("cursor commands dir readable") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(); + entries.sort(); + assert_eq!(entries.len(), 13, "expected 13 cursor native commands"); + + let mut violations = Vec::new(); + for path in entries { + let at = path.display(); + let slug = path.file_stem().and_then(|s| s.to_str()).expect("stem"); + let raw = std::fs::read_to_string(&path).expect("read command"); + if raw.contains('\r') { + violations.push(format!("{at}: CRLF line endings")); + } + if !raw.ends_with('\n') || raw.ends_with("\n\n") { + violations.push(format!("{at}: trailing-newline hygiene")); + } + let h1 = raw + .lines() + .find(|line| line.starts_with("# ")) + .map(|line| line.trim_start_matches("# ").trim()); + match h1 { + Some(title) if title == format!("/{slug}") => {} + Some(title) => violations.push(format!( + "{at}: H1 {title:?} must be the slash form `/{slug}`" + )), + None => violations.push(format!("{at}: command must open with an H1 title")), + } + } + assert_no_violations("cursor commands", &violations); +} + +/// The Cursor agent overlay is a small separate surface; assert it ships and is +/// LF-clean so a byte-copy install of it stays stable. +#[test] +fn cursor_agent_overlay_is_present_and_clean() { + let overlay = repo_path(CURSOR_AGENT_OVERLAY_ROOT); + let files: BTreeSet = std::fs::read_dir(&overlay) + .expect("cursor agent overlay readable") + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + for expected in [ + "code-explorer.md", + "code-health-auditor.md", + "session-historian.md", + ] { + assert!( + files.contains(expected), + "cursor agent overlay missing {expected}" + ); + } + let mut violations = Vec::new(); + for file in &files { + let raw = std::fs::read_to_string(overlay.join(file)).expect("read overlay agent"); + if raw.contains('\r') || !raw.ends_with('\n') { + violations.push(format!("{}: line-ending hygiene", file)); + } + } + assert_no_violations("cursor agent overlay", &violations); +} diff --git a/tests/agent_suite/skill_lint_claude_test.rs b/tests/agent_suite/skill_lint_claude_test.rs index 553faddc6..4b0f9a2fc 100644 --- a/tests/agent_suite/skill_lint_claude_test.rs +++ b/tests/agent_suite/skill_lint_claude_test.rs @@ -1,9 +1,11 @@ -//! Claude Code / Agent Skills portability lint for the shared skill -//! collections (`plugin/skills/` and the Cursor dispatcher overlay at -//! `plugin/overlays/cursor/skills/`). +//! Claude Code / Agent Skills portability lint for the shared skill collection +//! (`plugin/skills/`). //! //! These tests keep the shared skills close to Claude Code's documented skill -//! rules so a Claude bundle can reuse them without a rewrite. +//! rules so a Claude bundle can reuse them without a rewrite. The Cursor +//! workflow slugs are native `commands/` on Cursor now (not +//! `disable-model-invocation` skills), so the whole shared skill set is +//! strictly Agent-Skills-spec conformant. //! //! Rule sources (fetched 2026-07-02): //! - Claude Code skills reference (frontmatter field table, 1,536-char @@ -21,28 +23,16 @@ //! `skills//SKILL.md`, with `license` and `version` frontmatter in //! shipping skills. //! -//! Cross-ecosystem conflicts (documented skips, not failures): Cursor requires -//! `disable-model-invocation: true` on command-style skills. Claude Code -//! supports that field natively, but the strict Agent Skills open spec (and -//! Anthropic's `quick_validate.py` packaging validator) rejects it. See -//! [`CROSS_ECOSYSTEM_CONFLICT_FIELDS`] and the compatibility matrix in -//! `docs/PLUGIN-VALIDATION.md` (layer 5). - #![allow(clippy::unwrap_used, clippy::expect_used)] use tracedecay::automation::skill_frontmatter::SkillFrontmatterValue; use crate::plugin_validation_support::{is_kebab_case_skill_name, load_skill_docs, SkillDoc}; -/// The full shared skill surface: the 30 canonical model-invocable skills -/// (`plugin/skills`, the set Codex/Claude ship) plus the 13 Cursor dispatcher -/// overlays (`disable-model-invocation: true`, the form Cursor ships). -const SKILL_ROOTS: &[&str] = &["plugin/skills", CURSOR_OVERLAY_SKILL_ROOT]; -/// The Codex/Claude canonical skill set — must be strictly Agent-Skills-spec -/// conformant (no `disable-model-invocation`, no Cursor-only keys). -const CANONICAL_SKILL_ROOT: &str = "plugin/skills"; -/// The Cursor dispatcher overlay carrying the cross-ecosystem conflict fields. -const CURSOR_OVERLAY_SKILL_ROOT: &str = "plugin/overlays/cursor/skills"; +/// The full shared skill surface: the 29 canonical model-invocable skills +/// (`plugin/skills`) that every host ships. Cursor's workflow slugs are native +/// commands, not skills, so there is no dispatcher overlay left to lint here. +const SKILL_ROOTS: &[&str] = &["plugin/skills"]; /// Frontmatter fields Claude Code recognizes, per the field table at /// code.claude.com/docs/en/skills, plus the Agent Skills open-spec fields @@ -83,17 +73,6 @@ const AGENT_SKILLS_SPEC_ALLOWED_FRONTMATTER: &[&str] = &[ "name", ]; -/// Fields our bundles use that the strict open spec rejects, kept anyway -/// because a host ecosystem requires them. Each entry is a documented skip: -/// the spec-conformance test tolerates exactly these fields and nothing else. -/// -/// - `disable-model-invocation`: Cursor command-style skills (the -/// `/tracedecay-*` commands) must set this to stay -/// manual-only. Claude Code documents and supports the same field, so a -/// future claude-plugin bundle can carry it unchanged; only spec-strict -/// packagers (`quick_validate.py`, the Claude API skill upload) reject it. -const CROSS_ECOSYSTEM_CONFLICT_FIELDS: &[&str] = &["disable-model-invocation"]; - /// platform.claude.com: skill names uploaded to the Claude API cannot contain /// the reserved words "anthropic" or "claude". const CLAUDE_RESERVED_NAME_WORDS: &[&str] = &["anthropic", "claude"]; @@ -203,66 +182,30 @@ fn bundled_skill_descriptions_satisfy_claude_description_rules() { } } -/// Strict Agent Skills spec conformance with documented skips. +/// Strict Agent Skills spec conformance. /// -/// Every field outside the open-spec whitelist must be one of the known -/// cross-ecosystem conflicts in [`CROSS_ECOSYSTEM_CONFLICT_FIELDS`]; anything -/// else is a new portability regression and fails. The Codex bundle must be -/// fully spec-clean because Codex's own validator is the spec whitelist. +/// The whole shared skill set must stay strictly Agent-Skills-spec conformant: +/// every frontmatter key must be in the open-spec whitelist. Codex validates +/// with the spec whitelist, so any extra key is a portability regression. #[test] -fn open_spec_conflicts_are_limited_to_documented_cursor_requirements() { +fn shared_skills_stay_strictly_agent_skills_spec_conformant() { for root in SKILL_ROOTS { - let is_codex_bundle = *root == CANONICAL_SKILL_ROOT; for skill in load_skill_docs(root) { let extras = skill .frontmatter .keys() .filter(|key| !AGENT_SKILLS_SPEC_ALLOWED_FRONTMATTER.contains(&key.as_str())) .collect::>(); - - if is_codex_bundle { - assert!( - extras.is_empty(), - "{} must stay strictly Agent-Skills-spec conformant (Codex \ - validates with the spec whitelist) but uses {extras:?}", - skill.path.display() - ); - continue; - } - - let undocumented = extras - .iter() - .filter(|key| !CROSS_ECOSYSTEM_CONFLICT_FIELDS.contains(&key.as_str())) - .collect::>(); assert!( - undocumented.is_empty(), - "{} uses spec-nonconformant frontmatter {undocumented:?} that is \ - not a documented cross-ecosystem conflict; either drop the field \ - or document it in CROSS_ECOSYSTEM_CONFLICT_FIELDS and \ - docs/PLUGIN-VALIDATION.md", + extras.is_empty(), + "{} must stay strictly Agent-Skills-spec conformant (Codex \ + validates with the spec whitelist) but uses {extras:?}", skill.path.display() ); } } } -/// The documented skips must stay real: if the Cursor bundle stops using a -/// conflict field, the allowlist entry (and the notes matrix) is stale. -#[test] -fn documented_conflict_fields_are_actually_used_by_the_cursor_bundle() { - let cursor_skills = load_skill_docs(CURSOR_OVERLAY_SKILL_ROOT); - for field in CROSS_ECOSYSTEM_CONFLICT_FIELDS { - assert!( - cursor_skills - .iter() - .any(|skill| skill.frontmatter.contains_key(*field)), - "documented conflict field {field:?} is no longer used by any Cursor \ - skill; remove it from CROSS_ECOSYSTEM_CONFLICT_FIELDS and update \ - docs/PLUGIN-VALIDATION.md" - ); - } -} - /// Claude Code preloads model-invocable skill metadata into its skill listing. /// Keep the aggregate near the Cursor/Codex contract budget so the listing /// stays small. diff --git a/tests/agent_suite/skill_lint_cursor_test.rs b/tests/agent_suite/skill_lint_cursor_test.rs index dc939b420..059cea1a3 100644 --- a/tests/agent_suite/skill_lint_cursor_test.rs +++ b/tests/agent_suite/skill_lint_cursor_test.rs @@ -1,7 +1,8 @@ -//! Cursor-specific lint for the composed Cursor skill set (shared -//! `plugin/skills/` + `plugin/overlays/cursor/skills/`) SKILL.md -//! files, ported from community/official skill linters so enforcement runs -//! offline inside `cargo test` (no node/python CI dependency). +//! Cursor-specific lint for the composed Cursor skill set (the 16 shared +//! model-invocable skills from `plugin/skills/`) plus the Cursor native slash +//! commands (`plugin/overlays/cursor/commands/`), ported from +//! community/official skill linters so enforcement runs offline inside +//! `cargo test` (no node/python CI dependency). //! //! Rule sources: //! - skillmark (): broken file @@ -12,37 +13,43 @@ //! body, trailing whitespace. //! - skillkit (): skipped heading //! levels, consistent structure. -//! - Cursor docs (): `disable-model-invocation` -//! slash-command semantics and `paths` glob scoping. +//! - Cursor docs (): `paths` glob scoping; +//! native slash commands () whose `/slug` +//! title matches the command file name. //! //! Repo-specific reference-integrity rules (same spirit as skillmark E031, //! applied to this bundle's conventions): `tracedecay:` cross-skill //! references, backticked `/skill` invocations, and `tracedecay_*` MCP tool //! mentions must all resolve against the bundle / the live MCP tool list. //! -//! `tests/agent_suite/plugin_skill_contract_test.rs` already enforces the -//! frontmatter key whitelist, name/folder match and charset, description -//! budgets and trigger language, the 500-line body cap, resource-dir layout, -//! and install byte-parity. Those rules are intentionally NOT duplicated here. +//! The generic per-file intersection contract — frontmatter whitelist, +//! name/folder match, description budgets/trigger/uniqueness, one plain-title +//! H1 / heading levels / no `## When to Use`, the 500-line cap, LF hygiene, +//! placeholder + reserved-prefix checks, and resource-dir layout — now lives +//! once in `tests/agent_suite/shared_skill_contract_test.rs` over the single +//! `plugin/skills/` tree. `plugin_skill_contract_test.rs` owns install +//! byte-parity + host-extra frontmatter + metadata budgets. This file keeps +//! only the Cursor-specific reference-integrity checks (skill/tool/link +//! resolution and `paths` glob scoping) plus the native-command lint. #![allow(clippy::unwrap_used, clippy::expect_used)] -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use regex::Regex; use tracedecay::automation::skill_frontmatter::SkillFrontmatterValue; use tracedecay::mcp::get_tool_definitions; -use crate::plugin_validation_support::{load_skill_docs_from, repo_path, SkillDoc}; +use crate::plugin_validation_support::{load_skill_docs_from, repo_path}; use tempfile::TempDir; -/// Cursor's dispatcher overlay (the 13 `tracedecay-*` slash dispatchers). -const CURSOR_OVERLAY_SKILL_ROOT: &str = "plugin/overlays/cursor/skills"; +/// Cursor's native slash commands (the 13 `tracedecay-*` workflow commands). +const CURSOR_COMMAND_ROOT: &str = "plugin/overlays/cursor/commands"; -/// Stages the composed Cursor skill *source* set into a temp dir: the shared -/// model-invocable skills from `plugin/skills/` (all non-`tracedecay-*` slugs) -/// plus the 13 Cursor dispatcher overlays. This is exactly what Cursor deploys -/// — the canonical `tracedecay-*` bodies never reach Cursor. +/// Stages the Cursor skill *source* set into a temp dir: the 16 shared +/// model-invocable skills from `plugin/skills/` (all non-`tracedecay-*` slugs). +/// This is exactly the skill set Cursor deploys — the `tracedecay-*` workflow +/// slugs are native commands there (see [`command_slugs`]), not skills. fn staged_cursor_skills() -> TempDir { let staged = TempDir::new().expect("temp cursor skill source"); let shared = repo_path("plugin/skills"); @@ -54,17 +61,27 @@ fn staged_cursor_skills() -> TempDir { } copy_dir(&entry.path(), &staged.path().join(&name)); } - let overlay = repo_path(CURSOR_OVERLAY_SKILL_ROOT); - for entry in std::fs::read_dir(&overlay).unwrap() { - let entry = entry.unwrap(); - if !entry.file_type().unwrap().is_dir() { - continue; - } - copy_dir(&entry.path(), &staged.path().join(entry.file_name())); - } staged } +/// The `/slug` names Cursor exposes as native commands (the file stems under +/// `plugin/overlays/cursor/commands/`). Backticked `/slug` references in skill +/// or command bodies resolve against this set. +fn command_slugs() -> BTreeSet { + std::fs::read_dir(repo_path(CURSOR_COMMAND_ROOT)) + .expect("cursor commands dir readable") + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|t| t.is_file())) + .filter_map(|entry| { + entry + .path() + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::to_string) + }) + .collect() +} + fn copy_dir(src: &std::path::Path, dst: &std::path::Path) { std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap() { @@ -78,192 +95,16 @@ fn copy_dir(src: &std::path::Path, dst: &std::path::Path) { } } -/// skillmark W003 flags descriptions under 50 chars as too short to convey -/// what the skill does and when to trigger it. -const MIN_DESCRIPTION_CHARS: usize = 50; - -/// skillmark E037: reserved vendor prefixes a skill name must not claim. -const RESERVED_NAME_PREFIXES: &[&str] = &["claude", "anthropic"]; - -/// skillmark W006: placeholder fragments that mark unfinished authoring. -/// Plain TODO/FIXME words are deliberately not listed: several bundled skills -/// legitimately discuss TODO/FIXME markers (`tracedecay_todos`). -const PLACEHOLDER_FRAGMENTS: &[&str] = &["{{", "}}", "tktk", "lorem ipsum", " {} - Some(first) => violations.push(format!( - "{at}: body must open with an H1 title, found {first:?}" - )), - None => continue, // empty body reported by the hygiene test - } - - let headings = unfenced_headings(&skill.body); - let h1_count = headings.iter().filter(|(level, _)| *level == 1).count(); - if h1_count != 1 { - violations.push(format!("{at}: expected exactly one H1, found {h1_count}")); - } - - // skillkit best-practices: no skipped heading levels (h2 -> h4). - let mut prev_level = 0usize; - for (level, text) in &headings { - if prev_level > 0 && *level > prev_level + 1 { - violations.push(format!( - "{at}: heading {text:?} skips from h{prev_level} to h{level}" - )); - } - prev_level = *level; - } - - // Cursor docs: `disable-model-invocation: true` makes a skill a slash - // command. The bundle titles those skills `# /`; a slash-form H1 - // on a model-invocable skill (or one naming a different slug) would - // document an invocation that does not exist. - if let Some((_, title)) = headings.iter().find(|(level, _)| *level == 1) { - if let Some(slug) = title.strip_prefix('/') { - if slug != skill.name { - violations.push(format!( - "{at}: H1 slash title `/{slug}` does not match skill name {:?}", - skill.name - )); - } - if scalar(skill, "disable-model-invocation") != Some("true") { - violations.push(format!( - "{at}: slash-form H1 requires disable-model-invocation: true" - )); - } - } - } - } - - assert_no_violations("heading conventions", &violations); -} - -#[test] -fn cursor_skill_names_and_descriptions_meet_lint_quality_bar() { - let staged = staged_cursor_skills(); - let skills = load_skill_docs_from(staged.path()); - let mut violations = Vec::new(); - let mut descriptions_seen: BTreeMap = BTreeMap::new(); - - for skill in &skills { - let at = skill.path.display(); - // skillmark E037. - for prefix in RESERVED_NAME_PREFIXES { - if skill.name.starts_with(prefix) { - violations.push(format!("{at}: name uses reserved prefix {prefix:?}")); - } - } - - let Some(description) = scalar(skill, "description") else { - continue; // required-field enforcement lives in the contract test - }; - // skillmark W003. - if description.chars().count() < MIN_DESCRIPTION_CHARS { - violations.push(format!( - "{at}: description is shorter than {MIN_DESCRIPTION_CHARS} chars" - )); - } - // skillmark E036: the contract test only checks Codex descriptions - // for angle brackets; Cursor metadata is injected into prompts too. - if description.contains(['<', '>']) { - violations.push(format!("{at}: description contains angle brackets")); - } - if !description.ends_with(['.', '!', '?']) { - violations.push(format!( - "{at}: description must end with terminal punctuation" - )); - } - // Duplicate descriptions make model routing between skills ambiguous - // (the agent picks skills from metadata alone). - if let Some(other) = descriptions_seen.insert(description.to_string(), skill.name.clone()) { - violations.push(format!( - "{at}: description duplicates skill {other:?} exactly" - )); - } - } - - assert_no_violations("name/description quality", &violations); -} - #[test] fn cursor_skill_references_resolve() { let staged = staged_cursor_skills(); let skills = load_skill_docs_from(staged.path()); let skill_names: BTreeSet<&str> = skills.iter().map(|skill| skill.name.as_str()).collect(); + let command_names = command_slugs(); let tool_names = mcp_tool_names(); let mut violations = Vec::new(); @@ -321,13 +162,13 @@ fn cursor_skill_references_resolve() { } } - // Cursor docs: `/name` invokes a skill by name; a backticked slash - // reference must resolve to a bundled skill. + // Cursor docs: `/name` invokes a native command; a backticked slash + // reference must resolve to a bundled command. for capture in slash_ref_re.captures_iter(&skill.raw) { - let slug = &capture[1]; - if !skill_names.contains(slug) { + let slug = capture[1].to_string(); + if !command_names.contains(&slug) { violations.push(format!( - "{at}: references slash command /{slug} which is not a bundled skill" + "{at}: references slash command /{slug} which is not a bundled command" )); } } @@ -371,40 +212,94 @@ fn cursor_skill_references_resolve() { assert_no_violations("reference integrity", &violations); } -fn first_content_line(body: &str) -> Option<&str> { - body.lines() - .map(str::trim_end) - .find(|line| !line.is_empty()) -} +/// The Cursor native slash commands (`plugin/overlays/cursor/commands/*.md`) +/// must be LF-clean, open with a `# /` H1 that matches the file name, and +/// only reference bundled skills and live MCP tools. This is the command-side +/// analogue of the retired dispatcher-skill slash lint. +#[test] +fn cursor_commands_are_hygienic_and_reference_resolve() { + let command_dir = repo_path(CURSOR_COMMAND_ROOT); + let staged = staged_cursor_skills(); + let skill_names: BTreeSet = load_skill_docs_from(staged.path()) + .into_iter() + .map(|skill| skill.name) + .collect(); + let tool_names = mcp_tool_names(); + let skill_ref_re = Regex::new(r"tracedecay:([a-z0-9][a-z0-9-]*)").unwrap(); + let tool_ref_re = Regex::new(r"tracedecay_[a-z_]+").unwrap(); + let mut violations = Vec::new(); + let mut command_count = 0usize; + + let mut entries: Vec<_> = std::fs::read_dir(&command_dir) + .expect("cursor commands dir readable") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) + .collect(); + entries.sort(); + + for path in entries { + command_count += 1; + let at = path.display(); + let slug = path + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("command file stem") + .to_string(); + let raw = std::fs::read_to_string(&path).expect("read command"); + + if raw.contains('\r') { + violations.push(format!("{at}: contains CRLF line endings")); + } + if !raw.ends_with('\n') { + violations.push(format!("{at}: missing trailing newline")); + } + if raw.ends_with("\n\n") { + violations.push(format!("{at}: ends with blank lines")); + } + for (idx, line) in raw.lines().enumerate() { + if line.ends_with(' ') || line.ends_with('\t') || line.contains('\t') { + violations.push(format!("{at}:{}: trailing whitespace or tab", idx + 1)); + } + } -/// ATX headings outside code fences, as (level, text-after-hashes). -fn unfenced_headings(body: &str) -> Vec<(usize, String)> { - let mut in_fence = false; - let mut headings = Vec::new(); - for line in body.lines() { - if line.trim_start().starts_with("```") { - in_fence = !in_fence; - continue; + // The command body opens with a `# /` H1 matching the file name, + // so the documented invocation is the one Cursor exposes. + let h1 = raw + .lines() + .find(|line| line.starts_with("# ")) + .map(|line| line.trim_start_matches("# ").trim()); + match h1 { + Some(title) if title == format!("/{slug}") => {} + Some(title) => violations.push(format!( + "{at}: H1 {title:?} must be the slash form `/{slug}`" + )), + None => violations.push(format!("{at}: command body must open with an H1 title")), } - if in_fence || !line.starts_with('#') { - continue; + + for capture in skill_ref_re.captures_iter(&raw) { + let referenced = capture[1].to_string(); + if !skill_names.contains(&referenced) { + violations.push(format!( + "{at}: references skill tracedecay:{referenced} which is not bundled" + )); + } } - let level = line.bytes().take_while(|byte| *byte == b'#').count(); - let rest = &line[level..]; - if level <= 6 { - if let Some(text) = rest.strip_prefix(' ') { - headings.push((level, text.trim().to_string())); + for found in tool_ref_re.find_iter(&raw) { + let identifier = found.as_str().trim_end_matches('_'); + if !tool_names.contains(identifier) && !NON_TOOL_IDENTIFIERS.contains(&identifier) { + violations.push(format!( + "{at}: mentions MCP tool {identifier} which the server does not define" + )); } } } - headings -} -fn scalar<'a>(skill: &'a SkillDoc, field: &str) -> Option<&'a str> { - skill - .frontmatter - .get(field) - .and_then(SkillFrontmatterValue::as_scalar) + assert_eq!( + command_count, 13, + "expected 13 Cursor native slash commands, found {command_count}" + ); + assert_no_violations("cursor command integrity", &violations); } fn mcp_tool_names() -> BTreeSet { diff --git a/tests/agent_suite/tool_skill_coverage_test.rs b/tests/agent_suite/tool_skill_coverage_test.rs index b09128c96..5b9305eb6 100644 --- a/tests/agent_suite/tool_skill_coverage_test.rs +++ b/tests/agent_suite/tool_skill_coverage_test.rs @@ -106,6 +106,17 @@ fn mentions_tool(haystack: &str, tool_name: &str) -> bool { false } +/// Collects every flat `*.md` command body under a commands directory. +fn command_bodies(commands_root: &Path) -> Vec { + std::fs::read_dir(commands_root) + .unwrap_or_else(|e| panic!("read {}: {e}", commands_root.display())) + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md")) + .map(|path| std::fs::read_to_string(&path).expect("read command body")) + .collect() +} + /// Collects every `SKILL.md` body under the given skills-root directories. fn skill_bodies(skills_roots: &[PathBuf]) -> Vec { let mut bodies = Vec::new(); @@ -129,21 +140,34 @@ fn skill_bodies(skills_roots: &[PathBuf]) -> Vec { #[test] fn every_mcp_tool_is_taught_by_at_least_one_bundled_skill() { let plugin = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin"); - // Codex/Claude deploy the 30 canonical skills under plugin/skills. Cursor - // deploys the 17 shared skills plus the 13 dispatcher overlays. Both host - // views must teach every MCP tool. `plugin/skills` alone (canonical, 30) - // is a superset of the shared 17, so checking `plugin/skills` covers the - // Codex/Claude view; adding the cursor overlay covers the Cursor view's - // dispatcher bodies (which differ from the canonical dispatcher form). - let host_views: &[(&str, Vec)] = &[ - ("codex/claude (canonical)", vec![plugin.join("skills")]), - ( - "cursor (shared + overlay)", - vec![plugin.join("skills"), plugin.join("overlays/cursor/skills")], - ), + // Codex/Claude deploy the 29 canonical skills under plugin/skills. Cursor + // deploys the 16 shared model-invocable skills plus the 13 workflow slugs + // as native commands (`overlays/cursor/commands`). Both host views must + // teach every MCP tool. `plugin/skills` alone (canonical, 29) is a superset + // of the shared 17 plus the canonical dispatcher bodies, so it covers the + // Codex/Claude view; the Cursor view is the 17 shared skills plus the 13 + // command bodies (which carry the workflow tool mentions Cursor ships). + let codex_claude_bodies = skill_bodies(&[plugin.join("skills")]); + let mut cursor_bodies: Vec = std::fs::read_dir(plugin.join("skills")) + .expect("read plugin/skills") + .flatten() + .filter(|entry| { + !entry + .file_name() + .to_string_lossy() + .starts_with("tracedecay-") + }) + .map(|entry| entry.path().join("SKILL.md")) + .filter(|path| path.is_file()) + .map(|path| std::fs::read_to_string(&path).expect("read shared skill")) + .collect(); + cursor_bodies.extend(command_bodies(&plugin.join("overlays/cursor/commands"))); + + let host_views: &[(&str, Vec)] = &[ + ("codex/claude (canonical)", codex_claude_bodies), + ("cursor (shared skills + commands)", cursor_bodies), ]; - for (view, roots) in host_views { - let bodies = skill_bodies(roots); + for (view, bodies) in host_views { let mut uncovered: Vec = Vec::new(); for def in get_tool_definitions() { if SKILL_COVERAGE_EXCEPTIONS.contains(&def.name.as_str()) { diff --git a/tests/agent_suite/update_plugin_test.rs b/tests/agent_suite/update_plugin_test.rs index 57ea6d465..023435dc9 100644 --- a/tests/agent_suite/update_plugin_test.rs +++ b/tests/agent_suite/update_plugin_test.rs @@ -739,18 +739,24 @@ fn staged_host_source(host: &str) -> TempDir { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("plugin"); let staged = TempDir::new().expect("temp host source"); let mut copies: Vec<(String, String)> = Vec::new(); - // Shared canonical skills (30) — same deploy path for all hosts. + // Shared canonical skills — same deploy path for all hosts. Codex ships all + // 30; Cursor ships only the 17 model-invocable ones (the `tracedecay-*` + // workflow slugs are native commands on Cursor, not skills). for name in subdir_names(&src.join("skills")) { + if host == "cursor" && name.starts_with("tracedecay-") { + continue; + } let rel = format!("skills/{name}/SKILL.md"); copies.push((rel.clone(), rel)); } match host { "cursor" => { - // Cursor overrides the 13 dispatcher slugs with its overlay form. - for name in subdir_names(&src.join("overlays/cursor/skills")) { + // Cursor ships the 13 workflow slugs as native slash commands. + for entry in std::fs::read_dir(src.join("overlays/cursor/commands")).unwrap() { + let file = entry.unwrap().file_name().to_string_lossy().into_owned(); copies.push(( - format!("overlays/cursor/skills/{name}/SKILL.md"), - format!("skills/{name}/SKILL.md"), + format!("overlays/cursor/commands/{file}"), + format!("commands/{file}"), )); } for name in ["code-explorer", "code-health-auditor", "session-historian"] { diff --git a/tests/fixtures/analytics/cursor_skill_read_text.json b/tests/fixtures/analytics/cursor_skill_read_text.json index 1e62f3f0b..cdcda6c3a 100644 --- a/tests/fixtures/analytics/cursor_skill_read_text.json +++ b/tests/fixtures/analytics/cursor_skill_read_text.json @@ -3,7 +3,7 @@ "type": "tool_use", "name": "ReadFile", "input": { - "path": "/home/zack/.cursor/plugins/local/tracedecay/skills/curating-project-memory/SKILL.md" + "path": "/home/zack/.cursor/plugins/local/tracedecay/skills/project-memory/SKILL.md" } }, {