From 58215b4b7835a7006a047516f0a730fe9657968e Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 5 Aug 2026 09:35:34 -0500 Subject: [PATCH 1/3] Coordinate Skills release candidates Bind the installable plugin to an immutable source SHA. SemVer gates compatibility while explicit approval gates release. Document candidate tags and the stable-channel boundary. --- .claude-plugin/marketplace.json | 8 +- .github/workflows/ci.yml | 1 + AGENTS.md | 21 ++ README.md | 160 ++++++-------- RELEASING.md | 136 ++++++++++++ package.json | 3 +- release/compatibility.json | 21 ++ script/check | 1 + script/check-claude-plugin-install.mjs | 23 +- script/check-release-compatibility.mjs | 294 +++++++++++++++++++++++++ test/release-compatibility.test.mjs | 290 ++++++++++++++++++++++++ test/repository.test.mjs | 227 ++++++++++++------- 12 files changed, 1010 insertions(+), 175 deletions(-) create mode 100644 AGENTS.md create mode 100644 RELEASING.md create mode 100644 release/compatibility.json create mode 100644 script/check-release-compatibility.mjs create mode 100644 test/release-compatibility.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 04f9dec..3cb5a51 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,13 @@ "plugins": [ { "name": "firstdraft", - "source": "./skills/create-full-stack-app", + "version": "0.1.0-alpha.1", + "source": { + "source": "git-subdir", + "url": "https://github.com/firstdraft/skills.git", + "path": "skills/create-full-stack-app", + "sha": "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e" + }, "strict": false, "skills": [ "./" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02b2b31..feb35f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52954f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Agent Instructions — First Draft Skills + +## Release coordination + +- Treat a merge to `main` as integration, not release authorization. After merging, report the exact merged SHA and + ask the user whether to coordinate a candidate across `firstdraft`, `cli`, and `skills` and promote it. +- SemVer compatibility establishes candidate eligibility only. Record the exact SHA of every repository as the + candidate identity, including the Skills catalog checkout and pinned plugin source, then follow + [`RELEASING.md`](RELEASING.md). +- Do not publish npm packages, deploy First Draft, or release the plugin without explicit user approval. If the user + declines promotion, identify the merged SHA as unpromoted. +- Never create or fast-forward the distributable `stable` ref without explicit approval. Moving `stable` is the + plugin release mutation; reconcile an ambiguous ref-update outcome read-only before taking further action. +- Create an immutable marketplace candidate tag only after candidate-preparation approval. Never move or delete a + candidate tag, and reconcile an ambiguous tag write read-only before continuing. +- Never reuse a marketplace SemVer with a different source SHA. Revisions and corrections use a new version and move + `stable` forward only after qualification. +- Before the first release mutation, verify read-only that rulesets block `stable` force-push/deletion, restrict its + updates to the release operator, and make candidate tags immutable. Do not change remote protection without + separate approval. +- Keep deployment, package publication, plugin release, and replay mutations serialized through one operator. diff --git a/README.md b/README.md index f79a322..73849f0 100644 --- a/README.md +++ b/README.md @@ -113,19 +113,40 @@ claude plugin validate --strict .claude-plugin/plugin.json The root preview manifest uses the distinct name `firstdraft-preview` and carries a preview-only `0.1.0` version because direct strict manifest validation requires a semantic version. The separate name cannot collide with an installed `firstdraft@firstdraft-skills` plugin in the same session. That manifest is excluded from the marketplace -plugin source and does not version an ordinary installation. The source-only marketplace plugin deliberately omits -a semantic version while it is experimental. +plugin source and does not version an ordinary installation. The installable marketplace entry independently owns +the plugin release version, currently `0.1.0-alpha.1`; the checkout preview's separate `0.1.0` does not govern that +installation. The private root npm package and its `0.0.0` version are repository test tooling, not plugin identity. +The marketplace uses the officially documented +[`git-subdir` `url`, `path`, `ref`, and `sha` source shape](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) +and pins a full commit SHA. One marketplace SemVer maps permanently to one source SHA: a failed or revised candidate +must receive a new prerelease version and immutable candidate tag, never reuse an earlier version with new bytes. +The compatibility check searches the committed compatibility documents reachable through every local Git ref and +rejects a reused version whose source identity differs. Release checkouts must therefore fetch candidate tags as +well as full branch history. Later `main` changes remain unpromoted until an explicit catalog candidate advances +both the source SHA and SemVer. +The distributable +marketplace channel is the dedicated `stable` ref; `main` holds candidates, and moving `stable` is a separate +approval-gated release mutation. Before that move, qualification uses a retained immutable candidate tag at the +exact catalog commit so the Git-hosted catalog and its pinned source are both exercised. No `stable` ref is created +by this candidate; initial channel creation remains part of a future approved release. The marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition. The [official marketplace strict-mode documentation](https://code.claude.com/docs/en/plugin-marketplaces#strict-mode) says this mode permits a raw source directory without its own `plugin.json`; a source manifest that also declares components would conflict with the marketplace definition. It does not relax validation. Separately, the [official plugin validation reference](https://code.claude.com/docs/en/plugins-reference#unrecognized-fields) says `claude plugin validate --strict` treats validation warnings, including unrecognized fields, as errors for CI. Both -strict validation commands above remain release gates. +strict validation commands above remain manual release gates, but a successful local invocation is not durable +qualification of a Git-hosted catalog or installation. +The `git-subdir` field set and the `owner/repo@ref` add form used below were rechecked on 2026-08-05 against those +two official marketplace sections. Recheck both claims when renewing the supported Claude Code floor or preparing +a release; this dated documentation check is not installation evidence. The [official marketplace documentation](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels) -says a Git-hosted marketplace falls back to the commit SHA when a plugin version is omitted. That is the documented -expectation here, not observed Git-hosted installation evidence. Add a marketplace semantic version only when plugin -changes follow an explicit release-and-version-bump cadence. +says an explicitly versioned plugin remains cached until that version changes. Every revised candidate therefore +requires a deliberate, never-reused SemVer bump. Exact Git SHAs still identify coordinated release candidates; +the version only participates in compatibility eligibility and never authorizes release. This remains packaging +policy, not observed Git-hosted installation evidence. This explicitly versioned candidate has not completed a +fresh-model rehearsal or an isolated marketplace installation; the dated observations remain evidence only for +their pinned earlier revisions and do not qualify this candidate. The documented local-development path starts one Claude Code session from the checkout without registering a marketplace or installing the plugin: @@ -158,106 +179,47 @@ local development and plugin Skills use the `plugin-name:skill-name` namespace. tool identifier above. Interactive `/firstdraft-preview:create-full-stack-app` invocation and invocation through an installed `firstdraft@firstdraft-skills` marketplace plugin remain unobserved. -Before release, when Claude Code is available, manually exercise the real add-and-install path with child state -redirected away from the user's Claude configuration and plugin cache and with the scoped monitoring described -below: - -Close every other Claude Code session before running the smoke. Claude Code sessions share plugin registries and -caches; a concurrent legitimate update will change a monitored real-state path and correctly make this check fail. - -The dated evidence's `~/.claude.json` exclusion and default real-state targets require `CLAUDE_CONFIG_DIR` and -`CLAUDE_CODE_PLUGIN_CACHE_DIR` to be unset in the parent shell. Both smoke commands fail before resolving Claude, -inspecting real Claude state, or creating temporary state if either override is present. The diagnostic names only -the override variables, never their values. - -```sh -npm run check:claude-plugin-install -``` - -That check does not write repository evidence. To renew the machine-readable observation after intentionally -reviewing a Claude Code upgrade or packaging change, run: - -```sh -npm run record:claude-plugin-install -``` - -The recording command regenerates only the machine-readable JSON. Its UTC date and observed Claude Code version are -deliberate review pins. If either changes, rename the dated Markdown evidence file to the new observation date, -refresh its title, CLI version, transcript, inventory, digest table, state-presence bullets, and real-state monitor -summary line, dated default-component-location recheck, and model-session conclusion against any newer evaluation -evidence; update the evidence path and the expected date/version pins in `test/repository.test.mjs`; then rerun the -repository checks and both strict validations. Recheck the checkout-root default-component allowlist against the new -version's documented discovery locations as part of that review. A changed pin is a request to re-review current -component discovery and isolation behavior, not a mechanical update. -The smoke's expected live component inventory is deliberately hardcoded. If a reviewed Claude Code discovery change -alters it, update the assertion in `script/check-claude-plugin-install.mjs`, its repository-test pins, the generated -observation, and the dated prose together. Rerunning the recording command alone cannot renew that expectation. - -The smoke runs both strict manifest validations through the native CLI before any mutation and derives each recorded -validation result from that exact invocation's captured successful validator output. It then constructs a -minimal child environment containing only isolated Claude, home, temporary, and XDG state; -traffic and updater controls; and a guard-only PATH. It does not inherit credentials, tokens, API keys, SSH agent, -proxy, Git, Node, dynamic-loader, or unrelated variables. The PATH guards block and record common Node -package-manager invocations. Every child command runs from a newly created isolated working directory rather than -the checkout. Because that guard-only PATH cannot safely support arbitrary interpreter lookup, the smoke requires -`CLAUDE_BIN` or the parent PATH to resolve to a regular, executable native Claude Code binary and rejects shebang -wrappers. Before and after, it recursively fingerprints content and metadata beneath the real target plugin's cache, -data, and marketplace paths while monitoring the real Claude registries, settings, and credential metadata. It -byte-compares the installed cache with the eight canonical Skill files, checks the live Claude component inventory -is one combined Skills/Commands entry and zero Agents, Hooks, MCP servers, and LSP servers, and removes all temporary -state before returning. Claude Code reports Skills and Commands in one combined Skills count. The smoke therefore -derives Commands absence separately from the marketplace's lack of a Commands declaration and the exact installed -file set; it does not present Commands as a live count. The PATH guards detect ordinary package-manager lookup; they -are not a claim that an executable invoked by an absolute path is impossible. - -The portable `agents/openai.yaml` file is Skill metadata, not a Claude Code Agent definition. The -[official plugin reference](https://code.claude.com/docs/en/plugins-reference) currently discovers plugin Agents -from Markdown definitions under `agents/` or explicit manifest paths. The observed `Agents=0` result therefore -depends on current Claude Code discovery behavior and must be rechecked whenever the CLI version changes. - -Real-state monitoring is deliberately scoped rather than recursive over all Claude state. It covers content and -metadata for the plugin registries and catalog, the `firstdraft-skills` cache, data, and marketplace trees, and -metadata for settings and credentials. It excludes the high-churn `~/.claude.json`, plugin maintenance markers, -session history, and unrelated Claude configuration; the smoke makes no whole-configuration monitoring claim. -Claude Code 2.1.221 did not create `plugins/marketplaces/firstdraft-skills` for the isolated local-directory -registration or `plugins/data/firstdraft-firstdraft-skills` during its isolated install. `targetMarketplace` and -`targetData` are conservative candidate-path monitors for the unobserved Git-hosted installation, not confirmed -current CLI storage layouts; their absence is not load-bearing isolation evidence. -It refuses to make an unchanged-state claim if any monitored target or nested entry is a symbolic link. -The smoke reports which named targets were present and absent and refuses to make an unchanged-state claim unless at -least one core registry target is present. It checks the real targets immediately after marketplace registration -and again after plugin installation, aborting before the second mutation if the first escaped isolation. A detected -escape reports the exact uninstall and marketplace-removal commands for the operator to inspect and run. -Its diagnostic names every changed monitor together with its resolved absolute filesystem path. - -The ordinary repository test owns the exact eight-file allowlist and fails if this documented count, the canonical -source shape, the checkout-root preview component paths on disk, or the smoke's isolation assignments drift. The -non-recording isolated smoke also compares its live CLI version, captured strict-validation results, component -inventory, and installed bytes with the committed observation. Real-state presence is run-local information rather -than a cross-machine release-gate value: every run independently requires at least one core registry target and -proves the monitored targets unchanged. Packaging drift fails closed and directs the operator through the explicit -evidence-renewal review. The dated +The dated [Claude Code plugin install smoke evidence](evidence/2026-08-04-claude-code-plugin-install-smoke.md) and its -[generated machine-readable observation](evidence/claude-code-plugin-install-observation.json) record the current -CLI version, per-file sizes and SHA-256 digests, installed tree digest, component inventory, cleanup, and exact -real-state target presence. Source drift fails with an instruction to rerun the isolated recording command instead -of treating hand-edited prose as installation evidence. - -This isolated install smoke is a manual release check. Ordinary `sh script/check` and hosted CI retain structural -and syntax assertions but do not assume Claude Code is installed. +[generated machine-readable observation](evidence/claude-code-plugin-install-observation.json) remain bound to the +eight canonical Skill files at historical revision `b5c3897b240bfa3a9117d1a564d8e6b7d783e993`. Repository tests reconstruct +those Git objects rather than requiring today's unpromoted source to match them. The report retains the observed +Claude Code version, file digests, component inventory, cleanup, and scoped real-state result at that historical +boundary; it does not qualify this remote-pinned catalog. + +The old local-directory harness cannot exercise the current remote-pinned `git-subdir` source: its isolated child +PATH intentionally provided no Git, and it compared the cache with the checkout's current subtree. Both +`npm run check:claude-plugin-install` and `npm run record:claude-plugin-install` now fail before resolving Claude, +inspecting real state, or creating temporary state with a precise unsupported-source diagnostic. Do not use either +command to renew or qualify the current candidate. + +Git-hosted qualification requires a reviewed successor harness. It must add the exact catalog through the officially +documented [`owner/repo@ref` form](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) using +the immutable candidate tag, install the catalog's pinned remote source, confirm the cache directory is exactly the +candidate SemVer, and byte- and mode-compare the cache with that pinned Git tree. It must retain the catalog SHA, +source SHA and path, version, command transcript, and scoped host-state and credential-leak observations. Supplying +Git to the isolated child requires a narrowly scrubbed executable path and a fresh security review; do not inherit +the operator's general PATH, tokens, SSH agent, or unrelated environment. Close other Claude Code sessions before +that run so legitimate concurrent registry changes do not invalidate the isolation result. + +Ordinary `sh script/check` and hosted CI retain structural, historical-evidence, compatibility, and syntax +assertions. They neither install the plugin nor claim remote reachability or qualification. Once this packaging is merged and the release gates below are satisfied, the intended ordinary installation from GitHub is: ```sh -CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills +CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills@stable claude plugin install firstdraft@firstdraft-skills ``` The [official environment-variable reference](https://code.claude.com/docs/en/env-vars) documents `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner/repo` shorthand over HTTPS rather than SSH. The assignment above makes the intended path independent of SSH setup; users with working GitHub SSH configuration may -omit it. No live GitHub clone has been observed for this package. +omit it. The official +[`plugin marketplace add` reference](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) +documents `owner/repo@ref`. The `@stable` ref is the intended distributable channel; ordinary installation must not +track mutable `main`. No live GitHub clone from either ref has been observed for this package. Do not run those installation commands for ordinary use yet. The required `@firstdraft.com/cli@0.1.0-alpha.2` package remains unpublished, and no compatible First Draft staging or live @@ -266,13 +228,21 @@ execution evidence. Keep using `gh skill preview` when evaluating the portable S ## Development +Cross-repository compatibility metadata and the approval-gated release process are documented in +[`RELEASING.md`](RELEASING.md). + The installed Skills contain no executable code or runtime packages. Repository checks use Node.js 22 or newer and one locked development dependency for exact JSON Schema validation: Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available. A source archive, exported tree, or installed plugin cache is insufficient because the preview-boundary checks use the Git index for the enumerated checkout-root component locations and inspect those paths plus the complete `skills/` -subtree on disk. +subtree on disk. Evidence and compatibility checks also read pinned historical commits and require full, +unshallowed history containing them. The version-to-source check also reads compatibility documents reachable from +every local ref, including fetched immutable candidate tags. Hosted CI uses `fetch-depth: 0`. In a local clone, +fetch tags with `git fetch origin --tags`, then inspect `git rev-parse --is-shallow-repository`; when it returns +`true`, run `git fetch --unshallow origin` or fetch the required full commits through an approved equivalent before +running checks. ```sh npm ci --ignore-scripts diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..d088c84 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,136 @@ +# Releasing First Draft Skills + +This repository participates in a coordinated release with +[`firstdraft/firstdraft`](https://github.com/firstdraft/firstdraft) and +[`firstdraft/cli`](https://github.com/firstdraft/cli). A merge to `main` integrates source; it does not authorize a +plugin release, npm publication, or a First Draft deployment. + +## Release identity and compatibility + +The installable `firstdraft@firstdraft-skills` marketplace entry in +[`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) owns the Skills/plugin SemVer. The separate +`firstdraft-preview` manifest at [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json) exists for checkout-local +preview and is not the installable plugin's version authority. The root `@firstdraft/skills` package is private test +tooling; its `0.0.0` package version is also not a release identity. + +The current installable marketplace version is `0.1.0-alpha.1`. The preview manifest remains independently +`0.1.0`; matching or ordering those unrelated numbers is not a release rule. + +[`release/compatibility.json`](release/compatibility.json) mirrors the marketplace plugin version and declares the +immutable installed-byte source SHA and subdirectory, service API contract, CLI version, and exact Foundation Plan +format required by this source. Repository checks fail if that metadata drifts from the marketplace entry or its +closed shape. The current metadata can reject an incompatible three-repository candidate, but a compatible result +never authorizes promotion. + +Claude Code's official +[`git-subdir` source documentation](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) defines its +`url`, `path`, optional `ref`, and full `sha` fields. This marketplace source is pinned to full commit +`8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e` and path `skills/create-full-stack-app`. Content can therefore merge to +`main` without changing the bytes selected by that catalog entry. Do not infer a release from a version number: the +exact catalog checkout SHA, installed-byte source SHA and path, and service and CLI SHAs identify the candidate that +was reviewed and qualified. See Claude Code's official +[version-resolution documentation](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels). +One marketplace SemVer maps forever to exactly one source SHA. Every failed or revised candidate gets a new +prerelease version, catalog commit, and immutable candidate tag; never reuse a version for different bytes. The +local compatibility gate searches the committed compatibility documents reachable through every local Git ref and +rejects a reused version with a different source identity. Fetch all immutable candidate tags before relying on +that result; protected remote refs make this history an enforceable release record rather than mutable prose. +README records the dated official-documentation recheck for the source and marketplace-add shapes; renew that +check before release rather than treating the citation alone as runtime evidence. + +`main` holds marketplace candidates. The dedicated `stable` ref is the distributable channel, and ordinary +installation must use `claude plugin marketplace add firstdraft/skills@stable`. A catalog commit on `main` is not a +release. Merging catalog commit B integrates that candidate on `main`; a separately approved fast-forward of +`stable` to exact B is the release mutation. If `stable` does not yet exist, creating it at exact B is the initial +release mutation and requires the same approval and reconciliation boundary. + +Plugin promotion separates content and catalog commits: + +1. Merge content normally without changing the marketplace SemVer or pinned source. Report the merged content SHA + as unpromoted and ask whether to prepare and qualify a coordinated candidate. +2. With explicit approval, prepare a separate catalog commit that bumps the marketplace version according to SemVer + and points its `sha` at the already-existing content commit. Update `release/compatibility.json` in that same + commit. After separate candidate-preparation approval, create one immutable tag such as + `marketplace-v0.1.0-alpha.1-candidate.1` at that exact catalog commit. Never move or delete a candidate tag. If tag + creation has an ambiguous outcome, inspect the remote ref read-only and do not retry until its identity is known. + +## Remote ref prerequisites + +Before creating the first candidate tag or `stable`, a human administrator must verify GitHub rulesets that: + +- block force-push and deletion of `stable`; +- restrict creation and updates of `stable` to the designated release operator; +- block update and deletion for `marketplace-v*-candidate.*` tags; and +- leave ordinary `main` integration unable to advance the distributable ref. + +Protection changes are separate external mutations and are not performed by repository checks. If the required +rulesets are absent, stop before tag or branch creation and request approval for that administration work. + +Release corrections are forward-only. Publish a new SemVer, source pin, catalog commit, and candidate tag, then +fast-forward `stable` after qualification. Never rewrite a released version's source, move a candidate tag, force +`stable`, or rewind it to an earlier catalog. + +## Candidate and promotion flow + +After merging a change to `main`: + +1. Report this repository's exact merged SHA and ask the user whether to coordinate and promote a three-repository + candidate. If the user declines, record the SHA as unpromoted and stop. +2. With explicit approval, resolve clean checkouts at exact SHAs for `firstdraft` and `cli`, the exact Skills catalog + commit, and its immutable plugin source SHA. Run each repository's checks and the First Draft cross-repository + compatibility gate. The Skills checkout must contain full, unshallowed history including the historical evidence + and pinned source commits, plus every immutable candidate tag; fetch tags, use + `git rev-parse --is-shallow-repository`, and fetch any missing history before the check. The local gate verifies + that the source SHA is an ancestor of catalog HEAD and that no compatibility document reachable from a local ref + already maps this version to another source. A SemVer-compatible result makes the candidate eligible for + qualification only. +3. One operator manually deploys the exact service candidate to staging and uses the exact CLI and Skills candidates + for the approved staging qualification and replay. Add the marketplace as + `firstdraft/skills@` so the externally fetched catalog is exact. The plugin must then be + installed from that catalog's pinned source SHA, not from the catalog checkout's current `skills/` subtree. Do + not substitute newer `main` revisions during the journey. Claude Code's official + [`plugin marketplace add` reference](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) + documents this `owner/repo@ref` form. +4. During live plugin qualification, verify that the cache directory uses the expected SemVer and byte-compare every + installed file and mode with the pinned source tree. The pinned subtree must remain manifestless so no + `plugin.json` can override the marketplace version. The existing isolation harness was designed for a local + marketplace; review it before this run because the pinned Git source may require a narrowly scrubbed Git path. + Do not weaken its credential or host-state boundaries, and do not present local checks as this live observation. +5. Verify remote reachability read-only before release: fetch the candidate tag and `origin/main`, confirm the tag + resolves to the qualified catalog commit, and confirm the source SHA is an ancestor of the fetched remote main. + A local object alone is insufficient evidence that Claude Code can fetch it. +6. Preserve the candidate tag, qualified catalog SHA, plugin source SHA/version, other component SHAs, and evidence. + A human decides whether to promote the same candidate to production and release its CLI and plugin components. +7. With separate explicit approval, one operator creates `stable` at the exact qualified catalog commit for the + initial release, or fast-forwards the existing ref for a later release. If the ref update outcome is ambiguous, + reconcile the remote ref read-only before any retry; never retry or advance another ref until the outcome is + known. Each external mutation remains serialized through that operator. + +The CLI package and this plugin remain unreleased, and the repository does not currently claim a completed staging +or production journey. The dated fresh-model and isolated-install observations predate the explicit marketplace +version and remain bound to their recorded Git revisions; they do not qualify this candidate. Do not run +`npm publish`; this repository's npm package is private test tooling. + +## Checks + +Run the compatibility check by itself with: + +```sh +npm run check:release-compatibility +``` + +Run the normal repository checks from a full-history checkout. Strict validation through the real Claude Code CLI is +a separate manual candidate gate; a passing invocation is useful preflight evidence, not durable Git-hosted +installation qualification: + +```sh +npm ci --ignore-scripts +npm run check +claude plugin validate --strict . +claude plugin validate --strict .claude-plugin/plugin.json +``` + +The old local-directory install smoke described in [`README.md`](README.md) intentionally rejects the remote-pinned +source before resolving Claude or inspecting host state. Git-hosted qualification must use the reviewed successor +harness, immutable candidate tag, and pinned remote source. Neither strict validation nor any local check authorizes +release. diff --git a/package.json b/package.json index 8cf34bc..8adb7b8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "ajv": "8.20.0" }, "scripts": { - "check": "node --test", + "check": "sh script/check", + "check:release-compatibility": "node script/check-release-compatibility.mjs", "check:claude-plugin-install": "node script/check-claude-plugin-install.mjs", "record:claude-plugin-install": "node script/check-claude-plugin-install.mjs --observation-output evidence/claude-code-plugin-install-observation.json" } diff --git a/release/compatibility.json b/release/compatibility.json new file mode 100644 index 0000000..c08aba8 --- /dev/null +++ b/release/compatibility.json @@ -0,0 +1,21 @@ +{ + "format": "firstdraft.release-compatibility/1", + "component": "skills", + "version": "0.1.0-alpha.1", + "plugin_source": { + "git_sha": "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e", + "path": "skills/create-full-stack-app" + }, + "requires": { + "api_contract": [ + ">= 0.1.0", + "< 0.2.0" + ], + "cli": [ + "= 0.1.0-alpha.2" + ], + "foundation_plan_formats": [ + "firstdraft.foundation-plan.sketch/0.19" + ] + } +} diff --git a/script/check b/script/check index 196a20b..82200ff 100755 --- a/script/check +++ b/script/check @@ -4,4 +4,5 @@ set -eu repository=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repository" +node script/check-release-compatibility.mjs node --test 'test/**/*.test.mjs' diff --git a/script/check-claude-plugin-install.mjs b/script/check-claude-plugin-install.mjs index c6c66e5..988261f 100644 --- a/script/check-claude-plugin-install.mjs +++ b/script/check-claude-plugin-install.mjs @@ -45,9 +45,16 @@ import { stateTargetPresence, } from "./plugin-isolation.mjs"; +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const installSmokeMarketplace = JSON.parse( + readFileSync( + path.join(repository, ".claude-plugin", "marketplace.json"), + "utf8", + ), +); +assertLocalInstallSmokeSupported(installSmokeMarketplace); assertDefaultClaudeStateLocations(process.env); -const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const portableSkill = path.join( repository, "skills", @@ -554,3 +561,17 @@ function requestedObservationPath(arguments_) { ); return path.resolve(arguments_[1]); } + +function assertLocalInstallSmokeSupported(marketplace) { + const marketplacePlugin = marketplace.plugins?.find( + ({ name }) => name === "firstdraft", + ); + assert(marketplacePlugin, "marketplace plugin entry is missing"); + assert.equal( + typeof marketplacePlugin.source, + "string", + "the local Claude Code install smoke does not support the remote-pinned " + + "git-subdir plugin source; qualify the immutable candidate tag and its " + + "pinned remote source with the reviewed release harness", + ); +} diff --git a/script/check-release-compatibility.mjs b/script/check-release-compatibility.mjs new file mode 100644 index 0000000..deba00f --- /dev/null +++ b/script/check-release-compatibility.mjs @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + cliPackageVersion, + foundationPlanFormat, +} from "./cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compatibilityPath = "release/compatibility.json"; +const semanticVersionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +export function isSemanticVersion(value) { + return typeof value === "string" && semanticVersionPattern.test(value); +} + +export function assertSkillsReleaseCompatibility({ + compatibility, + marketplace, + packageDocument, + previewManifest, +}) { + assertExactKeys( + compatibility, + ["component", "format", "plugin_source", "requires", "version"], + "release compatibility", + ); + assert.equal( + compatibility.format, + "firstdraft.release-compatibility/1", + ); + assert.equal(compatibility.component, "skills"); + assertSemanticVersion(compatibility.version, "Skills compatibility version"); + assertExactKeys( + compatibility.plugin_source, + ["git_sha", "path"], + "plugin source identity", + ); + assert.match( + compatibility.plugin_source.git_sha, + /^[0-9a-f]{40}$/, + "plugin source identity must use a full lowercase Git SHA", + ); + assert.equal( + compatibility.plugin_source.path, + "skills/create-full-stack-app", + ); + + assertExactKeys( + compatibility.requires, + ["api_contract", "cli", "foundation_plan_formats"], + "release compatibility requirements", + ); + for (const [name, requirements] of Object.entries(compatibility.requires)) { + assertStringArray(requirements, `${name} requirements`); + } + for (const requirement of [ + ...compatibility.requires.api_contract, + ...compatibility.requires.cli, + ]) { + assertComparator(requirement); + } + assert.deepEqual(compatibility.requires, { + api_contract: [">= 0.1.0", "< 0.2.0"], + cli: [`= ${cliPackageVersion}`], + foundation_plan_formats: [foundationPlanFormat], + }); + + assert.equal(marketplace.name, "firstdraft-skills"); + assert(Array.isArray(marketplace.plugins), "marketplace plugins must be an array"); + const installablePlugins = marketplace.plugins.filter( + ({ name }) => name === "firstdraft", + ); + assert.equal( + installablePlugins.length, + 1, + "marketplace must contain exactly one installable firstdraft plugin", + ); + const installablePlugin = installablePlugins[0]; + assertSemanticVersion( + installablePlugin.version, + "installable marketplace plugin version", + ); + assert.equal( + compatibility.version, + installablePlugin.version, + "compatibility version must match the installable marketplace plugin", + ); + assertExactKeys( + installablePlugin.source, + ["path", "sha", "source", "url"], + "installable marketplace plugin source", + ); + assert.equal(installablePlugin.source.source, "git-subdir"); + assert.equal( + installablePlugin.source.url, + "https://github.com/firstdraft/skills.git", + ); + assert.equal( + compatibility.plugin_source.git_sha, + installablePlugin.source.sha, + "compatibility source SHA must match the marketplace plugin source", + ); + assert.equal( + compatibility.plugin_source.path, + installablePlugin.source.path, + "compatibility source path must match the marketplace plugin source", + ); + + assert.equal(previewManifest.name, "firstdraft-preview"); + assert.notEqual( + previewManifest.name, + installablePlugin.name, + "checkout preview manifest must remain distinct from the installable plugin", + ); + assert.equal(packageDocument.name, "@firstdraft/skills"); + assert.equal(packageDocument.version, "0.0.0"); + assert.equal( + packageDocument.private, + true, + "the root npm package is private tooling, not the plugin release identity", + ); + + return compatibility; +} + +export async function checkSkillsReleaseCompatibility(root = repository) { + const [compatibility, marketplace, packageDocument, previewManifest] = + await Promise.all([ + readJson(path.join(root, "release", "compatibility.json")), + readJson(path.join(root, ".claude-plugin", "marketplace.json")), + readJson(path.join(root, "package.json")), + readJson(path.join(root, ".claude-plugin", "plugin.json")), + ]); + + const checked = assertSkillsReleaseCompatibility({ + compatibility, + marketplace, + packageDocument, + previewManifest, + }); + assertPluginSourceAncestor({ + gitSha: checked.plugin_source.git_sha, + root, + }); + assertVersionSourceHistory({ + compatibility: checked, + historicalCompatibilities: readHistoricalCompatibilities({ root }), + }); + + return checked; +} + +export function assertVersionSourceHistory({ + compatibility, + historicalCompatibilities, +}) { + for (const historical of historicalCompatibilities) { + if ( + historical.compatibility.component !== compatibility.component || + historical.compatibility.version !== compatibility.version + ) { + continue; + } + assert.deepEqual( + historical.compatibility.plugin_source, + compatibility.plugin_source, + `Skills version ${compatibility.version} was already mapped to a ` + + `different plugin source at ${historical.revision}; assign a new ` + + "never-reused SemVer", + ); + } +} + +export function readHistoricalCompatibilities({ + root, + spawn = spawnSync, +}) { + const history = spawn( + "git", + [ + "log", + "--all", + "--diff-merges=first-parent", + "--no-patch", + "--diff-filter=AM", + "--format=%H", + "--", + compatibilityPath, + ], + { cwd: root, encoding: "utf8" }, + ); + assertGitSucceeded(history, "could not inspect release compatibility history"); + + return history.stdout + .trim() + .split("\n") + .filter(Boolean) + .map((revision) => { + assert.match(revision, /^[0-9a-f]{40,64}$/); + const document = spawn( + "git", + ["show", `${revision}:${compatibilityPath}`], + { cwd: root, encoding: "utf8" }, + ); + assertGitSucceeded( + document, + `could not read release compatibility at ${revision}`, + ); + return { + revision, + compatibility: JSON.parse(document.stdout), + }; + }); +} + +export function assertPluginSourceAncestor({ + gitSha, + root, + spawn = spawnSync, +}) { + const result = spawn( + "git", + ["merge-base", "--is-ancestor", gitSha, "HEAD"], + { cwd: root, encoding: "utf8" }, + ); + if (result.status === 1) { + assert.fail( + `plugin source ${gitSha} is not an ancestor of catalog HEAD; ` + + "the pin or catalog checkout is wrong", + ); + } + assert.equal( + result.status, + 0, + "could not verify plugin source ancestry: " + + [result.error?.message, result.stderr].filter(Boolean).join("; ") + + "; fetch full unshallowed history containing the pinned commit", + ); +} + +function assertGitSucceeded(result, message) { + assert.equal( + result.status, + 0, + `${message}: ` + + [result.error?.message, result.stderr].filter(Boolean).join("; "), + ); +} + +function assertExactKeys(value, expected, label) { + assert( + value && typeof value === "object" && !Array.isArray(value), + `${label} must be an object`, + ); + assert.deepEqual( + Object.keys(value).sort(), + [...expected].sort(), + `${label} must contain exactly the supported keys`, + ); +} + +function assertStringArray(value, label) { + assert(Array.isArray(value) && value.length > 0, `${label} must be nonempty`); + for (const item of value) { + assert.equal(typeof item, "string", `${label} must contain only strings`); + } +} + +function assertComparator(value) { + const match = /^(>=|<=|>|<|=) (.+)$/.exec(value); + assert(match, `invalid SemVer comparator: ${value}`); + assertSemanticVersion(match[2], `SemVer comparator ${value}`); +} + +function assertSemanticVersion(value, label) { + assert(isSemanticVersion(value), `${label} must be a valid semantic version`); +} + +async function readJson(file) { + return JSON.parse(await readFile(file, "utf8")); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await checkSkillsReleaseCompatibility(); + process.stdout.write("Skills release compatibility metadata is valid.\n"); +} diff --git a/test/release-compatibility.test.mjs b/test/release-compatibility.test.mjs new file mode 100644 index 0000000..8881f49 --- /dev/null +++ b/test/release-compatibility.test.mjs @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertPluginSourceAncestor, + assertSkillsReleaseCompatibility, + assertVersionSourceHistory, + checkSkillsReleaseCompatibility, + isSemanticVersion, + readHistoricalCompatibilities, +} from "../script/check-release-compatibility.mjs"; +import { + cliPackageVersion, + foundationPlanFormat, +} from "../script/cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +test("release compatibility matches the installable plugin manifest", async () => { + const compatibility = await checkSkillsReleaseCompatibility(repository); + + assert.deepEqual(compatibility, { + format: "firstdraft.release-compatibility/1", + component: "skills", + version: "0.1.0-alpha.1", + plugin_source: { + git_sha: "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e", + path: "skills/create-full-stack-app", + }, + requires: { + api_contract: [">= 0.1.0", "< 0.2.0"], + cli: [`= ${cliPackageVersion}`], + foundation_plan_formats: [foundationPlanFormat], + }, + }); +}); + +test("release compatibility rejects shape and manifest drift", async () => { + const documents = await releaseDocuments(); + const withExtraKey = structuredClone(documents); + withExtraKey.compatibility.release = true; + assert.throws( + () => assertSkillsReleaseCompatibility(withExtraKey), + /exactly the supported keys/, + ); + + const withInvalidRequirement = structuredClone(documents); + withInvalidRequirement.compatibility.requires.cli = ["0.1.0-alpha.2"]; + assert.throws( + () => assertSkillsReleaseCompatibility(withInvalidRequirement), + /invalid SemVer comparator/, + ); + + const withMarketplaceDrift = structuredClone(documents); + withMarketplaceDrift.marketplace.plugins[0].version = "0.1.1"; + assert.throws( + () => assertSkillsReleaseCompatibility(withMarketplaceDrift), + /must match the installable marketplace plugin/, + ); + + const withSourceDrift = structuredClone(documents); + withSourceDrift.marketplace.plugins[0].source.sha = "0".repeat(40); + assert.throws( + () => assertSkillsReleaseCompatibility(withSourceDrift), + /source SHA must match/, + ); + + const withShortSourceIdentity = structuredClone(documents); + withShortSourceIdentity.compatibility.plugin_source.git_sha = "8ffbd96"; + assert.throws( + () => assertSkillsReleaseCompatibility(withShortSourceIdentity), + /full lowercase Git SHA/, + ); +}); + +test("release compatibility uses strict semantic versions", () => { + for (const version of ["0.1.0", "0.1.0-alpha.1", "1.0.0+build.7"]) { + assert.equal(isSemanticVersion(version), true, version); + } + for (const version of ["v0.1.0", "01.0.0", "0.1", "0.1.0-"]) { + assert.equal(isSemanticVersion(version), false, version); + } +}); + +test("plugin source must be an ancestor of the catalog checkout", () => { + let invocation; + assert.doesNotThrow(() => + assertPluginSourceAncestor({ + gitSha: "1".repeat(40), + root: "/catalog", + spawn(...arguments_) { + invocation = arguments_; + return { status: 0, stderr: "" }; + }, + }), + ); + assert.deepEqual(invocation, [ + "git", + ["merge-base", "--is-ancestor", "1".repeat(40), "HEAD"], + { cwd: "/catalog", encoding: "utf8" }, + ]); + + assert.throws( + () => + assertPluginSourceAncestor({ + gitSha: "2".repeat(40), + root: "/catalog", + spawn: () => ({ status: 1, stderr: "" }), + }), + /is not an ancestor of catalog HEAD.*pin or catalog checkout is wrong/, + ); + assert.throws( + () => + assertPluginSourceAncestor({ + gitSha: "3".repeat(40), + root: "/catalog", + spawn: () => ({ status: 128, stderr: "missing commit" }), + }), + /could not verify plugin source ancestry: missing commit.*full unshallowed history/, + ); +}); + +test("one Skills version maps permanently to one plugin source", () => { + const current = { + component: "skills", + version: "0.1.0-alpha.1", + plugin_source: { + git_sha: "1".repeat(40), + path: "skills/create-full-stack-app", + }, + }; + assert.doesNotThrow(() => + assertVersionSourceHistory({ + compatibility: current, + historicalCompatibilities: [ + { + revision: "2".repeat(40), + compatibility: structuredClone(current), + }, + { + revision: "3".repeat(40), + compatibility: { + ...structuredClone(current), + version: "0.1.0-alpha.2", + plugin_source: { + ...current.plugin_source, + git_sha: "4".repeat(40), + }, + }, + }, + ], + }), + ); + + assert.throws( + () => + assertVersionSourceHistory({ + compatibility: current, + historicalCompatibilities: [ + { + revision: "5".repeat(40), + compatibility: { + ...structuredClone(current), + plugin_source: { + ...current.plugin_source, + git_sha: "6".repeat(40), + }, + }, + }, + ], + }), + /version 0\.1\.0-alpha\.1 was already mapped.*assign a new never-reused SemVer/, + ); +}); + +test("release history is read from every committed ref", () => { + const invocations = []; + const historical = readHistoricalCompatibilities({ + root: "/catalog", + spawn(command, arguments_, options) { + invocations.push([command, arguments_, options]); + if (arguments_[0] === "log") { + return { status: 0, stderr: "", stdout: `${"7".repeat(40)}\n` }; + } + return { + status: 0, + stderr: "", + stdout: JSON.stringify({ + component: "skills", + version: "0.1.0-alpha.1", + plugin_source: { + git_sha: "8".repeat(40), + path: "skills/create-full-stack-app", + }, + }), + }; + }, + }); + + assert.deepEqual(invocations, [ + [ + "git", + [ + "log", + "--all", + "--diff-merges=first-parent", + "--no-patch", + "--diff-filter=AM", + "--format=%H", + "--", + "release/compatibility.json", + ], + { cwd: "/catalog", encoding: "utf8" }, + ], + [ + "git", + ["show", `${"7".repeat(40)}:release/compatibility.json`], + { cwd: "/catalog", encoding: "utf8" }, + ], + ]); + assert.equal(historical.length, 1); + assert.equal(historical[0].revision, "7".repeat(40)); + assert.equal(historical[0].compatibility.plugin_source.git_sha, "8".repeat(40)); +}); + +test("release operator and agent instructions track the candidate", async () => { + const [marketplace, releasing, agents] = await Promise.all([ + readJson(".claude-plugin/marketplace.json"), + readText("RELEASING.md"), + readText("AGENTS.md"), + ]); + const candidate = marketplace.plugins.find(({ name }) => name === "firstdraft"); + + assert(releasing.includes(`version is \`${candidate.version}\``)); + assert(releasing.includes(candidate.source.sha)); + assert( + releasing.includes( + `marketplace-v${candidate.version}-candidate.1`, + ), + ); + assert.match( + releasing, + /One marketplace SemVer maps forever to exactly one source SHA/, + ); + assert.match( + releasing, + /block force-push and deletion of `stable`[\s\S]*?restrict creation and updates of `stable`[\s\S]*?block update and deletion for `marketplace-v\*-candidate\.\*` tags[\s\S]*?ordinary `main` integration unable to advance the distributable ref/, + ); + assert.match( + releasing, + /Verify remote reachability read-only before release/, + ); + assert.match( + releasing, + /Release corrections are forward-only[\s\S]*?Never rewrite a released version's source[\s\S]*?force\s+`stable`[\s\S]*?rewind it/, + ); + + assert.match( + agents, + /Never reuse a marketplace SemVer with a different source SHA/, + ); + assert.match( + agents, + /verify read-only that rulesets block `stable` force-push\/deletion[\s\S]*?candidate tags immutable/, + ); + assert.match(agents, /move\s+`stable` forward only after qualification/); +}); + +async function releaseDocuments() { + const [compatibility, marketplace, packageDocument, previewManifest] = + await Promise.all([ + readJson("release/compatibility.json"), + readJson(".claude-plugin/marketplace.json"), + readJson("package.json"), + readJson(".claude-plugin/plugin.json"), + ]); + + return { compatibility, marketplace, packageDocument, previewManifest }; +} + +async function readJson(relativePath) { + return JSON.parse(await readText(relativePath)); +} + +async function readText(relativePath) { + return readFile(path.join(repository, relativePath), "utf8"); +} diff --git a/test/repository.test.mjs b/test/repository.test.mjs index c68d687..c03d15c 100644 --- a/test/repository.test.mjs +++ b/test/repository.test.mjs @@ -17,7 +17,6 @@ import { import { assertNoObservationAbsolutePathLeaks, observedFileBytes, - observedFileInventory, observedFileTreeSha256, renderManifestValidationEvidence, renderStatePresenceNames, @@ -86,6 +85,8 @@ const freshModelServiceTree = "076415a4b1e34cc458a85186e1e335503eb30612"; const freshModelPluginBaseline = "b5c3897b240bfa3a9117d1a564d8e6b7d783e993"; +const marketplacePluginSourceBaseline = + "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e"; const freshModelPluginRuntimeDigest = "a5c3bfe0dd8d5396a692c4204c670e10cbc4b996883f76025d9e8a6586becc7b"; const freshModelClaudeExecutableDigest = @@ -243,6 +244,7 @@ test("revision pins remain exhaustive across coordination surfaces", async () => freshModelServiceBaseline, freshModelServiceTree, freshModelPluginBaseline, + marketplacePluginSourceBaseline, freshModelPublicationTree, freshModelPublicationCommit, foundationIosCoreRevision, @@ -420,7 +422,10 @@ test("fresh Claude Code evidence is exact and bounded", async () => { revision: freshModelPluginBaseline, runtime_sha256: freshModelPluginRuntimeDigest, }); - assert.equal(await pluginRuntimeDigest(), freshModelPluginRuntimeDigest); + assert.equal( + pluginRuntimeDigestAtRevision(freshModelPluginBaseline), + freshModelPluginRuntimeDigest, + ); assert.deepEqual(observation.command_ledger, { "compilation.help": 1, "generate.help": 1, @@ -526,6 +531,7 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => await readFile(path.join(claudePluginDirectory, "marketplace.json"), "utf8"), ); const portableSkillPath = `./skills/${portableSkillName}`; + const marketplaceSourcePath = `skills/${portableSkillName}`; assert.deepEqual(plugin, { $schema: "https://json.schemastore.org/claude-code-plugin-manifest.json", @@ -555,7 +561,13 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => plugins: [ { name: claudePluginName, - source: "./skills/create-full-stack-app", + version: "0.1.0-alpha.1", + source: { + source: "git-subdir", + url: "https://github.com/firstdraft/skills.git", + path: marketplaceSourcePath, + sha: marketplacePluginSourceBaseline, + }, strict: false, skills: ["./"], displayName: "First Draft", @@ -574,7 +586,6 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => }, ], }); - assert(!("version" in marketplace.plugins[0])); const pluginSkillDirectory = path.resolve(repository, portableSkillPath); assert.equal( @@ -582,7 +593,7 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => path.join(skillsDirectory, portableSkillName), ); assert.equal( - path.resolve(repository, marketplace.plugins[0].source), + path.resolve(repository, marketplace.plugins[0].source.path), pluginSkillDirectory, ); assert.equal( @@ -672,6 +683,16 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => "references/foundation-plan-019.md", "references/modeling-guide.md", ]); + const pinnedSourceFiles = gitTreePathsAtRevision( + marketplacePluginSourceBaseline, + marketplaceSourcePath, + ).map((file) => path.posix.relative(marketplaceSourcePath, file)); + assert.deepEqual(pinnedSourceFiles, canonicalClaudePluginSkillFiles); + assert.equal( + pinnedSourceFiles.some((file) => file === ".claude-plugin/plugin.json"), + false, + "the pinned plugin source must remain manifestless", + ); assert.deepEqual(installedSourceFiles, canonicalClaudePluginSkillFiles); assert.deepEqual(forbiddenClaudePluginPathSegments, [ "evals", @@ -723,15 +744,7 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => assert(!Object.hasOwn(marketplace.plugins[0], component)); } - const installedSourceBytes = ( - await Promise.all( - installedSourceFiles.map(async (relativePath) => - (await stat(path.join(pluginSkillDirectory, relativePath))).size, - ), - ) - ).reduce((total, size) => total + size, 0); assert.equal(installedSourceFiles.length, 8); - assert.equal(installedSourceBytes, 207_433); const readme = await readFile(path.join(repository, "README.md"), "utf8"); assert.match(readme, /eight canonical Skill files/); assert.match( @@ -754,7 +767,16 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => ); assert.match( readme, - /preview-only `0\.1\.0` version[\s\S]*?direct strict manifest validation[\s\S]*?excluded from the marketplace\s+plugin source/, + /preview-only `0\.1\.0` version[\s\S]*?excluded from the marketplace\s+plugin source[\s\S]*?installable marketplace entry independently owns\s+the plugin release version, currently `0\.1\.0-alpha\.1`[\s\S]*?checkout preview's separate `0\.1\.0` does not govern that\s+installation[\s\S]*?private root npm package[\s\S]*?`0\.0\.0` version[\s\S]*?not plugin identity/, + ); + assert( + readme.includes( + "https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources", + ), + ); + assert.match( + readme, + /`git-subdir` field set and the `owner\/repo@ref` add form[\s\S]*?rechecked on 2026-08-05[\s\S]*?dated documentation check is not installation evidence/, ); assert( readme.includes( @@ -763,7 +785,7 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => ); assert.match( readme, - /Git-hosted marketplace falls back to the commit SHA[\s\S]*?documented\s+expectation here, not observed Git-hosted installation evidence/, + /One marketplace SemVer maps permanently to one source SHA[\s\S]*?failed or revised candidate\s+must receive a new prerelease version and immutable candidate tag[\s\S]*?never reuse an earlier version with new bytes[\s\S]*?explicitly versioned plugin remains cached until that version changes[\s\S]*?Every revised candidate therefore\s+requires a deliberate, never-reused SemVer bump[\s\S]*?version only participates in compatibility eligibility and never authorizes release/, ); assert( readme.includes( @@ -777,19 +799,36 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => ); assert.match( readme, - /marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition[\s\S]*?raw source directory without its own `plugin\.json`[\s\S]*?source manifest that also declares\s+components would conflict[\s\S]*?does not relax validation[\s\S]*?`claude plugin validate --strict` treats validation warnings, including unrecognized fields, as errors for CI[\s\S]*?Both\s+strict validation commands above remain release gates/, + /marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition[\s\S]*?raw source directory without its own `plugin\.json`[\s\S]*?source manifest that also declares\s+components would conflict[\s\S]*?does not relax validation[\s\S]*?strict validation commands above remain manual release gates[\s\S]*?successful local invocation is not durable\s+qualification of a Git-hosted catalog or installation/, ); assert(readme.includes("https://code.claude.com/docs/en/plugins")); assert(readme.includes("https://code.claude.com/docs/en/plugins-reference")); assert(readme.includes("https://code.claude.com/docs/en/env-vars")); + const marketplaceAddSources = [ + ...readme.matchAll(/\bclaude plugin marketplace add ([^\s`]+)/g), + ].map((match) => match[1]); + assert(marketplaceAddSources.includes("firstdraft/skills@stable")); + assert( + marketplaceAddSources.every( + (source) => + source === "firstdraft/skills@stable" || + /^firstdraft\/skills@marketplace-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?-candidate\.\d+$/.test( + source, + ), + ), + ); + assert.doesNotMatch( + readme, + /claude plugin marketplace add firstdraft\/skills(?:@main)?(?=\s|`|$)/, + ); assert( readme.includes( - "CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills", + "https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add", ), ); assert.match( readme, - /`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner\/repo` shorthand over HTTPS rather than SSH[\s\S]*?users with working GitHub SSH configuration may\s+omit it[\s\S]*?No live GitHub clone has been observed for this package/, + /`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner\/repo` shorthand over HTTPS rather than SSH[\s\S]*?users with working GitHub SSH configuration may\s+omit it[\s\S]*?documents `owner\/repo@ref`[\s\S]*?`@stable` ref is the intended distributable channel[\s\S]*?ordinary installation must not\s+track mutable `main`[\s\S]*?No live GitHub clone from either ref has been observed for this package/, ); assert.match( readme, @@ -801,51 +840,19 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => ); assert.match( readme, - /recording command regenerates only the machine-readable JSON[\s\S]*?UTC date and observed Claude Code version[\s\S]*?rename the dated Markdown evidence file[\s\S]*?state-presence bullets, and real-state monitor\s+summary line[\s\S]*?dated default-component-location recheck[\s\S]*?model-session conclusion against any newer evaluation\s+evidence[\s\S]*?update the evidence path and the expected date\/version pins[\s\S]*?rerun the\s+repository checks and both strict validations[\s\S]*?Recheck the checkout-root default-component allowlist/, - ); - assert.match( - readme, - /`~\/\.claude\.json` exclusion and default real-state targets require `CLAUDE_CONFIG_DIR` and\s+`CLAUDE_CODE_PLUGIN_CACHE_DIR` to be unset[\s\S]*?fail before resolving Claude,\s+inspecting real Claude state, or creating temporary state[\s\S]*?names only\s+the override variables, never their values/, - ); - assert.match( - readme, - /expected live component inventory is deliberately hardcoded[\s\S]*?assertion in `script\/check-claude-plugin-install\.mjs`[\s\S]*?repository-test pins[\s\S]*?generated\s+observation[\s\S]*?dated prose together[\s\S]*?Rerunning the recording command alone cannot renew that expectation/, - ); - assert.match( - readme, - /`agents\/openai\.yaml` file is Skill metadata, not a Claude Code Agent definition[\s\S]*?observed `Agents=0` result[\s\S]*?rechecked whenever the CLI version changes/, - ); - assert.match( - readme, - /Every child command runs from a newly created isolated working directory[\s\S]*?requires\s+`CLAUDE_BIN` or the parent PATH to resolve to a regular, executable native Claude Code binary and rejects shebang\s+wrappers/, - ); - assert.match( - readme, - /excludes the high-churn `~\/\.claude\.json`[\s\S]*?no whole-configuration monitoring claim/, - ); - assert.match( - readme, - /refuses to make an unchanged-state claim if any monitored target or nested entry is a symbolic link/, + /machine-readable observation[\s\S]*?remain bound to the\s+eight canonical Skill files at historical revision `b5c3897b240bfa3a9117d1a564d8e6b7d783e993`[\s\S]*?reconstruct\s+those Git objects rather than requiring today's unpromoted source to match them[\s\S]*?does not qualify this remote-pinned catalog/, ); assert.match( readme, - /Close every other Claude Code session before running the smoke[\s\S]*?share plugin registries and\s+caches[\s\S]*?concurrent legitimate update[\s\S]*?make this check fail/, + /old local-directory harness cannot exercise the current remote-pinned `git-subdir` source[\s\S]*?child\s+PATH intentionally provided no Git[\s\S]*?`npm run check:claude-plugin-install` and `npm run record:claude-plugin-install` now fail before resolving Claude,\s+inspecting real state, or creating temporary state[\s\S]*?Do not use either\s+command to renew or qualify the current candidate/, ); assert.match( readme, - /diagnostic names every changed monitor together with its resolved absolute filesystem path/, + /Git-hosted qualification requires a reviewed successor harness[\s\S]*?`owner\/repo@ref` form[\s\S]*?immutable candidate tag[\s\S]*?cache directory is exactly the\s+candidate SemVer[\s\S]*?byte- and mode-compare[\s\S]*?narrowly scrubbed executable path[\s\S]*?do not inherit\s+the operator's general PATH, tokens, SSH agent, or unrelated environment/, ); assert.match( readme, - /Claude Code 2\.1\.221 did not create `plugins\/marketplaces\/firstdraft-skills`[\s\S]*?`plugins\/data\/firstdraft-firstdraft-skills`[\s\S]*?`targetMarketplace` and\s+`targetData` are conservative candidate-path monitors[\s\S]*?not confirmed\s+current CLI storage layouts[\s\S]*?absence is not load-bearing isolation evidence/, - ); - assert.match( - readme, - /compares its live CLI version, captured strict-validation results, component\s+inventory, and installed bytes with the committed observation[\s\S]*?Real-state presence is run-local information rather\s+than a cross-machine release-gate value[\s\S]*?independently requires at least one core registry target and\s+proves the monitored targets unchanged/, - ); - assert.match( - readme, - /Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available[\s\S]*?source\s+archive, exported tree, or installed plugin cache is insufficient[\s\S]*?Git\s+index for the enumerated checkout-root component locations[\s\S]*?complete `skills\/`\s+subtree on disk/, + /Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available[\s\S]*?source\s+archive, exported tree, or installed plugin cache is insufficient[\s\S]*?complete `skills\/`\s+subtree on disk[\s\S]*?full,\s+unshallowed history[\s\S]*?`git rev-parse --is-shallow-repository`[\s\S]*?`git fetch --unshallow origin`/, ); const observationSource = await readFile(claudePluginObservation, "utf8"); const observation = JSON.parse(observationSource); @@ -853,15 +860,16 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => assert.equal( observation.observedOn, "2026-08-04", - "observation date changed; rerun the isolated recording, rename and " + - "refresh the dated evidence, then update this reviewed pin", + "historical observation date changed; preserve this pinned evidence. " + + "Use the reviewed candidate-tag and pinned-remote-source successor " + + "harness for a new dated observation with byte and mode comparison", ); assert.equal( observation.claudeCode.version, "2.1.221", - "Claude Code version changed; rerun the isolated recording, review " + - "component discovery and isolation, refresh the dated evidence, then " + - "update this pin", + "historical Claude Code version changed; preserve this pinned evidence. " + + "Use the reviewed candidate-tag and pinned-remote-source successor " + + "harness for a new dated observation with byte and mode comparison", ); assert.deepEqual(observation.claudeCode.componentInventory, { agents: 0, @@ -913,15 +921,23 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => observation.installedPlugin.files.map((file) => file.path), canonicalClaudePluginSkillFiles, ); - const currentFileInventory = observedFileInventory( - pluginSkillDirectory, - canonicalClaudePluginSkillFiles, + const historicalFileInventory = canonicalClaudePluginSkillFiles.map( + (relativePath) => { + const source = gitBlobAtRevision( + freshModelPluginBaseline, + `skills/create-full-stack-app/${relativePath}`, + ); + return { + path: relativePath, + bytes: source.length, + sha256: createHash("sha256").update(source).digest("hex"), + }; + }, ); assert.deepEqual( - currentFileInventory, + historicalFileInventory, observation.installedPlugin.files, - "canonical Skill bytes differ from the observed isolated install; " + - "rerun `npm run record:claude-plugin-install` to regenerate evidence", + "the pinned historical Skill bytes differ from the dated observation", ); assert.equal( observedFileBytes(observation.installedPlugin.files), @@ -1113,7 +1129,20 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => encoding: "utf8", }); assert.equal(syntaxCheck.status, 0, syntaxCheck.stderr); + const unsupportedSmoke = spawnSync(process.execPath, [smokeScript], { + cwd: repository, + encoding: "utf8", + env: {}, + }); + assert.notEqual(unsupportedSmoke.status, 0); + assert.match( + unsupportedSmoke.stderr, + /local Claude Code install smoke does not support the remote-pinned git-subdir plugin source[\s\S]*?qualify the immutable candidate tag and its pinned remote source with the reviewed release harness/, + ); const smokeSource = await readFile(smokeScript, "utf8"); + const remoteSourcePrecondition = smokeSource.indexOf( + "assertLocalInstallSmokeSupported(installSmokeMarketplace)", + ); const defaultStateLocationPrecondition = smokeSource.indexOf( "assertDefaultClaudeStateLocations(process.env)", ); @@ -1124,10 +1153,11 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => "const realStateBefore = realClaudeStateSnapshot()", ); assert( - defaultStateLocationPrecondition >= 0 && + remoteSourcePrecondition >= 0 && + remoteSourcePrecondition < defaultStateLocationPrecondition && defaultStateLocationPrecondition < claudeResolution && claudeResolution < realStateSnapshot, - "default real-state location precondition must run before Claude resolution and state inspection", + "remote-source and real-state preconditions must run before Claude resolution and state inspection", ); assert.doesNotMatch( smokeSource, @@ -3812,18 +3842,19 @@ function assertRevisionTokens(source, expected) { assert.deepEqual(revisionTokens(source), [...expected].sort()); } -async function pluginRuntimeDigest() { - const files = trackedFiles().filter((file) => { - const relativePath = path.relative(repository, file); - return ( +function pluginRuntimeDigestAtRevision(revision) { + const relativePaths = gitTreePathsAtRevision( + revision, + ".claude-plugin", + "skills/create-full-stack-app", + ).filter( + (relativePath) => /^\.claude-plugin\/[^/]+\.json$/.test(relativePath) || - relativePath.startsWith("skills/create-full-stack-app/") - ); - }); + relativePath.startsWith("skills/create-full-stack-app/"), + ); const digest = createHash("sha256"); - for (const file of files) { - const relativePath = path.relative(repository, file); - const source = await readFile(file); + for (const relativePath of relativePaths) { + const source = gitBlobAtRevision(revision, relativePath); const pathLength = Buffer.alloc(4); pathLength.writeUInt32BE(Buffer.byteLength(relativePath)); const sourceLength = Buffer.alloc(8); @@ -3836,6 +3867,48 @@ async function pluginRuntimeDigest() { return digest.digest("hex"); } +function gitBlobAtRevision(revision, relativePath) { + const blob = spawnSync("git", ["show", `${revision}:${relativePath}`], { + cwd: repository, + encoding: "buffer", + maxBuffer: 10 * 1024 * 1024, + }); + assert.equal( + blob.status, + 0, + `git show failed for ${revision}:${relativePath}: ` + + spawnBufferText(blob.stderr), + ); + return blob.stdout; +} + +function gitTreePathsAtRevision(revision, ...roots) { + const tree = spawnSync( + "git", + [ + "ls-tree", + "-r", + "--name-only", + "-z", + revision, + "--", + ...roots, + ], + { cwd: repository, encoding: "buffer" }, + ); + assert.equal( + tree.status, + 0, + `git ls-tree failed for ${revision}: ${spawnBufferText(tree.stderr)}`, + ); + const relativePaths = spawnBufferText(tree.stdout) + .split("\0") + .filter(Boolean) + .sort(); + assert(relativePaths.length > 0, `no Git tree paths found for ${revision}`); + return relativePaths; +} + function trackedFiles() { const result = spawnSync("git", ["ls-files", "-z"], { cwd: repository, From 07d1e81a4f67b2e673c197801fa836caf04bfacb Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 5 Aug 2026 11:20:31 -0500 Subject: [PATCH 2/3] Package Claude plugin with CLI Assemble one deterministic npm plugin from the canonical Skill and the exact reviewed CLI package so Claude Code installs both capabilities together. Gate publication on coordinated versions, protected release state, and the qualified artifact digest. --- .claude-plugin/marketplace.json | 14 +- .github/workflows/ci.yml | 5 +- .github/workflows/publish.yml | 127 +++ AGENTS.md | 25 +- README.md | 158 +--- RELEASING.md | 193 ++--- ...-08-05-claude-plugin-vendored-cli-smoke.md | 23 + package-lock.json | 6 +- package.json | 8 +- .../claude-plugin/.claude-plugin/plugin.json | 38 + packages/claude-plugin/bin/firstdraft | 12 + packages/claude-plugin/bin/firstdraft.js | 46 ++ packages/claude-plugin/package.template.json | 27 + release/compatibility.json | 6 +- script/check | 1 + script/check-claude-plugin-install.mjs | 11 +- script/check-claude-plugin-package.mjs | 301 +++++++ script/check-release-compatibility.mjs | 116 +-- script/claude-plugin-boundaries.mjs | 4 + script/claude-plugin-package.mjs | 182 +++++ script/cli-contract/config.mjs | 4 +- skills/create-full-stack-app/SKILL.md | 13 +- .../references/diagnostics-and-recovery.md | 5 +- .../references/foundation-plan-019.md | 4 +- test/release-compatibility.test.mjs | 128 ++- test/repository.test.mjs | 754 +++--------------- 26 files changed, 1129 insertions(+), 1082 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md create mode 100644 packages/claude-plugin/.claude-plugin/plugin.json create mode 100755 packages/claude-plugin/bin/firstdraft create mode 100644 packages/claude-plugin/bin/firstdraft.js create mode 100644 packages/claude-plugin/package.template.json create mode 100644 script/check-claude-plugin-package.mjs create mode 100644 script/claude-plugin-package.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3cb5a51..e2725ae 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,17 +9,13 @@ "plugins": [ { "name": "firstdraft", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "source": { - "source": "git-subdir", - "url": "https://github.com/firstdraft/skills.git", - "path": "skills/create-full-stack-app", - "sha": "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e" + "source": "npm", + "package": "@firstdraft.com/claude-code", + "version": "0.1.0-alpha.2", + "registry": "https://registry.npmjs.org/" }, - "strict": false, - "skills": [ - "./" - ], "displayName": "First Draft", "description": "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", "author": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index feb35f4..4006dce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: fetch-depth: 0 path: tmp/firstdraft-cli persist-credentials: false - - run: git -C tmp/firstdraft-cli merge-base --is-ancestor f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 HEAD - - run: git -C tmp/firstdraft-cli checkout --detach f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 - run: node script/check-cli-contract.mjs tmp/firstdraft-cli + - run: node script/check-claude-plugin-package.mjs --cli-root tmp/firstdraft-cli diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ba290a4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,127 @@ +name: Publish Claude plugin + +on: + push: + tags: ["claude-v*"] + +permissions: {} + +concurrency: + group: claude-plugin-npm-publish + cancel-in-progress: false + +jobs: + verify: + name: Verify release + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + - name: Verify tag and source commit + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = "firstdraft/skills" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + test "$release_sha" = "$(git rev-parse 'refs/release-check/tag^{commit}')" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("packages/claude-plugin/package.template.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "claude-v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + - run: npm ci --ignore-scripts + - run: npm audit + - run: npm run check + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: firstdraft/cli + ref: main + fetch-depth: 0 + path: tmp/firstdraft-cli + persist-credentials: false + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 + - run: node script/claude-plugin-package.mjs pack "$RUNNER_TEMP/plugin" --cli-root tmp/firstdraft-cli + + publish: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + package-manager-cache: false + registry-url: https://registry.npmjs.org/ + - name: Verify approved release + env: + NPM_RELEASE_ENABLED: ${{ vars.NPM_RELEASE_ENABLED }} + run: | + set -euo pipefail + test "$NPM_RELEASE_ENABLED" = "true" + test "$GITHUB_REPOSITORY" = "firstdraft/skills" + test "$GITHUB_EVENT_NAME" = "push" + test "$GITHUB_REF_TYPE" = "tag" + test "$GITHUB_REF_PROTECTED" = "true" + release_sha="$(git rev-parse 'HEAD^{commit}')" + event_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + test "$release_sha" = "$event_sha" + git fetch --force --no-tags origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/tags/${GITHUB_REF_NAME}:refs/release-check/tag" + test "$release_sha" = "$(git rev-parse 'refs/release-check/tag^{commit}')" + package_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("packages/claude-plugin/package.template.json", "utf8")).version')" + test "$GITHUB_REF_NAME" = "claude-v$package_version" + git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/main-first-parent" + grep -Fqx "$release_sha" "$RUNNER_TEMP/main-first-parent" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: firstdraft/cli + ref: main + fetch-depth: 0 + path: tmp/firstdraft-cli + persist-credentials: false + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 + - name: Verify CLI release is published + run: | + set -euo pipefail + cli_version="$(node --print 'JSON.parse(require("node:fs").readFileSync("tmp/firstdraft-cli/package.json", "utf8")).version')" + test "$(npm view "@firstdraft.com/cli@$cli_version" version)" = "$cli_version" + - run: node script/claude-plugin-package.mjs pack "$RUNNER_TEMP/plugin" --cli-root tmp/firstdraft-cli + - name: Verify publication bytes + run: | + set -euo pipefail + package_version="${GITHUB_REF_NAME#claude-v}" + tarball="$RUNNER_TEMP/plugin/firstdraft.com-claude-code-$package_version.tgz" + expected="$(node --print 'JSON.parse(require("node:fs").readFileSync("release/compatibility.json", "utf8")).plugin_source.tarball_sha256')" + actual="$(shasum -a 256 "$tarball" | awk '{print $1}')" + test "$actual" = "$expected" + - name: Publish verified package + run: npm publish "$RUNNER_TEMP/plugin/firstdraft.com-claude-code-${GITHUB_REF_NAME#claude-v}.tgz" --access public --tag next --provenance --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 52954f4..030dad1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,19 +3,16 @@ ## Release coordination - Treat a merge to `main` as integration, not release authorization. After merging, report the exact merged SHA and - ask the user whether to coordinate a candidate across `firstdraft`, `cli`, and `skills` and promote it. -- SemVer compatibility establishes candidate eligibility only. Record the exact SHA of every repository as the - candidate identity, including the Skills catalog checkout and pinned plugin source, then follow - [`RELEASING.md`](RELEASING.md). + ask whether to coordinate a candidate across `firstdraft`, `cli`, and `skills` and promote it. +- SemVer compatibility establishes candidate eligibility only. Record the exact SHA of every repository and the + packed Claude plugin SHA-256, then follow [`RELEASING.md`](RELEASING.md). - Do not publish npm packages, deploy First Draft, or release the plugin without explicit user approval. If the user declines promotion, identify the merged SHA as unpromoted. -- Never create or fast-forward the distributable `stable` ref without explicit approval. Moving `stable` is the - plugin release mutation; reconcile an ambiguous ref-update outcome read-only before taking further action. -- Create an immutable marketplace candidate tag only after candidate-preparation approval. Never move or delete a - candidate tag, and reconcile an ambiguous tag write read-only before continuing. -- Never reuse a marketplace SemVer with a different source SHA. Revisions and corrections use a new version and move - `stable` forward only after qualification. -- Before the first release mutation, verify read-only that rulesets block `stable` force-push/deletion, restrict its - updates to the release operator, and make candidate tags immutable. Do not change remote protection without - separate approval. -- Keep deployment, package publication, plugin release, and replay mutations serialized through one operator. +- Never reuse a published npm version or marketplace SemVer with different package bytes. Revisions and corrections + use a new version. +- Keep deployment, package publication, marketplace promotion, and replay mutations serialized through one + operator. Reconcile an ambiguous publication or push outcome read-only before retrying. +- Before pushing a `claude-v*` publication tag, verify its protection ruleset, the `npm` environment's required + reviewers, and the deliberately enabled `NPM_RELEASE_ENABLED` gate. +- The installable Claude package is assembled from the canonical Skill during packing. Do not commit a second + editable copy under `packages/`. diff --git a/README.md b/README.md index 73849f0..0862dd4 100644 --- a/README.md +++ b/README.md @@ -98,141 +98,65 @@ its full prepared capability boundary. ### Claude Code plugin preview -The repository also packages a source-only Claude Code plugin named `firstdraft`. Its marketplace entry points only -at the canonical `skills/create-full-stack-app` directory used by the portable Skill. The observed isolated installed -plugin cache contained no second copy of the Skill instructions, repository test harness, or runtime dependency -tree. A marketplace tree is a separate footprint: the smoke did not inventory an isolated marketplace tree, and no -Git-hosted marketplace tree has been observed. The root manifest exists only for the one-session local preview -below. From a checkout, validate the marketplace manifest and root preview manifest without installing either one: +The repository assembles an installable Claude Code plugin named `firstdraft` as the public npm package +`@firstdraft.com/claude-code`. Packing copies the canonical `skills/create-full-stack-app` directory into a temporary +staging tree; the generated copy is never edited or committed. The package also includes a small `firstdraft` +adapter and the exact packed bytes of `@firstdraft.com/cli@0.1.0-alpha.2`, so installing the plugin supplies both the +Skill and its compatible CLI without requiring Claude Code to install transitive npm dependencies. + +The marketplace catalog uses Claude Code's documented `npm` plugin source and pins +`@firstdraft.com/claude-code@0.1.0-alpha.2`. The installable manifest asks Claude Code for the staging API URL and a +sensitive API token. Claude stores sensitive configuration in secure storage and exports plugin options only to +plugin subprocesses. The adapter maps those options to the CLI's environment without printing them. Users should +create the token in First Draft's browser UI and enter it in Claude's configuration prompt, never paste it into an +agent conversation or command line. Installed-plugin configuration is authoritative: when it supplies the API URL, +the adapter deliberately ignores any ambient `FIRSTDRAFT_API_TOKEN` so a credential cannot cross API origins. The +environment variable remains a standalone-CLI configuration path. + +The root `firstdraft-preview` manifest remains a checkout-local development path and has a separate preview-only +version. From a checkout, validate the marketplace and preview manifests without installing either one: ```sh claude plugin validate --strict . claude plugin validate --strict .claude-plugin/plugin.json ``` -The root preview manifest uses the distinct name `firstdraft-preview` and carries a preview-only `0.1.0` version -because direct strict manifest validation requires a semantic version. The separate name cannot collide with an -installed `firstdraft@firstdraft-skills` plugin in the same session. That manifest is excluded from the marketplace -plugin source and does not version an ordinary installation. The installable marketplace entry independently owns -the plugin release version, currently `0.1.0-alpha.1`; the checkout preview's separate `0.1.0` does not govern that -installation. The private root npm package and its `0.0.0` version are repository test tooling, not plugin identity. -The marketplace uses the officially documented -[`git-subdir` `url`, `path`, `ref`, and `sha` source shape](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) -and pins a full commit SHA. One marketplace SemVer maps permanently to one source SHA: a failed or revised candidate -must receive a new prerelease version and immutable candidate tag, never reuse an earlier version with new bytes. -The compatibility check searches the committed compatibility documents reachable through every local Git ref and -rejects a reused version whose source identity differs. Release checkouts must therefore fetch candidate tags as -well as full branch history. Later `main` changes remain unpromoted until an explicit catalog candidate advances -both the source SHA and SemVer. -The distributable -marketplace channel is the dedicated `stable` ref; `main` holds candidates, and moving `stable` is a separate -approval-gated release mutation. Before that move, qualification uses a retained immutable candidate tag at the -exact catalog commit so the Git-hosted catalog and its pinned source are both exercised. No `stable` ref is created -by this candidate; initial channel creation remains part of a future approved release. -The marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition. The -[official marketplace strict-mode documentation](https://code.claude.com/docs/en/plugin-marketplaces#strict-mode) -says this mode permits a raw source directory without its own `plugin.json`; a source manifest that also declares -components would conflict with the marketplace definition. It does not relax validation. Separately, the -[official plugin validation reference](https://code.claude.com/docs/en/plugins-reference#unrecognized-fields) says -`claude plugin validate --strict` treats validation warnings, including unrecognized fields, as errors for CI. Both -strict validation commands above remain manual release gates, but a successful local invocation is not durable -qualification of a Git-hosted catalog or installation. -The `git-subdir` field set and the `owner/repo@ref` add form used below were rechecked on 2026-08-05 against those -two official marketplace sections. Recheck both claims when renewing the supported Claude Code floor or preparing -a release; this dated documentation check is not installation evidence. -The [official marketplace documentation](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels) -says an explicitly versioned plugin remains cached until that version changes. Every revised candidate therefore -requires a deliberate, never-reused SemVer bump. Exact Git SHAs still identify coordinated release candidates; -the version only participates in compatibility eligibility and never authorizes release. This remains packaging -policy, not observed Git-hosted installation evidence. This explicitly versioned candidate has not completed a -fresh-model rehearsal or an isolated marketplace installation; the dated observations remain evidence only for -their pinned earlier revisions and do not qualify this candidate. - -The documented local-development path starts one Claude Code session from the checkout without registering a -marketplace or installing the plugin: +The official [marketplace documentation](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) +documents npm plugin sources and notes that installed plugins are copied into Claude Code's cache. The official +[plugin reference](https://code.claude.com/docs/en/plugins-reference#file-locations-reference) documents that +executables in a plugin-root `bin/` directory are added to the Bash tool's `PATH`. Its +[user-configuration section](https://code.claude.com/docs/en/plugins-reference#user-configuration) documents +sensitive `userConfig` values and their `CLAUDE_PLUGIN_OPTION_*` subprocess environment variables. These claims +were rechecked on 2026-08-05; public installation remains unobserved until publication. + +For checkout-local development, start one Claude Code session without registering a marketplace: ```sh claude --plugin-dir . ``` -The 2026-08-04 Home Inventory evaluation observed a headless Claude Code 2.1.221 session load the -`Skill(firstdraft-preview:create-full-stack-app)` identifier from candidate revision -`b5c3897b240bfa3a9117d1a564d8e6b7d783e993` through an explicit `--plugin-dir ` without marketplace -registration or installation. It did not exercise the interactive command above. The Movie Catalog process also -received the candidate through `--plugin-dir`, but its retained evidence does not record Skill discovery or -invocation. Neither run establishes a model-backed session using the separately marketplace-installed plugin. -The isolated 2026-08-04 installation established the Claude Code 2.1.221 runtime observation. As a separate renewal -step, the current official plugin reference was rechecked and the allowlist below was pinned to its documented -locations. - -Under that documented preview model, the whole checkout is the preview plugin root. Repository checks therefore -forbid every other default component location documented for Claude Code 2.1.221: root `SKILL.md`, `agents/`, `bin/`, -`commands/`, `hooks/`, `monitors/`, `output-styles/`, `themes/`, `workflows/`, `settings.json`, `.mcp.json`, and -`.lsp.json`. They also require exactly one canonical subtree beneath `skills/`. Checks examine Git-index entries at -those enumerated checkout-root component locations, verify those locations on disk, and recursively inventory the -complete on-disk `skills/` subtree, including untracked entries there. That coverage prevents the preview from -silently growing a component outside the narrow marketplace source without claiming a scan of every working-tree -path. - -The [official plugin documentation](https://code.claude.com/docs/en/plugins) says `--plugin-dir` loads a plugin for -local development and plugin Skills use the `plugin-name:skill-name` namespace. The Home run observed the headless -tool identifier above. Interactive `/firstdraft-preview:create-full-stack-app` invocation and invocation through an -installed `firstdraft@firstdraft-skills` marketplace plugin remain unobserved. - -The dated -[Claude Code plugin install smoke evidence](evidence/2026-08-04-claude-code-plugin-install-smoke.md) and its -[generated machine-readable observation](evidence/claude-code-plugin-install-observation.json) remain bound to the -eight canonical Skill files at historical revision `b5c3897b240bfa3a9117d1a564d8e6b7d783e993`. Repository tests reconstruct -those Git objects rather than requiring today's unpromoted source to match them. The report retains the observed -Claude Code version, file digests, component inventory, cleanup, and scoped real-state result at that historical -boundary; it does not qualify this remote-pinned catalog. - -The old local-directory harness cannot exercise the current remote-pinned `git-subdir` source: its isolated child -PATH intentionally provided no Git, and it compared the cache with the checkout's current subtree. Both -`npm run check:claude-plugin-install` and `npm run record:claude-plugin-install` now fail before resolving Claude, -inspecting real state, or creating temporary state with a precise unsupported-source diagnostic. Do not use either -command to renew or qualify the current candidate. - -Git-hosted qualification requires a reviewed successor harness. It must add the exact catalog through the officially -documented [`owner/repo@ref` form](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) using -the immutable candidate tag, install the catalog's pinned remote source, confirm the cache directory is exactly the -candidate SemVer, and byte- and mode-compare the cache with that pinned Git tree. It must retain the catalog SHA, -source SHA and path, version, command transcript, and scoped host-state and credential-leak observations. Supplying -Git to the isolated child requires a narrowly scrubbed executable path and a fresh security review; do not inherit -the operator's general PATH, tokens, SSH agent, or unrelated environment. Close other Claude Code sessions before -that run so legitimate concurrent registry changes do not invalidate the isolation result. - -Ordinary `sh script/check` and hosted CI retain structural, historical-evidence, compatibility, and syntax -assertions. They neither install the plugin nor claim remote reachability or qualification. - -Once this packaging is merged and the release gates below are satisfied, the intended ordinary installation from -GitHub is: +Once both npm packages and the catalog are explicitly released, the intended colleague installation is: ```sh -CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 claude plugin marketplace add firstdraft/skills@stable +claude plugin marketplace add firstdraft/skills claude plugin install firstdraft@firstdraft-skills ``` -The [official environment-variable reference](https://code.claude.com/docs/en/env-vars) documents -`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner/repo` shorthand over HTTPS rather than SSH. The -assignment above makes the intended path independent of SSH setup; users with working GitHub SSH configuration may -omit it. The official -[`plugin marketplace add` reference](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) -documents `owner/repo@ref`. The `@stable` ref is the intended distributable channel; ordinary installation must not -track mutable `main`. No live GitHub clone from either ref has been observed for this package. - -Do not run those installation commands for ordinary use yet. The required -`@firstdraft.com/cli@0.1.0-alpha.2` package remains unpublished, and no compatible First Draft staging or live -endpoint has completed the agent-to-private-GitHub journey. The plugin metadata is source packaging, not release or -execution evidence. Keep using `gh skill preview` when evaluating the portable Skill path. +Those public commands are not yet expected to work: neither npm package has been published and this catalog change +has not been released. Local packed-install and isolated-Claude checks establish only prepublication behavior. The +historical 2026-08-04 source-only install report remains evidence for its recorded revision, not this composite +package. A dated [vendored-CLI smoke](evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md) records the current +local npm-source install, the rejected transitive-dependency design, and successful bare-command discovery. See +[`RELEASING.md`](RELEASING.md) for the approval-gated qualification and publication sequence. ## Development Cross-repository compatibility metadata and the approval-gated release process are documented in [`RELEASING.md`](RELEASING.md). -The installed Skills contain no executable code or runtime packages. Repository checks use Node.js 22 or newer -and one locked development dependency for exact JSON Schema validation: +The portable Skill directories contain no executable code or runtime packages. The assembled Claude plugin adds +only its CLI adapter and exact vendored CLI package. Repository checks use Node.js 22 or newer and one locked development +dependency for exact JSON Schema validation: Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available. A source archive, exported tree, or installed plugin cache is insufficient because the preview-boundary checks use the Git @@ -250,13 +174,13 @@ sh script/check ``` The CLI contract check requires a checkout at the exact reviewed revision -`f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, whose independently reproduced JavaScript-source runtime digest is -`9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`: +`1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, whose independently reproduced JavaScript-source runtime digest is +`0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`: ```sh git -C fetch origin main -git -C merge-base --is-ancestor f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 origin/main -git -C checkout --detach f55edffc9e88924f9a4c95f41c4d0bc9b72422f8 +git -C merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 origin/main +git -C checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 node script/check-cli-contract.mjs ``` diff --git a/RELEASING.md b/RELEASING.md index d088c84..d687447 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,134 +3,83 @@ This repository participates in a coordinated release with [`firstdraft/firstdraft`](https://github.com/firstdraft/firstdraft) and [`firstdraft/cli`](https://github.com/firstdraft/cli). A merge to `main` integrates source; it does not authorize a -plugin release, npm publication, or a First Draft deployment. - -## Release identity and compatibility - -The installable `firstdraft@firstdraft-skills` marketplace entry in -[`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json) owns the Skills/plugin SemVer. The separate -`firstdraft-preview` manifest at [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json) exists for checkout-local -preview and is not the installable plugin's version authority. The root `@firstdraft/skills` package is private test -tooling; its `0.0.0` package version is also not a release identity. - -The current installable marketplace version is `0.1.0-alpha.1`. The preview manifest remains independently -`0.1.0`; matching or ordering those unrelated numbers is not a release rule. - -[`release/compatibility.json`](release/compatibility.json) mirrors the marketplace plugin version and declares the -immutable installed-byte source SHA and subdirectory, service API contract, CLI version, and exact Foundation Plan -format required by this source. Repository checks fail if that metadata drifts from the marketplace entry or its -closed shape. The current metadata can reject an incompatible three-repository candidate, but a compatible result -never authorizes promotion. - -Claude Code's official -[`git-subdir` source documentation](https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources) defines its -`url`, `path`, optional `ref`, and full `sha` fields. This marketplace source is pinned to full commit -`8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e` and path `skills/create-full-stack-app`. Content can therefore merge to -`main` without changing the bytes selected by that catalog entry. Do not infer a release from a version number: the -exact catalog checkout SHA, installed-byte source SHA and path, and service and CLI SHAs identify the candidate that -was reviewed and qualified. See Claude Code's official -[version-resolution documentation](https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels). -One marketplace SemVer maps forever to exactly one source SHA. Every failed or revised candidate gets a new -prerelease version, catalog commit, and immutable candidate tag; never reuse a version for different bytes. The -local compatibility gate searches the committed compatibility documents reachable through every local Git ref and -rejects a reused version with a different source identity. Fetch all immutable candidate tags before relying on -that result; protected remote refs make this history an enforceable release record rather than mutable prose. -README records the dated official-documentation recheck for the source and marketplace-add shapes; renew that -check before release rather than treating the citation alone as runtime evidence. - -`main` holds marketplace candidates. The dedicated `stable` ref is the distributable channel, and ordinary -installation must use `claude plugin marketplace add firstdraft/skills@stable`. A catalog commit on `main` is not a -release. Merging catalog commit B integrates that candidate on `main`; a separately approved fast-forward of -`stable` to exact B is the release mutation. If `stable` does not yet exist, creating it at exact B is the initial -release mutation and requires the same approval and reconciliation boundary. - -Plugin promotion separates content and catalog commits: - -1. Merge content normally without changing the marketplace SemVer or pinned source. Report the merged content SHA - as unpromoted and ask whether to prepare and qualify a coordinated candidate. -2. With explicit approval, prepare a separate catalog commit that bumps the marketplace version according to SemVer - and points its `sha` at the already-existing content commit. Update `release/compatibility.json` in that same - commit. After separate candidate-preparation approval, create one immutable tag such as - `marketplace-v0.1.0-alpha.1-candidate.1` at that exact catalog commit. Never move or delete a candidate tag. If tag - creation has an ambiguous outcome, inspect the remote ref read-only and do not retry until its identity is known. - -## Remote ref prerequisites - -Before creating the first candidate tag or `stable`, a human administrator must verify GitHub rulesets that: - -- block force-push and deletion of `stable`; -- restrict creation and updates of `stable` to the designated release operator; -- block update and deletion for `marketplace-v*-candidate.*` tags; and -- leave ordinary `main` integration unable to advance the distributable ref. - -Protection changes are separate external mutations and are not performed by repository checks. If the required -rulesets are absent, stop before tag or branch creation and request approval for that administration work. - -Release corrections are forward-only. Publish a new SemVer, source pin, catalog commit, and candidate tag, then -fast-forward `stable` after qualification. Never rewrite a released version's source, move a candidate tag, force -`stable`, or rewind it to an earlier catalog. - -## Candidate and promotion flow - -After merging a change to `main`: - -1. Report this repository's exact merged SHA and ask the user whether to coordinate and promote a three-repository - candidate. If the user declines, record the SHA as unpromoted and stop. -2. With explicit approval, resolve clean checkouts at exact SHAs for `firstdraft` and `cli`, the exact Skills catalog - commit, and its immutable plugin source SHA. Run each repository's checks and the First Draft cross-repository - compatibility gate. The Skills checkout must contain full, unshallowed history including the historical evidence - and pinned source commits, plus every immutable candidate tag; fetch tags, use - `git rev-parse --is-shallow-repository`, and fetch any missing history before the check. The local gate verifies - that the source SHA is an ancestor of catalog HEAD and that no compatibility document reachable from a local ref - already maps this version to another source. A SemVer-compatible result makes the candidate eligible for - qualification only. -3. One operator manually deploys the exact service candidate to staging and uses the exact CLI and Skills candidates - for the approved staging qualification and replay. Add the marketplace as - `firstdraft/skills@` so the externally fetched catalog is exact. The plugin must then be - installed from that catalog's pinned source SHA, not from the catalog checkout's current `skills/` subtree. Do - not substitute newer `main` revisions during the journey. Claude Code's official - [`plugin marketplace add` reference](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) - documents this `owner/repo@ref` form. -4. During live plugin qualification, verify that the cache directory uses the expected SemVer and byte-compare every - installed file and mode with the pinned source tree. The pinned subtree must remain manifestless so no - `plugin.json` can override the marketplace version. The existing isolation harness was designed for a local - marketplace; review it before this run because the pinned Git source may require a narrowly scrubbed Git path. - Do not weaken its credential or host-state boundaries, and do not present local checks as this live observation. -5. Verify remote reachability read-only before release: fetch the candidate tag and `origin/main`, confirm the tag - resolves to the qualified catalog commit, and confirm the source SHA is an ancestor of the fetched remote main. - A local object alone is insufficient evidence that Claude Code can fetch it. -6. Preserve the candidate tag, qualified catalog SHA, plugin source SHA/version, other component SHAs, and evidence. - A human decides whether to promote the same candidate to production and release its CLI and plugin components. -7. With separate explicit approval, one operator creates `stable` at the exact qualified catalog commit for the - initial release, or fast-forwards the existing ref for a later release. If the ref update outcome is ambiguous, - reconcile the remote ref read-only before any retry; never retry or advance another ref until the outcome is - known. Each external mutation remains serialized through that operator. - -The CLI package and this plugin remain unreleased, and the repository does not currently claim a completed staging -or production journey. The dated fresh-model and isolated-install observations predate the explicit marketplace -version and remain bound to their recorded Git revisions; they do not qualify this candidate. Do not run -`npm publish`; this repository's npm package is private test tooling. +plugin release, npm publication, or First Draft deployment. + +## Release identity + +The installable `firstdraft@firstdraft-skills` plugin is the public npm package +`@firstdraft.com/claude-code`. Its current candidate version is `0.1.0-alpha.2`. The marketplace catalog points to +that exact package and version. The checkout-local `firstdraft-preview` manifest and private root +`@firstdraft/skills@0.0.0` package are test tooling, not release identities. + +Packing deterministically assembles the plugin from the canonical `skills/create-full-stack-app` directory, the +installable manifest and CLI adapter under `packages/claude-plugin`, the exact packed files from +`@firstdraft.com/cli@0.1.0-alpha.2`, and the repository license. Colleagues therefore install the Skill and compatible +CLI together through Claude Code rather than managing a separate global CLI or relying on transitive installation. + +[`release/compatibility.json`](release/compatibility.json) records the plugin package name, exact packed tarball +SHA-256, compatible service API range, exact CLI version, and Foundation Plan format. One marketplace SemVer maps +forever to exactly one package tarball. Never reuse a published npm version or catalog version for different bytes. +A compatible result establishes candidate eligibility only; it never authorizes deployment or publication. + +## Candidate flow + +1. Resolve clean, full-history checkouts at exact SHAs for all three repositories. Run each repository's checks and + the cross-repository compatibility gates. +2. Pack the exact CLI and Claude plugin candidates. Verify the plugin tarball SHA-256 against + `release/compatibility.json`, install both tarballs into an isolated temporary npm project, and confirm the + plugin-local `firstdraft` adapter runs the exact CLI version. +3. Validate the staged plugin with the real Claude Code CLI. Exercise a local marketplace in isolated Claude state + and confirm Skill discovery, sensitive configuration handling, and CLI invocation. This is prepublication + evidence, not proof of a public install. +4. One operator deploys the exact service candidate to staging and runs the approved Movie Catalog qualification + and singleton replay with the exact CLI and Skill candidates. Preserve the three repository SHAs, package + hashes, service revision, retained identifiers, and evidence. +5. A human decides whether the same candidate should be promoted. Publishing and catalog promotion require new, + explicit authorization. + +## Publication order + +After approval, one operator performs these mutations serially: + +Before pushing a release tag, verify that a GitHub ruleset protects `claude-v*` tags from deletion and unauthorized +updates, the `npm` environment requires the intended human reviewer, and its `NPM_RELEASE_ENABLED` variable is +deliberately set to `true`. The workflow fails closed unless the tag is protected and its commit is on `main`. + +1. Publish the exact compatible CLI package and reconcile its registry identity read-only. +2. Publish the already-qualified `@firstdraft.com/claude-code` tarball with npm provenance under the prerelease + dist-tag, then verify the registry returns the expected version and integrity. +3. Merge or fast-forward the marketplace catalog on `main` so + `claude plugin marketplace add firstdraft/skills` resolves to the published package version. +4. In fresh isolated Claude state, run the exact public installation: + + ```sh + claude plugin marketplace add firstdraft/skills + claude plugin install firstdraft@firstdraft-skills + ``` + +5. Start a fresh model session, confirm the Skill is discoverable, complete staging token onboarding without + exposing the token in chat or logs, and repeat the bounded qualification if required by the release decision. + +If any external mutation has an ambiguous result, stop and inspect the registry, Git ref, or deployment read-only. +Do not retry until its identity is known. Release corrections are forward-only and use a new SemVer. + +The plugin package is published by pushing protected tag `claude-v$package_version`. That tag triggers +`.github/workflows/publish.yml`, which rechecks the source commit, verifies the exact CLI release already exists in +npm, vendors that exact CLI checkout, reproduces the recorded plugin tarball digest, and publishes those bytes. Pushing the tag is therefore the +publication mutation and requires the explicit approval above. ## Checks -Run the compatibility check by itself with: - -```sh -npm run check:release-compatibility -``` - -Run the normal repository checks from a full-history checkout. Strict validation through the real Claude Code CLI is -a separate manual candidate gate; a passing invocation is useful preflight evidence, not durable Git-hosted -installation qualification: +From a full-history checkout, run: ```sh npm ci --ignore-scripts npm run check -claude plugin validate --strict . -claude plugin validate --strict .claude-plugin/plugin.json +node script/check-claude-plugin-package.mjs --cli-root /path/to/exact/cli ``` -The old local-directory install smoke described in [`README.md`](README.md) intentionally rejects the remote-pinned -source before resolving Claude or inspecting host state. Git-hosted qualification must use the reviewed successor -harness, immutable candidate tag, and pinned remote source. Neither strict validation nor any local check authorizes -release. +Stage the package and validate it with the current supported Claude Code CLI. Use isolated Claude configuration for +install tests; do not alter a colleague's real Claude state during qualification. A local validation or packed +install does not prove the two public commands until both npm packages and the GitHub marketplace catalog are +reachable externally. diff --git a/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md b/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md new file mode 100644 index 0000000..96968ca --- /dev/null +++ b/evidence/2026-08-05-claude-plugin-vendored-cli-smoke.md @@ -0,0 +1,23 @@ +# Claude plugin vendored-CLI smoke — 2026-08-05 + +## Outcome + +Claude Code 2.1.222 successfully installed the `firstdraft` plugin through an npm marketplace source backed by an +isolated loopback Verdaccio registry. The installed plugin's root `bin/firstdraft` executable was present and ran the +vendored CLI as a bare Bash command inside a fresh Claude session, printing exactly `0.1.0-alpha.2` on stdout. + +## Design finding + +An earlier package candidate declared `@firstdraft.com/cli@0.1.0-alpha.2` as an npm dependency. Claude Code installed +that npm-sourced plugin but did not materialize its dependency in the plugin cache; the adapter failed with +`ERR_MODULE_NOT_FOUND`. The release candidate therefore vendors the exact packed CLI files beneath `vendor/cli` and +does not depend on transitive npm installation. + +## Boundaries + +- The marketplace, registry, packages, Claude configuration, and plugin cache were isolated local test resources. +- The test used no First Draft API token and made no request to staging, GitHub, or the public npm registry. +- Direct cache execution and one fresh Claude model session both observed CLI version `0.1.0-alpha.2`. +- The test proves local npm-source installation, CLI materialization, and Bash PATH discovery. It does not prove the + public GitHub marketplace, public npm publication, colleague authentication, or the Movie Catalog journey. +- Local test resources were retained; cleanup was not performed. diff --git a/package-lock.json b/package-lock.json index 9bb23aa..88e2c3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,9 +40,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 8adb7b8..960d9cf 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "devDependencies": { "ajv": "8.20.0" }, + "overrides": { + "fast-uri": "3.1.5" + }, "scripts": { "check": "sh script/check", - "check:release-compatibility": "node script/check-release-compatibility.mjs", - "check:claude-plugin-install": "node script/check-claude-plugin-install.mjs", - "record:claude-plugin-install": "node script/check-claude-plugin-install.mjs --observation-output evidence/claude-code-plugin-install-observation.json" + "check:claude-plugin-package": "node script/check-claude-plugin-package.mjs", + "check:release-compatibility": "node script/check-release-compatibility.mjs" } } diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..b4d4a60 --- /dev/null +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "firstdraft", + "displayName": "First Draft", + "version": "0.1.0-alpha.2", + "description": "Author a Foundation Plan with your user and compile a bounded Rails and iPhone application", + "author": { + "name": "First Draft", + "url": "https://github.com/firstdraft" + }, + "homepage": "https://github.com/firstdraft/skills", + "repository": "https://github.com/firstdraft/skills", + "license": "MIT", + "keywords": [ + "foundation-plan", + "rails", + "application-generation" + ], + "skills": [ + "./skills/create-full-stack-app" + ], + "userConfig": { + "api_url": { + "type": "string", + "title": "First Draft API URL", + "description": "The First Draft service used for Plan analysis and Compilation", + "required": true, + "default": "https://staging.firstdraft.com" + }, + "api_token": { + "type": "string", + "title": "First Draft API token", + "description": "A token created in First Draft after signing in", + "sensitive": true, + "required": true + } + } +} diff --git a/packages/claude-plugin/bin/firstdraft b/packages/claude-plugin/bin/firstdraft new file mode 100755 index 0000000..26fe579 --- /dev/null +++ b/packages/claude-plugin/bin/firstdraft @@ -0,0 +1,12 @@ +#!/bin/sh + +target=$0 +while [ -L "$target" ]; do + link=$(readlink "$target") + case "$link" in + /*) target=$link ;; + *) target=$(dirname "$target")/$link ;; + esac +done + +exec node "$(dirname "$target")/firstdraft.js" "$@" diff --git a/packages/claude-plugin/bin/firstdraft.js b/packages/claude-plugin/bin/firstdraft.js new file mode 100644 index 0000000..319a20d --- /dev/null +++ b/packages/claude-plugin/bin/firstdraft.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; + +const apiUrlOption = "CLAUDE_PLUGIN_OPTION_api_url"; +const apiTokenOption = "CLAUDE_PLUGIN_OPTION_api_token"; +const cli = fileURLToPath( + new URL("../vendor/cli/bin/firstdraft.js", import.meta.url), +); +const environment = {...process.env}; +const apiUrlConfigured = Boolean(environment[apiUrlOption]); +const apiTokenConfigured = Boolean(environment[apiTokenOption]); + +if (!apiUrlConfigured && apiTokenConfigured) { + process.stderr.write( + `${JSON.stringify({ + error: "plugin_configuration_incomplete", + detail: "Configure the First Draft API URL in Claude Code.", + })}\n`, + ); + process.exit(2); +} + +if (apiUrlConfigured) { + environment.FIRSTDRAFT_API_URL = environment[apiUrlOption]; + delete environment.FIRSTDRAFT_API_TOKEN; +} +if (apiTokenConfigured) { + environment.FIRSTDRAFT_API_TOKEN = environment[apiTokenOption]; +} +delete environment[apiUrlOption]; +delete environment[apiTokenOption]; + +const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], { + env: environment, + stdio: "inherit", +}); +const [status, signal] = await once(child, "exit"); + +if (signal) { + process.kill(process.pid, signal); +} else { + process.exitCode = status ?? 1; +} diff --git a/packages/claude-plugin/package.template.json b/packages/claude-plugin/package.template.json new file mode 100644 index 0000000..dd9ba3d --- /dev/null +++ b/packages/claude-plugin/package.template.json @@ -0,0 +1,27 @@ +{ + "name": "@firstdraft.com/claude-code", + "version": "0.1.0-alpha.2", + "description": "First Draft Foundation Plan authoring for Claude Code", + "license": "MIT", + "type": "module", + "bin": { + "firstdraft": "bin/firstdraft" + }, + "engines": { + "node": ">=22.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/firstdraft/skills.git" + }, + "homepage": "https://github.com/firstdraft/skills#readme", + "bugs": { + "url": "https://github.com/firstdraft/skills/issues" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/", + "tag": "next" + } +} diff --git a/release/compatibility.json b/release/compatibility.json index c08aba8..3f0db35 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,10 +1,10 @@ { "format": "firstdraft.release-compatibility/1", "component": "skills", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "plugin_source": { - "git_sha": "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e", - "path": "skills/create-full-stack-app" + "package": "@firstdraft.com/claude-code", + "tarball_sha256": "2dccdc0a635557aad8cd9c520685f25eaa8caaa09750f533d797103636ad9261" }, "requires": { "api_contract": [ diff --git a/script/check b/script/check index 82200ff..453f952 100755 --- a/script/check +++ b/script/check @@ -5,4 +5,5 @@ repository=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repository" node script/check-release-compatibility.mjs +node script/check-claude-plugin-package.mjs node --test 'test/**/*.test.mjs' diff --git a/script/check-claude-plugin-install.mjs b/script/check-claude-plugin-install.mjs index 988261f..db86ab9 100644 --- a/script/check-claude-plugin-install.mjs +++ b/script/check-claude-plugin-install.mjs @@ -531,9 +531,8 @@ function assertMatchesCommittedObservation(current) { reviewedPackagingObservation(current), reviewedPackagingObservation(committed), "live Claude Code plugin observation differs from committed evidence; " + - "review current discovery and isolation behavior, run " + - "`npm run record:claude-plugin-install`, rename and refresh the dated " + - "evidence, then update the reviewed test pins", + "the historical recording path is retired, so use the vendored-package " + + "qualification in RELEASING.md for new evidence", ); } @@ -570,8 +569,8 @@ function assertLocalInstallSmokeSupported(marketplace) { assert.equal( typeof marketplacePlugin.source, "string", - "the local Claude Code install smoke does not support the remote-pinned " + - "git-subdir plugin source; qualify the immutable candidate tag and its " + - "pinned remote source with the reviewed release harness", + "the historical local Claude Code install smoke does not support the npm " + + "plugin source; use the staged-package preflight and then qualify the " + + "published package through the public marketplace", ); } diff --git a/script/check-claude-plugin-package.mjs b/script/check-claude-plugin-package.mjs new file mode 100644 index 0000000..1d1c7f5 --- /dev/null +++ b/script/check-claude-plugin-package.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { canonicalClaudePluginSkillFiles } from "./claude-plugin-boundaries.mjs"; +import { packClaudePlugin } from "./claude-plugin-package.mjs"; +import { cliPackageVersion } from "./cli-contract/config.mjs"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const packageTemplate = readJson( + path.join(repository, "packages", "claude-plugin", "package.template.json"), +); +const pluginManifest = readJson( + path.join( + repository, + "packages", + "claude-plugin", + ".claude-plugin", + "plugin.json", + ), +); +const compatibility = readJson( + path.join(repository, "release", "compatibility.json"), +); +const cliRootIndex = process.argv.indexOf("--cli-root"); +const cliRoot = + cliRootIndex === -1 + ? undefined + : path.resolve(process.argv[cliRootIndex + 1] || ""); + +assert.equal(packageTemplate.name, "@firstdraft.com/claude-code"); +assert.equal(packageTemplate.version, compatibility.version); +assert.equal(pluginManifest.name, "firstdraft"); +assert.equal(pluginManifest.version, compatibility.version); +assert.equal(packageTemplate.dependencies, undefined); +assert.equal(pluginManifest.userConfig.api_url.sensitive, undefined); +assert.equal( + pluginManifest.userConfig.api_url.default, + "https://staging.firstdraft.com", +); +assert.equal(pluginManifest.userConfig.api_token.sensitive, true); + +const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), "firstdraft-claude-plugin-check-"), +); +const cleanEnvironment = {...process.env}; +for (const name of [ + "FIRSTDRAFT_API_URL", + "FIRSTDRAFT_API_TOKEN", + "CLAUDE_PLUGIN_OPTION_api_url", + "CLAUDE_PLUGIN_OPTION_api_token", +]) { + delete cleanEnvironment[name]; +} + +try { + const fakeCliRoot = createFakeCli(temporaryDirectory); + const packageCliRoot = cliRoot || fakeCliRoot; + const first = await packClaudePlugin( + path.join(temporaryDirectory, "first"), + packageCliRoot, + ); + const second = await packClaudePlugin( + path.join(temporaryDirectory, "second"), + packageCliRoot, + ); + assert.equal(first.sha256, second.sha256, "plugin tarballs must be deterministic"); + if (cliRoot) { + assert.equal( + first.sha256, + compatibility.plugin_source.tarball_sha256, + "plugin tarball must match its release compatibility digest", + ); + } + + const expectedFiles = [ + ".claude-plugin/plugin.json", + "LICENSE", + "bin/firstdraft", + "bin/firstdraft.js", + "package.json", + ...canonicalClaudePluginSkillFiles.map( + (file) => `skills/create-full-stack-app/${file}`, + ), + ].sort(); + const packagedFiles = first.manifest.files.map(({path: file}) => file).sort(); + assert.deepEqual( + packagedFiles.filter((file) => !file.startsWith("vendor/cli/")), + expectedFiles, + ); + assert(packagedFiles.includes("vendor/cli/package.json")); + assert(packagedFiles.includes("vendor/cli/bin/firstdraft.js")); + for (const file of first.manifest.files) { + const executable = + file.path === "bin/firstdraft" || + file.path === "vendor/cli/bin/firstdraft.js"; + assert.equal( + file.mode, + executable ? 0o755 : 0o644, + `${file.path} has an unexpected package mode`, + ); + } + + const fakePlugin = await packClaudePlugin( + path.join(temporaryDirectory, "fake-plugin"), + fakeCliRoot, + ); + const fakeInstallation = installPackages({ + directory: path.join(temporaryDirectory, "fake-installation"), + packages: [fakePlugin.tarball], + }); + const canaryToken = `fd_${"a".repeat(43)}`; + const execution = run( + pluginExecutable(fakeInstallation), + ["probe"], + fakeInstallation, + { + ...process.env, + CLAUDE_PLUGIN_OPTION_api_url: "https://staging.firstdraft.com", + CLAUDE_PLUGIN_OPTION_api_token: canaryToken, + FIRSTDRAFT_API_URL: "https://wrong.example.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + ); + assert.deepEqual(JSON.parse(execution.stdout), { + apiToken: canaryToken, + apiUrl: "https://staging.firstdraft.com", + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + assert.equal(execution.stderr, ""); + + for (const pluginEnvironment of [ + {CLAUDE_PLUGIN_OPTION_api_token: canaryToken}, + ]) { + const incomplete = spawnSync( + pluginExecutable(fakeInstallation), + ["probe"], + { + cwd: fakeInstallation, + encoding: "utf8", + env: { + ...process.env, + ...pluginEnvironment, + FIRSTDRAFT_API_URL: "https://ambient.example.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + }, + ); + assert.equal(incomplete.status, 2); + assert.equal(incomplete.stdout, ""); + assert.equal( + incomplete.stderr, + '{"error":"plugin_configuration_incomplete","detail":"Configure the First Draft API URL in Claude Code."}\n', + ); + assert.doesNotMatch(incomplete.stderr, /fd_[A-Za-z0-9_-]+/); + } + + const urlOnly = run( + pluginExecutable(fakeInstallation), + ["probe"], + fakeInstallation, + { + ...process.env, + CLAUDE_PLUGIN_OPTION_api_url: "https://staging.firstdraft.com", + FIRSTDRAFT_API_TOKEN: `fd_${"b".repeat(43)}`, + }, + ); + assert.deepEqual(JSON.parse(urlOnly.stdout), { + apiUrl: "https://staging.firstdraft.com", + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + + const linkedExecution = run( + path.join(fakeInstallation, "node_modules", ".bin", "firstdraft"), + ["probe"], + fakeInstallation, + cleanEnvironment, + ); + assert.deepEqual(JSON.parse(linkedExecution.stdout), { + arguments: ["probe"], + pluginApiTokenPresent: false, + pluginApiUrlPresent: false, + }); + + if (cliRoot) { + const cliPackage = readJson(path.join(cliRoot, "package.json")); + assert.equal(cliPackage.name, "@firstdraft.com/cli"); + assert.equal(cliPackage.version, cliPackageVersion); + const actualInstallation = installPackages({ + directory: path.join(temporaryDirectory, "actual-installation"), + packages: [first.tarball], + }); + const version = run( + pluginExecutable(actualInstallation), + ["--version"], + actualInstallation, + ); + assert.equal(version.stdout, `${cliPackageVersion}\n`); + assert.equal(version.stderr, ""); + } +} finally { + rmSync(temporaryDirectory, {recursive: true, force: true}); +} + +process.stdout.write("Claude plugin package is deterministic and valid.\n"); + +function createFakeCli(directory) { + const source = path.join(directory, "fake-cli"); + mkdirSync(path.join(source, "bin"), {recursive: true}); + writeFileSync( + path.join(source, "package.json"), + `${JSON.stringify({ + name: "@firstdraft.com/cli", + version: cliPackageVersion, + type: "module", + bin: {firstdraft: "bin/firstdraft.js"}, + })}\n`, + ); + const executable = path.join(source, "bin", "firstdraft.js"); + writeFileSync( + executable, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ + apiToken: process.env.FIRSTDRAFT_API_TOKEN, + apiUrl: process.env.FIRSTDRAFT_API_URL, + arguments: process.argv.slice(2), + pluginApiTokenPresent: Object.hasOwn(process.env, "CLAUDE_PLUGIN_OPTION_api_token"), + pluginApiUrlPresent: Object.hasOwn(process.env, "CLAUDE_PLUGIN_OPTION_api_url"), +}) + "\\n"); +`, + ); + chmodSync(executable, 0o755); + return source; +} + +function installPackages({directory, packages}) { + mkdirSync(directory, {recursive: true}); + writeFileSync( + path.join(directory, "package.json"), + '{"name":"firstdraft-claude-plugin-smoke","private":true}\n', + ); + run( + process.env.npm_execpath || "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--offline", + "--no-save", + ...packages, + ], + directory, + ); + return directory; +} + +function pluginExecutable(installation) { + return path.join( + installation, + "node_modules", + "@firstdraft.com", + "claude-code", + "bin", + "firstdraft", + ); +} + +function run(command, arguments_, directory, environment = process.env) { + const result = spawnSync(command, arguments_, { + cwd: directory, + encoding: "utf8", + env: environment, + }); + assert.equal( + result.status, + 0, + [result.error?.message, `command exited ${result.status}; output suppressed`] + .filter(Boolean) + .join("; "), + ); + return result; +} + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} diff --git a/script/check-release-compatibility.mjs b/script/check-release-compatibility.mjs index deba00f..8f6dc15 100644 --- a/script/check-release-compatibility.mjs +++ b/script/check-release-compatibility.mjs @@ -20,8 +20,10 @@ export function isSemanticVersion(value) { export function assertSkillsReleaseCompatibility({ compatibility, + installableManifest, marketplace, packageDocument, + packageTemplate, previewManifest, }) { assertExactKeys( @@ -37,17 +39,17 @@ export function assertSkillsReleaseCompatibility({ assertSemanticVersion(compatibility.version, "Skills compatibility version"); assertExactKeys( compatibility.plugin_source, - ["git_sha", "path"], + ["package", "tarball_sha256"], "plugin source identity", ); - assert.match( - compatibility.plugin_source.git_sha, - /^[0-9a-f]{40}$/, - "plugin source identity must use a full lowercase Git SHA", - ); assert.equal( - compatibility.plugin_source.path, - "skills/create-full-stack-app", + compatibility.plugin_source.package, + "@firstdraft.com/claude-code", + ); + assert.match( + compatibility.plugin_source.tarball_sha256, + /^[0-9a-f]{64}$/, + "plugin source identity must use a full lowercase SHA-256", ); assertExactKeys( @@ -92,25 +94,35 @@ export function assertSkillsReleaseCompatibility({ ); assertExactKeys( installablePlugin.source, - ["path", "sha", "source", "url"], + ["package", "registry", "source", "version"], "installable marketplace plugin source", ); - assert.equal(installablePlugin.source.source, "git-subdir"); + assert.equal(installablePlugin.source.source, "npm"); assert.equal( - installablePlugin.source.url, - "https://github.com/firstdraft/skills.git", + installablePlugin.source.registry, + "https://registry.npmjs.org/", ); assert.equal( - compatibility.plugin_source.git_sha, - installablePlugin.source.sha, - "compatibility source SHA must match the marketplace plugin source", + compatibility.plugin_source.package, + installablePlugin.source.package, + "compatibility package must match the marketplace plugin source", ); assert.equal( - compatibility.plugin_source.path, - installablePlugin.source.path, - "compatibility source path must match the marketplace plugin source", + compatibility.version, + installablePlugin.source.version, + "compatibility version must match the marketplace package source", ); + assert.equal(installableManifest.name, installablePlugin.name); + assert.equal(installableManifest.version, compatibility.version); + assert.deepEqual(installableManifest.skills, [ + "./skills/create-full-stack-app", + ]); + + assert.equal(packageTemplate.name, compatibility.plugin_source.package); + assert.equal(packageTemplate.version, compatibility.version); + assert.equal(packageTemplate.dependencies, undefined); + assert.equal(previewManifest.name, "firstdraft-preview"); assert.notEqual( previewManifest.name, @@ -129,24 +141,45 @@ export function assertSkillsReleaseCompatibility({ } export async function checkSkillsReleaseCompatibility(root = repository) { - const [compatibility, marketplace, packageDocument, previewManifest] = - await Promise.all([ - readJson(path.join(root, "release", "compatibility.json")), - readJson(path.join(root, ".claude-plugin", "marketplace.json")), - readJson(path.join(root, "package.json")), - readJson(path.join(root, ".claude-plugin", "plugin.json")), - ]); + const [ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + ] = await Promise.all([ + readJson(path.join(root, "release", "compatibility.json")), + readJson( + path.join( + root, + "packages", + "claude-plugin", + ".claude-plugin", + "plugin.json", + ), + ), + readJson(path.join(root, ".claude-plugin", "marketplace.json")), + readJson(path.join(root, "package.json")), + readJson( + path.join( + root, + "packages", + "claude-plugin", + "package.template.json", + ), + ), + readJson(path.join(root, ".claude-plugin", "plugin.json")), + ]); const checked = assertSkillsReleaseCompatibility({ compatibility, + installableManifest, marketplace, packageDocument, + packageTemplate, previewManifest, }); - assertPluginSourceAncestor({ - gitSha: checked.plugin_source.git_sha, - root, - }); assertVersionSourceHistory({ compatibility: checked, historicalCompatibilities: readHistoricalCompatibilities({ root }), @@ -218,31 +251,6 @@ export function readHistoricalCompatibilities({ }); } -export function assertPluginSourceAncestor({ - gitSha, - root, - spawn = spawnSync, -}) { - const result = spawn( - "git", - ["merge-base", "--is-ancestor", gitSha, "HEAD"], - { cwd: root, encoding: "utf8" }, - ); - if (result.status === 1) { - assert.fail( - `plugin source ${gitSha} is not an ancestor of catalog HEAD; ` + - "the pin or catalog checkout is wrong", - ); - } - assert.equal( - result.status, - 0, - "could not verify plugin source ancestry: " + - [result.error?.message, result.stderr].filter(Boolean).join("; ") + - "; fetch full unshallowed history containing the pinned commit", - ); -} - function assertGitSucceeded(result, message) { assert.equal( result.status, diff --git a/script/claude-plugin-boundaries.mjs b/script/claude-plugin-boundaries.mjs index 15df38e..cec0108 100644 --- a/script/claude-plugin-boundaries.mjs +++ b/script/claude-plugin-boundaries.mjs @@ -26,6 +26,10 @@ export const forbiddenCheckoutRootClaudePluginComponentPaths = Object.freeze([ "workflows", ]); +// The checkout preview deliberately exposes only the portable Skill. The assembled +// installable plugin separately admits bin/ as Claude Code's documented executable +// component so the exact CLI adapter is available to the Bash tool. + export const forbiddenClaudePluginPathSegments = Object.freeze([ "evals", "node_modules", diff --git a/script/claude-plugin-package.mjs b/script/claude-plugin-package.mjs new file mode 100644 index 0000000..74c9eab --- /dev/null +++ b/script/claude-plugin-package.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const packageSource = path.join(repository, "packages", "claude-plugin"); +const skillSource = path.join( + repository, + "skills", + "create-full-stack-app", +); + +export async function stageClaudePlugin(destination, cliRoot) { + assert(cliRoot, "the exact CLI checkout is required"); + const target = path.resolve(destination); + await mkdir(target, {recursive: true}); + await Promise.all([ + cp( + path.join(packageSource, ".claude-plugin"), + path.join(target, ".claude-plugin"), + {recursive: true}, + ), + cp(path.join(packageSource, "bin"), path.join(target, "bin"), { + recursive: true, + }), + cp(skillSource, path.join(target, "skills", "create-full-stack-app"), { + recursive: true, + }), + cp(path.join(repository, "LICENSE"), path.join(target, "LICENSE")), + cp( + path.join(packageSource, "package.template.json"), + path.join(target, "package.json"), + ), + ]); + await stageCli(path.resolve(cliRoot), path.join(target, "vendor", "cli")); + await chmod(path.join(target, "bin", "firstdraft.js"), 0o644); + await chmod(path.join(target, "bin", "firstdraft"), 0o755); + return target; +} + +export async function packClaudePlugin(destination, cliRoot) { + const staging = await mkdtemp(path.join(tmpdir(), "firstdraft-claude-plugin-")); + try { + await stageClaudePlugin(staging, cliRoot); + await mkdir(destination, {recursive: true}); + const result = spawnSync( + process.env.npm_execpath || "npm", + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + path.resolve(destination), + ], + {cwd: staging, encoding: "utf8"}, + ); + assert.equal( + result.status, + 0, + [result.error?.message, result.stderr].filter(Boolean).join("; "), + ); + const [manifest] = JSON.parse(result.stdout); + assert(manifest, "npm pack did not return a package manifest"); + const tarball = path.join(path.resolve(destination), manifest.filename); + return { + manifest, + sha256: createHash("sha256") + .update(await readFile(tarball)) + .digest("hex"), + tarball, + }; + } finally { + await rm(staging, {recursive: true, force: true}); + } +} + +async function main() { + const [command, argument, option, cliRoot] = process.argv.slice(2); + assert( + command === "stage" || command === "pack", + "usage: claude-plugin-package.mjs ", + ); + assert(argument, "a destination directory is required"); + assert.equal(option, "--cli-root", "--cli-root is required"); + assert(cliRoot, "--cli-root is required"); + + if (command === "stage") { + await stageClaudePlugin(argument, cliRoot); + process.stdout.write(`${path.resolve(argument)}\n`); + return; + } + + const packed = await packClaudePlugin(argument, cliRoot); + await writeFile( + `${packed.tarball}.sha256`, + `${packed.sha256} ${path.basename(packed.tarball)}\n`, + ); + process.stdout.write( + `${JSON.stringify({sha256: packed.sha256, tarball: packed.tarball})}\n`, + ); +} + +async function stageCli(cliRoot, destination) { + const packedDirectory = await mkdtemp( + path.join(tmpdir(), "firstdraft-cli-package-"), + ); + try { + const packed = spawnSync( + process.env.npm_execpath || "npm", + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + packedDirectory, + ], + {cwd: cliRoot, encoding: "utf8"}, + ); + assert.equal( + packed.status, + 0, + [packed.error?.message, packed.stderr].filter(Boolean).join("; "), + ); + const [manifest] = JSON.parse(packed.stdout); + assert(manifest, "npm pack did not return a CLI package manifest"); + await mkdir(destination, {recursive: true}); + const extracted = spawnSync( + "tar", + [ + "-xzf", + path.join(packedDirectory, manifest.filename), + "--strip-components=1", + "-C", + destination, + ], + {encoding: "utf8"}, + ); + assert.equal( + extracted.status, + 0, + [extracted.error?.message, extracted.stderr].filter(Boolean).join("; "), + ); + await normalizeCliModes(destination, destination); + } finally { + await rm(packedDirectory, {recursive: true, force: true}); + } +} + +async function normalizeCliModes(root, current) { + for (const entry of await readdir(current, {withFileTypes: true})) { + const item = path.join(current, entry.name); + assert.equal(entry.isSymbolicLink(), false, `unexpected CLI symlink: ${item}`); + if (entry.isDirectory()) { + await chmod(item, 0o755); + await normalizeCliModes(root, item); + continue; + } + assert(entry.isFile(), `unexpected CLI package entry: ${item}`); + const relative = path.relative(root, item); + await chmod(item, relative === path.join("bin", "firstdraft.js") ? 0o755 : 0o644); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +} diff --git a/script/cli-contract/config.mjs b/script/cli-contract/config.mjs index 007e0ea..b572e49 100644 --- a/script/cli-contract/config.mjs +++ b/script/cli-contract/config.mjs @@ -1,6 +1,6 @@ -export const cliRevision = "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; +export const cliRevision = "1c5f44bf5f905b45931ec2c280d71d69b0d0ac78"; export const cliRuntimeSha256 = - "9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab"; + "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; export const cliPackageName = "@firstdraft.com/cli"; export const cliPackageVersion = "0.1.0-alpha.2"; diff --git a/skills/create-full-stack-app/SKILL.md b/skills/create-full-stack-app/SKILL.md index e94a383..565ad08 100644 --- a/skills/create-full-stack-app/SKILL.md +++ b/skills/create-full-stack-app/SKILL.md @@ -59,13 +59,16 @@ Require these public commands: - `plan init`, `plan push`, `plan status`, and zero-flag `plan compile`; and - `compilation status` and `compilation download`. -There is no public `plan publish`, `plan subject-id`, or `plan compile --output` contract. Do not install, -download, or upgrade the unreleased CLI automatically. If a required command is missing, report that capability -gap rather than approximating it with direct HTTP. +There is no public `plan publish`, `plan subject-id`, or `plan compile --output` contract. A marketplace installation +of this Skill supplies its exact compatible CLI dependency. Do not install, download, or upgrade another CLI +automatically. If a required command is missing or reports a different version, report a plugin installation defect +and ask the user to reinstall or update the plugin rather than approximating it with direct HTTP. `.firstdraft/state.json` is private CLI-owned concurrency state. Do not print, paste, commit, or treat it as -agent-authored Plan content. Let the user configure `FIRSTDRAFT_API_TOKEN` outside the conversation; never ask them -to paste it or place it on a command line. +agent-authored Plan content. For an installed plugin, let the user configure the API token through the plugin's +sensitive configuration prompt; it is authoritative and an ambient `FIRSTDRAFT_API_TOKEN` is deliberately ignored +when plugin configuration supplies the API URL. `FIRSTDRAFT_API_TOKEN` remains available to a standalone CLI. Never +ask the user to paste a token or place it on a command line. ## Initialize or resume the local Plan diff --git a/skills/create-full-stack-app/references/diagnostics-and-recovery.md b/skills/create-full-stack-app/references/diagnostics-and-recovery.md index e143745..805b8c2 100644 --- a/skills/create-full-stack-app/references/diagnostics-and-recovery.md +++ b/skills/create-full-stack-app/references/diagnostics-and-recovery.md @@ -5,8 +5,8 @@ Handled leaf-command failures write exactly one JSON object to standard error. B structured fields rather than the human-readable `detail` or the broad process exit status. The reviewed CLI contract in this stack is revision -`f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, with JavaScript-source runtime digest -`9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`. Its source package identifies itself as +`1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, with JavaScript-source runtime digest +`0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`. Its source package identifies itself as `@firstdraft.com/cli@0.1.0-alpha.2`, but remains unpublished. Check the command surface rather than assuming the version alone establishes compatibility. @@ -123,6 +123,7 @@ tree. | Commands | `error` | Recovery meaning | | --- | --- | --- | | Any leaf command | `invalid_arguments` | Syntax failed before the requested action. Read that command's help. | +| Plugin adapter | `plugin_configuration_incomplete` | Configure the missing API URL through Claude Code's plugin configuration prompt. | | `plan init` | `local_initialization_failed` | Preserve possibly partial local state. | | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `authentication_required` | Configure the token outside the conversation. | | `plan push`, `plan status`, `plan compile`, `compilation status`, `compilation download` | `local_input_unreadable` | Preserve unreadable local files; do not reconstruct private state. | diff --git a/skills/create-full-stack-app/references/foundation-plan-019.md b/skills/create-full-stack-app/references/foundation-plan-019.md index 4893179..896954b 100644 --- a/skills/create-full-stack-app/references/foundation-plan-019.md +++ b/skills/create-full-stack-app/references/foundation-plan-019.md @@ -64,8 +64,8 @@ not release or execution evidence. The bounded local Compilation evidence used reviewed CLI revision `121272cd592055354d09a4fe90e55c3ca002770c`, with JavaScript-source runtime digest `205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d`. The current contract fixtures and check use -reviewed successor revision `f55edffc9e88924f9a4c95f41c4d0bc9b72422f8`, with independently reproduced -JavaScript-source runtime digest `9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab`, as +reviewed successor revision `1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, with independently reproduced +JavaScript-source runtime digest `0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`, as contract provenance rather than release or execution evidence. That successor exposes `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, zero-flag `plan compile`, `compilation status`, and `compilation download`. It deliberately has no public `plan subject-id`, `plan publish`, or local-start diff --git a/test/release-compatibility.test.mjs b/test/release-compatibility.test.mjs index 8881f49..45f2e52 100644 --- a/test/release-compatibility.test.mjs +++ b/test/release-compatibility.test.mjs @@ -5,7 +5,6 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { - assertPluginSourceAncestor, assertSkillsReleaseCompatibility, assertVersionSourceHistory, checkSkillsReleaseCompatibility, @@ -25,10 +24,11 @@ test("release compatibility matches the installable plugin manifest", async () = assert.deepEqual(compatibility, { format: "firstdraft.release-compatibility/1", component: "skills", - version: "0.1.0-alpha.1", + version: "0.1.0-alpha.2", plugin_source: { - git_sha: "8ffbd9688f39118ddeeb48a3da7e5bc309b7be5e", - path: "skills/create-full-stack-app", + package: "@firstdraft.com/claude-code", + tarball_sha256: + "2dccdc0a635557aad8cd9c520685f25eaa8caaa09750f533d797103636ad9261", }, requires: { api_contract: [">= 0.1.0", "< 0.2.0"], @@ -62,17 +62,17 @@ test("release compatibility rejects shape and manifest drift", async () => { ); const withSourceDrift = structuredClone(documents); - withSourceDrift.marketplace.plugins[0].source.sha = "0".repeat(40); + withSourceDrift.marketplace.plugins[0].source.package = "@other/plugin"; assert.throws( () => assertSkillsReleaseCompatibility(withSourceDrift), - /source SHA must match/, + /package must match/, ); - const withShortSourceIdentity = structuredClone(documents); - withShortSourceIdentity.compatibility.plugin_source.git_sha = "8ffbd96"; + const withShortDigest = structuredClone(documents); + withShortDigest.compatibility.plugin_source.tarball_sha256 = "abc123"; assert.throws( - () => assertSkillsReleaseCompatibility(withShortSourceIdentity), - /full lowercase Git SHA/, + () => assertSkillsReleaseCompatibility(withShortDigest), + /full lowercase SHA-256/, ); }); @@ -85,51 +85,13 @@ test("release compatibility uses strict semantic versions", () => { } }); -test("plugin source must be an ancestor of the catalog checkout", () => { - let invocation; - assert.doesNotThrow(() => - assertPluginSourceAncestor({ - gitSha: "1".repeat(40), - root: "/catalog", - spawn(...arguments_) { - invocation = arguments_; - return { status: 0, stderr: "" }; - }, - }), - ); - assert.deepEqual(invocation, [ - "git", - ["merge-base", "--is-ancestor", "1".repeat(40), "HEAD"], - { cwd: "/catalog", encoding: "utf8" }, - ]); - - assert.throws( - () => - assertPluginSourceAncestor({ - gitSha: "2".repeat(40), - root: "/catalog", - spawn: () => ({ status: 1, stderr: "" }), - }), - /is not an ancestor of catalog HEAD.*pin or catalog checkout is wrong/, - ); - assert.throws( - () => - assertPluginSourceAncestor({ - gitSha: "3".repeat(40), - root: "/catalog", - spawn: () => ({ status: 128, stderr: "missing commit" }), - }), - /could not verify plugin source ancestry: missing commit.*full unshallowed history/, - ); -}); - test("one Skills version maps permanently to one plugin source", () => { const current = { component: "skills", version: "0.1.0-alpha.1", plugin_source: { - git_sha: "1".repeat(40), - path: "skills/create-full-stack-app", + package: "@firstdraft.com/claude-code", + tarball_sha256: "1".repeat(64), }, }; assert.doesNotThrow(() => @@ -147,7 +109,7 @@ test("one Skills version maps permanently to one plugin source", () => { version: "0.1.0-alpha.2", plugin_source: { ...current.plugin_source, - git_sha: "4".repeat(40), + tarball_sha256: "4".repeat(64), }, }, }, @@ -166,7 +128,7 @@ test("one Skills version maps permanently to one plugin source", () => { ...structuredClone(current), plugin_source: { ...current.plugin_source, - git_sha: "6".repeat(40), + tarball_sha256: "6".repeat(64), }, }, }, @@ -192,8 +154,8 @@ test("release history is read from every committed ref", () => { component: "skills", version: "0.1.0-alpha.1", plugin_source: { - git_sha: "8".repeat(40), - path: "skills/create-full-stack-app", + package: "@firstdraft.com/claude-code", + tarball_sha256: "8".repeat(64), }, }), }; @@ -223,7 +185,10 @@ test("release history is read from every committed ref", () => { ]); assert.equal(historical.length, 1); assert.equal(historical[0].revision, "7".repeat(40)); - assert.equal(historical[0].compatibility.plugin_source.git_sha, "8".repeat(40)); + assert.equal( + historical[0].compatibility.plugin_source.tarball_sha256, + "8".repeat(64), + ); }); test("release operator and agent instructions track the candidate", async () => { @@ -235,50 +200,61 @@ test("release operator and agent instructions track the candidate", async () => const candidate = marketplace.plugins.find(({ name }) => name === "firstdraft"); assert(releasing.includes(`version is \`${candidate.version}\``)); - assert(releasing.includes(candidate.source.sha)); - assert( - releasing.includes( - `marketplace-v${candidate.version}-candidate.1`, - ), - ); + assert(releasing.includes(candidate.source.package)); + assert(releasing.includes("claude-v$package_version")); assert.match( releasing, - /One marketplace SemVer maps forever to exactly one source SHA/, + /One marketplace SemVer maps\s+forever to exactly one package tarball/, ); assert.match( releasing, - /block force-push and deletion of `stable`[\s\S]*?restrict creation and updates of `stable`[\s\S]*?block update and deletion for `marketplace-v\*-candidate\.\*` tags[\s\S]*?ordinary `main` integration unable to advance the distributable ref/, + /Publishing and catalog promotion require new,[\s\S]*?explicit authorization/, ); assert.match( releasing, - /Verify remote reachability read-only before release/, + /reconcile its registry identity read-only/, ); assert.match( releasing, - /Release corrections are forward-only[\s\S]*?Never rewrite a released version's source[\s\S]*?force\s+`stable`[\s\S]*?rewind it/, + /Release corrections are forward-only and use a new SemVer/, ); assert.match( agents, - /Never reuse a marketplace SemVer with a different source SHA/, + /Never reuse a published npm version or marketplace SemVer with different package bytes/, ); assert.match( agents, - /verify read-only that rulesets block `stable` force-push\/deletion[\s\S]*?candidate tags immutable/, + /Reconcile an ambiguous publication or push outcome read-only before retrying/, ); - assert.match(agents, /move\s+`stable` forward only after qualification/); + assert.match(agents, /serialized through one[\s\S]*?operator/); }); async function releaseDocuments() { - const [compatibility, marketplace, packageDocument, previewManifest] = - await Promise.all([ - readJson("release/compatibility.json"), - readJson(".claude-plugin/marketplace.json"), - readJson("package.json"), - readJson(".claude-plugin/plugin.json"), - ]); + const [ + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + ] = await Promise.all([ + readJson("release/compatibility.json"), + readJson("packages/claude-plugin/.claude-plugin/plugin.json"), + readJson(".claude-plugin/marketplace.json"), + readJson("package.json"), + readJson("packages/claude-plugin/package.template.json"), + readJson(".claude-plugin/plugin.json"), + ]); - return { compatibility, marketplace, packageDocument, previewManifest }; + return { + compatibility, + installableManifest, + marketplace, + packageDocument, + packageTemplate, + previewManifest, + }; } async function readJson(relativePath) { diff --git a/test/repository.test.mjs b/test/repository.test.mjs index c03d15c..8ab9e15 100644 --- a/test/repository.test.mjs +++ b/test/repository.test.mjs @@ -72,8 +72,12 @@ const compilationEvidenceCliBaseline = const compilationEvidenceCliRuntimeDigest = "205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d"; const cliContractBaseline = - "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; + "1c5f44bf5f905b45931ec2c280d71d69b0d0ac78"; const cliContractRuntimeDigest = + "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; +const historicalCliContractBaseline = + "f55edffc9e88924f9a4c95f41c4d0bc9b72422f8"; +const historicalCliContractRuntimeDigest = "9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab"; const compilationProvenanceServiceBaseline = "5811bb3013cf25072db74355597f60d85be3c05b"; @@ -239,6 +243,7 @@ test("revision pins remain exhaustive across coordination surfaces", async () => foundationPlanServerBaseline, compilationEvidenceCliBaseline, cliContractBaseline, + historicalCliContractBaseline, compilationProvenanceServiceBaseline, productJourneySmokeBaseline, freshModelServiceBaseline, @@ -281,13 +286,13 @@ test("fresh Claude Code evidence is exact and bounded", async () => { const homeResponse = await readFile(homeInventoryOpeningResponse, "utf8"); const observation = JSON.parse(observationSource); assertRevisionTokens(evidence, [ - cliContractBaseline, + historicalCliContractBaseline, freshModelServiceBaseline, freshModelPluginBaseline, ]); assertRevisionTokens(homeResponse, []); assertRevisionTokens(observationSource, [ - cliContractBaseline, + historicalCliContractBaseline, freshModelServiceBaseline, freshModelServiceTree, freshModelPluginBaseline, @@ -323,8 +328,8 @@ test("fresh Claude Code evidence is exact and bounded", async () => { freshModelPluginBaseline, freshModelPluginRuntimeDigest, freshModelClaudeExecutableDigest, - cliContractBaseline, - cliContractRuntimeDigest, + historicalCliContractBaseline, + historicalCliContractRuntimeDigest, ]) { assert(evidence.includes(value)); } @@ -414,8 +419,8 @@ test("fresh Claude Code evidence is exact and bounded", async () => { tree_sha: freshModelServiceTree, }); assert.deepEqual(observation.cli, { - revision: cliContractBaseline, - runtime_sha256: cliContractRuntimeDigest, + revision: historicalCliContractBaseline, + runtime_sha256: historicalCliContractRuntimeDigest, version: "0.1.0-alpha.2", }); assert.deepEqual(observation.plugin, { @@ -524,144 +529,64 @@ test("installable Skills follow the portable repository profile", async () => { }); test("Claude Code packaging reuses the portable Skill exactly once", async () => { - const plugin = JSON.parse( + const previewManifest = JSON.parse( await readFile(path.join(claudePluginDirectory, "plugin.json"), "utf8"), ); const marketplace = JSON.parse( await readFile(path.join(claudePluginDirectory, "marketplace.json"), "utf8"), ); - const portableSkillPath = `./skills/${portableSkillName}`; - const marketplaceSourcePath = `skills/${portableSkillName}`; - - assert.deepEqual(plugin, { - $schema: "https://json.schemastore.org/claude-code-plugin-manifest.json", - name: claudePreviewPluginName, - displayName: "First Draft Preview", - version: "0.1.0", - description: - "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", - author: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - homepage: "https://github.com/firstdraft/skills", - repository: "https://github.com/firstdraft/skills", - license: "MIT", - keywords: ["foundation-plan", "rails", "application-generation"], - skills: [portableSkillPath], - }); - assert.deepEqual(marketplace, { - $schema: "https://json.schemastore.org/claude-code-marketplace.json", - name: claudeMarketplaceName, - description: "Experimental First Draft application-authoring skills for Claude Code", - owner: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - plugins: [ - { - name: claudePluginName, - version: "0.1.0-alpha.1", - source: { - source: "git-subdir", - url: "https://github.com/firstdraft/skills.git", - path: marketplaceSourcePath, - sha: marketplacePluginSourceBaseline, - }, - strict: false, - skills: ["./"], - displayName: "First Draft", - description: - "Experimental Foundation Plan authoring and bounded Rails application creation with First Draft", - author: { - name: "First Draft", - url: "https://github.com/firstdraft", - }, - homepage: "https://github.com/firstdraft/skills", - repository: "https://github.com/firstdraft/skills", - license: "MIT", - keywords: ["foundation-plan", "rails", "application-generation"], - category: "development", - tags: ["foundation-plan", "rails"], - }, - ], - }); - - const pluginSkillDirectory = path.resolve(repository, portableSkillPath); - assert.equal( - pluginSkillDirectory, - path.join(skillsDirectory, portableSkillName), - ); - assert.equal( - path.resolve(repository, marketplace.plugins[0].source.path), - pluginSkillDirectory, + const packageTemplate = JSON.parse( + await readFile( + path.join(repository, "packages", "claude-plugin", "package.template.json"), + "utf8", + ), ); - assert.equal( - path.resolve(pluginSkillDirectory, marketplace.plugins[0].skills[0]), - pluginSkillDirectory, + const installableManifest = JSON.parse( + await readFile( + path.join(repository, "packages", "claude-plugin", ".claude-plugin", "plugin.json"), + "utf8", + ), ); - assert((await stat(path.join(pluginSkillDirectory, "SKILL.md"))).isFile()); + + assert.equal(previewManifest.name, claudePreviewPluginName); + assert.deepEqual(previewManifest.skills, [`./skills/${portableSkillName}`]); + assert.equal(marketplace.name, claudeMarketplaceName); + assert.equal(marketplace.plugins.length, 1); + assert.equal(marketplace.plugins[0].name, claudePluginName); + assert.equal(marketplace.plugins[0].version, "0.1.0-alpha.2"); + assert.deepEqual(marketplace.plugins[0].source, { + source: "npm", + package: "@firstdraft.com/claude-code", + version: "0.1.0-alpha.2", + registry: "https://registry.npmjs.org/", + }); + assert.equal(packageTemplate.dependencies, undefined); + assert.deepEqual(installableManifest.skills, [ + "./skills/create-full-stack-app", + ]); + assert.equal(installableManifest.userConfig.api_token.sensitive, true); const repositoryFiles = trackedFiles(); const previewComponents = repositoryFiles .map((file) => path.relative(repository, file)) - .filter((relativePath) => { - const segments = relativePath.split(path.sep); - return forbiddenCheckoutRootClaudePluginComponentPaths.includes( - segments[0], - ); - }); - assert.deepEqual( - previewComponents, - [], - "the checkout-root plugin preview must not auto-discover components " + - "outside the marketplace source", - ); - assert.deepEqual(forbiddenCheckoutRootClaudePluginComponentPaths, [ - ".lsp.json", - ".mcp.json", - "SKILL.md", - "agents", - "bin", - "commands", - "hooks", - "monitors", - "output-styles", - "settings.json", - "themes", - "workflows", - ]); + .filter((relativePath) => + forbiddenCheckoutRootClaudePluginComponentPaths.includes( + relativePath.split(path.sep)[0], + ), + ); + assert.deepEqual(previewComponents, []); for (const relativePath of forbiddenCheckoutRootClaudePluginComponentPaths) { await assert.rejects( lstat(path.join(repository, relativePath)), (error) => error.code === "ENOENT", - `the checkout-root preview discovers ${relativePath} from the working tree`, ); } - const checkoutSkillsDirectoryDetails = await lstat(skillsDirectory); - assert.equal( - checkoutSkillsDirectoryDetails.isDirectory(), - true, - "the checkout-root skills path must be a directory, not a link", - ); - const checkoutSkillEntries = await readdir(skillsDirectory, { - withFileTypes: true, - }); - assert.deepEqual( - checkoutSkillEntries.map((entry) => entry.name).sort(), - [portableSkillName], - "the checkout-root preview must expose only the canonical portable Skill", - ); - assert.equal( - checkoutSkillEntries[0].isDirectory(), - true, - "the canonical checkout-root Skill must be a directory, not a link", - ); + + const pluginSkillDirectory = path.join(skillsDirectory, portableSkillName); const skillFiles = repositoryFiles.filter( (file) => path.basename(file) === "SKILL.md", ); assert.deepEqual(skillFiles, [path.join(pluginSkillDirectory, "SKILL.md")]); - const canonicalBody = await readFile(skillFiles[0]); const exactCopies = []; for (const file of repositoryFiles) { @@ -673,563 +598,48 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => const installedSourceFiles = (await filesUnder(pluginSkillDirectory)).map( (file) => path.relative(pluginSkillDirectory, file), ); - assert.deepEqual(canonicalClaudePluginSkillFiles, [ - "LICENSE.txt", - "SKILL.md", - "agents/openai.yaml", - "references/diagnostics-and-recovery.md", - "references/examples.md", - "references/foundation-plan-0.19.schema.json", - "references/foundation-plan-019.md", - "references/modeling-guide.md", - ]); - const pinnedSourceFiles = gitTreePathsAtRevision( - marketplacePluginSourceBaseline, - marketplaceSourcePath, - ).map((file) => path.posix.relative(marketplaceSourcePath, file)); - assert.deepEqual(pinnedSourceFiles, canonicalClaudePluginSkillFiles); - assert.equal( - pinnedSourceFiles.some((file) => file === ".claude-plugin/plugin.json"), - false, - "the pinned plugin source must remain manifestless", - ); assert.deepEqual(installedSourceFiles, canonicalClaudePluginSkillFiles); - assert.deepEqual(forbiddenClaudePluginPathSegments, [ - "evals", - "node_modules", - "package-lock.json", - "package.json", - "script", - "scripts", - "test", - ]); const forbiddenSegments = new Set(forbiddenClaudePluginPathSegments); for (const relativePath of installedSourceFiles) { - const segments = relativePath.split(path.sep); - assert.equal( - segments.some((segment) => forbiddenSegments.has(segment)), - false, - `unexpected installed source path: ${relativePath}`, - ); - assert.equal( - segments.includes("commands"), - false, - `installed source contains a Commands component: ${relativePath}`, - ); assert.equal( - segments.includes("hooks"), + relativePath + .split(path.sep) + .some((segment) => forbiddenSegments.has(segment)), false, - `installed source contains a Hooks component: ${relativePath}`, - ); - assert.notEqual( - path.basename(relativePath), - ".mcp.json", - `installed source contains an MCP component: ${relativePath}`, + `unexpected portable Skill path: ${relativePath}`, ); - if (segments.includes("agents")) { - assert.notEqual( - path.extname(relativePath), - ".md", - `installed source contains an Agent component: ${relativePath}`, - ); - } - } - for (const component of [ - "agents", - "commands", - "hooks", - "lspServers", - "mcpServers", - ]) { - assert(!Object.hasOwn(marketplace.plugins[0], component)); } - assert.equal(installedSourceFiles.length, 8); - const readme = await readFile(path.join(repository, "README.md"), "utf8"); - assert.match(readme, /eight canonical Skill files/); - assert.match( - readme, - /product-specific plugin packaging now points to and reuses this\s+canonical Skill; it does not fork the instructions/, - ); - assert.match( - readme, - /observed isolated installed\s+plugin cache contained no second copy of the Skill instructions, repository test harness, or runtime dependency\s+tree[\s\S]*?marketplace tree is a separate footprint[\s\S]*?did not inventory an isolated marketplace tree[\s\S]*?no\s+Git-hosted marketplace tree has been observed/, + const packageSources = trackedFiles().filter((file) => + file.startsWith(path.join(repository, "packages", "claude-plugin")), ); - assert.match( - readme, - /validate the marketplace manifest and root preview manifest without installing either one/, - ); - assert(readme.includes("evidence/2026-08-04-claude-code-plugin-install-smoke.md")); - assert( - readme.includes( - "evidence/claude-code-plugin-install-observation.json", - ), - ); - assert.match( - readme, - /preview-only `0\.1\.0` version[\s\S]*?excluded from the marketplace\s+plugin source[\s\S]*?installable marketplace entry independently owns\s+the plugin release version, currently `0\.1\.0-alpha\.1`[\s\S]*?checkout preview's separate `0\.1\.0` does not govern that\s+installation[\s\S]*?private root npm package[\s\S]*?`0\.0\.0` version[\s\S]*?not plugin identity/, - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#plugin-sources", - ), - ); - assert.match( - readme, - /`git-subdir` field set and the `owner\/repo@ref` add form[\s\S]*?rechecked on 2026-08-05[\s\S]*?dated documentation check is not installation evidence/, - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#version-resolution-and-release-channels", - ), - ); - assert.match( - readme, - /One marketplace SemVer maps permanently to one source SHA[\s\S]*?failed or revised candidate\s+must receive a new prerelease version and immutable candidate tag[\s\S]*?never reuse an earlier version with new bytes[\s\S]*?explicitly versioned plugin remains cached until that version changes[\s\S]*?Every revised candidate therefore\s+requires a deliberate, never-reused SemVer bump[\s\S]*?version only participates in compatibility eligibility and never authorizes release/, - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#strict-mode", - ), - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugins-reference#unrecognized-fields", - ), - ); - assert.match( - readme, - /marketplace entry's `"strict": false` selects the marketplace entry as the entire plugin definition[\s\S]*?raw source directory without its own `plugin\.json`[\s\S]*?source manifest that also declares\s+components would conflict[\s\S]*?does not relax validation[\s\S]*?strict validation commands above remain manual release gates[\s\S]*?successful local invocation is not durable\s+qualification of a Git-hosted catalog or installation/, - ); - assert(readme.includes("https://code.claude.com/docs/en/plugins")); - assert(readme.includes("https://code.claude.com/docs/en/plugins-reference")); - assert(readme.includes("https://code.claude.com/docs/en/env-vars")); - const marketplaceAddSources = [ - ...readme.matchAll(/\bclaude plugin marketplace add ([^\s`]+)/g), - ].map((match) => match[1]); - assert(marketplaceAddSources.includes("firstdraft/skills@stable")); - assert( - marketplaceAddSources.every( - (source) => - source === "firstdraft/skills@stable" || - /^firstdraft\/skills@marketplace-v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?-candidate\.\d+$/.test( - source, - ), - ), - ); - assert.doesNotMatch( - readme, - /claude plugin marketplace add firstdraft\/skills(?:@main)?(?=\s|`|$)/, - ); - assert( - readme.includes( - "https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add", - ), - ); - assert.match( - readme, - /`CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` for cloning GitHub `owner\/repo` shorthand over HTTPS rather than SSH[\s\S]*?users with working GitHub SSH configuration may\s+omit it[\s\S]*?documents `owner\/repo@ref`[\s\S]*?`@stable` ref is the intended distributable channel[\s\S]*?ordinary installation must not\s+track mutable `main`[\s\S]*?No live GitHub clone from either ref has been observed for this package/, - ); - assert.match( - readme, - /root preview manifest uses the distinct name `firstdraft-preview`[\s\S]*?cannot collide with an\s+installed `firstdraft@firstdraft-skills`[\s\S]*?documented local-development path[\s\S]*?without registering a\s+marketplace or installing the plugin[\s\S]*?claude --plugin-dir \.[\s\S]*?Home Inventory evaluation observed a headless Claude Code 2\.1\.221 session load the\s+`Skill\(firstdraft-preview:create-full-stack-app\)` identifier[\s\S]*?explicit `--plugin-dir ` without marketplace\s+registration or installation[\s\S]*?did not exercise the interactive command[\s\S]*?Movie Catalog process[\s\S]*?retained evidence does not record Skill discovery or\s+invocation[\s\S]*?Home run observed the headless\s+tool identifier[\s\S]*?Interactive `\/firstdraft-preview:create-full-stack-app` invocation[\s\S]*?remain unobserved/, - ); - assert.match( - readme, - /whole checkout is the preview plugin root[\s\S]*?default component location documented for Claude Code 2\.1\.221[\s\S]*?exactly one canonical subtree beneath `skills\/`[\s\S]*?enumerated checkout-root component locations[\s\S]*?complete on-disk `skills\/` subtree[\s\S]*?including untracked entries there[\s\S]*?without claiming a scan of every working-tree/, - ); - assert.match( - readme, - /machine-readable observation[\s\S]*?remain bound to the\s+eight canonical Skill files at historical revision `b5c3897b240bfa3a9117d1a564d8e6b7d783e993`[\s\S]*?reconstruct\s+those Git objects rather than requiring today's unpromoted source to match them[\s\S]*?does not qualify this remote-pinned catalog/, - ); - assert.match( - readme, - /old local-directory harness cannot exercise the current remote-pinned `git-subdir` source[\s\S]*?child\s+PATH intentionally provided no Git[\s\S]*?`npm run check:claude-plugin-install` and `npm run record:claude-plugin-install` now fail before resolving Claude,\s+inspecting real state, or creating temporary state[\s\S]*?Do not use either\s+command to renew or qualify the current candidate/, - ); - assert.match( - readme, - /Git-hosted qualification requires a reviewed successor harness[\s\S]*?`owner\/repo@ref` form[\s\S]*?immutable candidate tag[\s\S]*?cache directory is exactly the\s+candidate SemVer[\s\S]*?byte- and mode-compare[\s\S]*?narrowly scrubbed executable path[\s\S]*?do not inherit\s+the operator's general PATH, tokens, SSH agent, or unrelated environment/, - ); - assert.match( - readme, - /Repository checks require `git` on `PATH` and a real Git checkout with its index and working tree available[\s\S]*?source\s+archive, exported tree, or installed plugin cache is insufficient[\s\S]*?complete `skills\/`\s+subtree on disk[\s\S]*?full,\s+unshallowed history[\s\S]*?`git rev-parse --is-shallow-repository`[\s\S]*?`git fetch --unshallow origin`/, - ); - const observationSource = await readFile(claudePluginObservation, "utf8"); - const observation = JSON.parse(observationSource); - assert.equal(observation.schemaVersion, 3); assert.equal( - observation.observedOn, - "2026-08-04", - "historical observation date changed; preserve this pinned evidence. " + - "Use the reviewed candidate-tag and pinned-remote-source successor " + - "harness for a new dated observation with byte and mode comparison", - ); - assert.equal( - observation.claudeCode.version, - "2.1.221", - "historical Claude Code version changed; preserve this pinned evidence. " + - "Use the reviewed candidate-tag and pinned-remote-source successor " + - "harness for a new dated observation with byte and mode comparison", - ); - assert.deepEqual(observation.claudeCode.componentInventory, { - agents: 0, - hooks: 0, - lspServers: 0, - mcpServers: 0, - skillsAndCommands: 1, - }); - assert.deepEqual( - observation.manifestValidation.marketplace.normalizedArgv, - ["", "plugin", "validate", "--strict", ""], - ); - assert.deepEqual( - observation.manifestValidation.previewPlugin.normalizedArgv, - [ - "", - "plugin", - "validate", - "--strict", - "/.claude-plugin/plugin.json", - ], - ); - assert.deepEqual( - Object.fromEntries( - Object.entries(observation.manifestValidation).map(([name, value]) => [ - name, - { passed: value.passed, path: value.path, strict: value.strict }, - ]), - ), - { - marketplace: { passed: true, path: ".", strict: true }, - previewPlugin: { - passed: true, - path: ".claude-plugin/plugin.json", - strict: true, - }, - }, - ); - for (const validation of Object.values(observation.manifestValidation)) { - assert.match(validation.capturedOutput, /Validation passed$/); - assert(!validation.capturedOutput.includes(repository)); - } - assert.equal(observation.installedPlugin.marketplace, claudeMarketplaceName); - assert.equal(observation.installedPlugin.name, claudePluginName); - assert.equal(observation.installedPlugin.commandsDeclared, false); - assert.equal(observation.installedPlugin.fileCount, 8); - assert.equal(observation.installedPlugin.totalBytes, 207_433); - assert.deepEqual( - observation.installedPlugin.files.map((file) => file.path), - canonicalClaudePluginSkillFiles, - ); - const historicalFileInventory = canonicalClaudePluginSkillFiles.map( - (relativePath) => { - const source = gitBlobAtRevision( - freshModelPluginBaseline, - `skills/create-full-stack-app/${relativePath}`, - ); - return { - path: relativePath, - bytes: source.length, - sha256: createHash("sha256").update(source).digest("hex"), - }; - }, - ); - assert.deepEqual( - historicalFileInventory, - observation.installedPlugin.files, - "the pinned historical Skill bytes differ from the dated observation", - ); - assert.equal( - observedFileBytes(observation.installedPlugin.files), - observation.installedPlugin.totalBytes, - ); - assert.equal( - observedFileTreeSha256(observation.installedPlugin.files), - observation.installedPlugin.treeSha256, - ); - assert.match(observation.installedPlugin.treeSha256, /^[0-9a-f]{64}$/); - for (const file of observation.installedPlugin.files) { - assert.match(file.sha256, /^[0-9a-f]{64}$/, file.path); - } - assert.deepEqual(observation.checks, { - childWorkingDirectory: "isolated", - packageManagerInvocation: "absent", - realStateUnchanged: true, - temporaryStateRemoved: true, - }); - assert.deepEqual(observation.realStateMonitor.requiredRegistryAnyOf, [ - "installedPlugins", - "knownMarketplaces", - "pluginCatalog", - ]); - assert.deepEqual(observation.realStateMonitor.excluded, ["~/.claude.json"]); - assert( - observation.realStateMonitor.requiredRegistryAnyOf.some((name) => - observation.realStateMonitor.present.includes(name), - ), - "observed real-state monitoring is vacuous", - ); - assert.deepEqual( - [ - ...observation.realStateMonitor.present, - ...observation.realStateMonitor.absent, - ].sort(), - [ - "credentials", - "installedPlugins", - "knownMarketplaces", - "pluginCatalog", - "settings", - "settingsLocal", - "targetCache", - "targetData", - "targetMarketplace", - ], - ); - assertNoObservationAbsolutePathLeaks(observation); - - const evidence = await readFile(claudePluginEvidence, "utf8"); - assertNoObservationAbsolutePathLeaks({ evidenceMarkdown: evidence }); - assert(evidence.includes(`Claude Code ${observation.claudeCode.version}`)); - assert(evidence.includes(`# Claude Code plugin install smoke — ${observation.observedOn}`)); - assert( - evidence.includes( - `${observation.installedPlugin.fileCount} canonical Skill files, ${observation.installedPlugin.totalBytes} bytes`, - ), - ); - assert( - evidence.includes( - "live inventory Skills=1, Agents=0, Hooks=0, MCP servers=0, LSP servers=0", - ), - ); - assert(evidence.includes("derived Commands=absent")); - assert(evidence.includes("CLI combines Skills/Commands")); - assert(!evidence.includes("Commands=0")); - assert(evidence.includes("no PATH-level package manager invocation")); - const documentedPresenceSummary = - `real-state monitor present=${renderStatePresenceNames(observation.realStateMonitor.present)}, ` + - `absent=${renderStatePresenceNames(observation.realStateMonitor.absent)}, ` + - `excluded=${renderStatePresenceNames(observation.realStateMonitor.excluded)}`; - assert( - evidence.includes(documentedPresenceSummary), - `packaging evidence must contain the canonical real-state summary: ${documentedPresenceSummary}`, - ); - const documentedStatePresenceBlock = [ - `- Present: ${renderEvidenceStateNames(observation.realStateMonitor.present)}`, - `- Absent: ${renderEvidenceStateNames(observation.realStateMonitor.absent)}`, - `- Excluded: ${renderEvidenceStateNames(observation.realStateMonitor.excluded)}`, - ].join("\n"); - assertEvidenceStatePresenceBlock( - evidence, - documentedStatePresenceBlock, - ); - const evidenceWithAppendedStateBullet = evidence.replace( - `${documentedStatePresenceBlock}\n\nAt least one`, - `${documentedStatePresenceBlock}\n- Unexpected: \`notObserved\`\n\nAt least one`, - ); - assert.notEqual(evidenceWithAppendedStateBullet, evidence); - assert.throws( - () => - assertEvidenceStatePresenceBlock( - evidenceWithAppendedStateBullet, - documentedStatePresenceBlock, - ), - /state-presence bullets differ from the observation/, - ); - for (const [label, validation] of [ - ["marketplace", observation.manifestValidation.marketplace], - ["preview plugin", observation.manifestValidation.previewPlugin], - ]) { - const renderedValidation = renderManifestValidationEvidence( - label, - validation, - ); - assert( - evidence.includes(renderedValidation), - `packaging evidence must render the observed ${label} validation:\n${renderedValidation}`, - ); - } - assert.match( - evidence, - /rendered from\s+the machine-readable observation's repository-relative target,\s+normalized child argv, and captured normalized\s+output[\s\S]*?normalized evidence fields, not verbatim shell commands\s+or npm-wrapper output/, - ); - assert.match( - evidence, - /marketplace manifest and root preview manifest both passed strict validation/, - ); - assert.equal( - [...evidence.matchAll(/^✔ Validation passed$/gm)].length, - 2, - "packaging evidence must contain one captured success line per strict validation", - ); - assert.match( - evidence, - /Real-state presence remains run-local information and is not compared across machines[\s\S]*?every\s+run still requires a core registry target and proves monitored state unchanged/, - ); - assert(evidence.includes("The exact recording command was:")); - assert(evidence.includes("$ npm run record:claude-plugin-install")); - assert(evidence.includes("local source-only packaging check")); - assert(evidence.includes("explicit isolated values only")); - assert(evidence.includes("without traversing symlinks")); - assert.match(evidence, /all\s+monitored real-state entries were unchanged/); - assert.match( - evidence, - /excludes the high-churn `~\/\.claude\.json`[\s\S]*?real-state claim is limited/, - ); - assert.match( - evidence, - /`agents\/openai\.yaml` file is Skill metadata[\s\S]*?`Agents=0`[\s\S]*?rerun whenever Claude Code\s+is upgraded/, - ); - assert.match( - evidence, - /During this 2\.1\.221 evidence renewal[\s\S]*?current official plugin reference[\s\S]*?canonical `skills\/` discovery plus the same twelve[\s\S]*?root\s+`SKILL\.md`[\s\S]*?`commands\/`[\s\S]*?`agents\/`[\s\S]*?`workflows\/`[\s\S]*?`output-styles\/`[\s\S]*?`themes\/`[\s\S]*?`hooks\/`[\s\S]*?`\.mcp\.json`[\s\S]*?`\.lsp\.json`[\s\S]*?`monitors\/`[\s\S]*?`bin\/`[\s\S]*?`settings\.json`[\s\S]*?installation observation itself does not establish that documentation-wide\s+completeness claim/, - ); - assert.match(evidence, /temporary directory\s+was removed/); - assert.match( - evidence, - /None of the monitored real Claude registry, catalog, target-cache, settings, or\s+credential targets changed[\s\S]*?two conservative candidate paths also remained\s+absent/, - ); - assert.match( - evidence, - /Claude Code 2\.1\.221 did not create\s+`\/plugins\/marketplaces\/firstdraft-skills`[\s\S]*?`\/plugins\/data\/firstdraft-firstdraft-skills`[\s\S]*?`targetMarketplace` and `targetData` are conservative\s+candidate-path monitors[\s\S]*?not\s+confirmed current CLI storage layouts[\s\S]*?absence is not load-bearing\s+isolation evidence/, - ); - assert.doesNotMatch( - evidence, - /No marketplace or plugin was added to the operator's real Claude configuration/, - ); - assert.match(evidence, /did not\s+publish or release the plugin/); - assert.doesNotMatch(evidence, /\b[0-9a-f]{40}\b/); - const documentedFileRows = [ - ...evidence.matchAll(/^\| `([^`]+)` \| ([\d,]+) \|$/gm), - ].map((match) => [ - match[1], - Number.parseInt(match[2].replaceAll(",", ""), 10), - ]); - assert.deepEqual( - documentedFileRows, - observation.installedPlugin.files.map((file) => [file.path, file.bytes]), - ); - const documentedTotal = evidence.match( - /^\| \*\*Total\*\* \| \*\*([\d,]+)\*\* \|$/m, - ); - assert(documentedTotal, "packaging evidence omits the byte total"); - assert.equal( - Number.parseInt(documentedTotal[1].replaceAll(",", ""), 10), - observation.installedPlugin.totalBytes, + packageSources.some((file) => path.basename(file) === "SKILL.md"), + false, + "the installable package must not commit a second editable Skill copy", ); - const smokeScript = path.join( - repository, - "script", - "check-claude-plugin-install.mjs", - ); - assert((await stat(smokeScript)).isFile()); - const syntaxCheck = spawnSync(process.execPath, ["--check", smokeScript], { - cwd: repository, - encoding: "utf8", - }); - assert.equal(syntaxCheck.status, 0, syntaxCheck.stderr); - const unsupportedSmoke = spawnSync(process.execPath, [smokeScript], { - cwd: repository, - encoding: "utf8", - env: {}, - }); - assert.notEqual(unsupportedSmoke.status, 0); - assert.match( - unsupportedSmoke.stderr, - /local Claude Code install smoke does not support the remote-pinned git-subdir plugin source[\s\S]*?qualify the immutable candidate tag and its pinned remote source with the reviewed release harness/, - ); - const smokeSource = await readFile(smokeScript, "utf8"); - const remoteSourcePrecondition = smokeSource.indexOf( - "assertLocalInstallSmokeSupported(installSmokeMarketplace)", - ); - const defaultStateLocationPrecondition = smokeSource.indexOf( - "assertDefaultClaudeStateLocations(process.env)", - ); - const claudeResolution = smokeSource.indexOf( - 'const claude = resolveNativeExecutable(process.env.CLAUDE_BIN ?? "claude")', - ); - const realStateSnapshot = smokeSource.indexOf( - "const realStateBefore = realClaudeStateSnapshot()", - ); - assert( - remoteSourcePrecondition >= 0 && - remoteSourcePrecondition < defaultStateLocationPrecondition && - defaultStateLocationPrecondition < claudeResolution && - claudeResolution < realStateSnapshot, - "remote-source and real-state preconditions must run before Claude resolution and state inspection", - ); - assert.doesNotMatch( - smokeSource, - /process\.env\.(?:CLAUDE_CONFIG_DIR|CLAUDE_CODE_PLUGIN_CACHE_DIR)\s*\?\?/, - ); - const versionRead = smokeSource.indexOf( - 'runPluginCommand(claude, ["--version"], commandOptions)', - ); - const marketplaceValidation = smokeSource.indexOf( - "const marketplaceValidationArguments = [", - ); - const marketplaceValidationEvidence = smokeSource.indexOf( - "const marketplaceValidation = observedManifestValidation({", - ); - const previewValidationEvidence = smokeSource.indexOf( - "const previewValidation = observedManifestValidation({", - ); - const marketplaceAdd = smokeSource.indexOf( - '["plugin", "marketplace", "add", repository, "--scope", "user"]', - ); - const marketplaceGuard = smokeSource.indexOf( - 'assertRealStateUnchanged("after isolated marketplace add")', - ); - const isolatedMarketplaceTreeCheck = smokeSource.indexOf( - '"isolated directory marketplace unexpectedly created a persistent tree"', - ); - assert.match( - smokeSource, - /pathEntryExists\(isolatedMarketplaceTree\)[\s\S]*?pathEntryExists\(isolatedPluginData\)/, - ); - const pluginInstall = smokeSource.indexOf( - '["plugin", "install", `${pluginName}@${marketplaceName}`, "--scope", "user"]', - ); - const pluginGuard = smokeSource.indexOf( - 'assertRealStateUnchanged("after isolated plugin install")', - ); - assert( - versionRead >= 0 && - versionRead < marketplaceValidation && - marketplaceValidation < marketplaceValidationEvidence && - marketplaceValidationEvidence < previewValidationEvidence && - previewValidationEvidence < marketplaceAdd && - marketplaceAdd < isolatedMarketplaceTreeCheck && - isolatedMarketplaceTreeCheck < marketplaceGuard && - marketplaceGuard < pluginInstall && - pluginInstall < pluginGuard, - "the real-state guard must run between isolated mutations and after install", - ); + const packageReadme = await readFile(path.join(repository, "README.md"), "utf8"); assert.match( - smokeSource, - /claude plugin uninstall firstdraft@firstdraft-skills --scope user[\s\S]*?claude plugin marketplace remove firstdraft-skills --scope user/, + packageReadme, + /Packing copies the canonical `skills\/create-full-stack-app` directory/, ); assert.match( - smokeSource, - /resolvedStateTargetDiagnostics\(changedRealState, realStateTargets\)\.join\(", "\)/, + packageReadme, + /plugin-root `bin\/` directory are added to the Bash tool's `PATH`/, ); assert.match( - smokeSource, - /assertMatchesCommittedObservation\(observation\)[\s\S]*?reviewedPackagingObservation\(current\)[\s\S]*?reviewedPackagingObservation\(committed\)[\s\S]*?review current discovery and isolation behavior/, - ); - const packageDocument = JSON.parse( - await readFile(path.join(repository, "package.json"), "utf8"), - ); - assert.equal( - packageDocument.scripts["check:claude-plugin-install"], - "node script/check-claude-plugin-install.mjs", + packageReadme, + /claude plugin marketplace add firstdraft\/skills[\s\S]*?claude plugin install firstdraft@firstdraft-skills/, ); - assert.equal( - packageDocument.scripts["record:claude-plugin-install"], - "node script/check-claude-plugin-install.mjs --observation-output " + - "evidence/claude-code-plugin-install-observation.json", + const vendoredSmoke = await readFile( + path.join(repository, "evidence", "2026-08-05-claude-plugin-vendored-cli-smoke.md"), + "utf8", ); + assert.match(vendoredSmoke, /Claude Code 2\.1\.222/); + assert.match(vendoredSmoke, /printing exactly `0\.1\.0-alpha\.2`/); + assert.match(vendoredSmoke, /did not materialize its dependency/); + assert.doesNotMatch(vendoredSmoke, /(?:\/Users\/|\/home\/|[A-Za-z]:\\)/); }); test("repository inventory traverses .git directories and rejects unsafe .git entries", async () => { @@ -1295,6 +705,10 @@ test("CI checks the exact modular CLI contract", async () => { path.join(repository, ".github", "workflows", "ci.yml"), "utf8", ); + const publishWorkflow = await readFile( + path.join(repository, ".github", "workflows", "publish.yml"), + "utf8", + ); const contractCheck = await readFile( path.join(repository, "script", "check-cli-contract.mjs"), "utf8", @@ -1309,6 +723,18 @@ test("CI checks the exact modular CLI contract", async () => { `repository: firstdraft/cli\\s+ref: main\\s+fetch-depth: 0`, ), ); + assert.equal( + [...publishWorkflow.matchAll(/[0-9a-f]{40}/g)].filter( + ([revision]) => revision === cliContractBaseline, + ).length, + 4, + ); + assert.doesNotMatch( + publishWorkflow + .replace(/^.*uses:\s+\S+@[0-9a-f]{40}.*$/gm, "") + .replaceAll(cliContractBaseline, ""), + /\b[0-9a-f]{40}\b/, + ); assert.match( workflow, new RegExp( @@ -1323,6 +749,10 @@ test("CI checks the exact modular CLI contract", async () => { workflow, /node script\/check-cli-contract\.mjs tmp\/firstdraft-cli/, ); + assert.match( + workflow, + /node script\/check-claude-plugin-package\.mjs --cli-root tmp\/firstdraft-cli/, + ); assert(contractConfig.includes(cliContractBaseline)); assert(contractConfig.includes(cliContractRuntimeDigest)); assert.match(contractConfig, /src\/commands\/compilation\.js/); From 020205e113f91b91df938b0b42a831d014f1a6d3 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Wed, 5 Aug 2026 11:24:13 -0500 Subject: [PATCH 3/3] Pin landed CLI release Use the exact CLI commit now on main and advance the unreleased Claude package candidate so one version continues to identify one immutable tarball. --- .claude-plugin/marketplace.json | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 8 ++++---- README.md | 8 ++++---- RELEASING.md | 2 +- packages/claude-plugin/.claude-plugin/plugin.json | 2 +- packages/claude-plugin/package.template.json | 2 +- release/compatibility.json | 4 ++-- script/cli-contract/config.mjs | 2 +- .../references/diagnostics-and-recovery.md | 2 +- .../references/foundation-plan-019.md | 2 +- test/release-compatibility.test.mjs | 4 ++-- test/repository.test.mjs | 6 +++--- 13 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e2725ae..43faaea 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,11 +9,11 @@ "plugins": [ { "name": "firstdraft", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "source": { "source": "npm", "package": "@firstdraft.com/claude-code", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "registry": "https://registry.npmjs.org/" }, "displayName": "First Draft", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4006dce..6d6d892 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: fetch-depth: 0 path: tmp/firstdraft-cli persist-credentials: false - - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD - - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 - run: node script/check-cli-contract.mjs tmp/firstdraft-cli - run: node script/check-claude-plugin-package.mjs --cli-root tmp/firstdraft-cli diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ba290a4..5ef40b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,8 +54,8 @@ jobs: fetch-depth: 0 path: tmp/firstdraft-cli persist-credentials: false - - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD - - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 - run: node script/claude-plugin-package.mjs pack "$RUNNER_TEMP/plugin" --cli-root tmp/firstdraft-cli publish: @@ -105,8 +105,8 @@ jobs: fetch-depth: 0 path: tmp/firstdraft-cli persist-credentials: false - - run: git -C tmp/firstdraft-cli merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 HEAD - - run: git -C tmp/firstdraft-cli checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 + - run: git -C tmp/firstdraft-cli merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 HEAD + - run: git -C tmp/firstdraft-cli checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 - name: Verify CLI release is published run: | set -euo pipefail diff --git a/README.md b/README.md index 0862dd4..c3b9f8a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ adapter and the exact packed bytes of `@firstdraft.com/cli@0.1.0-alpha.2`, so in Skill and its compatible CLI without requiring Claude Code to install transitive npm dependencies. The marketplace catalog uses Claude Code's documented `npm` plugin source and pins -`@firstdraft.com/claude-code@0.1.0-alpha.2`. The installable manifest asks Claude Code for the staging API URL and a +`@firstdraft.com/claude-code@0.1.0-alpha.3`. The installable manifest asks Claude Code for the staging API URL and a sensitive API token. Claude stores sensitive configuration in secure storage and exports plugin options only to plugin subprocesses. The adapter maps those options to the CLI's environment without printing them. Users should create the token in First Draft's browser UI and enter it in Claude's configuration prompt, never paste it into an @@ -174,13 +174,13 @@ sh script/check ``` The CLI contract check requires a checkout at the exact reviewed revision -`1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, whose independently reproduced JavaScript-source runtime digest is +`e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, whose independently reproduced JavaScript-source runtime digest is `0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`: ```sh git -C fetch origin main -git -C merge-base --is-ancestor 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 origin/main -git -C checkout --detach 1c5f44bf5f905b45931ec2c280d71d69b0d0ac78 +git -C merge-base --is-ancestor e53eb38d7e8254e6ba1e660b38c5d32d0314be17 origin/main +git -C checkout --detach e53eb38d7e8254e6ba1e660b38c5d32d0314be17 node script/check-cli-contract.mjs ``` diff --git a/RELEASING.md b/RELEASING.md index d687447..38bff69 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,7 +8,7 @@ plugin release, npm publication, or First Draft deployment. ## Release identity The installable `firstdraft@firstdraft-skills` plugin is the public npm package -`@firstdraft.com/claude-code`. Its current candidate version is `0.1.0-alpha.2`. The marketplace catalog points to +`@firstdraft.com/claude-code`. Its current candidate version is `0.1.0-alpha.3`. The marketplace catalog points to that exact package and version. The checkout-local `firstdraft-preview` manifest and private root `@firstdraft/skills@0.0.0` package are test tooling, not release identities. diff --git a/packages/claude-plugin/.claude-plugin/plugin.json b/packages/claude-plugin/.claude-plugin/plugin.json index b4d4a60..18593da 100644 --- a/packages/claude-plugin/.claude-plugin/plugin.json +++ b/packages/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "firstdraft", "displayName": "First Draft", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "description": "Author a Foundation Plan with your user and compile a bounded Rails and iPhone application", "author": { "name": "First Draft", diff --git a/packages/claude-plugin/package.template.json b/packages/claude-plugin/package.template.json index dd9ba3d..fe5569f 100644 --- a/packages/claude-plugin/package.template.json +++ b/packages/claude-plugin/package.template.json @@ -1,6 +1,6 @@ { "name": "@firstdraft.com/claude-code", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "description": "First Draft Foundation Plan authoring for Claude Code", "license": "MIT", "type": "module", diff --git a/release/compatibility.json b/release/compatibility.json index 3f0db35..ce7f006 100644 --- a/release/compatibility.json +++ b/release/compatibility.json @@ -1,10 +1,10 @@ { "format": "firstdraft.release-compatibility/1", "component": "skills", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "plugin_source": { "package": "@firstdraft.com/claude-code", - "tarball_sha256": "2dccdc0a635557aad8cd9c520685f25eaa8caaa09750f533d797103636ad9261" + "tarball_sha256": "1167fcdf43fba9040fc4068371fe263e779f26c6c88eb3fe3207369e12d32ba0" }, "requires": { "api_contract": [ diff --git a/script/cli-contract/config.mjs b/script/cli-contract/config.mjs index b572e49..899d2a9 100644 --- a/script/cli-contract/config.mjs +++ b/script/cli-contract/config.mjs @@ -1,4 +1,4 @@ -export const cliRevision = "1c5f44bf5f905b45931ec2c280d71d69b0d0ac78"; +export const cliRevision = "e53eb38d7e8254e6ba1e660b38c5d32d0314be17"; export const cliRuntimeSha256 = "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; export const cliPackageName = "@firstdraft.com/cli"; diff --git a/skills/create-full-stack-app/references/diagnostics-and-recovery.md b/skills/create-full-stack-app/references/diagnostics-and-recovery.md index 805b8c2..935b8aa 100644 --- a/skills/create-full-stack-app/references/diagnostics-and-recovery.md +++ b/skills/create-full-stack-app/references/diagnostics-and-recovery.md @@ -5,7 +5,7 @@ Handled leaf-command failures write exactly one JSON object to standard error. B structured fields rather than the human-readable `detail` or the broad process exit status. The reviewed CLI contract in this stack is revision -`1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, with JavaScript-source runtime digest +`e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, with JavaScript-source runtime digest `0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`. Its source package identifies itself as `@firstdraft.com/cli@0.1.0-alpha.2`, but remains unpublished. Check the command surface rather than assuming the version alone establishes compatibility. diff --git a/skills/create-full-stack-app/references/foundation-plan-019.md b/skills/create-full-stack-app/references/foundation-plan-019.md index 896954b..523b7c4 100644 --- a/skills/create-full-stack-app/references/foundation-plan-019.md +++ b/skills/create-full-stack-app/references/foundation-plan-019.md @@ -64,7 +64,7 @@ not release or execution evidence. The bounded local Compilation evidence used reviewed CLI revision `121272cd592055354d09a4fe90e55c3ca002770c`, with JavaScript-source runtime digest `205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d`. The current contract fixtures and check use -reviewed successor revision `1c5f44bf5f905b45931ec2c280d71d69b0d0ac78`, with independently reproduced +reviewed successor revision `e53eb38d7e8254e6ba1e660b38c5d32d0314be17`, with independently reproduced JavaScript-source runtime digest `0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad`, as contract provenance rather than release or execution evidence. That successor exposes `generate uuid`, `generate application-key`, `plan init`, `plan push`, `plan status`, zero-flag `plan compile`, `compilation status`, and diff --git a/test/release-compatibility.test.mjs b/test/release-compatibility.test.mjs index 45f2e52..7a29b56 100644 --- a/test/release-compatibility.test.mjs +++ b/test/release-compatibility.test.mjs @@ -24,11 +24,11 @@ test("release compatibility matches the installable plugin manifest", async () = assert.deepEqual(compatibility, { format: "firstdraft.release-compatibility/1", component: "skills", - version: "0.1.0-alpha.2", + version: "0.1.0-alpha.3", plugin_source: { package: "@firstdraft.com/claude-code", tarball_sha256: - "2dccdc0a635557aad8cd9c520685f25eaa8caaa09750f533d797103636ad9261", + "1167fcdf43fba9040fc4068371fe263e779f26c6c88eb3fe3207369e12d32ba0", }, requires: { api_contract: [">= 0.1.0", "< 0.2.0"], diff --git a/test/repository.test.mjs b/test/repository.test.mjs index 8ab9e15..f6b644a 100644 --- a/test/repository.test.mjs +++ b/test/repository.test.mjs @@ -72,7 +72,7 @@ const compilationEvidenceCliBaseline = const compilationEvidenceCliRuntimeDigest = "205e664df0ed9c7e63651a1c2c01e749a04d8879fe7f62cc4c1e13b66dce738d"; const cliContractBaseline = - "1c5f44bf5f905b45931ec2c280d71d69b0d0ac78"; + "e53eb38d7e8254e6ba1e660b38c5d32d0314be17"; const cliContractRuntimeDigest = "0983106d7c1054137d70dccb1091eeadd8272ffcca1f7bba1bde9c8028452fad"; const historicalCliContractBaseline = @@ -553,11 +553,11 @@ test("Claude Code packaging reuses the portable Skill exactly once", async () => assert.equal(marketplace.name, claudeMarketplaceName); assert.equal(marketplace.plugins.length, 1); assert.equal(marketplace.plugins[0].name, claudePluginName); - assert.equal(marketplace.plugins[0].version, "0.1.0-alpha.2"); + assert.equal(marketplace.plugins[0].version, "0.1.0-alpha.3"); assert.deepEqual(marketplace.plugins[0].source, { source: "npm", package: "@firstdraft.com/claude-code", - version: "0.1.0-alpha.2", + version: "0.1.0-alpha.3", registry: "https://registry.npmjs.org/", }); assert.equal(packageTemplate.dependencies, undefined);