Ship — validate, lint, document, version, distribute
Current forge state: !cat .forge/state.json 2>/dev/null || echo NO_STATE
If the line above shows a policy notice instead of JSON (org setting
disableSkillShellExecution), read .forge/state.json with the Read tool
before continuing.
Phase gate (hard)
Proceed only when phase is ship — verification must already be green. For
any other state, run NOTHING and reply with exactly the remediation below,
then stop:
| Injected state | Reply and stop |
|---|---|
| NO_STATE | "No forge run in this project. Run /plugin-forge:forge to start one." |
interview |
"Interview incomplete. Run /plugin-forge:forge." |
contract |
"No approved contract yet. Run /plugin-forge:write-contract." |
evals |
"Suite not armed. Run /plugin-forge:build-evals, then /plugin-forge:arm-evals." |
armed |
"Nothing built yet. Run /plugin-forge:build-loop." |
building |
"Build loop has not gone green. Run /plugin-forge:build-loop." |
smoke or verify |
"Verification is not complete. Run /plugin-forge:verify." |
Inputs to read before Step 1: plugin_name and plugin_dir from the injected
state; design/CONTRACT.md (C6 distribution decisions, runtime answers —
cloud/routine or local, version floors, permission story, cost sheet); the
latest runs/<ts>/verify-report.md (baseline numbers for the README); and
.forge/freeze.json (suite version).
Step 1 — Static gates (all four pass before anything is written)
1a. Strict validation — the same check the community pipeline runs:
claude plugin validate --strict <plugin_dir>
1b. Frontmatter YAML lint on every generated SKILL.md. Malformed YAML fails silently: the body loads with no metadata, so the skill never auto-triggers. Run:
python3 - <plugin_dir> <<'EOF'
import sys, re, pathlib
root, bad = pathlib.Path(sys.argv[1]), []
files = sorted(root.glob('skills/**/SKILL.md')) + sorted(root.glob('SKILL.md'))
for f in files:
t = f.read_text(encoding='utf-8')
m = re.match(r'\A---\r?\n(.*?)\r?\n---\r?\n', t, re.S)
if not m:
bad.append(f'{f}: frontmatter fence missing or unclosed'); continue
fm = m.group(1)
tops = [(k.group(1), k.start()) for k in re.finditer(r'^([A-Za-z][\w-]*):', fm, re.M)]
names = [k for k, _ in tops]
for req in ('name', 'description'):
if req not in names: bad.append(f'{f}: missing required key {req}:')
if '\t' in fm: bad.append(f'{f}: literal tab in frontmatter (YAML hazard)')
for b in re.finditer(r':\s*(yes|no|on|off)\s*$', fm, re.M | re.I):
bad.append(f'{f}: boolean "{b.group(1)}" needs >=2.1.218 - use true/false')
def block(key):
d = dict(tops)
if key not in d: return ''
starts = sorted(s for _, s in tops); i = starts.index(d[key])
return fm[d[key]:starts[i+1]] if i + 1 < len(starts) else fm[d[key]:]
if len(block('description')) + len(block('when_to_use')) > 1536:
bad.append(f'{f}: description+when_to_use exceed the 1536-char listing cut')
for b in bad: print('FAIL', b)
print('frontmatter lint:', 'FAIL' if bad else 'PASS')
sys.exit(1 if bad else 0)
EOF
1c. Scoped-name lint — bundled-MCP references using a bare server name
never fire; they must be mcp__plugin_<plugin>_<server>__<tool> (and
plugin:<plugin>:<server> in mcp_tool hook server fields):
grep -rnE 'mcp__' <plugin_dir> --include='*.md' --include='*.json' | grep -v 'mcp__plugin_'
Every hit in hook matchers, permission rules, agent tools lists, or skill
allowed-tools is a FAIL — unless it names an EXTERNAL server the plugin does
not bundle (check against the plugin's .mcp.json before failing).
1d. Freshness lint — deprecated shapes must not ship:
grep -rnE '"decision"[[:space:]]*:[[:space:]]*"(approve|block)"' <plugin_dir>
grep -rn '/mnt/skills' <plugin_dir>
Both greps must return nothing. The decision-JSON hook shape is deprecated
(current discipline: stderr + exit 2 to block, or hookSpecificOutput);
/mnt/skills paths are foreign-environment rot that never resolves in Claude
Code. Fix at the source, never by weakening the lint.
Step 2 — Install the flywheel (trace capture)
mkdir -p <plugin_dir>/hooks/scripts
cp "${CLAUDE_PLUGIN_ROOT}/templates/hooks/trace-capture.sh" <plugin_dir>/hooks/scripts/trace-capture.sh
chmod +x <plugin_dir>/hooks/scripts/trace-capture.sh
Merge ${CLAUDE_PLUGIN_ROOT}/templates/hooks/hooks-snippet.json into
<plugin_dir>/hooks/hooks.json: create the file from the snippet when the
plugin ships no hooks yet; otherwise append the snippet's PostToolUse entry to
the existing array — never clobber existing entries. Confirm the merged entry
is exec-form and references
"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/trace-capture.sh" — at the generated
plugin's runtime that variable resolves to ITS root, not to plugin-forge's.
Why this ships in every generated plugin: the hook appends tool
args/results/errors to ${CLAUDE_PLUGIN_DATA}/traces/*.jsonl, so production
failures become eval tasks — /plugin-forge:build-evals mine converts traces
to tasks, /plugin-forge:arm-evals re-freezes vN+1, and
/plugin-forge:build-loop rebuilds until green. Document the loop in the
README (Step 4).
Step 3 — Optional observability (fleet/long-job plugins)
Offer when the generated plugin dispatches subagent fleets or long jobs; otherwise skip.
- subagentStatusLine — a plugin
settings.jsonsupports ONLY theagentandsubagentStatusLinekeys:
{"subagentStatusLine": {"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/status.sh"}}
with the script reading a ${CLAUDE_PLUGIN_DATA}/status.json the plugin's
long-running jobs update.
- Monitor — pair each long-job skill with
monitors/monitors.json:
[{"name": "<job>-watch", "command": "tail -F <log path>",
"description": "streams <job> progress into the session",
"when": "on-skill-invoke:<skill-name>"}]
State the caveats wherever this is offered: monitors are experimental,
interactive-CLI only, do not load from project-scope skills-dir installs,
reject ${user_config.*} in commands, and need a session restart after
plugin updates.
Step 4 — README for the generated plugin
Instantiate
${CLAUDE_PLUGIN_ROOT}/skills/generation-standards/templates/readme.tmpl.md
into <plugin_dir>/README.md. Required sections:
- Overview + invocations — what the plugin does; every user-facing skill
as
/<plugin_name>:<skill>with its argument hint. - Permission story — a
permissions.allowlist covering EVERY command the plugin's skills and agents run. allowed-tools grants are turn-scoped (cleared on the next user message), so long flows need standing allows in the consumer's.claude/settings.json:{"permissions": {"allow": ["Bash(<cmd> *)", "..."]}}. - Minimum Claude Code version — the highest floor among features used
(from CONTRACT C6). Known floors:
${CLAUDE_SKILL_DIR}in allowed-tools 2.1.129;defaultEnabled2.1.154; marketplacerenames2.1.193;--json-schemahard errors / LSPrestartOnCrash2.1.205; worktree resume 2.1.212; frontmatternamesegment override 2.1.216; yes/no boolean forms and background-fork default 2.1.218. - Headless recipe — the
claude -pinvocation for CI/routines, using the suite's sandbox settings profile (never--dangerously-skip-permissions), plusCLAUDE_CODE_PLUGIN_SEED_DIRfor network-free container loading. - How to run your evals — the
evals/layout;evals/bin/run.pyis vendored and stdlib-only, so the suite runs WITHOUT plugin-forge installed; red baseline via the no-plugin target; the finalFORGE_EVAL:line and exit code as the CI signal; run the regression suite in CI; cost honesty (--max-cost-usd); current baseline for future battle A/B: suite version from.forge/freeze.json, pass^3 and cost fromverify-report.md. - Flywheel — traces land in
${CLAUDE_PLUGIN_DATA}/traces/; feed failures back with/plugin-forge:build-evals mine.
Step 5 — Versioning decision (ask the user)
Present exactly two options via AskUserQuestion:
- Still iterating — OMIT
versionfrom plugin.json. The git SHA becomes the version: every pushed commit reaches users. - Releasing — pin semver in plugin.json, add a CHANGELOG entry, and run
claude plugin tag --push(creates the<name>--v<version>tag that dependency constraints resolve against).
Explain the trap before they choose: a pinned semver means users update ONLY when the field is bumped — pushing commits under a stale pin does nothing ("already at latest version"). A plugin pinned at 1.0.0 and then iterated strands every installed user on the first build. Never pin without a release process that bumps it.
Also state: the plugin name is a permanent public identifier (the
marketplace renames map is the only migration path, 2.1.193+);
displayName is the safe-to-change label.
Step 6 — Final re-validation
Steps 2–4 added files. Re-run on the FINAL tree:
claude plugin validate --strict <plugin_dir>
and repeat the Step 1c/1d greps over the added files. Ship nothing that is not green here.
Step 7 — Distribution menu
Ask via AskUserQuestion unless $ARGUMENTS pre-selects a path:
A. skills-dir plugin (lightest) — copy <plugin_dir> (with
.claude-plugin/plugin.json) to ~/.claude/skills/<plugin_name>/ (personal)
or <repo>/.claude/skills/<plugin_name>/ (project). It loads as
<plugin_name>@skills-dir next session; SKILL.md edits are live, hooks/MCP
changes need /reload-plugins. Warn: project scope is trust-gated, does NOT
walk up to the repo root (launch Claude from the root), and monitors do not
load.
B. Private marketplace — add or extend .claude-plugin/marketplace.json
in the hosting repo:
{"name": "<marketplace-name>", "owner": {"name": "<owner>"},
"plugins": [{"name": "<plugin_name>", "source": "./<relative-path>",
"description": "<one line>", "category": "<category>"}]}
Validate the marketplace repo with claude plugin validate .. Consumers run:
claude plugin marketplace add <org>/<repo>
claude plugin install <plugin_name>@<marketplace-name>
Maintenance: claude plugin marketplace update <marketplace-name>; inspection:
claude plugin marketplace list --json. Warn: marketplace names collide
globally per user (a second add replaces the first) and several names are
reserved — pick something distinctive.
C. Community submission — pre-submission requirements: kebab-case plugin
name (the claude.ai marketplace sync REJECTS non-kebab names);
claude plugin validate --strict clean (the community pipeline runs the same
check as Step 1a); README and LICENSE present; semver pinned and tagged via
claude plugin tag --push; public repo. Then submit the repo through the
community marketplace submission form, with the marketplace-entry fields
(name, source, description, category, tags) prepared to paste.
Step 8 — Repo-declared settings (cloud/routine contracts)
If CONTRACT.md says the plugin runs in cloud sessions or scheduled routines,
personal installs are invisible there (cloud sessions never read
~/.claude/skills) — the consuming repo must declare the plugin. Instantiate
${CLAUDE_PLUGIN_ROOT}/skills/generation-standards/templates/settings-snippet.tmpl.json
into the consuming repo's .claude/settings.json:
{"extraKnownMarketplaces": {"<marketplace-name>": {"source":
{"source": "github", "repo": "<org>/<repo>"}}},
"enabledPlugins": {"<plugin_name>@<marketplace-name>": true}}
Teammates get a one-time trust prompt, then auto-install. When the contract
includes long headless runs, fold the Step 4 permissions.allow block into
the same snippet.
Step 9 — Wrap up
Report to the user:
- Gates re-passed on the final tree (Step 6).
- Files added:
hooks/scripts/trace-capture.sh, the hooks.json entry,README.md, plussettings.json/monitors/monitors.jsonif chosen. - Version strategy chosen and why; distribution path with the exact consumer commands; settings snippet emitted (or why not).
- Baseline carried into the README: suite vN, pass^3, cost, run timestamp.
- The honest claim: the plugin is "green + holdout + triaged" against suite vN — not proven "correct".
Leave .forge/ in place: it is the flywheel's memory (freeze hash, change
requests, run history). Phase stays ship. To start a fresh forge run in
this project later, forge-eval doctor clears stuck state safely.