Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ _Ways architecture, matching, macros, hooks, session lifecycle_
| [ADR-126](./system/ADR-126-window-relative-refire.md) | Window-relative refire with named presets | Draft |
| [ADR-127](./system/ADR-127-reject-full-body-embedding-corpus.md) | Full-body embedding corpus for way matching | Rejected |
| [ADR-128](./system/ADR-128-memory-as-repo-portable-ways-seed-routing-over-accumulated-snapshots.md) | Memory as repo-portable ways — seed routing over accumulated snapshots | Draft |
| [ADR-129](./system/ADR-129-plugin-way-discovery.md) | Plugin Way Discovery | Draft |

## Documentation
_Documentation structure, tooling, coherence_
Expand Down
152 changes: 152 additions & 0 deletions docs/architecture/system/ADR-129-plugin-way-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
---
status: Draft
date: 2026-04-25
deciders:
- aaronsb
- claude
related:
- ADR-108
- ADR-111
---

# ADR-129: Plugin Way Discovery

## Context

Way discovery is currently hardcoded to two filesystem locations:

1. **Project-local**: `$PROJECT/.claude/ways/`
2. **Global**: `~/.claude/hooks/ways/`

Claude Code plugins can ship `ways/` directories inside their install paths (matching the project-local convention, demonstrated by the `x@tracer-plugins` plugin which contains `.claude/ways/fruity/way.md`). However, the ways system has no mechanism to discover or scan these. Plugin-shipped ways are invisible.

Claude Code provides a stable CLI interface for querying plugin state:

```
claude plugin list --json
```

Returns an array of installed plugins, each with:
- `id` — plugin identifier (`name@marketplace`)
- `installPath` — absolute path to the installed plugin on disk
- `enabled` — whether the plugin is currently active
- `scope` — `"user"` (global) or `"project"` (scoped to a specific project)
- `projectPath` — (project-scoped only) which project the plugin belongs to
- `version` — installed version
- `installedAt` / `lastUpdated` — timestamps

This is sufficient to resolve which plugins are active and where their files live.

### Design constraints

- **No per-invocation subprocess**: `ways scan` runs on every prompt and tool use. Shelling out to `claude plugin list --json` on each invocation adds unacceptable latency.
- **Don't couple to internal file formats**: Reading `installed_plugins.json` and `settings.json` directly is faster but couples to Claude Code's internal storage format, which may change without notice.
- **Use the official CLI**: `claude plugin list --json` is the stable public interface for plugin state.
- **Enabled means enabled**: The `enabled` field already reflects whether a plugin is active. No additional scope filtering is needed — if `enabled` is `true`, the plugin participates.
- **Version deduplication**: If the same plugin ID appears with multiple versions, use the latest (by `lastUpdated` timestamp). The CLI already resolves to the active version, but defensive dedup protects against edge cases.

## Decision

### Hybrid approach: resolve once, scan many

**At session start**, resolve enabled plugin way-paths via `claude plugin list --json` and write them to a session-scoped manifest. The `ways` binary reads this manifest during scans, adding plugin directories to the candidate collection alongside project-local and global ways.

### Session-start resolution

A new step in the `SessionStart` hook chain (after `ways init`, before `ways corpus --if-stale`):

1. Run `claude plugin list --json`
2. Filter to `enabled == true`
3. For each, check if `$installPath/ways/` exists on disk
4. Deduplicate by plugin name: if multiple versions, keep the one with the latest `lastUpdated`
5. Write the list of way-paths to `$SESSION_DIR/plugin-ways.json`

The manifest format:

```json
[
{
"id": "x@tracer-plugins",
"path": "/Users/tracer/.claude/plugins/cache/tracer-plugins/x/1.0.0/ways"
}
]
```

### Candidate collection

`collect_candidates()` gains a third source, inserted between project-local and global:

```
1. $PROJECT/.claude/ways/ — project-local (highest priority)
2. $PLUGIN/ways/ — per enabled plugin (middle priority)
3. ~/.claude/hooks/ways/ — global (lowest priority)
```

The `ways` binary reads the session manifest (`plugin-ways.json`) and calls `collect_from_dir()` on each path. The existing `WalkDir`-based scanning, frontmatter parsing, domain filtering, and scope gating apply identically to plugin-sourced ways.

### ID namespacing

Plugin way IDs are prefixed with the plugin identifier to prevent collisions:

```
plugin:x@tracer-plugins/fruity (from plugin)
softwaredev/code/security (from global)
softwaredev/code/testing (from project-local)
```

