Skip to content

feat(work-items): bundle work-item-tracker seam in plugin (shape A)#224

Merged
kyle-sexton merged 10 commits into
mainfrom
feat/work-items-seam-shape-a
Jul 19, 2026
Merged

feat(work-items): bundle work-item-tracker seam in plugin (shape A)#224
kyle-sexton merged 10 commits into
mainfrom
feat/work-items-seam-shape-a

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Executes shape A of the tracker-seam distribution decision (melodic-software/medley#1502): move the work-item-tracker seam out of medley and bundle it in the work-items plugin, so installing the plugin delivers the engine and the shipped adapters — no per-repo vendoring.

Refs melodic-software/medley#1505 (non-closing — the medley-side de-vendor + the ADR-0022 reversal are described-but-not-executed here). Part of the shape-A decision under melodic-software/medley#1502 (EPIC-adjacent; not closed by this PR).

Related

No linked issue in this repository. Related decision-log entries this PR does not close:

  • melodic-software/medley#1502 — shape-A tracker-seam distribution decision (EPIC-adjacent).
  • melodic-software/medley#1505 — medley-side de-vendor + ADR-0022 reversal spec (described here; executed in a follow-up medley-side PR per the "Medley-side follow-up" section below).

What changed (plugin side)

  • Seam relocated into plugins/work-items/tools/work-item-tracker/ — dispatcher, lib/, CONTRACT.md, the github + local-markdown adapters, and the conformance suite (byte-identical move; migrations/ deliberately left in medley — medley-specific, out of scope).
  • Self-contained test harness. The seam brought a minimal tools/work-item-tracker/tests/lib.sh (the 6 assertion helpers its .test.sh files use + counter defaults + the git-env unset guard); every .test.sh now sources it self-locating (relative to its own dir), with zero dependency on any host-repo test tooling.
  • Two-rule resolver (CONTRACT.md "Adapter resolution"):
    • Seam code — plugin-dir canonical, project-root fallback (skills resolve "$TRACKER" this way, then invoke it).
    • Adapters — consumer-local-first (${CLAUDE_PROJECT_DIR}/tools/work-item-tracker/adapters/<provider>/), plugin-bundled fallback; first match wins. WIT_ADAPTERS_DIR remains a single-root test override. New dispatcher tests + a live throwaway-fixture demo cover consumer-local shadow, unshipped-provider addition, and bundled fallback.
  • Setup entrypoint (decision: extend the existing setup skill). /work-items:setup now seeds .work-item-tracker.json (provider + non-secret config) as the once-per-repo binding step, before the recurring-schedule and role-label passes. Shape chosen to match the plugin's existing setup idiom (a re-runnable, disable-model-invocation: true skill) rather than introduce a second setup surface.
  • Docs de-medley-ised for portability. CONTRACT.md and the GitHub adapter reference no longer cite a specific consuming repo's convention docs or bot-auth wrapper; the three now-obsolete "no setup wizard / skills don't bind the provider" statements are corrected; skill citations point at ${CLAUDE_PLUGIN_ROOT}/tools/work-item-tracker/....
  • Conformance travels with the seam (decision on an issue open-question): it lives with the adapters it tests, in the plugin.
  • plugin.json 0.7.0 → 0.8.0; CHANGELOG + regenerated root catalog.

Verification (all real, from the new plugin location)

  • Seam unit suite: 29/29 .test.sh pass headless (no gh/network) from the new location.
  • local-markdown conformance: run-conformance.sh --binding local-markdown61 cases, 0 failed, offline.
  • github conformance: deliberately NOT run in this draft. It is operator-gated by design (ADR 0022: on-demand, needs WIT_CONFORMANCE_GITHUB_REPO + a throwaway sandbox + gh auth), and the live run creates/deletes real GitHub account state. The github adapter's unit surface passes and the move is byte-identical, so the local-markdown E2E already exercises the shared core path. Run live github conformance against a throwaway sandbox repo as part of human review, before merge — not a precondition for this staying a draft.
  • Two-rule resolver, live throwaway fixtures: consumer-local shadow of a bundled adapter → shadow wins; consumer-local unshipped provider (jira) → resolves; no consumer-local dir → plugin-bundled local-markdown → resolves; rule-1 seam code → plugin copy when ${CLAUDE_PLUGIN_ROOT} set, project-root fallback when unset.
  • shellcheck (repo .shellcheckrc) clean on all 60 moved shell files (two findings fixed at root: a documented default case; one justified SC2016 for a literal shim body).
  • editorconfig-checker clean on the moved tree.
  • validate-plugin-contracts.mjs and validate-plugins.sh pass (catalog regenerated).
  • scripts/run-plugin-tests.sh: the work-items suite (all 29 moved .test.sh) runs green locally via the runner's own discovery from repo root (CWD-independent); the full cross-plugin suite runs in CI.
  • Rule-1 resolver idiom (${CLAUDE_PLUGIN_ROOT} in executed skill-bash): verified against marketplace precedent — claude-config, claude-memory, claude-ops, context7, code-tidying all invoke bundled scripts via plain bash "${CLAUDE_PLUGIN_ROOT}/skills/.../x.sh" in executed bash, so the mechanism is proven. The resolver uses that exact plain-token form (not a ${VAR:+…} conditional, which could break under token text-substitution). Review item chore: initialize marketplace scaffold #1: confirm the plugin-canonical branch resolves in your target runtime (a non-vendoring consumer relies on it).

Deferred / noted (not built here)

  • Jira transport shape (single jira/ adapter w/ config switch vs jira-mcp/jira-rest) — no Jira adapter exists; recorded as a future-extension decision, not blocking. The consumer-local-first resolver already lets a consumer drop one in.
  • Secret binding for a future provider — the binding file is tracked; secrets never go in it (a provider token is referenced by name from inside its adapter). GitHub uses ambient gh, no secret today. Design left open, not foreclosed.

Medley-side follow-up (ready-to-execute spec — NOT done in this PR)

Precondition: work-items >= 0.8.0 installed in medley (ships the seam + adapters + resolver).
Goal: medley stops vendoring the seam engine and resolves it from the installed plugin (dogfoods the distribution path), keeping its GitHub binding working.

Remove from medley (now bundled in the plugin, byte-identical):

  • tools/work-item-tracker/work-item-tracker.sh
  • tools/work-item-tracker/lib/ (binding.sh, frontier.sh, id.sh, json.sh, lease.sh, verb-test-helpers.sh + their .test.sh)
  • tools/work-item-tracker/adapters/github/ and tools/work-item-tracker/adapters/local-markdown/ (all)
  • tools/work-item-tracker/conformance/ (all)
  • tools/work-item-tracker/CONTRACT.md

Keep in medley (medley-specific, NOT relocated):

  • .work-item-tracker.json at repo root (provider: github, lease_ttl_hours: 24) — the plugin's resolver climbs from CWD to find it. Unchanged.
  • tools/github-auth/gh-bot.sh — medley's bot-identity wrapper.
  • tools/work-item-tracker/migrations/ — one-time label-governance tooling (out of scope) — but see dependency fix: correct plugin-system inaccuracies and harden references #2.
  • medley's tests/shell/lib.sh — its own harness (the seam carried its own copy into the plugin; medley's other tests still use this).

