diff --git a/plugins/harness-ops/.claude-plugin/plugin.json b/plugins/harness-ops/.claude-plugin/plugin.json index 935ba18db4..e65b104dc7 100644 --- a/plugins/harness-ops/.claude-plugin/plugin.json +++ b/plugins/harness-ops/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harness-ops", - "version": "3.7.2", + "version": "3.7.3", "description": "Claude Code operations: audit-skill-visibility (skills the listing budget hides), inventory (all commands, skills, agents, tools, plugins), audit-install-state (~/.claude), audit-performance (slowness), audit-native-overlap (skills duplicating built-ins), observability (telemetry), known-issues (Claude bugs, status), changelog, prerequisites, check, machine-profile, plugins (update the fleet), morning-brief, lanes (background loop sessions), setup. Plus opt-in hook event logs.", "author": { "name": "Melodic Software", diff --git a/plugins/harness-ops/CHANGELOG.md b/plugins/harness-ops/CHANGELOG.md index a78b73b7c6..41241cea22 100644 --- a/plugins/harness-ops/CHANGELOG.md +++ b/plugins/harness-ops/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `harness-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [3.7.3] - 2026-10-04 + +### Fixed + +- **`/harness-ops:plugins` sync report rows say what their numbers cover, and its timings account for the run up to the digest.** The `Divergences:` split now follows the total it sums to (`67 actionable: 24 pre-existing, 43 newly created by this run (…)`); with a project root the line leads with this repo's count and gives the machine-wide split under `on this whole machine`, instead of trailing a run-wide split after the "more elsewhere" remainder it did not add up to. Each marketplace's `timings` gains `pre_install_read` (the Steps 4-5 gate read), `finalize` (version capture, the divergence and regression diffs, and the monitor count, which used to run after the block's clock stopped), and `unattributed`, so the steps and the remainder sum to the block's `total`; on the default-marketplace path the block's window opens at the resolving read, so its `pre_refresh_read` is inside the total. The run's `timings` gains `outside_marketplaces`, the part of `total` no marketplace block's window covers (run setup and each block's assembly into the digest); `total` stops before the digest is assembled, since a digest cannot time its own assembly without a second `jq` process, so it leaves out that assembly, the render and the print. Both `Timing:` rows render the remainder, and the run row reads `run total` rather than `whole invocation`. `catalog_last_updated` is read after Step 1's refresh, on the first pass and on an `--only-install` re-entry, rather than from the pre-refresh snapshot. The cache-content check is asked for user scope explicitly, the digest carries `cache_content.scope` in place of the always-zero `skipped_absent_project_paths`, and the rendered row counts `user-scope install(s)`. `SKILL.md` no longer asks for an audit concurrency caveat the render never prints, the Steps 4-5 spoke gate is stated in digest fields (`install_gap`, `enable_gap`, `install_enable_deferred`) with the obsolete snapshot-gate rationale dropped, and the spoke says its command blocks describe what `sync-run.sh` runs. The audit report's first line (`every call sync would make is a prediction`), `SKILL.md` and the render's header comment now state that the `would run:` prefix marks calls `sync` would make, so a remedy `sync` never runs (the `git -C … pull --ff-only`, a project-scope enable) is unprefixed in both modes, and `sync.md`'s self-update paragraph points at the loading docs instead of quoting them ([#6056](https://github.com/melodic-software/claude-code-plugins/issues/6056)). + ## [3.7.2] - 2026-10-04 ### Fixed diff --git a/plugins/harness-ops/skills/plugins/SKILL.md b/plugins/harness-ops/skills/plugins/SKILL.md index a3005df4bd..239217edbb 100644 --- a/plugins/harness-ops/skills/plugins/SKILL.md +++ b/plugins/harness-ops/skills/plugins/SKILL.md @@ -181,10 +181,10 @@ Read-only dry run of what `sync` (and, where relevant, `converge`) would do. Run in [context/sync.md](context/sync.md) with every mutating CLI call replaced by "would run: ``" in the report. Call `fleet-state.sh`, compute the same install/enable/divergence deltas, but issue **zero** `plugin install|update|uninstall|marketplace update` invocations. Predict `converge`'s -per-plugin intent (context/converge.md's preview step) the same way, without executing it. State-file -contents (`installed_plugins.json`, `known_marketplaces.json`, committed settings) are unchanged by -an `audit` run, modulo any concurrent session or background `autoUpdate` sweep. Note that caveat in -the report rather than asserting byte-identical files. +per-plugin intent (context/converge.md's preview step) the same way, without executing it. An +`audit` run writes no state file (`installed_plugins.json`, `known_marketplaces.json`, committed +settings); a concurrent session or background `autoUpdate` sweep can still change them while it +runs. **A read that sits beside a mutating call is still taken.** Step 1 is the case that matters: its pre-refresh `fleet-state.sh` snapshot is a read and `audit` takes it, and only the @@ -225,16 +225,17 @@ counted variant, forward moves only); `Updated:` (forward moves and pairs flagge `(direction unknown)`) and `Downgraded:`; `Catalog regression:`; `Installed:` with the policy-`all` recurrence clause; `Normalized:`; `Enabled:`; the `Divergences:` split, led by this project's count when a root resolved; the self-update note when the sweep moved this plugin; the stale project -records and cache content sections; the `Timing:` row (the marketplace total and its slowest step, -with the clock's resolution; a measurement with no threshold); and `Action needed` (a behind `directory` checkout with the +records and user-scope cache content sections; the `Timing:` row (the marketplace total, its slowest step, +and the seconds outside the named steps, with the clock's resolution; a measurement with no threshold); and `Action needed` (a behind `directory` checkout with the `git -C pull --ff-only` to run and the marketplace to resync, install and enable gaps, failed CLI calls, user-scope orphans, installs that left userConfig options unset, installs the CLI reported as disabled by default, plugins absent from the catalog (installed, or only enabled in settings), updated plugins whose installed build declares a monitor, reorder refusals, an unsorted project-scope map, withheld downgrades with both versions and the likely cause, and every error). -In `audit` mode every mutating line carries the `would run:` prefix and `Would withhold:` sits -beside `Would update:` whether or not a downgrade was found; `--allow-downgrade` is named as -ignored. Each shape is pinned to a golden file under `scripts/fixtures/render/` by +In `audit` mode every call `sync` would make carries the `would run:` prefix, and a remedy `sync` +never runs itself (the `git -C … pull --ff-only`, a project-scope enable) reads the same as in +`sync`, unprefixed. `Would withhold:` sits beside `Would update:` whether or not a downgrade was +found; `--allow-downgrade` is named as ignored. Each shape is pinned to a golden file under `scripts/fixtures/render/` by `scripts/sync-run.test.sh`. Paste the render as printed. Do not restate a number, an id, a scope, or a count it already @@ -349,7 +350,7 @@ through the Read tool, or on the Bash tool's environment carrying `CLAUDE_SKILL_ | 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 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/sync-install-enable.md](context/sync-install-enable.md) | A digest block has a non-empty `install_gap` or `enable_gap`, or `install_enable_deferred: true`. Both gaps 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/stale-records-cache-content.md](context/stale-records-cache-content.md) | The digest's `stale_project_records.total` or `cache_content.stale_content` is above 0, or the user asks why either section reads as it does. | | [context/script-contracts.md](context/script-contracts.md) | A step misbehaves, `converge` needs an id list, or a caller other than `sync-run.sh` is about to invoke `fleet-state.sh`, `cache-content-check.sh`, or `normalize-enabled-plugins.sh`. | diff --git a/plugins/harness-ops/skills/plugins/context/stale-records-cache-content.md b/plugins/harness-ops/skills/plugins/context/stale-records-cache-content.md index 7cfdccb26f..42c7511b69 100644 --- a/plugins/harness-ops/skills/plugins/context/stale-records-cache-content.md +++ b/plugins/harness-ops/skills/plugins/context/stale-records-cache-content.md @@ -55,8 +55,8 @@ copy, not a reconstruction. Only ids that Step 5 did not enable at `user`/`local appear there. For the rest the command would fail rather than run, and Step 5 explains why. When a project root resolved, the render leads the `Divergences:` line with *this* project's -actionable count and folds the rest of the machine into one trailing clause, e.g. `2 behind here -→ converge; 27 more elsewhere on this machine`. Per-row detail (naming exact ` → ` +actionable count and gives the machine-wide total, which includes it, in one trailing clause, e.g. +`2 behind here → converge; on this whole machine 29 actionable: …`. Per-row detail (naming exact ` → ` versions per repo) is reserved for genuine conflicts: a delisted or delisted_settings_only plugin id, or a CLI call that failed, never for the routine bulk case. (Enable-state mismatches, a plugin `true` in one scope's `enabledPlugins` and `false` in another, are a known blind spot, not a reportable category: @@ -72,9 +72,11 @@ the new commit while the directory still holds the old build. See [scope-semantics.md](scope-semantics.md) "An unchanged version number keeps the old cache directory while `gitCommitSha` moves" for the observation this rests on. -Step 5b runs `cache-content-check.sh`, which byte-compares every file in each cache directory -against the recorded commit in the marketplace clone. The render omits the `Cache content:` row -when it finds nothing. When it finds something, the render names the ids and gives the remediation +Step 5b runs `cache-content-check.sh --scope user`, which byte-compares every file in each +user-scope install's cache directory against the recorded commit in the marketplace clone. +Project- and local-scope records are not compared, so the digest's `cache_content.scope` reads +`user` and the rendered row counts `user-scope install(s)`. The render omits the `Cache content:` +row when it finds nothing. When it finds something, the render names the ids and gives the remediation that was actually proved to work, rather than a suggestion: remove that version's directory under the plugin cache, then re-run `claude plugin update @`, which recreates it from the clone. diff --git a/plugins/harness-ops/skills/plugins/context/sync-install-enable.md b/plugins/harness-ops/skills/plugins/context/sync-install-enable.md index bb34d372bd..b7b1fedb36 100644 --- a/plugins/harness-ops/skills/plugins/context/sync-install-enable.md +++ b/plugins/harness-ops/skills/plugins/context/sync-install-enable.md @@ -1,12 +1,15 @@ # Sync Steps 4 and 5: install and enable -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. +Read this file only when a digest block has a non-empty `install_gap` **or** a non-empty +`enable_gap`, or `install_enable_deferred: true` because Step 1's refresh failed for that +marketplace and the report names what these two steps deferred. `sync-run.sh` fills both gaps from +the fresh pre-Step-4 re-read it saves as `pre-install..json`, never from Step 1's older report. +On an already-current fleet both gaps are empty, both steps are no-ops, and none of this is +reachable. + +`sync-run.sh` runs these steps. The command blocks below describe what it runs, so a reader can +check a digest row against the call behind it; they are not steps for the model to run. The one +part the model runs is the `ask` prompt under Step 4. 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 applies here unchanged: CLI-mediated mutation only, the per-marketplace diff --git a/plugins/harness-ops/skills/plugins/context/sync.md b/plugins/harness-ops/skills/plugins/context/sync.md index 340197fc98..0b73189a47 100644 --- a/plugins/harness-ops/skills/plugins/context/sync.md +++ b/plugins/harness-ops/skills/plugins/context/sync.md @@ -67,20 +67,29 @@ install output said the plugin is disabled by default (`installed_disabled[]`), installs the catalog's names no longer carry (`delisted[]`), the same for installs in the repo the run stands in (`delisted_project[]`, one `{id, scope}` each), the effective `true` `enabledPlugins` keys with no install record that those names no longer carry (`delisted_settings_only[]`; a -catalog with an empty `plugins` array produces none of the three), the project-scope enable rows, the normalizer result, the cache-content counts and stale ids, the catalog regression +catalog with an empty `plugins` array produces none of the three), the project-scope enable rows, the normalizer result, the cache-content counts and stale ids with the scope they cover (`cache_content.scope`, always `user`), the catalog regression interval, the three-snapshot divergence split, whether the sweep updated this plugin itself, the moved plugins whose installed build declares a monitor (`updated_with_monitors[]`, one `{id, scope, monitors}` each, read from the record's own cache directory in the post-sweep -snapshot), and `timings`: seconds to three decimals for -`pre_refresh_read`, `marketplace_update`, `in_repo_update`, `user_sweep`, `install_enable`, -`cache_content_check`, `post_read`, and the marketplace's `total`, with `resolution` naming the +snapshot), `catalog_last_updated` (the marketplace's `lastUpdated` as Step 2's `pre..json` read +it, after Step 1's refresh, on the first pass and on an `--only-install` re-entry alike; a +background refresh can move the stamp again later in the run, so it dates the catalog as of +Step 2, not every read), and `timings`: seconds to three decimals for +`pre_refresh_read`, `marketplace_update`, `in_repo_update`, `user_sweep`, `pre_install_read` (the +Steps 4 and 5 gate read, taken on every pass), `install_enable`, `cache_content_check`, +`post_read`, `finalize` (version capture, the divergence and regression diffs, and the monitor +count that build the block), `unattributed` (the rest of the block's time, between and around those +steps), and the marketplace's `total`, which those keys sum to, with `resolution` naming the clock that produced them (`microseconds` from bash's `EPOCHREALTIME`, `nanoseconds` from a -validated `date +%s.%N`, else `seconds`). A step this invocation did not run, because `audit` -predicted it, the policy stopped before Step 4, or an `--only-install` re-entry reuses the first -pass's result, reads `null`, never 0. The digest's top-level `timings.total` times the whole -invocation, and its `cwd` is what the `In-repo:` row names when no project root resolved. Ids and -counts only: the per-file cache detail and every snapshot stay in the run directory, which the -digest names. +validated `date +%s.%N`, else `seconds`). On the default-marketplace path the block's window opens +at the resolving read, which is that marketplace's `pre_refresh_read`. A step this invocation did +not run, because `audit` predicted it, the policy stopped before Step 4, or an `--only-install` +re-entry reuses the first pass's result, reads `null`, never 0. The digest's top-level +`timings.total` times the run from its start until the digest is about to be assembled (so it +excludes the digest's own assembly, the render and the print), and `timings.outside_marketplaces` +is the part of it no block's window covers (run setup and each block's assembly into the digest). Its `cwd` is what the `In-repo:` row names when no +project root resolved. Ids and counts only: the per-file cache detail and every snapshot stay in +the run directory, which the digest names. ## Concurrency @@ -634,18 +643,12 @@ and on Windows a hand-written mutating step, so the concurrency rule already requires its own re-read; it is taken here, before either step decides whether it has anything to do, and saved as `pre-install..json`. -**Read [sync-install-enable.md](sync-install-enable.md) only when `pre-install..json` has a -non-empty `missing_from_user_install` or a non-empty `missing_from_enabled`, or when Step 1's -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. +The script takes that read on every pass and projects the digest's `install_gap` and `enable_gap` +from it, so the gaps reflect state as of Step 4, not Step 1. **Read +[sync-install-enable.md](sync-install-enable.md) only when a block's `install_gap` or `enable_gap` +is non-empty, or its `install_enable_deferred` is true.** Both gaps are empty on an +already-current fleet, which is the common case, and then both steps are no-ops with nothing to +load. **Step 5 still takes its own re-read.** Step 4 mutates in between, installing and normalizing the user-scope `enabledPlugins` map, so `pre-install..json` is stale by the time Step 5 runs and @@ -678,10 +681,17 @@ that re-entry's installs and enables, so the picks are what gets checked. A firs stop writes the report, and a re-entry reuses it. The checker runs once per marketplace across the two invocations. -**One call per marketplace.** `cache-content-check.sh --marketplace "$mp"` writes its JSON to -`cache-content..json`, and the stale ids come out of that same JSON, CR-stripped in the shell -the way every id list in this algorithm is. A second call for the id list would recompute a -fleet-wide byte comparison the first call already did, which is the most expensive read in the run. +**One call per marketplace.** `cache-content-check.sh --marketplace "$mp" --scope user` writes its +JSON to `cache-content..json`, and the stale ids come out of that same JSON, CR-stripped in the +shell the way every id list in this algorithm is. A second call for the id list would recompute a +byte comparison over every user-scope install the first call already did, which is the most +expensive read in the run. + +**The check covers user-scope installs only.** Project- and local-scope records, including the +repo the run stands in, are not compared, so the digest carries `cache_content.scope` and the +rendered row says `user-scope install(s)` rather than implying the whole fleet. The checker has +`--scope project` and `--scope all`, but no filter that limits a project-scope check to one repo; +extending Step 5b to the standing repo's records needs that filter first. In `sync` that redirect lands in the run journal beside the `fleet-state.sh` snapshots, so Step 6 reads the finding rather than remembering it. In `audit` it lands in the throwaway scratch directory @@ -760,21 +770,30 @@ marketplace against that marketplace's own three snapshots; a cross-marketplace unrelated fleets. Report as -` actionable ( newly created by this run: by the in-repo update, by the user-scope -sweep, pre-existing)`. When the two intervals genuinely cannot be separated (a snapshot was -missed), say ` newly created by this run` without splitting it, rather than assigning the whole -delta to one step. +` actionable: pre-existing, newly created by this run ( by the in-repo update, +by the user-scope sweep)`, so each split follows the total it sums to. When a project root +resolved, the line leads with this repo's count and then gives the same machine-wide split under +`on this whole machine`, because `` counts every repo's divergences and the here-count is a part +of it, not a separate remainder. When a snapshot was missed the split cannot be computed, and the +line says `not computed` rather than assigning the whole delta to one step. **Say when the sweep updated `harness-ops` itself.** Step 3 sweeps every user-scope id, which necessarily includes the plugin providing this skill. When it does, the algorithm that ran is the **pre-update** one: `${CLAUDE_PLUGIN_ROOT}` keeps resolving to the version loaded at session start, so every later `fleet-state.sh` call and every remaining step executes the old copy, and the report -describes work done by a version the user no longer has installed. Current docs, `plugins-reference` -(fetched 2026-08-22): "When a plugin updates mid-session, hook commands, monitors, MCP servers, and -LSP servers keep using the previous version's path." This is not a crash risk, since the previous -version directory is retained on a grace period and the running script does not vanish mid-run. It -is a reporting obligation. The render emits the self-update note when the digest's `self_updated` -is true. +describes work done by a version the user no longer has installed. We treat this as a reporting +obligation, not a crash risk: the previous version's directory stays on disk for a grace period, so +the running script does not vanish mid-run. The render emits the self-update note when the digest's +`self_updated` is true. + +- **Pointer**: for which components keep the previous version's path after a mid-session update, + see the update paragraph under + [Which marketplaces and plugins auto-update](https://code.claude.com/docs/en/plugins/loading#which-marketplaces-and-plugins-auto-update); + for how long the previous version's directory is kept, see + [Cleanup of previous versions](https://code.claude.com/docs/en/plugins/loading#cleanup-of-previous-versions). +- **As of**: 2026-10-04 +- **Recheck trigger**: either section changes which components keep the previous version's path, + or drops the delay before the previous version's directory is removed. **Name the monitors.** The script reads each moved record's own cache directory from the post-sweep snapshot (`installPath`) and counts the monitors that build declares; `monitor_count_at` in diff --git a/plugins/harness-ops/skills/plugins/evals/evals.json b/plugins/harness-ops/skills/plugins/evals/evals.json index b33f9b0535..b659cd1969 100644 --- a/plugins/harness-ops/skills/plugins/evals/evals.json +++ b/plugins/harness-ops/skills/plugins/evals/evals.json @@ -160,8 +160,8 @@ { "id": 13, "name": "rendered-report-is-pasted-not-restated", - "prompt": "/harness-ops:plugins audit: sync-run.sh --audit --render printed the digest line and then the rendered report, whose rows read `Would update: 3 plugin(s)` with three `would run: claude plugin update … -s user` lines, `Would withhold: 1 downgrade(s)` naming gamma@market1 0.5.0 → 0.1.0, `Divergences: 2 behind here → run /harness-ops:plugins converge; 5 more elsewhere on this machine (…)`, and a `Timing:` row.", - "expected_output": "The response pastes the rendered report as printed and appends exactly one line of its own: the reload guidance, stated as the docs' two-step (run /reload-plugins; if it warns that the next message would re-read the conversation, run /reload-plugins --force). It does not retype, paraphrase, or recompute any count, id, scope, or version the render already carries (the 3, the 1, gamma's versions, the 2 and 5 of the Divergences split), does not add rows the render omitted, and does not compute its own Divergences split from the digest. It also does not add a monitor call-out of its own: that bullet is the render's, under Action needed, when the digest's updated_with_monitors is non-empty.", + "prompt": "/harness-ops:plugins audit: sync-run.sh --audit --render printed the digest line and then the rendered report, whose rows read `Would update: 3 plugin(s)` with three `would run: claude plugin update … -s user` lines, `Would withhold: 1 downgrade(s)` naming gamma@market1 0.5.0 → 0.1.0, `Divergences: 2 behind here → run /harness-ops:plugins converge; on this whole machine 7 actionable: …`, and a `Timing:` row.", + "expected_output": "The response pastes the rendered report as printed and appends exactly one line of its own: the reload guidance, stated as the docs' two-step (run /reload-plugins; if it warns that the next message would re-read the conversation, run /reload-plugins --force). It does not retype, paraphrase, or recompute any count, id, scope, or version the render already carries (the 3, the 1, gamma's versions, the 2 and 7 of the Divergences line), does not add rows the render omitted, and does not compute its own Divergences split from the digest. It also does not add a monitor call-out of its own: that bullet is the render's, under Action needed, when the digest's updated_with_monitors is non-empty.", "files": [], "expectations": [ "The response reproduces the rendered report verbatim rather than summarizing it in its own words", diff --git a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/audit-would-run.txt b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/audit-would-run.txt index c5a228a732..fade24f5d3 100644 --- a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/audit-would-run.txt +++ b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/audit-would-run.txt @@ -1,4 +1,4 @@ -Audit (read-only): every mutating call below is a prediction, prefixed would run: +Audit (read-only): every call sync would make is a prediction, prefixed would run: Marketplace: market1: needs update (autoUpdate: on) would run: claude plugin marketplace update market1 (audit: predicted against the unrefreshed catalog, lastUpdated 2026-01-01T00:00:00Z; a lower bound on what sync would update) In-repo: 0: has no project/local installs diff --git a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-no-root.txt b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-no-root.txt new file mode 100644 index 0000000000..f776405238 --- /dev/null +++ b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-no-root.txt @@ -0,0 +1,7 @@ +Marketplace: m: current (autoUpdate: on) +In-repo: skipped: no project context resolved from /w +Divergences: 67 actionable: 24 pre-existing, 43 newly created by this run (5 by the in-repo update, 38 by the user-scope sweep) → run `/harness-ops:plugins converge` +Timing: 1s this marketplace (seconds) + +Run journal: /r +Timing: 1s run total (seconds) diff --git a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-project-root.txt b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-project-root.txt new file mode 100644 index 0000000000..d03b451845 --- /dev/null +++ b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/divergence-split-project-root.txt @@ -0,0 +1,7 @@ +Marketplace: m: current (autoUpdate: on) +In-repo: 0 project/local install(s) updated in /w/repo +Divergences: 3 behind here → run `/harness-ops:plugins converge`; on this whole machine 67 actionable: 24 pre-existing, 43 newly created by this run (5 by the in-repo update, 38 by the user-scope sweep) +Timing: 1s this marketplace (seconds) + +Run journal: /r +Timing: 1s run total (seconds) diff --git a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/stale-records-cache-content.txt b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/stale-records-cache-content.txt index 51cce8515f..7e5e83f948 100644 --- a/plugins/harness-ops/skills/plugins/scripts/fixtures/render/stale-records-cache-content.txt +++ b/plugins/harness-ops/skills/plugins/scripts/fixtures/render/stale-records-cache-content.txt @@ -9,7 +9,7 @@ Stale project records: 3 record(s) across 2 path(s) not present on this machine (not counted as divergences: converge cannot cd into a path that is not present. A path can also be absent because a volume is unmounted or a share is offline, so this is an observation, not a verdict that the directory is gone for good.) -Cache content: 1 install(s) whose cache files disagree with their recorded gitCommitSha +Cache content: 1 user-scope install(s) whose cache files disagree with their recorded gitCommitSha - alpha@market1 0.1.0: 4 file(s) differ Remediation: remove that version's directory under the plugin cache, then re-run `claude plugin update @`, which recreates it from the clone. diff --git a/plugins/harness-ops/skills/plugins/scripts/render-report.jq b/plugins/harness-ops/skills/plugins/scripts/render-report.jq index 9ac07ce3ae..81a82d3c26 100644 --- a/plugins/harness-ops/skills/plugins/scripts/render-report.jq +++ b/plugins/harness-ops/skills/plugins/scripts/render-report.jq @@ -10,7 +10,9 @@ # Every section, conditional row, annotation and `Action needed` bullet is # derived from a digest field; nothing here is judgment, so the report cannot # misstate a number, an id, or a scope the digest carries. In audit mode every -# line that would be a mutation carries the `would run:` prefix, and the +# call sync would make carries the `would run:` prefix. A remedy sync never runs +# itself (the `git -C … pull`, a project-scope enable) is the operator's in both +# modes and carries no prefix, since sync would not run it either. The # `Would withhold` row appears beside `Would update` whether or not a downgrade # was found. Errors in a block's `errors[]` render under that block's # `Action needed`; the run's own `errors[]` render under the trailing one. @@ -45,7 +47,8 @@ def key_count: (capture("keys=(?[0-9]+)") // {k: "?"}) | .k; # The slowest timed step of a block's timings object, or null. def slowest_step: - [to_entries[] | select(.key != "total" and .key != "resolution" and .value != null)] + [to_entries[] + | select(.key != "total" and .key != "resolution" and .key != "unattributed" and .value != null)] | if length == 0 then null else max_by(.value) end; def block($d): @@ -167,16 +170,17 @@ def block($d): else empty end), # Divergences: actionable only (versionsMatch false), split by interval. + # The split is machine-wide, so it follows the machine-wide count it sums to. (.divergences as $v | if $v.post == null then "Divergences: not computed (\($v.note // "a snapshot was missing"))" else - ("(\($v.new_total) newly created by this run: \($v.new_by_interval.in_repo) by the in-repo update, \($v.new_by_interval.user_sweep) by the user-scope sweep, \($v.pre_existing) pre-existing)") as $split + ("\($v.post) actionable: \($v.pre_existing) pre-existing, \($v.new_total) newly created by this run (\($v.new_by_interval.in_repo) by the in-repo update, \($v.new_by_interval.user_sweep) by the user-scope sweep)") as $split | if $v.post == 0 then "Divergences: 0 actionable" elif .project_root != null and .divergences_here != null then - "Divergences: \(.divergences_here) behind here → run `/harness-ops:plugins converge`; \($v.post - .divergences_here) more elsewhere on this machine \($split)" + "Divergences: \(.divergences_here) behind here → run `/harness-ops:plugins converge`; on this whole machine \($split)" else - "Divergences: \($v.post) actionable \($split) → run `/harness-ops:plugins converge`" + "Divergences: \($split) → run `/harness-ops:plugins converge`" end end), @@ -196,15 +200,16 @@ def block($d): else empty end), (.cache_content as $c + | ("\($c.scope // "user")-scope install(s)") as $installs | if $c == null then empty elif ($c.stale_content // 0) > 0 then - "Cache content: \($c.stale_content) install(s) whose cache files disagree with their recorded gitCommitSha", + "Cache content: \($c.stale_content) \($installs) whose cache files disagree with their recorded gitCommitSha", ($c.stale[] | if .files_differ == null then " - \(.id)" else " - \(.id) \(.version): \(.files_differ) file(s) differ" end), - (if ($c.unverifiable // 0) > 0 then " (checked \($c.checked): \($c.match) match, \($c.unverifiable) unverifiable; unverifiable is not a pass)" else empty end), + (if ($c.unverifiable // 0) > 0 then " (checked \($c.checked) \($installs): \($c.match) match, \($c.unverifiable) unverifiable; unverifiable is not a pass)" else empty end), " Remediation: remove that version's directory under the plugin cache, then re-run", " `claude plugin update @`, which recreates it from the clone." elif ($c.unverifiable // 0) > 0 then - "Cache content: \($c.unverifiable) of \($c.checked) install(s) unverifiable (recorded commit not in the local marketplace clone); no disagreement found among the rest" + "Cache content: \($c.unverifiable) of \($c.checked) \($installs) unverifiable (recorded commit not in the local marketplace clone); no disagreement found among the rest" else empty end), (if $t == null then "Timing: n/a" @@ -212,6 +217,7 @@ def block($d): ($t | slowest_step) as $slow | "Timing: \($t.total | secs) this marketplace" + (if $slow == null then "" else "; slowest step \($slow.key) \($slow.value)s" end) + + (if $t.unattributed == null then "" else "; \($t.unattributed)s outside the named steps" end) + " (\($t.resolution))" end), @@ -288,13 +294,16 @@ def block($d): . as $d | [ - (if $d.mode == "audit" then "Audit (read-only): every mutating call below is a prediction, prefixed would run:" else empty end), + (if $d.mode == "audit" then "Audit (read-only): every call sync would make is a prediction, prefixed would run:" else empty end), ($d.marketplaces[] | block($d)), (if ($d.marketplaces | length) == 0 then "Marketplace: none resolved for this run" else empty end), "", (if $d.mode == "audit" then "Run: audit scratch directory, removed on exit" else "Run journal: \($d.run_dir)" end), - "Timing: \($d.timings.total | secs) whole invocation (\($d.timings.resolution))", + "Timing: \($d.timings.total | secs) run total" + + (if $d.timings.outside_marketplaces == null then "" + else "; \($d.timings.outside_marketplaces)s outside the marketplace blocks" end) + + " (\($d.timings.resolution))", ([ (if $d.install_new_invalid != null then "install_new value \"\($d.install_new_invalid)\" is not all, none, or ask; this run used ask" diff --git a/plugins/harness-ops/skills/plugins/scripts/sync-run.sh b/plugins/harness-ops/skills/plugins/scripts/sync-run.sh index 171f5bff10..68a71dd9d2 100755 --- a/plugins/harness-ops/skills/plugins/scripts/sync-run.sh +++ b/plugins/harness-ops/skills/plugins/scripts/sync-run.sh @@ -56,7 +56,7 @@ # Output (stdout): one compact JSON object, also written to `/digest.json`, # followed under `--render` by a blank line and the rendered report. # {run_dir, cwd, mode, allow_downgrade, install_new, install_new_invalid, -# timings:{total,resolution}, marketplaces:[…], errors:[…]} +# timings:{total,outside_marketplaces,resolution}, marketplaces:[…], errors:[…]} # Each marketplace block: {name, timings, catalog_last_updated, catalog_source, # source_checkout, auto_update, refresh:{rc,output,predicted}, project_root, # in_repo:{updated,failed,would_update}, @@ -80,9 +80,14 @@ # catalog with an empty plugins array produces none of the three. # `updated_with_monitors[]` is `{id, scope, monitors}` for each plugin this # run moved whose installed manifest declares a monitor. +# `catalog_last_updated` is the marketplace's lastUpdated as Step 2's read saw +# it, after Step 1's refresh; a background refresh can move it later in the run. +# `timings` holds one key per step plus `unattributed`, the rest of the block's +# `total`, so the steps and the remainder sum to it. # `in_repo_records` counts the project/local records belonging to the repo the # run stands in, whether or not any of them moved; `stale_project_records` is -# `{total, by_path:[{path,count}]}`; `cache_content.stale[]` is +# `{total, by_path:[{path,count}]}`; `cache_content.scope` is `user`, the only +# records Step 5b compares, and `cache_content.stale[]` is # `{id, version, files_differ}` per stale install. `source_checkout` is null # unless the source is `directory`; then it is `{path, state, branch, upstream, # ahead, behind, dirty}` with `state` one of tracking, no_upstream, detached, @@ -706,7 +711,8 @@ reset_marketplace_state() { # Step stamps (start, end) for this marketplace; an empty pair is a step this # invocation did not run and reads `null` in the digest, never 0. T_PRR_S="" T_PRR_E="" T_MU_S="" T_MU_E="" T_IR_S="" T_IR_E="" T_US_S="" T_US_E="" - T_IE_S="" T_IE_E="" T_CC_S="" T_CC_E="" T_PR_S="" T_PR_E="" T_MP_S="" T_MP_E="" + T_PI_S="" T_PI_E="" T_IE_S="" T_IE_E="" T_CC_S="" T_CC_E="" T_PR_S="" T_PR_E="" + T_FN_S="" T_MP_S="" T_MP_E="" CACHE_CHECK_RAN=0 } reset_marketplace_state @@ -813,10 +819,11 @@ run_marketplace() { return 0 fi elif [[ "$mp" == "$MAIN_READ_MP" ]]; then + # That read ran before this function, so the block's window opens where it did. T_PRR_S="$MAIN_READ_S" T_PRR_E="$MAIN_READ_E" + T_MP_S="$MAIN_READ_S" fi - jq_to CATALOG_LAST_UPDATED -r '.marketplace.lastUpdated // ""' "$pre_refresh" if [[ "$MODE" == "audit" ]]; then # Predicted, not run: the step's timing stays null. @@ -839,8 +846,8 @@ run_marketplace() { else # The re-entry reports the whole run, so the fields Steps 1-3 filled come back # off their saved snapshots rather than reading as absent. - [[ -f "$pre_refresh" ]] && jq_to CATALOG_LAST_UPDATED -r '.marketplace.lastUpdated // ""' "$pre_refresh" if [[ -f "$RUN_DIR/pre.$mp.json" ]]; then + jq_to CATALOG_LAST_UPDATED -r '.marketplace.lastUpdated // ""' "$RUN_DIR/pre.$mp.json" jq_to PROJECT_ROOT_JSON -c '.project_root' "$RUN_DIR/pre.$mp.json" [[ -n "$PROJECT_ROOT_JSON" ]] || PROJECT_ROOT_JSON="null" fi @@ -865,6 +872,9 @@ run_marketplace() { emit_marketplace_block "$mp" return 0 fi + # Step 1's refresh rewrites the stamp, so it is read after that refresh, never + # off the pre-refresh snapshot. + jq_to CATALOG_LAST_UPDATED -r '.marketplace.lastUpdated // ""' "$RUN_DIR/pre.$mp.json" jq_to PROJECT_ROOT_JSON -c '.project_root' "$RUN_DIR/pre.$mp.json" [[ -n "$PROJECT_ROOT_JSON" ]] || PROJECT_ROOT_JSON="null" @@ -986,6 +996,9 @@ run_marketplace() { fi # ---- Steps 4 and 5 — install and enable, gated on the FRESH pre-install read --- + # Timed as its own step: it runs on every pass, while `install_enable` stays + # null when the gate below finds nothing to do or the `ask` policy stops. + clock_into T_PI_S fleet_read "$mp" "$RUN_DIR/pre-install.$mp.json" pre-install rc=$? if ((rc == 0)); then @@ -1008,6 +1021,7 @@ run_marketplace() { local gap_count enable_gap_count jq_to gap_count -r 'length' <<<"$INSTALL_GAP" jq_to enable_gap_count -r 'length' <<<"$ENABLE_GAP" + clock_into T_PI_E if ((REFRESH_FAILED == 1)); then # Step 4 derives installations from the catalog and Step 5 consults catalog @@ -1050,6 +1064,7 @@ run_marketplace() { fleet_read "$mp" "$RUN_DIR/post.$mp.json" post clock_into T_PR_E + clock_into T_FN_S finalize_moves "$mp" local own @@ -1225,7 +1240,7 @@ cache_content_block() { if [[ ! -f "$report" ]]; then CACHE_CHECK_RAN=1 - "$CACHE_CHECK" --marketplace "$mp" >"$report" 2>"$RUN_DIR/.cc-err" + "$CACHE_CHECK" --marketplace "$mp" --scope user >"$report" 2>"$RUN_DIR/.cc-err" rc=$? fi @@ -1233,10 +1248,11 @@ cache_content_block() { # names, and one file count. All three directions are summed, because the row # says the cache DISAGREES with the recorded sha and a file only in the tree or # only in the cache disagrees exactly as much as one whose bytes changed. - # `stale_ids` stays alongside it for readers that only need the ids. + # `stale_ids` stays alongside it for readers that only need the ids. `scope` + # names which records the counts cover: project and local records are not + # compared. if ((rc == 0)) && jq -e 'has("checked")' "$report" >/dev/null 2>&1; then - jq_to CACHE_JSON -c '{checked, match, stale_content, unverifiable, - skipped_absent_project_paths, + jq_to CACHE_JSON -c '{scope: (.scope // "user"), checked, match, stale_content, unverifiable, stale_ids: [.installs[]? | select(.verdict == "stale-content") | .id], stale: [.installs[]? | select(.verdict == "stale-content") | {id, version, files_differ: ((.differing // 0) + (.missing_from_cache // 0) @@ -1248,7 +1264,7 @@ cache_content_block() { # The JSON is unusable, so the id list is projected with the checker's own `--ids` # form rather than left unknown. CACHE_CHECK_RAN=1 - "$CACHE_CHECK" --marketplace "$mp" --ids >"$RUN_DIR/cache-stale-ids.$mp.txt" 2>>"$RUN_DIR/.cc-err" + "$CACHE_CHECK" --marketplace "$mp" --scope user --ids >"$RUN_DIR/cache-stale-ids.$mp.txt" 2>>"$RUN_DIR/.cc-err" rc=$? json_lines ids "$RUN_DIR/cache-stale-ids.$mp.txt" mp_error "cache-content JSON unusable; stale ids taken from the checker's --ids form (exit $rc)" @@ -1257,8 +1273,8 @@ cache_content_block() { # tell an empty finding from a missing field. # shellcheck disable=SC2016 # a jq program: every $var is a jq variable jq_to CACHE_JSON -c -n --argjson ids "$ids" \ - '{checked: null, match: null, stale_content: ($ids | length), unverifiable: null, - skipped_absent_project_paths: null, stale_ids: $ids, + '{scope: "user", checked: null, match: null, stale_content: ($ids | length), unverifiable: null, + stale_ids: $ids, stale: ($ids | map({id: ., version: null, files_differ: null})), source: "ids-fallback"}' } @@ -1504,7 +1520,8 @@ monitor_rows() { emit_marketplace_block() { local mp="$1" local ir_u ir_f ir_w us_u us_f us_w wh dg inst en pr errs unset_cfg installed_disabled monitors - clock_into T_MP_E + # A marketplace abandoned on a failed read reaches here without finalize_moves. + [[ -n "$T_FN_S" ]] || clock_into T_FN_S # Written on the first pass only. The re-entry reads this sidecar; overwriting # it after restore would drop the first-pass failures from a later re-entry. if ((ONLY_INSTALL_MODE == 0)); then @@ -1541,6 +1558,9 @@ emit_marketplace_block() { [[ -n "$extras" ]] || extras='{}' [[ -n "$reg_rows" ]] || reg_rows='[]' + # The window closes here, after every read above, so the `finalize` step + # carries their cost; only the jq below and the append fall outside it. + clock_into T_MP_E # shellcheck disable=SC2016 # a jq program: every $var is a jq variable jq_to block -c -n \ --arg name "$mp" \ @@ -1566,9 +1586,11 @@ emit_marketplace_block() { --arg t_mu_s "$T_MU_S" --arg t_mu_e "$T_MU_E" \ --arg t_ir_s "$T_IR_S" --arg t_ir_e "$T_IR_E" \ --arg t_us_s "$T_US_S" --arg t_us_e "$T_US_E" \ + --arg t_pi_s "$T_PI_S" --arg t_pi_e "$T_PI_E" \ --arg t_ie_s "$T_IE_S" --arg t_ie_e "$T_IE_E" \ --arg t_cc_s "$T_CC_S" --arg t_cc_e "$T_CC_E" \ --arg t_pr_s "$T_PR_S" --arg t_pr_e "$T_PR_E" \ + --arg t_fn_s "$T_FN_S" \ --arg t_mp_s "$T_MP_S" --arg t_mp_e "$T_MP_E" \ --arg resolution "$CLOCK_RESOLUTION" \ --argjson errors "$errs" ' @@ -1581,16 +1603,23 @@ emit_marketplace_block() { if $s == "" or $e == "" then null else ([0, ((ms($s; $e) | floor) / 1000)] | max) end; def dur_up($s; $e): if $s == "" or $e == "" then null else ([0, ((ms($s; $e) | ceil) / 1000)] | max) end; - $extras + {name: $name, - timings: {pre_refresh_read: dur($t_prr_s; $t_prr_e), - marketplace_update: dur($t_mu_s; $t_mu_e), - in_repo_update: dur($t_ir_s; $t_ir_e), - user_sweep: dur($t_us_s; $t_us_e), - install_enable: dur($t_ie_s; $t_ie_e), - cache_content_check: dur($t_cc_s; $t_cc_e), - post_read: dur($t_pr_s; $t_pr_e), - total: dur_up($t_mp_s; $t_mp_e), - resolution: $resolution}, + {pre_refresh_read: dur($t_prr_s; $t_prr_e), + marketplace_update: dur($t_mu_s; $t_mu_e), + in_repo_update: dur($t_ir_s; $t_ir_e), + user_sweep: dur($t_us_s; $t_us_e), + pre_install_read: dur($t_pi_s; $t_pi_e), + install_enable: dur($t_ie_s; $t_ie_e), + cache_content_check: dur($t_cc_s; $t_cc_e), + post_read: dur($t_pr_s; $t_pr_e), + finalize: dur($t_fn_s; $t_mp_e)} as $steps + | dur_up($t_mp_s; $t_mp_e) as $total + # The time between and around the named steps, so the steps plus this + # remainder account for the whole total. + | (if $total == null then null + else [0, ((($total * 1000) - ([$steps[] | select(. != null)] | add // 0) * 1000) | round) / 1000] + | max end) as $unattributed + | $extras + {name: $name, + timings: ($steps + {unattributed: $unattributed, total: $total, resolution: $resolution}), catalog_last_updated: (if $lastUpdated == "" then null else $lastUpdated end), source_checkout: $checkout, refresh: {rc: $refresh_rc, output: $refresh_out, reason: $refresh_reason, predicted: $refresh_predicted}, @@ -1741,8 +1770,15 @@ jq_to DIGEST -c -n \ {run_dir: $run_dir, cwd: $cwd, mode: $mode, allow_downgrade: $allow_downgrade, install_new: $install_new, install_new_invalid: (if $install_new_invalid == "" then null else $install_new_invalid end), - timings: {total: ([0, (((($t_run_e | tonumber) - ($t_run_s | tonumber)) * 1000 | ceil) / 1000)] | max), - resolution: $resolution}, + timings: (([0, (((($t_run_e | tonumber) - ($t_run_s | tonumber)) * 1000 | ceil) / 1000)] | max) as $total + | {total: $total, + # Run setup and the per-block assembly: the part of the total no + # block window covers. The clock stops before this jq runs, since a digest + # cannot time its own assembly without a second jq process. + outside_marketplaces: ([0, ((($total * 1000) + - ([$marketplaces[].timings.total | numbers] | add // 0) * 1000) + | round) / 1000] | max), + resolution: $resolution}), marketplaces: $marketplaces, errors: $errors}' printf '%s\n' "$DIGEST" >"$RUN_DIR/digest.json" diff --git a/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh b/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh index e909b246e1..8340ea45ff 100755 --- a/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh +++ b/plugins/harness-ops/skills/plugins/scripts/sync-run.test.sh @@ -96,6 +96,12 @@ case "$verb" in echo "✘ Failed to clone marketplace repository" exit 1 fi + # A refresh rewrites the marketplace's lastUpdated, as the real one does. + if [[ -n "${CLAUDE_STUB_REFRESH_STAMP:-}" ]]; then + jq --arg mp "$mp" --arg t "$CLAUDE_STUB_REFRESH_STAMP" '.[$mp].lastUpdated = $t' \ + "$FLEET_STATE_MARKETPLACES_JSON" >"$FLEET_STATE_MARKETPLACES_JSON.next" && + mv "$FLEET_STATE_MARKETPLACES_JSON.next" "$FLEET_STATE_MARKETPLACES_JSON" + fi echo "Successfully updated marketplace: $mp" ;; "plugin update") @@ -790,22 +796,34 @@ esac " chmod +x "$case_dir/stubs/date" } -# Every non-null timing is a non-negative number, and the marketplace total is at -# least the sum of its steps. +# Every non-null timing is a non-negative number, the marketplace total is at +# least the sum of its steps, and the steps plus the named remainder account for +# every second of it, at both levels. assert_timings_shape() { local label="$1" digest="$2" t t=$(jq -c '.marketplaces[0].timings' <<<"$digest") - assert_eq "$label: the timings object carries the eight step keys and resolution" \ - "cache_content_check,in_repo_update,install_enable,marketplace_update,post_read,pre_refresh_read,resolution,total,user_sweep" \ + assert_eq "$label: the timings object carries the nine step keys, the remainder and resolution" \ + "cache_content_check,finalize,in_repo_update,install_enable,marketplace_update,post_read,pre_install_read,pre_refresh_read,resolution,total,unattributed,user_sweep" \ "$(jq -r 'keys | join(",")' <<<"$t")" assert_eq "$label: every non-null timing is a non-negative number" "true" \ "$(jq -r 'del(.resolution) | [.[] | select(. != null)] | all(type == "number" and . >= 0)' <<<"$t")" assert_eq "$label: the total is at least the sum of the steps" "true" \ "$(jq -r '. as $t | [.pre_refresh_read, .marketplace_update, .in_repo_update, .user_sweep, - .install_enable, .cache_content_check, .post_read] | map(select(. != null)) | (add // 0) <= $t.total' <<<"$t")" - assert_eq "$label: the whole invocation is timed at top level with the same clock" "true" \ + .pre_install_read, .install_enable, .cache_content_check, .post_read, .finalize] + | map(select(. != null)) | (add // 0) <= $t.total' <<<"$t")" + assert_eq "$label: the steps plus unattributed sum to the marketplace total" "true" \ + "$(jq -r '. as $t | [.pre_refresh_read, .marketplace_update, .in_repo_update, .user_sweep, + .pre_install_read, .install_enable, .cache_content_check, .post_read, .finalize, .unattributed] + | map(select(. != null)) | (add // 0) - $t.total | . < 0.0015 and . > -0.0015' <<<"$t")" + assert_eq "$label: the post-read work is a named step" "number" "$(jq -r '.finalize | type' <<<"$t")" + assert_eq "$label: the pre-install read is a named step" "number" "$(jq -r '.pre_install_read | type' <<<"$t")" + assert_eq "$label: the run is timed at top level with the same clock" "true" \ "$(jq -r '(.timings.total | type == "number") and .timings.total >= .marketplaces[0].timings.total and .timings.resolution == .marketplaces[0].timings.resolution' <<<"$digest")" + assert_eq "$label: the marketplace totals plus outside_marketplaces sum to the run total" "true" \ + "$(jq -r '(0.0015 * ((.marketplaces | length) + 1)) as $tol + | ([.marketplaces[].timings.total] | add) + .timings.outside_marketplaces - .timings.total + | . < $tol and . > -$tol' <<<"$digest")" } CASE_NUM=$((CASE_NUM + 1)) case_dir=$(new_case_dir) @@ -819,8 +837,10 @@ write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": true}}' setup_case "$case_dir" EXTRA_ENV=(CLAUDE_STUB_NEW_VERSION=0.2.0) +wall_s=${EPOCHREALTIME:-0} out=$(run_sync "$case_dir" --marketplace market1 --install-new none --journal-root "$case_dir/journal") assert_exit "timings: exit 0" 0 $? +wall_e=${EPOCHREALTIME:-0} assert_timings_shape "timings" "$out" assert_contains "timings: the resolution names a rung of the ladder" \ " microseconds nanoseconds seconds " " $(jq -r '.marketplaces[0].timings.resolution' <<<"$out") " @@ -829,6 +849,12 @@ assert_eq "timings: the sync steps that ran are all timed" "number number number assert_eq "timings: an already-current install and enable set leaves install_enable null" "null" \ "$(jq -c '.marketplaces[0].timings.install_enable' <<<"$out")" if [[ -n "${EPOCHREALTIME:-}" ]]; then + # The run clock stops before the digest is assembled, so the total leaves out at + # least that jq, the report write and the print, which the wall clock around the + # whole invocation includes. + assert_eq "timings: the run total stops short of the whole invocation (it excludes the digest assembly)" "true" \ + "$(jq -rn --arg ws "$wall_s" --arg we "$wall_e" --argjson total "$(jq '.timings.total' <<<"$out")" \ + '$total < (($we | tonumber) - ($ws | tonumber))')" assert_eq "timings: a bash with EPOCHREALTIME reports microseconds" "microseconds" \ "$(jq -r '.marketplaces[0].timings.resolution' <<<"$out")" fi @@ -870,6 +896,38 @@ assert_eq "date rung: without EPOCHREALTIME the run reports the date rung the ho "$(jq -r '.marketplaces[0].timings.resolution' <<<"$out")" assert_timings_shape "date rung" "$out" +# The default-marketplace path: the resolving read runs in main, before the +# loop body, and it is that marketplace's pre_refresh_read. A wrapper slows only +# that zero-argument read, so a block window that opened after it would report +# a total below the sum of its own steps. +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +catalog_plugin "$case_dir" market1 alpha 0.2.0 +mkdir -p "$case_dir/self/.claude-plugin" +write "$case_dir/self/.claude-plugin/plugin.json" '{"name":"alpha","version":"0.1.0"}' +self_root=$(norm_path "$case_dir/self") +write "$case_dir/installed_plugins.json" "{ + \"version\": 1, + \"plugins\": {\"alpha@market1\": [{\"scope\": \"user\", \"installPath\": \"$self_root\", \"version\": \"0.1.0\"}]} +}" +write "$case_dir/known_marketplaces.json" "{\"market1\": {\"source\": {\"source\": \"github\", \"repo\": \"e/m\"}, \"installLocation\": \"$case_dir/mkt\", \"lastUpdated\": \"2026-01-01T00:00:00Z\"}}" +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "alpha"}]}' +write "$case_dir/user_settings.json" '{"enabledPlugins": {"alpha@market1": true}}' +setup_case "$case_dir" +write "$case_dir/stubs/slow-fleet-state.sh" "#!/usr/bin/env bash +if [[ \$# -eq 0 ]]; then sleep 1; fi +exec bash \"$SCRIPT_DIR/fleet-state.sh\" \"\$@\" +" +chmod +x "$case_dir/stubs/slow-fleet-state.sh" +EXTRA_ENV=(CLAUDE_PLUGIN_ROOT="$case_dir/self" SYNC_RUN_FLEET_STATE="$case_dir/stubs/slow-fleet-state.sh" + CLAUDE_STUB_NEW_VERSION=0.2.0) +out=$(run_sync "$case_dir" --install-new none --journal-root "$case_dir/journal") +assert_exit "default path: exit 0" 0 $? +assert_eq "default path: the default marketplace resolved" "market1" "$(jq -r '.marketplaces[0].name' <<<"$out")" +assert_eq "default path: the resolving read is timed as pre_refresh_read" "true" \ + "$(jq -r '.marketplaces[0].timings.pre_refresh_read >= 1' <<<"$out")" +assert_timings_shape "default path" "$out" + # ============================================================================ # Cases: the Step 6 report is rendered by the script. Golden files under # fixtures/render/ hold the whole fixed-section report for each shape; a case @@ -939,10 +997,10 @@ assert_eq "render: the renderer carries no user_config placeholder token" "0" \ "$(grep -c -F '${user_config' "$SCRIPT_DIR/render-report.jq")" # The Timing row's shape, asserted here because the golden elides its figures. out=$(run_sync "$case_dir" --marketplace market1 --install-new none --journal-root "$case_dir/journal" --render) -assert_contains "render: the marketplace Timing row names the slowest step and the clock" \ - "$(printf '%s\n' "$out" | grep -E '^Timing: [0-9.]+s this marketplace; slowest step [a-z_]+ [0-9.]+s \((microseconds|nanoseconds|seconds)\)$' | head -n 1)" "Timing: " -assert_contains "render: the run Timing row times the whole invocation" \ - "$(printf '%s\n' "$out" | grep -E '^Timing: [0-9.]+s whole invocation \((microseconds|nanoseconds|seconds)\)$')" "whole invocation" +assert_contains "render: the marketplace Timing row names the slowest step, the remainder and the clock" \ + "$(printf '%s\n' "$out" | grep -E '^Timing: [0-9.]+s this marketplace; slowest step [a-z_]+ [0-9.]+s; [0-9.]+s outside the named steps \((microseconds|nanoseconds|seconds)\)$' | head -n 1)" "Timing: " +assert_contains "render: the run Timing row names the run total and its remainder" \ + "$(printf '%s\n' "$out" | grep -E '^Timing: [0-9.]+s run total; [0-9.]+s outside the marketplace blocks \((microseconds|nanoseconds|seconds)\)$')" "run total" # --- a withheld downgrade, with the source named as the likely cause -------- CASE_NUM=$((CASE_NUM + 1)) @@ -1515,6 +1573,91 @@ assert_contains "delisted in-repo audit: the report names it as a prediction" "$ assert_eq "delisted in-repo audit: not a would-update" "0" \ "$(jq -r '[.marketplaces[0].in_repo.would_update[]?.id] | map(select(. == "stale@market1")) | length' <<<"$REPORT_DIGEST")" +# ============================================================================ +# Case: catalog_last_updated is the stamp after Step 1's refresh, on the first +# pass and on the `--only-install` re-entry +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +golden_fixture "$case_dir" 0.1.0 0.1.0 true +catalog_plugin "$case_dir" market1 beta 0.1.0 +write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha", "source": "alpha"}, {"name": "beta", "source": "beta"}]}' +EXTRA_ENV=(CLAUDE_STUB_NOOP_ID=alpha@market1 CC_STUB_CLEAN=1 CLAUDE_STUB_REFRESH_STAMP=2026-02-02T00:00:00Z) +out=$(run_sync "$case_dir" --marketplace market1 --install-new ask --journal-root "$case_dir/journal") +assert_exit "catalog stamp: exit 0" 0 $? +assert_eq "catalog stamp: the digest carries the stamp the refresh wrote" "2026-02-02T00:00:00Z" \ + "$(jq -r '.marketplaces[0].catalog_last_updated' <<<"$out")" +run_dir=$(jq -r '.run_dir' <<<"$out") +assert_eq "catalog stamp: the pre-refresh snapshot still holds the older stamp" "2026-01-01T00:00:00Z" \ + "$(jq -r '.marketplace.lastUpdated' "$run_dir/pre-refresh.market1.json")" +out=$(run_sync "$case_dir" --only-install beta@market1 --run-dir "$run_dir") +assert_exit "catalog stamp re-entry: exit 0" 0 $? +assert_eq "catalog stamp re-entry: the re-emitted digest keeps the post-refresh stamp" "2026-02-02T00:00:00Z" \ + "$(jq -r '.marketplaces[0].catalog_last_updated' <<<"$out")" + +# ============================================================================ +# Case: the cache-content check names the scope it covered, in the digest and +# in every rendered row +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +case_dir=$(new_case_dir) +golden_fixture "$case_dir" 0.1.0 0.1.0 true +EXTRA_ENV=(CLAUDE_STUB_NOOP_ID=alpha@market1) +out=$(run_sync "$case_dir" --marketplace market1 --install-new none --journal-root "$case_dir/journal") +assert_exit "cache scope: exit 0" 0 $? +assert_eq "cache scope: the checker is asked for user scope explicitly" "1" \ + "$(grep -c -- '--scope user' "$case_dir/cc.log")" +assert_eq "cache scope: the digest names the scope checked" "user" \ + "$(jq -r '.marketplaces[0].cache_content.scope' <<<"$out")" +assert_eq "cache scope: the always-zero project-path counter is not carried" "false" \ + "$(jq -r '.marketplaces[0].cache_content | has("skipped_absent_project_paths")' <<<"$out")" +cache_text=$(needs_digest sync '{"cache_content": {"scope": "user", "checked": 10, "match": 7, "stale_content": 0, + "unverifiable": 3, "stale": [], "stale_ids": []}}' | jq -r -f "$SCRIPT_DIR/render-report.jq") +assert_contains "cache scope: the unverifiable-only row names user scope" "$cache_text" \ + "Cache content: 3 of 10 user-scope install(s) unverifiable" +cache_text=$(needs_digest sync '{"cache_content": {"scope": "user", "checked": 10, "match": 7, "stale_content": 1, + "unverifiable": 2, "stale": [{"id": "a@m", "version": "1.0.0", "files_differ": 2}], "stale_ids": ["a@m"]}}' | + jq -r -f "$SCRIPT_DIR/render-report.jq") +assert_contains "cache scope: the stale row names user scope" "$cache_text" \ + "Cache content: 1 user-scope install(s) whose cache files disagree" +assert_contains "cache scope: the checked sub-line names user scope" "$cache_text" \ + "(checked 10 user-scope install(s): 7 match, 2 unverifiable" + +# ============================================================================ +# Case: the Divergences split follows the total it sums to, with and without a +# project root +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +split_divergences='{"post": 67, "pre_existing": 24, "new_total": 43, "new_by_interval": {"in_repo": 5, "user_sweep": 38}}' +div_text=$(needs_digest sync "{\"divergences\": $split_divergences}" | jq -r -f "$SCRIPT_DIR/render-report.jq") +assert_golden "divergence split, no project root: the report matches the golden" divergence-split-no-root.txt "$div_text" +div_text=$(needs_digest sync "{\"divergences\": $split_divergences, \"project_root\": \"/w/repo\", + \"in_repo_records\": 4, \"divergences_here\": 3}" | jq -r -f "$SCRIPT_DIR/render-report.jq") +assert_golden "divergence split, project root: the report matches the golden" divergence-split-project-root.txt "$div_text" +# The number each split follows is the sum of its parts, in both variants. +for variant in no-root project-root; do + assert_eq "divergence split, $variant: the split sums to the count it follows" "true" \ + "$(grep '^Divergences:' "$GOLDEN_DIR/divergence-split-$variant.txt" | + sed -E 's/.* ([0-9]+) actionable: ([0-9]+) pre-existing, ([0-9]+) newly created by this run \(([0-9]+) by the in-repo update, ([0-9]+) by the user-scope sweep\).*/\1 \2 \3 \4 \5/' | + awk '{print ($1 == $2 + $3 && $3 == $4 + $5) ? "true" : "false"}')" +done + +# ============================================================================ +# Case: in audit, `would run:` marks the calls sync would make; a remedy sync +# never runs itself reads as it does in sync, and the header says which is which +# ============================================================================ +CASE_NUM=$((CASE_NUM + 1)) +remedy_text=$(needs_digest audit '{"source_checkout": {"path": "/src", "state": "tracking", "branch": "main", + "upstream": "origin/main", "ahead": 0, "behind": 2, "dirty": false}, + "project_enable_rows": [{"id": "p@m", "project_path": "/w/repo"}]}' | jq -r -f "$SCRIPT_DIR/render-report.jq") +assert_eq "audit remedies: the header scopes the prefix to calls sync would make" \ + "Audit (read-only): every call sync would make is a prediction, prefixed would run:" \ + "$(head -n 1 <<<"$remedy_text")" +assert_contains "audit remedies: the git pull remedy is unprefixed" "$remedy_text" \ + "and the catalog is read from it as it is: run \`git -C '/src' pull --ff-only\`" +assert_contains "audit remedies: the project-scope enable remedy is unprefixed" "$remedy_text" \ + " - project-scope enable gap: (cd \"/w/repo\" && claude plugin enable p@m -s project)" + # ============================================================================ if ((FAILED > 0)); then printf '\n%d test(s) failed\n' "$FAILED" >&2