Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `/harness-ops:plugins` no longer reports a no-op update as a success. `fleet-state.sh` marks each project or local record `linkedWorktree` and labels a lagging one in a linked git worktree `update ineffective; uninstall clears it`, keeping it in the divergence count. `sync` reports an "already at the latest version" update whose project record is still below the catalog as a failed row. `converge` offers linked-worktree rows only the uninstall strategy and counts an update whose re-read record did not move as a failure.
32 changes: 27 additions & 5 deletions plugins/harness-ops/skills/plugins/context/converge.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,34 @@ running the bare form for a row whose `projectPath` isn't the current directory
mutate, or fail against, the wrong repo's settings. `--keep-data` applies to `uninstall` only:
`update` has no such flag, and the uninstall half is a pin removal, never a data deletion.

Two `git worktree` checkouts of one repository pin independently, verified on Claude Code 2.1.228
by uninstalling one id in a repo's main checkout and observing the worktree's record for the same id
survive untouched. They share one `.git` and one tracked `.claude/settings.json` yet hold separate
Two `git worktree` checkouts of one repository pin independently for `uninstall`, verified on Claude
Code 2.1.228 by uninstalling one id in a repo's main checkout and observing the worktree's record for
the same id survive untouched. They do not behave independently for `update` (see the linked-worktree
section below and [scope-semantics.md](scope-semantics.md)). They share one `.git` and one tracked `.claude/settings.json` yet hold separate
`projectPath` records, so never collapse them into one row and never assume converging one clears
the other: each needs its own `cd`. Per [scope-semantics.md](scope-semantics.md), the CLI keys
`projectPath` on the literal cwd while `fleet-state.sh` matches on the checkout root. That gap is a
blind spot in its own right, recorded in [gotchas.md](gotchas.md).

### Linked-worktree rows: no update strategy

