Skip to content

claude-ops:plugins sync — an already-current fleet costs ~7s of script work but a full 52KB doc load, and the run cannot reproduce its own report after a compaction #3728

Description

@kyle-sexton

Summary

Three findings from a /claude-ops:plugins sync run on 2026-09-04 (Claude Code 2.1.260, claude-ops 0.41.13) against an already-current fleet. Every step was a no-op — the marketplace refreshed cleanly, update-candidates-user returned 0 of 74 user-scope ids, and missing_from_user_install and missing_from_enabled were both empty — so this is the cheapest path the algorithm has, and it is where the fixed overheads show up unmixed with real work.

None of the three are correctness bugs. The sync did the right thing. They are cost and resilience findings.

The fourth finding from the same run, stale project-scope install records, is already tracked as #3688 and has been commented there rather than duplicated here.

Finding 1 — the --ids selector rebuilds state the algorithm just built

Measured on this run:

call seconds
fleet-state.sh full JSON report 1.46
fleet-state.sh --marketplace melodic-software --ids update-candidates-user 2.26
claude plugin marketplace update melodic-software 1.60

The selector call costs more than the full report it duplicates. It is a separate process that re-parses the 462 KB installed_plugins.json, re-walks the catalog manifests, and re-runs the realpath batch, then projects one field set out of the result.

The concurrency rule in context/sync.md already mandates a full fleet-state.sh re-read immediately before each mutating step, so on the standard path that process runs anyway and its JSON contains everything the selector emits. update-candidates-user, missing-user-install, missing-enabled, and current-project are all derivable from installed[], catalog_versions, enabled, and project_root in that same document.

The stated reason for mandating --ids over hand-rolled jq is sound and documented in context/gotchas.md: a Windows jq -r ... | while read silently appends a carriage return to every id but the last. That argues against hand-rolling the extraction — not against the script exposing the projection from an already-parsed report.

Two shapes worth considering:

  1. fleet-state.sh --ids <selector> --from <report.json> — project from a saved report instead of rebuilding. Keeps one code path for the projection logic, so the carriage-return protection is unchanged.
  2. Emit the selector lists as arrays inside the main JSON report, and have the caller read them from there.

The saving is roughly 2.3 s per selector call per marketplace. On this single-marketplace run that is 2.3 s out of about 7 s of total script work. Under all mode with several marketplaces, and with Steps 2, 3, 4, and 5 each taking their own selector, it multiplies.

Finding 2 — no durable run journal, so a long sweep can lose the ability to report itself

The "Version capture for the report" section of context/sync.md is explicit about this:

Keep that snapshot (and each claude plugin update line as it is emitted) available through Step 6 rather than assuming it can be recovered; a sweep of several dozen mutations whose report depends on the old/new pairs is otherwise one context compaction away from being unable to emit its own report. This skill provides no durable log for that today.

The section names the failure and the reason the script does not write one, then leaves the mitigation as "hold it in context". On a fleet this size that is a real exposure: the pre-sweep snapshot is the sole source of every old version value, those values are destroyed by the sweep itself, and the Step 6 report format requires them. A sweep of 74 user-scope updates plus in-repo updates, interleaved with each claude plugin update output line, is exactly the shape of run most likely to hit a compaction before it reports.

The three-snapshot divergence attribution in Step 6 has the same dependency — it needs the divergences[] read from all three fleet-state.sh calls to attribute a new row to the interval it first appeared in.

Suggested fix: write a per-run journal under the plugin data directory — the pre-sweep snapshot, each mutating call and its output, and the post-sweep re-read. Step 6 then reads the journal rather than reconstructing from conversation. It also gives converge and a later audit a real before-state to compare against, and it is the durable log the CHANGELOG currently lists as deferred.

Finding 3 — Steps 4 and 5 load unconditionally on the path where they cannot do anything

The skill's docs are 92,691 bytes across 5 files. This run loaded SKILL.md (19,036 B) and context/sync.md (32,996 B) in full: about 52 KB to execute five operations, all no-ops.

context/sync.md is 508 lines. Steps 4 and 5 are roughly 100 of them and include the install_new policy branches, the --setting-sources caveat, the reinstall-recurrence caveat, the normalize-enabled-plugins.sh contract, the defaultEnabled precedence rule, and the project-scope enable-gap suppression ordering. Every one of those is unreachable when missing_from_user_install and missing_from_enabled are both empty — which, on a fleet kept current, is the common case and was the case here.

The gating signal is already in hand before either step: both arrays come from the fleet-state.sh report that Step 1 precedes and that Steps 2 and 3 already read.

Suggested fix: move Steps 4 and 5 into their own spoke, for example context/sync-install-enable.md, and have sync.md carry a one-line pointer with its read condition — read this only when missing_from_user_install or missing_from_enabled is non-empty. That is the progressive-disclosure pattern the hub already uses for converge.md and scope-semantics.md, applied to the branch of sync.md that is conditional in exactly the same way.

This trims the common path without weakening the uncommon one, and it does not touch the two blocks that SKILL.md documents as deliberate hub-only exceptions (the Report template and the install_new render).

Run evidence

Marketplace: melodic-software — current (autoUpdate: on)
  lastUpdated 2026-09-04T20:23:02.759Z -> 2026-09-04T20:36:42.951Z; catalog unchanged, 0 entries and 0 versions moved
In-repo: skipped — no project context resolved from C:\Users\KyleSexton
Updated: 0 plugin(s) — update-candidates-user returned 0 of 74
Installed: 0 — missing_from_user_install empty
Enabled: 0 — missing_from_enabled empty
Divergences: 64 actionable (0 newly created by this run — 64 pre-existing)   [tracked in #3688]
Stale project records: 755 record(s) across 12 path(s)                        [tracked in #3688]

Machine work totalled about 7 seconds; wall clock for the whole invocation was 189 seconds, and the difference is doc load and analysis rather than CLI time.

Environment

  • Claude Code 2.1.260, Windows 11 Pro 10.0.26200, Git Bash
  • claude-ops 0.41.13, marketplace melodic-software, autoUpdate: on
  • 74 catalog plugins, 1,079 install records, installed_plugins.json 462,371 bytes

Verification

Timing is reproducible with date +%s.%N around each call. Two incidental notes for anyone reproducing on this machine shape: bc is not present in this Git Bash install, so the arithmetic needs awk; and the guardrails block-windows-drive-tmp guard rejects /tmp as a scratch root, so scratch files need $TEMP.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

priority: mediumReal value, no hard deadline; normal backlog flow.status: readyTriaged, unblocked, and fully specified; eligible to pick up.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions