# Ship

> Package and release a verified forge-built plugin: claude plugin validate --strict, frontmatter/scoped-name/freshness lints, README with the permission story, versioning decision (SHA vs pinned semver), distribution menu (skills-dir, private marketplace, community), and installing the trace-capture flywheel. Use when the user says ship, release, publish, or distribute the plugin.

- Skill: `aznatkoiny/ship` (Agent Skill)
- Install (CLI): `npx skillmds@latest add aznatkoiny/ship`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aznatkoiny/ship/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: aznatkoiny (https://skillmd.com/u/aznatkoiny)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aznatkoiny/ship

---


# 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.json` supports ONLY the `agent`
  and `subagentStatusLine` keys:

```json
{"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`:

```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:

1. **Overview + invocations** — what the plugin does; every user-facing skill
   as `/<plugin_name>:<skill>` with its argument hint.
2. **Permission story** — a `permissions.allow` list 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> *)", "..."]}}`.
3. **Minimum Claude Code version** — the highest floor among features used
   (from CONTRACT C6). Known floors: `${CLAUDE_SKILL_DIR}` in allowed-tools
   2.1.129; `defaultEnabled` 2.1.154; marketplace `renames` 2.1.193;
   `--json-schema` hard errors / LSP `restartOnCrash` 2.1.205; worktree resume
   2.1.212; frontmatter `name` segment override 2.1.216; yes/no boolean forms
   and background-fork default 2.1.218.
4. **Headless recipe** — the `claude -p` invocation for CI/routines, using the
   suite's sandbox settings profile (never `--dangerously-skip-permissions`),
   plus `CLAUDE_CODE_PLUGIN_SEED_DIR` for network-free container loading.
5. **How to run your evals** — the `evals/` layout; `evals/bin/run.py` is
   vendored and stdlib-only, so the suite runs WITHOUT plugin-forge installed;
   red baseline via the no-plugin target; the final `FORGE_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 from `verify-report.md`.
6. **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 `version` from 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:

```json
{"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`:

```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`, plus `settings.json`/`monitors/monitors.json` if 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.