A row whose `scopes[].linkedWorktree` is `true` (the git dir differs from the git common dir;
`fleet-state.sh` reads it from the worktree's `.git` file) never gets the update strategy, proposed or
run. The record behind that decision, and the trigger to recheck it, are in
[scope-semantics.md](scope-semantics.md). Emit it as:

```text
- <id>@<marketplace>: update is not offered here: "<projectPath>" is a linked worktree, and
its record is at <version>. The uninstall strategy clears it, and edits that worktree's
tracked `.claude/settings.json` (Step 5).
```

The row keeps the label `fleet-state.sh` gave it, `update ineffective; uninstall clears it`, and stays
in the divergence count. Offer only the uninstall strategy for it, still confirmed per pin; when the
highest version is at `project`/`local` scope and no uninstall would bring the row current, report the
row with this label and offer nothing. A main checkout, and a submodule (its `.git` file points into
`modules/`, not `worktrees/`), behave as before.

### Precondition: never emit a `cd` command into a path that is not present

Before emitting any `(cd "<projectPath>" && …)` command, check that row's `projectPathPresent` (see
Expand Down Expand Up @@ -153,8 +173,10 @@ update a sibling worktree's record of the same id instead of the cwd's
The intended record is still at <version>.
```

A record that did not move and no other record that did is reported as "did not move", with the CLI's
output.
A record that did not move and no other record that did is a **failure**, never "ok": report it as
"did not move", with the CLI's output, and count it with the failed rows. This holds whatever the CLI
printed, including "already at the latest version" and `updated from X to Y`, because the exit status
and the CLI's line are not evidence that the record changed. The re-read is the only evidence.

## Step 5: Surface the resulting diff

Expand Down
11 changes: 11 additions & 0 deletions plugins/harness-ops/skills/plugins/context/scope-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ checkout that has a linked worktree holding a record of the same id, showing `up
updating the record keyed to the literal cwd. On a confirmed fix, drop the read-back in `converge.md`
Step 4 and `sync.md` Step 2 and the `currentProject` filter in `sync-run.sh`.

**A lagging record in a linked worktree is labeled, counted, and cleared by `uninstall`, never
trusted to `update`.** `fleet-state.sh` marks each project/local record `linkedWorktree` and labels a
lagging one `update ineffective; uninstall clears it`; the row stays a divergence, and any update the
run does make is verified by re-reading the record ([converge.md](converge.md) Step 4). `uninstall`
clears it and edits that worktree's tracked settings file ([converge.md](converge.md) Step 5).
**Record**: pointer, the update-strategy results of the 2026-10-12 converge run in
[#6713 (comment)](https://github.com/melodic-software/claude-code-plugins/issues/6713#issuecomment-6125445322);
as-of 2026-10-12. **Recheck trigger**: a Claude Code release note that changes how `update -s project`
treats a record keyed to a linked worktree, or a re-run of that probe (the converge update strategy
against lagging records in linked worktrees, then a re-read) showing the records move.

When reading a record out of a `fleet-state.sh` report, a `project`/`local` record belongs to the run
only when its `currentProject` flag is `true`; matching on id and scope alone returns whichever
sibling's record comes first.
Expand Down
8 changes: 6 additions & 2 deletions plugins/harness-ops/skills/plugins/context/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,8 +545,12 @@ unlike `converge`.
the record flagged `currentProject`, and after the post read it compares the run's own record with
the CLI's `updated from X to Y` line: when that line names the version `Y` and the record is still at
`X`, the id is reported as a failed row naming the record that moved, never as an Updated row. A line
that names no version leaves the row Updated with direction `unknown`. This check covers this
step only; an `--allow-downgrade` project/local rollback is not compared.
that names no version leaves the row Updated with direction `unknown`. The same re-read catches the
opposite report: when the CLI says "already at the latest version" and the run's own project/local
record is still below the catalog version, the id is a failed row too, never a quiet no-op. For a record
in a linked git worktree ([scope-semantics.md](scope-semantics.md)), the row's reason carries the label
`update ineffective; uninstall clears it` that `fleet-state.sh` puts on the record. This check covers
this step only; an `--allow-downgrade` project/local rollback is not compared.

## Step 3: User-scope update sweep

Expand Down
79 changes: 77 additions & 2 deletions plugins/harness-ops/skills/plugins/scripts/fleet-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@
# attached. null means not applicable (a user-scope record, or no
# projectPath).
#
# Each of those records also carries linkedWorktree: true|false|null — whether
# its projectPath is a linked git worktree (the git dir differs from the git
# common dir: `.git` is a file whose gitdir line points into a `worktrees/`
# directory that holds a `commondir` file). null means not applicable, or the path is not present so nothing
# could be read. `label` is "update ineffective; uninstall clears it" on a
# linked-worktree record whose version is below the catalog's (else below the
# highest any record of the id holds), and null otherwise. In a linked worktree
# `claude plugin update -s project` reports success and leaves the record
# unchanged (#6713), so the record is labeled, never dropped: it stays in
# installed[] and in divergences[], and versionsMatch is unchanged.
#
# Output (stdout) with --ids <selector>: NOT JSON — one record per line, in the
# order the block carries them, and nothing else. Fields are TAB-separated and
# the first field is always the fully-qualified `<name>@<marketplace>` id, so
Expand Down Expand Up @@ -995,7 +1006,37 @@ for pp in ${pp_lines[@]+"${pp_lines[@]}"}; do
[[ -n "$pp" ]] || continue
pp_fs="${pp//\\//}"
if [[ -d "$pp_fs" ]]; then
presence_tsv+="$pp"$'\t'"true"$'\n'
# A third column says whether the directory is a LINKED git worktree: its
# `.git` is a file whose `gitdir:` line points into `<common>/worktrees/<name>`
# (the git dir differs from the git common dir). A submodule's `.git` file is
# also a file but points into `modules/`, so the pattern anchors on a
# `/worktrees/` segment and excludes a later `/modules/` segment (a
# submodule inside a linked worktree). Builtins only, no process per path.
# The common dir's name is not constrained (a bare layout uses `.bare`).
# A `/worktrees/` segment alone also matches a separate-git-dir main checkout
# kept under a `worktrees/` parent, so the git dir must hold the `commondir`
# file git writes only into a linked worktree's admin directory.
pp_linked="false"
if [[ -f "$pp_fs/.git" ]]; then
pp_gitfile=""
IFS= read -r pp_gitfile <"$pp_fs/.git" || true
pp_gitfile="${pp_gitfile//$'\r'/}"
pp_gitfile="${pp_gitfile//\\//}"
case "$pp_gitfile" in
gitdir:*/worktrees/*/modules/*) ;;
gitdir:*/worktrees/*)
pp_gd="${pp_gitfile#gitdir:}"
pp_gd="${pp_gd# }"
case "$pp_gd" in
/* | ?:/*) ;;
*) pp_gd="$pp_fs/$pp_gd" ;;
esac
[[ -f "$pp_gd/commondir" ]] && pp_linked="true"
;;
*) ;;
esac
fi
presence_tsv+="$pp"$'\t'"true"$'\t'"$pp_linked"$'\n'
else
presence_tsv+="$pp"$'\t'"false"$'\n'
fi
Expand Down Expand Up @@ -1032,12 +1073,23 @@ PASS2_PROGRAM='

# shellcheck disable=SC2016 # a jq program: every $var is a jq variable
PASS3_PROGRAM='
def triple:
if type == "string" then
((capture("^v?(?<x>[0-9]+)[.](?<y>[0-9]+)[.](?<z>[0-9]+)") // null)
| if . == null then null else [.x, .y, .z] | map(tonumber) end)
else null end;
(. | split("\u001d")) as $parts
| ($parts[0] | fromjson) as $ctx
| ($parts[1] | split("\n")
| map(select(length > 0) | split("\t") | select(length >= 2 and (.[0] | length) > 0)
| {key: .[0], value: (.[1] == "true")})
| from_entries) as $pres
# Third column: whether a PRESENT path is a linked git worktree. Absent from a
# path that is not present, so the lookup below answers null, never false.
| ($parts[1] | split("\n")
| map(select(length > 0) | split("\t") | select(length >= 3 and (.[0] | length) > 0))
| map({key: .[0], value: (.[2] == "true")})
| from_entries) as $wt
# Entries whose source is not a string never reached the payload, so they
# are absent from this map entirely — a lookup returns null, the same
# fail-open answer as an entry whose manifest could not be read. A manifest
Expand Down Expand Up @@ -1080,8 +1132,31 @@ PASS3_PROGRAM='
.projectPath as $pp
| (if ($pres | has($pp)) then $pres[$pp] else null end)
else null end
),
linkedWorktree: (
if (.scope == "project" or .scope == "local") and (.projectPath // "" | length) > 0 then
.projectPath as $pp
| (if ($wt | has($pp)) then $wt[$pp] else null end)
else null end
)
})) as $installed
# A lagging project/local record in a linked worktree: `claude plugin update`
# reports success there and leaves the record unchanged (#6713), so the record
# is labeled instead of dropped. It stays in installed[] and in divergences[],
# and the label never changes versionsMatch. "Lagging" is measured against the
# catalog version when it parses, else the highest version any record of the id
# holds; a version that does not parse is never labeled.
| ($installed | group_by(.id)
| map({key: .[0].id, value: ([.[].version | triple | select(. != null)] | max)})
| from_entries) as $highest
| ($installed | map(
. as $r
| (($cv[$r.id] | triple) // $highest[$r.id]) as $ref
| $r + {label: (
if $r.linkedWorktree == true and ($r.version | triple) != null
and $ref != null and ($r.version | triple) < $ref
then "update ineffective; uninstall clears it" else null end)}
)) as $installed
| ([$catalog[0].plugins[]?.name // empty] | unique) as $catalog_names
| ($catalog_names | map(. + "@" + $name)) as $catalog_ids
| ([$installed[].id] | unique) as $installed_ids
Expand Down Expand Up @@ -1137,7 +1212,7 @@ PASS3_PROGRAM='
| map(select(length > 1))
| map({
id: .[0].id,
scopes: map({scope, version, projectPath, projectPathPresent}),
scopes: map({scope, version, projectPath, projectPathPresent, linkedWorktree, label}),
versionsMatch: ((map(.version) | unique | length) == 1)
})) as $divergences
| {
Expand Down
85 changes: 85 additions & 0 deletions plugins/harness-ops/skills/plugins/scripts/fleet-state.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,91 @@ assert_eq "projectPathPresent: an absent path never suppresses the divergence ro
assert_eq "projectPathPresent: and the row is still graded on version skew alone" \
"false" "$(jq -r '.divergences[0].versionsMatch' <<<"$out" 2>/dev/null)"

# ============================================================================
# linkedWorktree / label — a lagging record in a linked worktree is labeled,
# stays counted, and a main checkout or a submodule is never mislabeled (#6713).
# ============================================================================

CASE_NUM=$((CASE_NUM + 1))
case_dir=$(new_case_dir)
main_dir="$case_dir/main-repo"
wt_dir="$case_dir/linked-wt"
sub_dir="$case_dir/submodule"
bare_wt_dir="$case_dir/bare-repo/main"
sep_dir="$case_dir/sep-checkout"
mkdir -p "$main_dir/.git/worktrees/linked-wt" "$wt_dir" "$sub_dir" \
"$case_dir/bare-repo/.bare/worktrees/main" "$bare_wt_dir" \
"$case_dir/worktrees/sep.git" "$sep_dir"
printf '../..\n' >"$main_dir/.git/worktrees/linked-wt/commondir"
printf '../..\n' >"$case_dir/bare-repo/.bare/worktrees/main/commondir"
printf 'gitdir: %s/main-repo/.git/worktrees/linked-wt\n' "$case_dir" >"$wt_dir/.git"
printf 'gitdir: ../.git/modules/submodule\n' >"$sub_dir/.git"
printf 'gitdir: %s/bare-repo/.bare/worktrees/main\n' "$case_dir" >"$bare_wt_dir/.git"
printf 'gitdir: %s/worktrees/sep.git\n' "$case_dir" >"$sep_dir/.git"
write "$case_dir/installed_plugins.json" "$(
jq -cn --arg m "$main_dir" --arg w "$wt_dir" --arg s "$sub_dir" --arg bw "$bare_wt_dir" --arg sd "$sep_dir" '{
version: 1,
plugins: {
"alpha@market1": [
{scope: "project", projectPath: $w, installPath: "a", version: "0.1.0"},
{scope: "user", installPath: "u", version: "0.2.0"}
],
"beta@market1": [
{scope: "project", projectPath: $m, installPath: "b", version: "0.1.0"},
{scope: "user", installPath: "u2", version: "0.2.0"}
],
"gamma@market1": [
{scope: "project", projectPath: $s, installPath: "c", version: "0.1.0"},
{scope: "user", installPath: "u3", version: "0.2.0"}
],
"delta@market1": [
{scope: "project", projectPath: $w, installPath: "d", version: "0.2.0"},
{scope: "user", installPath: "u4", version: "0.2.0"}
],
"epsilon@market1": [
{scope: "project", projectPath: $bw, installPath: "e", version: "0.2.0"},
{scope: "user", installPath: "u5", version: "0.2.0"}
],
"zeta@market1": [
{scope: "project", projectPath: $sd, installPath: "f", version: "0.2.0"},
{scope: "user", installPath: "u6", version: "0.2.0"}
]
}
}'
)"
write "$case_dir/known_marketplaces.json" '{"market1": {"source": {"source": "github", "repo": "example/market1"}, "installLocation": "z", "lastUpdated": "2026-01-01T00:00:00Z"}}'
write "$case_dir/catalog/market1.json" '{"plugins": [{"name": "alpha"}, {"name": "beta"}, {"name": "gamma"}, {"name": "delta"}, {"name": "epsilon"}, {"name": "zeta"}]}'
write "$case_dir/user_settings.json" '{"enabledPlugins":{}}'
ARGS=(--marketplace market1)
out=$(run_state "$case_dir")
assert_eq "linkedWorktree: true for a worktree whose .git file points into worktrees/" \
"true" "$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "linkedWorktree: false for a main checkout (.git is a directory)" \
"false" "$(jq -r '.installed[] | select(.id == "beta@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "linkedWorktree: false for a submodule (.git file points into modules/)" \
"false" "$(jq -r '.installed[] | select(.id == "gamma@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "linkedWorktree: true for a bare layout (gitdir points into .bare/worktrees/)" \
"true" "$(jq -r '.installed[] | select(.id == "epsilon@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "linkedWorktree: false for a separate-git-dir main checkout under a worktrees/ parent" \
"false" "$(jq -r '.installed[] | select(.id == "zeta@market1" and .scope == "project") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "linkedWorktree: null for a user-scope record" \
"null" "$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "user") | .linkedWorktree' <<<"$out" 2>/dev/null)"
assert_eq "label: a lagging linked-worktree record is labeled" \
"update ineffective; uninstall clears it" \
"$(jq -r '.installed[] | select(.id == "alpha@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)"
assert_eq "label: the same label rides divergences[].scopes[]" \
"update ineffective; uninstall clears it" \
"$(jq -r '.divergences[] | select(.id == "alpha@market1") | .scopes[] | select(.scope == "project") | .label' <<<"$out" 2>/dev/null)"
assert_eq "label: a lagging record in a main checkout is not labeled" \
"null" "$(jq -r '.installed[] | select(.id == "beta@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)"
assert_eq "label: a lagging record in a submodule is not labeled" \
"null" "$(jq -r '.installed[] | select(.id == "gamma@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)"
assert_eq "label: a linked-worktree record already at the highest version is not labeled" \
"null" "$(jq -r '.installed[] | select(.id == "delta@market1" and .scope == "project") | .label' <<<"$out" 2>/dev/null)"
assert_eq "label: the labeled record stays in the actionable divergence count" \
"alpha@market1 beta@market1 gamma@market1" \
"$(jq -r '[.divergences[] | select(.versionsMatch == false) | .id] | sort | join(" ")' <<<"$out" 2>/dev/null)"

# ============================================================================
# user_scope_orphans — the class every other field is structurally blind to.
# ============================================================================
Expand Down
Loading
Loading