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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Resolve the tracker-seam engine plugin-canonical and adapters consumer-first

- Status: accepted
- Date: 2026-08-17

## Context

The work-item tracker seam (`plugins/work-items/tools/work-item-tracker/`) ships bundled with
the `work-items` plugin and also runs from a consumer-vendored copy. Two kinds of code resolve
at call time: the engine (dispatcher, `lib/`, the contract itself) and the per-provider
adapters. `CONTRACT.md` ("Adapter resolution") documents the mechanics and cited an ADR for
the rationale, but the ADR was never written — the citation dangled at a number (`0022`) this
repository's sequence had not reached (seam-scrutiny finding F3.5, #2942). This ADR records
the rationale in-tree, at the next number in sequence.

## Decision

The seam resolves its two code surfaces in deliberately opposite directions, and the
directions are locked:

- **Engine — plugin-dir canonical, project-root fallback.** Callers resolve
`work-item-tracker.sh` from `${CLAUDE_PLUGIN_ROOT}/tools/work-item-tracker/` when that
exists, else `${CLAUDE_PROJECT_DIR}/tools/work-item-tracker/`. Engine fixes flow to every
consumer through plugin updates with no re-vendoring, and the security-sensitive shell —
credential-egress guards, the claim-race protocol, reclaim revalidation — stays under one
expert owner. The fallback keeps a vendored copy runnable when the plugin is absent.
- **Adapters — consumer-local-first, plugin-bundled fallback; first match wins.** A consuming
repo can add a provider the plugin does not ship, or shadow a bundled adapter with a local
copy it owns fully, without forking the plugin. The accepted cost of this direction is
version skew between a consumer's adapter and the plugin's engine; the mitigation is the
manifest contract-version handshake (`CONTRACT.md` "Contract-version handshake"), not a ban
on shadowing.

Reversing either direction silently changes which code executes for every consumer: an
engine resolved consumer-first would pin consumers to stale vendored copies of the
security-sensitive shell, and adapters resolved plugin-first would make local providers and
shadows unreachable — the two extension points the seam exists to offer. Either reversal is a
breaking change to the seam contract, never a refactor.

## Conformance note

The conformance suite runs the same abstract suite over real adapters through the core CLI
only. The GitHub conformance binding targets a throwaway sandbox repo named per run via
`WIT_CONFORMANCE_GITHUB_REPO`, and there is deliberately **no default and no standing shared
sandbox**: the suite's clean-at-start closes every open issue in its target, so a baked
default would eventually be pointed at (or drift into being) a repo someone coordinates real
work in. The GitHub binding is therefore on-demand only — never CI, never a coordination
repo.
2 changes: 1 addition & 1 deletion plugins/work-items/.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": "work-items",
"version": "0.35.25",
"version": "0.35.26",
"description": "Manages development work items through a provider-neutral tracker seam that ships with the plugin (bundled dispatcher plus github and local-markdown adapters; seam plugin-dir canonical, adapters consumer-local-first): dashboard, taxonomy-labeled creation, a race-safe assignee-plus-lease claim protocol, recurring-schedule checks, TODO scanning, stale-lease auditing, plan decomposition into vertical-slice items, raw-intake triage (issues and unsolicited PRs through raw, verified, briefed, autonomous-eligible states), plus the two work-items loop lanes of the loop-lane convention: a self-paced autonomous work-loop drain (work-class admission gate, adaptive item cap, PR-only) and an attended attend-queue escalation lane. The re-runnable setup skill binds the provider (.work-item-tracker.json), seeds the recurring-schedule seam (.github/recurring-schedule.json), and remaps canonical role labels.",
"author": {
"name": "Melodic Software",
Expand Down
42 changes: 42 additions & 0 deletions plugins/work-items/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,48 @@
All notable changes to the `work-items` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.35.26]

### Added