Same-ID ways across sources share a session marker (project-local overrides plugin overrides global). Plugin ways cannot shadow global ways unless they use the same domain/path structure intentionally.

### Corpus integration

`ways corpus --if-stale` must include plugin way directories so that semantic (embedding) matching works for plugin-shipped ways. The corpus generation reads the same session manifest to discover additional scan roots.

### Macro trust

Plugin macros (`macro.sh` files inside plugin ways) are third-party code. They follow the same trust model as project-local macros: disabled by default, enabled per-plugin via `~/.claude/trusted-plugin-macros` (or extending the existing `trusted-project-macros` mechanism).

## Consequences

### Benefits

- Plugins can ship ways alongside skills and hooks — a single plugin can provide guidance, tools, and workflows
- Plugin authors can use the full way authoring surface: frontmatter, semantic matching, macros, check curves, scope gating
- No coupling to Claude Code's internal plugin storage format — uses the stable CLI interface
- Session-start resolution means zero per-scan overhead from plugin discovery
- Existing way precedence model extends naturally (project > plugin > global)

### Costs

- Session-start adds one `claude plugin list --json` subprocess call (~100-200ms)
- Session manifest is a new file to manage (create on start, stale if plugins change mid-session)
- Corpus regeneration may take slightly longer with additional plugin way directories
- Plugin way authors must understand the ID namespacing scheme

### Risks

- **Mid-session plugin changes**: If a user installs/removes/toggles a plugin during a session, the manifest is stale until the next session or compaction. Acceptable — plugin changes are rare and a session restart is natural.
- **Manifest missing**: If the session manifest doesn't exist (e.g., older ways binary, failed resolution), `collect_candidates()` falls back to the current two-source behavior. No breakage.
- **Plugin path instability**: Plugin install paths include version strings that change on update. The session manifest captures the path at resolution time, so this is fine within a session. Cross-session, the next start re-resolves.

### Way file path convention

Each way lives in its own directory, named to match the way file. This enables sibling files (`.check.md`, `macro.sh`) alongside the way definition.

| Scope | Full path |
|-------|-----------|
| Global | `~/.claude/hooks/ways/{domain}/{way}/{way}.md` |
| Project-local | `$PROJECT/.claude/ways/{domain}/{way}/{way}.md` |
| Plugin | `$PLUGIN_INSTALL_PATH/ways/{domain}/{way}/{way}.md` |

Plugins use `ways/` at the plugin install root. Global ways use `hooks/ways/` under `~/.claude/` because they sit alongside other hook types.

## Implementation

### Touch points

1. **New hook script**: `hooks/ways/resolve-plugins.sh` — runs `claude plugin list --json`, filters, writes manifest
2. **`settings.json`**: Add `resolve-plugins.sh` to `SessionStart` hooks (after `ways init`)
3. **`candidates.rs`**: `collect_candidates()` reads session manifest and adds plugin dirs
4. **`candidates.rs`**: `collect_checks()` — same addition for check files
5. **`cmd/corpus.rs`**: Corpus generation reads manifest for additional scan roots
6. **Way ID derivation**: Prefix plugin-sourced IDs with `plugin:{id}/`
7. **Macro trust**: New trust file or extend existing mechanism for plugin macros
11 changes: 11 additions & 0 deletions hooks/ways/resolve-plugins.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/bin/bash
# Resolve enabled plugin way-paths and write session manifest.
# Delegates to `ways resolve-plugins` (single Rust implementation).
# ADR-129: Plugin Way Discovery

INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')

[[ -z "$SESSION_ID" ]] && exit 0

"${HOME}/.claude/bin/ways" resolve-plugins --session "$SESSION_ID"
11 changes: 11 additions & 0 deletions settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
],
"defaultMode": "default"
},
"model": "sonnet[1m]",
"hooks": {
"SessionStart": [
{
Expand All @@ -95,6 +96,10 @@
"type": "command",
"command": "${HOME}/.claude/bin/ways init"
},
{
"type": "command",
"command": "${HOME}/.claude/hooks/ways/resolve-plugins.sh"
},
{
"type": "command",
"command": "${HOME}/.claude/bin/ways corpus --if-stale --quiet"
Expand Down Expand Up @@ -254,6 +259,12 @@
"source": "github",
"repo": "anthropics/knowledge-work-plugins"
}
},
"tracer-plugins": {
"source": {
"source": "directory",
"path": "/Users/tracer/claude-plugins"
}
}
}
}
116 changes: 114 additions & 2 deletions tools/ways-cli/src/cmd/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,43 @@ pub fn run(ways_dir: Option<String>, quiet: bool, if_stale: bool) -> Result<()>
}
}

