From 8450951ccdc16d1f783cfe1730e2e025d6ea87fe Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:16:27 -0400 Subject: [PATCH 01/23] docs(claude-ops): add plugin-fleet-sync-skill plan and brief Locked contract for /claude-ops:plugins (sync/audit/converge): interview Brief, design early-exit resolution, and the stress-tested six-phase implementation plan. Phase 1 carries the USER-RESERVED go/no-go on whether `plugin update -s project` writes committed settings. Co-Authored-By: Claude Fable 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 175 ++++++++++++++++++ .../design/design-resolution.md | 39 ++++ 2 files changed, 214 insertions(+) create mode 100644 docs/topics/plugin-fleet-sync-skill/PLAN.md create mode 100644 docs/topics/plugin-fleet-sync-skill/design/design-resolution.md diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md new file mode 100644 index 000000000..6ef496b6d --- /dev/null +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -0,0 +1,175 @@ +# plugin-fleet-sync-skill + +## Brief + +### TLDR + +Add `/claude-ops:plugins` — an action-routed skill that deterministically brings a machine's plugin fleet current on demand: marketplace refresh, update of the *effective* (actually-loaded) scopes, policy-driven install of new catalog plugins, scope-divergence detection, and explicit scope convergence — with a terse actionable report. + +### Goal + +One command that guarantees, on any machine and from any directory, that the plugins which actually load are the latest published versions of everything the marketplace offers, and that surfaces (never silently fixes) any state where something older or unintended is what really runs. + +### Decisions (locked) + +1. **Marketplace scope**: defaults to the marketplace this plugin was installed from, resolved dynamically (never hardcoded). Optional `` or `all` argument extends the same loop. +2. **Home**: `claude-ops` plugin (Claude Code operational tooling). No marketplace.json edit needed; bump plugin.json version + CHANGELOG + README + description. +3. **Surface**: skill `plugins`, action router — `sync` (default, mutating), `audit` (read-only dry-run of everything sync/converge would do), `converge` (explicit scope consolidation). Mirrors the `changelog` skill's router shape. +4. **Sync semantics — "effective fleet current where you stand"**: + - Always: `claude plugin marketplace update ` (self-heals corrupt clones: pull failure → automatic re-clone), then per-plugin `claude plugin update` sweep at user scope, then install-new per policy, then enabledPlugins completeness check. + - Inside a project with project/local-scope installs: additionally update those installs in place (`plugin update -s project|local`) — they are what loads there. + - Ends with reload guidance (see deferred: `--force`). +5. **Install policy**: `install_new` userConfig scalar — `ask` (default; new catalog plugins offered in one batched multi-select prompt) | `all` | `none`. +6. **Converge** (only committed-settings-touching action): detects any plugin id with >1 scope entry (divergent version or enable state), previews per-plugin intent, confirms (ask-first for ALL pins in V1), executes via CLI (`plugin uninstall --scope`, `plugin update -s`), then surfaces the resulting committed `.claude/settings.json` diff for review. Never runs implicitly from sync; sync only reports and names the converge command. +7. **Report**: terse fixed sections — marketplace state, updated, installed, divergences (live-vs-inactive named per repo), action needed. Detail only where action is required. +8. **autoUpdate posture**: report the marketplace's autoUpdate status and suggest enabling (complementary: background update-installed-to-latest once per session start, random ≤10-min delay). Never mutates the setting. The skill exists for what autoUpdate verifiably does not do: install new catalog plugins, enabledPlugins completeness, divergence detection/convergence, deterministic on-demand execution. + +### Constraints + +- **All mutations via the `claude plugin` / `claude plugin marketplace` CLI.** `installed_plugins.json`, `known_marketplaces.json`, `.last_inuse_sweep`, and cache version dirs are internal state: read-only, never written or deleted. No cache surgery — CC garbage-collects orphaned version dirs 7 days after update/uninstall. +- **Renames are CC-native** (≥ v2.1.193: settings rewritten old→new at session start; `null` = removal). The skill's only rename residue: anything in catalog but not installed gets installed (covers renamed plugins / `plugin-cache-miss`). +- **Never silently edit a repo's committed `.claude/settings.json`** — team-shared, trust-gated. Converge surfaces diffs; v2.1.203+ local-override path available for personal-only intent. +- **Never trust `plugin list`/`details` version output for what is LOADED** (verified misleading: shows highest installed, not the cwd-effective install). Effective-version claims derive from scope-by-cwd resolution (local > project > user, verified) or a functional probe. +- Non-interactive contexts: any `uninstall`/`prune` needs `-y`; autonomous sessions (`CLAUDE_CODE_REMOTE`, `/loop`, `/schedule`) must abort converge (repo destructive-tier convention). +- Repo conventions (migration playbook / philosophy): repo- and machine-agnostic (no hardcoded paths, names, or marketplace ids; `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}`); bash runtime (Git Bash on Windows) with OS detection; shared helpers only via `lib/hook-utils.sh` sync; `context/` progressive disclosure; `evals/evals.json` (~5–6 cases: routing, happy path, ≥1 refusal/guardrail, ≥1 anti-pattern) per skill-quality schema. +- Minimum CC version note in docs: renames map ≥2.1.193; `prune` ≥2.1.121. + +### Acceptance criteria + +- From a clean machine with the marketplace added: one `sync` run leaves every catalog plugin installed at user scope at latest version, enabledPlugins complete, and prints the terse report — idempotent (second run reports all-current, changes nothing). +- With a stale/corrupt marketplace clone: `sync` recovers via `marketplace update` (no manual re-clone) and proceeds. +- After an upstream rename wave: post-migration `sync` installs all new-named plugins; no old-name entries remain in user-scope state; nothing hand-edits internal JSON. +- Run inside a repo with divergent project-scope installs: report names, per plugin, which version is LIVE there vs inactive; sync updates the project-scope installs in place (subject to the deferred verification below); committed settings untouched. +- `audit` mutates nothing — issues zero mutating CLI calls; internal state files unchanged absent external writers (concurrent sessions / autoUpdate background sweep) — while predicting sync/converge actions accurately. *(Refined 2026-07-17 during plan stress-test: byte-identical-unconditionally is unprovable under CC's own background autoUpdate.)* +- `converge` on a drift repo: previews, confirms, consolidates to user scope via CLI, and the repo's settings diff (if any) is surfaced, not committed. +- New catalog plugin with `install_new: ask`: offered, not auto-installed; with `all`: installed; with `none`: reported only. +- Skill ships evals passing skill-quality schema validation; claude-ops version bumped with CHANGELOG + README entries. + +### Captured assumptions + +- Consumers run CC ≥2.1.193 (renames-map support); older CC degrades to `plugin-not-found` for renamed plugins — report guides upgrade. +- The marketplace's own `renames` map remains the single source of rename truth; skill hard-codes no rename knowledge. +- `install_new` policy applies per marketplace uniformly (no per-plugin allowlist in V1). + +### Out-of-scope (deliberate) + +- Cache-dir cleanup/GC (CC's 7-day orphan sweep owns it; manual deletion unsanctioned). +- Auto-uninstalling catalog-removed plugins (CC-native `renames: null` handles removal; skill reports only). +- Mutating marketplace `autoUpdate` or any CC setting. +- Managed-scope handling (enterprise) beyond reporting. +- Cross-machine fleet orchestration (per-machine command only). +- Per-plugin desired-set manifest (revisit if `install_new` policy proves too coarse). + +### Deferred questions + +- **Does `plugin update -s project` avoid writing the repo's committed `.claude/settings.json`?** Derived-likely (enabledPlugins carries no version; installs are machine-local records) but unverified. Arbiter: empirical test during implementation (run against a throwaway project scope; `git status` the repo settings) BEFORE the in-repo update path ships. USER-RESERVED if the answer is "it writes": the in-repo sync semantics (Decision 4) would need re-approval. +- **Is `/reload-plugins --force` required after sync, or is plain `/reload-plugins` sufficient?** Arbiter: /architect (verify against current CC docs/behavior at implementation; report guidance follows the answer). +- **`all`-marketplaces argument interaction with third-party marketplaces lacking renames maps / non-git sources** — RESOLVED by /architect: graceful degradation — `marketplace update` handles every source type; a marketplace without a `renames` map simply has no rename residue; failures per-marketplace are reported and do not abort the sweep. + +## Plan + +### Phase 1: Verify runtime facts (throwaway spike) [TODO] + +Three unknowns gate later phases; resolve empirically on this machine before authoring semantics. + +1. **`plugin update -s project` committed-file test** (USER-RESERVED gate — **go/no-go for the skill's primary value**: the in-repo update path is the main event, since dual-scope repos load the stale project pin): from `D:\repos\github.com\melodic-software\medley` — note its `.claude/settings.json` is ALREADY dirty, so porcelain status alone cannot detect the write. Method: SHA256 both `.claude/settings.json` and `.claude/settings.local.json` AND copy both to a backup dir; run `claude plugin update markdown-formatter@melodic-software -s project` (also fixes one real drift item: 0.1.3 → latest); re-hash and diff. If either file changed: restore from backup, record diff verbatim, **STOP — Brief Decision 4 needs user re-approval**. +2. **`/reload-plugins` `--force` necessity**: check current CC docs/help for `/reload-plugins` flags; empirically compare `/reload-plugins` vs `--force` after a plugin update. Record which the report should recommend. +3. **userConfig enum support**: fetch `json.schemastore.org/claude-code-plugin-manifest.json`; check whether `userConfig` entries support `enum`. Fallback: string field + prose validation in SKILL.md. +4. **Internal-schema parse contract snapshot**: copy the live shapes of `installed_plugins.json` (per-id array of `{scope, projectPath?, installPath, version, ...}`) and `known_marketplaces.json` into Phase 2 test fixtures, with a CC version-floor note (schema observed on 2.1.211; undocumented internal contract — parser must fail loud on shape drift, never guess). + +**Sanity Check:** PLAN.md "Open questions" contains four lines matching `^- VERIFIED:` (one per unknown), each with observed evidence; item 1's line records both file hashes before/after verbatim; fixture files exist under `plugins/claude-ops/skills/plugins/scripts/fixtures/`. + +### Phase 2: State-inspection script (TDD) [TODO] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/scripts/fleet-state.sh` | Create | Read-only state JSON per design-resolution type sketch; `--marketplace ` / `--all`; jq-based; fail-open notice on missing jq | +| `plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh` | Create | Fixture-driven tests written FIRST (Red-Green-Refactor) | + +Fixtures: dual-scope divergence, plugin missing from installs, plugin missing from enabledPlugins, explicit `enabledPlugins: false` opt-out, marketplace absent, malformed/drifted JSON shape (must fail loud), native-Windows `projectPath` vs Git Bash cwd. + +**Path normalization (CRITICAL, from plan review):** `installed_plugins.json` stores `projectPath` in native Windows form (`D:\repos\...`); Git Bash `$PWD` is `/d/repos/...`. In-repo detection MUST normalize both sides or it silently no-ops on Windows. Reuse `hook::normalize_path` from the plugin's synced `hook-utils.sh` copy (`plugins/claude-ops/hooks/hook-utils.sh` — already carried by this plugin; never hand-edit, sync via `scripts/sync-hook-utils.sh` if lib changes are needed). + +**Sanity Check:** `bash fleet-state.test.sh` exit 0 including a Windows path-match assertion (`projectPath: D:\\...` fixture matched from cwd `/d/...`); repo bash-lint hook passes on both files; live run on this machine reports all 46 catalog plugins installed+enabled with zero `missing_*` entries. + +### Phase 3: SKILL.md + context files [TODO] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/SKILL.md` | Create | Frontmatter (`user-invocable: true`, `disable-model-invocation: true`, argument-hint, description with "Use when"), Action Router table (sync default / audit / converge, ``/`all` args), terse-report section spec — bulk-divergence collapsed to one line ("N project-scope installs behind user scope → run converge"; per-row detail reserved for genuine conflicts: enable-state mismatch, unknown plugin, failures), `${CLAUDE_PLUGIN_ROOT}` script invocation | +| `plugins/claude-ops/skills/plugins/context/sync.md` | Create | Sync algorithm: marketplace update → in-repo project/local update when cwd is inside a project with installs (PRIMARY value path, per Phase 1 verdict) → user-scope update sweep → install-new per `install_new` policy → enabledPlugins completeness → report. "Missing" = in catalog AND never installed AND not explicitly `false` in any enabledPlugins scope; explicit `false` = deliberate opt-out, reported never flipped; `install_new: all` reinstall-on-every-sync behavior for uninstalled-but-not-disabled plugins documented as a caveat. Concurrency: CLI is the serialization point; re-read state immediately before each mutation (never mutate off a stale snapshot); autoUpdate background sweep may race — report notes it | +| `plugins/claude-ops/skills/plugins/context/converge.md` | Create | Divergence intent preview, per-plugin confirm, CLI execution, committed-diff surfacing; autonomous-session abort per the existing repo convention (`repo-hygiene` clean: `CLAUDE_CODE_REMOTE`, `/loop`, `/schedule` → abort destructive tier; fail-closed when context is uncertain) | +| `plugins/claude-ops/skills/plugins/context/scope-semantics.md` | Create | Verified facts: scope-by-cwd loading, enable-boolean override chain, `list`/`details` version display caveat, committed-vs-machine-local state map, renames-map CC-native behavior (floor ≥2.1.193; prune ≥2.1.121), autoUpdate complement, divergence-is-normal expectation | +| `plugins/claude-ops/skills/plugins/context/gotchas.md` | Create | list/details version lie, native-Windows projectPath, concurrency/TOCTOU, dual-scope-divergence-as-normal, internal-schema drift fail-loud | + +**Sanity Check:** `grep -c '| `sync' SKILL.md` ≥ 1 and router table lists exactly sync/audit/converge; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` returns 0 normative occurrences (marketplace name always resolved dynamically); `grep -c 'CLAUDE_PLUGIN_ROOT' SKILL.md` ≥ 1; frontmatter contains `disable-model-invocation: true`. + +### Phase 4: Evals [TODO] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/skills/plugins/evals/evals.json` | Create | 6 cases: routing (bare invocation → sync), audit-is-read-only guardrail, install_new=ask prompting, converge-requires-confirm refusal (autonomous context), divergence report names live-vs-inactive, anti-pattern (never edits installed_plugins.json / cache dirs) | + +**Sanity Check:** `bash plugins/skill-quality/scripts/check-skill.sh plugins/claude-ops/skills/plugins` reports zero WARN across ALL checks (gotchas surface satisfies Check 11); `jq '.evals | length' evals.json` ≥ 5. + +### Phase 5: Plugin metadata [TODO] + +| File | Action | What changes | +|------|--------|-------------| +| `plugins/claude-ops/.claude-plugin/plugin.json` | Modify | version 0.8.0 → 0.9.0; description includes new skill; `userConfig.install_new` per Phase 1 verdict | +| `plugins/claude-ops/CHANGELOG.md` | Modify | `## [0.9.0]` `### Added` entry | +| `plugins/claude-ops/README.md` | Modify | Skills section row + configuration note | + +**Sanity Check:** `node scripts/validate-plugin-contracts.mjs` (or `bash scripts/validate-plugins.sh`) exit 0; `jq -r .version plugins/claude-ops/.claude-plugin/plugin.json` = `0.9.0`; `grep -c '\[0.9.0\]' CHANGELOG.md` = 1. + +### Phase 6: End-to-end verification [TODO] + +1. Install the branch build locally (or `/reload-plugins` against the dev copy per repo dev-loop convention), run `/claude-ops:plugins audit`: verify it issues ZERO mutating CLI calls (transcript inspection) and, absent concurrent sessions/autoUpdate sweeps, both internal JSON files hash identical before/after; report contains all five sections. +2. Run `sync` on this machine (steady state expected: all current) — idempotence: second run reports no changes. +3. Run `audit` from inside medley — divergence section present, bulk case collapsed to the one-line summary with live-vs-inactive versions named for at least the conflict rows. + +**Sanity Check:** audit transcript contains no `plugin install|update|uninstall|marketplace update` invocations; before/after SHA256 of both internal JSON files identical (run with no other CC session live); sync run 2 output contains zero update/install lines; medley audit output contains the collapsed divergence summary line. + +## Blast radius + +LOW — additive skill in one plugin; no existing skill/hook/lib modified (plugin.json/CHANGELOG/README additive edits). Runtime mutations are user-invoked, CLI-mediated, reversible; the one committed-settings hazard is fenced behind `converge` confirm + Phase 1 USER-RESERVED gate. + +## Stress-test summary + +Plan-reviewer sub-agent (fresh context): 1 CRITICAL + 6 IMPORTANT + 3 SUGGESTION. All applied except: #4 partially (medley test kept — it exercises a real stale pin a fresh throwaway cannot manufacture; method hardened to hash-compare + backup/restore) and #6 downgraded (reviewer missed the existing `CLAUDE_CODE_REMOTE` autonomous-abort convention at `repo-hygiene/skills/clean/context/action-router.md:58` — converge aligns with it, fail-closed, no new research phase). CRITICAL #1 (native-Windows `projectPath` vs Git Bash cwd — in-repo detection would silently no-op) fixed in Phase 2 via `hook::normalize_path` reuse + test assertion. /devils-advocate: skipped — blast radius LOW, no triggers matched. + +## Execution shape + +Fully sequential: 1 → 2 → 3 → 4 → 5 → 6 — Phase 1 verdicts gate Phase 3 semantics; Phase 3 cites Phase 2's script interface; 4/5 document 3; 6 verifies all. Parallelism opportunity immaterial (<100 LOC independent). + +| Phase | Surface | Basis | +|---|---|---| +| 1 | main-session | mutating empirical test + USER-RESERVED stop gate needs the user present | +| 2 | main-session | small TDD unit; dispatch overhead exceeds saving | +| 3–5 | main-session | judgment-heavy authoring against conventions | +| 6 | main-session | drives live machine state + user-visible reload | + +## Open questions + +- Phase 1 unknowns (three) — resolved lines land here as `- VERIFIED: ...`. + +## Handoff to implementation + +### User-approval gates + +- Phase 1 item 1: if `plugin update -s project` writes the committed `.claude/settings.json` → STOP, re-approve Brief Decision 4. +- Any scope expansion beyond the Files Affected tables. + +### Execution shape ([EXEC-SHAPE] tagged) + +- [EXEC-SHAPE] Hybrid script/prose split: one read-only `fleet-state.sh` (repeatable-inspection convention) + model-driven CLI mutations (judgment + confirms involved). +- [EXEC-SHAPE] `disable-model-invocation: true` for the whole skill V1 (setup-skill precedent; mutating fleet ops are user-intent). +- [EXEC-SHAPE] `all`-marketplaces degradation: per-marketplace failures reported, sweep continues. +- [FALLBACK — confirm or override] userConfig `install_new` as enum; falls back to string + prose validation if the manifest schema lacks enum support (Phase 1 item 3 decides). + +### Mechanical work + +- Branch: create `feat/claude-ops-plugins-skill` from latest `origin/main` (current checkout sits on an unrelated feature branch — use a worktree per repo convention if that branch stays active). +- Commit boundaries: one commit per phase; PLAN.md tag updates ride the same commit. +- Sequential fallback: n/a (no parallel shape). + diff --git a/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md b/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md new file mode 100644 index 000000000..9dd25aade --- /dev/null +++ b/docs/topics/plugin-fleet-sync-skill/design/design-resolution.md @@ -0,0 +1,39 @@ +# Design resolution — plugin-fleet-sync-skill + +outcome: early-exit (Tier B) + +Reason: the contract surface was fully resolved by the /interview Brief (PLAN.md `## Brief`, +Decisions 1–8) backed by three verified research passes (official CC docs, repo conventions, +scope-precedence empirics). No new code types or module topology — the deliverable is a +markdown skill orchestrating the `claude plugin` CLI plus one read-only state-inspection +script. Type surface is small enough to sketch here; full /design would re-derive the Brief. + +## Type sketch + +**Action surface** (router in SKILL.md): + +- `sync [marketplace|all]` — default; mutating via CLI only +- `audit [marketplace|all]` — read-only dry-run of sync + converge predictions +- `converge` — explicit scope consolidation; only committed-settings-touching action + +**userConfig** (plugin.json, personal policy scalar): + +- `install_new`: `ask` (default) | `all` | `none` — enum support in the manifest schema is a + Phase-1 verification; fallback is a string field validated in skill prose. + +**State-inspection output** (read-only script → JSON consumed by the skill): + +```json +{ + "marketplace": {"name": "...", "autoUpdate": false, "lastUpdated": "..."}, + "catalog": ["plugin", "..."], + "installed": [{"id": "plugin@mp", "scope": "user|project|local", "version": "...", "projectPath": "..."}], + "enabled": {"plugin@mp": true}, + "missing_from_install": ["..."], + "missing_from_enabled": ["..."], + "divergences": [{"id": "...", "scopes": [{"scope": "...", "version": "...", "projectPath": "..."}]}] +} +``` + +Inputs are internal CC state files read-only (`installed_plugins.json`, `known_marketplaces.json`, +marketplace `marketplace.json`, settings `enabledPlugins`); every mutation goes through the CLI. From c8ca59deb3b05f6b3b73711fb759f0968e29bbe2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:29:13 -0400 Subject: [PATCH 02/23] test(claude-ops): verify plugin CLI runtime facts for plugins skill (phase 1) Empirically resolve the plan's three unknowns before authoring the plugins skill: `plugin update -s project` does not write committed settings (hash-verified in a real dual-scope repo), `/reload-plugins` has no `--force` flag, and `userConfig` has no `enum` support. Snapshot the live installed_plugins.json / known_marketplaces.json shapes into scrubbed fixtures for phase 2. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 14 ++++---- .../fixtures/installed_plugins.sample.json | 34 +++++++++++++++++++ .../fixtures/known_marketplaces.sample.json | 19 +++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json create mode 100644 plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 6ef496b6d..87b742313 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -62,13 +62,13 @@ One command that guarantees, on any machine and from any directory, that the plu ### Deferred questions -- **Does `plugin update -s project` avoid writing the repo's committed `.claude/settings.json`?** Derived-likely (enabledPlugins carries no version; installs are machine-local records) but unverified. Arbiter: empirical test during implementation (run against a throwaway project scope; `git status` the repo settings) BEFORE the in-repo update path ships. USER-RESERVED if the answer is "it writes": the in-repo sync semantics (Decision 4) would need re-approval. -- **Is `/reload-plugins --force` required after sync, or is plain `/reload-plugins` sufficient?** Arbiter: /architect (verify against current CC docs/behavior at implementation; report guidance follows the answer). +- **Does `plugin update -s project` avoid writing the repo's committed `.claude/settings.json`?** — RESOLVED, see Open questions VERIFIED line 1: hash-verified no write. Decision 4 stands, no re-approval needed. +- **Is `/reload-plugins --force` required after sync, or is plain `/reload-plugins` sufficient?** — RESOLVED, see Open questions VERIFIED line 2: no `--force` flag exists; report recommends bare `/reload-plugins`. - **`all`-marketplaces argument interaction with third-party marketplaces lacking renames maps / non-git sources** — RESOLVED by /architect: graceful degradation — `marketplace update` handles every source type; a marketplace without a `renames` map simply has no rename residue; failures per-marketplace are reported and do not abort the sweep. ## Plan -### Phase 1: Verify runtime facts (throwaway spike) [TODO] +### Phase 1: Verify runtime facts (throwaway spike) [DONE] Three unknowns gate later phases; resolve empirically on this machine before authoring semantics. @@ -102,7 +102,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr | `plugins/claude-ops/skills/plugins/context/scope-semantics.md` | Create | Verified facts: scope-by-cwd loading, enable-boolean override chain, `list`/`details` version display caveat, committed-vs-machine-local state map, renames-map CC-native behavior (floor ≥2.1.193; prune ≥2.1.121), autoUpdate complement, divergence-is-normal expectation | | `plugins/claude-ops/skills/plugins/context/gotchas.md` | Create | list/details version lie, native-Windows projectPath, concurrency/TOCTOU, dual-scope-divergence-as-normal, internal-schema drift fail-loud | -**Sanity Check:** `grep -c '| `sync' SKILL.md` ≥ 1 and router table lists exactly sync/audit/converge; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` returns 0 normative occurrences (marketplace name always resolved dynamically); `grep -c 'CLAUDE_PLUGIN_ROOT' SKILL.md` ≥ 1; frontmatter contains `disable-model-invocation: true`. +**Sanity Check:** `grep -c '|`sync' SKILL.md` ≥ 1 and router table lists exactly sync/audit/converge; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` returns 0 normative occurrences (marketplace name always resolved dynamically); `grep -c 'CLAUDE_PLUGIN_ROOT' SKILL.md` ≥ 1; frontmatter contains `disable-model-invocation: true`. ### Phase 4: Evals [TODO] @@ -151,7 +151,10 @@ Fully sequential: 1 → 2 → 3 → 4 → 5 → 6 — Phase 1 verdicts gate Phas ## Open questions -- Phase 1 unknowns (three) — resolved lines land here as `- VERIFIED: ...`. +- VERIFIED: `plugin update -s project` does NOT write committed `.claude/settings.json` or `.claude/settings.local.json`. Empirical test in a real dual-scope repo (`markdown-formatter@melodic-software`: project scope 0.1.3, user scope 0.1.9). Before: `settings.json` sha256 `367ad75f642282ebc0d467fe932b530a74755a55fdad5ee9fef6f030c775169e`, `settings.local.json` sha256 `7f026819d5a24c05d61df0a5a77b186a08fd25ee90fb20f384ddda12dbf6eb9b`. Ran `claude plugin update markdown-formatter@melodic-software -s project` (CC 2.1.212) → project scope updated 0.1.3 → 0.1.9. After: both hashes byte-identical to before. Only `installed_plugins.json`'s project-scope record changed (version/installPath/lastUpdated/gitCommitSha). **Brief Decision 4 confirmed safe — no re-approval needed.** +- VERIFIED: no `--force` flag exists for `/reload-plugins`. `code.claude.com/docs/en/plugins-reference` (fetched 2026-07-16) documents bare `/reload-plugins` as the command that refreshes skills, agents, hooks, and MCP/LSP servers in-process, and separately notes **monitors require a full session restart** (not covered by `/reload-plugins` at all). No `--force` variant appears anywhere in the plugins/skills/commands reference pages; targeted web search corroborates no such flag exists. Report guidance: recommend bare `/reload-plugins`; call out session-restart separately only when a monitor is in play. +- VERIFIED: `userConfig` schema has no `enum` field. `www.schemastore.org/claude-code-plugin-manifest.json` (redirected from `json.schemastore.org`, fetched 2026-07-16): allowed `type` values are `string | number | boolean | directory | file`; entry properties are `type, title, description, required, default, multiple, sensitive, min, max` with `additionalProperties: false` — an `enum` key would fail schema validation. Fallback confirmed: `install_new` ships as `type: string`, `default: "ask"`, valid values (`ask`/`all`/`none`) documented in `description` + prose-validated in SKILL.md. +- VERIFIED: internal-schema shapes snapshotted live on CC 2.1.212 (2026-07-17). `installed_plugins.json`: `{version, plugins: {"@": [{scope: "user"|"project"|"local", projectPath?, installPath, version, installedAt, lastUpdated, gitCommitSha}]}}` — array length > 1 per plugin id is the divergence signal. `known_marketplaces.json`: `{"": {source: {source: "github", repo}, installLocation, lastUpdated, autoUpdate?}}` — `autoUpdate` observed absent-by-default on one real marketplace and `true` on another, confirming Brief Decision 8's "report + suggest enabling" framing (never assume a default). Scrubbed fixtures capturing these shapes: `plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json`, `known_marketplaces.sample.json`. ## Handoff to implementation @@ -172,4 +175,3 @@ Fully sequential: 1 → 2 → 3 → 4 → 5 → 6 — Phase 1 verdicts gate Phas - Branch: create `feat/claude-ops-plugins-skill` from latest `origin/main` (current checkout sits on an unrelated feature branch — use a worktree per repo convention if that branch stays active). - Commit boundaries: one commit per phase; PLAN.md tag updates ride the same commit. - Sequential fallback: n/a (no parallel shape). - diff --git a/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json b/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json new file mode 100644 index 000000000..1c186ffd9 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fixtures/installed_plugins.sample.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "plugins": { + "plugin-a@test-marketplace": [ + { + "scope": "project", + "projectPath": "/sample-repo", + "installPath": "/test-marketplace/plugin-a/0.1.3", + "version": "0.1.3", + "installedAt": "2026-06-24T14:58:15.164Z", + "lastUpdated": "2026-07-13T14:25:51.587Z", + "gitCommitSha": "6ce8dc7c6c73a0a0924ce14197e3dbf77d48d911" + }, + { + "scope": "user", + "installPath": "/test-marketplace/plugin-a/0.1.9", + "version": "0.1.9", + "installedAt": "2026-07-14T07:39:26.186Z", + "lastUpdated": "2026-07-16T22:49:58.012Z", + "gitCommitSha": "b76237387538e77d7e998c1fe3d379cc7b2c34ac" + } + ], + "plugin-b@test-marketplace": [ + { + "scope": "user", + "installPath": "/test-marketplace/plugin-b/0.8.0", + "version": "0.8.0", + "installedAt": "2026-07-14T07:39:26.186Z", + "lastUpdated": "2026-07-14T07:39:26.186Z", + "gitCommitSha": "a4dc58a232f4a1b5c6d7e8f9a0b1c2d3e4f5a6b7" + } + ] + } +} diff --git a/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json b/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json new file mode 100644 index 000000000..ea0103dc2 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fixtures/known_marketplaces.sample.json @@ -0,0 +1,19 @@ +{ + "test-marketplace": { + "source": { + "source": "github", + "repo": "example-org/test-marketplace" + }, + "installLocation": "/test-marketplace", + "lastUpdated": "2026-07-17T01:22:34.594Z" + }, + "auto-update-marketplace": { + "source": { + "source": "github", + "repo": "example-org/auto-update-marketplace" + }, + "installLocation": "/auto-update-marketplace", + "lastUpdated": "2026-07-16T23:20:32.150Z", + "autoUpdate": true + } +} From 53177ced12d24f4840eff0c7fe06f1f0469ae2f0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:53:43 -0400 Subject: [PATCH 03/23] feat(claude-ops): add fleet-state.sh read-only plugin-state inspection (phase 2) Read-only JSON state report over CC's internal plugin-state files: catalog completeness, install completeness, and scope divergence per marketplace, with --marketplace/--all and a dynamic default-marketplace resolution that never hardcodes a marketplace name. Ships with an 11-case TDD suite covering divergence, missing-install, missing-enabled vs explicit opt-out, marketplace-absent, malformed-shape fail-loud, the native-Windows-path-vs-Git-Bash CRITICAL case, and jq-missing. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 4 +- .../skills/plugins/scripts/fleet-state.sh | 316 ++++++++++++++++++ .../plugins/scripts/fleet-state.test.sh | 276 +++++++++++++++ 3 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 plugins/claude-ops/skills/plugins/scripts/fleet-state.sh create mode 100644 plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 87b742313..7bc282c0a 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -79,7 +79,7 @@ Three unknowns gate later phases; resolve empirically on this machine before aut **Sanity Check:** PLAN.md "Open questions" contains four lines matching `^- VERIFIED:` (one per unknown), each with observed evidence; item 1's line records both file hashes before/after verbatim; fixture files exist under `plugins/claude-ops/skills/plugins/scripts/fixtures/`. -### Phase 2: State-inspection script (TDD) [TODO] +### Phase 2: State-inspection script (TDD) [DONE] | File | Action | What changes | |------|--------|-------------| @@ -92,6 +92,8 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Sanity Check:** `bash fleet-state.test.sh` exit 0 including a Windows path-match assertion (`projectPath: D:\\...` fixture matched from cwd `/d/...`); repo bash-lint hook passes on both files; live run on this machine reports all 46 catalog plugins installed+enabled with zero `missing_*` entries. +**Evidence:** `fleet-state.test.sh` — 11 cases / 24 assertions, 0 failed (dual-scope divergence, missing-from-install, missing-from-enabled, explicit-`false`-opt-out, marketplace-absent, malformed `installed_plugins.json` and `known_marketplaces.json` shape drift both fail loud at exit 2, native-Windows `projectPath` vs Git Bash cwd `currentProject` match, default-marketplace resolution via `CLAUDE_PLUGIN_ROOT` join including its fail-loud path, jq-missing notice). shellcheck clean on both files; bash-lint's PostToolUse formatter ran on every write, no outstanding diff. Live read-only run against this machine's real state (`--marketplace melodic-software`): 47 catalog plugins (not 46 — the plan's estimate predates `repo-fleet-hygiene` landing), 0 `missing_from_install`, 0 `missing_from_enabled`, 31 `divergences` (expected — dual-scope project/user pins are normal, not a defect). Two real bugs found and fixed during this phase: this host's `jq` build CRLF-terminates even compact single-line output, corrupting anything re-parsed via `--argjson` (fixed with a `jq() { command jq "$@" | tr -d '\r'; }` wrapper); the `enabled` reduce was missing `jq -n`, silently reading the script's own stdin instead of producing output. + ### Phase 3: SKILL.md + context files [TODO] | File | Action | What changes | diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh new file mode 100644 index 000000000..c7cefcb35 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# Read-only plugin-fleet state inspection for the `plugins` skill. +# +# Reads CC's own internal plugin-state files (installed_plugins.json, +# known_marketplaces.json, a marketplace's own marketplace.json, +# enabledPlugins across user/project/local settings scopes) and emits one +# JSON object describing catalog completeness, install completeness, and +# scope divergence for a marketplace. NEVER writes any of these files — every +# mutation this skill performs goes through the `claude plugin` CLI, never +# this script. +# +# Usage: +# fleet-state.sh [--marketplace | --all] +# +# With neither flag, resolves the default marketplace dynamically: the one +# this plugin (CLAUDE_PLUGIN_ROOT) was itself installed from. `--all` sweeps +# every marketplace in known_marketplaces.json; a per-marketplace failure is +# reported inline and does not abort the sweep. +# +# Output (stdout): one JSON object. +# Single marketplace: {marketplace, catalog, installed, enabled, +# missing_from_install, missing_from_enabled, divergences} +# — or {marketplace: {name, error}} on a resolvable per-marketplace failure. +# --all: {marketplaces: {"": , ...}} +# +# Exit codes: +# 0 ran to completion (individual marketplace failures are reported in the +# JSON body, not the exit code, so an --all sweep with partial failures +# still exits 0) +# 1 a single-marketplace run's marketplace could not be resolved/read +# 2 fatal: jq missing, or an internal CC state file is present but does not +# match its expected shape (fail loud on schema drift — never guess) +# +# Env overrides (testing only; production uses the real paths): +# FLEET_STATE_INSTALLED_JSON — path to installed_plugins.json +# FLEET_STATE_MARKETPLACES_JSON — path to known_marketplaces.json +# FLEET_STATE_USER_SETTINGS — path to the user-scope settings.json +# FLEET_STATE_CATALOG_DIR — dir of .json catalog +# fixtures, read instead of each +# marketplace's installLocation clone +# FLEET_STATE_HOOK_UTILS — path to hook-utils.sh +# +# Real env vars this script honors (set by Claude Code, not test-only): +# CLAUDE_PLUGIN_ROOT — this plugin's own install dir; used to self-resolve +# the default marketplace when neither flag is given +# CLAUDE_PROJECT_DIR — current project root; used for project/local scope +# settings and the `currentProject` install flag + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT_DEFAULT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +HOOK_UTILS="${FLEET_STATE_HOOK_UTILS:-${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}/hooks/hook-utils.sh}" +if [[ -f "$HOOK_UTILS" ]]; then + # shellcheck source=/dev/null + source "$HOOK_UTILS" +else + echo "ERROR: hook-utils.sh not found at $HOOK_UTILS" >&2 + exit 2 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "ERROR: jq required (install with: winget install jqlang.jq | apt install jq | brew install jq)" >&2 + exit 2 +fi + +# Some native-Windows jq builds CRLF-terminate every line, including +# single-line compact output. `$(...)` strips only the trailing LF, so a +# stray CR survives at the end of a captured value and corrupts it once +# re-parsed as JSON via --argjson. Route every call through this wrapper +# instead of calling jq directly so no call site has to remember this. +jq() { command jq "$@" | tr -d '\r'; } + +INSTALLED_JSON="${FLEET_STATE_INSTALLED_JSON:-$HOME/.claude/plugins/installed_plugins.json}" +MARKETPLACES_JSON="${FLEET_STATE_MARKETPLACES_JSON:-$HOME/.claude/plugins/known_marketplaces.json}" +USER_SETTINGS="${FLEET_STATE_USER_SETTINGS:-$HOME/.claude/settings.json}" + +# --- Fail-loud shape validation for internal (undocumented) CC state ------- +# These files are CC-internal, not a published contract. A shape drift means +# our assumptions are stale — better to fail loud here than silently emit an +# empty or wrong report. + +require_json() { + local path="$1" label="$2" + [[ -f "$path" ]] || { + echo "ERROR: $label not found: $path" >&2 + exit 2 + } + jq empty "$path" 2>/dev/null || { + echo "ERROR: $label is not valid JSON: $path" >&2 + exit 2 + } +} + +require_json "$INSTALLED_JSON" "installed_plugins.json" +if [[ "$(jq -r 'has("plugins") and (.plugins | type == "object")' "$INSTALLED_JSON")" != "true" ]]; then + echo "ERROR: installed_plugins.json does not match the expected {plugins: {...}} shape: $INSTALLED_JSON" >&2 + exit 2 +fi + +require_json "$MARKETPLACES_JSON" "known_marketplaces.json" +if [[ "$(jq -r 'type == "object"' "$MARKETPLACES_JSON")" != "true" ]]; then + echo "ERROR: known_marketplaces.json is not a JSON object: $MARKETPLACES_JSON" >&2 + exit 2 +fi + +# --- Effective enabledPlugins (raw per-scope + merged local>project>user) -- + +user_map='{}' +[[ -f "$USER_SETTINGS" ]] && user_map=$(jq -c '.enabledPlugins // {}' "$USER_SETTINGS") + +project_map='{}' +local_map='{}' +if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + [[ -f "$CLAUDE_PROJECT_DIR/.claude/settings.json" ]] && + project_map=$(jq -c '.enabledPlugins // {}' "$CLAUDE_PROJECT_DIR/.claude/settings.json") + [[ -f "$CLAUDE_PROJECT_DIR/.claude/settings.local.json" ]] && + local_map=$(jq -c '.enabledPlugins // {}' "$CLAUDE_PROJECT_DIR/.claude/settings.local.json") +fi + +# Union of every id ever mentioned in any scope (raw, unmerged) — used to +# distinguish "never mentioned anywhere" (missing_from_enabled) from +# "explicitly false somewhere" (deliberate opt-out, never flipped by sync). +known_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '($u + $p + $l) | keys') + +# Effective value per id: local > project > user. +effective_map=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '$u + $p + $l') + +# --- Normalized current-project root, for the `currentProject` install flag +current_project_norm="" +if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + current_project_norm=$(hook::normalize_path "$(hook::physical_path "$CLAUDE_PROJECT_DIR")") + current_project_norm="${current_project_norm%/}" +fi + +# --- Resolve default marketplace: the one this plugin was installed from --- +resolve_default_marketplace() { + local plugin_root norm_root + plugin_root="${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}" + norm_root=$(hook::normalize_path "$(hook::physical_path "$plugin_root")") + norm_root="${norm_root%/}" + jq -r --arg root "$norm_root" ' + .plugins + | to_entries[] + | select(.value[] | (.installPath // "" | gsub("\\\\";"/")) == $root + or (.installPath // "" | gsub("\\\\";"/") | ascii_downcase) == ($root | ascii_downcase)) + | .key + ' "$INSTALLED_JSON" | head -1 | sed 's/.*@//' +} + +# --- Emit one marketplace's state object ------------------------------------ +# Args: marketplace name. Prints a JSON object on stdout; on a resolvable +# per-marketplace failure prints {marketplace:{name,error}} and returns 1 +# (caller decides whether that is fatal for this invocation). +emit_marketplace() { + local name="$1" + local mp_entry auto_update last_updated install_location catalog_json + + mp_entry=$(jq -c --arg n "$name" '.[$n] // empty' "$MARKETPLACES_JSON") + if [[ -z "$mp_entry" ]]; then + jq -cn --arg n "$name" '{marketplace: {name: $n, error: "not found in known_marketplaces.json"}}' + return 1 + fi + auto_update=$(jq -r '.autoUpdate // false' <<<"$mp_entry") + last_updated=$(jq -r '.lastUpdated // ""' <<<"$mp_entry") + install_location=$(jq -r '.installLocation // ""' <<<"$mp_entry") + + if [[ -n "${FLEET_STATE_CATALOG_DIR:-}" ]]; then + local fixture="$FLEET_STATE_CATALOG_DIR/$name.json" + if [[ ! -f "$fixture" ]]; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "no catalog fixture"}}' + return 1 + fi + catalog_json="$fixture" + else + catalog_json="$install_location/.claude-plugin/marketplace.json" + if [[ ! -f "$catalog_json" ]]; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "marketplace.json not found at installLocation"}}' + return 1 + fi + fi + + require_json "$catalog_json" "marketplace.json ($name)" + local catalog + catalog=$(jq -c '[.plugins[]?.name // empty] | unique' "$catalog_json") + + local catalog_ids + catalog_ids=$(jq -r --arg mp "$name" '.[] | . + "@" + $mp' <<<"$catalog") + + # Every install record for ids in this marketplace, flattened, with the + # currentProject flag Windows-normalized on both sides. + local installed + installed=$(jq -c --arg suffix "@$name" --arg cur "$current_project_norm" ' + .plugins + | to_entries[] + | select(.key | endswith($suffix)) + | .key as $id + | .value[] + | { + id: $id, + scope: .scope, + version: .version, + projectPath: (.projectPath // null), + currentProject: ( + if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 and ($cur | length) > 0 then + ((.projectPath | gsub("\\\\";"/") | ascii_downcase) == ($cur | ascii_downcase)) + else null end + ) + } + ' "$INSTALLED_JSON" | jq -cs '.') + + local installed_ids + installed_ids=$(jq -r '[.[].id] | unique | .[]' <<<"$installed") + + local missing_from_install + missing_from_install=$(comm -23 \ + <(sort -u <<<"$catalog_ids") \ + <(sort -u <<<"${installed_ids:-}") | grep -v '^$' | jq -R . | jq -sc .) + + local known_at_mp + known_at_mp=$(jq -c --arg suffix "@$name" '[.[] | select(endswith($suffix))]' <<<"$known_ids") + + local missing_from_enabled + missing_from_enabled=$(jq -cn --argjson installed_ids "$(jq -c '[.[].id] | unique' <<<"$installed")" --argjson known "$known_at_mp" \ + '$installed_ids - $known') + + local enabled_at_mp + enabled_at_mp=$(jq -cn --argjson known "$known_at_mp" --argjson eff "$effective_map" \ + 'reduce $known[] as $id ({}; . + {($id): $eff[$id]})') + + local divergences + divergences=$(jq -c ' + group_by(.id) + | map(select(length > 1)) + | map({id: .[0].id, scopes: map({scope, version, projectPath})}) + ' <<<"$installed") + + jq -cn \ + --arg name "$name" \ + --argjson autoUpdate "$([[ "$auto_update" == "true" ]] && echo true || echo false)" \ + --arg lastUpdated "$last_updated" \ + --argjson catalog "$catalog" \ + --argjson installed "$installed" \ + --argjson enabled "$enabled_at_mp" \ + --argjson missingInstall "$missing_from_install" \ + --argjson missingEnabled "$missing_from_enabled" \ + --argjson divergences "$divergences" \ + '{ + marketplace: {name: $name, autoUpdate: $autoUpdate, lastUpdated: $lastUpdated}, + catalog: $catalog, + installed: $installed, + enabled: $enabled, + missing_from_install: $missingInstall, + missing_from_enabled: $missingEnabled, + divergences: $divergences + }' +} + +# --- Arg parsing ------------------------------------------------------------- + +MODE="default" +TARGET="" +while [[ $# -gt 0 ]]; do + case "$1" in + --marketplace) + MODE="single" + TARGET="${2:-}" + shift 2 + ;; + --all) + MODE="all" + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +case "$MODE" in +default) + TARGET=$(resolve_default_marketplace) + if [[ -z "$TARGET" ]]; then + echo "ERROR: could not resolve the default marketplace (this plugin's CLAUDE_PLUGIN_ROOT was not found in installed_plugins.json). Pass --marketplace explicitly." >&2 + exit 1 + fi + emit_marketplace "$TARGET" || exit 1 + ;; +single) + if [[ -z "$TARGET" ]]; then + echo "ERROR: --marketplace requires a name" >&2 + exit 2 + fi + emit_marketplace "$TARGET" || exit 1 + ;; +all) + names=$(jq -r 'keys[]' "$MARKETPLACES_JSON") + result='{}' + while IFS= read -r n; do + [[ -z "$n" ]] && continue + block=$(emit_marketplace "$n" || true) + result=$(jq -c --arg n "$n" --argjson b "$block" '. + {($n): $b}' <<<"$result") + done <<<"$names" + jq -cn --argjson m "$result" '{marketplaces: $m}' + ;; +*) + echo "ERROR: unreachable mode: $MODE" >&2 + exit 2 + ;; +esac diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh new file mode 100644 index 000000000..dfaed5341 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Black-box contract tests for fleet-state.sh (self-contained — ships with the plugin). +# Fixtures are built per-case into a temp dir via FLEET_STATE_* env overrides, +# mirroring the claude-config audit skill's SETTINGS_AUDIT_FIXTURE_DIR pattern. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/fleet-state.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + printf 'PASS: %s\n' "$1" +} +fail() { + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_eq() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected: $2, actual: $3"; fi +} +assert_exit() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected exit $2, got $3"; fi +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 — got: $2" ;; + esac +} + +if ! command -v jq >/dev/null 2>&1; then + echo "SKIP: jq not installed" >&2 + exit 0 +fi + +# --- Fixture builders -------------------------------------------------------- + +# CASE_NUM must be incremented in the CALLER's shell before calling this, +# never inside it: `case_dir=$(new_case_dir)` runs in a subshell, so a +# `CASE_NUM=...` assignment made in here is discarded when the subshell +# exits, silently leaving every case reuse case-1's directory. +new_case_dir() { + local case_dir="$TEST_TMPDIR/case-$CASE_NUM" + mkdir -p "$case_dir/catalog" + echo "$case_dir" +} + +write() { + local path="$1" json="$2" + printf '%s' "$json" >"$path" +} + +# Args: case_dir, marketplace name, run extra env (e.g. "CLAUDE_PROJECT_DIR=/x"). +# Always seeds a minimal installed_plugins.json / known_marketplaces.json / +# user settings.json unless the case already wrote its own. +run_state() { + local case_dir="$1" + shift + [[ -f "$case_dir/installed_plugins.json" ]] || write "$case_dir/installed_plugins.json" '{"version":1,"plugins":{}}' + [[ -f "$case_dir/known_marketplaces.json" ]] || write "$case_dir/known_marketplaces.json" '{}' + [[ -f "$case_dir/user_settings.json" ]] || write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' + env \ + FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + FLEET_STATE_CATALOG_DIR="$case_dir/catalog" \ + FLEET_STATE_HOOK_UTILS="$SCRIPT_DIR/../../../hooks/hook-utils.sh" \ + "$@" \ + bash "$SCRIPT" "${ARGS[@]}" 2>&1 +} + +# ============================================================================ +# Case: dual-scope divergence +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [ + {"scope": "project", "projectPath": "/sample-repo", "installPath": "x", "version": "0.1.0"}, + {"scope": "user", "installPath": "y", "version": "0.2.0"} + ] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "divergence: exit 0" 0 "$rc" +divergence_count=$(jq '.divergences | length' <<<"$out" 2>/dev/null) +assert_eq "divergence: one divergence entry" "1" "$divergence_count" +divergence_id=$(jq -r '.divergences[0].id' <<<"$out" 2>/dev/null) +assert_eq "divergence: correct id" "alpha@market1" "$divergence_id" +scope_count=$(jq '.divergences[0].scopes | length' <<<"$out" 2>/dev/null) +assert_eq "divergence: two scopes listed" "2" "$scope_count" + +# ============================================================================ +# Case: plugin missing from installs +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}, {"name": "beta"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing=$(jq -cS '.missing_from_install' <<<"$out" 2>/dev/null) +assert_eq "missing-install: both catalog plugins flagged" '["alpha@market1","beta@market1"]' "$missing" + +# ============================================================================ +# Case: plugin missing from enabledPlugins (never mentioned anywhere) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "y", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "missing-enabled: installed-but-never-mentioned flagged" '["alpha@market1"]' "$missing_enabled" + +# ============================================================================ +# Case: explicit enabledPlugins:false is an opt-out, NOT missing_from_enabled +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "y", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": false}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "opt-out: explicit false is not missing_from_enabled" '[]' "$missing_enabled" +enabled_value=$(jq -r '.enabled["alpha@market1"]' <<<"$out" 2>/dev/null) +assert_eq "opt-out: effective value reported as false, never flipped" "false" "$enabled_value" + +# ============================================================================ +# Case: marketplace absent from known_marketplaces.json +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +ARGS=(--marketplace ghost-market) +out=$(run_state "$case_dir") +rc=$? +assert_exit "absent marketplace: exit 1" 1 "$rc" +error_msg=$(jq -r '.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "absent marketplace: error field set" "not found in known_marketplaces.json" "$error_msg" + +# ============================================================================ +# Case: malformed/drifted installed_plugins.json shape — must fail loud +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{"unexpectedShape": true}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "malformed installed_plugins.json: exit 2" 2 "$rc" +assert_contains "malformed installed_plugins.json: error names the file" "$out" "installed_plugins.json" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '["not", "an", "object"]' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +rc=$? +assert_exit "malformed known_marketplaces.json: exit 2" 2 "$rc" +assert_contains "malformed known_marketplaces.json: error names the file" "$out" "known_marketplaces.json" + +# ============================================================================ +# Case: native-Windows projectPath vs Git Bash cwd — CRITICAL normalization +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/project-root" +mkdir -p "$project_dir/.claude" +native_project_path="$(cygpath -w "$project_dir" 2>/dev/null || echo "$project_dir")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$native_project_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, installPath: "x", version: "0.1.0"}]}}' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "windows-path: native backslash projectPath matches Git Bash cwd" "true" "$current_flag" + +# ============================================================================ +# Case: --all sweeps every marketplace; one absent-catalog failure does not +# abort the sweep +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{ + "market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}, + "market2": {"source": {"source": "github", "repo": "example/market2"}, "installLocation": "z2", "lastUpdated": "2026-01-01T00:00:00Z"} +}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +# market2 deliberately has no catalog fixture — simulates an unreachable marketplace. +ARGS=(--all) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--all: exits 0 even with one marketplace unreachable" 0 "$rc" +m1_catalog=$(jq -c '.marketplaces.market1.catalog' <<<"$out" 2>/dev/null) +assert_eq "--all: market1 resolved" '["alpha"]' "$m1_catalog" +m2_error=$(jq -r '.marketplaces.market2.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "--all: market2 reports its failure inline" "no catalog fixture" "$m2_error" + +# ============================================================================ +# Case: default marketplace resolved dynamically from CLAUDE_PLUGIN_ROOT +# (never hardcoded) — zero-arg invocation +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +fake_plugin_root="$case_dir/fake-plugin-root" +mkdir -p "$fake_plugin_root" +native_root="$(cygpath -w "$fake_plugin_root" 2>/dev/null || echo "$fake_plugin_root")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg root "$native_root" \ + '{version: 1, plugins: {"this-plugin@market1": [{scope: "user", installPath: $root, version: "0.1.0"}]}}' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "this-plugin"}]}' +ARGS=() +out=$(run_state "$case_dir" CLAUDE_PLUGIN_ROOT="$fake_plugin_root") +rc=$? +assert_exit "default marketplace: resolves via CLAUDE_PLUGIN_ROOT join" 0 "$rc" +resolved_name=$(jq -r '.marketplace.name' <<<"$out" 2>/dev/null) +assert_eq "default marketplace: correct name" "market1" "$resolved_name" + +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +ARGS=() +out=$(run_state "$case_dir" CLAUDE_PLUGIN_ROOT="$case_dir/nowhere-installed") +rc=$? +assert_exit "default marketplace: unresolvable root fails loud, doesn't guess" 1 "$rc" +assert_contains "default marketplace: names the fallback" "$out" "--marketplace" + +# ============================================================================ +# Case: jq missing — clear notice, not a bare command-not-found +# ============================================================================ +# Strip only jq's own directory out of PATH (rather than rebuilding PATH from +# scratch via symlinks) so bash, dirname, and every other tool the script +# needs stay resolvable — symlinking coreutils individually is unreliable on +# Windows without elevated rights. +real_jq=$(command -v jq) +jq_dir=$(dirname "$real_jq") +filtered_path=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vF "$jq_dir" | tr '\n' ':') +out=$(PATH="$filtered_path" bash "$SCRIPT" --marketplace market1 2>&1) +rc=$? +assert_exit "jq missing: exit 2" 2 "$rc" +assert_contains "jq missing: actionable notice" "$out" "jq required" + +# --- Summary ------------------------------------------------------------- +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] && exit 0 +exit 1 From 3ac3073cfdf29f1b448975a9fa42a07bf36127ce Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:58:18 -0400 Subject: [PATCH 04/23] fix(claude-ops): separate benign same-version multi-scope from real divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fleet-state.sh's divergences flagged every multi-scope install record as a conflict, but a project pin and a user pin at the identical version isn't one — it's a normal, non-actionable state. A raw record count inflates the report's "N behind, run converge" line with entries that need no action (2 of 31 on this machine). Add versionsMatch so the report layer can filter to the actionable subset (29 here) instead of the raw count. Co-Authored-By: Claude Sonnet 5 --- .../skills/plugins/scripts/fleet-state.sh | 10 ++++++- .../plugins/scripts/fleet-state.test.sh | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index c7cefcb35..e9e7377f1 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -233,11 +233,19 @@ emit_marketplace() { enabled_at_mp=$(jq -cn --argjson known "$known_at_mp" --argjson eff "$effective_map" \ 'reduce $known[] as $id ({}; . + {($id): $eff[$id]})') + # `versionsMatch` separates a benign multi-scope install (project and user + # scope both pinned to the same version — normal, not actionable) from a + # real version skew (some scope is behind another — the "run converge" + # signal). A record count alone conflates the two. local divergences divergences=$(jq -c ' group_by(.id) | map(select(length > 1)) - | map({id: .[0].id, scopes: map({scope, version, projectPath})}) + | map({ + id: .[0].id, + scopes: map({scope, version, projectPath}), + versionsMatch: ((map(.version) | unique | length) == 1) + }) ' <<<"$installed") jq -cn \ diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index dfaed5341..11ebf3cef 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -99,6 +99,32 @@ divergence_id=$(jq -r '.divergences[0].id' <<<"$out" 2>/dev/null) assert_eq "divergence: correct id" "alpha@market1" "$divergence_id" scope_count=$(jq '.divergences[0].scopes | length' <<<"$out" 2>/dev/null) assert_eq "divergence: two scopes listed" "2" "$scope_count" +versions_match=$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null) +assert_eq "divergence: differing versions flagged versionsMatch=false" "false" "$versions_match" + +# ============================================================================ +# Case: same-version multi-scope install is benign — NOT an actionable +# divergence, must not inflate the "N behind" report count +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [ + {"scope": "project", "projectPath": "/sample-repo", "installPath": "x", "version": "0.3.0"}, + {"scope": "user", "installPath": "y", "version": "0.3.0"} + ] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +same_version_match=$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null) +assert_eq "benign multi-scope: same version flagged versionsMatch=true" "true" "$same_version_match" +actionable_count=$(jq '[.divergences[] | select(.versionsMatch == false)] | length' <<<"$out" 2>/dev/null) +assert_eq "benign multi-scope: zero actionable (version-behind) divergences" "0" "$actionable_count" # ============================================================================ # Case: plugin missing from installs From f69f5ac8b816986ada209b9a1e960692da349ca3 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:11:11 -0400 Subject: [PATCH 05/23] feat(claude-ops): add plugins skill router, sync/converge algorithms, docs (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKILL.md action router (sync default / audit / converge) plus four context files: the sync algorithm (marketplace refresh, in-repo update, user-scope sweep, install-new per policy, enabledPlugins completeness), the converge scope-consolidation flow with autonomous-session abort, verified scope/version/reload semantics, and known failure modes. Also fixes fleet-state.sh's missing_from_install, which didn't yet exclude ids explicitly opted out (enabledPlugins: false) before ever being installed — found while grounding sync.md's "Missing" definition in the script's actual behavior. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 4 +- plugins/claude-ops/skills/plugins/SKILL.md | 130 ++++++++++++++++++ .../skills/plugins/context/converge.md | 71 ++++++++++ .../skills/plugins/context/gotchas.md | 61 ++++++++ .../skills/plugins/context/scope-semantics.md | 85 ++++++++++++ .../claude-ops/skills/plugins/context/sync.md | 96 +++++++++++++ .../skills/plugins/scripts/fleet-state.sh | 23 +++- .../plugins/scripts/fleet-state.test.sh | 15 ++ 8 files changed, 478 insertions(+), 7 deletions(-) create mode 100644 plugins/claude-ops/skills/plugins/SKILL.md create mode 100644 plugins/claude-ops/skills/plugins/context/converge.md create mode 100644 plugins/claude-ops/skills/plugins/context/gotchas.md create mode 100644 plugins/claude-ops/skills/plugins/context/scope-semantics.md create mode 100644 plugins/claude-ops/skills/plugins/context/sync.md diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 7bc282c0a..216467b82 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -94,7 +94,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Evidence:** `fleet-state.test.sh` — 11 cases / 24 assertions, 0 failed (dual-scope divergence, missing-from-install, missing-from-enabled, explicit-`false`-opt-out, marketplace-absent, malformed `installed_plugins.json` and `known_marketplaces.json` shape drift both fail loud at exit 2, native-Windows `projectPath` vs Git Bash cwd `currentProject` match, default-marketplace resolution via `CLAUDE_PLUGIN_ROOT` join including its fail-loud path, jq-missing notice). shellcheck clean on both files; bash-lint's PostToolUse formatter ran on every write, no outstanding diff. Live read-only run against this machine's real state (`--marketplace melodic-software`): 47 catalog plugins (not 46 — the plan's estimate predates `repo-fleet-hygiene` landing), 0 `missing_from_install`, 0 `missing_from_enabled`, 31 `divergences` (expected — dual-scope project/user pins are normal, not a defect). Two real bugs found and fixed during this phase: this host's `jq` build CRLF-terminates even compact single-line output, corrupting anything re-parsed via `--argjson` (fixed with a `jq() { command jq "$@" | tr -d '\r'; }` wrapper); the `enabled` reduce was missing `jq -n`, silently reading the script's own stdin instead of producing output. -### Phase 3: SKILL.md + context files [TODO] +### Phase 3: SKILL.md + context files [DONE] | File | Action | What changes | |------|--------|-------------| @@ -106,6 +106,8 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Sanity Check:** `grep -c '|`sync' SKILL.md` ≥ 1 and router table lists exactly sync/audit/converge; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` returns 0 normative occurrences (marketplace name always resolved dynamically); `grep -c 'CLAUDE_PLUGIN_ROOT' SKILL.md` ≥ 1; frontmatter contains `disable-model-invocation: true`. +**Evidence:** all four checks pass as written (sync row count 1, router table exactly 3 rows sync/audit/converge, 0 `melodic-software` occurrences, `CLAUDE_PLUGIN_ROOT` count 2, `disable-model-invocation: true` present). `markdownlint-cli2` clean on all 5 files. Frontmatter/router semantics fetched fresh from `code.claude.com/docs/en/skills` this session (2026-07-17) rather than assumed. `claude plugin marketplace update [name]` and per-plugin `claude plugin update -s ` / `install` / `enable` CLI syntax verified empirically against the installed CC 2.1.212 binary's own `--help` output, not just docs prose. One real gap found and fixed while writing `context/sync.md`: `fleet-state.sh`'s `missing_from_install` did not yet honor the Brief's "not explicitly `false` in any enabledPlugins scope" exclusion (only `missing_from_enabled` had it) — fixed, covered by a new test case, live-verified still 0/0 missing on this machine. A second gap found while writing `context/converge.md`: Brief Decision 6 names both version AND enable-state divergence as convergeable, but `fleet-state.sh` only exposes the merged effective `enabled` map, not raw per-scope values needed to detect a true/false split. Scoped V1 `converge` to version divergence only (satisfies the acceptance criteria's "drift repo" test, which is version-based) and documented the enable-state-mismatch gap explicitly in `context/converge.md` and `context/gotchas.md` as a named limitation rather than silently dropping or silently expanding `fleet-state.sh`'s output contract further. + ### Phase 4: Evals [TODO] | File | Action | What changes | diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md new file mode 100644 index 000000000..d69215679 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -0,0 +1,130 @@ +--- +name: plugins +description: "Bring a machine's plugin fleet current on demand: marketplace refresh, update the plugins that actually load (including in-repo project/local-scope installs), install new catalog plugins per policy, detect scope divergence, and surface (never silently fix) drift — with a terse actionable report. Actions: sync (default, mutating), audit (read-only dry run), converge (explicit scope consolidation). Use when: 'sync plugins', 'update my plugins', 'are my plugins current', 'check plugin drift', 'converge plugin scopes', or before relying on a plugin that might be stale." +argument-hint: "[action] [|all] — actions: sync (default), audit, converge" +user-invocable: true +disable-model-invocation: true +--- + +## Variables + +Arguments: `$ARGUMENTS` + +## Scope + +Guarantees that the plugins which actually load — for the machine's user scope, and for any repo +you're standing in with its own project/local-scope installs — are the latest published versions of +everything the marketplace offers, and surfaces any state where something older or unintended is +what really runs. + +Distinct from what Claude Code's own background `autoUpdate` does (see +[context/scope-semantics.md](context/scope-semantics.md)): `autoUpdate` silently refreshes marketplace +data and bumps already-installed plugins post-startup. It never installs a new catalog plugin, never +checks `enabledPlugins` completeness, never detects or reports scope divergence, and only runs once +per session start on its own schedule — not on demand. This skill covers exactly that gap. + +Distinct from `claude-config`'s `audit` skill's plugin-drift check: that check compares a project's +committed `enabledPlugins` against a marketplace's *upstream* `marketplace.json` (orphan/new/rename +plugin names). This skill compares the *local, already-installed* state (`installed_plugins.json`, +per-scope `enabledPlugins`) against the *local* marketplace catalog — a different axis (install/scope +completeness, not settings-vs-upstream drift). + +**Never silently fixes drift it finds.** `sync` mutates only via the documented CLI actions below; +`converge` is the one action that can touch a committed `.claude/settings.json`, and only after an +explicit per-plugin confirm. + +## Action Router + +Parse `$ARGUMENTS` for the action (first token) and an optional marketplace target (second token: +a marketplace name, or `all`). + +| Action | Mutates | Description | Detail | +|---|---|---|---| +| `sync` (default) | Yes — CLI only | Marketplace refresh → in-repo update → user-scope update sweep → install new per policy → enabledPlugins completeness → report | [context/sync.md](context/sync.md) | +| `audit` | No | Same algorithm as `sync`, every mutating step replaced with a prediction; issues zero mutating CLI calls | "Action: audit" below | +| `converge` | Yes — the one action that can touch committed settings | Detects any plugin id with an actionable (non-benign) scope divergence, previews per-plugin intent, confirms, executes, surfaces the resulting diff | [context/converge.md](context/converge.md) | + +Bare invocation (no arguments) → `sync` against the default marketplace. `help` or an unrecognized +action → show this table. + +## Marketplace resolution + +No hardcoded marketplace name anywhere in this skill. Every action resolves its target the same way: + +- No marketplace argument → the default: the marketplace this plugin (`claude-ops`) was itself + installed from, resolved dynamically by `fleet-state.sh` (joins `${CLAUDE_PLUGIN_ROOT}` against + `installed_plugins.json`'s install records — never a hardcoded name). +- `` argument → that marketplace only. +- `all` argument → every marketplace in `known_marketplaces.json`; per-marketplace failures are + reported inline and never abort the sweep (see [context/sync.md](context/sync.md)). + +## State inspection + +Every action starts by calling the bundled read-only script — never hand-parse the internal JSON +files directly, and never write them: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/scripts/fleet-state.sh [--marketplace | --all] +``` + +Read [context/scope-semantics.md](context/scope-semantics.md) before interpreting its output — in +particular, `divergences[].versionsMatch` separates a benign same-version multi-scope install +(normal, no action) from a real version skew (the actionable "run converge" signal); a raw +divergence-record count conflates the two and overstates the report. + +## Action: audit + +Read-only dry run of what `sync` (and, where relevant, `converge`) would do. Run the full algorithm +in [context/sync.md](context/sync.md) with every mutating CLI call replaced by "would run: ``" +in the report — call `fleet-state.sh`, compute the same install/enable/divergence deltas, but issue +**zero** `plugin install|update|uninstall|marketplace update` invocations. Predict `converge`'s +per-plugin intent (context/converge.md's preview step) the same way, without executing it. State-file +contents (`installed_plugins.json`, `known_marketplaces.json`, committed settings) are unchanged by +an `audit` run, modulo any concurrent session or background `autoUpdate` sweep — note that caveat in +the report rather than asserting byte-identical files. + +## Report + +Terse, fixed sections. Detail only where action is required — do not enumerate rows that need no +action. + +```text +Marketplace: (autoUpdate: ) +Updated: plugin(s) — @: (only when N > 0) +Installed: new catalog plugin(s) — @ (only when N > 0; per install_new policy) +Divergences: project-scope install(s) behind user scope → run `/claude-ops:plugins converge` + (N = actionable only — versionsMatch:false; same-version multi-scope installs are not counted + or listed here) +Action needed: (omit section entirely when empty) +``` + +When running inside a project (`CLAUDE_PROJECT_DIR` set and `fleet-state.sh`'s `installed[]` entries +carry `currentProject: true`), lead the Divergences line with *this* project's actionable count and +fold the rest of the machine into one trailing clause — e.g. `2 behind here → converge; 27 more +elsewhere on this machine`. Per-row detail (naming exact `` versions per repo) is +reserved for genuine conflicts: an enable-state mismatch (`true` in one scope, `false` in another), +an unknown/orphaned plugin id, or a CLI call that failed — never for the routine bulk case. + +Close with reload guidance: recommend bare `/reload-plugins` (no `--force` — verified: no such flag +exists). If any updated component includes a monitor, call that out separately — monitors need a +full session restart, `/reload-plugins` doesn't cover them (see +[context/scope-semantics.md](context/scope-semantics.md)). + +## userConfig: `install_new` + +Controls new-catalog-plugin install policy during `sync`. Ships as a plain `string` (the manifest +schema has no `enum` type — verified against the published schema), default `"ask"`: + +- `ask` (default) — offer every not-yet-installed catalog plugin in one batched multi-select prompt +- `all` — install every not-yet-installed catalog plugin automatically +- `none` — report them in "Action needed" only, never install + +Any other value is invalid; treat it as `ask` and note the invalid value in the report. + +## Cross-references + +- [context/sync.md](context/sync.md) — full `sync` algorithm +- [context/converge.md](context/converge.md) — scope-consolidation flow, confirm gate, autonomous-session abort +- [context/scope-semantics.md](context/scope-semantics.md) — verified CC scope/version/reload facts this skill depends on +- [context/gotchas.md](context/gotchas.md) — known failure modes and how this skill avoids them diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md new file mode 100644 index 000000000..3832c87b3 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -0,0 +1,71 @@ +# Converge — explicit scope consolidation + +`converge` is the **only** action that can touch a committed `.claude/settings.json`. It never runs +implicitly from `sync` — `sync`'s report only names the `converge` command; the user runs it +explicitly. + +## Autonomous-session abort (run this check FIRST, before any preview work) + +`converge` is destructive-tier (it can uninstall a scoped plugin install and rewrite committed +settings). Per this repo's existing convention (`repo-hygiene`'s `clean` skill, preflight §1.5): +**abort immediately** when the session is autonomous — `CLAUDE_CODE_REMOTE` set, or the invocation +arrived via `/loop` or `/schedule` — since no human is present to receive an `AskUserQuestion` +confirm. Fail closed when the context is genuinely ambiguous (uncertain whether a human is present): +treat it as autonomous and abort. Report why, and that `converge` can be re-run interactively. + +## V1 scope: version divergence only + +`converge` resolves entries in `fleet-state.sh`'s `divergences[]` with `versionsMatch: false` — +scopes disagree on version. It does **not** currently resolve an enable-state mismatch (a plugin +`true` in one scope's `enabledPlugins` and `false` in another) — that needs comparing each scope's +*raw* `enabledPlugins` map, which `fleet-state.sh` doesn't expose today (only the merged effective +value, in `enabled`). Report an enable-state mismatch under "Action needed" as a **known +limitation — resolve manually** rather than silently skipping it or claiming `converge` handles it. + +## Step 1 — Detect + +Call `fleet-state.sh` (default marketplace, named one, or the current invocation's target) and take +`divergences[]` filtered to `versionsMatch: false`. + +## Step 2 — Preview per-plugin intent + +For each actionable divergence, decide the consolidation strategy from its `scopes[]`: + +- **A `user`-scope entry exists** → the default strategy is to make the *project/local* scope + fall through to it: `claude plugin uninstall -s project` (or `-s local`) removes the + redundant lower-precedence pin, and scope precedence (local > project > user) means the project + now loads whatever `user` scope has — always current from here on without a standing project pin. +- **No `user`-scope entry** (only multiple `project`/`local`-scope pins across different repos, no + user baseline) → the default strategy is to bring the lagging scope(s) up to the newest version + present: `claude plugin update -s `. + +Present every plugin's proposed strategy and exact CLI command(s) before running anything — do not +batch-apply. Per Brief Decision 6 (V1): confirm **every** pin individually, even when many plugins +share the same strategy — do not infer consent from one confirm to the next. + +## Step 3 — Confirm + +Use `AskUserQuestion` per plugin (or a clearly-enumerated batch the user can approve/override/skip +per row — never a single blanket "yes to all"). Options per plugin: apply the proposed strategy, +choose the other strategy, or skip this one. + +## Step 4 — Execute + +Run only the confirmed commands, one plugin at a time. Re-read `fleet-state.sh` state immediately +before each mutation (per `sync.md`'s concurrency note) — do not act on a snapshot taken during +Step 1 if meaningful time has passed or another mutation already landed. + +## Step 5 — Surface the resulting diff + +After all confirmed mutations run, `git diff` (or the equivalent status check) any project's +committed `.claude/settings.json` that `-s project` mutations may have touched. Per +[scope-semantics.md](scope-semantics.md), a plain `claude plugin update -s project` does **not** +write committed settings — but `claude plugin uninstall -s project` (this action's actual mechanism) +can remove an `enabledPlugins` entry from it. Show the diff; never commit it. The user reviews and +commits (or discards) it through their own normal git workflow. + +## Non-interactive execution + +Any `uninstall` needs `-y` when stdin/stdout isn't a TTY (required by the CLI itself). This only +applies once Step 3's confirm has already been obtained through the interactive flow above — never +add `-y` to bypass Step 3's per-plugin confirm. diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md new file mode 100644 index 000000000..1d76910b7 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -0,0 +1,61 @@ +# Gotchas + +Failure modes this skill is specifically built to avoid, and what breaks if the safeguard is +bypassed. Underlying facts are in [scope-semantics.md](scope-semantics.md) — this file is the +"here's what goes wrong" companion, not a restatement. + +## Trusting `plugin list` / `plugin details` for "what's loaded here" + +Both show the highest installed version across every scope, not the cwd-effective one. Reporting a +plugin as "current" based on their output can be wrong for any project with its own project/local +scope pin. Always derive effective-version claims from `fleet-state.sh`'s `currentProject` flag and +scope precedence, never from `list`/`details` text. + +## Native-Windows `projectPath` vs Git Bash `$PWD` + +`installed_plugins.json` stores `projectPath` in native Windows form (`D:\repos\...`); a Bash-tool +`$PWD` reads POSIX form (`/d/repos/...`). A naive string-equality check between the two silently +never matches on Windows — the in-repo detection this skill's primary value depends on (Step 2 of +`sync.md`) would quietly no-op, and nobody would notice because the *rest* of sync (marketplace +refresh, user-scope sweep) still runs and still produces *a* report. `fleet-state.sh` avoids this by +routing both sides through `hook::normalize_path` (from the plugin's own `hooks/hook-utils.sh` +copy) before comparing — empirically verified to fold both representations to the identical +canonical string. Never hand-roll a separate path comparison anywhere else in this skill; always go +through the `currentProject` field `fleet-state.sh` already computed. + +## Concurrency / TOCTOU + +`fleet-state.sh`'s output is a snapshot. A background `autoUpdate` sweep (random delay up to ten +minutes after session start) or a concurrent Claude Code session can mutate installed/enabled state +between when you read it and when you act on it. Re-read state immediately before each mutating +step (`sync.md`'s "Concurrency" section, `converge.md` Step 4) rather than driving a whole multi-step +sync off one snapshot taken at the start. When a mutation's actual result doesn't match what the +snapshot predicted, that's this race — note it in the report, don't treat it as a bug to chase. + +## Dual-scope divergence is normal, not a defect + +A project pinning an older version at `project` scope while your personal `user` scope has moved on +is expected, common, and not itself something to "fix" silently. `fleet-state.sh`'s `versionsMatch` +field is what separates that benign case from a real, actionable version skew — see +scope-semantics.md. Never report a raw `divergences[].length` count; always filter to +`versionsMatch == false` first, or the report overstates drift with entries that need no action. + +## Internal-schema drift — fail loud, never guess + +`installed_plugins.json` and `known_marketplaces.json` are Claude Code's *internal* state — not a +published, versioned contract. `fleet-state.sh` validates their top-level shape +(`{plugins: {...}}` / a JSON object) before trusting them, and exits 2 with a clear message on a +mismatch rather than silently emitting an empty or wrong report. If a future Claude Code version +changes this shape, that exit-2 failure is the signal to re-verify against a live install (not +training-data recall) and update the parser — never widen the shape check to "whatever doesn't +crash the script." + +## This host's `jq` build CRLF-terminates its output + +Discovered empirically while implementing `fleet-state.sh` (Windows/MSYS `jq`): even single-line +compact JSON output ends `\r\n`, not just `\n`. `$(...)` command substitution strips only the +trailing `\n`, so a stray `\r` survives at the end of a captured value and corrupts it once +re-embedded in another `--argjson` argument (`jq: invalid JSON text passed to --argjson`). Any new +script in this skill that shells out to `jq` and captures its output should route every call through +the same `jq() { command jq "$@" | tr -d '\r'; }`-style wrapper `fleet-state.sh` already uses — +don't rediscover this the hard way in a second script. diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md new file mode 100644 index 000000000..27665eea0 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -0,0 +1,85 @@ +# Scope semantics — verified facts this skill depends on + +Every claim below was verified against a fetched official-docs page or an empirical test on a real +machine during this skill's implementation, not assumed from training data. Re-verify against +`code.claude.com/docs/en/plugins-reference` and `discover-plugins` if Claude Code's plugin CLI +changes shape. + +## Scope-by-cwd loading + +A plugin loads for a given working directory using scope precedence **local > project > user**: + +| Scope | Settings file | Written by | +|---|---|---| +| `user` | `~/.claude/settings.json` | `claude plugin install\|update -s user` (default scope) | +| `project` | `/.claude/settings.json` | `claude plugin install\|update -s project` — committed, team-shared | +| `local` | `/.claude/settings.local.json` | `claude plugin install\|update -s local` — gitignored, personal | +| `managed` | Managed settings (enterprise) | Administrators only — this skill reports, never mutates | + +`installed_plugins.json` (`~/.claude/plugins/installed_plugins.json`) can hold multiple install +records for the same `@` id — one per scope, each with its own `version`. This +is normal, not a defect: a project can legitimately pin a different version than your personal user +scope. The record that actually loads for a given directory is the one at the *highest-precedence +scope present*, never simply "the newest version installed." + +## Divergence is not automatically actionable + +`fleet-state.sh`'s `divergences[]` lists every plugin id with more than one scope record, but tags +each with `versionsMatch`: + +- `versionsMatch: true` — every scope pins the identical version. Benign, informational only. Do + not count these toward a report's "N behind" line or list them under "Action needed." +- `versionsMatch: false` — scopes disagree on version. This is the actionable signal `sync`'s report + surfaces and `converge` resolves. + +A raw count of `divergences[].length` conflates the two and overstates drift — always filter on +`versionsMatch == false` before presenting a count to the user. + +## `plugin list` / `plugin details` version output is misleading + +**Verified misleading**: `claude plugin list` and `claude plugin details ` show the *highest +installed version across all scopes*, not the version actually loaded for the current working +directory. Never treat their output as "what's running here." Derive effective-version claims from +`fleet-state.sh`'s scope-by-cwd resolution (via `currentProject` + scope precedence) or a functional +probe — never from `list`/`details` text. + +## `plugin update -s project` does NOT write committed settings + +**Empirically verified** (hash-compared a real repo's `.claude/settings.json` and +`.claude/settings.local.json` before and after): `claude plugin update -s project` updates only +the machine-local `installed_plugins.json` record. `enabledPlugins` carries no version — the +committed settings files are untouched by an update. `sync`'s in-repo update step is safe to run +without a settings-diff review; `converge`'s scope-*consolidation* is the one action that can add or +remove an `enabledPlugins` entry, and only that action surfaces a settings diff. + +## No `--force` flag on `/reload-plugins` + +**Verified against `code.claude.com/docs/en/plugins-reference`**: no `--force` variant exists. +`/reload-plugins` alone refreshes skills, agents, hooks, MCP, and LSP servers in-process. It does +**not** cover monitors — a monitor requires a full session restart. Recommend bare `/reload-plugins` +in every report; call out the restart requirement only when an updated plugin ships a monitor. + +## `userConfig` has no `enum` field + +**Verified against the published plugin-manifest JSON Schema**: allowed `type` values are `string`, +`number`, `boolean`, `directory`, `file` — no `enum`. `install_new` ships as `type: string` with its +valid values (`ask`/`all`/`none`) documented in `description` and validated in prose by this skill, +not by the manifest schema. + +## Renames are CC-native (≥ v2.1.193) + +Claude Code rewrites a marketplace's `renames` map into installed/enabled state automatically at +session start (old id → new id; `null` means removal). This skill hard-codes no rename knowledge — +its only rename-adjacent behavior is that anything present in the current catalog but absent from +`installed_plugins.json` shows up as `missing_from_install`, which naturally covers a renamed +plugin's new id. `claude plugin prune` requires ≥ v2.1.121; renames mapping requires ≥ v2.1.193. + +## `autoUpdate` is a background complement, not a substitute + +Official-Anthropic marketplaces default `autoUpdate: true`; third-party and local-dev marketplaces +default it off (absent from `known_marketplaces.json`, not `false`). When on, Claude Code refreshes +marketplace data and bumps already-installed plugins once per session start, after a random delay of +up to ten minutes. This skill never mutates the setting — it only reports the marketplace's current +`autoUpdate` state and suggests enabling it when off, since it never overlaps with what this skill +covers (new-plugin install, `enabledPlugins` completeness, divergence detection/convergence, +deterministic on-demand execution). diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md new file mode 100644 index 000000000..40773fc14 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -0,0 +1,96 @@ +# Sync algorithm + +`sync` is the default action: bring the effective fleet current where you stand. Every step below +is CLI-mediated — never edit `installed_plugins.json`, `known_marketplaces.json`, or any +`.claude/settings*.json` directly. `audit` runs this same sequence with every mutating call replaced +by a prediction (see SKILL.md's "Action: audit"). + +## Concurrency + +The `claude plugin` CLI is the serialization point — there is no separate lock this skill manages. +Re-read state (re-run `fleet-state.sh`) immediately before each mutating step rather than mutating +off a snapshot taken several steps ago; a background `autoUpdate` sweep or a concurrent session can +change installed/enabled state between steps. Note in the report when a mutation's outcome doesn't +match what the pre-mutation snapshot predicted — that's this race, not a bug. + +## Step 1 — Marketplace refresh + +For each target marketplace (the resolved default, the named one, or every marketplace when the +argument is `all`): + +```bash +claude plugin marketplace update +``` + +Self-heals a stale or corrupt local clone by re-fetching from the marketplace's registered source +(per Brief Decision 4 — no manual re-clone or cache surgery). In `all` mode, loop this per +marketplace name (rather than the bulk no-argument form) so a single marketplace's failure is +attributable and reported inline without aborting the sweep for the rest. + +## Step 2 — In-repo update (the primary value path) + +Only when `CLAUDE_PROJECT_DIR` is set (you're standing inside a project). Call `fleet-state.sh` and +look at `installed[]` entries with `currentProject: true`: + +```bash +claude plugin update -s project # for a currentProject:true entry with scope "project" +claude plugin update -s local # for a currentProject:true entry with scope "local" +``` + +Run this **only** for entries whose id also appears in `divergences[]` with `versionsMatch: false` — +a `currentProject: true` entry with no divergence is already current, nothing to do. Verified safe: +`plugin update -s project` does not write the committed `.claude/settings.json` (see +[scope-semantics.md](scope-semantics.md)) — no settings-diff review needed for this step, unlike +`converge`. + +## Step 3 — User-scope update sweep + +For every catalog plugin id currently installed at `user` scope (from `fleet-state.sh`'s +`installed[]`, `scope == "user"`), run: + +```bash +claude plugin update -s user +``` + +One call per plugin — `claude plugin update` takes a single `` argument, there is no bulk +"update everything" flag. Loop it; a single plugin's update failure is reported inline (under +"Action needed") and does not abort the sweep for the rest. + +## Step 4 — Install new catalog plugins (per `install_new` policy) + +Take `fleet-state.sh`'s `missing_from_install` (already excludes anything explicitly opted out with +`enabledPlugins: false` in any scope — never re-offer a deliberate decline). Apply the +`install_new` userConfig value: + +- **`ask`** (default) — present every entry in one batched `AskUserQuestion` multi-select, then + `claude plugin install -s user` for each the user picks +- **`all`** — `claude plugin install -s user` for every entry, no prompt +- **`none`** — install nothing; list the entries under "Action needed" in the report only + +**Caveat (document, don't silently absorb):** with `install_new: all`, a catalog plugin that's +installed and then *disabled* (not uninstalled — `enabledPlugins: false` still recorded, install +record still present) is correctly excluded (it's not in `missing_from_install`, it's an installed, +opted-out plugin). But a plugin that's *uninstalled entirely* without ever setting `false` reappears +in `missing_from_install` on the very next sync and gets reinstalled — `install_new: all` has no +memory of "I removed this on purpose." If that's not the intent, uninstall AND disable +(`enabledPlugins: false`), or switch the policy to `ask`/`none`. + +## Step 5 — `enabledPlugins` completeness + +Take `fleet-state.sh`'s `missing_from_enabled` — ids installed somewhere but never mentioned (true +or false) in any scope's `enabledPlugins`. For each, and for each scope where that id has an install +record (from `installed[]`) but no raw entry in that scope's own `enabledPlugins` map: + +```bash +claude plugin enable -s +``` + +Never touches an id that has an explicit entry anywhere (true — already enabled, nothing to do; or +false — deliberate opt-out, never flipped). This step only fills a genuine gap: installed but never +recorded either way. + +## Step 6 — Report + +Emit the report per SKILL.md's "Report" section. End with reload guidance: bare `/reload-plugins` +(verified — no `--force` flag exists); call out a session restart separately only when an updated +component ships a monitor (monitors aren't covered by `/reload-plugins`). diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index e9e7377f1..947b60223 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -129,6 +129,13 @@ known_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson effective_map=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ '$u + $p + $l') +# ids explicitly set to false in ANY scope — a deliberate opt-out, even for a +# plugin never installed at all (e.g. a team pre-declares "we're not using +# this" in project settings before anyone runs install). "Missing" (needing +# an install prompt) excludes these; sync never installs over an opt-out. +explicit_false_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" --argjson l "$local_map" \ + '[($u, $p, $l) | to_entries[] | select(.value == false) | .key] | unique') + # --- Normalized current-project root, for the `currentProject` install flag current_project_norm="" if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then @@ -190,7 +197,7 @@ emit_marketplace() { catalog=$(jq -c '[.plugins[]?.name // empty] | unique' "$catalog_json") local catalog_ids - catalog_ids=$(jq -r --arg mp "$name" '.[] | . + "@" + $mp' <<<"$catalog") + catalog_ids=$(jq -c --arg mp "$name" '[.[] | . + "@" + $mp]' <<<"$catalog") # Every install record for ids in this marketplace, flattened, with the # currentProject flag Windows-normalized on both sides. @@ -215,18 +222,22 @@ emit_marketplace() { ' "$INSTALLED_JSON" | jq -cs '.') local installed_ids - installed_ids=$(jq -r '[.[].id] | unique | .[]' <<<"$installed") + installed_ids=$(jq -c '[.[].id] | unique' <<<"$installed") + # catalog minus installed minus any id explicitly opted out (false) in any + # scope, even one never installed at all. local missing_from_install - missing_from_install=$(comm -23 \ - <(sort -u <<<"$catalog_ids") \ - <(sort -u <<<"${installed_ids:-}") | grep -v '^$' | jq -R . | jq -sc .) + missing_from_install=$(jq -cn \ + --argjson catalog "$catalog_ids" \ + --argjson installed "$installed_ids" \ + --argjson falseIds "$explicit_false_ids" \ + '($catalog - $installed) - $falseIds') local known_at_mp known_at_mp=$(jq -c --arg suffix "@$name" '[.[] | select(endswith($suffix))]' <<<"$known_ids") local missing_from_enabled - missing_from_enabled=$(jq -cn --argjson installed_ids "$(jq -c '[.[].id] | unique' <<<"$installed")" --argjson known "$known_at_mp" \ + missing_from_enabled=$(jq -cn --argjson installed_ids "$installed_ids" --argjson known "$known_at_mp" \ '$installed_ids - $known') local enabled_at_mp diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 11ebf3cef..9d18cc1ae 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -126,6 +126,21 @@ assert_eq "benign multi-scope: same version flagged versionsMatch=true" "true" " actionable_count=$(jq '[.divergences[] | select(.versionsMatch == false)] | length' <<<"$out" 2>/dev/null) assert_eq "benign multi-scope: zero actionable (version-behind) divergences" "0" "$actionable_count" +# ============================================================================ +# Case: explicit enabledPlugins:false opt-out on a NEVER-INSTALLED plugin +# excludes it from missing_from_install too — sync must not offer to install +# something explicitly declined, even pre-install +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}, {"name": "beta"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": false}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_install=$(jq -cS '.missing_from_install' <<<"$out" 2>/dev/null) +assert_eq "opt-out before install: excluded from missing_from_install" '["beta@market1"]' "$missing_install" + # ============================================================================ # Case: plugin missing from installs # ============================================================================ From eebdbdca23a0757c029c732cf2d777e8d5f647ef Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:15:48 -0400 Subject: [PATCH 06/23] test(claude-ops): add evals for the plugins skill (phase 4) 6 cases covering routing (bare invocation -> sync), audit's read-only guarantee, install_new=ask batched-prompt behavior, converge's autonomous-session abort, the divergence report's bulk-collapse vs per-row-conflict shape, and the never-hand-edit-internal-state anti-pattern. Passes check-skill.sh with 0 errors/0 warnings. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 4 +- .../skills/plugins/evals/evals.json | 77 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 plugins/claude-ops/skills/plugins/evals/evals.json diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 216467b82..77aea7b68 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -108,7 +108,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Evidence:** all four checks pass as written (sync row count 1, router table exactly 3 rows sync/audit/converge, 0 `melodic-software` occurrences, `CLAUDE_PLUGIN_ROOT` count 2, `disable-model-invocation: true` present). `markdownlint-cli2` clean on all 5 files. Frontmatter/router semantics fetched fresh from `code.claude.com/docs/en/skills` this session (2026-07-17) rather than assumed. `claude plugin marketplace update [name]` and per-plugin `claude plugin update -s ` / `install` / `enable` CLI syntax verified empirically against the installed CC 2.1.212 binary's own `--help` output, not just docs prose. One real gap found and fixed while writing `context/sync.md`: `fleet-state.sh`'s `missing_from_install` did not yet honor the Brief's "not explicitly `false` in any enabledPlugins scope" exclusion (only `missing_from_enabled` had it) — fixed, covered by a new test case, live-verified still 0/0 missing on this machine. A second gap found while writing `context/converge.md`: Brief Decision 6 names both version AND enable-state divergence as convergeable, but `fleet-state.sh` only exposes the merged effective `enabled` map, not raw per-scope values needed to detect a true/false split. Scoped V1 `converge` to version divergence only (satisfies the acceptance criteria's "drift repo" test, which is version-based) and documented the enable-state-mismatch gap explicitly in `context/converge.md` and `context/gotchas.md` as a named limitation rather than silently dropping or silently expanding `fleet-state.sh`'s output contract further. -### Phase 4: Evals [TODO] +### Phase 4: Evals [DONE] | File | Action | What changes | |------|--------|-------------| @@ -116,6 +116,8 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Sanity Check:** `bash plugins/skill-quality/scripts/check-skill.sh plugins/claude-ops/skills/plugins` reports zero WARN across ALL checks (gotchas surface satisfies Check 11); `jq '.evals | length' evals.json` ≥ 5. +**Evidence:** `check-skill.sh` doesn't actually accept a full path as its argument (its skills-root resolution expects `/`, defaulting to `${CLAUDE_PROJECT_DIR}/.claude/skills`) — the plan's literal invocation string doesn't run as written. Ran the equivalent correctly via `CHECK_SKILL_SKILLS_ROOT="plugins/claude-ops/skills" bash plugins/skill-quality/scripts/check-skill.sh plugins`: `CHECK-SKILL plugins: PASS — 0 errors, 0 warning(s)` (description length 573/1536, all 5 trigger phrases preserved, SKILL.md 130/500 lines, markdownlint clean, `scripts/fleet-state.test.sh` passed as part of the run). `jq '.evals | length'` = 6. + ### Phase 5: Plugin metadata [TODO] | File | Action | What changes | diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json new file mode 100644 index 000000000..d966b78a9 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -0,0 +1,77 @@ +{ + "skill_name": "plugins", + "evals": [ + { + "id": 1, + "name": "bare-invocation-routes-to-sync", + "prompt": "/claude-ops:plugins", + "expected_output": "Bare invocation (no action argument) routes to the default action, sync, against the dynamically-resolved default marketplace. The response calls fleet-state.sh, runs the sync algorithm (marketplace refresh, in-repo update when applicable, user-scope sweep, install-new per policy, enabledPlugins completeness), and ends with a terse fixed-section report plus reload guidance.", + "files": [], + "expectations": [ + "The response treats the bare invocation as the sync action, not audit or converge", + "The marketplace target is resolved dynamically (via fleet-state.sh's CLAUDE_PLUGIN_ROOT join) rather than a hardcoded marketplace name", + "The response ends with a terse fixed-section report and bare `/reload-plugins` guidance (no `--force`)" + ] + }, + { + "id": 2, + "name": "audit-issues-zero-mutating-calls", + "prompt": "/claude-ops:plugins audit", + "expected_output": "audit runs the same algorithm as sync but every mutating step becomes a prediction — no `claude plugin install|update|uninstall|marketplace update` command is ever run. The response calls fleet-state.sh (read-only) and reports what sync/converge would do without doing it.", + "files": [], + "expectations": [ + "The response issues zero `claude plugin install|update|uninstall` or `claude plugin marketplace update` invocations", + "The response still calls the read-only fleet-state.sh script to compute state", + "The response frames its output as a prediction of what `sync` (and, where relevant, `converge`) would do, not as work already performed" + ] + }, + { + "id": 3, + "name": "install-new-ask-offers-does-not-auto-install", + "prompt": "Sync my plugins — I have `install_new` set to `ask` and there are 2 new catalog plugins not yet installed.", + "expected_output": "With install_new: ask (the default), new catalog plugins are offered in one batched multi-select prompt (e.g. via AskUserQuestion) rather than installed automatically. No `claude plugin install` runs for the new plugins until the user responds to the prompt.", + "files": [], + "expectations": [ + "The response presents the new catalog plugins as a batched choice for the user to select from, not as already installed", + "No `claude plugin install` command runs for any new plugin before the user's selection is obtained", + "The prompt is a single batched multi-select, not one separate confirmation per plugin" + ] + }, + { + "id": 4, + "name": "converge-aborts-in-autonomous-session", + "prompt": "[Autonomous/background session context: CLAUDE_CODE_REMOTE is set] /claude-ops:plugins converge", + "expected_output": "converge is destructive-tier (can uninstall a scoped install and touch committed settings) and detects the autonomous session context. It aborts before previewing or executing any consolidation, explains why (no human present to confirm), and suggests re-running converge interactively.", + "files": [], + "expectations": [ + "The response aborts converge without executing or previewing any `claude plugin uninstall`/`update` consolidation command", + "The response names the autonomous-session detection (e.g. CLAUDE_CODE_REMOTE) as the reason for the abort", + "The response does not silently fall back to auto-applying a default strategy for every divergence" + ] + }, + { + "id": 5, + "name": "divergence-report-collapses-bulk-names-conflicts", + "prompt": "Audit my plugins — I have 29 plugins with an actionable version divergence between project and user scope, plus 2 more that are multi-scope but pinned to the identical version, and one plugin with an enable-state mismatch (true in user scope, false in project scope).", + "expected_output": "The report's Divergences line collapses the 29 actionable (version-behind) entries into one bulk count and names `/claude-ops:plugins converge` as the fix — it does NOT list all 29 individually, and it does NOT count the 2 same-version multi-scope installs as actionable (versionsMatch: true is benign). The one enable-state mismatch gets its own per-row detail line under Action needed as a genuine conflict, distinct from the bulk divergence count, with the live-vs-inactive scope values named.", + "files": [], + "expectations": [ + "The 29 actionable divergences are collapsed into a single bulk-count line, not enumerated one by one", + "The 2 same-version (versionsMatch: true) multi-scope installs are excluded from the actionable divergence count", + "The enable-state mismatch gets an individual per-row detail line naming which scope is live (true) and which is inactive (false), separate from the bulk divergence line" + ] + }, + { + "id": 6, + "name": "never-hand-edits-internal-state-or-cache", + "prompt": "Sync is reporting a divergence for plugin-x that looks wrong — can you just fix its entry in installed_plugins.json directly, or delete its cache directory to force a clean reinstall?", + "expected_output": "The skill refuses to hand-edit installed_plugins.json, known_marketplaces.json, or any cache version directory — these are internal Claude Code state, read-only from this skill's perspective. It explains that all state changes go through the `claude plugin` CLI (install/update/uninstall/enable/disable), and offers the appropriate CLI command instead (e.g. `claude plugin update plugin-x@`), or `converge` if the actual issue is scope divergence.", + "files": [], + "expectations": [ + "The response does not edit installed_plugins.json, known_marketplaces.json, or any plugins/cache directory", + "The response does not delete or otherwise manipulate a plugin's cache version directory", + "The response redirects to the appropriate `claude plugin` CLI command (or the converge action) instead of manual state surgery" + ] + } + ] +} From 93eee1a45cccc0454ae32a8bac495232c111f65e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:21:38 -0400 Subject: [PATCH 07/23] chore(claude-ops): bump to 0.9.0 for the plugins skill (phase 5) version, description, CHANGELOG, README skills table + configuration entry for the new plugins skill and its install_new userConfig option. Regenerates the repo-root README's plugin catalog block, which went stale against the updated claude-ops description. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 +- docs/topics/plugin-fleet-sync-skill/PLAN.md | 4 +++- plugins/claude-ops/.claude-plugin/plugin.json | 11 ++++++++--- plugins/claude-ops/CHANGELOG.md | 15 +++++++++++++++ plugins/claude-ops/README.md | 17 ++++++++++++----- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c6ddb6834..0e84f3c48 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Browse and manage with `/plugin`. To refresh after updates: `/plugin marketplace - [`playbooks`](plugins/playbooks) — Doctrine and knowledge playbooks as on-demand skills, plus a maintainer-facing update skill. boris — Boris Cherny's Claude Code workflow tips (howborisusesclaudecode.com); thariq — Anthropic's internal skill-authoring playbook; fable-5 — Claude Fable 5's operating doctrine (self-authored, no upstream). The boris and thariq packs vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs those baselines centrally (maintainers). - [`claude-config`](plugins/claude-config) — Three audit skills for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), automation-gaps (evidence-gated verdicts on automation gaps), and permission-hygiene (allow-rule / allowed-tools grants for auto-mode durability and portability). - [`claude-memory`](plugins/claude-memory) — Audits the Claude Code instruction/memory layer — CLAUDE.md, CLAUDE.local.md, .claude/rules/, and auto-memory — against a checklist derived from official Claude Code documentation. A deterministic script-backed spine (MEMORY.md index integrity, orphan always-loaded rules) yields identical findings on identical repo state; judgment-tier checks apply fixed criteria with model reading. Actions: audit (default), fix (per-item approval), update (refresh criteria from current docs), report. -- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Four skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. +- [`claude-ops`](plugins/claude-ops) — Claude Code operations toolkit. Five skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads. - [`skill-quality`](plugins/skill-quality) — Skill-authoring QA tooling: a static contract checker that runs seventeen deterministic checks over a Claude Code skill (frontmatter, listing-budget cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence) and a bundled evals.json schema for validation. Runs against any repo's skills directory via the convention-resolution ladder — no baked layout. ### Security diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 77aea7b68..aeaa413c6 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -118,7 +118,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Evidence:** `check-skill.sh` doesn't actually accept a full path as its argument (its skills-root resolution expects `/`, defaulting to `${CLAUDE_PROJECT_DIR}/.claude/skills`) — the plan's literal invocation string doesn't run as written. Ran the equivalent correctly via `CHECK_SKILL_SKILLS_ROOT="plugins/claude-ops/skills" bash plugins/skill-quality/scripts/check-skill.sh plugins`: `CHECK-SKILL plugins: PASS — 0 errors, 0 warning(s)` (description length 573/1536, all 5 trigger phrases preserved, SKILL.md 130/500 lines, markdownlint clean, `scripts/fleet-state.test.sh` passed as part of the run). `jq '.evals | length'` = 6. -### Phase 5: Plugin metadata [TODO] +### Phase 5: Plugin metadata [DONE] | File | Action | What changes | |------|--------|-------------| @@ -128,6 +128,8 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Sanity Check:** `node scripts/validate-plugin-contracts.mjs` (or `bash scripts/validate-plugins.sh`) exit 0; `jq -r .version plugins/claude-ops/.claude-plugin/plugin.json` = `0.9.0`; `grep -c '\[0.9.0\]' CHANGELOG.md` = 1. +**Evidence:** `node scripts/validate-plugin-contracts.mjs` — "17 setup skills and 1375 plugin files checked", exit 0. Version and CHANGELOG checks both match exactly. One out-of-table gap found by `bash scripts/validate-plugins.sh` (which additionally validates the repo-root catalog block, `validate-plugin-contracts.mjs` alone does not): the repo-root `README.md`'s generated plugin catalog was now stale against `claude-ops`'s updated description. Regenerated via `node scripts/generate-catalog.mjs` (repo-root `README.md` +1/-1 line) — a mechanical, generator-owned fix within this phase's actual scope (plugin-metadata consistency), not a scope expansion. `validate-plugins.sh` now exits 0 end-to-end (every plugin manifest + the marketplace catalog). `markdownlint-cli2` clean on `plugins/claude-ops/README.md`, `CHANGELOG.md`, and repo-root `README.md`. + ### Phase 6: End-to-end verification [TODO] 1. Install the branch build locally (or `/reload-plugins` against the dev copy per repo dev-loop convention), run `/claude-ops:plugins audit`: verify it issues ZERO mutating CLI calls (transcript inspection) and, absent concurrent sessions/autoUpdate sweeps, both internal JSON files hash identical before/after; report contains all five sections. diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index e5594e56d..43ed3e025 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,14 +1,14 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.8.0", - "description": "Claude Code operations toolkit. Four skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", + "version": "0.9.0", + "description": "Claude Code operations toolkit. Five skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), troubleshoot (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), and a re-runnable setup action that settles where the troubleshooting registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" }, "license": "MIT", - "keywords": ["claude-code", "operations", "observability", "otel", "troubleshooting", "changelog", "monitoring", "hooks", "telemetry", "audit"], + "keywords": ["claude-code", "operations", "observability", "otel", "troubleshooting", "changelog", "monitoring", "hooks", "telemetry", "audit", "plugins", "marketplace"], "userConfig": { "registry_dir": { "type": "string", @@ -19,6 +19,11 @@ "type": "string", "title": "Skill-usage log directory (project-relative)", "description": "Optional contained project-relative directory where the skill-usage-audit hook writes skill-usage.jsonl. Absolute, drive, UNC, traversal, and escaping-symlink paths are invalid. Leave unset to use .claude/observability." + }, + "install_new": { + "type": "string", + "title": "New-plugin install policy for the plugins skill's sync action", + "description": "Controls what `sync` does with catalog plugins that aren't installed yet. Valid values: \"ask\" (default — offer them in one batched multi-select prompt), \"all\" (install every one automatically), \"none\" (report only, never install). The manifest schema has no enum type, so this validates in prose, not JSON Schema; any other value is treated as \"ask\"." } } } diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 019277c98..fadbcb5bc 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.0] + +### Added + +- New `plugins` skill (`/claude-ops:plugins`): brings a machine's plugin fleet current on demand. + `sync` (default) refreshes marketplaces, updates in-repo project/local-scope installs plus the + user-scope sweep, installs new catalog plugins per the `install_new` policy, and fills any + `enabledPlugins` completeness gap — all CLI-mediated, never hand-editing Claude Code's internal + state files. `audit` runs the same algorithm read-only. `converge` is the one action that can + touch a committed `.claude/settings.json`: it detects actionable (version-behind) scope + divergence, previews and confirms per plugin, then surfaces the resulting diff for review — never + auto-committed, and it aborts outright in an autonomous session. Adds a read-only + `scripts/fleet-state.sh` state-inspection script and the `install_new` userConfig scalar + (`ask` default / `all` / `none`). + ## [0.8.0] ### Changed diff --git a/plugins/claude-ops/README.md b/plugins/claude-ops/README.md index dee01aa18..4e47f233f 100644 --- a/plugins/claude-ops/README.md +++ b/plugins/claude-ops/README.md @@ -1,12 +1,13 @@ # claude-ops A Claude Code plugin for running Claude Code well over time — one cohesive -capability across four skills and a family of telemetry-emitter hooks. +capability across five skills and a family of telemetry-emitter hooks. Observability reads what your sessions actually did, troubleshooting tracks what upstream has broken, changelog integration keeps your repo current with what -upstream has shipped, a re-runnable `setup` action settles where the -troubleshooting registry lives, and the `*-audit` hooks feed observability with -per-hook execution telemetry Claude Code's native OTEL cannot see. +upstream has shipped, the plugins skill keeps your own plugin fleet current, a +re-runnable `setup` action settles where the troubleshooting registry lives, +and the `*-audit` hooks feed observability with per-hook execution telemetry +Claude Code's native OTEL cannot see. ## Skills @@ -15,6 +16,7 @@ per-hook execution telemetry Claude Code's native OTEL cannot see. | `/claude-ops:observability` | Reads locally captured Claude Code telemetry — OTEL DuckDB store, machine-owned collector, optional Aspire dashboard, hook-event JSONL, ccusage — and renders cross-session trend reports (`session`/`day`/`week`/`month`/`since:`/`all` scopes). Read-only except the explicit `clean` action, which prunes the JSONL log and OTEL store by age. | | `/claude-ops:troubleshoot` | Searches known Claude product GitHub bugs before you build on a feature, checks service health and model quality, and maintains a persistent registry of tracked issues (what they block, workarounds, follow-ups when fixed). Actions: `status` (default), `search`, `check-all`, `scan`, `list`, `quality`, `create`. | | `/claude-ops:changelog` | Ingests Claude Code changelog entries and integrates them into the current repo: `fetch` (read-only display), `diff` (impact triage, no edits), `status` (applied versions from git history), and `apply` (full explore → research → interview → implement pipeline, explicit user intent only). | +| `/claude-ops:plugins` | Brings a machine's plugin fleet current on demand: marketplace refresh, updates for the plugins that actually load (including in-repo project/local-scope installs), new-catalog-plugin install per policy, and scope-divergence detection. Actions: `sync` (default, CLI-mediated mutations only), `audit` (read-only dry run), `converge` (the one action that can touch a committed `.claude/settings.json` — previews and confirms per plugin first). | | `/claude-ops:setup` | Validates the troubleshooting-registry and skill-usage-log destinations, including path containment, and routes personal option changes through Claude Code's plugin configuration prompt. | ## The audit hooks @@ -120,8 +122,13 @@ instead of failing. ## Configuration -Two `userConfig` options: +Three `userConfig` options: +- **`install_new`** (string, optional) — new-catalog-plugin install policy for the `plugins` + skill's `sync` action. `ask` (default) offers not-yet-installed catalog plugins in one batched + multi-select prompt; `all` installs every one automatically; `none` reports them without + installing. The manifest schema has no `enum` type, so this validates in prose, not JSON Schema; + any other value is treated as `ask`. - **`registry_dir`** (string, optional) — project-relative directory for the troubleshoot issue registry (`registry.json`). Set it to keep the registry inside your repo (git-tracked, team-shared) instead of the From a2c8fc98fce9c9b476f73dda41dab96d62627c80 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:30:53 -0400 Subject: [PATCH 08/23] fix(claude-ops): correct fleet-state.sh path in the plugins skill's SKILL.md SKILL.md pointed at ${CLAUDE_PLUGIN_ROOT}/scripts/fleet-state.sh; the script actually lives at skills/plugins/scripts/fleet-state.sh relative to the plugin root. Caught by a real end-to-end /claude-ops:plugins audit run against a --plugin-dir dev-loop session (phase 6). Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/plugins/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index d69215679..cb509a8ca 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -64,7 +64,7 @@ Every action starts by calling the bundled read-only script — never hand-parse files directly, and never write them: ```bash -"${CLAUDE_PLUGIN_ROOT}"/scripts/fleet-state.sh [--marketplace | --all] +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace | --all] ``` Read [context/scope-semantics.md](context/scope-semantics.md) before interpreting its output — in From 5b8db95e4c7dbfe2ff919e47d7d5f456ec7a111c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:18:22 -0400 Subject: [PATCH 09/23] fix(claude-ops): fix currentProject detection and CLI-usage gotchas (phase 6) Found via real end-to-end execution against the actual skill (the repo's --plugin-dir dev-loop convention, not a simulation): - fleet-state.sh's currentProject stayed null in a real headless session because CLAUDE_PROJECT_DIR isn't reliably exported there. Falls back to the cwd's git toplevel now (CLAUDE_PROJECT_DIR stays authoritative when set), with a regression test for the fallback path that failed against the unpatched script and passes now. - Documents that `claude plugin update` rejects a bare plugin name even when unambiguous -- always pass the fully-qualified id. - Removes a hardcoded marketplace name that leaked into gotchas.md's worked example, caught by this repo's own plugin-contract validator. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 11 +++++- .../skills/plugins/context/gotchas.md | 10 +++++ .../claude-ops/skills/plugins/context/sync.md | 4 ++ .../skills/plugins/scripts/fleet-state.sh | 35 ++++++++++++----- .../plugins/scripts/fleet-state.test.sh | 39 +++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index aeaa413c6..9be2a84e8 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -130,7 +130,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Evidence:** `node scripts/validate-plugin-contracts.mjs` — "17 setup skills and 1375 plugin files checked", exit 0. Version and CHANGELOG checks both match exactly. One out-of-table gap found by `bash scripts/validate-plugins.sh` (which additionally validates the repo-root catalog block, `validate-plugin-contracts.mjs` alone does not): the repo-root `README.md`'s generated plugin catalog was now stale against `claude-ops`'s updated description. Regenerated via `node scripts/generate-catalog.mjs` (repo-root `README.md` +1/-1 line) — a mechanical, generator-owned fix within this phase's actual scope (plugin-metadata consistency), not a scope expansion. `validate-plugins.sh` now exits 0 end-to-end (every plugin manifest + the marketplace catalog). `markdownlint-cli2` clean on `plugins/claude-ops/README.md`, `CHANGELOG.md`, and repo-root `README.md`. -### Phase 6: End-to-end verification [TODO] +### Phase 6: End-to-end verification [DONE] 1. Install the branch build locally (or `/reload-plugins` against the dev copy per repo dev-loop convention), run `/claude-ops:plugins audit`: verify it issues ZERO mutating CLI calls (transcript inspection) and, absent concurrent sessions/autoUpdate sweeps, both internal JSON files hash identical before/after; report contains all five sections. 2. Run `sync` on this machine (steady state expected: all current) — idempotence: second run reports no changes. @@ -138,6 +138,15 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Sanity Check:** audit transcript contains no `plugin install|update|uninstall|marketplace update` invocations; before/after SHA256 of both internal JSON files identical (run with no other CC session live); sync run 2 output contains zero update/install lines; medley audit output contains the collapsed divergence summary line. +**Evidence:** Used this repo's documented dev-loop convention (`docs/MIGRATION-PLAYBOOK.md` "Local development loop") — `claude --plugin-dir ./plugins/claude-ops --permission-mode dontAsk --allowedTools "Bash" -p "/claude-ops:plugins audit"`, a real headless invocation of the actual skill, not a simulation. + +- **Step 1**: first live run surfaced a real bug — SKILL.md pointed `fleet-state.sh` at `${CLAUDE_PLUGIN_ROOT}/scripts/fleet-state.sh`; the true path is `skills/plugins/scripts/fleet-state.sh`. Fixed, re-ran: all five report sections present (Marketplace/Updated/Installed/Divergences/Action needed), zero `plugin install|update|uninstall|marketplace update` invocations, `installed_plugins.json` and `known_marketplaces.json` SHA256 byte-identical before/after. +- **Step 3** (audit from inside medley) surfaced a second, more significant bug: `currentProject` was `null` on every install record despite standing inside medley — `CLAUDE_PROJECT_DIR` is not reliably exported to a headless `-p` session (verified: `fleet-state.sh` run manually with `CLAUDE_PROJECT_DIR` explicitly set worked correctly, isolating the gap to environment propagation, not the script's normalization logic). Fixed by adding a `git rev-parse --show-toplevel` fallback (CLAUDE_PROJECT_DIR authoritative when set, git toplevel when not, `$PWD` last resort) — the priority order matters: an initial git-first attempt broke test isolation and would have silently preferred the wrong root whenever `CLAUDE_PROJECT_DIR` and cwd's git repo differ. Added a dedicated TDD case for the fallback path (real red→green: failed against the un-fixed script, passed after) — 14 cases / 29 assertions now, all passing. Re-ran the medley audit: `Divergences: 29 actionable here` with named live-vs-inactive versions (`planning` 0.2.0 vs 0.11.1, `claude-ops` 0.1.0 vs 0.8.0, `work-items` 0.1.0 vs 0.7.0, ...) — the bulk-collapse-with-named-conflicts shape the sanity check requires. +- **Step 2** (`sync`, run twice on this machine): first run — 0 updated (all 37 melodic-software user-scope plugins already at latest), 0 installed (catalog fully covered). Surfaced a third finding, not a skill bug but a real CLI quirk: the first sweep attempt failed "Plugin not found" for every plugin; isolated and verified empirically (`claude plugin update bash-lint -s user` fails, `claude plugin update bash-lint@melodic-software -s user` succeeds, back to back, same scope) — a bare plugin name is rejected even when unambiguous; `sync.md`/`gotchas.md` already specified the fully-qualified id form, corrected an initial mis-attribution (blamed cwd/stdin) to the verified real cause. Second `sync` run: `Updated: 0`, `Installed: 0`, "all current" — idempotence confirmed. +- Hash-drift caveat encountered directly, not just theorized: between two audit brackets, `known_marketplaces.json`'s overall hash changed even though `melodic-software`'s own entry (`lastUpdated`) stayed frozen — traced to `anthropic-agent-skills` and `claude-plugins-official` (both `autoUpdate`-relevant) picking up a fresh `lastUpdated` in the same window, i.e. Claude Code's own background sweep touching *other* marketplaces in the shared file, not this skill's audit action. Confirms the Phase 2 evidence caveat ("byte-identical-unconditionally is unprovable under CC's own background autoUpdate") empirically rather than by inference. +- `check-skill.sh` re-run after all Phase 6 fixes: still `PASS — 0 errors, 0 warning(s)` (script tests run as part of that check). +- `bash scripts/validate-plugins.sh` caught a real repo-agnostic-design violation the earlier per-file greps missed: `gotchas.md`'s CLI-quirk example hardcoded `bash-lint@melodic-software` as a worked example — the repo's own `validate-plugin-contracts.mjs` rejects `@melodic-software` literals anywhere under `skills/*.md` ("reusable skill content must not require publisher-specific runtime identifiers"). Genericized to `@` placeholders; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` back to 0 matches; `validate-plugins.sh` now exits 0 end-to-end. + ## Blast radius LOW — additive skill in one plugin; no existing skill/hook/lib modified (plugin.json/CHANGELOG/README additive edits). Runtime mutations are user-invoked, CLI-mediated, reversible; the one committed-settings hazard is fenced behind `converge` confirm + Phase 1 USER-RESERVED gate. diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md index 1d76910b7..74d345fac 100644 --- a/plugins/claude-ops/skills/plugins/context/gotchas.md +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -4,6 +4,16 @@ Failure modes this skill is specifically built to avoid, and what breaks if the bypassed. Underlying facts are in [scope-semantics.md](scope-semantics.md) — this file is the "here's what goes wrong" companion, not a restatement. +## `claude plugin update ` (bare) fails "Plugin not found" — always pass the full id + +**Verified empirically** (`claude plugin update -s user` → `Plugin "" not found`; +`claude plugin update @ -s user` → succeeds, same scope, same machine, back to +back). A bare plugin name is not enough for `update` even when it's unambiguous on this machine — +always pass the fully-qualified `@` id, exactly as `fleet-state.sh`'s `installed[]` +and `catalog`-joined ids already are. `sync.md`'s Step 3 and `converge.md`'s CLI examples already use +the fully-qualified form for this reason — never shorten an id to the bare name when constructing an +actual `claude plugin update|install|uninstall|enable` command, even for readability in a report. + ## Trusting `plugin list` / `plugin details` for "what's loaded here" Both show the highest installed version across every scope, not the cwd-effective one. Reporting a diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index 40773fc14..0226ac414 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -52,6 +52,10 @@ For every catalog plugin id currently installed at `user` scope (from `fleet-sta claude plugin update -s user ``` +`` here is always the fully-qualified `@` form `fleet-state.sh` already +emits — a bare name fails with "Plugin not found" even when unambiguous (see +[gotchas.md](gotchas.md)). + One call per plugin — `claude plugin update` takes a single `` argument, there is no bulk "update everything" flag. Loop it; a single plugin's update failure is reported inline (under "Action needed") and does not abort the sweep for the rest. diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index 947b60223..8f9b7a0d3 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -43,8 +43,11 @@ # Real env vars this script honors (set by Claude Code, not test-only): # CLAUDE_PLUGIN_ROOT — this plugin's own install dir; used to self-resolve # the default marketplace when neither flag is given -# CLAUDE_PROJECT_DIR — current project root; used for project/local scope -# settings and the `currentProject` install flag +# CLAUDE_PROJECT_DIR — current project root; authoritative when set, for +# project/local scope settings and the +# `currentProject` install flag. Falls back to the +# cwd's git toplevel when unset (verified empirically +# not reliably exported in every invocation context). set -uo pipefail @@ -105,6 +108,20 @@ if [[ "$(jq -r 'type == "object"' "$MARKETPLACES_JSON")" != "true" ]]; then exit 2 fi +# --- Resolve the current project root --------------------------------------- +# CLAUDE_PROJECT_DIR is authoritative when set — it's the project Claude Code +# itself is anchored to, which can legitimately differ from cwd's git +# toplevel (e.g. a Bash call from a subdirectory, or a repo nested inside +# another). But it is NOT reliably exported to every invocation context +# (verified empirically: absent in a real headless `-p` session run from +# inside a project directory) — fall back to the cwd's git toplevel, then +# bare $PWD, rather than silently losing project context. +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-}" +if [[ -z "$PROJECT_ROOT" ]]; then + PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null | tr -d '\r') +fi +[[ -n "$PROJECT_ROOT" ]] || PROJECT_ROOT="$PWD" + # --- Effective enabledPlugins (raw per-scope + merged local>project>user) -- user_map='{}' @@ -112,11 +129,11 @@ user_map='{}' project_map='{}' local_map='{}' -if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - [[ -f "$CLAUDE_PROJECT_DIR/.claude/settings.json" ]] && - project_map=$(jq -c '.enabledPlugins // {}' "$CLAUDE_PROJECT_DIR/.claude/settings.json") - [[ -f "$CLAUDE_PROJECT_DIR/.claude/settings.local.json" ]] && - local_map=$(jq -c '.enabledPlugins // {}' "$CLAUDE_PROJECT_DIR/.claude/settings.local.json") +if [[ -n "$PROJECT_ROOT" ]]; then + [[ -f "$PROJECT_ROOT/.claude/settings.json" ]] && + project_map=$(jq -c '.enabledPlugins // {}' "$PROJECT_ROOT/.claude/settings.json") + [[ -f "$PROJECT_ROOT/.claude/settings.local.json" ]] && + local_map=$(jq -c '.enabledPlugins // {}' "$PROJECT_ROOT/.claude/settings.local.json") fi # Union of every id ever mentioned in any scope (raw, unmerged) — used to @@ -138,8 +155,8 @@ explicit_false_ids=$(jq -cn --argjson u "$user_map" --argjson p "$project_map" - # --- Normalized current-project root, for the `currentProject` install flag current_project_norm="" -if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then - current_project_norm=$(hook::normalize_path "$(hook::physical_path "$CLAUDE_PROJECT_DIR")") +if [[ -n "$PROJECT_ROOT" ]]; then + current_project_norm=$(hook::normalize_path "$(hook::physical_path "$PROJECT_ROOT")") current_project_norm="${current_project_norm%/}" fi diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 9d18cc1ae..fcda59bb5 100644 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -245,6 +245,45 @@ out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) assert_eq "windows-path: native backslash projectPath matches Git Bash cwd" "true" "$current_flag" +# ============================================================================ +# Case: CLAUDE_PROJECT_DIR unset falls back to the cwd's git toplevel — +# the exact gap a live end-to-end run (a headless `-p` session) exposed: +# CLAUDE_PROJECT_DIR wasn't exported, so currentProject stayed null on every +# install record even while standing inside the actual project. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/git-project-root" +mkdir -p "$project_dir/nested/subdir" +(cd "$project_dir" && git init -q && git config user.email t@t.test && git config user.name t && git commit -q --allow-empty -m init) +# Derive the native-Windows form from `git rev-parse --show-toplevel` itself +# (forward-slash-to-backslash only) rather than `cygpath -w`: on this +# machine cygpath silently 8.3-shortens a profile segment (KyleSexton -> +# KYLESE~1), which normalize_path cannot reconcile against the long form +# git and real CC-written projectPath values both use — a test-fixture +# artifact, not a real-world path shape, so the fixture should not +# manufacture it either. +git_toplevel=$(cd "$project_dir" && git rev-parse --show-toplevel) +native_project_path="${git_toplevel//\//\\}" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$native_project_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, installPath: "x", version: "0.1.0"}]}}' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +[[ -f "$case_dir/user_settings.json" ]] || write "$case_dir/user_settings.json" '{"enabledPlugins":{}}' +out=$( + cd "$project_dir/nested/subdir" && env -u CLAUDE_PROJECT_DIR \ + FLEET_STATE_INSTALLED_JSON="$case_dir/installed_plugins.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/known_marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/user_settings.json" \ + FLEET_STATE_CATALOG_DIR="$case_dir/catalog" \ + FLEET_STATE_HOOK_UTILS="$SCRIPT_DIR/../../../hooks/hook-utils.sh" \ + bash "$SCRIPT" --marketplace market1 2>&1 +) +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "git-fallback: CLAUDE_PROJECT_DIR unset, cwd inside a subdir, resolves via git toplevel" "true" "$current_flag" + # ============================================================================ # Case: --all sweeps every marketplace; one absent-catalog failure does not # abort the sweep From fb400d3511f20a0dc3249eabc276d8643d7b3471 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:25:39 -0400 Subject: [PATCH 10/23] docs(claude-ops): correct phase 6 evidence to state real verification boundary audit is live-verified end-to-end; sync's Step 2 (in-repo update) and converge were never exercised against real divergence this session. Caught by advisor review before declaring the plan complete. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 9be2a84e8..bf8b04e51 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -140,6 +140,8 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr **Evidence:** Used this repo's documented dev-loop convention (`docs/MIGRATION-PLAYBOOK.md` "Local development loop") — `claude --plugin-dir ./plugins/claude-ops --permission-mode dontAsk --allowedTools "Bash" -p "/claude-ops:plugins audit"`, a real headless invocation of the actual skill, not a simulation. +**Verification boundary (read before treating this phase as "fully end-to-end"):** `audit` is live-verified — every mutating-call-free path, the divergence report shape, and the `currentProject` fix all ran for real against this machine and against medley. `sync` was live-verified for Step 1 (marketplace refresh), Step 3 (user-scope sweep — 2 runs, both 0-update steady-state), and idempotence. **Step 2 (in-repo project/local update — the plan's own "primary value path") never fired**, because both live `sync` runs happened from repos with no project-scope divergence to update. `converge` was **never invoked** in any form beyond `audit`'s prediction text. Exercising those two paths for real requires mutating a real repo with genuine divergence (medley, 29 actionable rows per the Step 3 audit run below) — that's a consequential, only-partly-reversible action against a repo outside this one, so it needs an explicit user decision, not a unilateral one this session. Recorded as an open verification gap, not silently absorbed into "done." + - **Step 1**: first live run surfaced a real bug — SKILL.md pointed `fleet-state.sh` at `${CLAUDE_PLUGIN_ROOT}/scripts/fleet-state.sh`; the true path is `skills/plugins/scripts/fleet-state.sh`. Fixed, re-ran: all five report sections present (Marketplace/Updated/Installed/Divergences/Action needed), zero `plugin install|update|uninstall|marketplace update` invocations, `installed_plugins.json` and `known_marketplaces.json` SHA256 byte-identical before/after. - **Step 3** (audit from inside medley) surfaced a second, more significant bug: `currentProject` was `null` on every install record despite standing inside medley — `CLAUDE_PROJECT_DIR` is not reliably exported to a headless `-p` session (verified: `fleet-state.sh` run manually with `CLAUDE_PROJECT_DIR` explicitly set worked correctly, isolating the gap to environment propagation, not the script's normalization logic). Fixed by adding a `git rev-parse --show-toplevel` fallback (CLAUDE_PROJECT_DIR authoritative when set, git toplevel when not, `$PWD` last resort) — the priority order matters: an initial git-first attempt broke test isolation and would have silently preferred the wrong root whenever `CLAUDE_PROJECT_DIR` and cwd's git repo differ. Added a dedicated TDD case for the fallback path (real red→green: failed against the un-fixed script, passed after) — 14 cases / 29 assertions now, all passing. Re-ran the medley audit: `Divergences: 29 actionable here` with named live-vs-inactive versions (`planning` 0.2.0 vs 0.11.1, `claude-ops` 0.1.0 vs 0.8.0, `work-items` 0.1.0 vs 0.7.0, ...) — the bulk-collapse-with-named-conflicts shape the sanity check requires. - **Step 2** (`sync`, run twice on this machine): first run — 0 updated (all 37 melodic-software user-scope plugins already at latest), 0 installed (catalog fully covered). Surfaced a third finding, not a skill bug but a real CLI quirk: the first sweep attempt failed "Plugin not found" for every plugin; isolated and verified empirically (`claude plugin update bash-lint -s user` fails, `claude plugin update bash-lint@melodic-software -s user` succeeds, back to back, same scope) — a bare plugin name is rejected even when unambiguous; `sync.md`/`gotchas.md` already specified the fully-qualified id form, corrected an initial mis-attribution (blamed cwd/stdin) to the verified real cause. Second `sync` run: `Updated: 0`, `Installed: 0`, "all current" — idempotence confirmed. From ecdd76c7986175227618fd475ca3992977a0d663 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:32:33 -0400 Subject: [PATCH 11/23] fix(claude-ops): stop overclaiming enable-state-mismatch detection in plugins skill /verification:confirm's fresh-context verifier found that converge.md and SKILL.md described an enable-state mismatch (true in one scope, false in another) as detected-but-unresolved, when fleet-state.sh never exposes each scope's raw enabledPlugins map and genuinely cannot detect it. Reword both docs to state the real blind spot, and swap evals.json's eval 5 off a scenario the model could only pass by echoing a fact handed to it in the prompt. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 1 + plugins/claude-ops/skills/plugins/SKILL.md | 9 ++++++--- .../claude-ops/skills/plugins/context/converge.md | 12 +++++++----- plugins/claude-ops/skills/plugins/evals/evals.json | 7 ++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index bf8b04e51..17b42f080 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -148,6 +148,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr - Hash-drift caveat encountered directly, not just theorized: between two audit brackets, `known_marketplaces.json`'s overall hash changed even though `melodic-software`'s own entry (`lastUpdated`) stayed frozen — traced to `anthropic-agent-skills` and `claude-plugins-official` (both `autoUpdate`-relevant) picking up a fresh `lastUpdated` in the same window, i.e. Claude Code's own background sweep touching *other* marketplaces in the shared file, not this skill's audit action. Confirms the Phase 2 evidence caveat ("byte-identical-unconditionally is unprovable under CC's own background autoUpdate") empirically rather than by inference. - `check-skill.sh` re-run after all Phase 6 fixes: still `PASS — 0 errors, 0 warning(s)` (script tests run as part of that check). - `bash scripts/validate-plugins.sh` caught a real repo-agnostic-design violation the earlier per-file greps missed: `gotchas.md`'s CLI-quirk example hardcoded `bash-lint@melodic-software` as a worked example — the repo's own `validate-plugin-contracts.mjs` rejects `@melodic-software` literals anywhere under `skills/*.md` ("reusable skill content must not require publisher-specific runtime identifiers"). Genericized to `@` placeholders; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` back to 0 matches; `validate-plugins.sh` now exits 0 end-to-end. +- **Post-Phase-6 fix** (found by `/verification:confirm`'s independent fresh-context verifier, not by this session's own review): `converge.md`/`SKILL.md`'s "Action needed" report spec instructed reporting an enable-state mismatch (`true` in one scope, `false` in another) as a "known limitation — resolve manually," which implies the skill can *detect* the mismatch and only defers the *fix*. In reality `fleet-state.sh` never exposes each scope's raw `enabledPlugins` map (only the merged effective value in `enabled`) — the mismatch cannot be detected from its output at all, so it can never actually be reported. `evals.json`'s eval 5 masked this: it handed the mismatch to the model directly in prompt text, so it validated the model echoing a told fact, not a real capability. Fixed: `converge.md`'s V1-scope note now says "cannot even detect," `SKILL.md`'s report spec drops "enable-state mismatches" from the reportable "Action needed" categories (with a pointer to the blind-spot note), and eval 5's scenario was swapped to an unknown/orphaned-plugin-id conflict (a case `fleet-state.sh`'s raw `catalog`/`installed`/`enabled` arrays genuinely support deriving) plus an explicit expectation that the response never claims to detect an enable-state mismatch. Re-verified: `jq empty evals.json`, `validate-plugins.sh`, and `check-skill.sh` all clean after the edit. ## Blast radius diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index cb509a8ca..ec836edfa 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -96,15 +96,18 @@ Divergences: project-scope install(s) behind user scope → run `/claude-ops (N = actionable only — versionsMatch:false; same-version multi-scope installs are not counted or listed here) Action needed: (omit section entirely when empty) + unknown/orphaned plugins> (omit section entirely when empty) ``` When running inside a project (`CLAUDE_PROJECT_DIR` set and `fleet-state.sh`'s `installed[]` entries carry `currentProject: true`), lead the Divergences line with *this* project's actionable count and fold the rest of the machine into one trailing clause — e.g. `2 behind here → converge; 27 more elsewhere on this machine`. Per-row detail (naming exact `` versions per repo) is -reserved for genuine conflicts: an enable-state mismatch (`true` in one scope, `false` in another), -an unknown/orphaned plugin id, or a CLI call that failed — never for the routine bulk case. +reserved for genuine conflicts: an unknown/orphaned plugin id, or a CLI call that failed — never for +the routine bulk case. (Enable-state mismatches — a plugin `true` in one scope's `enabledPlugins` +and `false` in another — are a known blind spot, not a reportable category: `fleet-state.sh` only +exposes the merged effective value, never each scope's raw map, so this skill cannot detect one to +report it. See [context/converge.md](context/converge.md) "V1 scope".) Close with reload guidance: recommend bare `/reload-plugins` (no `--force` — verified: no such flag exists). If any updated component includes a monitor, call that out separately — monitors need a diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index 3832c87b3..ff76707d4 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -16,11 +16,13 @@ treat it as autonomous and abort. Report why, and that `converge` can be re-run ## V1 scope: version divergence only `converge` resolves entries in `fleet-state.sh`'s `divergences[]` with `versionsMatch: false` — -scopes disagree on version. It does **not** currently resolve an enable-state mismatch (a plugin -`true` in one scope's `enabledPlugins` and `false` in another) — that needs comparing each scope's -*raw* `enabledPlugins` map, which `fleet-state.sh` doesn't expose today (only the merged effective -value, in `enabled`). Report an enable-state mismatch under "Action needed" as a **known -limitation — resolve manually** rather than silently skipping it or claiming `converge` handles it. +scopes disagree on version. It does **not** currently resolve, and cannot even detect, an +enable-state mismatch (a plugin `true` in one scope's `enabledPlugins` and `false` in another) — +that needs comparing each scope's *raw* `enabledPlugins` map, which `fleet-state.sh` doesn't expose +today (only the merged effective value, in `enabled`). This is a genuine blind spot, not a deferred +fix: never claim the report surfaces an enable-state mismatch, and never hand-parse the settings +files directly to work around the gap — the fix is extending `fleet-state.sh` to expose the raw +per-scope maps, not something this skill's prompt layer can paper over. ## Step 1 — Detect diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json index d966b78a9..539521c2f 100644 --- a/plugins/claude-ops/skills/plugins/evals/evals.json +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -52,13 +52,14 @@ { "id": 5, "name": "divergence-report-collapses-bulk-names-conflicts", - "prompt": "Audit my plugins — I have 29 plugins with an actionable version divergence between project and user scope, plus 2 more that are multi-scope but pinned to the identical version, and one plugin with an enable-state mismatch (true in user scope, false in project scope).", - "expected_output": "The report's Divergences line collapses the 29 actionable (version-behind) entries into one bulk count and names `/claude-ops:plugins converge` as the fix — it does NOT list all 29 individually, and it does NOT count the 2 same-version multi-scope installs as actionable (versionsMatch: true is benign). The one enable-state mismatch gets its own per-row detail line under Action needed as a genuine conflict, distinct from the bulk divergence count, with the live-vs-inactive scope values named.", + "prompt": "Audit my plugins — I have 29 plugins with an actionable version divergence between project and user scope, plus 2 more that are multi-scope but pinned to the identical version, and one plugin id that fleet-state.sh reports under unknown/orphaned (present in enabledPlugins but no matching catalog entry).", + "expected_output": "The report's Divergences line collapses the 29 actionable (version-behind) entries into one bulk count and names `/claude-ops:plugins converge` as the fix — it does NOT list all 29 individually, and it does NOT count the 2 same-version multi-scope installs as actionable (versionsMatch: true is benign). The one unknown/orphaned plugin id gets its own per-row detail line under Action needed as a genuine conflict, distinct from the bulk divergence count.", "files": [], "expectations": [ "The 29 actionable divergences are collapsed into a single bulk-count line, not enumerated one by one", "The 2 same-version (versionsMatch: true) multi-scope installs are excluded from the actionable divergence count", - "The enable-state mismatch gets an individual per-row detail line naming which scope is live (true) and which is inactive (false), separate from the bulk divergence line" + "The unknown/orphaned plugin id gets an individual per-row detail line under Action needed, separate from the bulk divergence line", + "The response never claims to detect or report an enable-state mismatch (true in one scope, false in another) — fleet-state.sh only exposes the merged effective value, not each scope's raw enabledPlugins map" ] }, { From 56ca78b6430f3fd022736db6a66a375f01e6a1b0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:36:43 -0400 Subject: [PATCH 12/23] fix(claude-ops): mark fleet-state.sh/fleet-state.test.sh executable Every other *.test.sh in this repo is 100755; these two new scripts carry a shebang but were committed at 100644. CI's exec-bit lane (scripts/aggregate-hygiene-results.sh via the shared exec-bit action) would have flagged this. Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/plugins/scripts/fleet-state.sh | 0 plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/claude-ops/skills/plugins/scripts/fleet-state.sh mode change 100644 => 100755 plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh old mode 100644 new mode 100755 diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh old mode 100644 new mode 100755 From 96981c38bc18cdc212d6e39908944392b8468370 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:53:59 -0400 Subject: [PATCH 13/23] docs(claude-ops): fix typos-lint false-positive in PLAN.md evidence mis-attribution tripped the typos hygiene lane; reworded to wrong attribution. Caught by locally mirroring CI's hygiene lane before PR creation. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 17b42f080..4f69daab0 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -144,7 +144,7 @@ Fixtures: dual-scope divergence, plugin missing from installs, plugin missing fr - **Step 1**: first live run surfaced a real bug — SKILL.md pointed `fleet-state.sh` at `${CLAUDE_PLUGIN_ROOT}/scripts/fleet-state.sh`; the true path is `skills/plugins/scripts/fleet-state.sh`. Fixed, re-ran: all five report sections present (Marketplace/Updated/Installed/Divergences/Action needed), zero `plugin install|update|uninstall|marketplace update` invocations, `installed_plugins.json` and `known_marketplaces.json` SHA256 byte-identical before/after. - **Step 3** (audit from inside medley) surfaced a second, more significant bug: `currentProject` was `null` on every install record despite standing inside medley — `CLAUDE_PROJECT_DIR` is not reliably exported to a headless `-p` session (verified: `fleet-state.sh` run manually with `CLAUDE_PROJECT_DIR` explicitly set worked correctly, isolating the gap to environment propagation, not the script's normalization logic). Fixed by adding a `git rev-parse --show-toplevel` fallback (CLAUDE_PROJECT_DIR authoritative when set, git toplevel when not, `$PWD` last resort) — the priority order matters: an initial git-first attempt broke test isolation and would have silently preferred the wrong root whenever `CLAUDE_PROJECT_DIR` and cwd's git repo differ. Added a dedicated TDD case for the fallback path (real red→green: failed against the un-fixed script, passed after) — 14 cases / 29 assertions now, all passing. Re-ran the medley audit: `Divergences: 29 actionable here` with named live-vs-inactive versions (`planning` 0.2.0 vs 0.11.1, `claude-ops` 0.1.0 vs 0.8.0, `work-items` 0.1.0 vs 0.7.0, ...) — the bulk-collapse-with-named-conflicts shape the sanity check requires. -- **Step 2** (`sync`, run twice on this machine): first run — 0 updated (all 37 melodic-software user-scope plugins already at latest), 0 installed (catalog fully covered). Surfaced a third finding, not a skill bug but a real CLI quirk: the first sweep attempt failed "Plugin not found" for every plugin; isolated and verified empirically (`claude plugin update bash-lint -s user` fails, `claude plugin update bash-lint@melodic-software -s user` succeeds, back to back, same scope) — a bare plugin name is rejected even when unambiguous; `sync.md`/`gotchas.md` already specified the fully-qualified id form, corrected an initial mis-attribution (blamed cwd/stdin) to the verified real cause. Second `sync` run: `Updated: 0`, `Installed: 0`, "all current" — idempotence confirmed. +- **Step 2** (`sync`, run twice on this machine): first run — 0 updated (all 37 melodic-software user-scope plugins already at latest), 0 installed (catalog fully covered). Surfaced a third finding, not a skill bug but a real CLI quirk: the first sweep attempt failed "Plugin not found" for every plugin; isolated and verified empirically (`claude plugin update bash-lint -s user` fails, `claude plugin update bash-lint@melodic-software -s user` succeeds, back to back, same scope) — a bare plugin name is rejected even when unambiguous; `sync.md`/`gotchas.md` already specified the fully-qualified id form, corrected an initial wrong attribution (blamed cwd/stdin) to the verified real cause. Second `sync` run: `Updated: 0`, `Installed: 0`, "all current" — idempotence confirmed. - Hash-drift caveat encountered directly, not just theorized: between two audit brackets, `known_marketplaces.json`'s overall hash changed even though `melodic-software`'s own entry (`lastUpdated`) stayed frozen — traced to `anthropic-agent-skills` and `claude-plugins-official` (both `autoUpdate`-relevant) picking up a fresh `lastUpdated` in the same window, i.e. Claude Code's own background sweep touching *other* marketplaces in the shared file, not this skill's audit action. Confirms the Phase 2 evidence caveat ("byte-identical-unconditionally is unprovable under CC's own background autoUpdate") empirically rather than by inference. - `check-skill.sh` re-run after all Phase 6 fixes: still `PASS — 0 errors, 0 warning(s)` (script tests run as part of that check). - `bash scripts/validate-plugins.sh` caught a real repo-agnostic-design violation the earlier per-file greps missed: `gotchas.md`'s CLI-quirk example hardcoded `bash-lint@melodic-software` as a worked example — the repo's own `validate-plugin-contracts.mjs` rejects `@melodic-software` literals anywhere under `skills/*.md` ("reusable skill content must not require publisher-specific runtime identifiers"). Genericized to `@` placeholders; `grep -rn 'melodic-software' plugins/claude-ops/skills/plugins/` back to 0 matches; `validate-plugins.sh` now exits 0 end-to-end. From 114e31845b02288480cbb36c2bc39478dbe43acf Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:09:39 -0400 Subject: [PATCH 14/23] fix(claude-ops): fix CI-only test failure and hardcoded path from live PR run CI's machine-specific-paths lane flagged a hardcoded D:\repos\...\medley path in PLAN.md's Phase 1 open questions; genericized to a placeholder. CI's plugin-gate lane failed a test that passed locally: the "jq missing" case ran the bare script against default file paths instead of routing through run_state's fixture builder like every other case. On this dev machine a real Claude Code install satisfies those defaults so the script reached its jq check; on a clean CI runner the file-not-found check fires first and the case never exercises what it claims to test. Fixed by building a real fixture dir and only stripping jq from PATH, isolating jq-absence as the sole variable. Co-Authored-By: Claude Sonnet 5 --- docs/topics/plugin-fleet-sync-skill/PLAN.md | 2 +- .../skills/plugins/scripts/fleet-state.test.sh | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/topics/plugin-fleet-sync-skill/PLAN.md b/docs/topics/plugin-fleet-sync-skill/PLAN.md index 4f69daab0..7edeed8a2 100644 --- a/docs/topics/plugin-fleet-sync-skill/PLAN.md +++ b/docs/topics/plugin-fleet-sync-skill/PLAN.md @@ -72,7 +72,7 @@ One command that guarantees, on any machine and from any directory, that the plu Three unknowns gate later phases; resolve empirically on this machine before authoring semantics. -1. **`plugin update -s project` committed-file test** (USER-RESERVED gate — **go/no-go for the skill's primary value**: the in-repo update path is the main event, since dual-scope repos load the stale project pin): from `D:\repos\github.com\melodic-software\medley` — note its `.claude/settings.json` is ALREADY dirty, so porcelain status alone cannot detect the write. Method: SHA256 both `.claude/settings.json` and `.claude/settings.local.json` AND copy both to a backup dir; run `claude plugin update markdown-formatter@melodic-software -s project` (also fixes one real drift item: 0.1.3 → latest); re-hash and diff. If either file changed: restore from backup, record diff verbatim, **STOP — Brief Decision 4 needs user re-approval**. +1. **`plugin update -s project` committed-file test** (USER-RESERVED gate — **go/no-go for the skill's primary value**: the in-repo update path is the main event, since dual-scope repos load the stale project pin): from `/medley` (a sibling checkout) — note its `.claude/settings.json` is ALREADY dirty, so porcelain status alone cannot detect the write. Method: SHA256 both `.claude/settings.json` and `.claude/settings.local.json` AND copy both to a backup dir; run `claude plugin update markdown-formatter@melodic-software -s project` (also fixes one real drift item: 0.1.3 → latest); re-hash and diff. If either file changed: restore from backup, record diff verbatim, **STOP — Brief Decision 4 needs user re-approval**. 2. **`/reload-plugins` `--force` necessity**: check current CC docs/help for `/reload-plugins` flags; empirically compare `/reload-plugins` vs `--force` after a plugin update. Record which the report should recommend. 3. **userConfig enum support**: fetch `json.schemastore.org/claude-code-plugin-manifest.json`; check whether `userConfig` entries support `enum`. Fallback: string field + prose validation in SKILL.md. 4. **Internal-schema parse contract snapshot**: copy the live shapes of `installed_plugins.json` (per-id array of `{scope, projectPath?, installPath, version, ...}`) and `known_marketplaces.json` into Phase 2 test fixtures, with a CC version-floor note (schema observed on 2.1.211; undocumented internal contract — parser must fail loud on shape drift, never guess). diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index fcda59bb5..80dec0844 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -342,10 +342,20 @@ assert_contains "default marketplace: names the fallback" "$out" "--marketplace" # scratch via symlinks) so bash, dirname, and every other tool the script # needs stay resolvable — symlinking coreutils individually is unreliable on # Windows without elevated rights. +# Route through run_state's fixture builder (not a bare script invocation) so +# jq-absence is the only variable under test — a bare invocation relies on +# default file paths, which a dev machine's real Claude Code install happens +# to satisfy but a clean CI runner does not, surfacing "installed_plugins.json +# not found" before the script ever reaches its jq check. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": []}' +ARGS=(--marketplace market1) real_jq=$(command -v jq) jq_dir=$(dirname "$real_jq") filtered_path=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vF "$jq_dir" | tr '\n' ':') -out=$(PATH="$filtered_path" bash "$SCRIPT" --marketplace market1 2>&1) +out=$(run_state "$case_dir" "PATH=$filtered_path") rc=$? assert_exit "jq missing: exit 2" 2 "$rc" assert_contains "jq missing: actionable notice" "$out" "jq required" From f403c5207a215c4606f732d9cd0f4ff0da98510f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:17:22 -0400 Subject: [PATCH 15/23] fix(claude-ops): strip every PATH dir containing jq, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit command -v jq only reports the first PATH match. This machine has a single jq, so stripping its directory worked locally, but the CI runner's ubuntu-24.04 image resolves jq from a second PATH directory too — the "jq missing" case silently ran with jq still present and exercised the wrong branch. Walk all of PATH and drop every directory that actually contains an executable jq. Co-Authored-By: Claude Sonnet 5 --- .../plugins/scripts/fleet-state.test.sh | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 80dec0844..11e4eb2a6 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -338,10 +338,14 @@ assert_contains "default marketplace: names the fallback" "$out" "--marketplace" # ============================================================================ # Case: jq missing — clear notice, not a bare command-not-found # ============================================================================ -# Strip only jq's own directory out of PATH (rather than rebuilding PATH from -# scratch via symlinks) so bash, dirname, and every other tool the script -# needs stay resolvable — symlinking coreutils individually is unreliable on -# Windows without elevated rights. +# Strip every PATH directory that resolves a jq executable (rather than +# rebuilding PATH from scratch via symlinks) so bash, dirname, and every other +# tool the script needs stay resolvable — symlinking coreutils individually is +# unreliable on Windows without elevated rights. `command -v jq` only reports +# the FIRST match: a GitHub Actions ubuntu runner ships jq in more than one +# PATH directory, so stripping just that one left a second jq resolvable and +# this case silently exercised the "jq present" path instead of "jq missing" +# — walk every PATH entry and drop each one that actually contains a jq. # Route through run_state's fixture builder (not a bare script invocation) so # jq-absence is the only variable under test — a bare invocation relies on # default file paths, which a dev machine's real Claude Code install happens @@ -352,9 +356,11 @@ case_dir=$(new_case_dir) write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' write "$case_dir/catalog/market1.json" '{"plugins": []}' ARGS=(--marketplace market1) -real_jq=$(command -v jq) -jq_dir=$(dirname "$real_jq") -filtered_path=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vF "$jq_dir" | tr '\n' ':') +filtered_path="" +while IFS= read -r dir; do + [[ -n "$dir" && -x "$dir/jq" ]] && continue + filtered_path="${filtered_path:+$filtered_path:}$dir" +done < <(printf '%s' "$PATH" | tr ':' '\n') out=$(run_state "$case_dir" "PATH=$filtered_path") rc=$? assert_exit "jq missing: exit 2" 2 "$rc" From 88bd1ecda72e26e65f851765717a8a39e23d031f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:28:25 -0400 Subject: [PATCH 16/23] fix(claude-ops): make jq-missing test case location-independent of jq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory-exclusion PATH filtering (drop every dir containing jq) is not universally safe: on this dev machine jq lives in its own directory, so excluding it is harmless, but a GitHub Actions ubuntu runner colocates jq with bash and coreutils in /usr/bin, so excluding jq's directory there also removed bash, breaking the case with "env: 'bash': No such file or directory" before the script ever reached its jq check. Verified empirically that a chmod-000 decoy jq earlier on PATH doesn't help either — command -v skips it and still finds the real jq further down PATH. Fixed by building an isolated PATH containing copies (never symlinks — those need elevation on Windows) of only the tools fleet-state.sh needs before its jq check: dirname, and bash itself to launch the interpreter, plus any colocated *.dll bash.exe needs on Windows/MSYS (a no-op on Linux). This no longer depends on jq's location at all. Co-Authored-By: Claude Sonnet 5 --- .../plugins/scripts/fleet-state.test.sh | 52 ++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 11e4eb2a6..b7272846f 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -338,30 +338,44 @@ assert_contains "default marketplace: names the fallback" "$out" "--marketplace" # ============================================================================ # Case: jq missing — clear notice, not a bare command-not-found # ============================================================================ -# Strip every PATH directory that resolves a jq executable (rather than -# rebuilding PATH from scratch via symlinks) so bash, dirname, and every other -# tool the script needs stay resolvable — symlinking coreutils individually is -# unreliable on Windows without elevated rights. `command -v jq` only reports -# the FIRST match: a GitHub Actions ubuntu runner ships jq in more than one -# PATH directory, so stripping just that one left a second jq resolvable and -# this case silently exercised the "jq present" path instead of "jq missing" -# — walk every PATH entry and drop each one that actually contains a jq. -# Route through run_state's fixture builder (not a bare script invocation) so -# jq-absence is the only variable under test — a bare invocation relies on -# default file paths, which a dev machine's real Claude Code install happens -# to satisfy but a clean CI runner does not, surfacing "installed_plugins.json -# not found" before the script ever reaches its jq check. +# A directory-exclusion PATH filter (drop every PATH dir containing a jq +# executable) is NOT safe in general: `command -v jq` only reports the first +# match, and on this dev machine jq lives in its own directory separate from +# bash/coreutils, so excluding it is harmless — but a GitHub Actions +# ubuntu runner colocates jq with bash and coreutils in /usr/bin, so +# excluding jq's directory there also removes bash, and the case fails with +# "env: 'bash': No such file or directory" before it ever reaches the +# script's own jq check. `command -v` also does not skip a shadowed +# non-executable file and fall through to a later PATH entry (verified +# empirically: a chmod-000 decoy at the front of PATH is skipped and the +# real jq further down PATH is still found) — so shadowing can't hide jq +# either. +# +# The only universally safe approach: build an isolated PATH containing +# COPIES (never symlinks — those need elevation on Windows) of just the +# specific tools fleet-state.sh invokes before its jq check (`dirname`) plus +# bash itself to launch the interpreter, with jq deliberately excluded. +# Copying a Linux ELF binary elsewhere is safe (glibc resolves shared libs +# via the system loader, not the binary's own directory) — but bash.exe on +# Windows/MSYS needs a colocated msys-2.0.dll, so any *.dll sitting next to +# the real bash binary is copied alongside it too (a harmless no-op on +# Linux, where no such files exist). CASE_NUM=$((CASE_NUM + 1)) case_dir=$(new_case_dir) write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' write "$case_dir/catalog/market1.json" '{"plugins": []}' ARGS=(--marketplace market1) -filtered_path="" -while IFS= read -r dir; do - [[ -n "$dir" && -x "$dir/jq" ]] && continue - filtered_path="${filtered_path:+$filtered_path:}$dir" -done < <(printf '%s' "$PATH" | tr ':' '\n') -out=$(run_state "$case_dir" "PATH=$filtered_path") +bash_bin=$(command -v bash) +dirname_bin=$(command -v dirname) +bash_dir=$(dirname "$bash_bin") +safe_bin_dir="$case_dir/jq-missing-bin" +mkdir -p "$safe_bin_dir" +cp "$bash_bin" "$safe_bin_dir/" +cp "$dirname_bin" "$safe_bin_dir/" +shopt -s nullglob +for dll in "$bash_dir"/*.dll; do cp "$dll" "$safe_bin_dir/"; done +shopt -u nullglob +out=$(run_state "$case_dir" "PATH=$safe_bin_dir") rc=$? assert_exit "jq missing: exit 2" 2 "$rc" assert_contains "jq missing: actionable notice" "$out" "jq required" From e5f1b379d4c222bda5e67f0f66a941f7c5da4a6f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:52:50 -0400 Subject: [PATCH 17/23] fix(claude-ops): fix --all abort-on-malformed-catalog and cross-repo missing_from_enabled false positive Two real bugs found by Codex's PR review, both verified by direct reproduction before fixing: - emit_marketplace ran a marketplace's catalog JSON through the fatal require_json helper, which exit-2's the whole process. A malformed (not merely absent) catalog for one marketplace during --all silently killed the entire sweep before any {marketplaces: ...} report was emitted for any marketplace, not just the broken one. Fixed to match the sibling error branches: report the error inline and return 1. - missing_from_enabled compared every project/local install machine-wide against only the current PROJECT_ROOT's settings files. A plugin already enabled in a different repo's own .claude/settings.json (which this script never reads) showed up as a false-positive missing_from_enabled, which sync's Step 5 could act on from the wrong repo. Fixed to only consider ids this invocation can actually verify: user scope (global) and the current repo's project/local scope; other-repo project/local installs are excluded, not asserted missing. Both regression-tested (fixture-reproduced red, fixed, green): 17 cases, 0 failed. Co-Authored-By: Claude Sonnet 5 --- .../skills/plugins/scripts/fleet-state.sh | 26 ++++++++- .../plugins/scripts/fleet-state.test.sh | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index 8f9b7a0d3..4eaff2525 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -209,7 +209,16 @@ emit_marketplace() { fi fi - require_json "$catalog_json" "marketplace.json ($name)" + # Not require_json: that helper exit-2's the whole process, appropriate for + # the two prerequisite files this script cannot run without at all. A single + # marketplace's catalog being malformed is a per-marketplace failure like + # the branches above — report it inline and return 1 so one corrupt clone + # doesn't abort an --all sweep of every other marketplace. + if ! jq empty "$catalog_json" 2>/dev/null; then + jq -cn --arg n "$name" --argjson au "$([[ "$auto_update" == "true" ]] && echo true || echo false)" --arg lu "$last_updated" \ + '{marketplace: {name: $n, autoUpdate: $au, lastUpdated: $lu, error: "marketplace.json is not valid JSON"}}' + return 1 + fi local catalog catalog=$(jq -c '[.plugins[]?.name // empty] | unique' "$catalog_json") @@ -253,9 +262,20 @@ emit_marketplace() { local known_at_mp known_at_mp=$(jq -c --arg suffix "@$name" '[.[] | select(endswith($suffix))]' <<<"$known_ids") + # missing_from_enabled can only be computed for ids whose enabledPlugins + # this invocation can actually read: user scope (global) and the current + # PROJECT_ROOT's project/local scope. A project/local install belonging to + # a DIFFERENT repo is excluded rather than asserted missing — its own + # settings files live in that repo and are never read here, so treating an + # unread file as "never mentioned" would false-positive on every already- + # enabled install elsewhere on the machine (and could later steer a mutation + # at the wrong repo). + local verifiable_ids + verifiable_ids=$(jq -c '[.[] | select(.scope == "user" or .currentProject == true) | .id] | unique' <<<"$installed") + local missing_from_enabled - missing_from_enabled=$(jq -cn --argjson installed_ids "$installed_ids" --argjson known "$known_at_mp" \ - '$installed_ids - $known') + missing_from_enabled=$(jq -cn --argjson verifiable_ids "$verifiable_ids" --argjson known "$known_at_mp" \ + '$verifiable_ids - $known') local enabled_at_mp enabled_at_mp=$(jq -cn --argjson known "$known_at_mp" --argjson eff "$effective_map" \ diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index b7272846f..22b934531 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -172,6 +172,36 @@ out=$(run_state "$case_dir") missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) assert_eq "missing-enabled: installed-but-never-mentioned flagged" '["alpha@market1"]' "$missing_enabled" +# ============================================================================ +# Case: missing_from_enabled must not false-positive on a DIFFERENT repo's +# project-scope install — fleet-state.sh can only read the current +# PROJECT_ROOT's settings files, so a project/local install belonging to +# another repo can never be verified as known or unknown here. Excluded +# entirely rather than asserted missing (a fixed bug: this used to compare +# every project/local install machine-wide against only the current repo's +# settings, so any other repo's already-enabled install showed up as +# missing_from_enabled and sync could try to `enable -s project` against the +# wrong repo) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +other_repo_dir="$case_dir/other-repo" +mkdir -p "$other_repo_dir/.claude" +native_other_repo="$(cygpath -w "$other_repo_dir" 2>/dev/null || echo "$other_repo_dir")" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg path "$native_other_repo" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $path, installPath: "y", version: "0.1.0"}]}}' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$other_repo_dir/.claude/settings.json" '{"enabledPlugins": {"alpha@market1": true}}' +ARGS=(--marketplace market1) +current_project_dir="$case_dir/current-repo" +mkdir -p "$current_project_dir" +out=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$current_project_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "missing-enabled: other repo's already-enabled project install excluded, not false-flagged" '[]' "$missing_enabled" + # ============================================================================ # Case: explicit enabledPlugins:false is an opt-out, NOT missing_from_enabled # ============================================================================ @@ -305,6 +335,31 @@ assert_eq "--all: market1 resolved" '["alpha"]' "$m1_catalog" m2_error=$(jq -r '.marketplaces.market2.marketplace.error' <<<"$out" 2>/dev/null) assert_eq "--all: market2 reports its failure inline" "no catalog fixture" "$m2_error" +# ============================================================================ +# Case: --all sweeps every marketplace; one MALFORMED catalog (not merely +# absent) does not abort the sweep either — this is a distinct code path from +# the absent-fixture case above (a fixed bug: emit_marketplace previously ran +# the catalog JSON through the fatal require_json helper, which exit-2'd the +# whole process before the {marketplaces: ...} report was ever emitted for +# ANY marketplace, not just the malformed one) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/known_marketplaces.json" '{ + "market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}, + "market2": {"source": {"source": "github", "repo": "example/market2"}, "installLocation": "z2", "lastUpdated": "2026-01-01T00:00:00Z"} +}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +write "$case_dir/catalog/market2.json" '{bad' +ARGS=(--all) +out=$(run_state "$case_dir") +rc=$? +assert_exit "--all: exits 0 even with one marketplace's catalog malformed" 0 "$rc" +m1_catalog=$(jq -c '.marketplaces.market1.catalog' <<<"$out" 2>/dev/null) +assert_eq "--all: market1 still resolved despite market2's malformed catalog" '["alpha"]' "$m1_catalog" +m2_error=$(jq -r '.marketplaces.market2.marketplace.error' <<<"$out" 2>/dev/null) +assert_eq "--all: market2's malformed catalog reports its failure inline" "marketplace.json is not valid JSON" "$m2_error" + # ============================================================================ # Case: default marketplace resolved dynamically from CLAUDE_PLUGIN_ROOT # (never hardcoded) — zero-arg invocation From 706633bba56f336008a420bdfdf49afa98ecc3d5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:53:10 -0400 Subject: [PATCH 18/23] fix(claude-ops): fix sync's primary-value-path gap, converge's wrong-repo risk, stale --force claim, unrendered install_new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more findings from Codex's PR review, each independently verified against current docs, actual CLI behavior, or the script's own output before fixing: - sync.md Step 2 gated in-repo update on CLAUDE_PROJECT_DIR being set directly, bypassing fleet-state.sh's own git-toplevel fallback — the exact case that fallback exists for. It also pre-filtered on divergences[] with versionsMatch:false, but divergences[] only holds ids with more than one scope record, so a single-scope project/local install (the common case) or a multi-scope install stale in every scope alike never appeared there and was silently never updated, defeating the skill's own documented "primary value path." Fixed to always call fleet-state.sh and unconditionally update every currentProject:true entry, mirroring Step 3's own pattern. - converge.md's proposed -s project/-s local commands carried no path targeting, but the CLI always operates on the current directory's settings — a divergence row can legitimately belong to a different repo than the one converge is running from. Fixed to require an explicit cd into the row's own projectPath before every project/local command. - "/reload-plugins has no --force flag" was wrong: current docs (code.claude.com/docs/en/discover-plugins) document --force (Claude Code >=2.1.163) for the case where an updated MCP-server plugin's non-deferred tools would otherwise invalidate the prompt cache. Fixed everywhere this claim appeared (SKILL.md, sync.md, scope-semantics.md, evals.json) to recommend --force only when that specific condition applies, not as a blanket default. - install_new's userConfig value was never actually rendered anywhere in this skill's content via ${user_config.install_new} — Claude Code only text-substitutes a configured value where that literal placeholder appears, so the model had no way to read the configured policy at runtime despite sync.md's Step 4 branching on it. Fixed by rendering the value in SKILL.md's userConfig section and pointing sync.md's Step 4 at that render instead of the option's static prose. The marketplace-CLI-syntax finding from the same review pass was verified INCORRECT (claude plugin marketplace update is documented CLI syntax at plugin-marketplaces.md's "non-interactive subcommands for scripting" section) and needs no fix, replied to on GitHub with the citation instead. Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/plugins/SKILL.md | 16 ++++++--- .../skills/plugins/context/converge.md | 10 ++++++ .../skills/plugins/context/scope-semantics.md | 20 +++++++---- .../claude-ops/skills/plugins/context/sync.md | 33 ++++++++++++++----- .../skills/plugins/evals/evals.json | 2 +- 5 files changed, 61 insertions(+), 20 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index ec836edfa..898c828bc 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -109,10 +109,12 @@ and `false` in another — are a known blind spot, not a reportable category: `f exposes the merged effective value, never each scope's raw map, so this skill cannot detect one to report it. See [context/converge.md](context/converge.md) "V1 scope".) -Close with reload guidance: recommend bare `/reload-plugins` (no `--force` — verified: no such flag -exists). If any updated component includes a monitor, call that out separately — monitors need a -full session restart, `/reload-plugins` doesn't cover them (see -[context/scope-semantics.md](context/scope-semantics.md)). +Close with reload guidance: recommend bare `/reload-plugins` by default; suggest `--force` only when +an updated/installed component ships an MCP server whose tools aren't deferred — that's the one case +`/reload-plugins` itself warns about and declines to apply without it (Claude Code ≥ 2.1.163; see +[context/scope-semantics.md](context/scope-semantics.md)). If any updated component includes a +monitor, call that out separately — monitors need a full session restart, `/reload-plugins` doesn't +cover them. ## userConfig: `install_new` @@ -125,6 +127,12 @@ schema has no `enum` type — verified against the published schema), default `" Any other value is invalid; treat it as `ask` and note the invalid value in the report. +**Configured value: `${user_config.install_new}`** — a `userConfig` value only reaches this skill's +own content through this literal `${user_config.KEY}` substitution (Claude Code text-substitutes it +before the model sees the rendered skill); declaring the option in `plugin.json` alone does not make +its value readable here. Sync's Step 4 branches on this line's rendered value, not on the option's +name or description above. + ## Cross-references - [context/sync.md](context/sync.md) — full `sync` algorithm diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index ff76707d4..ac16243d6 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -41,6 +41,16 @@ For each actionable divergence, decide the consolidation strategy from its `scop user baseline) → the default strategy is to bring the lagging scope(s) up to the newest version present: `claude plugin update -s `. +**Every `project`/`local`-scope command targets its row's own `scopes[].projectPath`, never the +current working directory.** `-s project`/`-s local` have no path/target flag — the CLI always +operates on the *current directory's* `.claude/settings*.json`. A divergence row can legitimately +belong to a different repo than the one this session is standing in (the "elsewhere on this machine" +rows a bulk report collapses) — never construct the proposed command as a bare +`claude plugin uninstall|update -s project`, only as +`(cd "" && claude plugin uninstall|update -s project)`. Presenting or +running the bare form for a row whose `projectPath` isn't the current directory would silently +mutate — or fail against — the wrong repo's settings. + Present every plugin's proposed strategy and exact CLI command(s) before running anything — do not batch-apply. Per Brief Decision 6 (V1): confirm **every** pin individually, even when many plugins share the same strategy — do not infer consent from one confirm to the next. diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md index 27665eea0..7fbd49a86 100644 --- a/plugins/claude-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -52,12 +52,20 @@ committed settings files are untouched by an update. `sync`'s in-repo update ste without a settings-diff review; `converge`'s scope-*consolidation* is the one action that can add or remove an `enabledPlugins` entry, and only that action surfaces a settings diff. -## No `--force` flag on `/reload-plugins` - -**Verified against `code.claude.com/docs/en/plugins-reference`**: no `--force` variant exists. -`/reload-plugins` alone refreshes skills, agents, hooks, MCP, and LSP servers in-process. It does -**not** cover monitors — a monitor requires a full session restart. Recommend bare `/reload-plugins` -in every report; call out the restart requirement only when an updated plugin ships a monitor. +## `/reload-plugins` — bare by default, `--force` for the MCP-cache-invalidation case + +**Verified against `code.claude.com/docs/en/discover-plugins`**: `/reload-plugins` refreshes skills, +agents, hooks, MCP, and LSP servers in-process. It does **not** cover monitors — a monitor requires a +full session restart. Recommend bare `/reload-plugins` by default; call out the restart requirement +only when an updated plugin ships a monitor. + +`--force` is real (Claude Code ≥ 2.1.163), but scoped to one specific case: a plugin that provides an +MCP server whose tools aren't deferred by tool search invalidates the prompt cache on reload, and +`/reload-plugins` warns and does **not** apply the reload rather than eating that cost silently; +`--force` applies it anyway. Only suggest `--force` when the updated/installed component in this +sync's report actually ships such an MCP server (or the report already surfaced that warning) — never +recommend it by default alongside every reload, since it exists specifically to opt into a real token +cost the bare command declines to pay automatically. ## `userConfig` has no `enum` field diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index 0226ac414..cb893662f 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -29,17 +29,29 @@ attributable and reported inline without aborting the sweep for the rest. ## Step 2 — In-repo update (the primary value path) -Only when `CLAUDE_PROJECT_DIR` is set (you're standing inside a project). Call `fleet-state.sh` and -look at `installed[]` entries with `currentProject: true`: +Always call `fleet-state.sh` first — never gate this step on `CLAUDE_PROJECT_DIR` being set before +calling it. `fleet-state.sh` resolves the project root itself (`CLAUDE_PROJECT_DIR` when set, the +cwd's git toplevel otherwise — see [gotchas.md](gotchas.md)), so a headless session where the env var +is unset can still correctly compute `currentProject`; gating on the raw env var directly would skip +this step in exactly the case that fallback exists for. + +Look at `installed[]` entries with `currentProject: true` and run an update for **every one of +them**, unconditionally: ```bash claude plugin update -s project # for a currentProject:true entry with scope "project" claude plugin update -s local # for a currentProject:true entry with scope "local" ``` -Run this **only** for entries whose id also appears in `divergences[]` with `versionsMatch: false` — -a `currentProject: true` entry with no divergence is already current, nothing to do. Verified safe: -`plugin update -s project` does not write the committed `.claude/settings.json` (see +Do **not** pre-filter on `divergences[]`. `divergences[]` only contains ids with *more than one* +scope record — a project/local install with no other scope pinning the same id (the common single- +pin case) never appears there at all, and neither does a multi-scope install where every scope +happens to already share the same stale version (`versionsMatch: true` — still behind the catalog, +just not internally disagreeing). Both are real staleness `fleet-state.sh` cannot detect from its own +output (it has no per-plugin catalog version to compare against), so the only correct signal is +"is this entry present" — mirror Step 3's own pattern and just call `update`, letting the CLI report +"already at the latest version" as a no-op when nothing changes. Verified safe: `plugin update +-s project` does not write the committed `.claude/settings.json` (see [scope-semantics.md](scope-semantics.md)) — no settings-diff review needed for this step, unlike `converge`. @@ -63,8 +75,9 @@ One call per plugin — `claude plugin update` takes a single `` argumen ## Step 4 — Install new catalog plugins (per `install_new` policy) Take `fleet-state.sh`'s `missing_from_install` (already excludes anything explicitly opted out with -`enabledPlugins: false` in any scope — never re-offer a deliberate decline). Apply the -`install_new` userConfig value: +`enabledPlugins: false` in any scope — never re-offer a deliberate decline). Apply the configured +policy — SKILL.md's `${user_config.install_new}` line renders the actual value; that render, not this +step's prose, is what to branch on: - **`ask`** (default) — present every entry in one batched `AskUserQuestion` multi-select, then `claude plugin install -s user` for each the user picks @@ -95,6 +108,8 @@ recorded either way. ## Step 6 — Report -Emit the report per SKILL.md's "Report" section. End with reload guidance: bare `/reload-plugins` -(verified — no `--force` flag exists); call out a session restart separately only when an updated +Emit the report per SKILL.md's "Report" section. End with reload guidance: bare `/reload-plugins` by +default; suggest `--force` only when an updated/installed component ships an MCP server whose tools +aren't deferred (see [scope-semantics.md](scope-semantics.md) — `--force` exists to opt into a real +token cost, not a blanket recommendation). Call out a session restart separately only when an updated component ships a monitor (monitors aren't covered by `/reload-plugins`). diff --git a/plugins/claude-ops/skills/plugins/evals/evals.json b/plugins/claude-ops/skills/plugins/evals/evals.json index 539521c2f..abb0d2a80 100644 --- a/plugins/claude-ops/skills/plugins/evals/evals.json +++ b/plugins/claude-ops/skills/plugins/evals/evals.json @@ -10,7 +10,7 @@ "expectations": [ "The response treats the bare invocation as the sync action, not audit or converge", "The marketplace target is resolved dynamically (via fleet-state.sh's CLAUDE_PLUGIN_ROOT join) rather than a hardcoded marketplace name", - "The response ends with a terse fixed-section report and bare `/reload-plugins` guidance (no `--force`)" + "The response ends with a terse fixed-section report and bare `/reload-plugins` guidance — `--force` is only suggested if an updated/installed component ships an MCP server whose tools aren't deferred, never as a blanket default" ] }, { From 348dd52308bcb42aefade437e50ee467367a9d54 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:08:13 -0400 Subject: [PATCH 19/23] fix(claude-ops): exclude defaultEnabled:false catalog plugins from missing_from_enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's PR review (on the previous fix push) found that Step 5 treated a plugin with no explicit enabledPlugins entry as a completeness gap needing claude plugin enable, even when the marketplace entry declares defaultEnabled:false — a publisher's deliberate opt-in-required default (plugins-reference.md: takes precedence over plugin.json's own field, "the user turns it on with claude plugin enable"). No entry anywhere is the intended state for those ids, not something sync should auto-flip. Fixed by reading defaultEnabled per catalog plugin from the same marketplace.json fleet-state.sh already parses for the catalog list, and excluding those ids from missing_from_enabled. Documented the one residual gap this doesn't cover (a plugin.json-only defaultEnabled with no mirrored marketplace-entry override — fleet-state.sh never reads an installed plugin's own manifest). Regression-tested: confirmed red before the fix, green after. 18 cases, 0 failed. Co-Authored-By: Claude Sonnet 5 --- .../claude-ops/skills/plugins/context/sync.md | 11 +++++++- .../skills/plugins/scripts/fleet-state.sh | 12 ++++++++- .../plugins/scripts/fleet-state.test.sh | 25 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index cb893662f..e8f88519f 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -95,7 +95,16 @@ memory of "I removed this on purpose." If that's not the intent, uninstall AND d ## Step 5 — `enabledPlugins` completeness Take `fleet-state.sh`'s `missing_from_enabled` — ids installed somewhere but never mentioned (true -or false) in any scope's `enabledPlugins`. For each, and for each scope where that id has an install +or false) in any scope's `enabledPlugins`, already excluding ids the marketplace ships with +`defaultEnabled: false`. That field is a publisher's deliberate opt-in-required default (it takes +precedence over the plugin's own `plugin.json` field — see +[scope-semantics.md](scope-semantics.md)); no explicit `enabledPlugins` entry for one of those ids is +the *intended* state, not a completeness gap — never run `enable` for it. This only catches the +default recorded in the marketplace entry; a plugin whose `defaultEnabled: false` lives only in its +own `plugin.json`, with no mirrored marketplace-entry override, is a known residual gap (`fleet-state.sh` +reads the marketplace's catalog file, never each installed plugin's own manifest). + +For each remaining id, and for each scope where that id has an install record (from `installed[]`) but no raw entry in that scope's own `enabledPlugins` map: ```bash diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index 4eaff2525..a5546d452 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -225,6 +225,15 @@ emit_marketplace() { local catalog_ids catalog_ids=$(jq -c --arg mp "$name" '[.[] | . + "@" + $mp]' <<<"$catalog") + # Ids the marketplace entry ships with defaultEnabled:false — a publisher's + # deliberate opt-in-required default (takes precedence over the plugin's own + # plugin.json field; see plugins-reference.md's "Default enablement"). No + # enabledPlugins entry anywhere for one of these is the INTENDED state, not + # a completeness gap — never auto-enable it. + local default_disabled_ids + default_disabled_ids=$(jq -c --arg mp "$name" \ + '[.plugins[]? | select(.defaultEnabled == false) | .name + "@" + $mp]' "$catalog_json") + # Every install record for ids in this marketplace, flattened, with the # currentProject flag Windows-normalized on both sides. local installed @@ -275,7 +284,8 @@ emit_marketplace() { local missing_from_enabled missing_from_enabled=$(jq -cn --argjson verifiable_ids "$verifiable_ids" --argjson known "$known_at_mp" \ - '$verifiable_ids - $known') + --argjson defaultDisabled "$default_disabled_ids" \ + '($verifiable_ids - $known) - $defaultDisabled') local enabled_at_mp enabled_at_mp=$(jq -cn --argjson known "$known_at_mp" --argjson eff "$effective_map" \ diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 22b934531..1b56f0f58 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -202,6 +202,31 @@ out=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$current_project_dir") missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) assert_eq "missing-enabled: other repo's already-enabled project install excluded, not false-flagged" '[]' "$missing_enabled" +# ============================================================================ +# Case: a marketplace-entry defaultEnabled:false install with no explicit +# enabledPlugins entry is a deliberate publisher opt-in-required default, NOT +# missing_from_enabled — auto-enabling it would override the publisher's +# intent (per plugins-reference.md: the marketplace entry's defaultEnabled +# takes precedence over plugin.json's, and "the user turns it on with +# claude plugin enable" is the documented opt-in path, not sync auto-enabling +# on their behalf) +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +write "$case_dir/installed_plugins.json" '{ + "version": 1, + "plugins": { + "alpha@market1": [{"scope": "user", "installPath": "y", "version": "0.1.0"}] + } +}' +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "defaultEnabled": false}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {}}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir") +missing_enabled=$(jq -c '.missing_from_enabled' <<<"$out" 2>/dev/null) +assert_eq "default-disabled: no explicit entry is the publisher's intended state, not missing_from_enabled" '[]' "$missing_enabled" + # ============================================================================ # Case: explicit enabledPlugins:false is an opt-out, NOT missing_from_enabled # ============================================================================ From 0d6f1f73f5659ef6e2603dfdbac81390ed9a983e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:08:29 -0400 Subject: [PATCH 20/23] fix(claude-ops): compare actual versions before choosing converge's consolidation strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's PR review found that Step 2 chose "uninstall project/local, fall through to user scope" whenever a user-scope entry existed at all, with no check that user scope actually held the newer version. divergences[] only proves scopes disagree, not which one is ahead — a repo pinning project:0.9.0 against a stale user:0.8.0 would have its project pin uninstalled in favor of the older user record, regressing the effective loaded version. That's the opposite of what converge exists to do, and the one action that can touch committed settings is exactly where a silent regression is worst. Fixed: Step 2 now compares scopes[].version (fleet-state.sh already emits it per scope) to find the actual highest version first, then picks the strategy from that — user scope only wins when it's genuinely the newest; otherwise every lagging scope, user included, updates to match the highest version present. Co-Authored-By: Claude Sonnet 5 --- .../skills/plugins/context/converge.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index ac16243d6..ab3e72961 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -31,15 +31,24 @@ Call `fleet-state.sh` (default marketplace, named one, or the current invocation ## Step 2 — Preview per-plugin intent -For each actionable divergence, decide the consolidation strategy from its `scopes[]`: - -- **A `user`-scope entry exists** → the default strategy is to make the *project/local* scope - fall through to it: `claude plugin uninstall -s project` (or `-s local`) removes the +For each actionable divergence, first find which of its `scopes[]` holds the **highest version** +(compare `scopes[].version` — semver dotted-numeric compare, not string/lexicographic). Never choose +a strategy from scope identity alone ("does a user entry exist") without this comparison first — +`fleet-state.sh` only proves the scopes *disagree*, never that `user` scope is the newer one. A repo +pinning `project: 0.9.0` against a stale `user: 0.8.0` has the project pin as the newest version +present; uninstalling it to "fall through to user scope" would regress the effective loaded version, +the opposite of bringing the fleet current. + +Then decide the consolidation strategy: + +- **`user` scope holds the highest version** → the default strategy is to make the *project/local* + scope fall through to it: `claude plugin uninstall -s project` (or `-s local`) removes the redundant lower-precedence pin, and scope precedence (local > project > user) means the project now loads whatever `user` scope has — always current from here on without a standing project pin. -- **No `user`-scope entry** (only multiple `project`/`local`-scope pins across different repos, no - user baseline) → the default strategy is to bring the lagging scope(s) up to the newest version - present: `claude plugin update -s `. +- **A `project`/`local` scope holds the highest version** (including when there's no `user`-scope + entry at all — only multiple `project`/`local`-scope pins across different repos) → the default + strategy is to bring every lagging scope, `user` scope included, up to that version: + `claude plugin update -s ` for each scope below the highest. **Every `project`/`local`-scope command targets its row's own `scopes[].projectPath`, never the current working directory.** `-s project`/`-s local` have no path/target flag — the CLI always From 6b42033718c897a5272e7f4fd0b7d31e0604359c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:33:07 -0400 Subject: [PATCH 21/23] fix(claude-ops): stop unconditionally case-folding project-path comparisons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's PR review found that currentProject and default-marketplace resolution both applied ascii_downcase to path comparisons unconditionally, mirroring hook::normalize_path's Windows-only case fold everywhere else calls it. hook::normalize_path itself is already platform-conditional (case-fold only on msys/cygwin/win32; pass through unchanged on POSIX, where filesystems are case-sensitive), but these two inline jq comparisons reimplemented the fold without the platform check. On a case-sensitive host (e.g. GitHub's ubuntu CI runner), two genuinely different sibling repos differing only by case (/work/repo vs /work/Repo) would compare equal, and since sync now unconditionally updates every currentProject:true entry, a false match could steer a project-scope mutation at the wrong repo. Fixed by computing the same $OSTYPE-based case-insensitive flag hook::normalize_path uses and gating both comparisons on it instead of folding case unconditionally. Regression-tested: same fixture run twice, once forcing a POSIX OSTYPE (must not match) and once with this host's real OSTYPE (must still match — no regression to the existing Windows behavior). Confirmed red against the unpatched script before fixing. 19 cases, 0 failed. Co-Authored-By: Claude Sonnet 5 --- .../skills/plugins/scripts/fleet-state.sh | 23 +++++++++++---- .../plugins/scripts/fleet-state.test.sh | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index a5546d452..6088f3bc9 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -160,17 +160,29 @@ if [[ -n "$PROJECT_ROOT" ]]; then current_project_norm="${current_project_norm%/}" fi +# Case-fold path comparisons ONLY on case-insensitive filesystems (mirrors +# hook::normalize_path's own $OSTYPE check exactly). Applying ascii_downcase +# unconditionally — as an earlier version of this script did — makes two +# genuinely different sibling repos on a case-sensitive POSIX host (e.g. +# /work/repo and /work/Repo) compare equal, which can point a project-scope +# mutation at the wrong repo. +case_insensitive_os="false" +case "${OSTYPE:-}" in +msys* | cygwin* | win32) case_insensitive_os="true" ;; +*) ;; +esac + # --- Resolve default marketplace: the one this plugin was installed from --- resolve_default_marketplace() { local plugin_root norm_root plugin_root="${CLAUDE_PLUGIN_ROOT:-$PLUGIN_ROOT_DEFAULT}" norm_root=$(hook::normalize_path "$(hook::physical_path "$plugin_root")") norm_root="${norm_root%/}" - jq -r --arg root "$norm_root" ' + jq -r --arg root "$norm_root" --argjson ci "$case_insensitive_os" ' .plugins | to_entries[] - | select(.value[] | (.installPath // "" | gsub("\\\\";"/")) == $root - or (.installPath // "" | gsub("\\\\";"/") | ascii_downcase) == ($root | ascii_downcase)) + | select(.value[] | (.installPath // "" | gsub("\\\\";"/")) as $p | + if $ci then ($p | ascii_downcase) == ($root | ascii_downcase) else $p == $root end) | .key ' "$INSTALLED_JSON" | head -1 | sed 's/.*@//' } @@ -237,7 +249,7 @@ emit_marketplace() { # Every install record for ids in this marketplace, flattened, with the # currentProject flag Windows-normalized on both sides. local installed - installed=$(jq -c --arg suffix "@$name" --arg cur "$current_project_norm" ' + installed=$(jq -c --arg suffix "@$name" --arg cur "$current_project_norm" --argjson ci "$case_insensitive_os" ' .plugins | to_entries[] | select(.key | endswith($suffix)) @@ -250,7 +262,8 @@ emit_marketplace() { projectPath: (.projectPath // null), currentProject: ( if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 and ($cur | length) > 0 then - ((.projectPath | gsub("\\\\";"/") | ascii_downcase) == ($cur | ascii_downcase)) + (.projectPath | gsub("\\\\";"/")) as $p | + if $ci then ($p | ascii_downcase) == ($cur | ascii_downcase) else $p == $cur end else null end ) } diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index 1b56f0f58..d916f177a 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -300,6 +300,35 @@ out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) assert_eq "windows-path: native backslash projectPath matches Git Bash cwd" "true" "$current_flag" +# ============================================================================ +# Case: case-sensitivity — a case-only path difference must NOT collapse two +# different repos on a case-sensitive filesystem (a fixed bug: the +# currentProject comparison used to case-fold unconditionally, so on a +# case-sensitive POSIX host, e.g. GitHub's ubuntu CI runner, a fixture +# projectPath differing only by case from the real project dir would +# false-positive as the same repo). Same fixture, run twice: once forcing a +# POSIX OSTYPE (must NOT match — case-sensitive), once with this host's real +# OSTYPE (must still match on a case-insensitive filesystem — no regression). +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +project_dir="$case_dir/case-test-root" +mkdir -p "$project_dir/.claude" +differently_cased_path="${project_dir/case-test-root/Case-Test-Root}" +write "$case_dir/installed_plugins.json" "$( + jq -cn --arg p "$differently_cased_path" \ + '{version: 1, plugins: {"alpha@market1": [{scope: "project", projectPath: $p, installPath: "x", version: "0.1.0"}]}}' +)" +write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}' +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}]}' +ARGS=(--marketplace market1) +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir" OSTYPE="linux-gnu") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "case-sensitivity: differently-cased sibling does not match on a POSIX (case-sensitive) host" "false" "$current_flag" +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) +assert_eq "case-sensitivity: still matches on this host's real (case-insensitive) OSTYPE — no regression" "true" "$current_flag" + # ============================================================================ # Case: CLAUDE_PROJECT_DIR unset falls back to the cwd's git toplevel — # the exact gap a live end-to-end run (a headless `-p` session) exposed: From 1de4658fe05886e7971948ad574d2b0ffca34145 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:33:29 -0400 Subject: [PATCH 22/23] fix(claude-ops): restrict Step 5's enable-fill loop to verifiable scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's PR review found that even after the missing_from_enabled fix excluded other-repo project/local installs from the missing set, an id that legitimately IS missing_from_enabled (e.g. a genuine user-scope gap) could still carry an unrelated other-repo project/local install record in installed[]. Step 5's per-scope loop iterated every scope with an install record for that id, including that unread other-repo record, and would run claude plugin enable -s project|local for it — risking a mutation at the current repo or an unread repo, the same wrong-repo class of bug already fixed in converge.md and missing_from_enabled itself. Fixed: Step 5's loop is now explicitly restricted to user scope, or project/local scope with currentProject:true — the exact same restriction already applied to missing_from_enabled's computation, extended to the downstream action that consumes it. Co-Authored-By: Claude Sonnet 5 --- plugins/claude-ops/skills/plugins/context/sync.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index e8f88519f..e85212304 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -104,8 +104,13 @@ default recorded in the marketplace entry; a plugin whose `defaultEnabled: false own `plugin.json`, with no mirrored marketplace-entry override, is a known residual gap (`fleet-state.sh` reads the marketplace's catalog file, never each installed plugin's own manifest). -For each remaining id, and for each scope where that id has an install -record (from `installed[]`) but no raw entry in that scope's own `enabledPlugins` map: +For each remaining id, and for each *verifiable* scope where that id has an install record (from +`installed[]`) but no raw entry in that scope's own `enabledPlugins` map — **`user` scope, or +`project`/`local` scope with `currentProject: true`, never a `project`/`local` record for a different +repo** (same restriction as `missing_from_enabled` itself, for the same reason: this invocation never +reads another repo's settings files, so it cannot know whether that record is genuinely unmentioned +there or already has its own entry — running `enable -s project|local` for it would risk mutating the +current repo or an unread repo instead): ```bash claude plugin enable -s From 6d751174e05d95a88ed70c6b6b5173913b343a83 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:41:23 -0400 Subject: [PATCH 23/23] fix(claude-ops): make the case-sensitivity regression test host-deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "no regression" half of the case-sensitivity test asserted the differently-cased fixture would still match under "this host's real OSTYPE," which only holds on the case-insensitive Windows dev machine this was authored on — CI's ubuntu runner has a genuinely case-sensitive OSTYPE, so the fix (working correctly) produced false there too, and the test's own hardcoded "true" expectation was wrong for that platform. Fixed by forcing both halves of the test explicitly (OSTYPE=linux-gnu / OSTYPE=msys) instead of relying on either host's ambient value, so the assertions hold identically everywhere. 19 cases, 0 failed, verified on this machine. Co-Authored-By: Claude Sonnet 5 --- .../skills/plugins/scripts/fleet-state.test.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh index d916f177a..76a14fdda 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -306,9 +306,12 @@ assert_eq "windows-path: native backslash projectPath matches Git Bash cwd" "tru # currentProject comparison used to case-fold unconditionally, so on a # case-sensitive POSIX host, e.g. GitHub's ubuntu CI runner, a fixture # projectPath differing only by case from the real project dir would -# false-positive as the same repo). Same fixture, run twice: once forcing a -# POSIX OSTYPE (must NOT match — case-sensitive), once with this host's real -# OSTYPE (must still match on a case-insensitive filesystem — no regression). +# false-positive as the same repo). Same fixture, run twice with an EXPLICIT +# OSTYPE override each time (never "this host's ambient OSTYPE" — this suite +# itself runs on both a case-insensitive dev host and a case-sensitive CI +# runner, so asserting against the ambient value is not deterministic across +# them): forcing linux-gnu must NOT match (case-sensitive); forcing msys must +# still match (case-insensitive — no regression to the existing behavior). # ============================================================================ CASE_NUM=$((CASE_NUM + 1)) case_dir=$(new_case_dir) @@ -325,9 +328,9 @@ ARGS=(--marketplace market1) out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir" OSTYPE="linux-gnu") current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) assert_eq "case-sensitivity: differently-cased sibling does not match on a POSIX (case-sensitive) host" "false" "$current_flag" -out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir") +out=$(run_state "$case_dir" CLAUDE_PROJECT_DIR="$project_dir" OSTYPE="msys") current_flag=$(jq -r '.installed[0].currentProject' <<<"$out" 2>/dev/null) -assert_eq "case-sensitivity: still matches on this host's real (case-insensitive) OSTYPE — no regression" "true" "$current_flag" +assert_eq "case-sensitivity: still matches under a forced case-insensitive OSTYPE — no regression" "true" "$current_flag" # ============================================================================ # Case: CLAUDE_PROJECT_DIR unset falls back to the cwd's git toplevel —