- **Contract-version handshake at the adapter seam (#2942, F3.6).** The dispatcher now
compares the adapter manifest's declared `schema_version` to the core contract version
before every dispatch (`wit_check_contract_version`, `lib/json.sh`) — a directional
tolerant-reader: major skew (either direction) refuses with exit `3` naming both versions
and the direction-appropriate fix; a newer-minor manifest proceeds with a stderr notice;
an older-minor manifest proceeds silently; an unversioned manifest cannot handshake and
refuses. Previously only `.verbs` was read, so a consumer-local, shadowing, or generated
adapter skewed silently. Skew behavior is documented both directions in CONTRACT.md
("Contract-version handshake") and covered by dispatcher unit tests plus conformance
cases (synthetic skewed shadow of the bound provider). Prerequisite for the
adapter-onboarding generator (#2950).

### Fixed

- **The seam's direction-locking ADR citation resolves in-tree (#2942, F3.5).** CONTRACT.md
and the GitHub conformance binding cited "ADR 0022", a number `docs/adr/` never reached.
The rationale is now recorded as ADR 0014 (engine plugin-canonical / adapters
consumer-first, plus the no-standing-sandbox conformance note) and both citations point
at it.
- **Role-label defaults are single-sourced (#2942, F3.7).** The shipped defaults
(`needs-human`, `agent-ready`, `recurring`) were defined three times — `lib/binding.sh`
literals, a `lib/frontier.sh` parameter default, and a dispatcher inline fallback. They
now live once in `lib/labels.sh`; binding resolution, the frontier filter default, and
the dispatcher all read the constants.

### Changed

- **`gh`-absent degradation documented honestly (#2942).** CONTRACT.md "Degradation without
`gh`" records that MCP-only sessions cannot run the `github` adapter at all, defers a
REST fallback (recorded rationale), rejects MCP-as-adapter, and documents the supported
backfill ritual: body-text `Blocked by:` edges + a provenance comment, replayed through
`link-blocks`/`add-sub-item` from the next `gh ≥ 2.94` session — leases explicitly
excluded from the ritual.
- **Fixed-string postures recorded (#2942, F3.7).** `label-taxonomy.md` "Recorded postures"
now defers the `[Maintenance]` title prefix and the `.github/recurring-schedule.json`
path as fixed strings until a consumer requests a remap (binding `config` keys when that
lands, arriving with a reconciliation step).

## [0.35.25]

### Changed
Expand Down
20 changes: 20 additions & 0 deletions plugins/work-items/reference/label-taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ fall back silently. Two constraints on remapping:

`/work-items:setup` offers the remap interview and writes the binding key.

### Recorded postures: fixed strings without a remap seam

Two strings adjacent to the `recurring-maintenance` role are **fixed by design today** — no
binding key remaps them (recorded posture per the consumer-configurability doctrine; #2942
F3.7, same deferral pattern as the seam's container label):

- **`[Maintenance]` title prefix** — the exact-match key `due`/`work` use to reconcile a
schedule row with its open tracker item. Making it configurable is deferred until a consumer
asks for a different prefix; when that request lands it joins the binding as a sibling of
`config.role_labels` (a `config` key, not a parallel mechanism), and every reader named in
[`../skills/track/actions/due.md`](../skills/track/actions/due.md) and the setup
reconciliation steps resolves it the same way role labels resolve.
- **`.github/recurring-schedule.json` path** — the schedule seam's location. Deferred on the
same first-request trigger; a future remap is a binding `config` key resolved once per
invocation, never a second discovery climb.

Both strings participate in exact-match lookups against provider data, so a repo changing
either today would orphan existing `[Maintenance]` items and schedules — which is why the
remap, when it comes, arrives with a reconciliation step, not as a bare string swap.

## Project-specific axes

The consuming repo defines the members of these axes to match its own architecture surface, domain categorization, and language/toolchain mix. Discover the live set from the bound adapter's label listing (for the GitHub adapter, `${CLAUDE_PLUGIN_ROOT}/tools/work-item-tracker/adapters/github/README.md` — e.g. `gh label list`).
Expand Down
67 changes: 66 additions & 1 deletion plugins/work-items/tools/work-item-tracker/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Provider-neutral CLI contract for work-item tracker operations. Skills and scripts call the
core dispatcher (`work-item-tracker.sh`) only; the bound provider adapter executes the
operation. The seam ships bundled with the `work-items` plugin and resolves plugin-dir
canonical with a project-root fallback (see "Adapter resolution"). Direction locked by ADR 0022.
canonical with a project-root fallback (see "Adapter resolution"). Direction locked by
[ADR 0014](../../../../docs/adr/0014-resolve-seam-engine-plugin-canonical-and-adapters-consumer-first.md).

## Prerequisites

Expand All @@ -15,6 +16,47 @@ canonical with a project-root fallback (see "Adapter resolution"). Direction loc
adapter gates on it at call time (exit `3`), not the dispatcher — minimal shared-code
blast radius.

### Degradation without `gh` (cloud / MCP-only sessions)

Some execution environments have GitHub access but no `gh` binary — notably cloud sessions
whose GitHub surface is MCP tools (model-plane, not shell-plane). In such a session the
seam **cannot run the `github` adapter at all**, reads and writes alike: the dispatcher's
prerequisite gate exits `3` (the same first-run signal as a missing binding), and there is
deliberately no silent fallback to another provider (the binding names the coordination
surface; "Offline role activates only by manual binding switch" applies to degradation
too). Honest limitation, recorded 2026-08-17 (#2942): this repository's own spec board
(#2933) had to be published through MCP tools with blocking edges as body text, because
the publishing session had no `gh`.

Evaluated fallbacks, decided as follows:

- **REST fallback inside the `github` adapter (`curl`) — explicitly deferred.** It would
duplicate `gh`'s auth, pagination, and endpoint surface inside the adapter, and would
silently fork identity routing ("Identity routing (GitHub adapter)" — the bot-wrapper
seam wraps `gh`, not raw HTTP). The native sub-issue/dependency surface is exactly what
gates `gh ≥ 2.94`; re-deriving it over raw REST is a second implementation to keep
conformant. Revisit if gh-less environments become a primary execution surface rather
than an occasional one.
- **MCP tools as an adapter — rejected.** Adapters are shell verb-scripts; MCP tools are
callable only by the model, so a shell seam cannot invoke them. A session with MCP-only
GitHub access already has item CRUD through those tools directly — what it loses is the
seam's value-add (leases, frontier derivation, normalization, conformance).

**Supported path — the backfill ritual.** A `gh`-less session that must publish anyway
(the #2933 case) publishes through whatever GitHub surface it has, and:

1. records every blocking edge as a structured body line — `Blocked by: <qualified id>`
("ID grammar"; bare `#123` is never persisted) — and parent linkage via the provider
surface where it exists (MCP has native sub-issue support);
2. leaves one provenance comment on the container naming the edges awaiting native
backfill;
3. the next session with `gh ≥ 2.94` replays the recorded edges through the seam
(`link-blocks` / `add-sub-item`) and strikes the note.

Leases are NOT part of the ritual: a `gh`-less session must not simulate claims by
body-editing — claim/renew/reclaim stay seam-only, so an item worked this way is picked up
as unclaimed coordination (acceptable for a publish, wrong for contended work).

## Setup (binding file)

The repo binds exactly ONE active provider via `.work-item-tracker.json` at the repo root
Expand Down Expand Up @@ -294,6 +336,29 @@ label-agnostic and simply never surfaces items that are assigned or blocked.
Provider ceilings surface as exit `7` with the ceiling named on stderr when hit at
runtime (e.g. GitHub: 100 sub-issues/parent, 8 nesting levels, 50 dependencies/type).

### Contract-version handshake

Adapters resolve consumer-local first ("Adapter resolution"), so a consumer-owned,
shadowing, or generated adapter can legitimately be built against a different contract
revision than the engine dispatching to it. The dispatcher therefore performs a
**directional tolerant-reader handshake** before every dispatch: it compares the manifest's
declared `schema_version` to the core's contract version (`WIT_SCHEMA_VERSION`,
`lib/json.sh`). Skew behavior, both directions:

| Manifest vs core | Behavior |
|---|---|
| no valid `schema_version` (MAJOR.MINOR) | refuse: exit `3`, stderr says the manifest cannot handshake — consumer/generated adapters MUST declare one |
| newer MAJOR | refuse: exit `3`, stderr names both versions — "update the `work-items` plugin" |
| older MAJOR | refuse: exit `3`, stderr names both versions — "update or regenerate the adapter" |
| same MAJOR, newer MINOR | proceed with a stderr notice: minors are additive, so the core (a tolerant reader) ignores fields it does not know; updating the plugin consumes them |
| same MAJOR, MINOR ≤ core | proceed silently: additive fields introduced after the adapter's revision are optional by definition, so an older adapter simply omits them |

Major skew is a **configuration error, not a degradation**: exit `3` is the same
first-run/setup signal as a missing binding, pointing at the mismatched pair rather than
failing later inside a verb with a shape error. The conformance suite asserts both refusal
directions and the newer-minor notice against a synthetic skewed manifest, and every real
conformance case exercises the passing handshake.

## Identity routing (GitHub adapter)

Tracker WRITES (item create, lease comments, reclaim notes) route through an optional bot wrapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
# GitHub conformance binding — targets a throwaway sandbox repo (NEVER a
# coordination repo). On-demand, not in CI. The target is required: set
# WIT_CONFORMANCE_GITHUB_REPO to the sandbox repo (owner/name). No default —
# no standing sandbox exists to fall back to (see ADR 0022 conformance note).
# no standing sandbox exists to fall back to (see the conformance note in
# docs/adr/0014-resolve-seam-engine-plugin-canonical-and-adapters-consumer-first.md).

CB_BINDING_TMP=""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ usage_error() {
binding_name=""
while [[ $# -gt 0 ]]; do
case "$1" in
--binding)
if [[ $# -lt 2 ]]; then
usage_error
fi
binding_name="$2"
shift 2
;;
*)
--binding)
if [[ $# -lt 2 ]]; then
usage_error
;;
fi
binding_name="$2"
shift 2
;;
*)
usage_error
;;
esac
done
if [[ -z "$binding_name" ]]; then
Expand Down Expand Up @@ -69,8 +69,8 @@ wit_case() {
WIT_RC=$?
assert_eq "$label (exit code)" "$expected_rc" "$WIT_RC"
case "$WIT_OUT" in
*$'\r'*) fail "$label (stdout CR-free)" "no CR" "CR present" ;;
*) pass "$label (stdout CR-free)" ;;
*$'\r'*) fail "$label (stdout CR-free)" "no CR" "CR present" ;;
*) pass "$label (stdout CR-free)" ;;
esac
}

Expand All @@ -95,6 +95,34 @@ wit_case "unknown verb → usage" 2 definitely-not-a-verb
WIT_OUT="$(WORK_ITEM_TRACKER_BINDING="/nonexistent-$$.json" bash "$TRACKER" capabilities 2>/dev/null)"
assert_eq "missing binding → exit 3" "3" "$?"

# --- contract-version handshake (CONTRACT.md "Contract-version handshake") ---
# Every dispatched case in this suite already passes through the handshake against
# the real adapter's manifest (a bad declared version would fail every case), and
# the capabilities assertions above pinned its schema_version. Skew behavior is
# asserted here against a synthetic shadow of the SAME provider name via
# WIT_ADAPTERS_DIR: major skew refuses (exit 3, both versions named); newer-minor
# proceeds with a stderr notice (tolerant reader).

SKEW_ROOT="$(mktemp -d)"
mkdir -p "$SKEW_ROOT/$PROVIDER"
printf '%s\n' "{\"schema_version\":\"99.0\",\"provider\":\"$PROVIDER\",\"verbs\":{\"capabilities\":true}}" \
>"$SKEW_ROOT/$PROVIDER/capabilities.json"
SKEW_ERR="$(WIT_ADAPTERS_DIR="$SKEW_ROOT" bash "$TRACKER" capabilities 2>&1 >/dev/null)"
assert_eq "major-skew manifest refused (exit code)" "3" "$?"
assert_contains "major-skew stderr names both versions" "$SKEW_ERR" "99.0"

cat >"$SKEW_ROOT/$PROVIDER/capabilities.sh" <<EOF
#!/usr/bin/env bash
jq -c . "$SKEW_ROOT/$PROVIDER/capabilities.json"
EOF
printf '%s\n' "{\"schema_version\":\"1.99\",\"provider\":\"$PROVIDER\",\"verbs\":{\"capabilities\":true}}" \
>"$SKEW_ROOT/$PROVIDER/capabilities.json"
WIT_OUT="$(WIT_ADAPTERS_DIR="$SKEW_ROOT" bash "$TRACKER" capabilities 2>/dev/null)"
assert_eq "newer-minor manifest proceeds (exit code)" "0" "$?"
SKEW_ERR="$(WIT_ADAPTERS_DIR="$SKEW_ROOT" bash "$TRACKER" capabilities 2>&1 >/dev/null)"
assert_contains "newer-minor proceeds with a stderr notice" "$SKEW_ERR" "newer than core"
rm -rf "$SKEW_ROOT"

verb_supported() {
[[ "$(jq -r --arg v "$1" '.verbs[$v] // false' <<<"$CAPS")" == "true" ]]
}
Expand Down
12 changes: 8 additions & 4 deletions plugins/work-items/tools/work-item-tracker/lib/binding.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
[[ -n "${_WIT_BINDING_LOADED:-}" ]] && return 0
readonly _WIT_BINDING_LOADED=1

# shellcheck source=labels.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/labels.sh"

# wit_find_binding — echo the binding file path.
# Precedence: WORK_ITEM_TRACKER_BINDING env override, else climb from CWD toward the
# filesystem root and take the first .work-item-tracker.json.
Expand Down Expand Up @@ -73,10 +76,11 @@ wit_read_binding() {
storage="$(cd "$(dirname "$path")" && pwd)/$storage"
fi
# Canonical roles (label-taxonomy.md); config.role_labels lets a repo remap the
# literal label strings. Absent keys fall back to shipped defaults.
human_gated="$(wit_role_label "$path" "human-gated" "needs-human")"
autonomous_eligible="$(wit_role_label "$path" "autonomous-eligible" "agent-ready")"
recurring_maintenance="$(wit_role_label "$path" "recurring-maintenance" "recurring")"
# literal label strings. Absent keys fall back to the shipped defaults, defined
# once in lib/labels.sh.
human_gated="$(wit_role_label "$path" "human-gated" "$WIT_DEFAULT_HUMAN_GATED_LABEL")"
autonomous_eligible="$(wit_role_label "$path" "autonomous-eligible" "$WIT_DEFAULT_AUTONOMOUS_ELIGIBLE_LABEL")"
recurring_maintenance="$(wit_role_label "$path" "recurring-maintenance" "$WIT_DEFAULT_RECURRING_MAINTENANCE_LABEL")"
WIT_PROVIDER="$provider"
WIT_LEASE_TTL_HOURS="$ttl"
WIT_LEASE_TTL_MINUTES="$minutes"
Expand Down
Loading