From a3ff6fcb3731c5dc68be9569a5a6fd5e38664672 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:26:41 -0400 Subject: [PATCH 1/7] feat(claude-ops): add fleet-state.sh --from selector projection (#3728) The --ids selector rebuilt state the caller was already holding. `sync` re-reads the full JSON report before each mutating step, and every selector (update-candidates-user, missing-user-install, missing-enabled, current-project, installed-user, user-scope-orphans) is derivable from that report, so the separate live --ids process paid a second process creation to re-parse installed_plugins.json, re-walk the catalog manifests, and re-run realpath in order to recompute a block already in hand. `--ids --from ` projects from a saved single-marketplace report instead. The projection is lifted into one PROJECTION_PROGRAM that both pass 3 and --from run, so the CR-free, TAB-separated output contract cannot drift between the two modes, which is the whole reason the selector exists rather than a hand-written jq at each call site. A --from run reads no Claude Code state file at all. Every rejection is exit 2 with stdout left EMPTY, because the documented consumer is a process substitution that cannot see the exit status: --from with --all, --from without --ids, a missing or malformed file, an --all envelope (valid JSON every selector projects to nothing, refused by name rather than silently returning an empty list), and a --marketplace that disagrees with the report's own marketplace.name. Under --from that flag is an optional consistency check, never a second read. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- .../skills/plugins/scripts/fleet-state.sh | 126 +++++++++++-- .../plugins/scripts/fleet-state.test.sh | 173 ++++++++++++++++++ 2 files changed, 286 insertions(+), 13 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index d1e7423c97..d6f0f09769 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -12,6 +12,7 @@ # Usage: # fleet-state.sh [--marketplace | --all] # fleet-state.sh [--marketplace ] --ids +# fleet-state.sh --ids --from [--marketplace ] # fleet-state.sh --marketplaces # # With neither flag, resolves the default marketplace dynamically: the one @@ -92,6 +93,22 @@ # Zero matches is success with empty output (exit 0), not an error. Reject: # `--ids` with `--all` (no single block to project), or an unknown selector. # +# `--from ` projects the selector from a report this script +# already emitted (the object `--marketplace ` prints) instead of +# recomputing the fleet: no CC state file is read, no catalog manifest is +# walked, no realpath runs. Every selector is derivable from that report, and +# `sync` re-reads the full report before each mutating step anyway, so the +# separate live `--ids` process was recomputing a block the caller already +# held. The projection runs the SAME jq program the live mode runs, so the +# CR-free, TAB-separated output contract is unchanged. Reject (exit 2, stdout +# left EMPTY so a `< <(…)` consumer can never read an error as an id): +# `--from` with `--all`, `--from` without `--ids`, a missing or unreadable +# file, JSON that is not a single-marketplace report (an `--all` envelope is +# refused by name rather than projected to a silent empty list), and a +# `--marketplace ` that disagrees with the report's own +# `marketplace.name` — that flag is an optional consistency check here, never +# a second read. +# # Output (stdout) with --marketplaces: NOT JSON — every marketplace name from # known_marketplaces.json, one per line, nothing else, CR-free by the same # capture discipline as --ids. Empty output for an empty object is success @@ -114,7 +131,8 @@ # 1 a single-marketplace run's marketplace could not be resolved/read # 2 fatal: jq missing, an internal CC state file is present but does not # match its expected shape (fail loud on schema drift — never guess), a -# bad/absent --ids selector, or --ids combined with --all +# bad/absent --ids selector, --ids combined with --all, or a rejected +# --from invocation (see the --from paragraph above) # # Process budget. Every external command this script runs is a process # creation, and on Windows Git Bash each one costs fork() emulation plus a @@ -304,6 +322,7 @@ MODE="default" TARGET="" IDS_SELECTOR="" LIST_MARKETPLACES="" +FROM_REPORT="" while [[ $# -gt 0 ]]; do case "$1" in --marketplaces) @@ -320,6 +339,15 @@ while [[ $# -gt 0 ]]; do fi shift 2 ;; + --from) + FROM_REPORT="${2:-}" + # Same guard-before-`shift 2` reasoning as --marketplace below. + if [[ -z "$FROM_REPORT" ]]; then + echo "ERROR: --from requires a report path" >&2 + exit 2 + fi + shift 2 + ;; --marketplace) MODE="single" TARGET="${2:-}" @@ -356,8 +384,8 @@ done # order-independent: `--marketplaces --all` and `--all --marketplaces` fail # identically. if [[ -n "$LIST_MARKETPLACES" ]]; then - if [[ "$MODE" != "default" || -n "$IDS_SELECTOR" ]]; then - echo "ERROR: --marketplaces cannot be combined with --marketplace, --all, or --ids" >&2 + if [[ "$MODE" != "default" || -n "$IDS_SELECTOR" || -n "$FROM_REPORT" ]]; then + echo "ERROR: --marketplaces cannot be combined with --marketplace, --all, --ids, or --from" >&2 exit 2 fi MODE="list" @@ -418,6 +446,87 @@ if [[ -n "$IDS_SELECTOR" ]]; then ids_selector_valid "$IDS_SELECTOR" || exit 2 fi +# The selector projection, in ONE place. Pass 3 appends it to its own program so +# a live run projects the block it just composed; `--from` runs it standalone +# over a saved report. One code path means the CR-safe, tab-separated output +# contract cannot drift between the two modes — the whole reason `--ids` exists +# instead of a hand-written jq at each call site. +# shellcheck disable=SC2016 # a jq program: every $var is a jq variable +PROJECTION_PROGRAM=' + if $selector == "" then $block + elif $selector == "installed-user" then ($block.installed[]? | select(.scope == "user") | .id) + elif $selector == "update-candidates-user" then + ($block.catalog_versions as $cvs | $block.installed[]? | select(.scope == "user") + | select(($cvs[.id]) == null or ($cvs[.id]) != .version) | .id) + elif $selector == "current-project" then ($block.installed[]? | select(.currentProject == true) | "\(.id)\t\(.scope)") + elif $selector == "missing-user-install" then $block.missing_from_user_install[]? + elif $selector == "missing-enabled" then $block.missing_from_enabled[]? + elif $selector == "user-scope-orphans" then $block.user_scope_orphans[]? + else error("unknown selector: " + $selector) end' + +# --- --from: project a selector from an already-emitted report ---------------- +# `sync` re-reads the full JSON report before each mutating step anyway, and +# every selector is derivable from that report. Re-running the whole live +# pipeline for the projection costs a second process that re-parses +# installed_plugins.json, re-walks the catalog manifests, and re-runs realpath, +# to recompute a block the caller is already holding. `--from` runs the same +# PROJECTION_PROGRAM over the saved report instead. +# +# It is a PROJECTION mode, so every flag that would drive a live read is a +# contradiction and is refused rather than silently ignored: `--all` has no +# single block to project (the same reason --ids refuses it), and `--from` +# without `--ids` has nothing to project and would otherwise fall through to +# the `$selector == ""` branch and echo the report straight back. +FS_FROM_NAME="" +if [[ -n "$FROM_REPORT" ]]; then + if [[ "$MODE" == "all" ]]; then + echo "ERROR: --from cannot be combined with --all" >&2 + echo " --from projects ONE saved single-marketplace report." >&2 + exit 2 + fi + if [[ -z "$IDS_SELECTOR" ]]; then + echo "ERROR: --from requires --ids " >&2 + echo " --from projects a selector from a saved report; it emits no JSON report itself." >&2 + exit 2 + fi + if [[ ! -f "$FROM_REPORT" ]]; then + echo "ERROR: --from report not found: $FROM_REPORT" >&2 + exit 2 + fi + # Validate the SHAPE, not just the JSON. An --all envelope + # ({"marketplaces": {...}}) parses fine and every selector branch projects it + # to nothing — a silently-empty id list, which is the exact failure class the + # --ids contract exists to prevent. Fail loud and name the fix instead. + if ! jq_to FS_FROM_NAME -er ' + if type != "object" then error("not a JSON object") + elif has("marketplaces") then error("this is an --all envelope, not a single-marketplace report") + elif (.marketplace | type) != "object" or (.marketplace.name | type) != "string" + then error("no .marketplace.name string") + elif (.installed | type) != "array" then error("no .installed array") + else .marketplace.name end' "$FROM_REPORT" 2>/dev/null; then + echo "ERROR: --from report is not a single-marketplace fleet-state report: $FROM_REPORT" >&2 + echo " Expected the JSON object \`--marketplace \` prints (.marketplace.name," >&2 + echo " .installed, .catalog_versions, …), not an --all envelope or arbitrary JSON." >&2 + exit 2 + fi + # --marketplace alongside --from is an optional CONSISTENCY CHECK, never a + # second read: projecting one marketplace's report while believing it is + # another's is how a sweep mutates the wrong fleet. + if [[ "$MODE" == "single" && "$TARGET" != "$FS_FROM_NAME" ]]; then + echo "ERROR: --from report is for marketplace '$FS_FROM_NAME', not '$TARGET': $FROM_REPORT" >&2 + exit 2 + fi + # Same jq_to capture discipline as every other mode, so the CR strip is + # identical; zero matches is success with EMPTY output, never a blank line. + if ! jq_to FS_BLOCK -r --arg selector "$IDS_SELECTOR" \ + ". as \$block | $PROJECTION_PROGRAM" "$FROM_REPORT"; then + echo "ERROR: --from projection failed for selector '$IDS_SELECTOR': $FROM_REPORT" >&2 + exit 2 + fi + [[ -n "$FS_BLOCK" ]] && printf '%s\n' "$FS_BLOCK" + exit 0 +fi + # --- Fail-loud presence checks 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 @@ -879,16 +988,7 @@ PASS3_PROGRAM=' user_scope_orphans: $user_scope_orphans, divergences: $divergences } as $block - | if $selector == "" then $block - elif $selector == "installed-user" then ($block.installed[]? | select(.scope == "user") | .id) - elif $selector == "update-candidates-user" then - ($block.catalog_versions as $cvs | $block.installed[]? | select(.scope == "user") - | select(($cvs[.id]) == null or ($cvs[.id]) != .version) | .id) - elif $selector == "current-project" then ($block.installed[]? | select(.currentProject == true) | "\(.id)\t\(.scope)") - elif $selector == "missing-user-install" then $block.missing_from_user_install[]? - elif $selector == "missing-enabled" then $block.missing_from_enabled[]? - elif $selector == "user-scope-orphans" then $block.user_scope_orphans[]? - else error("unknown selector: " + $selector) end' + | '"$PROJECTION_PROGRAM" emit_marketplace() { local name="$1" 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 0097b00e26..3f4a606672 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -2023,6 +2023,179 @@ else fail "process budget: an --ids projection costs at most 12 process creations" "measured $ids_count" fi +# ============================================================================ +# Case: --from projects every selector from a saved report, identically to the +# live projection. The point of the flag is that `sync` already holds the JSON +# report before each mutating step, so the second live process was recomputing +# a block the caller had. Equality with the live run is the whole contract. +# ============================================================================ +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"}, + {"scope": "project", "projectPath": "/sample-repo", "installPath": "x", "version": "0.1.0"} + ], + "beta@market1": [{"scope": "user", "installPath": "z", "version": "0.2.0"}], + "delta@market1": [{"scope": "local", "projectPath": "/sample-repo", "installPath": "w", "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"}, {"name": "beta"}, {"name": "gamma"}, {"name": "delta"}]}' +mkdir -p "$case_dir/sample-repo" +ARGS=(--marketplace market1) +report=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$case_dir/sample-repo") +rc=$? +assert_exit "--from: the saved report itself is produced" 0 "$rc" +write "$case_dir/report.json" "$report" + +for sel in installed-user update-candidates-user current-project \ + missing-user-install missing-enabled user-scope-orphans; do + ARGS=(--marketplace market1 --ids "$sel") + live=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$case_dir/sample-repo") + ARGS=(--ids "$sel" --from "$case_dir/report.json") + projected=$(run_state "$case_dir" "CLAUDE_PROJECT_DIR=$case_dir/sample-repo") + assert_eq "--from: '$sel' projection equals the live projection" "$live" "$projected" +done + +# The fixture is built so the selectors are not all empty — an all-empty +# comparison would pass vacuously and prove nothing about the projection. +ARGS=(--ids missing-user-install --from "$case_dir/report.json") +assert_eq "--from: the compared selectors are non-empty (missing-user-install)" \ + "$(printf 'delta@market1\ngamma@market1')" "$(run_state "$case_dir")" + +# current-project carries a SECOND tab-separated field (the record's scope) that +# Step 2 reads off the same line. Asserted against a hand-written report so the +# case does not depend on this host resolving a fixture project root: --from +# reads the report and nothing else. +write "$case_dir/current-project-report.json" '{ + "marketplace": {"name": "market1", "autoUpdate": false, "lastUpdated": "2026-01-01T00:00:00Z"}, + "project_root": "/tmp/sample-repo", + "catalog": ["alpha"], + "catalog_versions": {"alpha@market1": "0.1.0"}, + "installed": [ + {"id": "alpha@market1", "scope": "project", "version": "0.1.0", "currentProject": true}, + {"id": "alpha@market1", "scope": "local", "version": "0.1.0", "currentProject": true}, + {"id": "beta@market1", "scope": "user", "version": "0.2.0", "currentProject": null} + ], + "enabled": {}, + "missing_from_install": [], + "missing_from_user_install": [], + "missing_from_enabled": [], + "user_scope_orphans": [], + "divergences": [] +}' +ARGS=(--ids current-project --from "$case_dir/current-project-report.json") +assert_eq "--from: current-project keeps its second TAB field, one line per scope record" \ + "$(printf 'alpha@market1\tproject\nalpha@market1\tlocal')" "$(run_state "$case_dir")" + +# ============================================================================ +# Case: every --from rejection is exit 2 with EMPTY stdout. The documented +# consumer is `while read … done < <(… --ids …)`, which cannot see the exit +# status, so anything left on stdout would be handed to `claude plugin update` +# as an id. Captured WITHOUT 2>&1 for exactly that reason. +# ============================================================================ +run_from_stdout_only() { + local case_dir="$1" + shift + 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" \ + bash "$SCRIPT" "$@" +} + +CASE_NUM=$((CASE_NUM + 1)) +prev_case_dir="$case_dir" +case_dir=$(new_case_dir) +cp "$prev_case_dir/installed_plugins.json" "$prev_case_dir/known_marketplaces.json" \ + "$prev_case_dir/user_settings.json" "$prev_case_dir/report.json" "$case_dir/" +cp "$prev_case_dir/catalog/market1.json" "$case_dir/catalog/" +write "$case_dir/malformed.json" '{"marketplace": {"name": "market1"' +write "$case_dir/envelope.json" '{"marketplaces": {"market1": {"marketplace": {"name": "market1"}, "installed": []}}}' + +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/absent.json" 2>/dev/null) +rc=$? +assert_exit "--from: a missing report is exit 2" 2 "$rc" +assert_eq "--from: a missing report leaves stdout empty" "" "$out" +err=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/absent.json" 2>&1 >/dev/null) +assert_contains "--from: the missing-report error names the file" "$err" "$case_dir/absent.json" + +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/malformed.json" 2>/dev/null) +rc=$? +assert_exit "--from: a malformed report is exit 2" 2 "$rc" +assert_eq "--from: a malformed report leaves stdout empty" "" "$out" +err=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/malformed.json" 2>&1 >/dev/null) +assert_contains "--from: the malformed-report error names the file" "$err" "$case_dir/malformed.json" + +# An --all envelope parses as JSON and every selector branch projects it to +# nothing. A silently-empty id list is the failure class the --ids contract +# exists to prevent, so it is refused by name. +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/envelope.json" 2>/dev/null) +rc=$? +assert_exit "--from: an --all envelope is refused, not projected to empty" 2 "$rc" +assert_eq "--from: the refused envelope leaves stdout empty" "" "$out" + +out=$(run_from_stdout_only "$case_dir" --all --ids missing-enabled --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: combined with --all is exit 2" 2 "$rc" +assert_eq "--from: the --all rejection leaves stdout empty" "" "$out" +err=$(run_from_stdout_only "$case_dir" --all --ids missing-enabled --from "$case_dir/report.json" 2>&1 >/dev/null) +assert_contains "--from: the --all rejection names --all" "$err" "cannot be combined with --all" + +# Without a selector the earlier --ids/--all guard cannot fire, so this is the +# --from path's own --all rejection rather than the existing one. +out=$(run_from_stdout_only "$case_dir" --all --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: --all without --ids is still exit 2" 2 "$rc" +assert_eq "--from: that rejection leaves stdout empty" "" "$out" +err=$(run_from_stdout_only "$case_dir" --all --from "$case_dir/report.json" 2>&1 >/dev/null) +assert_contains "--from: its own --all rejection names --from" "$err" "--from cannot be combined with --all" + +# Without --ids there is nothing to project; falling through would echo the +# report back as if it had been recomputed. +out=$(run_from_stdout_only "$case_dir" --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: without --ids is exit 2" 2 "$rc" +assert_eq "--from: the no-selector rejection leaves stdout empty" "" "$out" + +# --marketplace alongside --from is a consistency check, never a second read. +out=$(run_from_stdout_only "$case_dir" --marketplace other --ids missing-enabled --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: a disagreeing --marketplace is exit 2" 2 "$rc" +assert_eq "--from: the disagreement rejection leaves stdout empty" "" "$out" +out=$(run_from_stdout_only "$case_dir" --marketplace market1 --ids missing-user-install --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: an agreeing --marketplace passes the consistency check" 0 "$rc" +assert_eq "--from: the agreeing run still projects" "$(printf 'delta@market1\ngamma@market1')" "$out" + +out=$(run_from_stdout_only "$case_dir" --marketplaces --ids missing-enabled --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: combined with --marketplaces is exit 2" 2 "$rc" + +# ============================================================================ +# Case: --from reads NO Claude Code state file. That is the cost claim — the +# projection recomputes nothing — so it must hold even when every state file +# the live path requires is absent. +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +prev_case_dir="$case_dir" +case_dir=$(new_case_dir) +cp "$prev_case_dir/report.json" "$case_dir/" +out=$(env \ + FLEET_STATE_INSTALLED_JSON="$case_dir/absent-installed.json" \ + FLEET_STATE_MARKETPLACES_JSON="$case_dir/absent-marketplaces.json" \ + FLEET_STATE_USER_SETTINGS="$case_dir/absent-settings.json" \ + FLEET_STATE_CATALOG_DIR="$case_dir/catalog" \ + bash "$SCRIPT" --ids missing-user-install --from "$case_dir/report.json" 2>/dev/null) +rc=$? +assert_exit "--from: projects with every CC state file absent" 0 "$rc" +assert_eq "--from: the state-free projection still emits the ids" \ + "$(printf 'delta@market1\ngamma@market1')" "$out" + # --- Summary ------------------------------------------------------------- printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" [[ "$FAILED" -eq 0 ]] && exit 0 From 994fa40ea94c81d701da19e4a28902eab5d6f342 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:27:19 -0400 Subject: [PATCH 2/7] refactor(claude-ops): move sync Steps 4 and 5 into their own spoke (#3728) Steps 4 and 5 are roughly a hundred of sync.md's lines: the install_new policy branches, the --setting-sources caveat, the reinstall-recurrence caveat, the normalize-enabled-plugins.sh contract, defaultEnabled precedence, and the project-scope enable-gap suppression ordering. They loaded on every run and are unreachable when missing_from_user_install and missing_from_enabled are both empty, which is the common case on a current fleet. The gating signal is already in the Step 1 report. The text moves verbatim into context/sync-install-enable.md with a header stating its read condition, which carries both gates: either array non-empty, or a marketplace whose Step 1 refresh failed and whose report has to name what these two steps deferred. Same progressive-disclosure pattern the hub already uses for converge.md and scope-semantics.md. References that named "sync.md Step 4" or "Step 5" in converge.md, gotchas.md, and scope-semantics.md now point at the new spoke, and the step-internal "see Step 3" references become explicit cross-file links. sync.md's pointer paragraph and SKILL.md's spoke-table row land in the next commit, which is where the rest of those two files' changes live. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- .../skills/plugins/context/converge.md | 2 +- .../skills/plugins/context/gotchas.md | 4 +- .../skills/plugins/context/scope-semantics.md | 4 +- .../plugins/context/sync-install-enable.md | 191 ++++++++++++++++++ 4 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 plugins/claude-ops/skills/plugins/context/sync-install-enable.md diff --git a/plugins/claude-ops/skills/plugins/context/converge.md b/plugins/claude-ops/skills/plugins/context/converge.md index ce522c013b..2bce59b8d0 100644 --- a/plugins/claude-ops/skills/plugins/context/converge.md +++ b/plugins/claude-ops/skills/plugins/context/converge.md @@ -1,7 +1,7 @@ # Converge — explicit scope consolidation `converge` is the **only** action that can rewrite a committed `.claude/settings.json`, and only -after an explicit per-plugin confirm — [sync.md](sync.md) Step 5 keeps it that way by reporting a +after an explicit per-plugin confirm — [sync-install-enable.md](sync-install-enable.md) Step 5 keeps it that way by reporting a `project`-scope enable gap instead of filling it. It never runs implicitly from `sync`: that report only names the `converge` command, and the user runs it explicitly. diff --git a/plugins/claude-ops/skills/plugins/context/gotchas.md b/plugins/claude-ops/skills/plugins/context/gotchas.md index 1c49dcff32..af636f2054 100644 --- a/plugins/claude-ops/skills/plugins/context/gotchas.md +++ b/plugins/claude-ops/skills/plugins/context/gotchas.md @@ -128,8 +128,8 @@ Claude Code substitutes `userConfig` values when it renders the **skill**. A con `${user_config.install_new}` in a spoke and it arrives as that literal token, with **no error and no warning**; the value simply never appears, and a step branching on it branches on a placeholder. -This is why `SKILL.md` holds the `install_new` render and `sync.md` Step 4 branches on *that* line -rather than on its own prose. Verified empirically: `context/sync.md` on disk shows the raw +This is why `SKILL.md` holds the `install_new` render and `sync-install-enable.md` Step 4 branches on *that* line +rather than on its own prose. Verified empirically: `context/sync-install-enable.md` on disk shows the raw `${user_config.install_new}` token in the same session where `SKILL.md`'s render shows the configured value. Nothing enforces this — a future spoke that inlines such a token fails silently, so it is a review-time rule, not a checkable one. diff --git a/plugins/claude-ops/skills/plugins/context/scope-semantics.md b/plugins/claude-ops/skills/plugins/context/scope-semantics.md index 1abd8ccd09..9a28e02bbe 100644 --- a/plugins/claude-ops/skills/plugins/context/scope-semantics.md +++ b/plugins/claude-ops/skills/plugins/context/scope-semantics.md @@ -96,7 +96,7 @@ Two consequences: `enabledPlugins` entry still dirties the tracked file, with a diff that changes no behavior: an empty map plus a key reorder. Expect it; it is not evidence an entry was removed. [converge.md](converge.md) Step 5 classifies it. -- **`sync`** — this is why [sync.md](sync.md) Step 5 enables automatically only at `user` and `local` +- **`sync`** — this is why [sync-install-enable.md](sync-install-enable.md) Step 5 enables automatically only at `user` and `local` scope and reports a `project`-scope gap instead of filling it. `sync` has no autonomous-session abort, so it has no safe moment to write a committed file; after that restriction, no `sync` path writes one. @@ -213,7 +213,7 @@ Two consequences this skill must not get wrong: indication the configured value was discarded. Never advise setting it at project or local scope. - A `--setting-sources` invocation that omits `user` drops user settings from that three-source read list, so a headless `sync` launched that way silently loses `install_new` the same way. See - [sync.md](sync.md) Step 4 — the fallback is correct, the silence is not. + [sync-install-enable.md](sync-install-enable.md) Step 4 — the fallback is correct, the silence is not. ## `userConfig` has no `enum` field diff --git a/plugins/claude-ops/skills/plugins/context/sync-install-enable.md b/plugins/claude-ops/skills/plugins/context/sync-install-enable.md new file mode 100644 index 0000000000..3772f03ff2 --- /dev/null +++ b/plugins/claude-ops/skills/plugins/context/sync-install-enable.md @@ -0,0 +1,191 @@ +# Sync Steps 4 and 5 — install and enable + +Read this file only when the Step 1 `fleet-state.sh` report for the marketplace being swept has a +non-empty `missing_from_user_install` **or** a non-empty `missing_from_enabled`, or when Step 1's +refresh failed for that marketplace and the report has to name what these two steps deferred. On an +already-current fleet both arrays are empty, both steps are no-ops, and none of this is reachable. + +The steps below are the loop body of [sync.md](sync.md) Steps 2–5, run once per marketplace, and +every rule that file states — CLI-mediated mutation only, the per-marketplace failure rule, the +re-read boundary at the step — applies here unchanged. + +## Contents + +- [Step 4 — Install new catalog plugins (per `install_new` policy)](#step-4--install-new-catalog-plugins-per-install_new-policy) +- [Step 5 — `enabledPlugins` completeness](#step-5--enabledplugins-completeness) + +## Step 4 — Install new catalog plugins (per `install_new` policy) + +Catalog-dependent: skipped (deferred) for a marketplace whose Step 1 refresh failed — see +[sync.md](sync.md) Step 1. + +This step makes its own live `fleet-state.sh --marketplace "$mp"` re-read, exactly as the +re-read-before-each-mutating-step rule requires, saves it to the run journal as +`pre-install.$mp.json`, and projects its ids from that file. `--from` replaces the SECOND process +this step used to launch, never the re-read itself: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/pre-install.$mp.json" +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids missing-user-install --from "$run_dir/pre-install.$mp.json" +``` + +Take `fleet-state.sh`'s `missing_from_user_install` from that projection (see +[sync.md](sync.md) Step 3 for why the ids never come from a hand-written `jq`) — catalog ids not +installed at `user` scope +(already excludes anything explicitly opted out with `enabledPlugins: false` in any scope — never +re-offer a deliberate decline). This is deliberately user-scope, not the all-scope `missing_from_install`: +a plugin installed only at `project`/`local` scope is absent from `missing_from_install` yet still not +usable from other directories, so installing at `user` scope below (the "usable from any directory" +guarantee) must key off user-scope completeness. 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 +- **`all`** — `claude plugin install -s user` for every entry, no prompt +- **`none`** — install nothing; list the entries under "Action needed" in the report only + +**A headless run launched with `--setting-sources` that omits `user` silently reverts this policy to +`ask`.** `pluginConfigs` is read from user settings, `--settings`, and managed settings only (see +[scope-semantics.md](scope-semantics.md)), so dropping `user` from the source list drops the +configured `install_new` with it — and the render falls back to the unset placeholder, which this +step correctly reads as `ask`. That is the right fallback and the wrong silence: say so in the +report rather than letting a policy the user set appear to have been honored. + +**Caveat (document, don't silently absorb):** with `install_new: all`, a catalog plugin that's +installed at `user` scope and then *disabled* (not uninstalled — `enabledPlugins: false` still +recorded, install record still present) is correctly excluded (it's not in `missing_from_user_install`, +it's an installed, opted-out plugin). But a plugin that's *uninstalled entirely* without ever setting +`false` reappears in `missing_from_user_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`. + +**Say that in the report, at the moment it fires.** When the policy is `all` and this step installed +anything, the `Installed:` row carries the recurrence clause from SKILL.md's Report section. A +caveat documented only here is invisible to the person reading the report, who is exactly the person +about to be surprised by it on the next run. Do not leave it to inference. + +**Capture each install's own CLI output, don't discard it.** An install can report that the plugin +declares `userConfig` options left unset, along with its own suggested remedy. That line is +per-install information this step is the only one positioned to see, and it belongs in the report's +"Action needed" list rather than in the scrollback — see SKILL.md's Report section for the slot. It +also belongs in the run journal, per [sync.md](sync.md)'s "Run journal" section: this is a mutating +call, and its output is the only record of what it said. + +### After any install — normalize user-scope `enabledPlugins` key order + +Claude Code's settings writer appends each new `enabledPlugins` key at the end of the map rather +than inserting it alphabetically. The rest of the map is sorted, so every sync that installs +something leaves an unsorted tail that never self-heals and churns diffs for anyone whose +`~/.claude/settings.json` is managed. + +There is no `claude plugin` verb that reorders the map. After this step installs **anything**, +run the bundled normalizer against the user-scope file only: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/normalize-enabled-plugins.sh +``` + +Override the path with `--file` or `FLEET_STATE_USER_SETTINGS` when the run is not using the +machine default (same override `fleet-state.sh` honors). + +- **User scope only.** The write is a strict key reorder: keys and values byte-identical, order + alone changed. It is consistent with what this step already does — Step 5 already writes + `~/.claude/settings.json` via `claude plugin enable -s user`. +- **Never normalize project scope.** That is the committed, team-shared file the Scope invariant + protects. If a project-scope map is unsorted, report it under Action needed and stop: + + ```bash + "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/normalize-enabled-plugins.sh \ + --report-project "${project_root}/.claude/settings.json" + ``` + + `project-unsorted` is a report row, not a write. `converge` remains the only action that may + touch that file. +- **Never silent.** A `normalized keys=N` result becomes the report's `Normalized:` row. A + `refused:` result (permission denial, unreadable JSON, a semantic diff) becomes an Action + needed bullet — fail loudly, never skip. `--check` is the audit-mode stand-in (predict + `would-normalize`, write nothing). +- **Skip the write when this step installed nothing.** An already-sorted map is a no-op either + way (`already-sorted`); the reorder exists to heal the tail this step just created. + +## Step 5 — `enabledPlugins` completeness + +Catalog-dependent (`defaultEnabled` comes from catalog metadata): skipped (deferred) for a +marketplace whose Step 1 refresh failed — see [sync.md](sync.md) Step 1. + +Same shape as Step 4: this step makes its own live re-read, saves it as `pre-enable.$mp.json`, and +projects from that file. + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/pre-enable.$mp.json" +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids missing-enabled --from "$run_dir/pre-enable.$mp.json" +``` + +Take `fleet-state.sh`'s `missing_from_enabled` from that projection — ids +installed somewhere but never mentioned (true +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). + +Consider each remaining id in each *verifiable* scope where it 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 — acting on it would risk mutating the current repo or an unread +repo instead). + +**`sync` never writes a committed settings file — the scope decides whether this step acts or +reports.** SKILL.md's scope section makes `converge` the one action that may touch a committed +`.claude/settings.json`, and only behind its confirm gate. `enable -s project` writes exactly +that file (verified on Claude Code 2.1.228 — see [scope-semantics.md](scope-semantics.md)), so this +step must not issue it. Confirming instead of skipping is not an option: `converge` can afford a +confirm because it *aborts* in an autonomous session, while `sync` is the on-demand and headless +maintenance action with no such abort, so there may be no human to answer. + +- **`user` and `local` — enable automatically.** Neither is team-shared state: `user` writes + machine-scope `~/.claude/settings.json`, and `local` writes the gitignored + `.claude/settings.local.json`. + + ```bash + claude plugin enable -s user # or -s local + ``` + +- **`project` — never enable; report it, but only when the report would be runnable.** Emit an + "Action needed" row per SKILL.md's Report section carrying the exact command, so the user can run + it deliberately and review the resulting diff: + + ```bash + (cd "" && claude plugin enable @ -s project) + ``` + + The `cd`-into-its-own-`projectPath` form is required for the reason + [converge.md](converge.md) Step 2 gives — `-s project` has no path flag and always acts on the + current directory — and the id stays fully qualified per [gotchas.md](gotchas.md). + + **Order matters — suppress this row for any id the `user`/`local` branch just enabled.** An id + with no `enabledPlugins` entry anywhere but install records at *both* `user` and `project` scope + produces two rows in one run. The `user` row enables first, and `enable -s project` gates on the + **merged effective** value, not that scope's raw map (see + [scope-semantics.md](scope-semantics.md)), so the reported command would then fail with + `Plugin "" is already enabled at project scope` — a report that hands the user a command + guaranteed to error. Emit the `project` row only for an id this step did **not** enable at `user` + or `local` scope; in practice that means an id whose only verifiable record is the project one. + Skipping is correct rather than merely convenient: after the `user` enable the plugin already + loads in that project by scope precedence, so nothing is broken — only the team-shared *declaration* + is absent, and that is a deliberate choice for the user to make, not drift for `sync` to report as + actionable. + +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. From 8b8fc1fdfdf56bb99ad8be56c45783e7c39efff8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:28:06 -0400 Subject: [PATCH 3/7] feat(claude-ops): give sync a durable run journal (#3728) A sweep of several dozen mutations was one context compaction away from being unable to emit its own report. Version capture requires an value that exists nowhere on the machine once the sweep has run, and a value only the CLI's own output carries, and the skill's mitigation was to hold both in context through Step 6. Every run now creates ${CLAUDE_PLUGIN_DATA}/plugins-sync/runs//, saves each fleet-state.sh re-read there, and appends each mutating CLI call and its output to journal.log. Step 6 reads the pairs and the three divergences[] snapshots out of those files rather than out of conversation, which also gives converge and a later audit a real before-state. fleet-state.sh does not write it. This honors the reasoning of the deferred --run-log finding rather than reversing it: the journal is agent-executed shell around calls the algorithm already makes, and the script stays the read-only inspector its header advertises. The saved reports are the re-reads the concurrency rule already requires, so the journal costs a redirect. audit mode writes no journal, keeping the action table's "Mutates: No" true. Steps 2 and 3 now project their id lists with --from against the report each step just read, replacing the second fleet-state.sh process per step, never the re-read itself. The mandate to take ids from the script and never from a hand-written jq is unchanged. SKILL.md carries the substituted journal_root because ${CLAUDE_PLUGIN_DATA} resolves in skill content and not in a context/*.md spoke, which is read raw. Also carries the Steps 4 and 5 pointer paragraph and spoke-table row for the preceding commit's move. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- plugins/claude-ops/skills/plugins/SKILL.md | 16 ++ .../claude-ops/skills/plugins/context/sync.md | 256 +++++++----------- 2 files changed, 112 insertions(+), 160 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index 0cfd90f0ba..9efd602c47 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -86,6 +86,7 @@ files directly, and never write them: ```bash "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace | --all] "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh [--marketplace ] --ids +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --ids --from ``` The second form emits the plain id list a mutating step loops, instead of the JSON report. One @@ -94,6 +95,20 @@ whenever a step needs ids; never hand-write a `jq` extraction over the JSON, whi trailing `\r` on Windows and silently corrupts every id but the last (see [context/gotchas.md](context/gotchas.md)). +The third form projects that same id list from a report already on disk rather than recomputing the +fleet, and is the form `sync`'s steps use: each step re-reads the full report anyway, and every +selector is derivable from it. Same script, same projection, so the `\r` protection is unchanged. + +`sync` writes its run journal under this plugin's per-machine data directory. The path is +substituted here because `${CLAUDE_PLUGIN_DATA}` resolves in skill content and **not** in a +`context/*.md` spoke, which is read raw: + +```bash +journal_root="${CLAUDE_PLUGIN_DATA}/plugins-sync/runs" +``` + +See [context/sync.md](context/sync.md)'s "Run journal" section for what goes in it. + After Step 4 installs anything, reorder user-scope `enabledPlugins` with the bundled writer. Never hand-edit `~/.claude/settings.json`: @@ -286,6 +301,7 @@ default when that render is still the placeholder token, not on the option's nam | File | Load when | |---|---| | [context/sync.md](context/sync.md) | Running `sync` or `audit`; it is the step sequence both actions execute. | +| [context/sync-install-enable.md](context/sync-install-enable.md) | Sync Steps 4 and 5, and only when this marketplace's report has a non-empty `missing_from_user_install` or `missing_from_enabled`, or its Step 1 refresh failed. Both arrays are empty on a current fleet. | | [context/converge.md](context/converge.md) | Running `converge`, the only action that may rewrite a committed settings file. | | [context/scope-semantics.md](context/scope-semantics.md) | A scope, version, or reload claim needs its verified source before you act on it. | | [context/gotchas.md](context/gotchas.md) | A run failed in a way the steps do not explain, or a safeguard looks removable. | diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index dfb9a40915..545648b57f 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -4,12 +4,12 @@ - [Concurrency](#concurrency) - [Version capture for the report](#version-capture-for-the-report) +- [Run journal](#run-journal) - [Marketplace scoping — Steps 2–5 are the per-marketplace loop body](#marketplace-scoping--steps-25-are-the-per-marketplace-loop-body) - [Step 1 — Marketplace refresh](#step-1--marketplace-refresh) - [Step 2 — In-repo update (the primary value path)](#step-2--in-repo-update-the-primary-value-path) - [Step 3 — User-scope update sweep](#step-3--user-scope-update-sweep) -- [Step 4 — Install new catalog plugins (per `install_new` policy)](#step-4--install-new-catalog-plugins-per-install_new-policy) -- [Step 5 — `enabledPlugins` completeness](#step-5--enabledplugins-completeness) +- [Steps 4 and 5 — install and enable](#steps-4-and-5--install-and-enable) - [Step 6 — Report](#step-6--report) `sync` is the default action: bring the effective fleet current where you stand. Every step below @@ -44,11 +44,10 @@ values have to be collected while the sweep runs — neither can be reconstructe **Retain the pre-sweep `fleet-state.sh` output for the whole run.** It is the sole source of every ``, and once Step 2/3 have run there is nothing left on the machine that still holds those -values — the pre-update versions are gone. 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 `` pairs is otherwise one context -compaction away from being unable to emit its own report. This skill provides no durable log for -that today — see "Deferred" in the plugin's CHANGELOG for why the script does not write one. +values — the pre-update versions are gone. Do not hold it in context and hope: a sweep of several +dozen mutations whose report depends on the `` pairs is one context compaction away +from being unable to emit its own report. Write it to the run journal below, and read it back in +Step 6. Three sources, in precedence order, and **never** a synthesized value: @@ -73,6 +72,63 @@ source 3 agreed with source 2 on every id. That establishes the write landed bef that run — not that it is synchronous per call, and not that it holds on another version. Keep source 2 primary and keep the divergence handling above. +## Run journal + +Every `sync` run keeps its own directory on disk, so Step 6 reads what happened rather than +reconstructing it from conversation. Nothing in the report then depends on the transcript surviving +a compaction, and `converge` or a later audit gets a real before-state. + +`fleet-state.sh` does not write it — it stays the read-only inspector its own header advertises. +The journal is agent-executed shell around the calls the algorithm already makes. + +At run start, take `journal_root` from SKILL.md's "State inspection" section (the value substitutes +there and **not** here — a `context/*.md` spoke is read raw, so a `${CLAUDE_PLUGIN_DATA}` written +here would resolve to nothing) and create one directory for this run: + +```bash +run_dir="$journal_root/$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$run_dir" +``` + +Then, for the rest of the run: + +- **Save every `fleet-state.sh` report** the algorithm re-reads, rather than only reading it. These + are the same calls the re-read-before-each-mutating-step rule already requires, so the journal + costs a redirect, not an extra read, and `--from` never replaces one of them. It replaces only the + second process a step used to launch to project its ids. Name them per marketplace: + + | File | The re-read it saves | + |---|---| + | `pre..json` | Step 2's, before the in-repo update | + | `mid..json` | Step 3's, before the user-scope sweep | + | `pre-install..json` | Step 4's, before any install | + | `pre-enable..json` | Step 5's, before any enable | + | `post..json` | the post-sweep re-read Step 6 reads | + + The three the divergence attribution needs are `pre`, `mid`, and `post`; the other two exist + because Steps 4 and 5 mutate too. +- **Append every mutating CLI call and its output** to `$run_dir/journal.log` as it runs, so the + `` values that only the CLI reports survive the step that produced them: + + ```bash + { echo "\$ claude plugin update $id -s user"; claude plugin update "$id" -s user 2>&1; } \ + | tee -a "$run_dir/journal.log" + ``` + +- **Step 6 reads those files.** Every `` pair comes from `pre.json` plus + `journal.log`, with `post.json` as source 3's fallback, and the three `divergences[]` snapshots + the attribution split needs come from the three saved reports. Do not re-derive any of it from + memory of the run. + +**`audit` writes no journal.** SKILL.md's action table says `audit` mutates nothing, and a run that +creates directories under the plugin data dir does not match that line even though the data dir is +not fleet state. `audit` issues no mutating call, so it has no `` pairs to lose and +nothing the journal would protect. + +The journal is best-effort and never fails the sweep: if the directory cannot be created, say so in +the report and fall back to holding the values in context, which is the weaker guarantee this +section exists to replace. + ## Marketplace scoping — Steps 2–5 are the per-marketplace loop body **Every `fleet-state.sh` call in Steps 2–5 carries `--marketplace "$mp"`, and in `all` mode the whole @@ -202,15 +258,24 @@ claude plugin update -s local # for a currentProject:true entry with sc `fleet-state.sh --ids current-project` emits exactly those records — use it rather than a hand-written `jq` over `installed[]` (see Step 3 for why the hand-written form breaks on Windows). -Each line is `\t`, so the `-s` flag comes off the same line as the id it belongs to: +Project it with `--from` off the report this step just saved rather than running a second live +process: that process would re-parse `installed_plugins.json`, re-walk the catalog manifests, and +re-run `realpath` to recompute a block already on disk. Same script, same projection, so the +`\r` protection is identical. Each line is `\t`, so the `-s` flag comes off the same line +as the id it belongs to: ```bash while IFS=$'\t' read -r id scope; do [[ -n "$id" ]] || continue claude plugin update "$id" -s "$scope" -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" --ids current-project) +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids current-project --from "$run_dir/pre.$mp.json") ``` +Pass `--marketplace "$mp"` alongside `--from` when you want the script to prove the saved report is +the one you think it is; with `--from` that flag is a consistency check (mismatch is exit 2), never +a second read. + The scope rides on the record for a reason: one plugin can hold **both** a `project`- and a `local`-scope record for the same repo (the multi-scope case `divergences[]` tracks), and both are `currentProject: true`. An id-only list would show that id twice with nothing to distinguish the @@ -271,13 +336,15 @@ One call per plugin — `claude plugin update` takes a single `` argumen "Action needed") and does not abort the sweep for the rest. Take the ids from `fleet-state.sh --ids`, never from a hand-written `jq` over its JSON, and use the -**`update-candidates-user`** selector rather than `installed-user`: +**`update-candidates-user`** selector rather than `installed-user`. Project it from this step's own +re-read (`mid.json`), for the reason Step 2 gives: ```bash while IFS= read -r id; do [[ -n "$id" ]] || continue claude plugin update "$id" -s user -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" --ids update-candidates-user) +done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids update-candidates-user --from "$run_dir/mid.$mp.json") ``` ### Why the pre-filter, and why it can only ever be a candidate list @@ -316,160 +383,28 @@ fails with "Plugin not found" even when unambiguous, and on Windows a hand-writt *same* "Plugin not found" text and so misreads as the bare-name problem. Both are [gotchas.md](gotchas.md); `--ids` is why neither can happen here. -## Step 4 — Install new catalog plugins (per `install_new` policy) - -Catalog-dependent: skipped (deferred) for a marketplace whose Step 1 refresh failed — see Step 1. - -Take `fleet-state.sh`'s `missing_from_user_install` (`--marketplace "$mp" --ids missing-user-install` -emits the id list directly — see Step 3) — catalog ids not installed at `user` scope -(already excludes anything explicitly opted out with `enabledPlugins: false` in any scope — never -re-offer a deliberate decline). This is deliberately user-scope, not the all-scope `missing_from_install`: -a plugin installed only at `project`/`local` scope is absent from `missing_from_install` yet still not -usable from other directories, so installing at `user` scope below (the "usable from any directory" -guarantee) must key off user-scope completeness. 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 -- **`all`** — `claude plugin install -s user` for every entry, no prompt -- **`none`** — install nothing; list the entries under "Action needed" in the report only - -**A headless run launched with `--setting-sources` that omits `user` silently reverts this policy to -`ask`.** `pluginConfigs` is read from user settings, `--settings`, and managed settings only (see -[scope-semantics.md](scope-semantics.md)), so dropping `user` from the source list drops the -configured `install_new` with it — and the render falls back to the unset placeholder, which this -step correctly reads as `ask`. That is the right fallback and the wrong silence: say so in the -report rather than letting a policy the user set appear to have been honored. - -**Caveat (document, don't silently absorb):** with `install_new: all`, a catalog plugin that's -installed at `user` scope and then *disabled* (not uninstalled — `enabledPlugins: false` still -recorded, install record still present) is correctly excluded (it's not in `missing_from_user_install`, -it's an installed, opted-out plugin). But a plugin that's *uninstalled entirely* without ever setting -`false` reappears in `missing_from_user_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`. - -**Say that in the report, at the moment it fires.** When the policy is `all` and this step installed -anything, the `Installed:` row carries the recurrence clause from SKILL.md's Report section. A -caveat documented only here is invisible to the person reading the report, who is exactly the person -about to be surprised by it on the next run. Do not leave it to inference. - -**Capture each install's own CLI output, don't discard it.** An install can report that the plugin -declares `userConfig` options left unset, along with its own suggested remedy. That line is -per-install information this step is the only one positioned to see, and it belongs in the report's -"Action needed" list rather than in the scrollback — see SKILL.md's Report section for the slot. - -### After any install — normalize user-scope `enabledPlugins` key order - -Claude Code's settings writer appends each new `enabledPlugins` key at the end of the map rather -than inserting it alphabetically. The rest of the map is sorted, so every sync that installs -something leaves an unsorted tail that never self-heals and churns diffs for anyone whose -`~/.claude/settings.json` is managed. - -There is no `claude plugin` verb that reorders the map. After this step installs **anything**, -run the bundled normalizer against the user-scope file only: - -```bash -"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/normalize-enabled-plugins.sh -``` - -Override the path with `--file` or `FLEET_STATE_USER_SETTINGS` when the run is not using the -machine default (same override `fleet-state.sh` honors). - -- **User scope only.** The write is a strict key reorder: keys and values byte-identical, order - alone changed. It is consistent with what this step already does — Step 5 already writes - `~/.claude/settings.json` via `claude plugin enable -s user`. -- **Never normalize project scope.** That is the committed, team-shared file the Scope invariant - protects. If a project-scope map is unsorted, report it under Action needed and stop: - - ```bash - "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/normalize-enabled-plugins.sh \ - --report-project "${project_root}/.claude/settings.json" - ``` - - `project-unsorted` is a report row, not a write. `converge` remains the only action that may - touch that file. -- **Never silent.** A `normalized keys=N` result becomes the report's `Normalized:` row. A - `refused:` result (permission denial, unreadable JSON, a semantic diff) becomes an Action - needed bullet — fail loudly, never skip. `--check` is the audit-mode stand-in (predict - `would-normalize`, write nothing). -- **Skip the write when this step installed nothing.** An already-sorted map is a no-op either - way (`already-sorted`); the reorder exists to heal the tail this step just created. - -## Step 5 — `enabledPlugins` completeness - -Catalog-dependent (`defaultEnabled` comes from catalog metadata): skipped (deferred) for a -marketplace whose Step 1 refresh failed — see Step 1. - -Take `fleet-state.sh`'s `missing_from_enabled` (`--marketplace "$mp" --ids missing-enabled` emits the -id list directly — see Step 3) — ids installed somewhere but never mentioned (true -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). - -Consider each remaining id in each *verifiable* scope where it 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 — acting on it would risk mutating the current repo or an unread -repo instead). - -**`sync` never writes a committed settings file — the scope decides whether this step acts or -reports.** SKILL.md's scope section makes `converge` the one action that may touch a committed -`.claude/settings.json`, and only behind its confirm gate. `enable -s project` writes exactly -that file (verified on Claude Code 2.1.228 — see [scope-semantics.md](scope-semantics.md)), so this -step must not issue it. Confirming instead of skipping is not an option: `converge` can afford a -confirm because it *aborts* in an autonomous session, while `sync` is the on-demand and headless -maintenance action with no such abort, so there may be no human to answer. - -- **`user` and `local` — enable automatically.** Neither is team-shared state: `user` writes - machine-scope `~/.claude/settings.json`, and `local` writes the gitignored - `.claude/settings.local.json`. - - ```bash - claude plugin enable -s user # or -s local - ``` +## Steps 4 and 5 — install and enable -- **`project` — never enable; report it, but only when the report would be runnable.** Emit an - "Action needed" row per SKILL.md's Report section carrying the exact command, so the user can run - it deliberately and review the resulting diff: +**Read [sync-install-enable.md](sync-install-enable.md) only when this marketplace's report has a +non-empty `missing_from_user_install` or a non-empty `missing_from_enabled`, or when Step 1's +refresh failed for it.** Both arrays are empty on an already-current fleet, which is the common +case, and then both steps are no-ops with nothing to load. The gating signal is in the report Step 1 +already read. - ```bash - (cd "" && claude plugin enable @ -s project) - ``` +- **Step 4 — install new catalog plugins.** Installs the `missing_from_user_install` ids at `user` + scope per the configured `install_new` policy, then normalizes the user-scope `enabledPlugins` + key order the install just disturbed. +- **Step 5 — `enabledPlugins` completeness.** Enables the `missing_from_enabled` ids at `user` and + `local` scope, and reports rather than writes at `project` scope. - The `cd`-into-its-own-`projectPath` form is required for the reason - [converge.md](converge.md) Step 2 gives — `-s project` has no path flag and always acts on the - current directory — and the id stays fully qualified per [gotchas.md](gotchas.md). - - **Order matters — suppress this row for any id the `user`/`local` branch just enabled.** An id - with no `enabledPlugins` entry anywhere but install records at *both* `user` and `project` scope - produces two rows in one run. The `user` row enables first, and `enable -s project` gates on the - **merged effective** value, not that scope's raw map (see - [scope-semantics.md](scope-semantics.md)), so the reported command would then fail with - `Plugin "" is already enabled at project scope` — a report that hands the user a command - guaranteed to error. Emit the `project` row only for an id this step did **not** enable at `user` - or `local` scope; in practice that means an id whose only verifiable record is the project one. - Skipping is correct rather than merely convenient: after the `user` enable the plugin already - loads in that project by scope precedence, so nothing is broken — only the team-shared *declaration* - is absent, and that is a deliberate choice for the user to make, not drift for `sync` to report as - actionable. - -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. +When Step 1's refresh failed for this marketplace, both steps are deferred rather than run — the +spoke carries what to say about that; see Step 1 above for why. ## Step 6 — Report Emit the report per SKILL.md's "Report" section, filling each updated plugin's `` from -the sources the "Version capture for the report" section above fixes. +the sources the "Version capture for the report" section above fixes, read back out of the run +journal rather than out of memory of the run. **Split the Divergences count into pre-existing and run-caused.** A user-scope sweep that moves user scope ahead of untouched project records *manufactures* actionable divergences — the run's own @@ -483,8 +418,9 @@ Concretely: equal project and user records at `v1`, Step 2 updates the project r user update fails — the skew is Step 2's, and a two-snapshot diff blames Step 3. Take the `divergences[]` read from each of the three `fleet-state.sh` calls the algorithm already makes — the pre-Step-2 snapshot, the pre-Step-3 re-read the concurrency rule requires anyway, and the post-sweep -re-read — and attribute each new row to the interval it first appeared in. No extra call is needed; -this is bookkeeping over reads that already happen. +re-read, saved as the run journal's `pre.json`, `mid.json`, and `post.json` — and attribute each new +row to the interval it first appeared in. No extra call is needed; this is bookkeeping over reads +that already happen. Report as ` actionable ( newly created by this run — by the in-repo update, by the user-scope From 65c28b5fbf5b8422f6571c20a516f210a2c23945 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:28:39 -0400 Subject: [PATCH 4/7] chore(claude-ops): release 0.42.4 (#3728) Version bump and CHANGELOG entry for the --from projection, the sync run journal, and the sync-install-enable spoke. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index a808e6256c..03e52e1b22 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.42.4", + "version": "0.42.5", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (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 \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) 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", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 9eb83c7929..3c1094dea5 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,62 @@ 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.42.5] + +### Added + +- **`fleet-state.sh --ids --from ` projects a selector from a + report the script already emitted.** `sync` re-reads the full JSON report before + each mutating step, and every selector is derivable from it, so the separate + live `--ids` process was paying a second process to re-parse + `installed_plugins.json`, re-walk the catalog manifests, and re-run `realpath` + in order to recompute a block the caller was already holding. `--from` reads no + Claude Code state file at all and runs the SAME jq projection the live mode + runs, so the CR-free, TAB-separated output contract is unchanged, which is the + reason the selector exists instead of a hand-written `jq` at each call site. + Refused with exit 2 and an EMPTY stdout, so a `< <(…)` consumer can never read + an error as a plugin id: `--from` with `--all`, `--from` without `--ids`, a + missing or malformed file, an `--all` envelope (valid JSON that every selector + projects to nothing, refused by name rather than silently returning an empty + list), and a `--marketplace ` disagreeing with the report's own + `marketplace.name`. That flag is an optional consistency check under `--from`, + never a second read. New regression cases cover per-selector equality with the + live projection, each rejection's exit code and empty stdout, and a projection + run with every state file absent. (#3728) +- **`sync` writes a per-run journal.** At run start it creates + `${CLAUDE_PLUGIN_DATA}/plugins-sync/runs//`, saves the pre-sweep, + mid-sweep, and post-sweep `fleet-state.sh` reports there, and appends every + mutating CLI call and its output to `journal.log`. Step 6 reads the + ``/`` pairs and the three `divergences[]` snapshots out of those files instead + of out of conversation, so a sweep of several dozen mutations is no longer one + context compaction away from being unable to emit its own report, and `converge` + or a later audit gets a real before-state. This does not reverse the deferred + `--run-log` finding, it honors its reasoning: the journal is agent-executed shell + around calls the algorithm already makes, and `fleet-state.sh` stays the + read-only inspector its own header advertises. The path is substituted in + `SKILL.md` because `${CLAUDE_PLUGIN_DATA}` resolves in skill content and not in a + `context/*.md` spoke, which is read raw. (#3728) +- **New spoke `context/sync-install-enable.md` carrying sync Steps 4 and 5.** + Roughly a hundred lines of install policy branches, the `--setting-sources` + caveat, the reinstall-recurrence caveat, the normalizer contract, + `defaultEnabled` precedence, and the project-scope enable-gap suppression + ordering used to load on every run, and are unreachable when + `missing_from_user_install` and `missing_from_enabled` are both empty, the + common case on a current fleet. `sync.md` keeps a pointer with the read + condition, the same progressive-disclosure pattern the hub already uses for + `converge.md` and `scope-semantics.md`. The Report template and the + `install_new` render stay in `SKILL.md`, which documents them as deliberate + hub-only exceptions. (#3728) + +### Changed + +- **`sync.md`'s Step 2 and Step 3 loops project their id lists with `--from`** + against the report each step already read, rather than launching a second + `fleet-state.sh`. The mandate to take ids from the script and never from a + hand-written `jq` is unchanged, and so is every selector's output. Cross-file + references in `converge.md`, `gotchas.md`, and `scope-semantics.md` that named + `sync.md` Step 4 or Step 5 now point at the new spoke. (#3728) + ## [0.42.4] ### Changed From f8d675a6317ea6195cb06e02f9f4e3b5aa3c929a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:38:53 -0400 Subject: [PATCH 5/7] fix(claude-ops): validate --from fields per selector and harden sync's journal (#3728) Addresses PR review findings on the sync cost/resilience branch. fleet-state.sh: `--from` now validates the fields the CHOSEN selector consumes, additively on top of the baseline `.marketplace.name` + `.installed` shape check. A syntactically valid but incomplete report such as `{"marketplace":{"name":"m"},"installed":[]}` used to evaluate the absent array with `[]?`, emit nothing, and exit 0 -- a silently-empty id list read as "nothing to do". It is now exit 2 with empty stdout and an error naming the file and the field. A field present but empty still exits 0 with empty output, so the exit status discriminates. sync.md: the run directory is created with `mktemp -d` so two sessions starting in the same UTC second cannot share it; every tee-journaled mutating call captures `rc=${PIPESTATUS[0]}` so a failed CLI call is not read as success through tee's status; Steps 2-5 show the redirect that creates their saved report and check the projection's exit status before looping; Steps 4 and 5 gate on a fresh pre-Step-4 re-read rather than Step 1's older report; Step 6 uses the marketplace-suffixed snapshot names. `audit` now runs the same algorithm against a throwaway `mktemp -d` scratch directory it deletes, instead of being forbidden to save the reports its own `--from` projections require. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- plugins/claude-ops/CHANGELOG.md | 58 +++++- plugins/claude-ops/skills/plugins/SKILL.md | 8 +- .../plugins/context/sync-install-enable.md | 47 +++-- .../claude-ops/skills/plugins/context/sync.md | 189 +++++++++++++++--- .../skills/plugins/scripts/fleet-state.sh | 59 +++++- .../plugins/scripts/fleet-state.test.sh | 50 +++++ 6 files changed, 349 insertions(+), 62 deletions(-) diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 3c1094dea5..1117bb9c49 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -20,13 +20,25 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo an error as a plugin id: `--from` with `--all`, `--from` without `--ids`, a missing or malformed file, an `--all` envelope (valid JSON that every selector projects to nothing, refused by name rather than silently returning an empty - list), and a `--marketplace ` disagreeing with the report's own - `marketplace.name`. That flag is an optional consistency check under `--from`, + list), a `--marketplace ` disagreeing with the report's own + `marketplace.name`, and a report that carries the baseline `.marketplace.name` + and `.installed` fields but not the field the CHOSEN selector reads. That last + one is per-selector: each selector declares the fields its branch of the + projection program consumes (only `update-candidates-user` reads + `catalog_versions`), and a missing or wrong-typed one is exit 2 naming the file + and the field, because `{"marketplace":{"name":"m"},"installed":[]}` otherwise + evaluated the absent array with `[]?`, emitted nothing, and exited 0 — a + silently-empty id list read as "nothing to do". A field that is present but + empty still exits 0 with empty output, so the exit status is a usable + discriminator. `--marketplace` under `--from` is an optional consistency check, never a second read. New regression cases cover per-selector equality with the - live projection, each rejection's exit code and empty stdout, and a projection - run with every state file absent. (#3728) + live projection, each rejection's exit code and empty stdout, the incomplete + and present-but-empty reports, and a projection run with every state file + absent. (#3728) - **`sync` writes a per-run journal.** At run start it creates - `${CLAUDE_PLUGIN_DATA}/plugins-sync/runs//`, saves the pre-sweep, + `${CLAUDE_PLUGIN_DATA}/plugins-sync/runs/.XXXXXX/` with + `mktemp -d` (atomically, so two sessions starting in the same UTC second cannot + share a directory and interleave their snapshots and logs), saves the pre-sweep, mid-sweep, and post-sweep `fleet-state.sh` reports there, and appends every mutating CLI call and its output to `journal.log`. Step 6 reads the ``/`` pairs and the three `divergences[]` snapshots out of those files instead @@ -48,16 +60,42 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo condition, the same progressive-disclosure pattern the hub already uses for `converge.md` and `scope-semantics.md`. The Report template and the `install_new` render stay in `SKILL.md`, which documents them as deliberate - hub-only exceptions. (#3728) + hub-only exceptions. The spoke loads on the FRESH pre-Step-4 re-read's arrays, + not on Step 1's older report: another session can uninstall a plugin or change + enable state in between, and a gate keyed on the stale report would skip the + live pre-install and pre-enable reads the spoke mandates and leave the new gap + unresolved. Step 4 reuses that re-read; Step 5 still takes its own, because + Step 4 mutates in between. (#3728) ### Changed - **`sync.md`'s Step 2 and Step 3 loops project their id lists with `--from`** against the report each step already read, rather than launching a second - `fleet-state.sh`. The mandate to take ids from the script and never from a - hand-written `jq` is unchanged, and so is every selector's output. Cross-file - references in `converge.md`, `gotchas.md`, and `scope-semantics.md` that named - `sync.md` Step 4 or Step 5 now point at the new spoke. (#3728) + `fleet-state.sh`. Each step now shows the redirect that creates its saved + report, and projects to a file whose exit status is checked before the loop: + a `--from` rejection is exit 2 with empty stdout, which a `while read` consumer + cannot tell apart from an empty id list, so the check is what keeps a failed + projection from being reported as "nothing to do". The mandate to take ids from + the script and never from a hand-written `jq` is unchanged, and so is every + selector's output. Cross-file references in `converge.md`, `gotchas.md`, and + `scope-semantics.md` that named `sync.md` Step 4 or Step 5 now point at the new + spoke. (#3728) +- **Every `tee`-journaled mutating call captures `rc=${PIPESTATUS[0]}`.** A + pipeline's own `$?` is `tee`'s status, and `tee` succeeds whenever it can write + the log, so the documented snippet reported success for a `claude plugin update` + that failed and never emitted the "Action needed" row the failure earned. The + capture must be the statement immediately after the pipeline, since any + intervening command clobbers `PIPESTATUS`. `sync.md`'s "Run journal" section + carries the one canonical shape; `sync-install-enable.md` points at it for its + install and enable calls rather than restating it. (#3728) +- **`audit` uses a throwaway `mktemp -d` scratch directory for its reports.** + `audit` runs the same algorithm, whose steps project with `--from` against a + saved report, so forbidding it from saving reports at all left it choosing + between violating its read-only contract and taking exit 2 from `--from` and + omitting its predictions. It now writes to a scratch directory under + `${TMPDIR:-${TEMP:-.}}` (never a hardcoded `/tmp`, which is an MSYS mount alias + on Windows) and removes it when the run ends, so one algorithm serves both + actions and only `sync` writes the durable journal. (#3728) ## [0.42.4] diff --git a/plugins/claude-ops/skills/plugins/SKILL.md b/plugins/claude-ops/skills/plugins/SKILL.md index 9efd602c47..2662657430 100644 --- a/plugins/claude-ops/skills/plugins/SKILL.md +++ b/plugins/claude-ops/skills/plugins/SKILL.md @@ -134,6 +134,12 @@ contents (`installed_plugins.json`, `known_marketplaces.json`, committed setting an `audit` run, modulo any concurrent session or background `autoUpdate` sweep. Note that caveat in the report rather than asserting byte-identical files. +`audit` runs the same steps, which project their id lists with `--from` against a saved report, so +it does write those reports — to a throwaway `mktemp -d` scratch directory it deletes when the run +ends, never to the durable run journal under this plugin's data directory. That keeps one algorithm +for both actions while leaving nothing behind, which is what "mutates nothing" means here. See +[context/sync.md](context/sync.md)'s "Run journal" section. + Because `audit` issues no `marketplace update`, its Step 3 prediction is computed against an **unrefreshed** catalog and is therefore a lower bound on what `sync` would update. Report it as one, carrying the catalog's `lastUpdated`. See [context/sync.md](context/sync.md) Step 3. An `audit` @@ -301,7 +307,7 @@ default when that render is still the placeholder token, not on the option's nam | File | Load when | |---|---| | [context/sync.md](context/sync.md) | Running `sync` or `audit`; it is the step sequence both actions execute. | -| [context/sync-install-enable.md](context/sync-install-enable.md) | Sync Steps 4 and 5, and only when this marketplace's report has a non-empty `missing_from_user_install` or `missing_from_enabled`, or its Step 1 refresh failed. Both arrays are empty on a current fleet. | +| [context/sync-install-enable.md](context/sync-install-enable.md) | Sync Steps 4 and 5, and only when the fresh pre-Step-4 re-read (not Step 1's report) has a non-empty `missing_from_user_install` or `missing_from_enabled`, or its Step 1 refresh failed. Both arrays are empty on a current fleet. | | [context/converge.md](context/converge.md) | Running `converge`, the only action that may rewrite a committed settings file. | | [context/scope-semantics.md](context/scope-semantics.md) | A scope, version, or reload claim needs its verified source before you act on it. | | [context/gotchas.md](context/gotchas.md) | A run failed in a way the steps do not explain, or a safeguard looks removable. | diff --git a/plugins/claude-ops/skills/plugins/context/sync-install-enable.md b/plugins/claude-ops/skills/plugins/context/sync-install-enable.md index 3772f03ff2..ab488ba9d1 100644 --- a/plugins/claude-ops/skills/plugins/context/sync-install-enable.md +++ b/plugins/claude-ops/skills/plugins/context/sync-install-enable.md @@ -1,13 +1,19 @@ # Sync Steps 4 and 5 — install and enable -Read this file only when the Step 1 `fleet-state.sh` report for the marketplace being swept has a -non-empty `missing_from_user_install` **or** a non-empty `missing_from_enabled`, or when Step 1's -refresh failed for that marketplace and the report has to name what these two steps deferred. On an -already-current fleet both arrays are empty, both steps are no-ops, and none of this is reachable. +Read this file only when the **fresh pre-Step-4 `fleet-state.sh` re-read** for the marketplace being +swept — the live read [sync.md](sync.md)'s "Steps 4 and 5" section takes and saves as +`$run_dir/pre-install.$mp.json` — has a non-empty `missing_from_user_install` **or** a non-empty +`missing_from_enabled`, or when Step 1's refresh failed for that marketplace and the report has to +name what these two steps deferred. On an already-current fleet both arrays are empty, both steps +are no-ops, and none of this is reachable. The gate deliberately keys on that re-read rather than +Step 1's older report, because state can change between the two; see sync.md for why. The steps below are the loop body of [sync.md](sync.md) Steps 2–5, run once per marketplace, and every rule that file states — CLI-mediated mutation only, the per-marketplace failure rule, the -re-read boundary at the step — applies here unchanged. +re-read boundary at the step, the projection shape and its exit-status check, and the +`rc=${PIPESTATUS[0]}` capture after every `tee`-journaled mutating call — applies here unchanged. +Each mutating call below is journaled and status-captured in exactly the shape sync.md's "Run +journal" section fixes; that shape is not restated here. ## Contents @@ -19,18 +25,22 @@ re-read boundary at the step — applies here unchanged. Catalog-dependent: skipped (deferred) for a marketplace whose Step 1 refresh failed — see [sync.md](sync.md) Step 1. -This step makes its own live `fleet-state.sh --marketplace "$mp"` re-read, exactly as the -re-read-before-each-mutating-step rule requires, saves it to the run journal as -`pre-install.$mp.json`, and projects its ids from that file. `--from` replaces the SECOND process -this step used to launch, never the re-read itself: +This step's live `fleet-state.sh --marketplace "$mp"` re-read — the one the +re-read-before-each-mutating-step rule requires — has already happened: it is the read that gated +loading this file, saved to the run journal as `pre-install.$mp.json`. Project the ids from that +file rather than reading a third time. `--from` replaces the SECOND process this step used to +launch, never the re-read itself: ```bash -"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ - >"$run_dir/pre-install.$mp.json" "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ - --ids missing-user-install --from "$run_dir/pre-install.$mp.json" + --ids missing-user-install --from "$run_dir/pre-install.$mp.json" \ + >"$run_dir/ids.pre-install.$mp.txt" +rc=$? # exit 2 with empty output is a FAILED projection, not "nothing to install" ``` +Check `rc` before looping the file, per sync.md's projection section: a `--from` rejection exits 2 +with empty stdout, which a loop alone cannot tell apart from an empty install list. + Take `fleet-state.sh`'s `missing_from_user_install` from that projection (see [sync.md](sync.md) Step 3 for why the ids never come from a hand-written `jq`) — catalog ids not installed at `user` scope @@ -116,16 +126,23 @@ machine default (same override `fleet-state.sh` honors). Catalog-dependent (`defaultEnabled` comes from catalog metadata): skipped (deferred) for a marketplace whose Step 1 refresh failed — see [sync.md](sync.md) Step 1. -Same shape as Step 4: this step makes its own live re-read, saves it as `pre-enable.$mp.json`, and -projects from that file. +**This step takes its own live re-read — it cannot reuse Step 4's.** Step 4 mutated in between: it +installed plugins and normalized the user-scope `enabledPlugins` map, so `pre-install.$mp.json` no +longer describes the state this step is about to act on. Save the new read as `pre-enable.$mp.json` +and project from it: ```bash "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ >"$run_dir/pre-enable.$mp.json" + "${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ - --ids missing-enabled --from "$run_dir/pre-enable.$mp.json" + --ids missing-enabled --from "$run_dir/pre-enable.$mp.json" \ + >"$run_dir/ids.pre-enable.$mp.txt" +rc=$? # exit 2 with empty output is a FAILED projection, not "nothing to enable" ``` +Check `rc` before looping the file, for the reason Step 4 gives. + Take `fleet-state.sh`'s `missing_from_enabled` from that projection — ids installed somewhere but never mentioned (true or false) in any scope's `enabledPlugins`, already excluding ids the marketplace ships with diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index 545648b57f..5f4a3b8588 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -6,6 +6,7 @@ - [Version capture for the report](#version-capture-for-the-report) - [Run journal](#run-journal) - [Marketplace scoping — Steps 2–5 are the per-marketplace loop body](#marketplace-scoping--steps-25-are-the-per-marketplace-loop-body) +- [Projecting a step's id list — the shape every mutating step uses](#projecting-a-steps-id-list--the-shape-every-mutating-step-uses) - [Step 1 — Marketplace refresh](#step-1--marketplace-refresh) - [Step 2 — In-repo update (the primary value path)](#step-2--in-repo-update-the-primary-value-path) - [Step 3 — User-scope update sweep](#step-3--user-scope-update-sweep) @@ -86,10 +87,17 @@ there and **not** here — a `context/*.md` spoke is read raw, so a `${CLAUDE_PL here would resolve to nothing) and create one directory for this run: ```bash -run_dir="$journal_root/$(date -u +%Y%m%dT%H%M%SZ)" -mkdir -p "$run_dir" +mkdir -p "$journal_root" +run_dir=$(mktemp -d "$journal_root/$(date -u +%Y%m%dT%H%M%SZ).XXXXXX") ``` +`mktemp -d`, not a bare `mkdir -p` on the timestamp alone: the stamp has one-second resolution, so +two `sync` sessions started within the same second compute the *same* path and `mkdir -p` succeeds +for both — snapshots overwrite each other and the two runs' `journal.log` lines interleave, which +is exactly the reconstruction failure this journal exists to prevent. `mktemp -d` creates the +directory atomically or fails, so each run gets its own. The suffix means a run directory is +`.XXXXXX`, not the bare timestamp; sort by name to order runs. + Then, for the rest of the run: - **Save every `fleet-state.sh` report** the algorithm re-reads, rather than only reading it. These @@ -113,21 +121,53 @@ Then, for the rest of the run: ```bash { echo "\$ claude plugin update $id -s user"; claude plugin update "$id" -s user 2>&1; } \ | tee -a "$run_dir/journal.log" + rc=${PIPESTATUS[0]} ``` -- **Step 6 reads those files.** Every `` pair comes from `pre.json` plus - `journal.log`, with `post.json` as source 3's fallback, and the three `divergences[]` snapshots - the attribution split needs come from the three saved reports. Do not re-derive any of it from - memory of the run. + **`rc=${PIPESTATUS[0]}` is not optional, and it has to be the very next statement.** A pipeline's + own `$?` is `tee`'s status, and `tee` succeeds whenever it can write the log — so without this + capture a `claude plugin update` that *failed* journals its own error text and is then read as a + success, and the "Action needed" row the failure earns is never emitted. `PIPESTATUS[0]` is the + first pipeline element, which here is the brace group, whose status is its last command's: the + `claude plugin update` call. Any command between the pipeline and the capture — including an + `echo` — overwrites `PIPESTATUS`, so read it first and branch on `rc` afterwards. (`set -o + pipefail` before the pipeline is an equivalent fix, but it makes the *pipeline* fail rather than + handing you the CLI's status, and every mutating step here needs the status itself.) + + **This is the canonical journaled-mutation shape.** Every other mutating call the algorithm makes + — `claude plugin install`, `claude plugin enable`, `claude plugin marketplace update` — is + journaled the same way and captures `rc` the same way, including the ones in + [sync-install-enable.md](sync-install-enable.md); that file points here rather than restating it. +- **Step 6 reads those files.** Every `` pair comes from `pre..json` plus + `journal.log`, with `post..json` as source 3's fallback, and the three `divergences[]` + snapshots the attribution split needs come from the three saved reports. Do not re-derive any of + it from memory of the run. + +**`audit` writes no durable journal — it uses a throwaway scratch directory instead.** SKILL.md's +action table says `audit` mutates nothing, and a run that leaves directories behind under the plugin +data dir does not match that line even though the data dir is not fleet state. But `audit` runs this +same algorithm, and Steps 2–5 project their id lists with `--from` against a saved report, so it +does need somewhere to put those reports. It gets one outside the journal root and deletes it: + +```bash +run_dir=$(mktemp -d "${TMPDIR:-${TEMP:-.}}/plugins-audit.XXXXXX") +# ... run the algorithm ... +rm -rf "$run_dir" +``` + +Note `${TMPDIR:-${TEMP:-.}}` and **not** a hardcoded `/tmp`: on Windows that path is an MSYS mount +alias this repo's guardrails reject. Remove the directory when the run ends, including on the error +paths — an audit that leaks one scratch directory per invocation is its own drift. -**`audit` writes no journal.** SKILL.md's action table says `audit` mutates nothing, and a run that -creates directories under the plugin data dir does not match that line even though the data dir is -not fleet state. `audit` issues no mutating call, so it has no `` pairs to lose and -nothing the journal would protect. +So `audit` and `sync` execute one algorithm, differing only in where the reports land and in +issuing no mutating call. `audit` has no `` pairs to lose and nothing durable the +journal would protect, which is why its copy is disposable rather than kept. -The journal is best-effort and never fails the sweep: if the directory cannot be created, say so in +The journal is best-effort and never fails the sweep: if `mkdir -p` or `mktemp -d` fails, say so in the report and fall back to holding the values in context, which is the weaker guarantee this -section exists to replace. +section exists to replace. Without a `run_dir` the `--from` projections have no saved report to read, +so those steps fall back to the live `--marketplace "$mp" --ids ` form — the second process +`--from` exists to avoid, correct but costlier. ## Marketplace scoping — Steps 2–5 are the per-marketplace loop body @@ -165,6 +205,51 @@ marketplace; `--ids` projects a single block, so it takes one marketplace at a t The per-marketplace failure rule from Step 1 carries through: a marketplace whose iteration fails is reported inline and never aborts the loop for the rest. +## Projecting a step's id list — the shape every mutating step uses + +Steps 2–5 all do the same three things: take the live re-read the concurrency rule already requires +and **redirect it to the run journal**, project the step's selector out of that file with `--from`, +and loop the result. Written out once, with Step 3's selector as the example: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/mid.$mp.json" + +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids update-candidates-user --from "$run_dir/mid.$mp.json" >"$run_dir/ids.mid.$mp.txt" +rc=$? +``` + +**Check that `rc` before looping — an empty projection is ambiguous and the exit status is the only +thing that disambiguates it.** Every `--from` rejection (a missing or malformed report, an `--all` +envelope, a report lacking the field the selector reads, a `--marketplace` disagreeing with the +report's own name) exits 2 with **empty stdout**, deliberately, so a failure can never be handed to +`claude plugin update` as an id. That makes the two outcomes identical to a +`while read … done < <(fleet-state.sh …)` consumer, which never sees the exit status at all: zero +lines read, step reports nothing to do. So: + +- **exit 0, empty output** — genuinely nothing to do for this selector. Proceed. +- **exit 2, empty output** — the projection failed. Report it inline under "Action needed" with the + script's own error text and treat the step as not run; never as "nothing to do". + +Project to a file and check `$?` first, then loop the file, rather than reading from a process +substitution whose status the loop discards. The intermediate lands in `$run_dir` alongside the +reports, so it is covered by the same cleanup and no third temp location is invented: + +```bash +while IFS= read -r id; do + [[ -n "$id" ]] || continue + # the step's mutating call for "$id" +done <"$run_dir/ids.mid.$mp.txt" +``` + +Pass `--marketplace "$mp"` alongside `--from` when you want the script to prove the saved report is +the one you think it is; with `--from` that flag is a consistency check (mismatch is exit 2), never +a second read. + +Steps 2 through 5 each name their own report file and selector below; the redirect, the `rc` check, +and the loop-from-a-file shape are this section's and are not restated at each step. + ## Step 1 — Marketplace refresh For each target marketplace (the resolved default, the named one, or every marketplace when the @@ -264,17 +349,28 @@ re-run `realpath` to recompute a block already on disk. Same script, same projec `\r` protection is identical. Each line is `\t`, so the `-s` flag comes off the same line as the id it belongs to: +Per the projection section above, this step's own re-read is redirected to `pre.$mp.json` — that +file is what the branch on `project_root` above reads, and what `--from` projects — and the +projection's exit status is checked before the loop: + ```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/pre.$mp.json" +project_root=$(jq -r '.project_root // "null"' "$run_dir/pre.$mp.json") + +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids current-project --from "$run_dir/pre.$mp.json" >"$run_dir/ids.pre.$mp.txt" +rc=$? # exit 2 with empty output is a FAILED projection, not "nothing in-repo" + while IFS=$'\t' read -r id scope; do [[ -n "$id" ]] || continue claude plugin update "$id" -s "$scope" -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ - --ids current-project --from "$run_dir/pre.$mp.json") +done <"$run_dir/ids.pre.$mp.txt" ``` -Pass `--marketplace "$mp"` alongside `--from` when you want the script to prove the saved report is -the one you think it is; with `--from` that flag is a consistency check (mismatch is exit 2), never -a second read. +The `rc` check matters more here than anywhere else: this is the primary value path, and an +unchecked failed projection is indistinguishable from the honest "a project resolved and it has no +in-repo installs" zero the step is required to report. The scope rides on the record for a reason: one plugin can hold **both** a `project`- and a `local`-scope record for the same repo (the multi-scope case `divergences[]` tracks), and both are @@ -336,17 +432,28 @@ One call per plugin — `claude plugin update` takes a single `` argumen "Action needed") and does not abort the sweep for the rest. Take the ids from `fleet-state.sh --ids`, never from a hand-written `jq` over its JSON, and use the -**`update-candidates-user`** selector rather than `installed-user`. Project it from this step's own -re-read (`mid.json`), for the reason Step 2 gives: +**`update-candidates-user`** selector rather than `installed-user`. This step makes its own live +re-read — the one the concurrency rule requires before a mutating step — redirects it to +`mid.$mp.json`, and projects from that file, for the reason Step 2 gives: ```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/mid.$mp.json" + +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ + --ids update-candidates-user --from "$run_dir/mid.$mp.json" >"$run_dir/ids.mid.$mp.txt" +rc=$? # exit 2 with empty output is a FAILED projection, not "fleet already current" + while IFS= read -r id; do [[ -n "$id" ]] || continue claude plugin update "$id" -s user -done < <("${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh \ - --ids update-candidates-user --from "$run_dir/mid.$mp.json") +done <"$run_dir/ids.mid.$mp.txt" ``` +Reading an unchecked empty projection as "already current" is the silently-skipped-update failure +this step exists to prevent, so check `rc` per the projection section before concluding the sweep +had nothing to do. + ### Why the pre-filter, and why it can only ever be a candidate list Each plugin's version lives in its own manifest inside the marketplace checkout @@ -385,11 +492,31 @@ fails with "Plugin not found" even when unambiguous, and on Windows a hand-writt ## Steps 4 and 5 — install and enable -**Read [sync-install-enable.md](sync-install-enable.md) only when this marketplace's report has a +**Take a fresh live re-read first, and gate the spoke on THAT report — never on Step 1's.** Step 4 +is a mutating step, so the concurrency rule already requires its own re-read; make it here, before +deciding whether to load the spoke: + +```bash +"${CLAUDE_PLUGIN_ROOT}"/skills/plugins/scripts/fleet-state.sh --marketplace "$mp" \ + >"$run_dir/pre-install.$mp.json" +``` + +**Read [sync-install-enable.md](sync-install-enable.md) only when `pre-install.$mp.json` has a non-empty `missing_from_user_install` or a non-empty `missing_from_enabled`, or when Step 1's -refresh failed for it.** Both arrays are empty on an already-current fleet, which is the common -case, and then both steps are no-ops with nothing to load. The gating signal is in the report Step 1 -already read. +refresh failed for this marketplace.** Both arrays are empty on an already-current fleet, which is +the common case, and then both steps are no-ops with nothing to load. + +Gating on the Step 1 report instead would be a real hole, not a nicety: another session can +uninstall a plugin or change enable state between Step 1 and here, and a gate keyed on the older +report would then decline to load the spoke, skip the live pre-install and pre-enable reads the +spoke mandates, and leave the new gap silently unresolved — while the step-level concurrency +boundary this file opens with says the decision belongs to the step's own re-read. The progressive +disclosure is kept; only the report it keys on moves. Step 4 reuses this file rather than reading +again, so the honest gate costs nothing. + +**Step 5 still takes its own re-read.** Step 4 mutates in between — it installs, and it normalizes +the user-scope `enabledPlugins` map — so `pre-install.$mp.json` is stale by the time Step 5 runs and +cannot stand in for `pre-enable.$mp.json`. Do not collapse the two. - **Step 4 — install new catalog plugins.** Installs the `missing_from_user_install` ids at `user` scope per the configured `install_new` policy, then normalizes the user-scope `enabledPlugins` @@ -418,9 +545,15 @@ Concretely: equal project and user records at `v1`, Step 2 updates the project r user update fails — the skew is Step 2's, and a two-snapshot diff blames Step 3. Take the `divergences[]` read from each of the three `fleet-state.sh` calls the algorithm already makes — the pre-Step-2 snapshot, the pre-Step-3 re-read the concurrency rule requires anyway, and the post-sweep -re-read, saved as the run journal's `pre.json`, `mid.json`, and `post.json` — and attribute each new -row to the interval it first appeared in. No extra call is needed; this is bookkeeping over reads -that already happen. +re-read, saved as the run journal's `pre..json`, `mid..json`, and `post..json` — and +attribute each new row to the interval it first appeared in. No extra call is needed; this is +bookkeeping over reads that already happen. + +**The names carry the marketplace, and in `all` mode there is one set per marketplace.** Steps 2–5 +are the per-marketplace loop body, so a three-marketplace run leaves three `pre..json`, three +`mid..json`, and three `post..json` files in one `run_dir`. Do the attribution split per +marketplace against that marketplace's own three snapshots; a cross-marketplace diff compares +unrelated fleets. Report as ` actionable ( newly created by this run — by the in-repo update, by the user-scope diff --git a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh index d6f0f09769..5cc0bbd676 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -439,6 +439,28 @@ ids_selector_valid() { esac } +# The fields each selector's branch of PROJECTION_PROGRAM actually consumes, +# as `:` pairs. This exists for `--from`: a live run computed +# the block itself and every field is present by construction, but a saved +# report is arbitrary input, and a syntactically valid object that simply lacks +# the field a selector reads projects to NOTHING and exits 0 — a silently-empty +# id list, the exact failure class `--ids` exists to prevent. +# +# ADDITIVE to the baseline shape check below (.marketplace.name plus +# .installed, which together are what makes a file a fleet-state report at +# all), never a replacement for it. Keep this in step with PROJECTION_PROGRAM: +# a selector that starts reading a new field adds it here. +ids_selector_required_fields() { + case "$1" in + installed-user | current-project) echo 'installed:array' ;; + update-candidates-user) echo 'installed:array catalog_versions:object' ;; + missing-user-install) echo 'missing_from_user_install:array' ;; + missing-enabled) echo 'missing_from_enabled:array' ;; + user-scope-orphans) echo 'user_scope_orphans:array' ;; + *) echo '' ;; + esac +} + # Validate the selector before any marketplace resolution: a typo is a usage # error (exit 2) and must report as one, not be masked by whatever the # resolution attempt would have returned first. @@ -496,19 +518,40 @@ if [[ -n "$FROM_REPORT" ]]; then # Validate the SHAPE, not just the JSON. An --all envelope # ({"marketplaces": {...}}) parses fine and every selector branch projects it # to nothing — a silently-empty id list, which is the exact failure class the - # --ids contract exists to prevent. Fail loud and name the fix instead. - if ! jq_to FS_FROM_NAME -er ' - if type != "object" then error("not a JSON object") - elif has("marketplaces") then error("this is an --all envelope, not a single-marketplace report") + # --ids contract exists to prevent. So does a truncated object that carries + # the baseline fields but not the one THIS selector reads, so the per-selector + # required-field list above is checked too. Fail loud and name the fix instead. + # + # The verdict comes back on stdout as `ok:` or `err:` rather + # than through jq's `error()`: the detail has to reach the operator's terminal + # naming the offending field, and a nonzero jq exit only distinguishes an + # unparseable file from a well-formed but wrong-shaped one. + FS_FROM_CHECK="" + if ! jq_to FS_FROM_CHECK -r --arg required "$(ids_selector_required_fields "$IDS_SELECTOR")" ' + if type != "object" then "err:not a JSON object" + elif has("marketplaces") then "err:this is an --all envelope, not a single-marketplace report" elif (.marketplace | type) != "object" or (.marketplace.name | type) != "string" - then error("no .marketplace.name string") - elif (.installed | type) != "array" then error("no .installed array") - else .marketplace.name end' "$FROM_REPORT" 2>/dev/null; then - echo "ERROR: --from report is not a single-marketplace fleet-state report: $FROM_REPORT" >&2 + then "err:missing or wrong-typed field .marketplace.name (expected a string)" + elif (.installed | type) != "array" + then "err:missing or wrong-typed field .installed (expected an array)" + else . as $r + | [$required | split(" ")[] | select(length > 0) | split(":") + | select(($r[.[0]] | type) != .[1]) + | "missing or wrong-typed field .\(.[0]) (expected \(if .[1] == "array" then "an" else "a" end) \(.[1]))"] + as $bad + | if ($bad | length) > 0 then "err:" + ($bad | join("; ")) + else "ok:" + $r.marketplace.name end + end' "$FROM_REPORT" 2>/dev/null; then + FS_FROM_CHECK="err:not valid JSON" + fi + if [[ "$FS_FROM_CHECK" != ok:* ]]; then + echo "ERROR: --from report is not a usable fleet-state report for --ids $IDS_SELECTOR: $FROM_REPORT" >&2 + echo " ${FS_FROM_CHECK#err:}" >&2 echo " Expected the JSON object \`--marketplace \` prints (.marketplace.name," >&2 echo " .installed, .catalog_versions, …), not an --all envelope or arbitrary JSON." >&2 exit 2 fi + FS_FROM_NAME="${FS_FROM_CHECK#ok:}" # --marketplace alongside --from is an optional CONSISTENCY CHECK, never a # second read: projecting one marketplace's report while believing it is # another's is how a sweep mutates the wrong fleet. 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 3f4a606672..84f7befd0f 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.test.sh @@ -2176,6 +2176,56 @@ out=$(run_from_stdout_only "$case_dir" --marketplaces --ids missing-enabled --fr rc=$? assert_exit "--from: combined with --marketplaces is exit 2" 2 "$rc" +# ============================================================================ +# Case: a report that is syntactically valid, carries the baseline fields, and +# simply lacks the field THIS selector reads is refused by name. Before the +# per-selector required-field check, `missing-enabled` evaluated the absent +# `.missing_from_enabled` with `[]?`, emitted nothing, and exited 0 — a +# silently-empty id list a `while read` consumer reads as "nothing to do". +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +write "$case_dir/incomplete.json" '{"marketplace":{"name":"m"},"installed":[]}' +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/incomplete.json" 2>/dev/null) +rc=$? +assert_exit "--from: an incomplete report is exit 2, not an empty exit 0" 2 "$rc" +assert_eq "--from: the incomplete report leaves stdout empty" "" "$out" +err=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/incomplete.json" 2>&1 >/dev/null) +assert_contains "--from: the incomplete-report error names the file" "$err" "$case_dir/incomplete.json" +assert_contains "--from: the incomplete-report error names the missing field" "$err" ".missing_from_enabled" + +# The baseline `.installed` check stays universal rather than becoming +# per-selector: it is the structural marker that the file is a fleet-state +# report at all, so its absence is refused even for a selector that never +# reads it. +write "$case_dir/no-installed.json" '{"marketplace":{"name":"m"},"missing_from_enabled":[]}' +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/no-installed.json" 2>/dev/null) +rc=$? +assert_exit "--from: a report with no .installed is exit 2 even for a selector that ignores it" 2 "$rc" +assert_eq "--from: that rejection leaves stdout empty" "" "$out" + +# update-candidates-user is the one selector that also reads catalog_versions, +# so its required-field list is the one that has to be per-selector rather than +# a single shared list. +write "$case_dir/no-catalog-versions.json" '{"marketplace":{"name":"m"},"installed":[]}' +out=$(run_from_stdout_only "$case_dir" --ids update-candidates-user --from "$case_dir/no-catalog-versions.json" 2>/dev/null) +rc=$? +assert_exit "--from: update-candidates-user without .catalog_versions is exit 2" 2 "$rc" +err=$(run_from_stdout_only "$case_dir" --ids update-candidates-user --from "$case_dir/no-catalog-versions.json" 2>&1 >/dev/null) +assert_contains "--from: that error names .catalog_versions" "$err" ".catalog_versions" +# ...and the same fixture is fine for a selector that does not read it. +out=$(run_from_stdout_only "$case_dir" --ids installed-user --from "$case_dir/no-catalog-versions.json" 2>/dev/null) +rc=$? +assert_exit "--from: installed-user does not require .catalog_versions" 0 "$rc" + +# Present-but-empty is NOT the same as absent: a real report with nothing to do +# still exits 0 with empty output, which is what makes the exit status a usable +# discriminator for the caller's guard. +write "$case_dir/empty-but-present.json" '{"marketplace":{"name":"m"},"installed":[],"missing_from_enabled":[]}' +out=$(run_from_stdout_only "$case_dir" --ids missing-enabled --from "$case_dir/empty-but-present.json" 2>/dev/null) +rc=$? +assert_exit "--from: the field present but empty is exit 0" 0 "$rc" +assert_eq "--from: the present-but-empty projection emits nothing" "" "$out" + # ============================================================================ # Case: --from reads NO Claude Code state file. That is the cost claim — the # projection recomputes nothing — so it must hold even when every state file From bc5712a2b3c37a47c2d5b1e2479d3b3a61520d94 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:49:10 -0400 Subject: [PATCH 6/7] docs(claude-ops): branch on the projection status the snippets capture (#3728) The Step 2, Step 3, and canonical projection snippets assigned `rc` and then looped unconditionally, so the prose telling the reader to check it sat next to code that did not. That is the same defect as journaling a mutating call through `tee` without capturing `PIPESTATUS[0]`: the status is available and discarded. Each snippet now branches, reporting the failure under "Action needed" instead of falling through to the loop. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- .../claude-ops/skills/plugins/context/sync.md | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/plugins/claude-ops/skills/plugins/context/sync.md b/plugins/claude-ops/skills/plugins/context/sync.md index 5f4a3b8588..c3f41a6383 100644 --- a/plugins/claude-ops/skills/plugins/context/sync.md +++ b/plugins/claude-ops/skills/plugins/context/sync.md @@ -237,12 +237,21 @@ substitution whose status the loop discards. The intermediate lands in `$run_dir reports, so it is covered by the same cleanup and no third temp location is invented: ```bash -while IFS= read -r id; do - [[ -n "$id" ]] || continue - # the step's mutating call for "$id" -done <"$run_dir/ids.mid.$mp.txt" +if ((rc != 0)); then + # The step did NOT run. Report it inline under "Action needed" with the + # script's own error text; do not fall through to the loop. +else + while IFS= read -r id; do + [[ -n "$id" ]] || continue + # the step's mutating call for "$id" + done <"$run_dir/ids.mid.$mp.txt" +fi ``` +The branch is the point. A snippet that assigns `rc` and then loops unconditionally has done nothing +the bare process substitution did not, which is the same defect as journaling a mutating call through +`tee` without capturing `PIPESTATUS[0]`: the status is available and discarded. + Pass `--marketplace "$mp"` alongside `--from` when you want the script to prove the saved report is the one you think it is; with `--from` that flag is a consistency check (mismatch is exit 2), never a second read. @@ -362,10 +371,14 @@ project_root=$(jq -r '.project_root // "null"' "$run_dir/pre.$mp.json") --ids current-project --from "$run_dir/pre.$mp.json" >"$run_dir/ids.pre.$mp.txt" rc=$? # exit 2 with empty output is a FAILED projection, not "nothing in-repo" -while IFS=$'\t' read -r id scope; do - [[ -n "$id" ]] || continue - claude plugin update "$id" -s "$scope" -done <"$run_dir/ids.pre.$mp.txt" +if ((rc != 0)); then + # Report the projection failure under "Action needed"; the step did not run. +else + while IFS=$'\t' read -r id scope; do + [[ -n "$id" ]] || continue + claude plugin update "$id" -s "$scope" + done <"$run_dir/ids.pre.$mp.txt" +fi ``` The `rc` check matters more here than anywhere else: this is the primary value path, and an @@ -444,10 +457,14 @@ re-read — the one the concurrency rule requires before a mutating step — red --ids update-candidates-user --from "$run_dir/mid.$mp.json" >"$run_dir/ids.mid.$mp.txt" rc=$? # exit 2 with empty output is a FAILED projection, not "fleet already current" -while IFS= read -r id; do - [[ -n "$id" ]] || continue - claude plugin update "$id" -s user -done <"$run_dir/ids.mid.$mp.txt" +if ((rc != 0)); then + # Report the projection failure under "Action needed"; the sweep did not run. +else + while IFS= read -r id; do + [[ -n "$id" ]] || continue + claude plugin update "$id" -s user + done <"$run_dir/ids.mid.$mp.txt" +fi ``` Reading an unchecked empty projection as "already current" is the silently-skipped-update failure From f4bd1bb7d9060d0ee9e402116007df10f5da4757 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:50:46 -0400 Subject: [PATCH 7/7] fix(claude-ops): satisfy typos and info-level shellcheck in fleet-state.sh (#3728) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE --- plugins/claude-ops/skills/plugins/scripts/fleet-state.sh | 3 ++- 1 file changed, 2 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 5cc0bbd676..c6275f10bf 100755 --- a/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh +++ b/plugins/claude-ops/skills/plugins/scripts/fleet-state.sh @@ -525,8 +525,9 @@ if [[ -n "$FROM_REPORT" ]]; then # The verdict comes back on stdout as `ok:` or `err:` rather # than through jq's `error()`: the detail has to reach the operator's terminal # naming the offending field, and a nonzero jq exit only distinguishes an - # unparseable file from a well-formed but wrong-shaped one. + # unparsable file from a well-formed but wrong-shaped one. FS_FROM_CHECK="" + # shellcheck disable=SC2016 # a jq program: every $var is a jq variable if ! jq_to FS_FROM_CHECK -r --arg required "$(ids_selector_required_fields "$IDS_SELECTOR")" ' if type != "object" then "err:not a JSON object" elif has("marketplaces") then "err:this is an --all envelope, not a single-marketplace report"