// Scan enabled plugin ways (ADR-129)
let mut plugin_total = 0;
let mut manifest_plugins: HashMap<String, serde_json::Value> = HashMap::new();

for (plugin_id, ways_path) in discover_plugin_way_dirs() {
if !seen_ways_dirs.insert(ways_path.clone()) {
continue;
}
let prefix = format!("plugin:{plugin_id}/");
let plugin_count = scan_ways_dir(&ways_path, &prefix, &excluded, &mut w)?;
if plugin_count > 0 {
plugin_total += plugin_count;
let plugin_hash = content_hash(&ways_path);
log(&format!(
" plugin {plugin_id}: {plugin_count} ways (hash: {}...)",
&plugin_hash[..16.min(plugin_hash.len())]
));
manifest_plugins.insert(
plugin_id.clone(),
json!({
"path": ways_path.display().to_string(),
"ways_hash": plugin_hash,
"ways_count": plugin_count,
}),
);
}
}

w.flush()?;
drop(w);

// Atomic move
std::fs::rename(&tmpfile, &output)?;

let total = global_count + project_total;
let total = global_count + project_total + plugin_total;
log(&format!(
"Generated {}: {total} ways ({global_count} global, {project_total} project)",
"Generated {}: {total} ways ({global_count} global, {project_total} project, {plugin_total} plugin)",
output.display()
));

Expand All @@ -137,8 +165,10 @@ pub fn run(ways_dir: Option<String>, quiet: bool, if_stale: bool) -> Result<()>
let manifest = json!({
"global_hash": global_hash,
"global_count": global_count,
"plugin_count": plugin_total,
"total_count": total,
"projects": manifest_projects,
"plugins": manifest_plugins,
});
let manifest_path = xdg_way.join("embed-manifest.json");
std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
Expand Down Expand Up @@ -503,6 +533,71 @@ fn content_hash(dir: &Path) -> String {
format!("{:016x}", hasher.finish())
}

/// Discover enabled plugins that ship ways/ directories (ADR-129).
///
/// Reads installed_plugins.json and settings.json directly — corpus
/// generation runs outside any session context, so there's no session
/// manifest to read.
fn discover_plugin_way_dirs() -> Vec<(String, PathBuf)> {
let home = home_dir();
let plugins_file = home.join(".claude/plugins/installed_plugins.json");
let settings_file = home.join(".claude/settings.json");

let plugins_content = match std::fs::read_to_string(&plugins_file) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let plugins_data: serde_json::Value = match serde_json::from_str(&plugins_content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};

let settings_content = std::fs::read_to_string(&settings_file).unwrap_or_default();
let settings_data: serde_json::Value =
serde_json::from_str(&settings_content).unwrap_or_default();
let enabled_plugins = settings_data
.get("enabledPlugins")
.and_then(|v| v.as_object());

let plugins = match plugins_data.get("plugins").and_then(|v| v.as_object()) {
Some(p) => p,
None => return Vec::new(),
};

let mut result = Vec::new();

for (plugin_id, entries) in plugins {
// Check enabled in settings
let is_enabled = enabled_plugins
.and_then(|ep| ep.get(plugin_id))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_enabled {
continue;
}

let entries = match entries.as_array() {
Some(a) => a,
None => continue,
};

// Pick the entry with the latest lastUpdated
let best = entries.iter().filter_map(|e| {
let path = e.get("installPath")?.as_str()?;
let updated = e.get("lastUpdated")?.as_str()?.to_string();
Some((path.to_string(), updated))
}).max_by(|a, b| a.1.cmp(&b.1));

if let Some((install_path, _)) = best {
if let Some(ways_dir) = crate::session::resolve_plugin_ways_path(&install_path) {
result.push((plugin_id.clone(), ways_dir));
}
}
}

result
}

use crate::util::{home_dir, xdg_cache_dir};

/// Check if any way file is newer than the manifest.
Expand Down Expand Up @@ -542,6 +637,23 @@ fn is_stale(manifest: &Path, global_dir: &Path, project_dir: &str) -> bool {
}
}

// Check plugin ways (ADR-129)
for (_plugin_id, ways_dir) in discover_plugin_way_dirs() {
for entry in WalkDir::new(&ways_dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
let ext = path.extension().and_then(|e| e.to_str());
if (ext == Some("md") || ext == Some("jsonl")) && is_newer_than(path, manifest) {
return true;
}
}
}
}

false
}

Expand Down
Loading