- Shell 97.8%
- HTML 2%
- Dockerfile 0.2%
| .claude/skills | ||
| .claude-plugin | ||
| .devcontainer | ||
| .devwork | ||
| plugin | ||
| scripts | ||
| sdlc-dogfood-run-reports | ||
| sdlc-retrospectives | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| devwork-skills-presentation.html | ||
| README.md | ||
| RELEASES.md | ||
| sdlc-context-economy-analysis.md | ||
| sdlc-develop-settled-gate-and-precondition.md | ||
| sdlc-devwork-skills-deep-dive.md | ||
| sdlc-dogfood-retrospective-2026-08-07.md | ||
| sdlc-git-forge-helper-shim-scope.md | ||
| sdlc-git-forge-issues-migration-plan.md | ||
| sdlc-git-forge-issues.md | ||
| sdlc-git-forge-phase-1-walkthrough-report.md | ||
| sdlc-git-forge-walkthrough-report.md | ||
| sdlc-lifecycle-conductor-analysis.md | ||
| sdlc-outwrit-run-c-session-analysis.md | ||
| sdlc-process-observability-analysis.md | ||
| sdlc-promote-redesign-25.md | ||
| sdlc-qa-disposition-fixnow-and-gate.md | ||
| sdlc-qa-disposition-recommendation-criteria.md | ||
| sdlc-qa-flow-redesign-19b.md | ||
| sdlc-qa-redesign-briefing.md | ||
| sdlc-qa-single-context-sequencer.md | ||
| sdlc-qa-ui-domain.md | ||
| sdlc-suite-simplification-decisions.md | ||
devwork-skills — the dev plugin
A stack-agnostic SDLC skill suite for Claude Code, shipped as a marketplace plugin named dev.
Fourteen skills carry a feature from requirements to production and record every phase on your
project's forge (Gitea/Forgejo, GitLab, GitHub, or the local filesystem):
/dev:setup → /dev:git-worktrees → /dev:requirements → /dev:technical-plan → /dev:develop
→ QA → /dev:integrate → /dev:promote → /dev:worktree-cleanup
(QA is a playbook the orchestrator runs, not a skill of its own; /dev:resolve answers the pending
decisions any phase raises, and /dev:simplify-report records what a simplification pass changed.)
Every shipped skill is invoked under the plugin namespace: /dev:<skill>. Bare names do not
resolve — that is deliberate, and it is what keeps a stale habit failing loudly instead of quietly
running something else.
This repository is both the source of the plugin and its marketplace. It also carries a
repo-local meta lane (/meta-amend, /meta-retrospective) used to develop the suite itself.
The meta lane is not shipped and is unreachable from any consumer machine.
Contents
- Which machine are you on?
- Prerequisites
- Install (consumer)
- Updating to a new release
- Recovering a previous release
- When it fails
- Old name → new name
- Runbook: cutting a consumer machine over
- Runbook: cutting the dev machine over
- Cutting a release
- Consumer CLAUDE.md guidance block
- Repository layout
Which machine are you on?
| Role | Which one are you | What that machine looks like once it is set up |
|---|---|---|
| Consumer (e.g. the desktop) | you use the suite here — you run its skills against your own projects, and you do not edit the skills themselves | the released plugin, installed from the GitHub marketplace. Moving to a new release is a deliberate marketplace update + plugin update. Releases only: no escape hatch for unreleased work, and nothing you edit. (The marketplace snapshot under ~/.claude/plugins/ is a git clone of this repo — 7 MB, scripts/ and all — but it is the CLI's working copy, not yours: it is overwritten on every refresh, so never edit or rely on it.) |
| Dev machine (the suite operator) | you author the suite here — the skill text itself is what you change | this repo, loaded live via ~/.claude/skills/dev -> <repo>/plugin. It is the source; git is its version identity. |
Pick your row by what you do on the machine, not by what is currently installed on it. A machine part-way through a migration matches neither right-hand column — the runbooks below are how it gets to one.
If you are installing the suite to use it, you are a consumer: read Prerequisites and Install. The dev-machine cutover and release sections are the operator's.
One check before you go there. If this machine already runs the suite as a symlink farm —
ls -la ~/.claude/skills/ shows links pointing into a clone of this repo — do not start at
Install. Use the consumer runbook instead: it installs
the plugin as its step 3, but only after taking a baseline that stops existing, and it tears the
farm down in the right order. Starting at Install would leave you mid-migration with the baseline
already unrecoverable.
# A farm link points into a clone of THIS repo — decided by the git remote, not by
# any symlink being present: your own symlinked skills are not a farm.
# `find`, not a glob: under zsh an unmatched glob is a fatal error, and "no links"
# is exactly the case this has to survive.
FARM=$(find "$HOME/.claude/skills" -maxdepth 1 -type l 2>/dev/null | while IFS= read -r l; do
d=$(cd -P "$l" 2>/dev/null && pwd -P) || continue
r=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || continue
git -C "$r" remote -v 2>/dev/null | grep -q 'devwork-skills' && printf '%s\n' "$l"
done)
if [ -n "$FARM" ]; then
printf 'farm link: %s\n' "$FARM"
echo "FARM PRESENT — use the consumer runbook, not Install"
else
echo "no farm here — Install is your path"
fi
A machine with a farm shows roughly fifteen such links, one per shipped skill plus _shared, all
resolving into the same clone. One or two unrelated links are your own skills, not a farm.
Prerequisites
Per machine. Check each one before doing anything else — anything that reports a version is present;
anything that prints nothing or errors (tea: command not found) is missing.
Run these in a login shell, or the check lies to you. Over a non-interactive connection —
ssh host 'command', a CI step, an agent driving another machine — the login profile is not read,
so tools installed under ~/.local/bin, a version manager, or a package manager's shim are simply
not on PATH. Verified on a real consumer machine: claude, tea and glab each reported
command not found that way while all three were installed and working. Use ssh -t host for an
interactive shell, or wrap the command: ssh host 'bash -lc "claude --version"'.
bash --version | head -1 # bash >= 3.2 (macOS's system bash qualifies)
jq --version # any recent jq
git --version
claude --version # the Claude Code CLI (>= 2.1.229 for the commands in this README)
Plus the forge CLI that every project on this machine declares in its CLAUDE.md
forge.adapter: — one machine commonly spans several, so enumerate before deciding what you need:
ROOT=~/Coding # <- your projects root; substitute yours
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' | wc -l # <- read this FIRST
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' -print0 | xargs -0 grep -Hn 'adapter:'
Read the count before the matches. A wrong or unset ROOT searches nothing and prints nothing,
which looks exactly like "no project declares an adapter" — and you would conclude you need no forge
CLI at all. The count must be non-zero and roughly match the number of projects you believe you
have; if it is 0 or implausibly small, fix ROOT and run it again. A search is only evidence once
you know it looked at something. (The same rule applies to both sweeps in the
cutover runbook; it is stated there too, because a
search that reports clean about ground it never covered is the one failure this document most wants
to prevent.)
The adapters and their CLIs:
tea --version # forge.adapter: tea-cli (Gitea / Forgejo)
glab --version # forge.adapter: glab-cli (GitLab)
gh --version # forge.adapter: gh-cli (GitHub)
# forge.adapter: local-fs needs no CLI, but does need a `date`/`touch` that can
# handle sub-second timestamps. It ships its own probe, which is the check.
# It cannot run yet — the path carries the installed version — so run it at
# Install step 7, which calls you back to it. Capability is per-FILESYSTEM,
# so it is probed once per local-fs project, not once per machine.
You only need a forge CLI for the forges your projects actually use, and it must be authenticated for that forge — installed is not enough, and an unauthenticated CLI fails at the first phase that posts, not at install. Each has its own check; run the one(s) your projects need:
tea login list # tea-cli — expect a login whose URL matches your forge.instance
glab auth status # glab-cli — expect "Logged in to <host> as <user>"
gh auth status # gh-cli — expect "Logged in to github.com as <user>"
This is a different question from the GitHub read access the plugin install needs, below: a
project on GitLab needs glab authenticated and GitHub access for the plugin itself. The
suite's own helpers need nothing beyond bash, jq and git.
Separately, and regardless of any of that: every consumer machine needs read access to the private
GitHub repository this plugin is distributed from. That is not a forge-adapter question — a
project that never touches GitHub still installs the plugin from GitHub. If your account does not
have access, no command in this document can grant it: ask whoever administers
jbr870/devwork-skills to add you, then come back to Install step 1. The
credential setup itself is step 1; gh auth login is one way to satisfy it and needs gh installed,
but a git credential helper with a read-only PAT works just as well and needs no gh.
If something is missing. jq and git come from your package manager. bash ≥ 3.2 is satisfied
by every system bash still shipping, macOS's included. The forge CLIs have their own install
instructions (tea, glab, gh). The one that catches people is claude itself: the commands
in this README need ≥ 2.1.229, and an older CLI fails in ways that look like plugin problems
rather than version problems — claude update (or reinstalling by whatever route you installed it)
is the fix. If claude --version reports not found rather than an old number, claude update
has nothing to update: install Claude Code first by whatever route your machine uses, then come
back. Check the floor before you debug anything else:
claude --version
Install (consumer)
The distribution remote is the private GitHub repository jbr870/devwork-skills. Private means
the install fails without a credential, so verify access first — a failed clone inside step 2 is
much harder to read than a failed ls-remote in step 1.
1. Verify you can read the remote. Check the transports the install will actually use: the CLI tries SSH first, then HTTPS, so if this machine has a GitHub SSH key, that is the path that matters — a check of HTTPS alone can pass while the install takes a different route.
GIT_TERMINAL_PROMPT=0 git ls-remote git@github.com:jbr870/devwork-skills.git | head -3 # SSH
GIT_TERMINAL_PROMPT=0 git ls-remote https://github.com/jbr870/devwork-skills.git | head -3 # HTTPS
One of the two printing <sha>\trefs/... is enough — the install needs one working path, not
both. GIT_TERMINAL_PROMPT=0 is not decoration: without it, HTTPS prompts for a username, and
over a non-interactive connection (ssh host 'command', a CI step, an agent-driven run) that
hangs rather than failing. With it you get could not read Username … terminal prompts disabled and can move on.
If neither prints refs — Repository not found, Authentication failed, Permission denied (publickey), or a prompt — you have no working credential. Fix that first:
gh auth login # as an account with read access; choose HTTPS and let it
# set up git authentication when asked
gh auth status # expect a ✓ line naming github.com and your account; the exact
# wording varies by gh version (2.46: "✓ Logged in to github.com account <you>")
gh auth login is the simplest path because it stores the token in the OS keyring and installs
itself as git's credential helper. A fine-grained read-only personal access token (contents:
read, this repository only) is the tighter option; give it to a credential helper that encrypts,
never to a URL and never to a plaintext file:
git config --global credential.helper libsecret # Linux keyring; macOS: osxkeychain
git credential-libsecret --help >/dev/null 2>&1 \
|| echo "libsecret helper NOT installed — git will silently fall back to prompting every time"
git ls-remote https://github.com/jbr870/devwork-skills.git # prompts once; username = your GitHub
# account, password = the PAT
# NEEDS A TERMINAL — see below
Check that the helper exists, as above. git config accepts any helper name without verifying
it, and a missing one does not error — git just falls back to prompting, so "prompts once" becomes
"prompts every time" and you would reasonably conclude the credential never stored. On Debian and
Ubuntu git-credential-libsecret is commonly not built: the package ships source under
/usr/share/doc/git/contrib/credential/libsecret that must be compiled, or you install a packaged
helper (libsecret-1-0 plus a distro git-credential-libsecret, names vary). macOS's
osxkeychain ships with git.
This step must be run at a real terminal. Unlike the checks above it deliberately does prompt,
so over ssh host 'command', in CI, or in an agent-driven run it hangs rather than failing — the
same hazard the GIT_TERMINAL_PROMPT=0 note describes, in the one place you cannot use that guard.
Use ssh -t host, or do this leg by hand before automating anything.
Prove the storage worked by running the ls-remote a second time: it must not prompt again. If
it does, the helper is not functioning regardless of what git config reports.
(credential.helper store also works but writes the token in clear text to ~/.git-credentials —
use it only if no keyring helper is available, and know what you are accepting.)
Re-run the ls-remote until it prints refs. Do not continue before it does.
2. Add the marketplace.
claude plugin marketplace add jbr870/devwork-skills
marketplace add also takes -s user|project|local and defaults to user, like the install
commands. If you intend a project-scope install in step 4, add the marketplace at that scope too
(-s project) — a project-scope plugin installed from a user-scope marketplace works on this
machine but is not self-contained in the project.
owner/repo is the GitHub source form (the command also takes an https:// URL or a local path;
this remote uses owner/repo). The CLI clones the repository — SSH first, then HTTPS — and
prints which it used. Observed directly: with the SSH path denied, the CLI logged
Cloning repository: git@github.com:… and then Cloning repository: https://github.com/… before
succeeding, and a first install on a machine with a working GitHub key produced a snapshot whose
remote was git@github.com:. Note this ordering only holds for the owner/repo form; an explicit
git@… or https://… URL is taken literally and gets no fallback.
What you should see: the clone line(s) above and a confirmation naming the marketplace. Step 3
is the actual verification, so do not linger here — but two outcomes are worth naming. If the
command reports the marketplace is already added (you are re-running this after a failure
elsewhere, as "When it fails" may send you to do), that is success, not an error:
go straight to step 3, or run claude plugin marketplace update devwork-skills first if you want a
fresh snapshot. If it fails to clone, the credential from step 1 is not reaching git — go back to
step 1 rather than retrying this command.
3. Verify the marketplace points where you think it does.
claude plugin marketplace list --json
Expect an entry for this suite — alongside any other marketplaces this machine already uses — and
check its repo field:
[
{
"name": "devwork-skills",
"source": "github",
"repo": "jbr870/devwork-skills",
"installLocation": "/home/<you>/.claude/plugins/marketplaces/devwork-skills"
}
]
The marketplace is named devwork-skills; the plugin inside it is named dev. Both names
matter: every command below addresses the plugin as dev@devwork-skills.
4. Install the plugin.
claude plugin install dev@devwork-skills
Expect ✔ Successfully installed plugin: dev@devwork-skills (scope: user). In an interactive
session the CLI may ask you to confirm; accept it. Running this non-interactively? This plugin's
marketplace entry declares no install command, so there is no confirmation to accept and the install
proceeds unattended; -y exists for the case where a marketplace does declare one, and is
harmless here. Add -s project or -s local if you want a scope other than the default user.
The install is a snapshot of the marketplace's current state, so it lands on whatever version the remote's default branch carries — which, on this repo, is the newest release.
5. Verify the installed version and inventory.
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope)"'
Expect a SemVer version — whatever the newest release is, never unknown — and one line. The
scope is printed rather than assumed: the same plugin can be installed at user scope and project
scope at once, and this command is the only place you would notice. Two lines is not automatically
wrong — two lines at different versions is, and which one a session runs then depends on where it
was started. Then:
claude plugin details dev@devwork-skills
Expect Skills (14) listing exactly: develop, gh-cli, git-worktrees, glab-cli, integrate, local-fs,
promote, requirements, resolve, setup, simplify-report, tea-cli, technical-plan, worktree-cleanup.
(The command prints more than the list — a projected token-cost table per skill, among other things.
That is expected; the list is what you are checking.)
6. Restart Claude Code — exit every running session (/exit, or Ctrl-D) and start claude
again. A plugin change is picked up at session start, so a session that was already open when you
installed will not see it no matter how long you wait; other open sessions on the same machine keep
serving the old state until they too are restarted. You will know it took when the next check
passes. Then confirm the skills resolve: type /dev: and expect the fourteen to
appear, and run /dev:setup in a project (cancel it once it is clearly running). On a fresh
install a bare name (/requirements) must not resolve. If you are migrating a machine that still
has the old symlink farm, skip that last check here — bare names still resolve until the farm is
removed, and that overlap is deliberate; the consumer runbook
makes the check after the teardown instead.
7. If any project here declares forge.adapter: local-fs, run its probe now. This is the one
prerequisite that could not be checked before installing, because the path carries the installed
version. Skip it if the enumeration in Prerequisites found no local-fs project.
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version)\t\(.scope)"'
Expect one line. If two scopes report different versions, resolve that split first — probing one
tells you nothing about the other. Then, with V set to the version you just read:
V=<the version printed above>
P=~/.claude/plugins/cache/devwork-skills/dev/"$V"/skills/local-fs/bin/probe_pin.sh
# ONE RUN PER local-fs PROJECT. --dir names the filesystem being certified.
bash "$P" --dir <path-to-a-local-fs-project>
Probe each local-fs project, not the machine. What is being tested is a filesystem
capability — whether touch can pin a sub-second mtime there — so the answer can differ between two
projects on one machine, and a pass proves nothing about a directory on another mount. Run it with
--dir pointing at each project the Prerequisites enumeration reported as local-fs. Without
--dir the probe picks a directory itself: the devwork project root if it can find one by walking
up from where you are standing, and otherwise the current directory — so run from your home
directory after an install, it answers for your home filesystem. Naming --dir removes the guess.
Reading the result: use the exit status, not the wording. Exit 0 is a pass and prints a single
line beginning probe_pin: ready —. Any non-zero exit is a fail, including the case where the
probe could not run at all. Most failure messages end in NOT ready, but not all of them do — the
date-capability checks report things like `date` cannot express milliseconds: … with no such
suffix, and a mistyped flag exits with unknown arg:. So do not pattern-match the text:
bash "$P" --dir <project>; echo "exit=$?" # 0 = usable here; anything else = not
If it says NOT ready. That filesystem cannot record the comment timestamps the local-fs
adapter relies on, so local-fs is not usable there — the message names which step failed
(date normalisation, touch pinning, or read-back). Your options, in order: move the project to a
filesystem that passes (the common cause is a mount that drops sub-second mtimes — some network and
FAT-family mounts do); install a coreutils whose date/touch support %N and -d @epoch.frac;
or declare a different forge.adapter for that project, which needs no such capability. Do not
proceed with local-fs on a filesystem that failed the probe: the failure surfaces later as
comments whose edits cannot be detected, not as an error.
If runs here are ever non-interactive
An installed plugin lives outside your project directory, and the suite's skills read their own
procedure files and shell helpers from it at runtime. In an interactive session you never notice —
you approve the prompts as they come. In a non-interactive one (claude -p, a CI step, a run driven
by another agent) those reads are auto-denied, and the failure is quiet in the worst way: the
phase still writes its local artefacts, so it looks like it worked, while never reaching the forge
at all.
If that applies, grant the plugin path and your forge CLI in the project's
.claude/settings.local.json. Write out an absolute path for this machine — run echo "$HOME"
and paste the result, rather than typing ~ or copying the example's prefix. On macOS home is
/Users/<you>, on Linux /home/<you>, and a rule whose path does not match the machine is not an
error: it simply never fires, which is the same silent auto-deny this section exists to prevent.
An absolute path is correct on every machine, so it is the one form worth learning.
Merge these keys into the file; do not paste over it. The block below is shown whole for
clarity, but .claude/settings.local.json usually already exists and may carry unrelated
permissions — replacing it wholesale removes grants something else depends on. Add the entries to
the arrays that are already there.
{
"permissions": {
"allow": [
"Bash(/home/<you>/.claude/plugins/cache/devwork-skills/**)",
"Bash(glab:*)"
],
"additionalDirectories": ["/home/<you>/.claude/plugins/cache/devwork-skills"]
}
}
Substitute your own forge CLI for glab. Key the rule to that parent directory, never to a
version. The installed path is …/devwork-skills/dev/<version>/, so a rule naming 0.1.0 stops
matching at the next release — and stops matching silently, which puts you back where you started.
Then prove it fired, because its failure mode is silence. From the project directory, run one non-interactive read of a file inside the plugin — the same kind of read the skills make at runtime:
cd <the project>
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope)"' # expect ONE line
V=<the version that printed> # if two scopes printed different versions, fix that first
C="$HOME/.claude/plugins/cache/devwork-skills/dev/$V" # absolute: the rule you wrote is absolute
# (a) the file-read half — exercises additionalDirectories
claude -p "Read $C/skills/_shared/forge-contract.md and reply with its first heading."
# (b) the helper-execution half — exercises the Bash(...) rule, which (a) does not
claude -p "Run this and show me its output: bash $C/skills/_shared/procedures/bin/issue-read-body.sh --help"
Both must succeed — (a) returning the file's heading and (b) returning the helper's usage text.
They test different keys: a passing (a) with a failing (b) means additionalDirectories is right and
the Bash(…) rule is not, which is precisely the half-configured state that runs a phase's reads and
then fails silently at the first helper call. A refusal, an empty answer, or a complaint about
permissions on either one means the grant is not working — and that is the state that otherwise
stays invisible until a phase silently fails to reach your forge.
Updating to a new release
Updating is deliberate and takes two commands: refresh the marketplace snapshot from the remote, then move the install onto it.
claude plugin marketplace update devwork-skills
claude plugin update dev@devwork-skills -s user # name the scope — see below
marketplace update prints a confirmation naming the marketplace it refreshed; its exit code is
what matters, because a refresh that fails to reach the remote can still print reassuring text and
still exit 0 is not something to assume — check it explicitly:
claude plugin marketplace update devwork-skills; echo "exit=$?" # must be 0
A non-zero exit means the snapshot was not refreshed, so whatever plugin update does next is
against stale content; fix the GitHub credential (step 1) before going on —
The install fails because there is no credential
covers the same root cause from the install side. plugin update prints the version change
explicitly:
✔ Plugin "dev" updated from 0.1.0 to 0.2.0 for scope user. Restart to apply changes.
-s user is not decoration, and it is why this is written out rather than left bare: update
defaults to user rather than detecting where the plugin is installed, so on a project- or
local-scope install the bare form reports success having moved nothing. If you installed at another
scope, name that one, and run it once per scope you use. The paragraph below spells this out.
Then verify:
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope)"'
Then restart Claude Code — the CLI said Restart to apply changes and meant it. Until you do,
running sessions keep serving the old version, which reads as "the update did not take".
How you learn there is something to update to. Nothing notifies you. marketplace update pulls
the default branch, so plugin update moving you is itself the signal; to look before you leap:
M=~/.claude/plugins/marketplaces/devwork-skills
git -C "$M" fetch --tags # without this you read whatever tags were last fetched —
# a snapshot that has only ever been *refreshed* carries
# none, so the list looks stale or empty and is not evidence
[ -f "$M/.git/shallow" ] && git -C "$M" fetch --unshallow # only if it is actually shallow
git -C "$M" tag -l 'dev--v*' | sort -V | tail -3
This is the same listing the recovery section uses, including the
conditional --unshallow leg: the snapshot is cloned shallow, and without that leg a shallow clone
under-reports the tag list while looking perfectly healthy. Both fetch commands need the GitHub
read access step 1 established.
And note scope — update does not find your install, it defaults to one. claude plugin update
takes -s user|project|local|managed and defaults to user (claude plugin update --help,
CLI 2.1.235). It does not auto-detect the scope the plugin is installed at — claude plugin enable
is the command that does that, which makes the pair easy to confuse. So a project- or local-scope
install is not touched by a bare claude plugin update dev@devwork-skills; the command reports
success having updated nothing you were looking at.
Name the scope explicitly, once per scope you actually use:
claude plugin update dev@devwork-skills -s user
claude plugin update dev@devwork-skills -s project # only if you installed at project scope
If the same plugin is installed at two scopes, updating one leaves the other behind — the version split the install verification calls a real problem. Re-run the scope-printing command above afterwards and confirm every line moved, rather than trusting the success message.
Three things worth knowing, all of them verified rather than assumed:
-
claude plugin installwill not update an existing install. Run against an already-installed plugin it reportsPlugin "dev@devwork-skills" is already installedand changes nothing. Version moves happen throughclaude plugin update. -
claude plugin updatefollows the marketplace snapshot in both directions. If the snapshot carries an older version than the one installed,updatemoves you down to it and says so. That is the mechanism the recovery path below relies on. -
claude plugin updateexits 0 even when the marketplace refresh failed. With no working credential it reports success with the failure folded in as a warning:✔ dev is already at the latest version (0.1.0). Warning: marketplace not refreshed (Failed to refresh marketplace 'devwork-skills': …) — version shown may be stale.and exits 0. So
marketplace updateis the command whose exit code means anything: if it fails, stop —plugin update's "already at the latest version" is only ever as true as the last successful refresh. Read the output, not just the status, and never wire a scripted update ritual toplugin update's exit code alone.
A consumer machine is releases-only. If you need something that is not released yet, the answer is a release, not a local edit: there is no clone on this machine to edit.
Recovering a previous release
The working route below is an operator procedure, run on the dev machine. If you are on a consumer machine, your move is to ask the operator to cut the re-release; the consumer half is the ordinary update ritual and is spelled out at the end.
Every release tag pins the exact content it was cut from, and release tags are never moved — so the content is always recoverable. Getting a consumer machine back onto it is not, however, a matter of pinning the marketplace snapshot. That was the obvious route and it does not work; the working route is below.
First, see what exists. The version to go back to is not something to guess or copy out of this document.
You are on the dev machine, so the answer is in your clone of this repo — not in a marketplace
snapshot. A dev machine has no ~/.claude/plugins/marketplaces/devwork-skills at all (it installs
the suite from the live symlink, per the role table), so do not go
looking for one:
cd <your clone of devwork-skills>
git fetch --tags # release tags may have been cut from another machine
git tag -l 'dev--v*' | sort -V # every release that exists
grep '^|' RELEASES.md # or read the record: version, tag, release commit
# (rows are appended, so the newest is LAST)
If you are reading this on a consumer machine (to see what is available, not to recover)
The consumer's equivalent is the marketplace snapshot. Querying it is fine — it is a real clone.
Note that fetch does write to it (refs and objects), which is safe and is not the thing the
warnings elsewhere forbid: what you must never do is edit its working tree or keep anything of your
own in it, because every refresh overwrites that. A fetch only adds upstream history the next refresh
would have brought anyway.
M=~/.claude/plugins/marketplaces/devwork-skills
git -C "$M" fetch --tags # bring the tags in; the snapshot is cloned without them
[ -f "$M/.git/shallow" ] && git -C "$M" fetch --unshallow # only if it is actually shallow
git -C "$M" tag -l 'dev--v*' | sort -V # every release that exists
grep '^|' "$M/RELEASES.md" # or read the record: version, tag, release commit
--unshallow is conditional on purpose: it errors with "repository is complete" on a clone that
is not shallow, which includes any clone this has already been run on. Both fetch commands need
the same GitHub read access step 1 established — if that has lapsed, the fetch
fails and the tag list silently reports whatever was last fetched, which is not evidence.
The route that works: re-cut the old content as a new release. From the dev machine:
git checkout dev--v0.1.0 -- plugin/ .claude-plugin/ # the content you want back
git commit -m "revert shipped content to 0.1.0"
scripts/release.sh <next-version> # a NEW version number, never a reused one —
# release tags are never moved, so a version
# already released is refused
git push github main
Then, on the consumer machine, take the ordinary update ritual — this is a separate act on a separate machine, and nothing above does it for you:
claude plugin marketplace update devwork-skills # check the exit code: echo $? -> must be 0
claude plugin update dev@devwork-skills -s user # name the scope, as in Updating
# expect: updated from <old> to <next-version>
-s user for the same reason as in Updating: update defaults to
user rather than finding your install, so on a project- or local-scope install the bare form
reports success while leaving you on the bad release — which is the one outcome this whole section
exists to prevent. Run it once per scope you use, then verify and restart:
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope)"'
Every line must read <next-version>. Then restart Claude Code — until you do, the session you are
in keeps serving the release you are trying to get off.
It lands on <next-version>, whose content is the older release's. The consumer side needs nothing
but the remote — no clone, no file copied by hand.
Why not just point the snapshot at the old tag
Because the snapshot is not what the update reads. Checking out dev--v0.1.0 in the marketplace
clone works and looks convincing, and then:
✔ dev is already at the latest version (0.1.1)
claude plugin update refreshes the marketplace itself before it compares, which undoes the
checkout in the same breath. Measured on a real consumer machine: the snapshot was at the pinned
commit with marketplace.json reading 0.1.0 on the line before the command, and back on the
default branch at 0.1.1 on the line after. uninstall followed by install behaves identically —
verified pinned immediately beforehand, installed 0.1.1 anyway. There is no ordering of these
commands that keeps the pin, because every one of them starts by refreshing.
Checked on CLI 2.1.236. If a later CLI makes the pin survive, this section gets shorter — but a step that did not work as written is a README defect, so report it either way.
When it fails
The install fails because there is no credential
git ls-remote (step 1) is the check that catches this before the CLI does. If you skipped it,
claude plugin marketplace add jbr870/devwork-skills fails while cloning, naming the repository as
not found or the authentication as failed — GitHub reports a private repository you cannot read as
missing, so "Repository not found" usually means "not authenticated", not "does not exist".
The message names only the last transport it tried. The CLI attempts SSH, then HTTPS. If your
intended credential is an SSH key, a broken key still surfaces as the HTTPS failure — unable to get password from user — which sends you hunting for a token you do not need. Check both
transports (step 1) rather than reading the error as a verdict on one of them.
Confirm nothing was half-installed:
claude plugin marketplace list --json | jq -r '.[] | select(.name == "devwork-skills")'
claude plugin list --json | jq -r '.[] | select(.id == "dev@devwork-skills")'
Both must print nothing. Filtering rather than eyeballing matters here: these lists hold everything the machine has (a real machine here carries several marketplaces and a dozen-plus plugins), and scanning them by hand for an absent entry is the one reading task you are worst at in the middle of a failure.
What matters is that neither names this suite, which is why both commands are filtered. Run them
without the jq filter and neither comes back empty on a real machine — they list everything you
have; a real machine here shows several marketplaces and fourteen plugins. (An earlier draft of this
section said "both should come back empty", measured on a throwaway config directory holding nothing
else. Diagnosing a healthy machine because a list is non-empty is exactly the wrong turn, and the
filter is what removes that trap.) A failed add produces no marketplace, and with no marketplace
there is nothing to install from. If a working install was already present, the second command is
how you confirm the failed attempt did not disturb it — drop the select to read its version, which
should be exactly what it was.
Then fix the credential (gh auth login, or store the PAT), re-run git ls-remote until it prints
refs, and repeat the install from step 2.
The install succeeded but /dev: does not resolve
Install step 6 tells you to check for this and, until now, gave you nowhere to go with it. In order:
-
Did you restart? The CLI says
Restart to apply changesfor a reason; a session started before the install does not see it. Restart before investigating anything else. -
Is it actually installed, and at what scope?
claude plugin list --json \ | jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope) \(.enabled)"'No line means the install did not land — go back to Install step 4. A project-scope line means it resolves only in that project.
A line ending in
falsemeans it is installed but disabled, which is why nothing resolves. Enable it —enableauto-detects the scope, so you do not normally need-shere:claude plugin enable dev@devwork-skillsThen re-run the listing above and confirm the line now ends in
true. A restart is required before the skills resolve.Every install/uninstall command takes
-s user|project|local(defaultuser), so a wrong-scope install is fixed by uninstalling from the scope that has it and installing into the one you want:claude plugin uninstall dev@devwork-skills -s project claude plugin install dev@devwork-skills -s user -
Are you in a directory the scope covers? A project-scope install does not resolve elsewhere.
-
Does the install tree actually hold the skills? Pin the path to the version printed by check 2 above — the cache keeps older versions alongside the current one, and an unpinned path answers for whichever sorts first, not for the one you are running.
V=REPLACE-ME # <- paste the version printed by check 2 above. Leave it literal and the command # fails loudly; paste a real-but-wrong version and it succeeds while # answering for a release you are not running. ls ~/.claude/plugins/cache/devwork-skills/dev/"$V"/skills/Expect the fourteen skill directories plus
_shared. An empty or missing tree means a broken install; replace it rather than repairing it by hand:claude plugin uninstall dev@devwork-skills # add -s project|local if that is the scope claude plugin install dev@devwork-skills
If all four check out and /dev: still resolves nothing, that is a defect worth
reporting with the output of check 2 above attached.
A release is refused
Operator only — a consumer never sees these messages.
scripts/release.sh refuses before it writes anything, and says which of the five conditions it is.
The message always begins release: refusing to release <version>: and continues with one of:
| Condition | The message continues | What to do |
|---|---|---|
| Wrong marketplace entry | .claude-plugin/marketplace.json plugins[0].name is '<x>' but the plugin manifest's name is 'dev'… |
reorder the plugins[] entries (or fix the name) so this plugin's entry is first |
| Dirty working tree | the working tree is dirty (uncommitted changes, listed above). A release ships HEAD… |
commit or stash, then re-run |
| Version fields disagree | the two version fields disagree — plugin/.claude-plugin/plugin.json says '<a>', .claude-plugin/marketplace.json plugins[0] says '<b>'… |
reconcile them in a commit of their own; release.sh is the only thing allowed to write either field |
| Version already released | tag dev--v<version> already exists — that version is already released… — or, when the tag points at HEAD with agreeing manifests, tag dev--v<version> already exists and points at HEAD… |
bump to a new version; tags are never moved. The tag-at-HEAD variant also covers the tagged-but-push-failed case and names the verify/push commands — follow those instead of bumping |
| Wrong branch | HEAD is on branch '<b>', not the integration branch 'main'… |
switch to main (consumers install what the default branch carries); RELEASE_INTEGRATION_BRANCH overrides the expected name |
Each exits non-zero and creates no tag. A failure in a later gate (lint, strict validation,
claude plugin tag --dry-run) happens after the version bump has been committed; that commit stays,
and the script tells you so — re-running release.sh <same version> detects the
committed-but-untagged state and resumes at the gates instead of bumping twice.
Reporting a defect
Several steps in this document tell you to report something — a step that did not work as written, an install that verifies clean but does not resolve. Here is where that goes.
Open an issue on the suite's tracker, jbr870/devwork-skills — the same repository you install
from. If you have web access to it, the issue tracker there is the right place; if you only have
read access to the git remote, send it to whoever administers that repository (the same person the
Prerequisites section tells you to ask for access).
Include, in order of usefulness:
claude --version
claude plugin list --json | jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope) \(.enabled)"'
uname -s # Linux or Darwin — several defects here are platform-specific
plus the exact command you ran, its full output, and the README line number you were following. A step that did not work as written is a defect in this document, not a mistake you made — the line number is the most valuable thing in the report, because it is what makes the fix findable.
Old name → new name
Every shipped skill moved into the dev namespace. Bare names no longer resolve anywhere.
| Was | Now |
|---|---|
/setup |
/dev:setup |
/git-worktrees |
/dev:git-worktrees |
/requirements |
/dev:requirements |
/technical-plan |
/dev:technical-plan |
/develop |
/dev:develop |
/simplify-report |
/dev:simplify-report |
/integrate |
/dev:integrate |
/promote |
/dev:promote |
/resolve |
/dev:resolve |
/worktree-cleanup |
/dev:worktree-cleanup |
/tea-cli (forge adapter) |
/dev:tea-cli |
/glab-cli (forge adapter) |
/dev:glab-cli |
/gh-cli (forge adapter) |
/dev:gh-cli |
/local-fs (forge adapter) |
/dev:local-fs |
/meta-amend, /meta-retrospective |
unchanged — still bare. They are repo-local to devwork-skills (project scope), are not part of the plugin, and do not resolve on a consumer machine at all. |
Project-scope adapter overrides still win. A consumer project that ships its own adapter at
./.claude/skills/<adapter>/ keeps using it: discovery is project scope → plugin-bundled →
user scope. Putting an adapter in your project is still the way to override the bundled one.
Runbook: cutting a consumer machine over
For a machine that currently runs the old form — a symlink farm in ~/.claude/skills/ pointing into
a manually-pulled clone. The order matters: the plugin is installed and verified before anything is
deleted, so there is never a moment when neither form works.
Plan for someone being at the machine. Step 3's verification is a human act — typing /dev: in
an interactive session and watching the completions — and it gates the teardown, so a cutover
driven from another machine cannot finish on its own. That is not a detail to discover at the gate:
either sit at the machine for step 3, or arrange the hand-off before you start. The same is true of
an agent-driven cutover for a different reason — the destructive steps (4, 5) meet the agent's own
permission layer, so an operator has to be on standby to approve them regardless.
1. Check the prerequisites (above) and note anything missing. A forge CLI you do not have is only a problem for projects that declare it.
2. Record what is there now, so you can tell afterwards whether it is gone — and capture the clone's real path while the evidence still exists. The farm links are the only record of where the clone actually is; step 4 deletes them. Derive it now rather than assuming a location:
ls -la ~/.claude/skills/ # the farm: links and their targets
# A farm link is one whose TARGET sits inside a clone of THIS repo. Identify that by
# the git REMOTE, never by how the path is spelled: the clone can live anywhere and be
# named anything, and a directory called devwork-skills need not be one. Keep this
# function — steps 5 and 7 use the same test, and they must agree.
farm_root() { # prints the clone root if $1 is a link into a clone of this repo
d="$(cd -P "$1" 2>/dev/null && pwd -P)" || return 1 # -P resolves the link, portably
r="$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" || return 1
git -C "$r" remote -v 2>/dev/null | grep -q 'devwork-skills' && printf '%s\n' "$r"
}
# `find`, not a glob — zsh makes an unmatched glob fatal (see the routing check above).
R=$(find "$HOME/.claude/skills" -maxdepth 1 -type l 2>/dev/null | while IFS= read -r l; do
farm_root "$l" && break
done)
printf 'clone: %s\n' "${R:-<none found — this machine may have no clone>}"
cd -P … && pwd -P rather than readlink -f: it is POSIX (macOS's readlink has no -f), it
resolves a relative or multi-hop link, and it yields the same canonical path every step here uses —
a step that canonicalises differently from the step that deletes is how a link survives a teardown
that reported success.
Write that path down. If R is empty, this machine may genuinely have no clone — step 7's
remote-based scan is what confirms it, and it is the only check entitled to that conclusion.
Expect 15 links (14 skills + _shared) pointing into the clone.
3. Install the plugin — steps 1–7 of Install, including the restart, the
/dev: check, and (step 7) the local-fs probe if any project here declares that adapter. A
migration needs that probe exactly as much as a fresh install does. Do not continue until a
/dev: skill has actually run. Both forms resolve at this
point (bare names from the farm, /dev: names from the plugin); that overlap is the safety net —
which is why Install step 6's "a bare name must not resolve" check does not apply here yet. On a
cutover that check belongs after step 4, and step 7 below is where it is made.
4. Remove the farm links. Only symlinks, only ones pointing into the old clone:
# farm_root() from step 2 — same shell, or paste it again. The teardown MUST use the
# same test as the capture, or it removes a different set than the one you inspected.
find "$HOME/.claude/skills" -maxdepth 1 -type l 2>/dev/null | while IFS= read -r l; do
farm_root "$l" >/dev/null || continue
printf 'removing %s\n' "$l"; rm "$l"
done
If this prints nothing, do not read that as "already clean" — it also means farm_root was
never defined in this shell (you would see farm_root: command not found first) or that the links
point somewhere unexpected. Compare the count against what step 2 recorded.
5. Remove the clone — if this machine has one. Use the R you derived in step 2, not a
guessed location: a clone can live anywhere and be called anything.
R=<the path step 2 printed> # NOT a fixed location — step 2 derived it
if [ -z "$R" ] || [ ! -d "$R" ]; then
echo "no clone at the path step 2 derived — do NOT conclude the machine is clean;"
echo "step 7's remote-based scan is what settles that. Go to step 6."
else
git -C "$R" status --short # expect no output (tracked changes)
git -C "$R" status --short --ignored # expect no output — see below; .devwork/ hides here
git -C "$R" log --branches --not --remotes # expect no output (nothing unpushed)
git -C "$R" stash list # expect no output (a stash exists nowhere else)
git -C "$R" worktree list # expect ONLY $R itself
fi
git status --short alone is not a safety check before rm -rf. It says nothing about
ignored files, and the thing most likely to be sitting in this clone is .devwork/ — gitignored
by design, and under some adapters the local staging area for work that has not reached the forge.
It also says nothing about stashes or linked worktrees. All four checks must be silent (and
worktree list must show only $R) before you delete anything; any output is data that exists
nowhere else.
The wording matters: an absent directory means this path holds no clone, which is not the same claim as "this machine has none". Only step 7, which matches on the git remote rather than on a path or a name, is entitled to the second one.
If any of those commands prints something, stop and deal with it — a consumer machine's clone should
be a pull-only copy, and anything else is a change that exists nowhere else. A fatal: from git here
means $R is not a git repository, which is a reason to stop and re-derive it, not to proceed:
the guard above already handled the "no clone at that path" case, so reaching git with a bad $R
means the path is wrong and deleting it would delete something else. When the clone exists and every
check is silent:
rm -rf "$R" # the path you verified above — never a literal typed fresh here
Delete the directory you just checked, not a path you retype: verifying one location and deleting another is how a clean-looking cutover leaves the real clone in place.
6. Rewrite the consumer guidance. Every project CLAUDE.md that names a shipped skill must name it
under /dev:. Let the sweep be the checklist — per-project counts go stale the moment anyone edits a
project, so this step quotes none.
Find the files with find, not with a glob. ~/Coding/*/CLAUDE.md ~/Coding/*/*/CLAUDE.md
reaches exactly two levels and skips dot-directories, so it silently misses everything deeper —
monorepo packages, and every linked worktree of a multi-slot repo. On one real consumer machine that
glob saw 4 of 34 CLAUDE.md files, and six of the thirty it missed each carried a dozen bare
invocations plus a path into the farm that step 4 deletes. The greps print nothing and the step
self-certifies as done.
ROOT=~/Coding # <- your projects root; substitute yours, this is not a default that fits everyone
# Count what the sweep can see BEFORE trusting what it does not find. "No hits" and
# "no files" print identically, and a wrong ROOT produces the second while looking
# like the first — the exact self-certification this step replaced the glob to avoid.
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' | wc -l
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' -print0 \
| xargs -0 grep -nE '(^|[^:[:alnum:]])/(setup|git-worktrees|requirements|technical-plan|develop|simplify-report|integrate|promote|resolve|worktree-cleanup|tea-cli|glab-cli|gh-cli|local-fs)($|[^[:alnum:]_-])'
Read that count first. It must be non-zero and roughly match the number of projects you believe
you have — a monorepo or a multi-worktree repo contributes many. If it is 0 or implausibly small,
ROOT is wrong and every silence below it is meaningless. The same applies to the prerequisite
enumeration earlier and to the second sweep further down: a search is only evidence once you know it
looked at something.
find will print a Permission denied line for any directory it cannot read. That is deliberately
not silenced: it means a subtree went unscanned, and an unscanned subtree is not a clean one — the
same rule as step 7. Read those lines and judge whether a project could live there.
Before editing: these are live repositories, not scratch. Do this before you change the first file, not after — an untracked file destroyed here has no copy anywhere.
-
Leave the changes uncommitted for each project's owner to review. You are editing someone's repo; the rewrite is trivially checkable from a diff.
-
If a tree is already dirty, note what was dirty before you started, so your changes stay separable from theirs.
-
An untracked CLAUDE.md has no baseline to revert to.
git status --shortshowing??meansgit checkout -- CLAUDE.mdwill undo nothing. Copy it aside first:P=<path to the project> # e.g. ~/Coding/verity B=/tmp/cutover-backup$(printf '%s' "$P" | tr '/' '_').CLAUDE.md [ -e "$B" ] && { echo "REFUSING: $B exists — rename it first"; } || cp "$P/CLAUDE.md" "$B"The whole path goes into the name, not just the last component. A monorepo has many
apps/api/CLAUDE.md-shaped paths — on the machine this was written against, seven directories share the basenameapi— and abasename-derived name would have each backup overwrite the last, destroying the only copy of exactly the files this step exists to protect. The[ -e ]guard is the belt: a backup that would clobber an existing backup refuses instead. This is not hypothetical — one project on the desktop was an untracked CLAUDE.md.
Rewrite each hit to /dev:<name>. Read each hit rather than rewriting blind: the pattern matches a
shipped skill name after any non-alphanumeric, so prose that merely mentions a name can match as
readily as an invocation, and only you can tell which is which. (It is tighter than it looks in one
respect: a URL ending in /setup does not match, because the character before the slash is
alphanumeric.)
Some hits are correct as they stand, so "re-run until it prints nothing" is the wrong completion test — it would have you corrupt prose to silence a grep. A sentence like "this replaces the old /setup flow" is documentation, not an invocation, and must keep its bare name. There is at least one such line on a real machine.
The completion test is therefore: every remaining hit is one you have read and consciously decided is prose. Make that decision durable instead of holding it in your head. Create the list first — empty is its correct starting state, and it must exist before anything reads it:
DECIDED=/tmp/cutover-prose-hits.txt
: > "$DECIDED" # create it NOW, empty. Append one "file:line:text" per hit you
# deliberately leave bare — copy the sweep's own output line verbatim.
sweep() { # the raw hit list; -H so a single-file machine still gets paths
# $HOME/.claude is excluded: with ROOT=~ the marketplace snapshot is a clone of THIS
# repo, and its own CLAUDE.md would be handed to you as a project file to rewrite.
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' -not -path "$HOME/.claude/*" -print0 \
| xargs -0 grep -HnE '(^|[^:[:alnum:]])/(setup|git-worktrees|requirements|technical-plan|develop|simplify-report|integrate|promote|resolve|worktree-cleanup|tea-cli|glab-cli|gh-cli|local-fs)($|[^[:alnum:]_-])'
}
filter='
BEGIN {
r = (getline line < D)
if (r < 0) { printf "FILTER FILE MISSING: %s — refusing to report a result\n", D > "/dev/stderr"; exit 2 }
while (r > 0) { if (line != "") d[line] = 1; r = (getline line < D) }
}
!($0 in d)'
Then, after each pass of rewriting, run the work list and its counts together:
sweep | awk -v D="$DECIDED" "$filter" # what is still to do
printf 'sweep: %s hits, %s cleared as prose, %s remaining\n' \
"$(sweep | grep -c .)" "$(grep -c . "$DECIDED")" \
"$(sweep | awk -v D="$DECIDED" "$filter" | grep -c .)"
Read the counts, not the silence. 0 remaining out of a plausible number of hits is done; 0 hits total means the sweep found nothing at all and ROOT is probably wrong. The filter is written
with awk and an explicit getline for one reason: the obvious spellings of this test lie. A
grep -vFf against a file that does not exist prints nothing and exits 2 — which reads as "done" —
and under some grep builds an empty filter file suppresses every line too; awk's common
NR==FNR idiom has the same flaw for an empty first file. This version fails loudly when the file
is missing and passes everything through when it is empty, which are the two states you actually
start in.
Keep the file when you are finished: it is the record of what you decided and why the step terminated, and it makes a re-run months later cheap instead of a re-litigation.
A worktree layout has one CLAUDE.md per worktree, and they are separate files. Do not assume a repo with linked worktrees shows you the same file from several paths: on a real seven-worktree repo all 28 were distinct inodes, and the seven top-level ones carried five different contents, because each worktree sits on its own commit. Edit every one the sweep returns and re-run the filtered sweep above until it is empty — that, not a mental model of the layout, is the completion test.
Leave /qa-* references bare. QA is a playbook the orchestrator runs, not a shipped skill, so
/dev:qa-… would name nothing. The sweep does not match them, which is easy to misread as
"already clean" — they are correct as they stand, and rewriting them would break them.
Then a second sweep, for paths into the farm you are about to delete. The sweep above matches
invocations only; by design it cannot match a path like ~/.claude/skills/glab-cli/:
find "$ROOT" -name CLAUDE.md -not -path '*/node_modules/*' -not -path "$HOME/.claude/*" -print0 \
| xargs -0 grep -HnE '\.claude/(skills|repos)'
A project that points at a bundled adapter by path should instead describe it as bundled with the
dev plugin and name no path at all — the installed path carries the plugin version, so any
path written into a project's CLAUDE.md goes stale at the next release.
Three places the sweeps above cannot reach, and all three matter more than a project file. Both
sweeps open only files named CLAUDE.md, so anything else is invisible to them by construction.
# 1. The CLAUDE.md loaded into EVERY session, whatever directory you start in.
# NOT silenced: a missing file and a file with no hits must not look alike.
if [ -f ~/.claude/CLAUDE.md ]; then
grep -nE '(^|[^:[:alnum:]])/(setup|git-worktrees|requirements|technical-plan|develop|simplify-report|integrate|promote|resolve|worktree-cleanup|tea-cli|glab-cli|gh-cli|local-fs)($|[^[:alnum:]_-])' \
~/.claude/CLAUDE.md || echo "no hits in ~/.claude/CLAUDE.md"
else
echo "~/.claude/CLAUDE.md does not exist — nothing to check (not the same as 'clean')"
fi
# 2. Farm-era permission rules at USER scope: is the plugin cache granted anywhere?
# Nothing here grants it by default, which is the same silent auto-deny the install
# section warns about, one level up. Read additionalDirectories too — a grant can
# live in either key, and `repos` is where the farm-era clone lived.
jq -r '(.permissions.allow[]?, .permissions.additionalDirectories[]?)
| select(test("\\.claude/(skills|repos)|plugins/cache"))' ~/.claude/settings.json
# 3. PER-PROJECT permission files — .claude/settings.local.json, the file the install
# section tells you to create. Farm-era grants live here too, and no CLAUDE.md sweep
# can see them. Same two keys, same shapes.
find "$ROOT" -path '*/.claude/settings*.json' -not -path '*/node_modules/*' -print0 \
| while IFS= read -r -d '' f; do
out=$(jq -r '(.permissions.allow[]?, .permissions.additionalDirectories[]?)
| select(test("\\.claude/(skills|repos)"))' "$f" 2>/dev/null)
[ -n "$out" ] && printf '%s:\n%s\n' "$f" "$out"
done
Rewrite hits in the first exactly as above.
For the second: do not remove a ~/.claude/skills/** rule. That directory is where your own
user-level skills live — the farm put its links among them, and removing the links (step 4) does not
empty the directory. On the machine this runbook was written against, nine unrelated skill
directories remained there, and the rule is what lets them be read in non-interactive sessions;
dropping it would break them silently, for no benefit. Target the links, never the directory — the
same principle as step 4 and step 7.
What may be missing is a grant for the plugin cache. If you want the suite usable from
non-interactive sessions anywhere rather than per project, add both entries from
the non-interactive section at user scope — the
permissions.allow entry and the additionalDirectories entry, which solve different halves of
the problem; adding only the first reproduces the silent auto-deny that section exists to prevent.
Use the version-free form of the path, and merge them alongside whatever is already there rather
than replacing the file.
7. Verify no remnants.
# No farm links into the old clone. Same REMOTE-based test as steps 2 and 4 — not
# "every symlink here": an unrelated symlinked skill of your own is not a remnant.
# Paste farm_root() from step 2 if this is a fresh shell. `find`, not a glob: the
# expected end state here is an EMPTY skills directory, which is precisely where
# zsh's unmatched-glob error would fire.
find "$HOME/.claude/skills" -maxdepth 1 -type l 2>/dev/null | while IFS= read -r l; do
farm_root "$l" >/dev/null && printf 'remnant link: %s\n' "$l"
done
# no clone of this repo anywhere. Match on the REMOTE, not on the directory name: a clone
# can be called anything, and a directory named devwork-skills need not be one. Match
# `.git` as either a directory OR a file — in a linked worktree it is a file, and step 6
# treats worktrees as the common case, so -type d alone would walk straight past them.
# $ROOT carries over from step 6 — but only in the SAME shell. In a fresh shell it is
# empty, and this scan would then quietly cover $HOME alone: if your projects live
# outside $HOME that is a clean verdict about ground never walked. So it refuses to
# run rather than reporting clean about ground it never walked.
if [ -z "${ROOT:-}" ]; then
echo "ROOT is unset — set it as in step 6, then re-run this block. NOT SCANNED."
else
ERR=$(mktemp)
for base in "$HOME" "$ROOT"; do
[ -d "$base" ] || continue
# -maxdepth 8 bounds the walk: deep enough for ordinary layouts, and it keeps the
# scan from descending forever. A repo nested deeper than 8 levels below $HOME or
# $ROOT will NOT be found — raise it if your layout is deeper than that.
find "$base" -maxdepth 8 -name .git -not -path "$HOME/.claude/plugins/*" 2>>"$ERR"
done | sort -u |
while read -r g; do
git -C "${g%/.git}" remote -v 2>/dev/null | grep -q devwork-skills &&
printf 'clone: %s\n' "${g%/.git}"
done
# a scan that could not read part of the tree proves nothing about it — so look:
[ -s "$ERR" ] && { echo "SCAN INCOMPLETE:"; sort -u "$ERR"; }; rm -f "$ERR"
fi
# the plugin is the only copy, at a known version. No `select` on a single id —
# print every match, because the same plugin can be installed at more than one scope.
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "\(.version) \(.scope) \(.installPath)"'
The first two print nothing — and the second must also print neither NOT SCANNED nor a
SCAN INCOMPLETE block. NOT SCANNED means ROOT was empty, so the clone scan did not run at all;
set it and run the block again. If SCAN INCOMPLETE appears,
those paths were never scanned, and silence about them is not absence: read the list and judge
whether a clone could plausibly live there. (An unreadable container volume or another user's data
is a fine reason to move on; an unreadable directory under your own projects is not.) The third
prints the released version and a path under ~/.claude/plugins/cache/devwork-skills/dev/<version>/
— one line per scope. More than one line is not an error, but the versions must agree; a
user-scope and a project-scope install at different versions is a real problem, and a select that
assumed a single match would have hidden it.
Now re-run the bare-name check that Install step 6 deferred: a bare /requirements must no longer
resolve. The farm is gone, so this is the point at which that check means something — but restart
Claude Code first. The session you have been working in started before step 4 removed the farm and
still holds the old skill list, so running the check in it reports a failure that is not real.
8. Record which release produced a run. A run on a consumer machine should be attributable to a release. Nothing stamps this for you today — no shipped skill writes the plugin version into the record it posts, so this is an operator action or it does not happen. Take the line:
claude plugin list --json \
| jq -r '.[] | select(.id == "dev@devwork-skills") | "dev plugin \(.version) (\(.scope))"'
and paste it into the phase's own comment on the issue when a phase posts one. Two runs across an update must be distinguishable by that line. Treat this as a stopgap: an instruction the tooling does not enforce gets forgotten, and it has been. Tooling-enforced attribution — the version stamped into the record by the helper that posts it — is tracked as issue #34; this step retires when that ships.
Runbook: cutting the dev machine over
Operator only, and only once. scripts/cutover-dev.sh owns the order — load and verify the plugin
form before the merge, merge under it, tear the farm down after, move the repo last — because
getting that order wrong breaks the running suite mid-session.
scripts/cutover-dev.sh preflight # every gate, reports ALL blockers, changes nothing
scripts/cutover-dev.sh run # from the first pending step through the next STOP
scripts/cutover-dev.sh run --step <id> # exactly one step (earlier steps must be done)
scripts/cutover-dev.sh status # the machine-state probe — safe at any time
Steps, in order: 1 preflight, 2 backup, 3 plugin_link (STOP), 4 merge (STOP), 5 farm_teardown, 6 repo_move (STOP), 7 final_probe. The read-only preflight gates re-run
automatically before the first pre-merge step of every run invocation — "no feature in flight" is
judged when something is about to change, not only when preflight happened to be typed.
Preflight blocks, and that is the point. It refuses when a feature is in flight: a slot worktree
other than this feature's own, a dirty tree, or any open issue carrying phase/planning,
phase/developing, phase/qa or phase/integrating on the forge. Retire the slots
(/dev:worktree-cleanup), land the work, and re-run. CUTOVER_SKIP_FORGE_CHECK=1 exists for scratch
rehearsals only and announces itself loudly; a real cutover never uses it.
The three STOP points need you, not the script. It cannot restart the session it runs in.
| After step | What to do | Then |
|---|---|---|
3 plugin_link |
restart the Claude session; confirm claude plugin list shows the plugin and a /dev:<skill> invocation resolves. The farm is still in place, so both forms work. |
scripts/cutover-dev.sh run — step 4 merges under the plugin form |
4 merge |
restart the session if it was loaded from the pre-merge target, and retire the feature worktree (git worktree list, then /dev:worktree-cleanup or git worktree remove <path>) — step 6 refuses to move the repo while any worktree is registered |
scripts/cutover-dev.sh run |
6 repo_move |
cd to the new location and restart the session there |
<new-repo>/scripts/cutover-dev.sh run — the script has moved with the repo, so use the new path |
Rehearse the abort before the real run. Every path comes from an environment override whose
default is the real machine, so the whole sequence runs against a scratch HOME — copy the repo and
the state you want to rehearse against into it first, then:
CUTOVER_HOME=/tmp/rehearsal CUTOVER_REPO=/tmp/rehearsal/repo \
CUTOVER_STATE_FILE=/tmp/rehearsal/state.json CUTOVER_SKIP_FORGE_CHECK=1 \
scripts/cutover-dev.sh run
Aborting after the merge is a two-part act. scripts/cutover-abort.sh [--dry-run] undoes what the
state file records, in reverse: repo move, runtime config (only if step 6 actually migrated it —
"differs from the backup" alone never triggers a restore), projects dir, farm links, then the plugin
link. It deliberately does not rewind the merge. So after a post-merge abort the 15 farm links
are back exactly as recorded — and they all dangle, because the merge moved the tree they point into.
In that state abort keeps the plugin link (removing the only resolving form would leave the
machine with none), says so, and prints the pre-merge SHA — --dry-run reports the same dangling
verdict, judged from the recorded targets:
abort: COULD NOT RESTORE: 15 of 15 restored farm link(s) DANGLE: their targets are gone because
the merge (step 4) moved the skills tree and is not undone…
abort: step 4 (merge) — NOT undone, by design.
abort: main was at <sha> before the cutover fast-forwarded it.
abort: To rewind a merge that was NOT pushed: git -C <repo> checkout main && git -C <repo> reset --hard <sha>
The restore is not finished until you decide about the merge. A pushed merge is never rewound silently — that is your call, not the script's.
The state probe answers "what shape is this machine in?" in one command, and is the thing to run
when you are not sure. scripts/cutover-dev.sh status prints the repo locations, the step table, the
plugin link, the farm-link count, the runtime-config keys and the projects-dir slugs, then one
verdict line:
=> PRE-CUTOVER — the farm is the only form; nothing has changed
=> COEXISTENCE — both forms resolve; the farm is still the fallback
=> FARM TORN DOWN, repo not yet moved — continue with: scripts/cutover-dev.sh run
=> CUTOVER COMPLETE — plugin form only, repo at /home/<you>/Coding/devwork-skills
Two verdicts mean stop and read: COEXISTENCE, DEGRADED / PRE-CUTOVER SHAPE, BUT N FARM LINK(S) DANGLE (the farm is not a working fallback right now — the merge ran and was not rewound), and
HALF-MIGRATED — the combination above matches no expected stage. Do not guess.
Cutting a release
Operator only, from a clean tree, on the integration branch.
scripts/release.sh --dry-run 0.2.0 # every gate, stops before tagging
scripts/release.sh 0.2.0
git push github main # REQUIRED: the tag alone is not the release
release.sh is the sole writer of the two version fields (plugin/.claude-plugin/plugin.json and
the .claude-plugin/marketplace.json entry), which is what makes the disagreement refusal a real
signal. In order:
- refuse on a wrong
plugins[0]entry / a dirty tree / disagreeing version fields / an existing tag / a branch other than the integration branch (git + jq only, so a release is refused for the right reason even without theclaudeCLI). When the existing tag points at HEAD, the refusal distinguishes the re-run case from the tagged-but-push-failed case and names the resume commands for the latter; - write both fields and commit the bump —
claude plugin tagrequires a clean tree, and a release must be attributable to a commit rather than to a working tree; scripts/lint-conventions.sh;claude plugin validate --strictover both manifest roots;claude plugin tag --dry-run— the CLI's own tag-uniqueness and{name}--v{version}format check (its dirty-tree/version legs run only on the real tagging path, which is why the refusals in step 1 are the load-bearing enforcement);claude plugin tag --push --remote github— createsdev--v<version>and pushes it;- append
{version, tag, release commit SHA}toRELEASES.mdand commit that.
The push of the branch is yours. release.sh pushes the tag; consumers install from the
default branch of the remote, so a release nobody can install is a release that only pushed a tag.
The record commit is also untagged by design — push the branch so the record travels with the tag.
--dry-run is not a no-op. It runs every gate and stops before tagging, but if the version is
new it has already written and committed the bump by then, and it says so. That commit is the
resumable state: re-run release.sh <same version> without --dry-run and it picks up at the gates
rather than bumping again.
The first push to a fresh remote is also what sets GitHub's default branch, which is the branch
consumers install from — push main, not a feature branch. And run the history scan below before it.
Gates you can run at any time:
bash scripts/lint-conventions.sh # portability baseline + shipped-tree reference hygiene
bash scripts/test-plugin-gates.sh # release refusals, validator + scan non-vacuity controls
bash scripts/history-scan.sh # full-history sensitive-data scan (see below)
Before the first push to a new remote, run the history scan: publishing a repo publishes its history, not its tip.
bash scripts/history-scan.sh --out /tmp/history-scan.md
It reports two classes and blocks on one. Secrets (private keys, token shapes, credential
assignments) exit non-zero — a secret in history stays in history, and no repository visibility makes
it safe. Internal information (hostnames, home paths, email addresses) is an advisory inventory:
acceptable while the repository is private, and exactly the list to read before ever making it
public. The verdict line is P0.8: PASS or P0.8: BLOCK.
Consumer CLAUDE.md guidance block
What a consumer project declares for the suite. The YAML keys are unchanged by the plugin move —
only prose references to skills take the /dev: prefix.
## devwork configuration
```yaml
devwork_root: .devwork
forge:
adapter: gh-cli # tea-cli | glab-cli | gh-cli | local-fs
owner: <owner>
repo: <repo>
taxonomy_labels:
- type:feature
- priority:high
- component:<area>
integration_branch: main
observability:
mode: none # or logging:/audit: sub-blocks — a conscious declaration either way
reason: <why this project logs/audits nothing>
uat:
mode: staging # preview-deploy | staging | local
open_pr: false # does a PR/MR exist before the merge?
url_source: <where the reviewable build turns up — free text the suite quotes>
api_invocation:
mode: none
reason: <or how the suite may call this project's API>
```
**Notes for the suite.** `/dev:promote` has nothing distinct to do here — `main` is both the
integration branch and what production deploys, so merging *is* releasing. That makes
`/dev:integrate`'s pre-merge verification the last automated gate before production; do not
weaken it.
Two rules worth stating in the project's own words:
- Reference shipped skills as
/dev:<skill>— bare names do not resolve. - A project-scope adapter at
./.claude/skills/<adapter>/overrides the plugin's bundled one; that is supported and unchanged.
Run /dev:setup in a project to have the suite check this block and fill the gaps interactively.
Repository layout
devwork-skills/
├── .claude-plugin/marketplace.json # the marketplace entry: plugin `dev`, source ./plugin
├── plugin/ # the plugin root — the ONLY shipped directory
│ ├── .claude-plugin/plugin.json # authoritative name + version
│ └── skills/
│ ├── _shared/ # procedures, schemas, QA domains, bin helpers
│ └── <14 shipped skills>/
├── .claude/skills/ # repo-local meta lane, NOT shipped: meta-amend, meta-retrospective
├── scripts/ # repo-local tooling, NOT shipped
│ ├── lint-conventions.sh # the mechanical gate
│ ├── test-plugin-gates.sh # release-refusal + non-vacuity controls
│ ├── release.sh # the only writer of the version fields
│ ├── cutover-dev.sh / cutover-abort.sh
│ └── history-scan.sh # pre-push full-history scan
├── sdlc-*.md, sdlc-dogfood-run-reports/, sdlc-retrospectives/ # historical records, kept verbatim
└── RELEASES.md # {version, tag, bump commit} per release
Authoring conventions — the portability baseline, the stack-agnostic rule, and the two-lane development process — are in CLAUDE.md. Read it before changing skill text.