Ship the suite as a Claude Code marketplace plugin (repo move, plugin root, CLAUDE_PLUGIN_ROOT paths, namespacing) #50
Labels
No labels
component:adapters
component:lifecycle
component:qa
component:setup
component:shared
component:worktrees
phase/accepted
phase/backlog
phase/deployed
phase/developing
phase/integrating
phase/planning
phase/qa
phase/requirements
phase/uat
priority:critical
priority:critical
priority:high
priority:high
priority:low
priority:low
priority:medium
priority:medium
type:bug
type:chore
type:docs
type:feature
type:infra
type:tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jbr870/devwork-skills#50
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PREQ: Ship the suite as a Claude Code marketplace plugin
Created: 2026-08-15
Problem
The suite operator struggles to keep the SDLC suite consistent, versioned, and honestly dogfooded
across two machines when the suite is distributed as a hand-maintained symlink farm.
Today the suite's source lives at
~/.claude/repos/devwork-skills— a product's source inside theruntime's config directory — and reaches Claude Code through 15 symlinks
(
~/.claude/skills/<x> → ../repos/devwork-skills/.claude/skills/<x>). The desktop (jochems-forge)runs a second clone of the same farm, synced by manual
git pull, with no versioning: nothingrecords which suite state a run executed, and stale checkouts are silent. Worst, the dev form is
not the shipped form — the farm hides exactly the failure classes real distribution introduces
(path resolution, namespacing, meta-lane fencing), so packaging regressions would surface on the
consumer machine instead of where development happens. The shipped/repo-local fence is convention
only: nothing structurally prevents a consumer from invoking the repo-local
meta-*skills.Users:
dev machine; consumes it on the desktop.
this repo itself) whose CLAUDE.md guidance blocks and operators invoke the suite's skills.
Current state: manual symlink farm on both machines; manual-pull sync; bare skill names; no
version identity; meta lane fenced by convention only.
Proposed Solution
Package the suite as a Claude Code marketplace plugin named
devand make the plugin form theonly form anyone runs:
promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan,
worktree-cleanup — plus
_sharedmove to a plugin root inside the repo; the repo carries itsown marketplace manifest. Repo-local material (meta skills,
scripts/, analysis docs, runreports,
.devwork/) never ships.including this repo's own dogfooding — exercises the shipped form and packaging regressions
surface where development happens.
(
github.com/jbr870/devwork-skills), updated by deliberate version bump. Unreleased suitechanges are dogfooded on the dev machine only — the desktop never runs anything but releases.
/dev:<skill>on every machine; barenames remain repo-local (meta lane), structurally unreachable from consumers.
~/.claude/repos/to~/Coding/devwork-skills, preserving project identity (memory, trust, MCP config), and everyconsumer project's CLAUDE.md guidance block on the cut-over machine is updated to
/dev:namesin the same disruption window.
version fields of the plugin manifest and the marketplace entry — unique version tag) starting
at 0.1.0, instead of a pull.
Scope: Full — plugin packaging, namespacing, both machine cutovers, and the repo move, in one
feature (one disruption window; the move was judged severable but bundling was chosen, and the
scope was re-confirmed against the panel's simpler-alternative case).
User Stories
that packaging regressions surface during development instead of on the consumer machine.
marketplace plugin so that every run's suite state is a known release instead of a silently
stale checkout.
/dev:namespace sothat shipped capability and repo-local meta tooling cannot be confused or cross-invoked.
that the shipped/internal fence is enforced by the loader, not by convention.
product's source no longer lives inside the runtime's config directory.
Acceptance Criteria
AC-1: Given the restructured repo, when the plugin and marketplace manifests are validated
with the plugin tooling's strict mode, then validation passes with zero errors and zero
warnings.
AC-2 (post-cutover; contingent on the P0.1 probe confirming plugin-skill
slash-invocability): Given a cut-over machine with the plugin loaded in its form for that
machine, when the operator lists skills AND when the operator invokes each of the 14 shipped
skills as
/dev:<skill>, then all 14 appear in the listing and each invocation resolves —and no shipped skill resolves under its bare name. Both listing and invocation are checked
on each machine's form.
AC-3: Given the shipped plugin's contents, when scanned mechanically, then they contain zero
references to
~/.claude/skills/paths, zero bare_shared/invocations (any_shared/reference — in a shell fence or in markdown text — not prefixed by the plugin-root
variable), and zero
/dev:<x>references where<x>is not one of the 14 shipped skills —and the mechanical scan itself is verified non-vacuous (it demonstrably sees a non-zero file
population).
AC-4: Given this repo, when the operator invokes
/meta-amendor/meta-retrospectivebybare name, then they run; and when the shipped plugin's contents are inspected, then no meta
skill or repo-local script is present in it.
AC-5: Given the plugin form loaded pre-cutover (coexisting with the farm — bare names may
still resolve during this window), when (a) a full
/dev:requirementsphase runs on ascratch issue of this repo (tea forge), (b) a worktree slot is created, (c) one meta-amend
cycle runs, and (d) one pipeline phase runs on castellan (gh-cli — the non-tea adapter leg),
then all four succeed with their forge records posted correctly per the forge contract: the
Phase Outcome present and parseable on the right issue, and the phase label advanced.
AC-6: Given the dev-machine cutover has run, when the operator starts a session in the moved
repo at
~/Coding/devwork-skills, then the suite works from the new location and projectidentity is preserved: memory loads, no trust re-prompt appears, and previously configured
MCP servers are still available.
AC-7: Given cutover verification fails on the dev machine, when the abort path runs, then
the pre-cutover state is restored (farm links, repo location, project identity) — and this
abort path has been rehearsed on a scratch copy before the real cutover. The desktop's
previous release remains installable from the remote as its recovery path.
AC-8: Given a cut release, when the desktop installs the plugin from the GitHub marketplace
source and runs a helper-invoking pipeline phase, then the install succeeds at the released
version and the phase completes with its forge records posted (same standard as AC-5).
AC-9: Given a subsequent release, when the desktop performs the update ritual, then it runs
the new version — the vN → vN+1 path is exercised, not just fresh install.
AC-10: Given a dirty working tree, disagreeing manifest version fields, or an
already-existing version tag, when a release is attempted, then the release tooling refuses
with a non-zero exit and no tag or install artifact is produced — each refusal condition
exercised separately.
AC-11: Given only the rewritten README — which enumerates the prerequisites (bash ≥ 3.2,
jq,git, the project's forge CLI) — the desktop cutover itself is executed followingonly the README's instructions; any step that needed outside knowledge is a README defect.
AC-12: Given a machine's cutover completed, when that machine is inspected, then no
symlink-farm links (and on the desktop, no suite clone) remain, every consumer-project
CLAUDE.md guidance block on it references shipped skills by
/dev:names only, and theREADME's consumer runbook carries the step by which the operator records the installed
release version against a run.
Out of Scope
plugin eval) — future issue.two sanctioned restorative text-behavior touches from the draft plan — the meta-helper path fix
and the adapter discovery-order hand-rewrite — are the only exceptions.)
version identity. Release-version attribution applies to the desktop only (AC-12).
decided against: unreleased dogfooding is dev-machine-only.
Dependencies
skills-dir plugin loading, strict validation, version tagging,
${CLAUDE_PLUGIN_ROOT}substitution. Its real contract was probed 2026-08-14/15 (recorded on issue #50), but three
behaviors remain unverified and are named as probe preconditions in the draft plan (P0.1
slash-invocability of plugin skills, P0.2 farm/plugin coexistence, P0.3 desktop install
credential path). P0.1 is decisive: if plugin skills are not invocable as
/dev:<skill>, therun halts with a Pending Decision for the operator — the command-wrapper fallback is not
pre-authorized.
github.com/jbr870/devwork-skills— the desktop's distribution source,including a working credential path from the desktop if the repo is private.
~/.claude/projects/,~/.claude.json) — the repo move rewritesproject-keyed state; identity preservation is an explicit criterion (AC-6).
phases; "no feature in flight on either machine" is asserted by the cutover script as a scripted
precondition, not just declared.
/technical-plan, not a binding SREQ.Timeline
Notes
ships from the working tree — verified hazard); the plugin name
devlocks before any referencerewrite (renaming re-churns every reference); historical records (run reports, retrospectives,
analysis docs) keep pre-migration names verbatim — audit integrity beats consistency.
(desktop). The dev machine catches skills-dir-form regressions; cache-form-specific regressions
are covered by the desktop's helper-invoking phase run (AC-8), not by dev-machine dogfooding.
slash-invocable); whether a running session hot-reloads skills-dir edits (affects
/meta-amenddesk-check wording only, new-session load is confirmed).
after adversarial review); plugin name
dev(operator's call over the recommendeddevwork);GitHub as the desktop's distribution source; desktop dogfooding of unreleased changes ends with
no escape hatch (dev machine only); P0.1 fallback not pre-authorized (stop and re-decide);
version attribution is desktop-only; cutover requires a rehearsed rollback path; consumer
guidance blocks updated in scope with AC; README verified by executing the desktop cutover from
it alone.
Technical Plan v2: Ship the suite as a Claude Code marketplace plugin (#50)
1. Context
The SDLC suite lives at
~/.claude/repos/devwork-skills, exposed on the dev machine via asymlink farm (
~/.claude/skills/<x> -> ../repos/devwork-skills/.claude/skills/<x>, 15 links =14 shipped skills +
_shared;meta-*are not linked — already project-scope only) and on thedesktop (jochems-forge) by a manually-pulled clone with the same farm. Target state:
~/Coding/devwork-skills[PREQ — bundling the move multiplies cutover risk;recommended but severable].
(
~/.claude/skills/devwork -> <repo>/plugin), desktop consumes a versioned marketplaceinstall from a git remote.
devwork:*= shipped; bare names = repo-local meta lane.2. Verified ground truth
From #50's probes (2026-08-14): skills-dir plugin auto-load, symlink support, live edits;
namespacing mandatory and identical across both forms;
${CLAUDE_PLUGIN_ROOT}substituted atload time in plugin skill markdown but empty in the Bash tool env; marketplace installs are
versioned working-tree snapshots; update is version-gated;
gitCommitSharecords HEAD(dirty-tree hazard);
claude plugin validate/tag/details/init/evalexist.Verified 2026-08-15 (plan + panel session):
origin(Gitea forge),github(github.com/jbr870/devwork-skills),desktop(ssh jochems-forge).
~/Codingis the projects root. Tree clean at1e6bd78.claude plugin marketplace add <url|path|github-repo> [--scope user|project|local] [--sparse].claude plugin validateexits 0 with warnings; only--strictfails — the gate must be--strict, and--strictrequiresauthorin plugin.json anddescriptionin themarketplace entry.
plugin.jsonversion wins at install; a marketplace-entryversionis silently ignored.skills/_shared/(no SKILL.md) is tolerated by--strictvalidate — no error, nowarning.
plugin.jsonalso accepts an explicit"skills": [...]array as an escape hatch.description:(the suite's shape) validates clean — no frontmatter churn.claude plugin tagalready enforces clean-tree, tag-uniqueness, and plugin↔marketplaceagreement (
--dry-run,--push,--remote).claude --plugin-dir <path>loads a plugin session-scoped, without touching~/.claude/skills.claude plugin details <name>resolves installed plugins; skills-dir resolution isunverified (only
plugin evaldocuments it).-home-jochem--claude-repos-devwork-skills, new-home-jochem-Coding-devwork-skills(no collision).~/.claude.jsonprojectsmap is keyedby absolute path and carries trust-dialog acceptance + MCP server config.
.git/worktreesis currently empty..devwork/readiness-report.mdhardcodes the old path.~/Coding/castellan/CLAUDE.mdcarries a devworkguidance block; jochems-forge consumers (verity, axana, outwrit, cunnan) unverified — ssh
unreachable from the drafting session.
Reference-rewrite inventory: 56
~/.claude/skills/...refs (22 files); 108 bare_shared/procedures/bin/...invocations; a large but unenumerated set of cross-skill slashrefs (the draft's "~500" overcounted: it includes
/simplify(separate user-level skill),built-ins (
/init,/login), retired names (/closeout,/decisions), and path-lookalikes(
/tmp,/health,/timeline) that must NOT be rewritten). Enumeration is a Phase 2 input,not a guess. The shipped set is exactly 14: develop, gh-cli, git-worktrees, glab-cli, integrate,
local-fs, promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan,
worktree-cleanup.
3. Approaches considered
A. In-place dual-form (plugin root mirrors
.claude/skills/): rejected — same-skilldouble-load hazard (verified) + guaranteed drift.
B. Big-bang restructure on the checked-out branch: rejected — the farm points into this
repo's working tree; the branch's
git mvwould kill the live suite mid-development.C. Restructure in an isolated worktree; staged, scripted cutover (chosen) — with the panel's
correction: worktree isolation alone only defers B's failure to the merge instant (the farm
dies when
mainfast-forwards, while/integrateis still executing from it). The cutover istherefore not "at integration" as a side effect — it is an explicit scripted sequence in
which the plugin form is loaded and verified before the merge, the merge runs under the plugin
form, and the farm is dismantled after (§6 Phase 5).
4. Decision record
devwork(recommended) [PREQ]devwork_root,.devwork/. Locking is for the PREQ; the plan only insists it be decided before Phase 2 (renaming re-churns every ref).plugin/subdirscripts/). Marketplacesource: ./plugin._sharedlocationplugin/skills/_shared/— now verified tolerated (--strictclean)plugin/_shared/fallback is withdrawn:_lib.sh:33/81resolves adapters as siblings of_shared, so that fallback needs helper code changes, violating §10 (layout-only). Escape hatch if discovery ever changes: explicit"skills"array in plugin.json.BASH_SOURCE+dirnameresolution; banrealpath,readlink -f,cd -P, andpwd -Pin the helper tier (lint). Grep confirms zero existing violations — this is a regression fence, not a fix.--scopechoice (user) documented at install.plugin.jsonis the authoritative version (marketplace entry mirrors it but is ignored by install — verified).scripts/release.sh(repo-local) is the only writer of both version fields, and delegates enforcement toclaude plugin tag(clean tree, manifest agreement, tag uniqueness) rather than reimplementing it: bump → lint →validate --strict→tag --dry-run→tag --push --remote github..claude/skills/. Their shipped-skill invocations usedevwork:names. Their helper calls cannot use${CLAUDE_PLUGIN_ROOT}(no substitution outside plugin scope) — they resolve the helper root once, before anycd, from the repo root they already run in:B="$(pwd)/plugin/skills/_shared/procedures/bin"(meta is repo-local and openly overfitted, so a repo-relative path is legitimate; the currentmeta-retrospectiveline-57 patterncd "$P" && .claude/skills/_shared/...is already broken today and gets fixed by this rule)..devwork/, run reports, analysis docs,scripts/stay at repo root, never ship..claude-plugin/marketplace.jsonat repo root.desktopgit remote loses its target. Options: accept (release-before-dogfood), or keep a dormant clone for slot runs. The plan recommends accept + release discipline, but this forecloses a real workflow and belongs to the PREQ.5. Architecture (target layout)
Consumption: dev machine
~/.claude/skills/devwork -> <repo>/plugin(skills-dir, live);desktop marketplace install (versioned cache).
${CLAUDE_PLUGIN_ROOT}= symlink path on dev,cache path on desktop; helpers must behave identically under both (AC-6).
Reference rewrite rules (Phase 2) — four classes plus an exclusion set
~/.claude/skills/<x>/...in shipped text (56)${CLAUDE_PLUGIN_ROOT}/skills/<x>/...— except refs that mean adapter discovery order (setup/SKILL.md:104-106,phase-outcome.md:49document project-scope-then-user-scope adapter override): hand-rewrite preserving the documented search order, and decide explicitly that the project-scope adapter override survives (it does —_lib.sh:97searches all locations)._shared/procedures/bin/...in shipped fences (108)${CLAUDE_PLUGIN_ROOT}/skills/_shared/procedures/bin/.../devwork:<name>only for the enumerated 14 (allowlist). Never rewrite:/simplify,/simplify-adjacent user skills, built-ins, retired names, path-lookalikes. Lint rejects any/devwork:Xwhere X ∉ allowlist..claude/skills/meta-*cd); shipped-skill names →devwork:form; also amendmeta-amend's lane gate (.claude/skills/**/*.md→plugin/skills/**/*.md+ meta) andmeta-retrospective:13.sdlc-*.md,sdlc-dogfood-run-reports/,sdlc-retrospectives/,.devwork/— historical records keep pre-migration names verbatim (audit integrity). The rewrite script takes the exclusion set as explicit input and is kept inscripts/for audit..claude/skills/sometimes means the consumer project's skills dir (learnings.md:81,learning.v1.md:64,74,git-worktrees/SKILL.md:160,262) — those stay;glab-cli/SKILL.md:184means the plugin's own file — moves. The rewrite is classifier-assisted + per-file diff review, not blind sed (R2).Substitution constraint (verified):
${CLAUDE_PLUGIN_ROOT}works only as load-time textsubstitution in plugin markdown (fences included) and is NOT a runtime env var. Emitted artifacts
that outlive the fence (temp
.sh, cron lines) must bake the substituted path in at emission.An unsubstituted occurrence expands to empty quietly in shell — hence lint bans unbraced
$CLAUDE_PLUGIN_ROOTand any read of it inside emitted-script heredocs (AC-2).6. Implementation phases
Phase 0 — probes (all read-only or session-scoped via
claude --plugin-dir; before any churn):/ns:cmdfora command; this suite is SKILL.md-only. Verify a plugin skill is invocable as
/devwork:<skill>and how it appears in the picker. If skills are not slash-invocable, thefallback is thin
commands/*.mdwrappers per skill (amends D8's "manifest + skills/ only") —a design change the PREQ should see.
devworkplugin loaded alongside the live farm (distinctnamespaces, distinct scopes — plausible, unverified). Required for pre-cutover dogfooding
(Phase 4) and the cutover sequence (Phase 5).
clone-credential path for a private repo; record the
--scopechoice.components (
plugin details?plugin list? eval baseline?) — AC-4's assertion depends on it./meta-amenddesk-check wording only).
no longer resolves (
develop/SKILL.md:61"Run/technical-planfirst") — R4's "fails loudly"is currently asserted only for the human picker path.
reachable from the operator's normal env) → Phase 7's actual worklist.
Phase 1 — restructure + lint retarget (one change, in the worktree):
git mvthe 14 skills_shared→plugin/skills/; author both manifests (0.1.0,author,description);validate --strictgreen. Same commit: retargetlint-conventions.sh— two roots(
plugin/skills,.claude/skills), assert non-zero helper-file count (the currentfind … 2>/dev/nullwould green-light an empty tree), fix the adapter-exemption anchor(
^\.claude/skills/(tea-cli|…)→ plugin-relative). Lint must never be blind while Phases 1–2 edit.Phase 2 — reference rewrite per §5 rules: enumerate → classify → scripted rewrite with
allowlist + exclusions → per-file diff review of hand-treatment cases (adapter discovery order,
consumer-project paths).
Phase 3 — gates: new lint rules: (i) no
~/.claude/skills/and no bare_shared/invocation in
plugin/skills/**; (ii) no/devwork:Xwith X ∉ allowlist; (iii) norealpath/readlink -f/cd -P/pwd -Pin helper tier; (iv) no unbraced$CLAUDE_PLUGIN_ROOT;(v)
claude plugin validate --strict(loud skip if CLI absent).scripts/release.shper D6.Phase 4 — pre-cutover dogfood (AC-4, before anything irreversible): load the worktree's
plugin/via P0.2 coexistence symlink or--plugin-dir, then run: (a) a full/devwork:requirementsphase on a scratch issue (tea-cli adapter dispatch, Phase Outcomeposts); (b) a
git-worktreesslot creation (helper resolution with cwd inside a worktree —D4's hardest case); (c) one
/meta-amendcycle including the lint gate (D7 mechanics); (d) onephase run on a non-tea consumer project (castellan,
gh-cli) — the adapter matrix's other leg.Phase 5 — dev-machine cutover (scripted, ordered; only after Phase 4 green):
Preconditions asserted by the script: clean tree; no git worktrees; no feature in flight on
either machine; #38–#49 meta-amend queue frozen (announced on the tracker). Steps:
mainSHA (rollback anchor).~/.claude/skills/devwork -> <worktree>/plugin; restart session; the merge itselfruns under the plugin form — no step after the merge may read farm paths.
main; repoint the symlink to the primary checkout'splugin/; delete the 15 farmlinks; retire the worktree.
~/.claude/repos/devwork-skills→~/Coding/devwork-skills(if D-move approved):migrate
~/.claude/projects/<old-slug>→<new-slug>and the~/.claude.jsonprojectsentry (trust acceptance, MCP config); delete + regenerate
.devwork/readiness-report.md;update the symlink target.
_sharedhelpers respond (phase-outcome-read-latest.sh --issue 50returns); memory loadsat the new path (AC-8/AC-9).
Rollback =
git reset --hard <pre-merge SHA>+ move repo back + re-link farm (the inversescript exists and is tested on a scratch copy first).
Phase 6 — desktop cutover (only after Phase 5 green + tag pushed): release via
scripts/release.sh; on desktop: marketplace add (P0.3 mechanics), install, restart, verifyinventory + one helper-invoking phase run (AC-6); then remove farm + clone per D9's resolution;
remove the dead
desktopremote from the dev repo. Rollback: reinstall previous version —noting cache GC (
.in_usemarkers) may have reclaimed it, so keep the previous tag installablefrom the remote, not just the local cache.
Phase 7 — docs + records: README rewritten to bootstrap a new machine alone (AC-10):
prerequisites (bash ≥ 3.2, jq, git, adapter CLI), verbatim marketplace add/install commands,
verification step, namespaced command list, update ritual, dev-machine skills-dir form. Consumer
CLAUDE.md guidance blocks (P0.7 worklist) updated same-day as Phase 5.
/devwork:setup'semitted guidance templates updated to namespaced names. Tracker note: records predating the
migration use bare names (read
/developas/devwork:develop);jochems-forgememory notereplaced (stale-checkout warning → version-check ritual).
sdlc-retrospectives/watchlist entryso
/meta-retrospectivedoesn't read pre-migration run reports as plugin-form evidence.7. Acceptance criteria (draft — to be formalized by the pipeline)
claude plugin validate --strictpasses onplugin/and the marketplace manifest~/.claude/skills/refs, zero bare_shared/invocations, zero non-allowlist/devwork:X, zero unbraced$CLAUDE_PLUGIN_ROOTinplugin/skills/**_sharedhelpers verified present/working at dev cutover; abort criteria definedrealpath/readlink -f/cd -P/pwd -P~/.claude.jsonentry migrated)claude plugin tag)8. Risks & preconditions
skills/_sharedtolerance--scope/devwork:<skill>for SKILL.md-only plugins)~/.claude.json); AC-99. Out of scope
plugin evalsuites (future issue); any skilllogic change (layout + references + tooling only — the D7 meta-helper fix and the setup
discovery-order hand-rewrite are the two sanctioned text-behavior touches, both restorative);
automating desktop updates.
10. Open questions carried to the PREQ
D1 (name), D5 (remote), D9 (desktop dogfood workflow), repo-move bundling (§1), and the P0.1
outcome if it forces commands/ wrappers.
11. Expert review (adversarial panel, 2026-08-15)
Reviewers: plugin-mechanics skeptic · portability-baseline enforcer · migration/rollout
engineer · process & dogfooding steward · consumer-experience reviewer (independent, parallel,
read-only verification against the live repo and CLI).
Blocking concerns absorbed: slash-invocability unverified (→ P0.1, decisive); rewrite
corrupts non-devwork/consumer-sense refs (→ §5 classes, allowlist, exclusions); meta-lane helper
paths break,
${CLAUDE_PLUGIN_ROOT}unavailable to project scope (→ D7 redesign, rule 4);merge-instant farm death + broken rollback (→ Phase 5 sequence, SHA anchor);
~/.claude.jsonnot migrated (→ Phase 5 step 4, AC-9);
validatenon-strict vacuous (→ AC-1); versionprecedence inverted (→ D6); lint retarget blind/late (→ Phase 1, AC-3); AC-4 previously ordered
after the irreversible step (→ Phase 4 pre-cutover via coexistence/--plugin-dir); product calls
masquerading as locked decisions (→ [PREQ] markers); dogfood-evidence integrity + queue freeze
(→ Phase 7, R6).
Noted, not actioned: thin alias shims for ergonomics (rejected — drift the fence exists to
prevent; picker prefix-match suffices);
pwd -Phad zero existing violations (fence only);scratchpad project dirs embedding the old path (ephemeral, cutover script ignores them);
CDPATH-hardening of the 19
cd $(dirname …)helpers (worth a lint idea, not this slice).Archived pre-PREQ issue body (2026-08-14 evidence record, preserved verbatim before /requirements replaced the body with the PREQ on 2026-08-15):
Ship the suite as a Claude Code marketplace plugin
Motivation
The suite currently lives at
~/.claude/repos/devwork-skillson two machines, exposed via asymlink farm
~/.claude/skills/* -> ../repos/devwork-skills/.claude/skills/*, kept in sync byhand (manual
git pullon the desktop). Decision (2026-08-14 session):~/.claude/repos/to thenormal coding-projects root (source of a product does not belong inside the runtime's config dir).
(
jbr870/devwork-skills), versioned install, updates via version bump.repo (dogfooding), invokes the plugin, so packaging regressions surface where development
happens, not on the consumer box. The current symlink farm hides exactly the failure classes
the plugin form introduces (path resolution, namespacing, fencing).
friction in the form that ships, not in a form nobody ships.
Experimental findings (verified 2026-08-14, this machine, all probes cleaned up)
1. Local-path marketplace: works, but installs are versioned snapshots
claude plugin marketplace add <path>registers source typedirectory; metadata read live,no checkout.
claude plugin installcopies to~/.claude/plugins/cache/<mp>/<plugin>/<version>/— areal directory, no
.git, no link.gitCommitSharecords HEAD. Installing from a dirty tree writesprovenance that disagrees with the shipped bytes. Release discipline: only install/tag from a
clean tree.
claude plugin updateis version-gated, not content-gated: content edits without a versionbump are never picked up (
already at the latest version), even afterclaude plugin marketplace update. A bump in both manifests (marketplace.json entry +plugin.json) triggers re-copy into a new
<version>/dir, old version left beside it, plus"Restart to apply."
bump per skill-text edit — untenable for /meta-amend. Not needed, because:
2. skills-dir plugins: live, symlinkable, no install step
~/.claude/skills/<name>/containing.claude-plugin/plugin.jsonauto-loads as<name>@skills-dir: no cache copy, no installed_plugins.json entry, status "loaded".~/.claude(probe pointed into /tmp scratchpad).claude plugin detailsinventory with no reinstall/bump. (Not verified: whether analready-running session picks up edits, vs only new sessions.)
ln -s <repo>/<plugin-root> ~/.claude/skills/devwork, /meta-amend needs norefresh step at all. The release to the desktop is the only versioned act
(
claude plugin tagexists: creates{name}--v{version}, validates plugin.json against themarketplace entry,
--push).3. Namespacing: mandatory, identical across both sources
wfns:nsprobewfmp:mpprobensprobeUnknown skill: nsprobe/wfns:nscmd(command)/nscmdUnknown command: /nscmdnaming exactly.
/develop,/technical-plan,/qa-*in skill text and consumerCLAUDE.md guidance becomes
/devwork:developetc. Largest single chunk of migration churn.devwork:*= shipped; bare = repo-local(/meta-amend, /meta-retrospective stay in the repo's project-scope
.claude/skills/,structurally unreachable from consumers).
every reference.
4. Path resolution:
${CLAUDE_PLUGIN_ROOT}is load-time text substitution only${CLAUDE_PLUGIN_ROOT}in skill/command markdown is substituted at load time (probereturned the real path). This is the fix for the 56 hardcoded
~/.claude/skills/...references.$CLAUDE_PLUGIN_ROOTin emitted shell is empty — not exported to the Bash tool env. Thesubstitution must live in the markdown text; runtime shell cannot read it.
$(_shared/procedures/bin/foo.sh ...)) currently resolveagainst cwd by luck of the symlink farm — must gain the
${CLAUDE_PLUGIN_ROOT}/prefix.BASH_SOURCE(already sanctioned bash surface).~/.claude/skills/wfns), not the realpath — ahelper doing
cd -P/realpathlands in the repo instead. Decide which is canonical beforeanything depends on it.
5. Tooling gains
claude plugin validate <path>— manifest gate for lint-conventions.sh.claude plugin details <name>— per-component token cost (always-on vs on-invoke); directlyuseful for the context-economy work (sdlc-context-economy-analysis.md).
claude plugin initscaffolds a skills-dir plugin;claude plugin evalruns evals against aplugin (skills-dir plugins resolve too).
Scope of the migration (for the PREQ)
~/.claude/repos/to the coding-projects root..claude-plugin/plugin.json+skills/), shipped skills leave.claude/skills/entirely (double-load hazard if any skill exists in both project scope andplugin scope); meta-* +
scripts/stay repo-local project scope.~/.claude/skills/...(56 refs) and bare_shared/...invocations to${CLAUDE_PLUGIN_ROOT}/..../devwork:developform); pick the plugin name first.plugin tagrelease discipline (clean tree only, cf. gitCommitSha finding).
~/.claude/skills/or bare_shared/in shipped skilltext;
claude plugin validategreen; (existing checks unchanged).~/.claude/skills/devwork→ repo plugin root; remove old symlinkfarm. Desktop: remove farm + repo checkout, marketplace install.
invocations.
Open questions for /requirements:
only; new-session load is confirmed).
.devwork/, run reports, analysis docs stay at repo root (presumably yes — plugin rootis a subdirectory; marketplace
source: ./<subdir>supports this, cf. official marketplacelayout).
Lane
New capability + contract/layout change → full pipeline (/requirements → /technical-plan →
/develop → QA → /integrate), per the two-lane rule. Filed from the 2026-08-14 experiment session;
this issue is the evidence record.
Test Plan: ship-the-suite-as-a-claude-code-marketplace-plugin
Prerequisites
~/.claude/skills/and the repo at~/.claude/repos/devwork-skills;jq,git, and the project's forge CLI (teafor this repo,ghfor castellan).github.com/jbr870/devwork-skillsexists and is reachable from the desktop, with whatever credential path that remote requires available to the desktop operator./dev:invocation) is void and the run halts on a Pending Decision instead.Required Test Data
/dev:requirementsphase in the coexistence run.~/.claude/skills/path reference, a_shared/reference not prefixed by the plugin-root variable (one in a shell fence, one in prose), and a/dev:<x>reference naming a skill outside the 14.Test Scenarios
Scenario 1: Manifests pass strict validation with a clean bill
Acceptance criterion: AC-1 — "validation passes with zero errors and zero warnings"
Expected outcome: Strict validation of both manifests is completely clean; nothing is deferred or explained away.
Scenario 2: The shipped plugin's contents scan clean, and the scan is proven non-vacuous
Acceptance criterion: AC-3 — "zero references to
~/.claude/skills/paths, zero bare_shared/invocations … and the mechanical scan itself is verified non-vacuous"~/.claude/skills/paths._shared/references that are not prefixed by the plugin-root variable — counting references in markdown prose, not only those inside shell fences./dev:<x>references where<x>is outside the 14 shipped skill names.Expected outcome: The scan looks at a real, non-empty population of files and finds none of the three violation classes.
Scenario 3: The meta lane runs by bare name in the repo and is absent from the shipped plugin
Acceptance criterion: AC-4 — "when the operator invokes
/meta-amendor/meta-retrospectiveby bare name, then they run; and … no meta skill or repo-local script is present in it"/meta-amendby its bare name./meta-retrospectiveby its bare name..devwork/material is present in it.Expected outcome: The meta lane is fully usable inside the repo by bare name, and entirely absent from what ships.
Scenario 4: A full requirements phase runs on a scratch tea issue with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "a full
/dev:requirementsphase runs on a scratch issue of this repo (tea forge) … with their forge records posted correctly"/dev:requirementsand run the phase through to completion against the scratch issue.Expected outcome: The requirements phase runs from the plugin form and leaves correct, parseable forge records on the right issue.
Scenario 5: A worktree slot is created with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(b) a worktree slot is created"
/dev:name.Expected outcome: Slot creation succeeds from the plugin form, with its records intact.
Scenario 6: One meta-amend cycle runs with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(c) one meta-amend cycle runs"
/meta-amend(bare name — it is repo-local) against a prepared finding issue.Expected outcome: The repo-local meta lane still works end to end while the plugin form is loaded.
Scenario 7: A pipeline phase runs on castellan (gh forge) with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(d) one pipeline phase runs on castellan (gh-cli — the non-tea adapter leg)"
/dev:name and run it to completion against the prepared castellan issue.Expected outcome: The non-tea adapter leg works identically from the plugin form.
Scenario 8: A rehearsed abort restores the pre-cutover state on a scratch copy
Acceptance criterion: AC-7 — "when the abort path runs, then the pre-cutover state is restored (farm links, repo location, project identity) — and this abort path has been rehearsed on a scratch copy before the real cutover"
Expected outcome: A failed cutover leaves the machine as it was, and this is demonstrated on a scratch copy before the real machine is touched.
Scenario 9: On the cut-over dev machine, all 14 shipped skills list and invoke under
/dev:, and bare names do not resolveAcceptance criterion: AC-2 — "all 14 appear in the listing and each invocation resolves — and no shipped skill resolves under its bare name"
/dev:names — develop, gh-cli, git-worktrees, glab-cli, integrate, local-fs, promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan, worktree-cleanup. Tick each one off the list individually./dev:<skill>in turn.requirements,develop,tea-cli).Expected outcome: On the dev machine, shipped capability is reachable only through the
/dev:namespace, and all 14 skills are there.Scenario 10: The moved repo keeps its project identity
Acceptance criterion: AC-6 — "the suite works from the new location and project identity is preserved"
~/Coding/devwork-skills./dev:name from this location.~/.claude/repos/path.Expected outcome: The repo works from its new home in the coding-projects root with memory, trust, and MCP configuration carried over.
Scenario 11: The dev machine shows no farm remnants and
/dev:-only consumer guidanceAcceptance criterion: AC-12 — "no symlink-farm links … remain, every consumer-project CLAUDE.md guidance block on it references shipped skills by
/dev:names only"~/.claude/repos/devwork-skills./dev:name./dev:.Expected outcome: The dev machine has no farm left, live guidance points at
/dev:names, and the audit trail is untouched.Scenario 12: A clean, agreeing, untagged state releases 0.1.0
Acceptance criterion: AC-10 — "when a release is attempted, then the release tooling refuses…" (positive control for the three refusal conditions)
0.1.0, and that no tag yet claims that version.0.1.0now exists.Expected outcome: A legitimate release goes through and produces a versioned
0.1.0tag — establishing that the later refusals are refusals, not a tool that never works.Scenario 13: The desktop cutover is executed from the README alone
Acceptance criterion: AC-11 — "the desktop cutover itself is executed following only the README's instructions; any step that needed outside knowledge is a README defect"
jq,git, and the project's forge CLI — and the operator can check each one on the desktop from what the README says.Expected outcome: The README is sufficient on its own to cut over the desktop; any gap is treated as a defect in the README rather than an operator problem.
Scenario 14: The desktop installs the released plugin and runs a helper-invoking phase
Acceptance criterion: AC-8 — "the install succeeds at the released version and the phase completes with its forge records posted"
0.1.0), not a branch, not "latest unknown".Expected outcome: A released version installs cleanly on the desktop and does real work, including running its helpers from the installed form.
Scenario 15: On the desktop, all 14 shipped skills list and invoke under
/dev:, and bare names do not resolveAcceptance criterion: AC-2 — "Both listing and invocation are checked on each machine's form"
/dev:names — tick each off individually./dev:<skill>; cancel each once it is clearly running./meta-amendand/meta-retrospectiveby bare name on the desktop.Expected outcome: The desktop's installed form exposes exactly the 14 shipped skills, only under
/dev:, with no path to the meta lane.Scenario 16: The desktop update ritual moves it from one release to the next
Acceptance criterion: AC-9 — "when the desktop performs the update ritual, then it runs the new version — the vN → vN+1 path is exercised"
0.1.0install from Scenario 14, cut a second release from the dev machine at a higher version.0.1.0./dev:on the desktop.Expected outcome: The desktop moves from one release to the next by a deliberate update, and demonstrably runs the newer one.
Scenario 17: The desktop shows no farm, no clone,
/dev:-only guidance, and version-attributable run recordsAcceptance criterion: AC-12 — "no symlink-farm links (and on the desktop, no suite clone) remain … and desktop run records are attributable to the installed release version"
/dev:names only.Expected outcome: The desktop is a pure consumer with no local suite copy, and every run it produces can be traced to a specific release.
Scenario 18: A release from a dirty tree is refused
Acceptance criterion: AC-10 — "Given a dirty working tree … the release tooling refuses with a non-zero exit and no tag or install artifact is produced"
Expected outcome: A dirty tree blocks the release loudly and leaves nothing behind.
Scenario 19: A release with disagreeing manifest version fields is refused
Acceptance criterion: AC-10 — "disagreeing manifest version fields … the release tooling refuses"
Expected outcome: The two manifests must agree on the version before anything ships; disagreement is caught, not silently resolved.
Scenario 20: A release reusing an existing version tag is refused
Acceptance criterion: AC-10 — "an already-existing version tag … the release tooling refuses"
0.1.0after Scenario 12).Expected outcome: A version number can be released once; a second attempt is refused without disturbing the first release.
Scenario 21: The desktop can fall back to the previous release from the remote
Acceptance criterion: AC-7 — "The desktop's previous release remains installable from the remote as its recovery path"
/dev:.Expected outcome: The desktop's recovery is a reinstall of the prior release from the remote, and it works.
Scenario 22: A desktop install with no working credential path fails loudly
Acceptance criterion: AC-8 — "when the desktop installs the plugin from the GitHub marketplace source" (failure edge of the install path)
/dev:skills from a half-install.Expected outcome: A missing credential path produces a clear failure and a clean machine, never a silently broken or half-present install.
Scenario 23: Strict validation actually rejects a defective manifest
Acceptance criterion: AC-1 — "validated with the plugin tooling's strict mode" (non-vacuity control)
Expected outcome: The clean result in Scenario 1 means something — the strict validator demonstrably fails a bad manifest and says which field is bad.
Scenario 24: The mechanical scan actually catches each violation class
Acceptance criterion: AC-3 — "the mechanical scan itself is verified non-vacuous" (per-class control)
~/.claude/skills/path reference into a skill file._shared/reference inside a shell fence that is not prefixed by the plugin-root variable._shared/reference in plain markdown prose, not in any fence, without the plugin-root prefix./dev:reference naming a skill outside the 14 (including one naming a meta skill).Expected outcome: Each of the three violation classes is provably detected, including the prose-not-fence case, so a clean scan is evidence rather than silence.
Notes
/dev:<skill>(9, 14, 15, 16, 21, and the coexistence runs 4–7) presumes the P0.1 probe confirmed that plugin skills are slash-invocable. If P0.1 comes back negative, these scenarios cannot be executed as written and the feature halts on a Pending Decision instead of failing these cases.0.1.0already being claimed. Scenario 16 depends on 14.Software Requirements: ship-the-suite-as-a-claude-code-marketplace-plugin
Context
The suite operator cannot keep the SDLC suite consistent, versioned, and honestly dogfooded across
two machines while it ships as a hand-maintained symlink farm (15 links on the dev machine, a
manually-pulled clone + farm on the desktop). The dev form is not the shipped form, nothing records
which suite state a run executed, and the shipped/meta fence is convention only. The feature
packages the 14 shipped skills +
_sharedas a marketplace plugin nameddev, makes the pluginform the only form anyone runs (
/dev:<skill>on both machines), moves the repo to~/Coding/devwork-skills, and turns releases into a tooling-enforced act starting at 0.1.0.Approaches Considered
Approach A: In-place dual-form (plugin root mirrors
.claude/skills/)Summary: Keep skills where they are; add plugin manifests pointing at the existing tree.
Pros: No restructure; no reference rewrite of file paths.
Cons: Same-skill double-load hazard (verified in the #50 probes); farm and plugin both resolve the same names with no fence; guaranteed drift between forms.
Effort: Low
Approach B: Big-bang restructure on the checked-out branch
Summary:
git mveverything toplugin/directly on the live checkout, cut over in one step.Pros: Single disruption; simplest git history.
Cons: The farm points into this repo's working tree — the branch's
git mvkills the live suite mid-development, including the very session doing the work.Effort: Medium
Approach C: Restructure in an isolated worktree; staged, scripted cutover (chosen)
Summary: Restructure and rewrite on the feature branch in an isolated git worktree; probe first; dogfood the plugin form in a coexistence window pre-cutover; then an explicit scripted cutover sequence in which the plugin form is loaded and verified before the merge, the merge runs under the plugin form, and the farm is dismantled after.
Pros: Live suite keeps working throughout development; every irreversible step is preceded by a verified reversible one; rollback is rehearsable.
Cons: Most moving parts; needs a scripted cutover with ordered steps and asserted preconditions.
Effort: High
Decision
Selected: Approach C — isolated-worktree restructure with a staged, scripted cutover.
Rationale: A and B both break the running suite (A by double-load, B by killing the farm's target mid-development). C is the only approach in which the merge instant is survivable: worktree isolation alone merely defers B's failure to the moment
mainfast-forwards, so the cutover is an explicit ordered sequence, not a side effect of integration.Architecture
Component Overview
Consumption: dev machine
~/.claude/skills/dev -> <repo>/plugin(skills-dir form, live);desktop: versioned marketplace install from the GitHub remote (cache form).
${CLAUDE_PLUGIN_ROOT}resolves to the symlink path on dev and the cache path on desktop; helpers must behave identically
under both (AC-8 exercises the cache form).
Data Flow
/dev:<skill>→ Claude Code resolves it from the loadedplugin (skills-dir or cache form) → skill markdown loads with
${CLAUDE_PLUGIN_ROOT}textuallysubstituted at load time → emitted shell calls helpers at
${CLAUDE_PLUGIN_ROOT}/skills/_shared/procedures/bin/*→ helpers self-locate via logicalBASH_SOURCE+dirname(symlink-preserving) and resolve forge adapters as siblings of_shared../.claude/skills/<adapter>) → plugin-bundled(
${CLAUDE_PLUGIN_ROOT}/skills/<adapter>) → user-scope (~/.claude/skills/<adapter>). Thediscovery-order docs are hand-rewritten to state exactly this; a consumer project's local adapter
override keeps winning over the bundled one.
scripts/release.shwrites both version fields → commits the bump → lint →claude plugin validate --strict→claude plugin tag --dry-run→tag --push --remote github→ appends
{version, tag, commit SHA}to the release record. Refusals (dirty tree, versiondisagreement, existing tag) come from
claude plugin tagitself.claude plugin marketplace update+ install at the newversion; run records carry the installed version (AC-12).
.claude/skills/(bare names, repo project scope). Meta skills resolve thehelper root once, before any
cd, viagit rev-parse --show-toplevel(not$(pwd)— agentthreads reset cwd, and worktree slots add roots):
B="$(git rev-parse --show-toplevel)/plugin/skills/_shared/procedures/bin".External Data Contracts
Inventory:
external-deps.json(this folder). Three boundaries verified at plan time; five couldnot be and are gated by blocking preconditions (D-PO-50-2-1 … D-PO-50-2-5, in inventory order).
plugin {validate,tag,list,marketplace}--strictgate; tag enforcement trio (clean tree / version agreement / tag uniqueness), tag format{name}--v{version}, default remoteorigin; marketplace add from URL|path|GitHub repo;list --json --availablerecorded→external-contracts/plugin-cli-toolchain.provenance.json~/.claude.jsonprojects entry + projects-dir slug-home-jochem-Coding-devwork-skillscollision-freerecorded→external-contracts/claude-runtime-config.provenance.jsonrecorded→external-contracts/consumer-guidance-dev-machine.provenance.json/dev:<skill>github.com/jbr870/devwork-skillsgit ls-remoteKey Decisions
dev(locked by PREQ, operator's call)plugin/subdir; marketplace.json at repo root withsource: ./pluginscripts/,.devwork/)._sharedlocationplugin/skills/_shared/validate --strict; adapter_lib.shresolves adapters as siblings of_shared— zero helper code changes. Escape hatch if a CLI update rejects it: explicit"skills": [...]array in plugin.json. Tolerance is re-checked by every release'svalidate --strictgate (R2).BASH_SOURCE+dirname; lint bansrealpath,readlink -f,cd -P,pwd -Pin the helper tierjbr870/devwork-skills(locked by PREQ). Recommended private + fine-grained read-only PAT (contents:read, this repo only) on the desktop, stored via credential helperplugin.jsonauthoritative (marketplace entry mirrors; ignored by install — verified);scripts/release.shsole writer of both fields; commits the bump before tagging (tag requires a clean tree); enforcement delegated toclaude plugin tag; release record gets{version, tag, commit SHA}so installs are attributable to content, not a movable label; re-run after mid-flow failure is idempotent (detects committed-but-untagged state and resumes at tagging — never double-bumps)B="$(git rev-parse --show-toplevel)/plugin/skills/_shared/procedures/bin", resolved once pre-cd${CLAUDE_PLUGIN_ROOT}substitution.git rev-parse(panel fix) survives cwd resets and worktree slots where$(pwd)breaks. This is sanctioned restorative touch #1 (the current meta-retrospective path is already broken).plugin/skills/**only; the meta lane lives outside that population, so its repo-relative helper paths need no exemption pattern~/.claude/skills/dev, restarts, verifies the plugin form, merges under it, and only then deletes the 15 farm links; the repo move follows; every step's inverse is in the abort script${CLAUDE_PLUGIN_ROOT}hygiene$CLAUDE_PLUGIN_ROOT; require double-quoted"${CLAUDE_PLUGIN_ROOT}"at use sites; ban escaped/dynamic forms (\${CLAUDE_PLUGIN_ROOT}, string-built names) — the actual leak paths, narrower and mechanically checkable than a heredoc ban (panel). Emitted artifacts that outlive a fence bake the substituted path and assert it non-empty + absolute before any file operationTechnical Risks
validate --stricttolerance ofskills/_shared/changes in a future CLI updatevalidate --strict(D6), so a regression surfaces at release time, not on the consumer; documented escape hatch: explicit"skills"array (D3).~/.claude.jsonbacked up before edit; only the moved path's entry migrated (never fresh trust granted programmatically); projects-dir slug migrated; verified shape recorded at plan time.Expert Review
Reviewers
_sharedplacement rests on undocumented validator tolerance.$(pwd)-based meta helper root breaks off-root; release.sh missing bump-commit + idempotency semantics; class-2 scan would flag the meta lane's own paths.Changes Made
jbr870/devwork-skillsbefore install.{version, tag, commit SHA}.git rev-parse --show-toplevel, not$(pwd).plugin/skills/**only — resolves the meta-lane collision by scope.~/.claude.jsonbackup; migrate only the moved entry.Noted (not actioned)
Acceptance Criteria
claude plugin validate --strictas a lint-gate rule inscripts/lint-conventions.sh(loud skip if CLI absent); non-vacuity control Scenario 23 inscripts/test-plugin-gates.sh/dev:<skill>on each machine's form; bare names don't resolve (contingent on P0.1)~/.claude/skills/refs, zero non-prefixed_shared/refs (fences AND prose), zero/dev:Xoutside the 14; scan non-vacuousplugin/skills/**(D8) with asserted non-zero examined-file count; per-class negative controls Scenario 24 inscripts/test-plugin-gates.shmeta-*/scripts/underplugin/~/.claude.jsonentry + new projects-dir slug existscripts/test-plugin-gates.sh(viatag --dry-runon prepared scratch states); positive control Scenario 12 is the real 0.1.0 release (human-UAT)/dev:-only; desktop records version-attributableSREQ-tier criteria (design preconditions and sanctioned touches with no PREQ AC — from PO-50-1
findings F-PO-50-1-6…10):
devis committed (manifests, Phase 1) before any reference-rewrite commit (Phase 2)sdlc-*,sdlc-dogfood-run-reports/,sdlc-retrospectives/git diff --statof the Phase 2 rewrite commits over the exclusion set is empty (Scenario 11 step 8 re-checks post-cutover)"Every call site routes through helper X" criteria: AC-3's three scans and D12's hygiene rules
are exactly this class — enforced as lint rules over
plugin/skills/**, never spot-checks. Thenon-vacuity controls (Scenarios 23/24) keep the green meaningful.
Temporary-scaffolding cleanup: the coexistence window (farm + plugin both loaded) is the one
deliberately temporary state; its removal is the cutover itself (Phase 5 deletes the farm,
AC-12 asserts no remnants) — no separate cleanup ticket needed. The work-queue freeze auto-expires
when Phase 6 completes (unfreeze announced on the tracker, same comment thread as the freeze).
Implementation Scope
Areas
plugin/.claude-plugin/plugin.json,.claude-plugin/marketplace.jsongit mv .claude/skills/{<14>,_shared}→plugin/skills/plugin/skills/**text (4 classes, allowlist, exclusions); rewrite script kept inscripts/for audit.claude/skills/meta-amend,.claude/skills/meta-retrospective(D7 helper root,/dev:names for shipped-skill refs, lane-gate path update)scripts/lint-conventions.sh: two roots, asserted non-zero file counts, adapter-exemption anchors, new rules (path scans D8, allowlist,validate --strict, D4 canonicalization ban, D12 hygiene)scripts/release.sh(D6),scripts/test-plugin-gates.sh(refusal + validator + scan negative controls — new suite, the integration-covered lane's named file)scripts/cutover-dev.sh,scripts/cutover-abort.sh(D11 ordering, S-2 assertion, state probe,~/.claude.jsonbackup/migration); desktop steps live in the README, not a scriptREADME.mdrewrite (prereqs, verbatim install/update/state-probe commands, old→new name table, failure modes with expected errors); consumer CLAUDE.md guidance blocks (castellan now; desktop consumers per P0.7 worklist at Phase 6); tracker + memory notesFile Boundaries
The restructure + rewrite happen in an isolated git worktree — the main checkout keeps serving
the farm until cutover.
scripts/and.claude/skills/meta-*are disjoint fromplugin/**and canbe worked in parallel with the rewrite. The README and consumer guidance blocks are independent of
all code areas.
scripts/lint-conventions.shmust be retargeted in the same commit as therestructure so lint is never blind between phases.
Dependencies & Sequencing
Preconditions (5 blocking decisions) → Phase 0 probes (P0.1 decisive; P0.8 history scan) →
Phase 1 restructure + manifests + lint retarget (one commit; locks name
dev, S-3) →Phase 2 reference rewrite → Phase 3 remaining gates + release/cutover tooling →
Phase 4 coexistence dogfood (AC-5; fix-forward window per R7) →
Phase 5 dev cutover (Scenario 8 rehearsal first; then D11 sequence; consumer guidance on this
machine same-day) → Phase 6 release 0.1.0 + desktop cutover (README-driven, AC-11) →
Phase 7 second release + update ritual (AC-9) and closing docs. Strictly sequential at phase
granularity; parallelism only inside Phases 2–3.
Constraints & Non-Goals
Constraints:
claude plugin tag).devlocks before any reference rewrite (S-3).Non-goals (do NOT build):
plugin eval) — future issue.Test Plan: ship-the-suite-as-a-claude-code-marketplace-plugin
Prerequisites
~/.claude/skills/and the repo at~/.claude/repos/devwork-skills;jq,git, and the project's forge CLI (teafor this repo,ghfor castellan).github.com/jbr870/devwork-skillsexists and is reachable from the desktop, with whatever credential path that remote requires available to the desktop operator./dev:invocation) is void and the run halts on a Pending Decision instead.Required Test Data
/dev:requirementsphase in the coexistence run.~/.claude/skills/path reference, a_shared/reference not prefixed by the plugin-root variable (one in a shell fence, one in prose), and a/dev:<x>reference naming a skill outside the 14.Test Scenarios
Scenario 1: Manifests pass strict validation with a clean bill
Acceptance criterion: AC-1 — "validation passes with zero errors and zero warnings"
Lane:
integration-covered—scripts/lint-conventions.sh(thevalidate --strictgate rule this slice adds to the existing lint suite).Expected outcome: Strict validation of both manifests is completely clean; nothing is deferred or explained away.
Scenario 2: The shipped plugin's contents scan clean, and the scan is proven non-vacuous
Acceptance criterion: AC-3 — "zero references to
~/.claude/skills/paths, zero bare_shared/invocations … and the mechanical scan itself is verified non-vacuous"Lane:
integration-covered—scripts/lint-conventions.sh(the AC-3 scan rules + asserted non-zero examined-file count, added by this slice).~/.claude/skills/paths._shared/references that are not prefixed by the plugin-root variable — counting references in markdown prose, not only those inside shell fences./dev:<x>references where<x>is outside the 14 shipped skill names.Expected outcome: The scan looks at a real, non-empty population of files and finds none of the three violation classes.
Scenario 3: The meta lane runs by bare name in the repo and is absent from the shipped plugin
Acceptance criterion: AC-4 — "when the operator invokes
/meta-amendor/meta-retrospectiveby bare name, then they run; and … no meta skill or repo-local script is present in it"Lane:
human-uat— owner: suite operator. Bare-name invocation is interactive-session behavior; the absence-from-plugin half is additionally covered mechanically by the lint scan (SREQ AC-4)./meta-amendby its bare name./meta-retrospectiveby its bare name..devwork/material is present in it.Expected outcome: The meta lane is fully usable inside the repo by bare name, and entirely absent from what ships.
Scenario 4: A full requirements phase runs on a scratch tea issue with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "a full
/dev:requirementsphase runs on a scratch issue of this repo (tea forge) … with their forge records posted correctly"Lane:
human-uat— owner: suite operator, in the Phase 4 coexistence window (live tea-forge writes; interactive/dev:session)./dev:requirementsand run the phase through to completion against the scratch issue.Expected outcome: The requirements phase runs from the plugin form and leaves correct, parseable forge records on the right issue.
Scenario 5: A worktree slot is created with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(b) a worktree slot is created"
Lane:
human-uat— owner: suite operator, in the Phase 4 coexistence window./dev:name.Expected outcome: Slot creation succeeds from the plugin form, with its records intact.
Scenario 6: One meta-amend cycle runs with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(c) one meta-amend cycle runs"
Lane:
human-uat— owner: suite operator, in the Phase 4 coexistence window (exercises SREQ D7 meta helper root)./meta-amend(bare name — it is repo-local) against a prepared finding issue.Expected outcome: The repo-local meta lane still works end to end while the plugin form is loaded.
Scenario 7: A pipeline phase runs on castellan (gh forge) with the plugin loaded pre-cutover
Acceptance criterion: AC-5 — "(d) one pipeline phase runs on castellan (gh-cli — the non-tea adapter leg)"
Lane:
human-uat— owner: suite operator, in the Phase 4 coexistence window (castellan, gh forge — live external writes; also exercises SREQ D10 adapter precedence)./dev:name and run it to completion against the prepared castellan issue.Expected outcome: The non-tea adapter leg works identically from the plugin form.
Scenario 8: A rehearsed abort restores the pre-cutover state on a scratch copy
Acceptance criterion: AC-7 — "when the abort path runs, then the pre-cutover state is restored (farm links, repo location, project identity) — and this abort path has been rehearsed on a scratch copy before the real cutover"
Lane:
human-uat— owner: suite operator, on the scratch copy before the real cutover; includes the SREQ S-2 negative test (cutover blocks while a feature is in flight).Expected outcome: A failed cutover leaves the machine as it was, and this is demonstrated on a scratch copy before the real machine is touched.
Scenario 9: On the cut-over dev machine, all 14 shipped skills list and invoke under
/dev:, and bare names do not resolveAcceptance criterion: AC-2 — "all 14 appear in the listing and each invocation resolves — and no shipped skill resolves under its bare name"
Lane:
human-uat— owner: suite operator (interactive listing + per-skill invocation on the cut-over dev machine)./dev:names — develop, gh-cli, git-worktrees, glab-cli, integrate, local-fs, promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan, worktree-cleanup. Tick each one off the list individually./dev:<skill>in turn.requirements,develop,tea-cli).Expected outcome: On the dev machine, shipped capability is reachable only through the
/dev:namespace, and all 14 skills are there.Scenario 10: The moved repo keeps its project identity
Acceptance criterion: AC-6 — "the suite works from the new location and project identity is preserved"
Lane:
human-uat— owner: suite operator (session-level identity checks: memory, trust, MCP).~/Coding/devwork-skills./dev:name from this location.~/.claude/repos/path.Expected outcome: The repo works from its new home in the coding-projects root with memory, trust, and MCP configuration carried over.
Scenario 11: The dev machine shows no farm remnants and
/dev:-only consumer guidanceAcceptance criterion: AC-12 — "no symlink-farm links … remain, every consumer-project CLAUDE.md guidance block on it references shipped skills by
/dev:names only"Lane:
human-uat— owner: suite operator; step 8's non-change assertion is additionally covered mechanically by SREQ S-5 (git diff --statover the exclusion set).~/.claude/repos/devwork-skills./dev:name./dev:.Expected outcome: The dev machine has no farm left, live guidance points at
/dev:names, and the audit trail is untouched.Scenario 12: A clean, agreeing, untagged state releases 0.1.0
Acceptance criterion: AC-10 — "when a release is attempted, then the release tooling refuses…" (positive control for the three refusal conditions)
Lane:
human-uat— owner: suite operator. A real release is a live external write (tag pushed to the GitHub remote) an automated run must not perform.0.1.0, and that no tag yet claims that version.0.1.0now exists.Expected outcome: A legitimate release goes through and produces a versioned
0.1.0tag — establishing that the later refusals are refusals, not a tool that never works.Scenario 13: The desktop cutover is executed from the README alone
Acceptance criterion: AC-11 — "the desktop cutover itself is executed following only the README's instructions; any step that needed outside knowledge is a README defect"
Lane:
human-uat— executor: a fresh operator/session with no prior cutover exposure, keeping the per-step log the scenario requires.jq,git, and the project's forge CLI — and the operator can check each one on the desktop from what the README says.Expected outcome: The README is sufficient on its own to cut over the desktop; any gap is treated as a defect in the README rather than an operator problem.
Scenario 14: The desktop installs the released plugin and runs a helper-invoking phase
Acceptance criterion: AC-8 — "the install succeeds at the released version and the phase completes with its forge records posted"
Lane:
human-uat— owner: suite operator, on the desktop (live install + consumer-project phase run).0.1.0), not a branch, not "latest unknown".Expected outcome: A released version installs cleanly on the desktop and does real work, including running its helpers from the installed form.
Scenario 15: On the desktop, all 14 shipped skills list and invoke under
/dev:, and bare names do not resolveAcceptance criterion: AC-2 — "Both listing and invocation are checked on each machine's form"
Lane:
human-uat— owner: suite operator, on the desktop./dev:names — tick each off individually./dev:<skill>; cancel each once it is clearly running./meta-amendand/meta-retrospectiveby bare name on the desktop.Expected outcome: The desktop's installed form exposes exactly the 14 shipped skills, only under
/dev:, with no path to the meta lane.Scenario 16: The desktop update ritual moves it from one release to the next
Acceptance criterion: AC-9 — "when the desktop performs the update ritual, then it runs the new version — the vN → vN+1 path is exercised"
Lane:
human-uat— owner: suite operator, on the desktop (second release + update ritual).0.1.0install from Scenario 14, cut a second release from the dev machine at a higher version.0.1.0./dev:on the desktop.Expected outcome: The desktop moves from one release to the next by a deliberate update, and demonstrably runs the newer one.
Scenario 17: The desktop shows no farm, no clone,
/dev:-only guidance, and version-attributable run recordsAcceptance criterion: AC-12 — "no symlink-farm links (and on the desktop, no suite clone) remain … and desktop run records are attributable to the installed release version"
Lane:
human-uat— owner: suite operator, on the desktop (filesystem + record inspection)./dev:names only.Expected outcome: The desktop is a pure consumer with no local suite copy, and every run it produces can be traced to a specific release.
Scenario 18: A release from a dirty tree is refused
Acceptance criterion: AC-10 — "Given a dirty working tree … the release tooling refuses with a non-zero exit and no tag or install artifact is produced"
Lane:
integration-covered—scripts/test-plugin-gates.sh(new suite authored by this slice — SREQ Implementation Scope, release tooling row; QA's lane-bootability check re-verifies it exists and runs).Expected outcome: A dirty tree blocks the release loudly and leaves nothing behind.
Scenario 19: A release with disagreeing manifest version fields is refused
Acceptance criterion: AC-10 — "disagreeing manifest version fields … the release tooling refuses"
Lane:
integration-covered—scripts/test-plugin-gates.sh(same suite as Scenario 18).Expected outcome: The two manifests must agree on the version before anything ships; disagreement is caught, not silently resolved.
Scenario 20: A release reusing an existing version tag is refused
Acceptance criterion: AC-10 — "an already-existing version tag … the release tooling refuses"
Lane:
integration-covered—scripts/test-plugin-gates.sh(same suite as Scenario 18).0.1.0after Scenario 12).Expected outcome: A version number can be released once; a second attempt is refused without disturbing the first release.
Scenario 21: The desktop can fall back to the previous release from the remote
Acceptance criterion: AC-7 — "The desktop's previous release remains installable from the remote as its recovery path"
Lane:
human-uat— owner: suite operator, on the desktop (reinstall of the previous release from the remote)./dev:.Expected outcome: The desktop's recovery is a reinstall of the prior release from the remote, and it works.
Scenario 22: A desktop install with no working credential path fails loudly
Acceptance criterion: AC-8 — "when the desktop installs the plugin from the GitHub marketplace source" (failure edge of the install path)
Lane:
human-uat— owner: suite operator, on the desktop (credential manipulation on a live machine)./dev:skills from a half-install.Expected outcome: A missing credential path produces a clear failure and a clean machine, never a silently broken or half-present install.
Scenario 23: Strict validation actually rejects a defective manifest
Acceptance criterion: AC-1 — "validated with the plugin tooling's strict mode" (non-vacuity control)
Lane:
integration-covered—scripts/test-plugin-gates.sh(validator negative controls on a scratch copy).Expected outcome: The clean result in Scenario 1 means something — the strict validator demonstrably fails a bad manifest and says which field is bad.
Scenario 24: The mechanical scan actually catches each violation class
Acceptance criterion: AC-3 — "the mechanical scan itself is verified non-vacuous" (per-class control)
Lane:
integration-covered—scripts/test-plugin-gates.sh(per-class scan negative controls on a scratch copy).~/.claude/skills/path reference into a skill file._shared/reference inside a shell fence that is not prefixed by the plugin-root variable._shared/reference in plain markdown prose, not in any fence, without the plugin-root prefix./dev:reference naming a skill outside the 14 (including one naming a meta skill).Expected outcome: Each of the three violation classes is provably detected, including the prose-not-fence case, so a clean scan is evidence rather than silence.
Notes
/dev:<skill>(9, 14, 15, 16, 21, and the coexistence runs 4–7) presumes the P0.1 probe confirmed that plugin skills are slash-invocable. If P0.1 comes back negative, these scenarios cannot be executed as written and the feature halts on a Pending Decision instead of failing these cases.0.1.0already being claimed. Scenario 16 depends on 14.e2e-browserlane is not assignable in this project —qa_domains.not_applicabledeclares the e2e domain has no surface here (markdown+shell suite, no browser). Mechanical scenarios route tointegration-covered(scripts/lint-conventions.shexists and runs today;scripts/test-plugin-gates.shis authored by this slice and re-checked by QA's lane-bootability gate); everything requiring an interactive session, a live machine cutover, or a live external write routes tohuman-uatwith the owner named per scenario.Linked: this issue is sibling #51 (recorded by the devwork pipeline).
Linked: this issue is sibling #52 (recorded by the devwork pipeline).
Decomposition rationale: the SREQ sequences Phases 1→2→3 strictly (restructure must precede the
rewrite so the rewrite operates on
plugin/skills/**; the gate suite needs the rewritten tree andthe manifests to assert clean states). Execution is fully sequential in a single isolated
worktree (
devwork-skills-50) per Approach C/D11 — the main checkout sits onmainserving thelive farm, so within-wave parallelism is forfeited for safety per the single-worktree rule.
WU-50-3-1/2/3/5/6 are artifact-shape units (verification = mechanical gates + UAT scenarios), so
their Test Writers are skipped per the artifact-shape exception; WU-50-3-4 is behavioral (release
refusal trio, scan/validator non-vacuity controls) and runs the full 2-phase TDD pair with
scripts/test-plugin-gates.shwritten red first. Phases 4–7 of the SREQ (coexistence dogfood,cutovers, releases) are human-UAT lanes and are deliberately outside /develop scope; the terminal
PO will carry them as in-scope-deferrable findings.
Dev journal — WU-50-3-1 (plugin restructure)
2026-08-15T14:40 DISCOVERY:
claude plugin validateinvocations that workContext: AC-1 requires
claude plugin validate --strictto pass with zero errors and zero warnings on both manifests. The CLI takes one<path>argument and auto-detects which manifest kind it is looking at.Pattern: From the worktree root, two invocations cover both manifests:
claude plugin validate --strict plugin→ resolvesplugin/.claude-plugin/plugin.jsonand reports "Validating plugin manifest".claude plugin validate --strict .→ resolves.claude-plugin/marketplace.jsonand reports "Validating marketplace manifest".Pass a directory, not the manifest file. The path is the plugin/marketplace root; the validator appends
.claude-plugin/<kind>.jsonitself.Why it matters: The two manifests cannot be validated in one call — a directory holding both would only surface one of them, and here they live at different roots anyway. Both invocations must be run and both must exit 0.
2026-08-15T14:42 DISCOVERY: marketplace validation recurses into the plugin, and needs its own description
Context: Probed the marketplace schema in a scratch fixture before writing the real manifest (CLI version 2.1.229).
Pattern: Two behaviors worth knowing:
descriptionon the marketplace itself is required under--strict. Omitting it is only a warning ("No marketplace description provided"), which the runtime tolerates — but--strictturns it into a failure. The per-plugindescriptioninsideplugins[]does not satisfy it; they are separate fields.sourceand validates the referenced plugin'splugin.jsontoo. Deliberately corruptingplugin/.claude-plugin/plugin.jsonin the fixture producedplugins[0] plugin.json → name: …errors from the marketplace invocation. So the marketplace call is a superset check — but still run the plugin call directly, since it names failures against the plugin manifest without theplugins[0]indirection.Unknown fields are warnings ("Claude Code ignores it at load time"), which
--strictalso fails on — so no speculative metadata fields. The manifests ship exactly the fields the WU specified: plugin =name/version/description/author.name; marketplace =name/description/owner.name/plugins[]withname/source/version/description.2026-08-15T14:55 DISCOVERY: lint dual-root retarget stays bash-3.2 clean via word-split find roots
Context:
scripts/lint-conventions.shhardcoded one skills root ($REPO_ROOT/.claude/skills) in twofindcalls, and the script self-enforces the bash-3.2 ceiling, so an array of roots was not an option worth reaching for.Pattern: Build a space-separated
SKILLS_DIRSof the roots that exist, then pass it unquoted tofind—findaccepts multiple starting points, and the existing script already relies on word-splitting forhelper_files, so this matches the file's own idiom and carries the same "repo paths have no spaces" assumption. An emptySKILLS_DIRSwould silently makefindscan the cwd instead, so the script fails fast with a message when neither root exists.The adapter exemption in the direct-forge-CLI rule became
^(plugin|\.claude)/skills/(tea-cli|glab-cli|gh-cli|local-fs)/, keeping the meta-lane alternative in place rather than swapping the prefix, so the rule does not silently stop matching if an adapter is ever staged under either root.Verified behaviorally, not by reading: planted a
declare -Ahelper and areadarrayfence under each root, confirmed all four were reported with the correct paths, removed them, and confirmed a clean run. The adapter exemption is genuinely exercised — 59 directtea|glab|ghforge lines live inplugin/skills/{tea,glab,gh}-cli/**.mdand the run is clean, so the new prefix matches.2026-08-15T15:00 COMPLETE
Hardest part: Nothing fought back — the only real unknown was the validator's schema, and probing it in a scratch fixture before writing the real manifests turned that into a two-minute question instead of a guess-and-retry loop.
If I did this again: Same order — probe the validator on a throwaway fixture first, then move, then retarget lint. Moving first would have meant debugging manifest schema against a repo mid-restructure.
Dev journal — WU-50-3-2: rewrite shipped-skill references to the
devplugin namespaceWorktree:
/home/jochem/.claude/repos/devwork-skills-50(branchfeature/50-ship-the-suite-as-a-claude-code-marketplace-plugin)Baseline:
77a5f2f(WU-50-3-1 restructure)Per-class counts (occurrences, not lines) over
plugin/skills/**/*.md77a5f2f~/.claude/skills/…_shared/…/<shipped-skill>${CLAUDE_PLUGIN_ROOT}introduced/dev:<skill>introducedThe arithmetic closes exactly: 358 = (56 − 2 class-1) + 299 class-2 + 5 hand-added occurrences
(the readiness-check
forlist, the phase-outcome precedence list, the setup precedence list, andthe two adapter smoke-test paths). Class 3 is 1:1 — 565 in, 565 out, and every
/dev:name is oneof the 14.
Files changed: 69 under
plugin/skills/**+scripts/rewrite-plugin-refs.sh.[2026-08-15T14:05] DISCOVERY: quoting hygiene is fence-state-dependent, so the mechanical pass had to track fences
Context: D12 requires
"${CLAUDE_PLUGIN_ROOT}/…"double-quoted at use sites but permits thebare form in prose. A blind substitution can satisfy neither, because the same textual path is a
prose cross-reference in one line and an executable argv[0] in the next.
Pattern: the rewrite script carries the same CommonMark fence tracker
scripts/lint-conventions.shuses (a fence closes only on ≥ its own backtick count), and applies thequoting pass only while inside a
bash/sh/zsh/shellfence. It also declines to quote a tokencarrying a glob or a
{placeholder}— quoting…/bin/*would suppress the glob, so those are leftfor hand review. In the end no such token turned out to live in a shell fence.
Why it matters: it keeps the audit record honest. Everything the script did is mechanically
reproducible and idempotent (
--checkre-run is a no-op); everything else is in this journal.[2026-08-15T14:20] STRUGGLE: the class-3 boundary rule silently skipped sentence-final invocations
Problem: rewrite
/setup→/dev:setuponly where it is an invocation, never where it is a path.Expected: all 565 invocations rewritten.
Actual: 555 rewritten; 10 left bare.
Attempts:
[A-Za-z0-9_./-]". This correctlyprotects
/develop.md(a file) but also rejectsre-run /setup.(a sentence). The 10 misses wereall sentence-final:
integrate/SKILL.md:108,250,readiness-check.md:166,175,promote/SKILL.md:125,resolve/SKILL.md:138,develop/SKILL.md:437,495,545,setup/SKILL.md:114..counts as sentence punctuation unless a word character follows it(which would make it an extension). Fixed in the script rather than by hand, so the script stays
the complete record of the mechanical pass.
Key insight: the trailing-boundary set and the leading-boundary set are not symmetric. A
/or~before the name always means path; a.after it usually means end of sentence.[2026-08-15T14:45] DISCOVERY: the awk quoting fixup over-reached into prose, and had to be narrowed
Context: 7 helper invocations sit in unlabeled
fences or inside a blockquoted ```` >bash ```` fence, so neither the linter's tier-1 extractor nor the rewrite script's quotingpass sees them. I hand-quoted them with a one-shot awk over three files.
Pattern: that awk matched the first occurrence per line, which also quoted backticked prose
mentions in the same lines (e.g. the
phase-outcome.mdAPI reference table,develop/SKILL.md:43),leaving those lines half-quoted and inconsistent with the other ~300 prose mentions. Reverted the
whole quoting pass across those three files with a
sedunquote, then re-applied quotes at the sevenknown line numbers only.
Why it matters: the shipped convention is now uniform and mechanically checkable — quoted inside
shell, bare in prose — instead of "quoted wherever a script happened to reach first".
[2026-08-15T15:00] DISCOVERY:
_lib.shresolved adapters in the wrong precedence for D10 — sanctioned touch #2Ruled by the lead (2026-08-15): this is inside sanctioned restorative touch #2 (D10), not beyond
it.
$_SKILLS_ROOTisBASH_SOURCE-derived, so post-move it is the plugin-bundled scope, and theswap implements D10's project → bundled → user exactly; S-4's desk-check verifies the actual
discovery order against D10, not merely the documented one, so the code had to move with the prose.
The truthful error message was ruled right too.
Context: the class-1 sweep found one
~/.claude/skillshit inside a helper script —_lib.sh:97, the_diemessage naming the directories_resolve_adapter_bin()searched.Pattern: the message was accurate, and that was the problem.
_resolve_adapter_bin()ordered itscandidates project → user scope →
$_SKILLS_ROOT(the plugin-bundled adapter, resolved as asibling of
_shared). D10 mandates project → plugin-bundled → user. Fixing only the string wouldhave made it describe an order the code does not implement, so I swapped the two candidate entries
and rewrote the message with the three real paths.
Why it matters: this is not cosmetic during Phase 4. The coexistence window keeps the 15 farm
links at
~/.claude/skills/<adapter>alive while~/.claude/skills/devpoints atplugin/. Underthe old order a helper invoked through the plugin form would have resolved the farm's
pre-migration adapter ahead of the plugin's own bundled one — a mixed-form defect squarely in R7's
class. Under the pure farm form the two candidates are the same directory, so the swap is a no-op
there; it only changes behavior when the two scopes genuinely differ, which is exactly the case it
is meant to disambiguate.
This is the only place I changed skill logic.
Verified empirically, not just by desk-check. This machine still carries the live farm link
~/.claude/skills/local-fs -> …/devwork-skills/.claude/skills/local-fs(the pre-migration maincheckout), so the two scopes genuinely differ here — this box is the coexistence-window scenario.
A probe that sources
_lib.shand calls_setup_adapter()against a scratchlocal-fsprojectresolves
…/devwork-skills-50/plugin/skills/local-fs/bin, the plugin-bundled adapter. Under the oldorder it would have resolved the farm copy. All five helper-tier flow tests
(
_shared/procedures/test/{smoke,disposition-flow,promotion-flow,release-flow,slot-flow}.sh) passthrough this path.
Scan-population ruling (lead, 2026-08-15) and what it required
~/.claude/skills/and$HOME/.claude/skillsliteralsplugin/skills/**,.mdand.sh_shared/.mdonly, fences and prose.shexempt as a class/dev:Xwith X outside the 14Why
.shis exempt from class 2 (recording the reasoning, since it is the non-obvious one):D3's zero-helper-change ruling. Helper code self-locates relative to
BASH_SOURCErather thannaming
_shared/as a path, and the ~20 remaining_shared/mentions in.share developer-facingheader comments (
# Spec: _shared/procedures/phase-outcome.md) that are accurate repo-relativepointers. Prefixing them with
${CLAUDE_PLUGIN_ROOT}would be actively wrong — that variable doesnot exist in a shell process (D12: it is load-time text substitution in markdown only), so the
comment would name a path no reader could resolve.
Allow-anchors placed — the complete allowlist (5)
All five document user scope as the third and last adapter-discovery location per D10. The
rewrite script skips any line carrying the anchor, which is what keeps it idempotent against these.
Markdown prose uses
<!-- allow: user-scope-path -->; shell (including shell inside a fence) uses atrailing
# allow: user-scope-path._shared/procedures/phase-outcome.md:52setup/SKILL.md:108/dev:setupStep 1 "validate the adapter is installed" precedence list, item 3 —/dev:setupmust tell the operator every place it looked._shared/procedures/readiness-check.md:50local-fsprobe_pinresolution loop's third candidate. It is inside ashfence, so the shell anchor form is the syntactically valid one — an HTML comment would become literal text in the snippet._shared/procedures/bin/_lib.sh:84_resolve_adapter_bin()'s third candidate — the executable form of the same precedence._shared/procedures/bin/_lib.sh:100_diemessage that names all three searched scopes.Both shell anchor placements are unusual enough that I verified them functionally rather than
trusting
bash -n: a comment inside acandidates=( … )array literal still yields 3 elements, anda trailing comment after a line-continued
for … ; dostill iterates all 3 items. Note for thelint WU: a line-level anchor scanner must accept the shell form in
.mdfiles too, because ofreadiness-check.md:50.Class-4 hand rewrites (D10 precedence, sanctioned restorative touch #2)
_shared/procedures/phase-outcome.md§Adapter dispatch item 4 — flat "project-level or user-level"prose replaced by the explicit three-scope, first-hit-wins list.
setup/SKILL.mdStep 1 — same list, plus "If neither exists" → "If none exists" now that there arethree.
_shared/procedures/readiness-check.md— the prose describing the precedence, and thelocal-fsprobe_pinresolution loop, which gained"${CLAUDE_PLUGIN_ROOT}/skills/local-fs/bin"between theproject-scope and
$HOMEentries. Still POSIXsh: a line-continuedforlist, every elementquoted.
_shared/forge-contract.md§1 layering box — the rewritten paths and/dev:names blew the ASCIIbox's alignment. Re-padded to a 66-column inner width, with the schemas path moved to its own
continuation line so the box stays readable.
Judgment calls (flagged for the lead)
tea-cli/SKILL.md:196said.claude/skills/tea-cli/test/smoke.shandglab-cli/SKILL.md:184said.claude/skills/glab-cli/test/release-smoke.sh. Both read "run the test that ships with thisadapter, from inside a real project" — the script is plugin content, not consumer-project content,
and post-migration a consumer has no
.claude/skills/tea-cli/. Rewritten to${CLAUDE_PLUGIN_ROOT}/skills/<adapter>/test/<script>(quoted in tea-cli's fence, bare inglab-cli's prose). This is the one place I read a
.claude/skills/<adapter>path as plugin ratherthan project.
learning.v1.md:64,74andlearnings.md:73,81(.claude/skills/stack-<name>/SKILL.md, theproject-local stack skill the learnings procedure writes and commits);
develop/SKILL.md:552(same);
git-worktrees/SKILL.md:160,169,262,274(symlinking a project's.claude/skillsinto aworktree — an explicitly project-scoped operation, and the surrounding text is about what the
project commits);
readiness-check.md:48(./.claude/skills/local-fs/bin, project-scope override).local-fs/SKILL.md:280points at a file that does not exist —${CLAUDE_PLUGIN_ROOT}/skills/_shared/schemas/link.v1.md. Verified pre-existing at77a5f2f: it isa forward-looking TODO ("promote
link-parent:v1/link-child:v1to …when a second adapter needsit"), not a dangling reference this WU created. Rewritten as a plugin path because that is where it
would land. Every one of the other 44 distinct
.mdtargets resolves on disk._diemessages printedrun /setup,which is wrong post-cutover. The lead ruled them in scope; all 10 now read
run /dev:setup:_shared/procedures/bin/_lib.sh:57,98;tea-cli/bin/_lib.sh:48,88;gh-cli/bin/_lib.sh:51,91;glab-cli/bin/_lib.sh:44,84;local-fs/bin/create_issue.sh:23;local-fs/bin/_lib.sh:216. Theseare the complete set — after the rewrite, zero non-comment bare
/<shipped-skill>occurrencesremain in any
.shunderplugin/skills/**. The ~50 comment-only hits (# Owned by /promote.)stay, per the same ruling.
Gates
bash scripts/rewrite-plugin-refs.sh --check→ 0 files would change (idempotent).bash scripts/lint-conventions.sh→lint-conventions: clean.bash -nover all six edited helper scripts → ok._shared/procedures/test/{smoke,disposition-flow,promotion-flow,release-flow,slot-flow}.sh._shared/(
.md);/dev:names outside the 14 (all files); unbraced$CLAUDE_PLUGIN_ROOT; escaped\${CLAUDE_PLUGIN_ROOT}; unquoted${CLAUDE_PLUGIN_ROOT}inside a shell fence; non-comment bare/<shipped-skill>in.sh./simplify(21),/closeout(12),/qa-playbook(16),/qa-*(4),/qa-domain(5),/qa-report(4),/qa-validate(1),/vibecoding(4). No/meta-amendor/meta-retrospectiveoccurs in the shipped tree at all.git diff --stat 77a5f2f..HEADtouches onlyplugin/skills/**andscripts/rewrite-plugin-refs.sh— nothing undersdlc-*,sdlc-dogfood-run-reports/,sdlc-retrospectives/,README.md,.claude/skills/meta-*,.devwork/, or the manifests.[2026-08-15T15:40] COMPLETE
Hardest part: deciding, for each
.claude/skills/<something>path, whether it meant "inside theshipped plugin" or "inside the consumer's project" — the two are textually identical and only the
surrounding paragraph distinguishes them.
If I did this again: I would write the fence-aware quoting pass into the script from the start
instead of bolting on a one-shot awk afterwards. That shortcut cost a full revert-and-redo across
three files, and it was the only step in the unit that damaged text I had already got right.
Dev journal — WU-50-3-3 (meta lane amendments)
Scope:
.claude/skills/meta-amend/SKILL.md,.claude/skills/meta-retrospective/SKILL.mdin the worktree
/home/jochem/.claude/repos/devwork-skills-50.2026-08-15T00:00 DISCOVERY: the D7 root and the drain loop's
cdare not in conflict — the order just had to invertContext: the learnings drain loop previously read
cd "$P" && B=.claude/skills/_shared/procedures/bin— one statement doing two incompatible jobs.
$Pis another project's checkout, so after thecdneither
$(pwd)norgit rev-parsepoints at devwork-skills, and the relativeBresolved againstthe wrong repo (this is the already-broken path D7 calls sanctioned restorative touch #1).
Pattern: the
cdis still required, but for a different reason than the helper path._lib.shwalks up from
$PWDfor a CLAUDE.md carrying aforge:block (_find_project_root) and resolves theadapter from that — so the helper binary must come from devwork-skills while the cwd must be the
source project. Resolving
Bbefore thecdgives both:Adapter dirs still resolve correctly too:
_lib.shderives_SKILLS_ROOTfromBASH_SOURCE/../../..,which under this
Bisplugin/skills, so the bundled adapters are the suite's own while a sourceproject's own project-scope adapter still wins (D10 precedence).
Why it matters: the skill text now states the reason for the ordering, so a future edit can't
"tidy" the
cdback up next to the assignment and silently re-break the drain.2026-08-15T00:10 DISCOVERY: writing the D7 rationale re-introduced the banned token
Context: my first draft of the rationale paragraph explained D7 by naming
${CLAUDE_PLUGIN_ROOT}literally ("… is never substituted for the meta lane"). Correct prose, but the WU's verification
requires zero occurrences of that token anywhere in the meta lane, and D12's lint rules key on
the token without caring whether it sits in a fence or in backticked prose.
Pattern: name the concept, not the token — "the plugin-root variable the shipped skills use is
never substituted here". Keeps the explanation, keeps the grep at zero, keeps the D12 rule
mechanically unambiguous.
Why it matters: a scan whose green depends on humans distinguishing prose mentions from use
sites is a scan that will eventually be argued with. Zero means zero.
Call sites changed
D7 helper roots (3):
B=.claude/skills/_shared/procedures/binB="$(git rev-parse --show-toplevel)/plugin/skills/_shared/procedures/bin"cd "$P" && B=.claude/skills/…B="$(git rev-parse …)/plugin/skills/…"resolved beforecd "$P".claude/skills/_shared/procedures/bin/issue-create.shB=…+"$B/issue-create.sh"Helper invocations also moved from
"$B"/name.shto the SREQ's"$B/name.sh"form (5 calls:post-comment, issue-close, issue-list, comments-scan, issue-create).
Shipped-skill invocation names (4):
/requirements→/dev:requirements/requirements→/technical-plan→/developbecame
/dev:requirements→/dev:technical-plan→/dev:developBare
/meta-amend(routing reminder) and/meta-retrospective(meta-amend frontmatter) left bare.Lane-gate / path references (2):
plugin/skills/**/*.md(shippedtext, procedures, schemas, domains) or
.claude/skills/**/*.md(the meta lane) — both arelegitimate amendment targets, and naming both keeps the fence explicit rather than implied.
.claude/skills/**text" → "never touches skilltext — neither the shipped
plugin/skills/**nor the meta lane's own.claude/skills/**".Kept verbatim (judgment calls)
develop: name the wait-discipline rule the WU brief must restate(meta-amend §5) — this isthe commit-subject example for the repo's
<area>: <imperative sentence>convention.developthere is a git commit area, not a slash invocation; commit areas are unchanged by this feature.
scripts/lint-conventions.sh(4 mentions across both files) — repo-local scripts did not move.sdlc-process-observability-analysis.md§7,sdlc-dogfood-run-reports/*.md,sdlc-retrospectives/*— repo-local historical/state records, unchanged locations, and S-5forbids rewriting the historical set.
glab auth status,tea login list,gh auth status) — meta-laneread-only CLI exemption, unrelated to plugin layout.
No occurrence of
~/.claude/skills,$HOME/.claude/skills, or the plugin-root token existed ineither file before the change, so there was nothing of that class to remove.
Verification
B="$(git rev-parse --show-toplevel)/plugin/skills/_shared/procedures/bin"evaluated from (a) the worktree root, (b)
plugin/skills, (c)plugin/skills/_shared/proceduresfollowed by
cd /tmp. All three yield/home/jochem/.claude/repos/devwork-skills-50/plugin/skills/_shared/procedures/bin, andphase-outcome-read-all.shis executable there in each case (--helpexits 0).git rev-parse --show-toplevelreturns the worktree root inside a linked worktree, which is what the slot caseneeds.
Bat theworktree root,
cd'd to a project root, ran"$B/issue-list.sh" --state all— real tea-cli JSONreturned, rc 0. Confirms the helper resolves the project's adapter from cwd while being invoked
from the D7 root.
~/.claude/skills,$HOME/.claude/skills, plugin-root token, bare/<shipped-name>invocations, and.claude/skills/{_shared,<shipped-name>}paths. The foursurviving
.claude/skillsstrings are the meta lane's own scope statements.bash scripts/lint-conventions.sh→lint-conventions: clean, rc 0.git diff --name-only→ exactly the two meta SKILL.md files.2026-08-15T00:20 COMPLETE
Hardest part: seeing that the drain loop's
cdand its helper path were solving two differentproblems that had been collapsed into one line — the fix is an ordering change, not a path swap.
If I did this again: I'd grep for the banned tokens in my own new prose before running the
verification, instead of writing the rationale with the literal token and then rewording it.
Dev Journal — WU-50-3-4 (gate-suite Test Writer)
Test Writer phase
Scope: Wrote
scripts/test-plugin-gates.shin the isolated worktree(
/home/jochem/.claude/repos/devwork-skills-50, branchfeature/50-ship-the-suite-as-a-claude-code-marketplace-plugin). Noproduction code touched —
scripts/release.shdoes not exist andscripts/lint-conventions.shwas read-only reference.Red-state table (this run,
bash scripts/test-plugin-gates.sh, 24.5s, exit 1)positive_control_release_dry_runrelease.sh missing (scripts/release.sh does not exist yet)— clean failure, not a crashscenario_18_dirty_treescenario_19_version_disagreementscenario_20_existing_tagscenario_23_validator_negativeclaude plugin validate --strictalready exists and already rejects a corruptednamefield by name, on both the plugin and marketplace manifests; pristine copies already validate cleanlint_nonvacuity_on_real_treelint-conventions.shprints onlylint-conventions: clean/N violation(s)— no examined-file count at all, so AC-3 non-vacuity is unprovenscenario_24_scan_negative_controls~/.claude/skills/,$HOME/.claude/skills, both.mdand.sh), class 2 (_shared/unprefixed, fence and prose), class 3 (/dev:outside the 14), class 3b (bare/setup), AC-4 absence (meta-amend/dir,plugin/scripts/), D4 (realpath), or D12 ($CLAUDE_PLUGIN_ROOTunbraced/escaped/unquoted) is caught — none of these rules exist yet inlint-conventions.sh. The two anchor-exemption sub-checks (class1_md_anchored_exempt,class1_sh_anchored_exempt) read as green today, but only because nothing is flagged at all yet — that's a coincidental green, not a verified exemption; it becomes a real assertion once the Implementer adds the class-1 rule. Post-cleanup non-vacuity also fails, for the same reason aslint_nonvacuity_on_real_tree.7 scenarios: 1 pass, 6 fail, 0 skipped. Verified the loud-skip path separately
(
env -i PATH=/usr/bin:/bin bash scripts/test-plugin-gates.sh): withclaudeoff PATH,
scenario_23_validator_negativereportsSKIP (LOUD): ... claude CLI not found on PATHinstead of a silent pass, and the summary line countsit under
skipped, notpassed.Contract ambiguities pinned down
--dry-run. The WU brief restricts--dry-runto "exercise ONLY with--dry-run" for the green path; itsays nothing about
--dry-runfor the three refusals. I run those withoutit (still only ever in a scratch clone) because with
--dry-runa refusaltest can't distinguish "release.sh's refusal logic caught this" from "dry-run
suppressed all side effects regardless of logic" — e.g. scenario 20's "the
existing tag still points at its original commit" assertion would be true
under
--dry-runeven if release.sh's tag-reuse check were missingentirely. Real invocation in a scratch clone gives a meaningful test;
--dry-runthere would not.claude plugin tag --help:{name}--v{version}.plugin/.claude-plugin/plugin.json .nameis"dev", so the tag prefix isliterally
dev--v(TAG_PREFIXin the script). Not otherwise stated inthe WU brief.
delegates tag enforcement to
claude plugin tag. I did not model thatinternal mechanism in the tests — the suite treats
release.sh [--dry-run] <version>as a black box against the five behaviors the WU brief spellsout (exit codes, tag-set deltas, manifest-field deltas, message content).
Whatever the Implementer's internal flow is (bump-then-tag vs. tag-only),
these assertions hold either way.
"identifies the disagreement (both values or both field locations)" — I
accept either (a) both version strings appear in the output, or (b) both
plugin.jsonandmarketplace(case-insensitive) appear. Whicheverphrasing the Implementer's message uses, one of the two should hold
naturally; I didn't want to force one specific wording.
lint_nonvacuity_on_real_treeand the post-cleanup half of scenario 24scrape the "examined N file(s)" count with a loose regex (
extract_examined_countin the script) rather than requiring one exact phrasing — it matches either
word order (
N files examined/examined N files). Whatever line theImplementer adds, it just needs a number next to the word "examined" and
the word "file(s)".
plugin/skills/setup/SKILL.md(has existing shell fences, safe to append after) and
plugin/skills/_shared/procedures/bin/phase-outcome-read-latest.sh(a realhelper under a
bin/dir, needed for the D4 canonicalization-ban check,which the WU brief scopes to
plugin/skills/**/bin/). Both aresnapshotted and restored between sub-checks so each seed is isolated.
(
plugin/skills/meta-amend/plugin/scripts) rather than apath:linehit, since these are presence-of-a-path violations, not line-level ones —
unlike every other class 1–3/D4/D12 check, which asserts an exact
path:line:hit to avoid a false PASS from an unrelated violationelsewhere in the same file.
make_lint_copy()copies onlyscripts/lint-conventions.sh+plugin/(not the whole repo) into scratch —lint-conventions.shcomputes
REPO_ROOTfrom its ownBASH_SOURCE, and only requires<root>/plugin/skillsto exist (the.claude/skillsroot is optional,skipped if absent). This keeps each of the 16 scenario-24 lint runs at
~1.4s instead of copying
.githistory or the ~30 unrelated top-levelsdlc-*.mddocs.Hardest part: Picking assertion granularity for scenario 24's "flagged"
checks — matching only the exact
path:lineno:of the seeded line (not just"the file appears anywhere in the output") so the checks stay meaningful
after the Implementer's rules exist, rather than trivially passing because
some other violation in the same fixture file got reported.
If I did this again: I'd pin the fixture files (
setup/SKILL.md,_shared/.../bin/phase-outcome-read-latest.sh) down earlier — I picked themafter already sketching the seed logic, which meant re-deriving expected
line numbers by hand once instead of parameterizing from the start.
Implementer phase
Scope: Extended
scripts/lint-conventions.shwith a tier-3 shipped-treesection and created
scripts/release.sh, both in the isolated worktree(
/home/jochem/.claude/repos/devwork-skills-50).scripts/test-plugin-gates.shwas read-only — no test was edited. Nothing under
plugin/,.claude/, themain checkout, or
~/.claude/skills/was touched.Green run:
bash scripts/test-plugin-gates.sh— 7 passed, 0 failed,0 skipped, exit 0, 32.6s (red baseline was 24.5s; the delta is the tier-3
scans, which scenario 24 invokes ~17 times).
Two iterations: iteration 1 added the lint rules (all 15 scenario-24 sub-checks
green on first run, 3/7 scenarios); iteration 2 added
release.sh(7/7). Therefactor pass that followed changed no behavior.
[2026-08-15T15:20] DISCOVERY:
claude plugin tag --dry-runenforces only one third of its trioContext: SREQ D6 says release enforcement is "delegated to
claude plugin tag", and the WU brief separately requires release.sh's own git-only refusals.Before writing either, I probed the CLI in a scratch clone to see how much
--dry-runactually enforces.Pattern:
claude plugin tag --dry-runenforces tag uniqueness only. Adeliberately dirtied tree passed
--dry-runwith exit 0 and a full "wouldcreate tag" report; the same tree with an existing
dev--v0.1.0tag exited 1with a clear message. So the dirty-tree and version-agreement legs of the
enforcement trio are live only on the real (non-dry-run) tagging path.
Why it matters: this makes release.sh's three git-only refusals load-bearing
rather than a convenience layer — under
--dry-runthey are the only thingstanding between a dirty tree and a green gate report. Recorded here because
D6's wording ("enforcement delegated to the CLI") would otherwise read as
license to drop them. Also pinned down:
claude plugin tagandplugin validateboth take a path argument to the plugin root; with no argumenttaglooks for<cwd>/.claude-plugin/plugin.jsonand fails, so every call sitepasses
plugin(or an absolute root) explicitly.[2026-08-15T15:35] DISCOVERY: release.sh must resolve its repo from the cwd, not from its own location
Context: Every helper in this repo resolves its root from
BASH_SOURCE.Copying that idiom into release.sh would have been the natural move.
Pattern:
REPO_ROOT="$(git rev-parse --show-toplevel)"— the repo we wereinvoked in, not the one the script lives in.
Why it matters: the gate suite runs
bash "$RELEASE_SH" <args>withcwdset to a scratch clone while$RELEASE_SHpoints at the working-treecopy. A
BASH_SOURCE-derived root would have made all four release scenariosoperate on the real worktree — creating real tags and real bump commits in
the repo under development, while the tests dutifully reported that the scratch
clone was unchanged. Same property matters in production: a release must be
cuttable from a clone or a worktree. This is the inverse of D7 (the meta lane
also uses
git rev-parse --show-toplevel, for the same class of reason: cwdresets and worktree slots break location-derived roots).
[2026-08-15T15:45] STRUGGLE: D4's "zero violations today" was not zero
Problem: The WU brief specifies the D4 canonicalization ban as
realpath,readlink -f,cd -P,pwd -Pin shippedbin/helpers, described as a pureregression fence with "zero today".
Expected: a literal implementation would be clean on the real tree.
Actual: two live hits.
plugin/skills/_shared/procedures/bin/slot-retire.sh:156(
canon_dir() { ... (cd "$1" && pwd -P); ... }) and the slot test fixturetest/fixtures/slot/bin/teardown-marker.sh:9(pwd -P > .../teardown-ran-for-$name).A literal rule would have failed
lint_nonvacuity_on_real_tree, which requiresexit 0 on the real tree — and I cannot fix it by anchoring the lines, since
plugin/is out of bounds for this work unit.Attempts:
*/bin/*.sh— two false positives.Solution:
realpathandreadlink -fstay banned outright — they have nolegitimate use in a shipped helper and are genuinely zero today.
cd -P/pwd -Pare flagged only when the same line also references$0orBASH_SOURCE, i.e. when the script is canonicalizing its own location.Key insight: D4 is not a ban on canonicalization, it is a ban on
self-canonicalization — "the loaded (symlink) path is canonical; helpers keep
logical
BASH_SOURCE+dirname".slot-retire.shcanonicalizes a directory thecaller passed in, and its own 10-line comment explains why that is required
for correctness (comparing a slot's recorded parent against the slots dir when
one side is reached through a symlinked parent). Banning that would have been
enforcing the letter against the purpose. The narrowed rule still catches the
seeded regression (
p="$(realpath "$0")") and would catch the realisticregression (
cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd). Known limit,documented in the script: a self-location split across two lines
(
d="$(dirname "${BASH_SOURCE[0]}")"thenroot="$(cd "$d" && pwd -P)")escapes the line-scoped check.
[2026-08-15T15:52] STRUGGLE: the path prefix of a grep hit is part of the haystack
Problem: Class 2 flags
_shared/references that lack the${CLAUDE_PLUGIN_ROOT}/skills/prefix. The obvious implementation — grep for_shared/, filter out the prefixed form — reported ~18 clean files.Expected: zero hits on the real tree (a survey confirmed every prose and
fence reference is correctly prefixed).
Actual: every hit was a file living under
plugin/skills/_shared/— thepath:lineno:prefix thatgrep -rnprepends contains_shared/itself, soany line-level filter sees the string whether or not the content has it.
Solution: strip the
^[^:]+:[0-9]+:prefix inawk, do the prefixed-formremoval on the content only, and print the original line if
_shared/survives. Same shape reused for class 3b's
.sh-comment exemption.Key insight: it is worth writing the negative control for a scan before
trusting its green — but equally worth running the new rule against the real
tree first, because that is where this one showed up. The seeded fixture in
plugin/skills/setup/SKILL.mdwould never have caught it:setup/has no_sharedin its path.Rule-scope decisions taken beyond the brief
brief's literal population, and the only one that changes what is flagged.
.shas well as.md. The brief scopes it to "shipped.mdtext" but also says "exempt.shcomment lines", which only makes senseif
.shis in the population. Scanning both with the comment exemption isthe stronger fence and is clean today: all ~40 bare
/promote-style hits inhelpers are pure comment lines naming an owning phase; non-comment hits are
zero.
allow: user-scope-path(5 lines today); class 3b honoursallow: bare-skill-name(0 today). A single blanket anchor would let aclass-1 exemption silently suppress an unrelated class-3b hit.
absent, rather than a violation.
make_lint_copy()builds a tree withplugin/but no root.claude-plugin/, so a hard requirement there wouldfail scenario 24's post-cleanup clean-scan assertion. The skip names the
manifest that went unchecked.
claude-absent skip is a skip, theclaude-absent release is a hardfailure. Lint must stay usable without the CLI; a release without the
validator is not a release (brief step 8). release.sh checks for the CLI
after the three git-only refusals but before the bump, so the common
failure modes still report their real cause and nothing is modified.
Note for QA
--dry-runstill commits the bump when the version actually changes.This follows the brief (steps 5 + 7: gates run against committed state, and
nothing is rolled back), and the Test Writer's positive control tolerates it.
It only fires when the requested version differs from the current one — the
suite's positive control passes the current version, which takes the
idempotent-resume path and commits nothing. release.sh prints a loud line
whenever a bump commit survives a
--dry-run.the
githubremote), beyond what the suite covers: full non-dry-run releaseincluding
tag --pushand theRELEASES.mdcreate-then-append + recordcommit; idempotent resume after a
--dry-runbump (HEAD verified unchanged onthe second run); all four refusals with
claudeoffPATH; argument errors.All refusals exit 1. The real worktree was verified untouched afterwards
(no tags, versions still 0.1.0/0.1.0, only the two intended files modified).
plugin/skillsreportsexamined 0 filesand raises a violation(exit 1); the real tree with
claudeoffPATHprints the loud skip andstill exits 0.
Hardest part: Recognising that D4's stated population and the tree's actual
contents disagreed, and that the right response was to narrow the rule to what
the decision protects rather than to weaken it into a warning or to edit
plugin/.If I did this again: I'd run each new rule against the real tree before
running it against the seeded fixtures. Both false positives (class 2's path
prefix, D4's two legitimate
pwd -Puses) were invisible in the negativecontrols and obvious the moment the rule met 199 real files.
Dev journal — WU-50-3-5 (cutover tooling: cutover-dev.sh, cutover-abort.sh)
2026-08-15T13:40 DISCOVERY: the state machine is what makes abort possible
Context: designing the D11 sequence as a script rather than a checklist.
Pattern: one state file (
~/.claude/cutover-dev-state.json,CUTOVER_STATE_FILE-overridable)holding, per step,
{status, at}plus the facts abort needs: the farm inventory(
name,target_raw,target_abs), the runtime-config backup path, the pre-merge integration SHA,both projects-dir slugs, and where the repo was. Steps assert preconditions, act, then record.
Two rules do the real work: (1) step 2's inventory is written once and never overwritten — it is
abort's baseline, and rewriting it after step 3 would capture a world that had already changed;
(2) the state file is bound to a
(home, repo)pair and refuses to run against a different one,so a scratch rehearsal's state can never be replayed against the real machine.
Why it matters: AC-7 judges restoration against a record. Without a written record, "restore" is
a guess about what the machine should look like.
2026-08-15T14:05 STRUGGLE:
<repo>/plugindoes not exist at step 3Problem: D11 (and the WU) say step 3 creates
~/.claude/skills/dev -> <repo>/plugin, restart,verify the plugin form loads, and only then merge.
Expected: the link resolves and the plugin loads at the STOP.
Actual: pre-merge, the integration branch has no
plugin/at all —git ls-tree mainon this repoconfirms the restructured tree exists only on the feature branch. So the link is dangling at
step 3, the "verify the plugin form loads" stop verifies nothing, and the first thing that makes
<repo>/pluginexist is the very merge the verification was supposed to precede.Attempts:
<repo>/pluginas specified — dangling; STOP is vacuous..claude/skills/from theworking tree, i.e. it breaks the farm at exactly the moment D11 forbids, and buys nothing.
Solution: step 3 resolves the plugin source (
CUTOVER_PLUGIN_SOURCE, else<repo>/plugin, else theworktree holding the feature branch) and links to that; step 4 retargets the link to
<repo>/pluginimmediately after the fast-forward, when the two are the same content, and says so. Consequently the
feature's own worktree is not an S-2 blocker in preflight (slot worktrees still are), and step 6
still refuses to move a repo with any worktree registered.
Key insight: D11's guarantee is "the plugin form is loaded and verified before the tree changes under
the farm", not the literal path
<repo>/plugin. Implementing the path breaks the guarantee.2026-08-15T14:20 STRUGGLE: the D7 helper root has the same hole
Problem: the S-2 forge check uses the D7 pattern
B="$(git rev-parse --show-toplevel)/plugin/skills/_shared/procedures/bin".Expected: the helper is there and the check queries the tracker.
Actual: caught by the scratch rehearsal — pre-merge the repo is on the integration branch, which
carries the pre-restructure layout, so that path does not exist and the S-2 check reported
"cannot run" at exactly the moment it matters. The plain D7 root is the post-cutover answer.
Solution:
resolve_helper_bin()tries, in order: the resolved plugin source'sskills/_shared/procedures/bin, then<repo>/plugin/skills/..., then<repo>/.claude/skills/...(the layout the running suite uses today). It prints which one it used.
Key insight: D7 is correct for the meta lane after the cutover; the cutover script runs across the
boundary and cannot assume either side.
2026-08-15T15:53 DISCOVERY: abort restores the links, not the farm
Context: the first full scratch rehearsal — forward cutover, then abort.
Pattern: abort re-created all 15 farm links byte-identically from the record, restored the runtime
config, the projects-dir slug and the repo location, removed the plugin link — and every one of the
15 links dangled. Abort deliberately does not undo the merge (step 4), and the merge is what moved
.claude/skills/away, so the targets are gone. "Links restored" and "the old form works again" aredifferent claims, and only rewinding the merge closes the gap.
Why it matters: reporting "pre-cutover state restored" there would be false. Abort now counts
dangling links after re-creating them and, when any dangle, records it as an outstanding item — naming
the pre-merge SHA and the exact
reset --hardcommand — and exits non-zero.statusgained the sameawareness: a machine in pre-cutover shape whose links dangle gets its own verdict line
("PRE-CUTOVER SHAPE, BUT n FARM LINK(S) DANGLE … neither form resolves"), because that is precisely the
half-migrated confusion R8 exists to prevent. Operator consequence: aborting after step 4 is a
two-part act — run
cutover-abort.sh, then decide about the merge.2026-08-15T15:20 DISCOVERY: rehearsability is an environment-override discipline
Context: UAT Scenario 8 requires the abort path rehearsed on a scratch copy before the real cutover.
Pattern: every real-machine path is an environment override whose default is the real machine:
CUTOVER_HOME,CUTOVER_REPO,CUTOVER_NEW_HOME,CUTOVER_STATE_FILE,CUTOVER_LOG_FILE,CUTOVER_INTEGRATION_BRANCH,CUTOVER_FEATURE_BRANCH,CUTOVER_PLUGIN_SOURCE,CUTOVER_FEATURE_ISSUE.The forge leg of S-2 is the one thing a scratch home cannot fake, so it has an explicit, loudly-announced
CUTOVER_SKIP_FORGE_CHECK=1.claude plugin listis skipped (with the reason printed) wheneverCUTOVER_HOME != $HOME, because it reads the real$HOMEand its answer would not describe the scratch.Why it matters: the full sequence — including the destructive steps — was exercised end to end
against a scratch replica while the real
~/.claude.json,~/.claude/skills/,~/.claude/projects/and both repos stayed byte-identical (verified by checksum + listing diff before/after).
2026-08-15T16:00 DISCOVERY: rehearsal results
Context: scratch replica of the dev machine's pre-cutover state — a clone at the old path with a
feature worktree, 15 relative farm links, a
~/.claude.jsonmatching the recorded sample shape (thisrepo's entry plus an unrelated project, to prove exactly one key moves), and a projects dir at the
old slug containing a canary memory file.
Forward (19 assertions, all pass). preflight clean → steps 2–3 → STOP → steps 4–5 → step 6 refused
while the feature worktree was still registered (named it) → worktree retired → step 6 → STOP → step 7
from the new location. End state: 0 of 15 farm links present; plugin link →
<new home>/plugin; repomoved; runtime config has the new key, lost the old one, kept the unrelated project (exactly 2 keys) and
preserved
hasTrustDialogAccepted; projects dir renamed with the canary intact; integration branch atthe feature tip; state marked complete. The
statusverdict read CUTOVER COMPLETE.Abort (7 assertions, all pass). All 15 links re-created with byte-identical raw targets; repo moved
back;
~/.claude.jsonbyte-identical to the backup (the pre-abort file preserved alongside, since awhole-file restore also rolls back anything else that changed in it); projects dir back at the old slug;
plugin link removed; merge untouched, with its pre-merge SHA printed. Exit 1 with the dangling-links
item outstanding — correct, and re-running abort was idempotent (0 re-created, 15 already in place).
S-2 negative. With a slot worktree added and a dirty tree, preflight reported both blockers
(it accumulates rather than failing fast) and exited 1 with no step marked done and nothing changed.
The forge leg was exercised against the real tracker read-only in both directions: with
CUTOVER_FEATURE_ISSUE=50it passed (#50 is the cutover's own issue), and withCUTOVER_FEATURE_ISSUE=999it correctly blocked naming
#50 … phase/developing.Edge cases.
statusworks with no state file; a refused command creates no state file;run --step 5out of order refuses naming the first pending step; a virgin home reports NO CUTOVER STATE rather than
the alarming HALF-MIGRATED.
Not covered by the rehearsal (remains UAT): that the plugin form actually loads at the STOP
(Scenario 9's interactive
/dev:checks), andclaude plugin liston the real machine.2026-08-15T16:05 COMPLETE
Hardest part: discovering that the specified step-3 target cannot exist yet — the same pre-merge
layout gap then turned up a second time in the D7 forge-helper root, and both had to be resolved
without weakening D11's ordering guarantee.
If I did this again: I would rehearse the abort before polishing the forward path. The
dangling-links finding — that abort alone does not restore a working farm once the merge has run — is
the single most consequential thing in this work unit, and it only appears after a full round trip.
Dev journal — WU-50-3-6 (README rewrite + P0.8 history scan)
Worktree:
/home/jochem/.claude/repos/devwork-skills-50(branchfeature/50-ship-the-suite-as-a-claude-code-marketplace-plugin)Files owned:
README.md(rewrite),scripts/history-scan.sh(new), the P0.8 report in the feature folder (not committed).2026-08-15T14:05 DISCOVERY: every plugin-CLI command in the README was verified against a real install, not against the help text alone
Context: AC-11 makes the README the desktop cutover's only instruction set, so a command that
merely looks right is a defect. Two verification routes were used:
claude plugin <sub> --helpon2.1.229 for flags, and a throwaway
CLAUDE_CONFIG_DIRfor behaviour — a scratch config dir wheremarketplaces can be added and plugins installed without touching the operator's real
~/.claude/plugins.Pattern: what was checked, and how:
plugin marketplace add <owner/repo>source formmarketplace add --help(--scope,--sparseonly) + the binary's own error stringInvalid GitHub repository format: … Expected format: owner/repo; afile://URL is rejected withTry: owner/repo, https://…, or ./pathdevwork-skills, plugin iddev@devwork-skillsmarketplace add <this worktree>+plugin install dev@devwork-skillsin a scratch config:✔ Successfully installed plugin: dev@devwork-skills (scope: user)plugin list --jsonreports version0.1.0{"id":"dev@devwork-skills","version":"0.1.0","enabled":true}plugin detailsshows the 14 skillsSkills (14) develop, gh-cli, git-worktrees, glab-cli, integrate, local-fs, promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan, worktree-cleanupplugin list --json --availablehas a different shape{installed: [...], available: [...]}, not a bare array — the README's verify commands use the barelist --jsonmarketplace add anthropics/claude-plugins-officialin a scratch config →.gitpresent,origin= the HTTPS URL,rev-list --count HEAD= 1,is-shallow-repository= true, 0 tags. Hence the README'sfetch --unshallow --tagsbefore any tag checkoutSSH not configured, cloning via HTTPS: https://github.com/…scripts/release.sh(prefixrelease: refusing to release <v>:)scripts/cutover-dev.sh(stop_message,do_statusverdicts) andscripts/cutover-abort.sh(COULD NOT RESTORE … DANGLE, the pre-merge SHA lines)uat.modeenum in the consumer templateplugin/skills/_shared/procedures/uat-surface.md—preview-deploy | staging | local(an earlier draft had inventedproduction)Why it matters: three of these contradicted the obvious reading, and each would have cost the
desktop operator a failed step mid-cutover.
2026-08-15T14:10 DISCOVERY: install does not update, update goes downwards, and the
@versionsuffix does not pinContext: AC-9 (the update ritual) and AC-7 (recover the previous release) both need a version to
be selectable.
claude plugin install --helpshows no version flag, so the question was how aconsumer lands on a specific release at all.
Pattern: built a scratch marketplace repo with two tagged versions (0.1.0, 0.2.0) and drove it in
an isolated
CLAUDE_CONFIG_DIR:plugin install <p>@<m>@0.1.0succeeds and installs 0.2.0. The@versionsuffix parses (it isstripped, and
playwright@claude-plugins-official@1.0.0resolves the same as without it) but doesnot select a version. Do not trust it; verify with
plugin list --json.plugin installagainst an already-installed plugin is a no-op:Plugin "…" is already installed (scope: user). An update ritual built oninstallwould silently do nothing.plugin update <p>@<m>moves the install to whatever the marketplace source currently carries, ineither direction: with the source rolled back to 0.1.0 it printed
✔ Plugin "scratchdev" updated from 0.2.0 to 0.1.0 for scope user. Restart to apply changes.Why it matters: the version a consumer runs is a property of the marketplace snapshot, not of
the install command. That is what makes the recovery path "point the snapshot at the old tag, then
update" rather than "install the old version", and it is why the README's update ritual is twocommands, not one.
Not verified from here: the recovery sequence end-to-end against a private, GitHub-sourced
marketplace. Its two premises are verified separately (the snapshot is a git clone;
updatefollowsthe source downwards); the composition is a UAT item, and the README says so and names the fallback
(re-cut the previous content as a new release).
2026-08-15T14:17 STRUGGLE: the generic secret rule blocked on documentation the first time it ran
Problem: the first full-history run reported
P0.8: BLOCK (15 hits).Expected: a clean history — this repo has never carried a credential.
Actual: every hit was the same line of QA example code in
_shared/domains/api.md:token = response.json()["token"].Attempts:
<your-token>,changeme,${VAR}). That is the naturalphrasing and it is the wrong shape:
response.jsonis not a placeholder, so it still blocked.Solution:
real_value()now requires the assigned value to survive a series of "this is not asecret" shapes (a function call, a path, an
ALL_CAPSenv-var name, a dotted identifier, anall-lowercase word run, a known placeholder word) and then look random: at least 16 characters
with both digits and letters, or 32+ opaque characters.
Key insight: an exclusion list answers "is this a known placeholder?", which is unbounded. The
question a scanner can actually answer is "does this look like credential material?" — and phrasing
it that way keeps a real credential in a documented example blocking, which the exclusion-list
version would eventually have let through.
2026-08-15T14:25 DISCOVERY: the scan's own non-vacuity control
Context: a clean scan of a clean history is indistinguishable from a scan that cannot find
anything — the same failure mode AC-3's negative controls exist for.
Pattern: built a scratch repo, planted one instance of each of the nine Class A shapes in a
commit, then deleted them all in a following commit so HEAD is clean and only history is not. The
scan reported all nine from history, with values redacted (
[REDACTED 34 chars]), and exited 1.Benign neighbours planted alongside (
token: <your-token-here>,password: changeme,TOKEN = os.environ["TOKEN"], a long absolute path) did not fire.Two other guards fell out of the same reasoning and are in the script: the run dies if the awk in
use does not support ERE intervals (mawk 1.3.3 would silently reduce every token rule to a rule that
matches nothing), and it dies if the population comes out empty rather than reporting PASS over
zero blobs.
Why it matters: the P0.8 PASS below is evidence, not silence.
2026-08-15T14:35 DISCOVERY: P0.8 result on this repo's full history
Context: the gate on the first push to
github.com/jbr870/devwork-skills(D5).Pattern: run at the branch tip
ca517f0— 239 commits, 9 refs, 1191 blobs reachable, 1191scanned (nothing skipped as binary or oversized; the object store holds no unreachable blobs).
Class A: 0 hits across all nine patterns —
P0.8: PASS.Class B (advisory, acceptable while the repo is private):
internal-domain80 hits / 75 blobs, ofwhich
git.wihslon.comis 76 / 73;email-address215 / 100 (mostlygit@git.wihslon.comin the oldREADME's clone command and
test@example.comin QA domain examples);operator-home-path23 / 12;tailscale7 / 4;desktop-hostname6 / 6;ssh-remote-url5 / 5;private-ip0.Why it matters: the push is unblocked, and the visibility decision now has its inventory. Every
Class B pattern would be published verbatim if the repo were ever made public — the private-forge
hostname most of all, since it appears in run reports, the devcontainer files and
.env.example.2026-08-15T14:40 COMPLETE
Commits:
d0b7e79(scripts: history-scan.sh),ca517f0(docs: README rewrite). Gates after both:bash -nclean,scripts/lint-conventions.shclean (199 files examined),scripts/test-plugin-gates.sh7/7 PASS. S-5 holds — the two commits touch
scripts/history-scan.shandREADME.mdand nothing else.Hardest part: deciding how to document the version-selection path honestly once the probes showed
the
@versionsuffix is accepted but ignored — the README had to be specific enough to execute andexplicit about which step has not been run end-to-end.
If I did this again: I would probe the CLI's install/update semantics in a scratch
CLAUDE_CONFIG_DIRbefore drafting the install section rather than after, since three sections hadto be rewritten around what the probes returned.
P0.8 — full-history sensitive-data scan
Produced by
scripts/history-scan.shon 2026-08-15T14:28:30Z.This gate exists because pushing a repo publishes its history, not its tip
(SREQ D5). It runs before the first push to the GitHub remote.
Scope
/home/jochem/.claude/repos/devwork-skills-50ca517f0b49b4cd604340d03b434cdd6a58939a6d(feature/50-ship-the-suite-as-a-claude-code-marketplace-plugin)Class A — secrets (push-blocking)
No hit on any of the 9 credential patterns below.
private-key-blockgithub-tokengithub-fine-grainedgitlab-tokenaws-access-keyslack-tokenai-api-keynetrc-credentialsassigned-secretClass B — internal information (advisory)
Acceptable while the repo is PRIVATE (SREQ D5). This is the inventory to
read before any decision to make it public — none of it blocks the push.
internal-forge-hostinternal-domaindesktop-hostnametailscaleoperator-home-pathemail-addressprivate-ipssh-remote-urlClass B samples
internal-forge-host — 76 hit(s) in 73 blob(s), first 3 shown:
15b7c3cbcb) — # SSH agent forwarding from host (git over SSH to git.wihslon.com):f811c0c793) — --url "https://git.wihslon.com" \39d28b4e53) — # Get one at https://git.wihslon.com/user/settings/applicationsinternal-domain — 80 hit(s) in 75 blob(s), first 3 shown:
15b7c3cbcb) — # SSH agent forwarding from host (git over SSH to git.wihslon.com):f811c0c793) — --url "https://git.wihslon.com" \39d28b4e53) — # Get one at https://git.wihslon.com/user/settings/applicationsdesktop-hostname — 6 hit(s) in 6 blob(s), first 3 shown:
af24779bef) — - the desktop (jochems-forge) — currently carries a second clone plus its own farm; becomes a pure consumer.cdc5d50322) — reg("B", "desktop-hostname", "jochems-forge", "the desktop machine name")8428495ed8) — from the deployed checkout at 'jochems-forge:~/.claude/repos/devwork-skills':tailscale — 7 hit(s) in 4 blob(s), first 3 shown:
cdc5d50322) — reg("B", "tailscale", "tailscale|\.ts\.net", "tailnet naming")1c921c427b) — ## Third pass (same day): transcript sweep over tailscale SSH1c921c427b) — tailscale peer with passwordless SSH from this box — transcripts, the verity checkout, and theoperator-home-path — 23 hit(s) in 12 blob(s), first 3 shown:
f4d574c21f) — # /home/jochem/.claude/repos/devwork-skills -> -home-jochem--claude-repos-devwork-skillsf4d574c21f) — # /home/jochem/Coding/devwork-skills -> -home-jochem-Coding-devwork-skillscdc5d50322) — reg("B", "operator-home-path", "/home/jochem", "absolute home paths carrying the username")email-address — 215 hit(s) in 100 blob(s), first 3 shown:
c36c691280) — "email": "test@example.com", # From CLAUDE.md test accountsc36c691280) — email: 'test@example.com', // From CLAUDE.md test accounts8f57b71848) — response = client.post("/api/users", json={"name": "", "email": "test@example.com"})ssh-remote-url — 5 hit(s) in 5 blob(s), first 3 shown:
2ac6d48bf7) — git clone ssh://git@git.wihslon.com:2222/jbr870/devwork-skills.git ~/devwork-skillsed90bf2052) — git clone ssh://git@git.wihslon.com:2222/jbr870/devwork-skills.git ~/devwork-skills1a5c8ee5f0) — git clone ssh://git@git.wihslon.com:2222/jbr870/devwork-skills.git ~/devwork-skillsLimitations
scanned:
git pushtransmits only what the pushed refs reach.and no secret-looking variable name is not detectable by these rules; the
assigned-secretrule is the only generic one, and it excludes values thatlook like placeholders (so a documented
token: <your-token>does not block,and a documented real value still does).
indistinguishable from a git SHA, and this history is full of SHAs. A rule
for it would be noise, not signal.
decision, not a gate on the push.
Verdict
P0.8: PASS (no push-blocking findings)
P0.8 re-verification (QA round 1) — supersedes the probe record in comment 1027.
The QA fix round strengthened
scripts/history-scan.sh(CR-1: Class-A samples carry locations only, never line content; CR-2: commit and annotated-tag objects join the scan population — messages and idents are pushed too; CR-38: the genericassigned-secretrule now matches case-insensitively), so the recorded PASS no longer described the current scanner and was re-established:glpat-seed, one assigned-secret assignment) introduced by the never-pushed red-tests commit 4576252.e049e3f) before any remote ever saw it — the literal-bearing blobs are unreachable and are not transmitted by a push.861e496— 242 commits, 1202 blobs scanned, 242 commit objects (messages + idents), 0 annotated tags, 0 push-blocking findings. Class B advisory inventory unchanged in character (internal hostnames/paths — acceptable while the repo is private, per D5).$SCRATCHPAD/p08-rerun-report.md(local; regenerate any time withscripts/history-scan.sh).The first-push gate therefore still holds as PASS, now against the stronger scanner and the exact history that will be pushed.
UAT report — plugin migration (#50)
Executed across both machines, 2026-08-13 → 2026-08-22. Full per-scenario evidence with commands and
output:
.devwork/feature-…/uat-log.md(gitignored, dev machine). This is the summary the decisionrests on.
What was proved
/dev:and bare names both resolved in the pre-cutover window0.1.0cut and pushed (first ever push to GitHub, P0.8-gated); desktop installed from the marketplace and ran a real forge-mutating phaseTwo releases exist:
dev--v0.1.0→ee15687,dev--v0.1.1→8c9a94e.The core claim holds. A consumer machine with no clone, no symlink farm and no local copy of the
suite installs the plugin from a private GitHub marketplace, resolves
/dev:skills, and runshelper-invoking phases that write to a live forge — proved end to end on verity (glab-cli), where
/dev:requirementscreated issue #239, posted a validPO-239-1, and advanced its phase label. Theupdate ritual then moved that machine 0.1.0 → 0.1.1 and a subsequent run resolved every plugin file
through the newer tree.
Findings: 34 recorded, 21 fixed
defect set, the two Scenario 22 findings, the headless-consumer gap (F28), and the release script's
remote mismatch (F13).
wording, not product).
.devwork/, not 4;.gitignorelists the directory but never untracked them), F5, F12 — exposed bythis work, not caused by it. These want deduping against their siblings before separate filing.
that has now served its purpose. Worth a decision on whether they are worth fixing at all.
The two that bear on acceptance
UAT-F34 — AC-7's documented recovery path does not work. "The desktop's previous release remains
installable from the remote as its recovery path." Run verbatim:
fetch --unshallow --tagsandcheckout dev--v0.1.0both succeed, thenclaude plugin updatereports "already at the latestversion (0.1.1)". Measured cause: the snapshot sat at
ee15687/0.1.0immediately before thecommand and
8081969/0.1.1immediately after —plugin updaterefreshes the marketplace itself,undoing the pin before it compares.
uninstall+installbehaves identically. The README alreadyanticipated this and names the working alternative (re-cut the previous content as a new release,
consumer takes the normal update ritual), which needs nothing but the remote. That alternative is
currently untestable: 0.1.0 and 0.1.1 ship byte-identical content, so a re-cut could not be
distinguished from a no-op.
UAT-F33 — AC-12's attribution half is unimplemented. "Desktop run records are attributable to the
installed release version."
PO-239-1, the one real consumer-machine record, carries no versionanywhere; nothing in
phase-outcome.md's schema orphase-outcome-post.shstamps one. Attributiontoday depends on the operator hand-pasting the version per the README's consumer-runbook step 8 — and
in the one real run, it was not done. Records either side of an update are not distinguishable. The
fix (stamp the version from
${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.jsoninto the outcomeheader) is a helper behaviour change, so it is full-pipeline work, not a meta-lane amendment.
Neither is a defect in the plugin form itself; both are gaps in acceptance criteria this feature
claimed. They are the reason this report does not simply say "approve".
Recommendation
Approve the migration and split the two gaps out as siblings — the deployment-mechanism change is
done, proved, and already in daily use on both machines, and holding it open behind a helper change
(F33) and an upstream CLI behaviour (F34) would keep a finished thing unfinished. The alternative,
rejecting to fold F33 into this feature, is defensible if AC-12 is considered load-bearing rather
than aspirational; it costs a full pipeline pass on the helper tier.
One cross-finding worth carrying to the siblings
F1 and #35 are the same trap from opposite ends. #35's fix direction is to replace
~/.claude/skills/…in domain docs with the plugin-root variable — but F1 is precisely that theplugin-root variable is not expanded when a subagent reads a procedure file off disk (it is
expanded only in the injected SKILL.md). Applying #35's fix naively would swap one unresolvable path
for another and look correct in review. Whichever is picked up first should be told about the other.
Addendum (2026-08-22) — the cold re-walk, a retracted verdict, and where F33/F34 sit
Releases now:
0.1.0,0.1.1,0.1.2. The desktop runs0.1.2.Scenario 13 re-walked cold, and still fails
A fresh-context executor, given only the 0.1.1 README and no knowledge of the cutover or the earlier
walk, found 4 blocking and 11 non-blocking defects. All are fixed in
0.1.2. AC-11 demands zero,so Scenario 13 does not pass yet; a second cold re-walk against
0.1.2is running.The worst finding retracts a verdict I had already recorded. The consumer-guidance sweep used
~/Coding/*/CLAUDE.md ~/Coding/*/*/CLAUDE.md— two levels deep, no dot-directories. On the desktopthat glob sees 4 of 34
CLAUDE.mdfiles. Re-run withfind: 67 stale bare invocations and 6dangling
~/.claude/skills/glab-cli/paths, where the glob reported zero.My Scenario 17 result ("all consumer projects are
/dev:-only — PASS") was produced with that sameglob, so it inherited the defect it was checking for. That PASS is withdrawn. Six live
CLAUDE.mdfiles in verity's slot worktrees were still instructing agents to invoke bare names thatno longer resolve and to read an adapter path that no longer exists — a cutover recorded as complete
that was not. Verifying a fix with the instrument that produced the defect proves nothing.
Now actually fixed (operator-approved, slots idle): all six rewritten to
/dev:with the adapterdescribed as bundled and no path named; pre-existing uncommitted work in slot1 preserved and
verified; backups in
/tmp/slot-claude-backup/; left uncommitted for the repo owner. The machine-widesweep is now 0 files with bare invocations, 0 with farm paths. Note the durable fix is committing
the corrected guidance on verity's own branches — the slot worktrees are detached-HEAD checkouts of
three older commits, so their working-tree fixes do not survive a re-pin.
A methodological finding, recorded against my own testing
The re-walk filed one false finding: that the "SSH first, then HTTPS" ordering was contradicted
by the marketplace clone's reflog. That reflog was residue from my own Scenario 21 and 22 tests,
which had forced an HTTPS re-clone hours earlier; the pre-test evidence shows the original clone was
SSH. The executor reasoned correctly from evidence the tester had polluted, and had no way to see it.
A UAT that mutates a machine contaminates every later cold read of that machine, invisibly. The
second re-walk brief now says so explicitly.
F33 and F34 — folded into this issue, not split out
Per operator decision, both stay in scope here rather than becoming siblings:
0.1.2. Thetag-pin recovery cannot be made to work —
plugin updaterefreshes the marketplace before itcompares, and
uninstall+installdoes the same, so no ordering of CLI commands preserves thepin. The README now leads with the route that does work (re-cut the old content as a new release),
explains why the obvious one fails, and shows how to discover which releases exist. AC-7's
substance — the previous release remains installable from the remote, needing nothing but the
remote — holds via that route.
into a phase outcome, so records either side of an update are indistinguishable.
0.1.2makes theREADME honest about it (it now says plainly that nothing stamps this for you, and treats the
operator paste as a stopgap), but honesty is not implementation. The fix belongs in
phase-outcome-post.shand is a helper behaviour change — full pipeline per this repo's ownconventions.
Consequence for the gate. With F33 folded in, this feature has implementation work outstanding,
so
D-PO-50-5-1cannot honestly be answered "approve" today. The deployment-mechanism change itselfis done, proved on two machines, and in daily use; what remains is one helper change and one more
cold re-walk.
AC-11's bar, restated — operator decision (2026-08-22)
Three cold re-walks have now been run against successive releases, each by a fresh executor with no
knowledge of the cutover, the previous walks, or their findings.
Every finding was fixed; releases
0.1.2,0.1.3and0.1.4carry them.AC-11 as written — zero log entries needing outside knowledge — is not converging. Blocking
findings are drying up; non-blocking are not. And a consistent share of each round's findings are
defects introduced by the previous round's fixes (a deleted section heading, orphaned edit residue,
and one piece of advice that would have silently broken nine unrelated user-level skills). Pursuing
zero-of-everything with the same editor is a treadmill rather than a gate.
Restated bar, by operator decision: Scenario 13 passes when a cold re-walk returns zero BLOCKING
findings. Blocking = the reader would be stopped or misdirected, which is what "needed outside
knowledge" was always about. Non-blocking findings are recorded and triaged as ordinary tracker
items; nobody gets stuck on imprecise wording.
Recorded as a deliberate narrowing of the criterion, with the evidence above, rather than a quiet
reinterpretation. Round 4 against
0.1.4decides Scenario 13 under this bar.UAT note — issue #50, Scenario 13 closure. Not a Phase Outcome. The original first line
carried a
phase-outcome:v1HTML header, which madescan_commentsabsorb this comment intothe #50 Phase Outcome ledger:
phase-outcome-read-all.shhard-failed on it and the next POwould have been minted at the wrong ordinal. Header removed 2026-08-22; body unchanged below.
Scenario 13 (README sufficiency) — CLOSED, and the bar restated
Released 0.1.5 (
dev--v0.1.5, release commit5a64031), pushed togithub; consumer machineupdated and verified on
0.1.5 / user / enabled. Gates at release: lint clean, README structureaudit clean (its first run as release gate 2/4),
plugin validate --strictboth roots, tag dry-run,and P0.8 full-history scan PASS with no push-blocking findings.
What this scenario is closed against
Not "zero findings from adversarial cold readers." That bar was adopted after round 3 and it is
unachievable by construction: it is an absolute claim about natural language, judged by fresh readers
explicitly instructed to hunt for gaps. Three such readers will find something in any prose document.
Recommending that bar was a mistake; it produced a treadmill, not a decision procedure.
Closed instead against a standard that can actually be met and checked:
desktop was migrated by following it, farm removed, plugin resolving, no bare names.
across 8 independent cold readers; all are closed at
ecc30b1.plus (this round) an internal-anchor check and a per-fence
bash -npass with placeholderssubstituted.
Why we stopped, with the measurement
The process was generating its own findings. Each round I fixed by adding text: +437, +185,
+162 lines. More text is more audit surface, and round 6's blocking count went up. Rounds 1–3 added
two lines total and found little — that correlation is the explanation, and I should have acted on it
at round 5 rather than reporting each round as an independent measurement.
My fix rate was roughly one new blocking defect per defect closed. Two of round 6's three
unanimous blocking findings were the round-5 fixes written for round 5's blocking findings. A loop
in which the fixer is the principal defect source does not terminate on its own.
What the six rounds were actually worth
Genuinely valuable (would have bitten a real operator):
basenamecollided — seven paths here shareapi, so each backupdestroyed the last, of exactly the untracked files the step exists to protect
rm -rfguarded bygit status --short, which is blind to ignored files, i.e..devwork/plugin updatesilently updating nothing at project scope (-sdefaults touser;enableauto-detects,
updatedoes not)enabled: falsediagnosed with no cure givenThe remainder is polish on a document whose realistic readership is the operator, on two machines
that are already migrated.
Known and accepted
end to end. Trimming is the honest fix and is deliberately not done now: it is more work in the
same direction. Revisit only if it causes friction in practice.
~/Coding/castellan/.claude/settings.jsonstill grants dead farm paths(
Bash(/home/jochem/.claude/skills/_shared/...)). Left for the operator — another project'ssettings are not this ticket's to edit.
Still open on #50
F33 / AC-12 — nothing stamps the plugin version into a phase outcome. Helper behaviour change
(
phase-outcome-post.sh), so it takes the full pipeline, not the meta lane.D-PO-50-5-1cannothonestly resolve to "approve" until that is settled or explicitly deferred.
Linked: this issue is relates_to #34 (recorded by the devwork pipeline).
Cut by this promote run.
dev--v0.1.6is the first release containing23fd477, thelast outstanding deliverable of this issue (the consumer-runbook fix pointing step 8 at
#34). The other 40 commits of the
code-bundle:v1shipped incrementally acrossdev--v0.1.0..dev--v0.1.5while the feature was in flight.status: tag-only— release.sh pushes tags to thegithubremote only; the Gitea forgehas no tag to hang a Release object on, so there is no release URL to record.
nonebranch unreachable #55