Two dangling dependencies the removal creates — owner decisions to surface, not silently pick:

  1. gh-bot identity on tracker WRITES. The plugin-bundled github adapter resolves its bot wrapper adapter-relative → ${CLAUDE_PLUGIN_ROOT}/github-auth/gh-bot.sh, which does not exist in the plugin → writes (item create, lease/reclaim comments) fall back to bare gh (session-user identity). Medley currently gets bot-attributed writes because its vendored adapter resolves tools/github-auth/gh-bot.sh (present). After de-vendoring, that attribution changes. Options:

    • (a) Accept bare-gh writes (session-user attribution) — simplest; fine if bot attribution on tracker writes isn't required.
    • (b) Shadow the github adapter consumer-local so WIT_GH_BOT resolves back to tools/github-auth/gh-bot.sh — but that re-vendors the whole adapter, defeating resolve-from-plugin.
    • (c) [RECOMMENDED, separate plugin PR] enhance the plugin's github adapter to also look for a consumer-root wrapper (${CLAUDE_PROJECT_DIR}/tools/github-auth/gh-bot.sh) — the clean long-term fix that lets medley keep bot writes with zero vendoring. Deliberately not built here: it changes the live adapter's documented identity/write path and isn't one of #1505's enumerated deliverables.
      Interim: (a); long-term: (c).
  2. migrations/migrate-blocked-labels.sh depends on the co-located dispatcher. Line 43 readonly SEAM="$SCRIPT_DIR/../work-item-tracker.sh" (→ tools/work-item-tracker/work-item-tracker.sh), used at line 360 (link-blocks) and line 508 (create-item). Removing the dispatcher dangles this. (migrate-type-labels.sh and rename-drift-labels.sh do not reference the seam engine.) Options:

    • (a) [RECOMMENDED if spent] these are one-time migrations (the label→native/edge migrations from #1339 / S7 / S8); if complete, retire migrate-blocked-labels.sh + its .test.sh.
    • (b) If still needed, repoint its SEAM to a plugin-first resolution — but migrations run as standalone scripts where ${CLAUDE_PLUGIN_ROOT} may be unset, so this needs a robust plugin-dir discovery (or keep a minimal vendored dispatcher for migrations only).

No other dangling references: medley CI (.github/) does not reference the seam by path; tools/run-shell-tests.sh globs **/*.test.sh generically (the seam tests simply vanish from medley's run); the only remaining non-tree reference is ADR 0022.

ADR reversal (medley-owned, describe-only here). ADR 0022 records "the seam lives in the app repo" (dispatcher at tools/work-item-tracker/work-item-tracker.sh resolving a repo-root binding — amendment text at ~line 57–58). Add an amendment or superseding ADR recording: (1) canonical home is the work-items plugin; (2) the two-rule resolution (seam plugin-canonical/project-root-fallback; adapters consumer-local-first/plugin-bundled-fallback); (3) binding + consumer-local adapters stay repo-side; (4) the gh-bot identity consequence from dependency #1.

Verify the medley PR: with 0.8.0 installed and the vendored tree removed, run /work-items:track (dashboard + a list-frontier) and confirm the plugin resolves its bundled seam + adapters against medley's binding; confirm write identity matches the decision in dependency #1.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D9yeNF5Mp9U5voSUpHXMUk

kyle-sexton and others added 3 commits July 16, 2026 11:05
Move the work-item-tracker seam (dispatcher, lib/, CONTRACT.md, github +
local-markdown adapters, conformance) from medley into the work-items plugin so
installing the plugin delivers the engine and the shipped adapters — no per-repo
vendoring.

- Two-rule resolver (CONTRACT.md "Adapter resolution"): seam code plugin-dir
  canonical with a project-root fallback; adapters consumer-local-first with a
  plugin-bundled fallback, first match wins. WIT_ADAPTERS_DIR stays a single-root
  test override. New dispatcher tests cover both adapter paths.
- /work-items:setup now seeds .work-item-tracker.json (provider + non-secret
  config) as the once-per-repo binding step; the three obsolete "no setup
  wizard / skills don't bind the provider" statements are corrected.
- Self-contained test harness (tools/work-item-tracker/tests/lib.sh) so the
  seam's .test.sh files run wherever the seam resolves from, with no host-repo
  test-tooling dependency.
- CONTRACT.md and the GitHub adapter reference de-medley-ised for portability;
  skill citations point at ${CLAUDE_PLUGIN_ROOT}/tools/work-item-tracker/...
- plugin.json 0.7.0 -> 0.8.0; CHANGELOG + regenerated root catalog.

Executes shape A from melodic-software/medley#1502; the medley-side de-vendor
and the ADR-0022 reversal are left as a described follow-up in the PR body.

Refs melodic-software/medley#1505

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9yeNF5Mp9U5voSUpHXMUk
Match the marketplace's proven executed-bash idiom for invoking a bundled
script (claude-config / claude-memory / claude-ops / context7 all use plain
"${CLAUDE_PLUGIN_ROOT}/skills/.../x.sh"). The ${VAR:+...} conditional form would
break if the harness text-substitutes the exact ${CLAUDE_PLUGIN_ROOT} token
instead of exporting it as a shell var; the plain form is robust either way —
the plugin copy when set, project-root fallback when unset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D9yeNF5Mp9U5voSUpHXMUk
The seam relocation from medley dropped the executable bit on 54
shebang scripts under work-item-tracker/adapters/ during the move.
@kyle-sexton
kyle-sexton marked this pull request as ready for review July 18, 2026 20:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03fdd2b3db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/work-items/tools/work-item-tracker/lib/frontier.sh Outdated
Comment thread plugins/work-items/tools/work-item-tracker/adapters/github/claim.sh
Resolve work-items plugin conflicts between shape-A seam bundling and main's
prerequisite-visibility wave + scan->scan-todos rename:

- plugin.json / CHANGELOG: renumber the bundle-seam release 0.8.0 -> 0.11.0
  (non-breaking feat, above main's shipped 0.10.0); keep the shape-A
  description. All of main's 0.10.0/0.9.0/0.8.1/0.8.0 changelog entries retained.
- README / setup SKILL: compose both sides -- main's Bash+jq requirement/check
  plus the PR's bundled-seam rewrite and 3-concern setup restructure.
- reference/tracker-seam: adopt the shape-A model and fold in main's jq +
  seam-script presence checks, adapting the seam-script remediation from
  "consumer-provided, copy it in" to "plugin-bundled; reinstall/repair if it
  resolves at neither the plugin path nor the project-root fallback." The
  "/work-items:setup does NOT create the seam" note is preserved.
@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 8s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Read PR diff and key changed files
  • Verify Codex P1/P2 findings
  • Review dispatcher and resolver logic
  • Review setup skill changes
  • Review CONTRACT.md and plugin.json
  • Review security posture

Overview

This is a well-structured relocation of the work-item tracker seam into the plugin. The two-rule resolver is correctly implemented, the conformance suite travels with the code it tests, shellcheck integration is thorough, and the MSYS/Git Bash process-substitution workaround is consistently applied where needed. The setup skill changes are comprehensive and correctly warn about the human-gated label gap.

That said, two P1 correctness bugs need fixes before merge, one P2 bug needs addressing, and there is a security gap in provider name validation.


P1 — frontier.sh hardcodes needs-human, bypassing configured human-gated remap

lib/frontier.sh:24

wit_filter_frontier always matches the literal string needs-human, regardless of what config.role_labels["human-gated"] is set to in the binding. When a repo remaps human-gated (e.g. to do-not-auto-pick), the list-frontier --autonomous filter silently drops items labeled needs-human (now a dead label) while passing items labeled do-not-auto-pick through to autonomous agents — defeating the HITL gate.

Root cause: lib/binding.sh's wit_read_binding exports WIT_PROVIDER, WIT_LEASE_TTL_HOURS, and WIT_STORAGE_DIR, but never reads or exports config.role_labels. frontier.sh therefore cannot access the configured value. frontier.test.sh only tests with the literal label, cementing this assumption. The setup skill warns about this (line 219: "the shipped seam reads needs-human") but does not prevent users from remapping.

Fix path: wit_read_binding should read and export config.role_labels["human-gated"] (defaulting to "needs-human" when absent), the dispatcher should pass the resolved label to wit_filter_frontier, and the function signature should accept it as a parameter. The binding test and frontier test both need a case for a non-default remap.

Fix this →


P1 — local-markdown relative storage_dir not rooted against binding location

lib/binding.sh:43–49

wit_read_binding stores config.storage_dir in WIT_STORAGE_DIR as the raw string from the JSON. When it is relative (e.g. .work-items, the value shown in the setup skill example), it is resolved against the shell's CWD at the time the adapter runs — not against the binding file's location. The binding climber correctly finds .work-item-tracker.json at the repo root, but mkdir -p "$WIT_STORAGE_DIR" in wit_need_storage then creates/reads .work-items relative to wherever the skill's bash subprocess was started. Invoking any local-markdown verb from a subdirectory writes items into <subdir>/.work-items while a later invocation from the root reads <root>/.work-items — silently reading an empty store or creating duplicates.

The binding test (binding.test.sh:78) only covers the absolute-path case (/tmp/x); the relative-path case is absent.

Fix: in wit_read_binding, root a relative storage_dir against the binding file's directory:

[[ "$storage" == /* ]] || storage="$(cd "$(dirname "$path")" && pwd)/$storage"

Add a binding.test.sh case that invokes wit_read_binding from a subdirectory with a relative storage_dir and asserts the exported path is absolute.

Fix this →


P2 — claim.sh leaves issue assigned to @me when lease POST fails

adapters/github/claim.sh:39,58–60

Step 1 assigns @me to the issue. Step 3 (wit_run_gh write api ... --jq '.id') creates the lease comment. If step 3 fails, wit_run_gh calls exit "$(wit_map_gh_error ...)" — exiting the script without removing the @me assignment. The issue is now assigned with no lease comment.

reclaim does not rescue this state: it selects the active lease comment (lease_json) and, when none is found, emits false "no active lease record" and exits — assignees are not touched. The stranded issue: has an assignee (excluded by list-frontier), no lease (ignored by reclaim). It can only be recovered by manual unassignment.

Fix: after the lease POST, add an error handler (or use set -e-compatible inline check) that removes @me before exiting. A trap-on-EXIT approach is clean here since steps 1–3 already bail on wit_run_gh failure:

# After step 1 succeeds:
_cleanup_assign() { gh issue edit "$number" -R "$owner/$repo" --remove-assignee "@me" >/dev/null 2>&1 || true; }
trap '_cleanup_assign' ERR
# ... step 2 (sole-assignee check already removes @me on conflict) ...
wit_run_gh write api "repos/$owner/$repo/issues/$number/comments" ...
trap - ERR  # lease created successfully — disarm

Fix this →


Security — Provider name not validated; allows path traversal in adapter resolution

work-item-tracker.sh:37–44 / lib/binding.sh:39–41

The adapter directory is constructed as $CLAUDE_PROJECT_DIR/tools/work-item-tracker/adapters/$WIT_PROVIDER. WIT_PROVIDER comes directly from the user-controlled .work-item-tracker.json binding. wit_read_binding only checks [[ -n "$provider" ]] — no content validation. A provider value containing .. (e.g. ../../injected) resolves to a path outside adapters/, and the dispatcher then executes bash "$adapter_dir/$verb.sh" against it. In normal usage the binding file is team-controlled and tracked in git, so this is low-severity, but it's a trivially-defended attack surface that violates plugin-form safety.

Fix: validate provider against ^[a-zA-Z0-9_-]+$ in wit_read_binding (return 1 on mismatch):

[[ "$provider" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1

Acknowledged — gh-bot.sh always falls back to bare gh for bundled adapter

adapters/github/common.sh:20

WIT_GH_BOT resolves to <plugin>/tools/github-auth/gh-bot.sh, which the plugin does not ship. All bundled-adapter writes (lease comments, item creates) therefore use the session user identity, never the bot. The PR documents this as dependency #1 and recommends option (c) — look for ${CLAUDE_PROJECT_DIR}/tools/github-auth/gh-bot.sh consumer-local-first — as the clean long-term fix. The current behavior is intentional for this PR, but worth flagging for human review and confirming the interim posture (bare-gh attribution) is acceptable.


Positive observations

  • The two-rule resolver (seam: plugin-canonical / project-root fallback; adapters: consumer-local / plugin-bundled fallback) is correctly implemented end-to-end and matches the CONTRACT.md spec.
  • wit_run_gh's exit-on-error pattern with $() || exit "$?" for subshell propagation is consistently applied across claim, reclaim, and renew-lease.
  • The MSYS/Git Bash process-substitution workaround (capture-then-iterate via here-string) is documented and applied correctly in local-markdown; github adapter uses < <(...) only where the loop body does not fork I/O-heavy subprocesses.
  • wit_strip_cr applied to all adapter output is good cross-platform practice.
  • Conformance suite co-located with the adapters it tests is the right call.
  • Setup skill correctly warns about the human-gated remap gap (even though the seam should close it).
  • plugin.json description accurately reflects the new bundled-seam posture.

Summary

Two P1 correctness bugs (frontier label hardcode, relative storage_dir) and one P2 bug (partial-claim rollback) are blocking. The provider-name validation is a quick hardening fix. The gh-bot.sh gap is documented and expected — confirm the interim identity posture is acceptable before merge. The review item flagged in the PR description (confirm ${CLAUDE_PLUGIN_ROOT}-canonical resolver works in your target runtime) remains outstanding for human verification.

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 18s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Check if P1/P2 findings from prior review were addressed
  • Review dispatcher and resolver logic
  • Review binding.sh and frontier.sh
  • Review claim.sh rollback logic
  • Review security: provider name validation
  • Post final review

What the recent fix commits addressed

Two fixes landed since the original review comment:

  • fix(work-items): use plain ${CLAUDE_PLUGIN_ROOT} in seam resolver — Replaced ${VAR:+...} conditional form with plain ${CLAUDE_PLUGIN_ROOT} in all skill files referencing the bundled seam. This closes the "review item chore: initialize marketplace scaffold #1" from the PR body and matches the proven marketplace idiom. ✅
  • fix(work-items): restore exec bit on vendored tracker adapter scripts — Restored the +x bit on 54 adapter/conformance/lib scripts that lost it during the move from medley. Without this, every verb invocation would fail with permission denied. ✅

Outstanding — P1, P2, and Security findings are still open

The four issues raised in the prior review are unchanged in the current tree. Confirming each against the live files:


P1 — frontier.sh still hardcodes needs-human (unfixed)

lib/frontier.sh:23

then (((.labels // []) | index("needs-human")) | not)

wit_filter_frontier still matches the literal string. binding.sh never reads or exports config.role_labels["human-gated"]. frontier.test.sh:17,28 only tests with the literal label value, cementing the assumption. Any repo that remaps human-gateddo-not-auto-pick via the setup skill's role_labels field will silently pass those items through to autonomous agents.

Fix: wit_read_binding should read config.role_labels["human-gated"] and export it as WIT_HUMAN_GATED_LABEL (default: "needs-human"); pass it into wit_filter_frontier as a second parameter; update frontier.test.sh to cover the remap case.

Fix this →


P1 — binding.sh relative storage_dir not rooted (unfixed)

lib/binding.sh:43–49

storage="$(jq -r '.config.storage_dir // empty' "$path")"
...
WIT_STORAGE_DIR="$storage"

The raw string is exported. When it is relative (e.g. .work-items, the example in the setup skill), local-markdown/common.sh:71 runs mkdir -p "$WIT_STORAGE_DIR" against the adapter script's CWD — not the binding file's directory. Invoking the seam from any subdirectory writes items into <subdir>/.work-items while a root invocation reads <root>/.work-items. Silently creates an empty-appearing store or duplicates items.

binding.test.sh covers only the absolute-path case.

Fix in wit_read_binding, after reading storage:

[[ "$storage" == /* ]] || storage="$(cd "$(dirname "$path")" && pwd)/$storage"

Add a binding.test.sh case that calls wit_read_binding from a nested subdirectory with a relative storage_dir and asserts WIT_STORAGE_DIR is absolute.

Fix this →


P2 — claim.sh leaves issue assigned when lease POST fails (unfixed)

adapters/github/claim.sh:39,58–60

Step 1 assigns @me (line 39). If step 3's wit_run_gh write api call fails, wit_run_gh exits immediately via exit "$(wit_map_gh_error ...)" — with no removal of the @me assignee. The issue is now assigned with no lease comment: excluded by list-frontier (has an assignee), ignored by reclaim (no active lease record). Stuck until manual unassignment.

Fix: set an ERR trap after step 1 succeeds, disarm it after our_comment_id is set:

# After line 39:
_cleanup_assign() { gh issue edit "$number" -R "$owner/$repo" --remove-assignee "@me" >/dev/null 2>&1 || true; }
trap '_cleanup_assign' ERR
# ...
our_comment_id="$WIT_GH_OUT"   # line 60
trap - ERR                      # lease created — disarm

Fix this →


Security — Provider name not validated against path traversal (unfixed)

lib/binding.sh:39–40

provider="$(jq -r '.provider // empty' "$path")"
[[ -n "$provider" ]] || return 1

Only non-empty check. A provider value of ../../injected passes validation and resolves wit_resolve_adapter_dir to a path outside adapters/; the dispatcher then executes bash "$adapter_dir/$verb.sh" against it. The binding file is typically team-controlled and tracked in git, so risk is low in practice, but it is trivially-defended.

Fix: add a pattern validation in wit_read_binding:

[[ "$provider" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1

Fix this →


Acknowledged — gh-bot.sh always falls back to bare gh (intentional for this PR)

adapters/github/common.sh:20

WIT_GH_BOT resolves to <plugin>/tools/github-auth/gh-bot.sh, which the plugin does not ship. All bundled-adapter writes use session identity. The PR documents this as dependency #1 with option (c) as the recommended long-term fix. Confirm the interim bare-gh attribution posture is acceptable before merge.


Summary

The exec-bit restore and ${CLAUDE_PLUGIN_ROOT} resolver fixes are solid and address two real operational issues. The four findings from the prior review remain open — the two P1s are the merge-blocking items. Human review remains required per the PR description before treating this as merge-ready.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4ad2e3dd2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…tial claims

Root-cause fixes for the codex/claude[bot] findings on PR #224:

- lib/binding.sh: root a relative config.storage_dir against the binding
  file's own directory instead of the caller's CWD, so a verb invoked from a
  subdirectory resolves the same local-markdown store as one invoked from the
  repo root. Validate provider against ^[a-zA-Z0-9_-]+$ before it is used to
  build an adapter directory path (path-traversal hardening). Read and export
  config.role_labels["human-gated"] as WIT_HUMAN_GATED_LABEL, defaulting to
  "needs-human" when unset.
- lib/frontier.sh + work-item-tracker.sh: wit_filter_frontier now takes the
  resolved human-gated label as a parameter instead of hardcoding
  "needs-human", so list-frontier --autonomous honors a repo's configured
  role-label remap.
- adapters/github/claim.sh: install an EXIT-trap rollback of the @me
  assignment for the window between a successful assignment and a successful
  lease-comment write, so a failure in between no longer strands the issue
  assigned with no lease record. wit_run_gh's failure path calls `exit`
  directly rather than returning, and an explicit `exit` inside a function
  does not run the caller's ERR trap (verified empirically) — only EXIT does.

New coverage: binding.test.sh (relative storage_dir rooting, provider
validation, role_labels remap/default) and frontier.test.sh (configured
human-gated label). Full work-items suite + local-markdown conformance
(normal + gh/curl-blocking-shim runs) verified green locally.
This PR's own Provider binding section (skills/setup/SKILL.md) newly makes
local-markdown a real, user-selectable binding, but local-markdown's
capabilities.json declares reclaim: false (no reclaim.sh — claim.sh already
race-checks pre-write instead) and the dispatcher's capability gate maps that
to exit 6 (EX_CAPABILITY, CONTRACT.md "Exit codes"). track/actions/start.md,
work/SKILL.md Step 0, and track/actions/audit.md all call reclaim
unconditionally on every assigned item and only handled the "still assigned"
outcome, not exit 6 — so a repo bound to local-markdown would stop at the
very first reclaim pre-check in /work-items:track start, /work-items:work,
and /work-items:track audit.

Document exit 6 as the documented degradation (CONTRACT.md already defines
it as "capability-unsupported", not an error) in all three call sites: skip
the stale-lease check and continue rather than treating it as an unhandled
failure. Flagged by chatgpt-codex-connector[bot] on PR #224
(capabilities.json:9, posted after the initial review round).
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 46s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Read recent fix commits (f15a329, 9023fa7)
  • Verify P1/P2 findings from prior reviews are addressed
  • Check for regressions or new issues
  • Post final review

All prior findings resolved

The two fix commits (9023fa7 and f15a329) close every issue flagged in the previous reviews. Verified against the live files:


✅ P1 — frontier.sh human-gated label hardcode (fixed)

lib/frontier.sh:18

wit_filter_frontier now accepts [<human-gated-label>] as a second argument, defaulting to "needs-human" only when absent. wit_read_binding reads config.role_labels["human-gated"] and exports WIT_HUMAN_GATED_LABEL. The dispatcher passes "${WIT_HUMAN_GATED_LABEL:-needs-human}" through on every list-frontier call. The remap path is fully covered in frontier.test.sh — the new fixture explicitly puts needs-human and do-not-auto-pick labels on separate items, then asserts wit_filter_frontier true "do-not-auto-pick" keeps the needs-human item and excludes only the remapped one.


✅ P1 — binding.sh relative storage_dir not rooted (fixed)

lib/binding.sh:55–57

if [[ -n "$storage" && "$storage" != /* ]]; then
  storage="$(cd "$(dirname "$path")" && pwd)/$storage"
fi

Relative storage_dir is now rooted against the binding file's directory, not the caller's CWD. binding.test.sh covers the CWD-independence case: calls wit_read_binding from a nested subdirectory with "storage_dir": ".work-items" and asserts WIT_STORAGE_DIR resolves to the absolute repo-root path. The cd "$(dirname "$path")" is safe since $path has already passed the jq -e . parse check on line 36, so its directory exists.


✅ P2 — claim.sh partial-claim rollback (fixed)

adapters/github/claim.sh:48–51, 73

The fix uses an EXIT trap (not an ERR trap, as suggested in the prior review). The comment explains correctly why: wit_run_gh calls exit directly inside its function body, which fires EXIT traps on the calling script but does NOT trigger the caller's ERR trap. The EXIT approach is strictly more correct. The trap is armed immediately after step 1 succeeds and is disarmed at line 73 once our_comment_id is set.

One observation: the sole-assignee conflict path (lines 57–61) manually removes @me before calling exit "$EX_CONFLICT". When that exit fires, the still-armed EXIT trap will also attempt removal — a harmless double-call since the || true on line 49 ensures the trap is idempotent. No issue, just worth noting.


✅ Security — Provider name path-traversal validation (fixed)

lib/binding.sh:44

[[ "$provider" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1

binding.test.sh covers both ../../injected (path traversal) and github/extra (embedded slash), both correctly rejected. The comment above the check is clear and accurate.


✅ Codex P1 — local-markdown reclaim unsupported gate (fixed)

skills/work/SKILL.md:47 / skills/track/actions/start.md:26

Both skills now document that exit 6 from reclaim means capability-unsupported (not a failure) and prescribe skipping the reclaim step and continuing normally. local-markdown/capabilities.json declares "reclaim": false, so the dispatcher correctly exits 6 without invoking the adapter, and the skills handle it gracefully.


Acknowledged — gh-bot.sh identity gap (intentional for this PR)

adapters/github/common.sh:20

WIT_GH_BOT still resolves to <plugin>/tools/github-auth/gh-bot.sh, which the plugin does not ship. All bundled-adapter writes use session identity. This is documented as dependency #1 with option (c) as the recommended long-term fix. Confirm the interim bare-gh attribution is acceptable before merge — this is unchanged from the prior reviews.


Summary

All four findings from the prior review and the Codex P1 (reclaim gate) are fixed with tests. The implementation is correct and the EXIT-trap choice in claim.sh is actually more robust than the ERR-trap approach the prior review suggested. No regressions found. The PR remains draft-status pending human review, live GitHub conformance run, and the gh-bot identity posture decision per the PR description — none of those are code correctness issues.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Classification table for the findings in this review (per-finding, evidence + fix commit):

# Finding Classification Evidence Reacted
1 frontier.sh hardcodes needs-human VALID — fixing Same root cause as codex's inline finding on the same line; lib/binding.sh now reads+exports config.role_labels["human-gated"] as WIT_HUMAN_GATED_LABEL, frontier.sh's wit_filter_frontier takes it as a param 👍
2 local-markdown relative storage_dir not rooted VALID — fixing Root-caused exactly where you pointed — wit_read_binding (lib/binding.sh:43-49) — not the adapter; a relative storage_dir is now rooted against the binding file's own directory before export 👍
3 claim.sh leaves issue assigned when lease POST fails VALID — fixing EXIT trap installed after the @me assignment succeeds, disarmed after the lease comment is confirmed created. One correction to the suggested fix: it must be an EXIT trap, not ERR — wit_run_gh's failure path calls exit directly, and I verified empirically (bash -c 'trap ... ERR; foo(){ exit 5; }; foo') that an explicit exit inside a function does not fire the caller's ERR trap, only its EXIT trap 👍
4 Provider name not validated (path traversal) VALID — fixing wit_read_binding now rejects any provider not matching ^[a-zA-Z0-9_-]+$ before it is used to build an adapter directory path; new binding.test.sh cases cover ../../injected and an embedded / 👍
5 gh-bot.sh always falls back to bare gh (Acknowledged) VALID (defer) Matches the PR description's own dependency #1 / option (c) — recommended as a separate plugin PR, not built here; code unchanged, that thread stays open rather than resolved 👍

Fixes for 1-4: 9023fa7. Also fixed a distinct P1 codex flagged after this review (local-markdown/capabilities.json:9reclaim: false breaks the unconditional reclaim pre-check in track start / work / track audit now that this PR's new Provider-binding section makes local-markdown a real, selectable binding): f15a329.

Full work-items test suite + local-markdown conformance (normal run and the gh/curl-blocking-shim run, 61/61 cases) pass locally against both commits.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f15a32999f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/work-items/tools/work-item-tracker/adapters/github/claim.sh Outdated
Follow-up to 9023fa7's partial-claim guard, flagged by codex on the fix
itself: disarming the EXIT trap right after the lease comment is created
closed the assign→lease-write window but reopened an equivalent one at the
very next step — a failure re-reading lease comments for arbitration
(`wit_list_lease_comments`) would exit with @me assigned and a live lease
comment whose id was never returned to the caller, stranding the item the
same way until TTL expiry.

The rollback now stays armed through the arbitration read and disarms at
exactly the two outcomes where keeping @me is correct: a same-login race
(the earlier comment from this identity wins, so the assignee still matches
the holder) and final success. The different-holder race still removes @me
explicitly as before; the trap firing there too is a harmless redundant call.
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 59s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Read new commit (f676872) changes
  • Verify Codex P1 on claim arbitration rollback (confirmed fixed)
  • Assess Codex P2 findings (link-blocks namespace validation, create-item body injection)
  • Post final review

What f676872 fixed

✅ Codex P1 — Claim rollback stays armed through arbitration (fixed)

adapters/github/claim.sh:74–119

The prior code disarmed trap - EXIT immediately after our_comment_id was set (step 3). A failure in the step-4 lease-list read would then exit with @me assigned and a live lease comment written, but no claim JSON returned to the caller — the same stranded-item state as the pre-lease window, just one step later.

The fix correctly defers the disarm:

  • Line 112: trap - EXIT fires on same-login race (@me legitimately stays; the winning lease belongs to the same login)
  • Line 119: trap - EXIT fires after arbitration confirms we hold the earliest live lease (clean win)
  • Different-holder conflict (lines 109–110): @me is removed explicitly before exit "$EX_CONFLICT", which then also fires the (now-idempotent) EXIT trap — double-remove is safe given || true

The comment at lines 41–47 explaining why it must be an EXIT trap (not ERR) is accurate and appropriately preserved from the prior commit.


Outstanding — two Codex P2 findings remain open

P2 — link-blocks.sh silently resolves foreign-namespace IDs against the local store

adapters/local-markdown/link-blocks.sh:29–38

wit_require_local_id "$blocker" || wit_usage_error "..."   # validates provider only
blocker_number="$WIT_ID_NUMBER"
...
wit_item_exists "$blocker_number" || ...                    # checks number-only file path

wit_require_local_id validates that the provider is local-markdown but not that the namespace matches. local-markdown:other/repo#1 passes the check, sets WIT_ID_NUMBER=1, and wit_item_exists "1" then checks $WIT_STORAGE_DIR/1.md — which may be local-markdown:local/markdown#1 in the actual store.

wit_emit_local_item resolves blockers via ${bid##*#} (number-only), so the foreign-namespace Blocked by: local-markdown:other/repo#1 line is silently matched against the local item with number 1, incorrectly inflating blocked_by_count and potentially hiding items from the frontier. This contradicts the declared cross_repo_edges: false capability, which documents that foreign-namespace IDs "must remain text pointers, never a resolvable edge."

Fix: validate the namespace in link-blocks.sh after wit_require_local_id:

[[ "$WIT_ID_OWNER/$WIT_ID_REPO" == "${WIT_LOCAL_DEFAULT_NS}" ]] \
  || wit_usage_error "cross-namespace blockers are not supported (cross_repo_edges: false)"

Or reject in wit_require_local_id by adding a namespace parameter. Confirm what the valid namespace is (note --repo overrides at create time make this per-item, not store-wide).

Fix this →


P2 — create-item.sh body text is parsed for structural dependency edges

adapters/local-markdown/create-item.sh:115 / common.sh:129

[[ -n "$body" ]] && printf '%s\n\n' "$body"     # written verbatim before dep lines
for blocker in "${blocker_ids[@]...}"; do
  printf 'Blocked by: %s\n' "$blocker"          # structured dep section follows
done
wit_blocked_by_ids() {
  sed -n 's/^Blocked by: //p' "$1"              # scans the entire file
}

A body containing Blocked by: local-markdown:local/markdown#1 is written verbatim to the file. wit_blocked_by_ids then interprets it as a structured dependency (same ^Blocked by: prefix), inflating blocked_by_count and potentially hiding items from the frontier via list-frontier — even though --blocked-by was never passed. The dependency record and the body text are indistinguishable at read time.

This is latent today (agent-generated bodies rarely contain the literal pattern), but it creates structural ambiguity in the file format. A minimal defence would be to strip any Blocked by: prefix-matching lines from --body before writing, or to demarcate the structured-deps section with a comment sentinel.

Fix this →


Summary

The Codex P1 (claim arbitration rollback) is correctly fixed in f676872 — the reasoning, execution, and same-login edge-case handling are all solid. The two Codex P2s (foreign-namespace resolution in link-blocks, body-text injection in create-item) remain open. Neither is merge-blocking for a draft that is already local-markdown-only for the offline path and requires human review before merge — but both should be addressed before this ships as 0.8.0. The gh-bot.sh identity gap (dependency #1) is unchanged and still documented as intentional-for-this-PR.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Thanks for confirming. One more round landed since this review: codex caught a follow-on gap in the claim.sh rollback itself (disarming right after the lease-comment write reopened an equivalent stranding window one step later, at the arbitration read) — fixed in f676872, same EXIT-trap mechanism, now disarmed only at the two outcomes where keeping @me is correct (same-login-race backoff, final success). Codex's fresh pass also flagged two P2s on pre-existing, byte-identical-moved local-markdown code (link-blocks.sh foreign-namespace validation, create-item.sh body-text/dependency-record ambiguity) — classified VALID (defer): real, but out of this PR's scope (unmodified moved code, each needing a store-format design decision, not a quick patch). Recommending both as follow-up issues rather than expanding this PR.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f676872e4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Reconcile the work-items setup skill onto main's uniform check|apply contract
while preserving this branch's provider-binding deliverable: apply binds the
provider first (before the schedule and role-label passes), and check gains a
read-only binding probe. plugin.json -> 0.12.0 (above main's current 0.11.0);
CHANGELOG keeps main's 0.11.0 check/apply entry and adds a 0.12.0 entry for the
bundle + provider-binding-via-apply.
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 46s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Identify what changed since last review (merge commit ce3c0c9 from origin/main)
  • Verify all prior fixes are still intact
  • Check for regressions introduced by the merge
  • Review deferred P2/P3 findings — confirm deferred vs. new state
  • Post final review summary

What the merge commit introduced

The merge (ce3c0c9: "Merge origin/main into feat/work-items-seam-shape-a") reconciles the work-items setup skill onto main's uniform check | apply contract (0.11.0 wave) while preserving this branch's provider-binding deliverable. Five work-items files changed:

  • plugin.json — version 0.11.0 → 0.12.0 (correctly above main's 0.11.0)
  • CHANGELOG.md — adds the 0.11.0 check/apply entry from main; updates 0.12.0 to describe the combined work
  • README.md — skills table and requirements section updated to document check | apply
  • skills/setup/evals/evals.json — all 4 eval prompts updated from /work-items:setup to /work-items:setup apply
  • skills/setup/SKILL.md — major restructuring; full review below

All prior P1/P2/Security fixes confirmed intact

Verified against the live files at HEAD:

  • lib/binding.sh — relative storage_dir rooting (lines 55–57), provider path-traversal guard (line 44), WIT_HUMAN_GATED_LABEL export (lines 61–66): all intact. ✅
  • lib/frontier.shwit_filter_frontier accepts [<human-gated-label>] as second arg, uses $human_gated instead of the literal needs-human string (line 28): intact. ✅
  • adapters/github/claim.sh — EXIT trap armed after step 1 (line 51), stays through arbitration, disarmed only at same-login-race (line 112) and clean-win (line 119): intact. ✅
  • adapters/local-markdown/capabilities.jsonreclaim: false; skills treat exit 6 as graceful degradation: intact. ✅

setup/SKILL.md — merge changes

The restructuring is correct and well-integrated. Specific observations:

check | apply sequencing is right. apply steps are: (1) bind provider first, (2–7) schedule interview/write, (8) re-verify. Provider binding precedes the schedule and role-label passes exactly as the commit message describes. No-argument defaults to check (read-only) — a safe default for a setup skill.

check probes are comprehensive. Six probes: jq gate, binding validity (schema_version, provider, lease_ttl_hours, storage_dir for local-markdown, consumer-local adapter resolution), schedule presence, schedule reconciliation-key uniqueness (id AND title), git check-ignore tracking check, and recurring-maintenance role label. Each correctly maps to PASS / FAIL / INFO with a remediation line per FAIL.

✅ Evals correctly updated. All 4 evals were testing the old no-arg=apply behavior and have been updated to /work-items:setup apply. The semantic gap they tested (interview/seed/reconcile) is now squarely in apply, so the prompts are accurate.

check step 2 validates consumer-local adapters. "…or a consumer-local one at ${CLAUDE_PROJECT_DIR}/tools/work-item-tracker/adapters/<provider>/" — the check tests resolution, not just provider name, which matches the two-rule resolver in CONTRACT.md.

apply step 8: verify after remediation. Re-running the check probes on the written files before reporting success is the right discipline. Consistent with how other plugins close the apply loop.


Minor observations (neither merge-blocking nor new)

Evals gap — no check-mode cases. The 4 evals all test apply. The new check mode has 6 probes with distinct PASS/FAIL/INFO outcomes — a check eval (e.g. "binding absent → reports INFO with apply remediation, modifies nothing") would give regression coverage for the read-only path. Low priority for this draft but worth adding before 0.12.0 ships.

check probe 6 — local-markdown has no label registry. The step says "verify the resolved label is present via the adapter's label listing (GitHub adapter: gh label list)". For local-markdown, there is no label registry (labels are strings in item frontmatter only), so this probe is effectively GitHub-specific. The SKILL.md doesn't document the fallback behavior for local-markdown here — in practice the model will need to infer "skip this live check for non-github providers." A note like "for providers without a label registry (local-markdown), this sub-check is skipped" would remove ambiguity. Not a correctness bug for the current adapter set, since local-markdown is the offline reference provider and the recurring use case is primarily github.


Acknowledged — deferred findings remain open (unchanged from last review)

Finding Location Status
link-blocks.sh silently resolves foreign-namespace IDs adapters/local-markdown/link-blocks.sh:38 Deferred follow-up issue (pre-existing moved code; needs store-format design decision)
create-item.sh body text parsed as structural dependency adapters/local-markdown/create-item.sh:115 Deferred follow-up issue (pre-existing moved code; same design-decision scope)
local-markdown/claim.sh stale assignee after lease expiry adapters/local-markdown/claim.sh:48 Deferred follow-up issue (consequence of exit-6 reclaim gap; needs local reclaim design decision)
adapters/github/common.sh gh-bot.sh always falls back to bare gh adapters/github/common.sh:20 Intentional-for-this-PR; dependency #1 / option (c) follow-up

All four are tracked and confirmed not modified by the merge.


Summary

The merge correctly integrates the 0.11.0 check | apply contract from main with this branch's provider-binding deliverable. All four prior P1/P2 fixes are intact. The check mode is well-specified and the apply step ordering is correct. The two minor observations above (no check evals, local-markdown label-registry ambiguity in probe 6) are non-blocking for a draft gated on human review.

The PR remains draft-status pending: live GitHub conformance run against a throwaway sandbox, human confirmation that bare-gh attribution (dependency #1) is acceptable for this release, and the runtime verification of the ${CLAUDE_PLUGIN_ROOT}-canonical resolver in the target consumer.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce3c0c97f1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Both adapters' common.sh sourced the shared seam libs (id/json/lease) relative
to the adapter's own dir. When a consumer shadows an adapter with a local copy
(the resolver's supported consumer-local path) that cannot resolve the bundled
lib/, the source failed silently (no set -e) and a verb could emit a malformed
empty-id record with exit 0 — silent store corruption.

Verify each required seam lib exists before sourcing; on a miss, print a
diagnostic and exit 3 (setup/config error). The seam-wide design question of how
a shadowed adapter should resolve shared libs is tracked as a follow-up.
@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 10s —— View job


PR Review — feat(work-items): bundle work-item-tracker seam in plugin (shape A)

  • Load tools and gather context
  • Identify what changed in latest commit (9b8ed32)
  • Verify the P1 fix for adapter shadow / missing seam lib (common.sh:35)
  • Confirm all previously-resolved findings remain intact
  • Assess remaining open findings
  • Post final review

What 9b8ed32 fixed

✅ Codex P1 — Hard-fail adapters on a missing shared seam lib (fixed)

adapters/github/common.sh:20–35 / adapters/local-markdown/common.sh:26–41

Both adapters sourced lib/id.sh, lib/json.sh, and lib/lease.sh relative to $ADAPTER_DIR/../../lib/. For a consumer-local shadow (the resolver's documented use case), those relative paths fall outside the plugin bundle and silently fail to source — leaving a verb to emit a malformed empty-id record with exit 0, silently corrupting the store.

The fix adds wit_gh_require_seam_lib() / wit_local_require_seam_lib() — a guard function called before each source:

wit_gh_require_seam_lib() {
  [[ -f "$1" ]] && return 0
  printf 'work-item-tracker github adapter: required seam library not found: %s\n' "$1" >&2
  printf '  a shadowed consumer-local adapter must still resolve the bundled seam lib/ directory\n' >&2
  exit 3
}

Three implementation details confirmed correct:

  • Runs before EX_* constants: The comment explicitly calls this out. The literal 3 is used (matching EX_CONFIG=3), since the constants aren't yet defined at the point of the call. ✅
  • Idempotency guard interaction: Both files open with [[ -n "${_WIT_*_COMMON_LOADED:-}" ]] && return 0. On subsequent sourcing, the file returns before the require calls — correct, the libs are already loaded. On first load, requires run in the right order (function defined at line 20–25, first call at line 27). ✅
  • Applies to both adapters: github/common.sh and local-markdown/common.sh receive identical protection. ✅

The diagnostic is clear and actionable — tells the user exactly what file was missing and why (shadowed adapter can't reach the plugin's bundled lib/).

One minor note: there are no new automated tests for the guard itself. The commit message acknowledges the seam-wide design question (how a shadowed adapter should correctly resolve shared libs) as a follow-up. The guard is a safe, minimal intervention for the current PR's scope — the behavior is deterministic and not worth a complex test fixture for a simple [[ -f ]] check.


All prior fixes confirmed intact

Verified against the current HEAD:

  • lib/binding.sh — relative storage_dir rooting (lines 55–57), provider path-traversal guard (line 44), WIT_HUMAN_GATED_LABEL export (lines 61–67): all intact. ✅
  • lib/frontier.shwit_filter_frontier accepts $human_gated as second arg (line 28), uses it instead of the hardcoded literal: intact. ✅
  • work-item-tracker.sh — passes "${WIT_HUMAN_GATED_LABEL:-needs-human}" to wit_filter_frontier (line 160): intact. ✅
  • adapters/github/claim.sh — EXIT trap armed after step 1 (line 51), disarmed at same-login-race (line 112) and clean-win (line 119): intact. ✅
  • adapters/local-markdown/capabilities.jsonreclaim: false, skills handle exit 6 gracefully: intact. ✅

Deferred findings — unchanged, all tracked

Finding Location Tracked
gh-bot.sh falls back to bare gh (intentional) adapters/github/common.sh:37 #365
link-blocks.sh silently resolves foreign-namespace IDs adapters/local-markdown/link-blocks.sh:38 #366
create-item.sh body text parsed as structural dependency adapters/local-markdown/create-item.sh:115 #366
local-markdown/claim.sh stale assignee after lease expiry adapters/local-markdown/claim.sh:48 #367
local-markdown/claim.sh wit_fm_set failure not propagated adapters/local-markdown/claim.sh:62 #367

None of these files were touched by 9b8ed32. All remain on their tracked follow-up issues.


Summary

9b8ed32 correctly closes the Codex P1 flagged in the last round — the store-corruption risk on consumer-local adapter shadows is eliminated, with a clear diagnostic on failure and the right exit code. All prior P1/P2/Security fixes are intact. No regressions found.

The PR remains draft-status pending: live GitHub conformance run against a throwaway sandbox, human confirmation that bare-gh attribution (dependency #1, tracked in #365) is acceptable for this release, and runtime verification of the ${CLAUDE_PLUGIN_ROOT}-canonical resolver in the target consumer. No code correctness issues remain.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b8ed32795

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kyle-sexton
kyle-sexton merged commit 57c4f4b into main Jul 19, 2026
16 checks passed
@kyle-sexton
kyle-sexton deleted the feat/work-items-seam-shape-a branch July 19, 2026 03:28
kyle-sexton added a commit that referenced this pull request Jul 19, 2026
Resolves the work-items collision with #224 (seam shape A landed
0.12.0): main's version and description win; this branch's
cross-plugin token update rides on top as 0.12.1 with its changelog
entry re-stacked above main's 0.12.0. New #224 files verified free of
old invocation tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 19, 2026
- orchestration.md: replace repo-specific PR-number example
  (medley#1567/#224 couplings) in Cross-PR Dependency Signalling with a
  generic `owner/repo#123 ↔ #456` placeholder, per the repo-agnostic design
  rule in CLAUDE.md; also fixes the awkward mid-sentence line wrap.
- pr_queue_snapshot.py: exit-code-3 docstring now states state is still
  written when requested, matching the explicit write contract already
  documented for codes 0 and 1 (write_state runs unconditionally on
  --write-state, before exit_code_for).
kyle-sexton added a commit that referenced this pull request Jul 19, 2026
…rule, snapshot exit-code taxonomy, cross-PR dependency channel (#456)

Closes #373.

Implements the four enhancements plus one gotcha from issue #373 in the
`babysit-prs` skill. The source retrospective the issue cites
(`.work/handoffs/babysit-autopilot-retro.md`) is not present in the
checkout
(`.work/` is gitignored), so every edit was driven by the issue body
plus the
live skill files; the retro is provenance only. All doc edits are
pointer-style
(they point at the existing rule/clause, never restate it).

## (a) No-monitor hard STOP at the merge / gate-completion step

Added in both `SKILL.md` (Guarded mutations) and `reference/safety.md`
(Pinned-Command Degradation): once a PR is proven ready, or its merge is
deferred to a human, report and stop — never arm a watch/monitor. Both
point
at the existing no-background-monitor clause in the Worker Contract
(`reference/orchestration.md`) rather than restating it.

## (b) Bare-wrapper pipe rule

`reference/safety.md` Guarded Mutation Wrappers now states that the
wrapper's
JSON must be parsed in a separate step — never `wrapper | python` / `|
jq` —
because an interpreter-in-pipeline trips the auto-mode safety classifier
and
blocks the call before the wrapper runs.

## (c) Snapshot exit-code taxonomy

`scripts/pr_queue_snapshot.py` previously collapsed every non-fatal
error into
exit `1`, so a purely-advisory head-ref alias-check error was
indistinguishable from a substantive per-PR hydration failure. The
split:

| code | meaning | state written |
| --- | --- | --- |
| `0` | valid snapshot, no errors | yes |
| `1` | valid snapshot, ≥1 substantive error (per-PR hydration or
discovery failure) | yes |
| `2` | fatal — exception before a snapshot existed | no |
| `3` | valid snapshot, advisory-only (head-ref alias cross-check)
errors | yes |

Substantive errors take precedence over advisory (a run with both
returns `1`).
The advisory marker is a single-source constant
(`babysit_delta.HEAD_REF_ALIAS_ERROR_MARKER`) used at both
message-construction
sites (queue path in `babysit_delta.py`, single-PR path in
`pr_queue_snapshot.py`) so they cannot drift. Taxonomy documented in the
module
docstring; new `scripts/tests/test_pr_queue_snapshot.py` covers 0/1/2/3
including the advisory-vs-substantive distinction and precedence.

**Consumers checked** (exit codes are a caller contract): every `*.md`,
`*.sh`,
`*.py`, `engine.test.sh`, orchestration doc, and `bin/` wrapper
referencing the
snapshot. No live consumer keys on the script's `$?` — the docs
(`orchestration.md`, `SKILL.md`, `loop.md`) invoke it and parse the JSON
snapshot; `babysit-python.sh` uses `exec` (child exit code passes
through
unchanged); no `bin/` wrapper or sibling script branches on it.
Introducing `3`
therefore breaks no caller that treated `1` as "any error".

## (d) Worker→main cross-PR dependency channel

New `Cross-PR Dependency Signalling` section in
`reference/orchestration.md`
formalizing a worker signalling a discovered cross-PR coupling (e.g.
medley#1567 ↔ #1597, #224#356) back to the main agent, which owns
cross-PR
ordering. Points at the existing `SendMessage` mechanism (documented
there for
main→worker), explicitly reversed.

## Gotcha: self-blocking CI check

Recorded in the `SKILL.md` Gotchas list: a newly required check whose
own fix
PR carries that same check cannot be gate-merged and needs a one-time
human
admin-merge bootstrap. The concurrent-same-identity yield rationale is
already
covered by the Checkout/Push invariants (head-SHA recheck + worker lease
+
`foreign_activity` suppressor) and is deliberately not duplicated.

## Verification

- `bash engine.test.sh`: `Ran 186 tests … OK`; ruff `All checks
passed!`;
  guarded-wrapper behavior checks all PASS.
- markdownlint-cli2 on the four changed `.md` files: 0 issues.
- Plugin bumped `0.9.0` → `0.9.1` (`jq -e .version` confirms) with a
matching
  `## [0.9.1]` CHANGELOG entry.
- No trailing whitespace / final-newline issues in any changed file.

## Related

- #322 (PR-B) — retro source for these enhancements.
- #324 — sibling review-trigger reconciliation (F8 posting-guard
follow-on territory).
- #326, #327 — same babysit-prs safety-hardening cluster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…#680)

## Summary

Follow-up from #224. Closes two related lease/assignee-integrity gaps in
the `local-markdown` adapter's `claim.sh` (relocated byte-for-byte from
medley by #224, not changed there), handled as the single hardening pass
the issue calls for. No new adapter capability is introduced — this
implements the issue's recommended, less-invasive candidate for Finding
1 (list-derivation projection), so it is a patch bump (`0.14.1` →
`0.14.2`).

## Fix

**Finding 1 (P1) — expired lease never cleared the assignee, so
abandoned claims never returned to the frontier.** An expired lease
still left `assignees` populated. Because `reclaim` is unsupported for
this offline adapter (it has no coordination surface to run an activity
check over) and the core `list-frontier` derivation always excludes
assigned items, any abandoned local claim was permanently absent from
selection once its TTL expired.

- `list-items` now projects an expired-lease item's **effective**
assignee as empty (`wit_emit_local_item` gained an opt-in
`unassign-expired` mode), so the core frontier filter — which keys on
`assignees` — returns the item to selection. No new capability, no
stored mutation.
- The projection is scoped to list/frontier derivation. `get-item` still
reports the **stored** assignee verbatim, preserving parity with the
GitHub adapter (whose assignee persists until `reclaim` runs) and
keeping the raw claim record inspectable.
- `CONTRACT.md`'s local-markdown degradation note now documents this
offline compensation.

**Finding 2 (P2) — a failed assignee write still reported a successful
claim.** `claim.sh` appended the inline lease marker and then called
`wit_fm_set … assignees` with no return-code check and no `set -e`, so a
failed assignee write (store full/unwritable) was silently ignored and a
success JSON was still emitted — leaving a live lease marker with an
empty `assignees`, which `list-frontier` presents as available while
later claims conflict on the live lease until it expires.

- The marker append and assignee write are now one consistent operation
(`wit_claim_write`): on a failed assignee write it best-effort rolls the
just-appended marker back and fails the claim with **exit 1**
(internal/unexpected store I/O, distinct from an exit-7 claim race),
emitting no success record for a half-applied write.

## Verification

New `adapters/local-markdown/claim-integrity.test.sh` covers both
findings — Finding 1 end-to-end through the core CLI (`list-frontier`),
Finding 2 as a deterministic, OS-portable unit test of the rollback (the
abstract conformance suite runs against every adapter and cannot hold
local-markdown-specific or fault-injection cases). All checks run via
the repo harness (`plugins/**/*.test.sh`).

New test (`bash claim-integrity.test.sh`), exit 0:

```
PASS: [1] expired-lease A returns to the frontier
PASS: [2] live-lease B stays out of the frontier
PASS: [3] get-item leaves the expired-lease assignee stored
PASS: [4] successful claim write returns 0
PASS: [5] successful claim write keeps the lease marker
PASS: [6] successful claim write records the assignee
PASS: [7] failed assignee write fails the claim
PASS: [8] failed assignee write rolls the lease marker back
PASS: [9] failed claim leaves assignees empty
```

Full local-markdown conformance still green, including the offline
gh/curl-blocking shim (`run-conformance.sh --binding local-markdown`):

```
Conformance (local-markdown): 61 cases, 0 failed
```

ShellCheck clean on all changed scripts (`--rcfile .shellcheckrc`);
every `plugins/work-items/**/*.test.sh` passes; changed files carry no
trailing whitespace, tabs, or comment issue/TODO markers.

Closes #367

## Related

- #367 — this issue.
- #224 — the bundle-seam PR that relocated this code (shape A); both
gaps were deferred there by author delegation.

🤖 Generated with a Claude Code implementation subagent (issue #367)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…686)

## Summary

Follow-up from #224. Closes two lease-coordination correctness gaps in
the GitHub adapter's `renew-lease.sh` and `reclaim.sh` — both in code
relocated byte-for-byte from medley by #224 (not changed there), handled
as the single hardening pass the issue calls for. No new adapter
capability; a bug fix, so a patch bump (`0.14.2` → `0.14.3`). This is
the GitHub-adapter sibling of #367 (local-markdown), shipped earlier
this session.

## Fix

**Finding 1 (P1) — `renew-lease` revived an expired lease.**
`renew-lease` selected the active (newest non-superseded) lease,
confirmed `active_id == lease_comment_id`, and PATCHed a fresh
`renewed_at` — but never checked liveness first. A worker retaining its
handle past `renewed_at + ttl_hours`, with no newer lease comment, still
matched `active_id`, so the PATCH revived an expired lease and defeated
TTL-based handoff: a crashed/delayed worker could reclaim an item
another worker had reasonably treated as expired.

- `renew-lease.sh` now checks `wit_lease_is_live` immediately before
patching and returns a conflict (`exit 7`) for an
expired-but-not-superseded active lease instead of reviving it. Recovery
from an expired lease is a fresh claim/reclaim, not a revive of the dead
handle.

**Finding 2 (P1) — `reclaim` removed assignees that did not hold the
expired lease.** On the expired-lease + no-activity path, `reclaim` read
every assignee (`[.assignees[].login]`) and removed each one — not just
the lease's `holder`. A user manually assigned after the old lease, or a
concurrent claimer added before this assignee snapshot, was silently
unassigned; in the concurrent case that claimer's live lease stayed
while the frontier treated the item as unassigned (two workers on one
item).

- `reclaim.sh` now removes **only the expired lease's `holder`**
(guarded on a fresh assignee read so an already-unassigned holder is a
no-op), leaving co-assignees untouched.
- Ownership is **revalidated immediately before mutating**: the lease
was selected before the two activity round-trips, a TOCTOU window in
which a concurrent claimer can renew or supersede it. `reclaim` re-reads
the leases and confirms this is still the active *and still expired*
lease; if a concurrent claim won the item in that window, reclaim is a
no-op (`reclaimed: false`) — never a mutation that strips the new owner.

Both verbs now share `wit_select_active_lease` (extracted to
`lib/lease.sh`) instead of a third copy of the active-lease selection
loop. `CONTRACT.md`'s "Lease protocol" documents both semantics (renew
refuses an expired lease; reclaim is holder-only and revalidated).

## Verification

New offline test `adapters/github/lease-coordination.test.sh` drives
both fixes against a stubbed `gh` (the GitHub-adapter conformance
binding requires a live target, so these decision-path cases live in a
dedicated test — mirroring the sibling's `claim-integrity.test.sh`
precedent). It asserts on the recorded write/remove call-log, not just
exit codes.

Against the fix — `bash adapters/github/lease-coordination.test.sh`,
exit 0 (cases 14–17 cover the concurrent in-place-renew revalidation
branch, added per review):

```
PASS: [1] renew-lease returns conflict (7) for an expired active lease
PASS: [2] renew-lease does NOT revive the expired lease (no PATCH)
PASS: [3] renew-lease succeeds (0) for a live active lease
PASS: [4] renew-lease emits the renewed lease record
PASS: [5] renew-lease PATCHes the live lease comment
PASS: [6] reclaim succeeds (0)
PASS: [7] reclaim reports reclaimed:true
PASS: [8] reclaim removes the expired lease holder (alice)
PASS: [9] reclaim leaves the co-assignee (bob) untouched
PASS: [10] reclaim succeeds (0) on a concurrent-claim revalidation
PASS: [11] reclaim reports reclaimed:false when the lease changed under it
PASS: [12] reclaim removes NO assignee when revalidation fails
PASS: [13] reclaim does not supersede when revalidation fails
PASS: [14] reclaim succeeds (0) when the lease was renewed in place during the window
PASS: [15] reclaim reports reclaimed:false when the lease was renewed in place
PASS: [16] reclaim removes NO assignee when the lease was renewed in place
PASS: [17] reclaim does not supersede when the lease was renewed in place
```

Discrimination proof — the (13-case) test against the **pre-fix**
`renew-lease.sh`/`reclaim.sh` at `origin/main` (exit 1), confirming the
cases fail without the fix and are not passing for the wrong reason:

```
FAIL: [1] renew-lease returns conflict (7) for an expired active lease — expected 7 got 0
FAIL: [2] renew-lease does NOT revive the expired lease (no PATCH) — expected no PATCH logged got PATCH logged
PASS: [3] renew-lease succeeds (0) for a live active lease
PASS: [4] renew-lease emits the renewed lease record
PASS: [5] renew-lease PATCHes the live lease comment
PASS: [6] reclaim succeeds (0)
PASS: [7] reclaim reports reclaimed:true
PASS: [8] reclaim removes the expired lease holder (alice)
FAIL: [9] reclaim leaves the co-assignee (bob) untouched — forbidden REMOVE_ASSIGNEE bob present
PASS: [10] reclaim succeeds (0) on a concurrent-claim revalidation
FAIL: [11] reclaim reports reclaimed:false when the lease changed under it — expected false got true
FAIL: [12] reclaim removes NO assignee when revalidation fails — forbidden REMOVE_ASSIGNEE present
FAIL: [13] reclaim does not supersede when revalidation fails — forbidden PATCH present
```

Direct unit coverage of the extracted helper added to
`lib/lease.test.sh` (newest-non-superseded selection, superseded
back-off not masking an earlier active lease, empty out-params when
none) — 15/15 pass. Full `plugins/work-items/**/*.test.sh` sweep green;
ShellCheck clean (`--rcfile .shellcheckrc`) on every changed script;
changed files carry no trailing whitespace or tabs.

Closes #370

## Related

- #370 — this issue.
- #224 — the bundle-seam PR (shape A) that relocated this code
byte-for-byte; both findings were deferred there by author delegation.
- #367 — the sibling `local-markdown` lease/assignee-integrity fix
shipped earlier this session (same lease-integrity class, different
adapter).

🤖 Generated with a Claude Code implementation subagent (issue #370)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 21, 2026
… of adapter location (#826)

## Summary

- The GitHub adapter's `WIT_GH_BOT` resolved the bot wrapper relative to
the adapter's own directory
(`${CLAUDE_PLUGIN_ROOT}/tools/github-auth/gh-bot.sh` in the normal
bundled path), never checking the consumer-local override path
CONTRACT.md already documented
(`${CLAUDE_PROJECT_DIR}/tools/github-auth/gh-bot.sh`).
- As a result, a consuming repo's own bot wrapper was never found, and
all tracker writes (item create, lease/reclaim comments) silently fell
back to the ambient `gh` (session-user) identity instead of bot
attribution.

## Fix

- `common.sh`: extracted `wit_gh_resolve_bot_wrapper`, which now
resolves the bot wrapper **consumer-local-first, plugin-bundled
fallback** — checking
`${CLAUDE_PROJECT_DIR}/tools/github-auth/gh-bot.sh` first (independent
of where the adapter itself resolved from, so a shadowed consumer-local
adapter still finds the consumer's wrapper), then falling back to the
bundled path beside the seam tree. This mirrors the existing two-rule
adapter resolution documented in CONTRACT.md's "Adapter resolution"
section.
- `CONTRACT.md`: updated the "Identity routing (GitHub adapter)" section
to document the corrected consumer-local-first/plugin-bundled-fallback
resolution order (previously it documented the buggy
adapter-relative-only behavior).
- `common.test.sh`: added regression coverage for
`wit_gh_resolve_bot_wrapper` — resolves the consumer-local wrapper when
present, falls back to the bundled path when the consumer has none, and
falls back when `CLAUDE_PROJECT_DIR` is unset.

## Verification

Full adapter + dispatcher test suite run, all green:

```
$ bash adapters/github/common.test.sh
PASS: [1] common.sh exposes gh_write
PASS: [2] common.sh exposes wit_run_gh
PASS: [3] common.sh exposes wit_resolve_repo
PASS: [4] common.sh exposes wit_emit_item
PASS: [5] common.sh exposes wit_lease_json
PASS: [6] common.sh exposes wit_lease_is_live
PASS: [7] common.sh exposes wit_list_lease_comments
PASS: [8] common.sh exposes wit_help_if_requested
PASS: [9] common.sh exposes wit_map_gh_error
PASS: [10] lease marker constant
PASS: [11] 404 → not-found (5)
PASS: [12] rate limit → unavailable (8)
PASS: [13] resolves consumer-local wrapper first when present
PASS: [14] falls back to bundled path when consumer has none
PASS: [15] falls back to bundled path when CLAUDE_PROJECT_DIR unset
```

All other `adapters/github/*.test.sh` files and
`work-item-tracker.test.sh` (including its own
consumer-local/adapter-resolution cases) also pass unchanged.

`shellcheck` on both modified shell files: no findings.

Closes #365

## Related

- #365
- Originates from the #224 review thread (deferred dependency #1, option
(c))

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant