Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/conventions/loop-lane/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ topology, the escalation contract, the capability-tier vocabulary, or any loop-l
major bump, and additive guidance is a minor bump. A new model release re-audits the capability-tier
table (§3); drift found by that audit is recorded here.

## 7.0.0 — 2026-07-30

Repartitions §4's telemetry binding from the lane **type** to the lane **instance**, resolving
[melodic-software/claude-code-plugins#1295](https://github.com/melodic-software/claude-code-plugins/issues/1295).
Tier ratified as **major** on 2.0.0's discriminator: it rewrites a loop-layer invariant every lane
body implements, and adds two more (instance identity, collision detection).

- **The telemetry marker now names the writer, not the lane (§4).** The marker was a fixed constant
per lane, so two instances of one lane on one repository resolved the same sentinel and overwrote
each other's durable state under last-writer-wins. The serious loss was `first_drain_complete`:
one machine finishing a drain ended the earn-trust C3 ratification gate for every other machine,
widening autonomy with no human ratification — a safety property failing open. The marker gains a
lane-instance suffix (`<lane-marker>@<lane_instance>`), the lane-type marker becoming its prefix,
and "exactly one comment" is restated as **one comment per writer identity**: N concurrent
instances legitimately hold N comments on one telemetry item.
- **Lane-instance identity (§4, new invariant).** Resolved from launch config, defaulting to the
sanitized lowercased hostname; stable across restarts, distinct across concurrent instances,
charset-validated `^[a-z0-9][a-z0-9-]{0,31}$` inside each lane's own executable block rather than
in prose alone, since the value is operator-supplied text interpolated into a shell string and a
`jq` program.
- **Instance-collision detection (§4, new invariant).** The state block gains `lane_instance`,
`writer_nonce`, `heartbeat_at`, and `paused_until`. A differing nonce over a stale block is the
ordinary restart path (adopt and continue); a differing nonce over a *fresh* block means another
live lane holds this id — write nothing, escalate per §2, stop cleanly. The staleness window is
two hours, twice the one-hour `ScheduleWakeup` ceiling, so maximum idle backoff can never read as
death. Detection runs before any write, so a collision degrades to a stopped lane rather than a
clobbered `first_drain_complete`.
- **The `Lane telemetry: <lane>` title contract is deliberately untouched.** The drain-exit
snapshot, the intake sweep, and the attention view all match lane infrastructure by that title;
the marker was chosen as the partition seam precisely so no title-matching consumer moves.
- **Migration is a deliberate reset.** No pre-existing comment matches an instance's new sentinel —
neither the legacy un-suffixed `marker=<lane>` comments nor the improvised
`<!-- work-items:telemetry lane=… instance=… -->` comments some lanes began posting in practice —
so the first cycle after adoption posts a fresh block from defaults, including
`first_drain_complete:false`. That fails closed and is intended; it produces one burst of
ratification queue comments on the next drain. The legacy comment is never adopted, edited, or
tombstoned by a lane — its marker names no writer, so no instance can prove it owns it, and a lane
that adopted it would reintroduce the shared-comment clobber this change removes. Retiring it is
an operator action; until then it reads as stale, which is honest, because nothing is writing it.

## 6.0.1 — 2026-07-29

Corrective, no topology, escalation, tier, or invariant change — 6.0.0's usage-sample invariant is
Expand Down
80 changes: 75 additions & 5 deletions docs/conventions/loop-lane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,19 +418,89 @@ lane telemetry, and the operator owns the restart (`lanes` `restart` is the oper
path). The restart-request in the #502 block is written so that the operator today, and an
automatic trigger when one exists, can act on the same surface.

**Telemetry comment (#502).** Each lane maintains exactly **one** status comment on a tracking item,
identified by a machine sentinel marker and **edited in place** every cycle — never a second comment.
**Telemetry comment (#502).** Each lane **instance** maintains exactly **one** status comment on a
tracking item, identified by a machine sentinel marker and **edited in place** every cycle — never a
second comment for that instance. The unit is the writer identity, not the lane type: N concurrent
instances of one lane legitimately hold N sentinel-identified comments on that lane's telemetry
item, one each, and no instance ever edits another's.
`claude-ops`'s `telemetry-upsert.sh` is the interim home of this contract and a compatible reader
(`morning-brief` reads the same surface); an installed plugin cannot invoke a sibling plugin's
script, so each lane **inlines** the small `gh api` upsert and the coupling to `claude-ops` stays
one-directional. An inlined upsert carries none of the wrapper's body checks, so it is bound by the
`@path`-as-body rule in [`claude-ops` lanes](../../../plugins/claude-ops/skills/lanes/SKILL.md),
section "Never pass a body as an `@path` string".

**Lane-instance identity (#1295).** The marker names the **writer**, not the lane type. A marker
that names only the lane makes two concurrent instances resolve one comment and clobber each other's
durable state under last-writer-wins — including `first_drain_complete`, whose loss silently ends
one instance's earn-trust ratification period because a different machine finished a drain. The
marker therefore carries a lane-instance suffix, the lane-type marker becoming its prefix:

```text
MARKER="<lane-marker>@<lane_instance>"
```

`<lane_instance>` is resolved from launch config, defaulting to the sanitized lowercased machine
hostname when unset (headless-config floor: never block on an interview, log the assumption). It
must be **stable across restarts** — durable state is precisely what survives a `/loop` expiry or a
cycle-budget relaunch — and **distinct across concurrently running instances**, so two lanes on one
machine must each be given an explicit id. It is operator-supplied text interpolated into a shell
string and a `jq` program, so every lane **validates it before use** — `^[a-z0-9][a-z0-9-]{0,31}$`,
rejected outright, never sanitized-and-continued — and the validation lives in the lane's own
executable block, not only in this prose. The value appears verbatim in tracker comments; an
operator who does not want a machine name published in a public tracker sets an opaque id.

The instance is reported on its **own `instance:` line** in the cycle report, never appended to the
`lane:` line: `morning-brief`'s lane capture is `[a-z0-9_-]+`, which would silently truncate a
suffix at the `@` and report the lane as if nothing were partitioned. Rendering one row per instance
is that reader's own follow-up; emitting the field is this contract's obligation.

Only the *instance* is new. The other two components of the (repo, lane, instance) identity already
hold by construction: the comment lives on one issue in one repository, and the telemetry item is
per-lane. The **issue title is not touched** — the `Lane telemetry: <lane>` title contract that the
drain-exit snapshot, the intake sweep, and the attention view all match on is the reason the marker
was chosen as the seam rather than the title.

**Instance-collision detection.** Partitioning is correct only while ids are distinct, so a
collision is detected rather than assumed away. This binds every lane that carries a durable-state
block; the attended queue, which carries none, is bound by the marker partition alone — its operator
is present by definition, so an id collision there surfaces to a human in the same pass. The durable
state block carries `lane_instance`, a
per-session random `writer_nonce`, an ISO-8601 UTC `heartbeat_at` rewritten every cycle, and
`paused_until`. At cycle start, after reading its own block:

- `writer_nonce` matches mine → ordinary continuation.
- `writer_nonce` differs **and** the block is stale (`heartbeat_at` older than **2 hours**, and past
`paused_until` when set) → a previous session of this same instance restarted or died. Adopt the
block, write my nonce, continue. This is the ordinary restart path. Two hours is twice the
one-hour `ScheduleWakeup` ceiling above, so a healthy lane at maximum idle backoff can never look
stale.
- `writer_nonce` differs **and** the block is fresh → **another live lane is using my instance id.**
Write nothing to the block, escalate per §2 (role label + machine-marked comment), and stop the
loop cleanly.

`paused_until` is not the rate-limit latch and does not replace it: the latch says *do not claim
work*, `paused_until` says *do not read my silence as death*. A lane entering a rate-limit pause
writes it before pausing, so a paused lane is never adopted as a dead one. Detection runs before any
write, so an id collision degrades to a stopped lane rather than a silently clobbered
`first_drain_complete`.

**Adopting the partition (one-time).** No pre-existing comment matches an instance's new sentinel,
so the first cycle after adoption posts a fresh block from defaults — including
`first_drain_complete:false` for every lane. That is intended and fails closed; it produces one
burst of ratification queue comments on the next drain and is not a regression. The legacy
un-suffixed comment is left in place and **never adopted, edited, or tombstoned by a lane**: its
marker names no writer, so no instance can prove it owns it, and a lane that adopted it would
reintroduce exactly the shared-comment clobber this rule removes. Retiring it is an operator action.
Until then it remains readable, and stale: `morning-brief` will show it aging past the staleness
threshold, which is the honest reading — nothing is writing it.

**Durable loop state.** Conversation context is lossy across compaction, so a lane persists its
adaptive-cap streak counter, its rate-limit-warning latch, its consecutive-no-progress counter, and
its cycle count in a machine-readable block of that same #502 telemetry comment, and re-reads them
at each cycle start.
adaptive-cap streak counter, its rate-limit-warning latch, its consecutive-no-progress counter, its
cycle count, and its instance-identity fields in a machine-readable block of that same #502
telemetry comment, and re-reads them at each cycle start. Every counter in the block is
**per-instance** — each measures the experience of one lane instance, which averaging two instances'
experience into one block never did.

**No-progress detector.** Every stall mechanism below the loop layer is per-PR or per-item, so a
lane cycling repeatedly while accomplishing nothing in aggregate is invisible to itself: each gate
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-ops/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-ops",
"version": "0.24.4",
"version": "0.25.0",
"description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — 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 — 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 — 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 — 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 seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.",
"author": {
"name": "Melodic Software",
Expand Down
33 changes: 33 additions & 0 deletions plugins/claude-ops/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@
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.25.0]

### Changed

- **`telemetry-upsert.sh` accepts the writer-identity marker suffix (#1295).** The marker charset
gains `@`, so a marker can name one *writer* (`<lane>@<instance>`) rather than a lane type — the
loop-lane convention's fix for concurrent instances of one lane sharing, and clobbering, a single
telemetry comment. This script is that convention's interim home, so a marker shape its validator
rejected would have left the contract and its executable owner disagreeing. `@` is added to
**both** lookaround classes in the two-tier detection's fallback as well, for exactly the reason
`-` is already in them: without it, `lane:x` matches inside `lane:x@laptop-a` and would adopt that
instance's comment — the boundary rule one level down from the `lane:triage` /
`lane:triage-old` prefix collision it already guards. Two cases cover the new boundary in both
directions, plus one asserting a suffixed marker validates at all.

### Fixed

- **`restart-consumer.sh` would have gone silently blind on suffixed markers.** Its per-lane
`telemetry.marker` binding matched a comment by exact marker equality, so once lanes carry
`<marker>@<instance>` no bound lane's comment would match — the consumer would report `no-state`
forever and restart nothing, the worst failure shape for an unattended relaunch trigger. A bound
marker now names a lane **type** and matches every writer instance of it, with the same trailing
boundary that keeps `work-items:work-loop` from adopting `work-items:work-loop-v2`. A new optional
`telemetry.instance` key pins one instance, as does writing the suffix into `marker` itself. The
scan also no longer stops at the first matching comment when that comment is not asking: with
several instances writing to one issue, a quiet sibling appearing first would otherwise mask a
later instance's live restart request. What an unpinned binding does with a suffixed writer's
request is *report* it: the run records `unbound-instance` naming the asking writer and
relaunches nothing, because an instance-suffixed comment is some machine's writer and consuming
it unpinned would relaunch the locally configured lane on **every** stopped consumer sharing the
issue — sibling instances started by a request none of them owns. Only the pinned instance's
comment, or the legacy un-suffixed one, is actionable.

## [0.24.4]

### Fixed
Expand Down
9 changes: 7 additions & 2 deletions plugins/claude-ops/skills/lanes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,13 @@ each — the summary below is a pointer, not a copy.
that sentinel across ALL comments (paginated — a match on any page prevents a
duplicate) and PATCHes it; failing that (first migration off a hand-authored
comment) it adopts the most recent comment BY THE AUTHENTICATED USER carrying the
raw marker text; else it creates one. `STR` is `[A-Za-z0-9:._-]+` (so it can
never close the HTML comment early), and one writer identity owns a given marker.
raw marker text; else it creates one. `STR` is `[A-Za-z0-9:@._-]+` (so it can
never close the HTML comment early), and one writer identity owns a given marker
— which is what the loop-lane convention's `<lane>@<instance>` suffix makes true
rather than aspirational (#1295): a marker naming only a lane type is shared by
every concurrent instance of that lane, so they clobber one another's durable
state. Both fallback boundaries treat `@` as a marker char, so `lane:x` never
adopts `lane:x@laptop-a`'s comment, nor `lane:x@a` adopt `lane:x@a@b`'s.
Body input: prefer `--body-file -` (stdin) for a body generated in memory (e.g.
piped from `machine-behavior.sh`); a real `--body-file PATH` must resolve under
`--body-dir` (default `$CLAUDE_PLUGIN_DATA`), may not be a symlink, and is capped
Expand Down
17 changes: 12 additions & 5 deletions plugins/claude-ops/skills/lanes/context/restart-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,15 @@ parity claim.

## Telemetry binding per lane

Optional `lanes[].telemetry` config keys (`issue`, `marker`, `repo`) bind a lane
to its telemetry comment; every one has a working default (issue resolved by the
exact `Lane telemetry: <lane>` title, marker matched by the shared sentinel plus
a `restart_request`-bearing state block, repo defaulting to the consumer's
`--target-repo`). Full semantics: the script's `--help` header.
Optional `lanes[].telemetry` config keys (`issue`, `marker`, `instance`, `repo`)
bind a lane to its telemetry comment; every one has a working default (issue
resolved by the exact `Lane telemetry: <lane>` title, marker matched by the shared
sentinel plus a `restart_request`-bearing state block, instance unpinned, repo
defaulting to the consumer's `--target-repo`). A bound `marker` names a lane
**type** and matches every writer instance of it, since a live comment's marker
carries the loop-lane convention's `@<instance>` writer suffix; pin one instance
with `instance`, or by writing the suffix into `marker` itself. Unpinned, a
suffixed writer's request is observed but never consumed — it reports as
`unbound-instance` and relaunches nothing, since a sibling machine's ask must
not start this machine's lane; only the legacy un-suffixed comment is
actionable without a pin. Full semantics: the script's `--help` header.
Loading