diff --git a/.github/workflows/host-acceptance-release.yml b/.github/workflows/host-acceptance-release.yml new file mode 100644 index 0000000..3c6f1ba --- /dev/null +++ b/.github/workflows/host-acceptance-release.yml @@ -0,0 +1,30 @@ +name: Host acceptance release gate + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix (for example, 0.17.2) + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/vibespec/skills/vibespec + steps: + - name: Check out repository + uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + - name: Verify Claude and Codex acceptance evidence + env: + RELEASE_VERSION: ${{ inputs.version }} + run: node scripts/check-host-acceptance.mjs --version "$RELEASE_VERSION" diff --git a/README.ko.md b/README.ko.md index 2a42af8..38848b2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -104,6 +104,7 @@ VibeSpec은 제품 구현 코드를 생성하거나 배포하지 않습니다. - [라이브 데모](docs/live-demos.md) - [아키텍처와 데이터 경계](docs/architecture.md) - [개발 및 테스트](docs/development.md) +- [릴리스 수용 검증](docs/releasing.md) - [기여 안내](CONTRIBUTING.md) · [보안](SECURITY.md) ## 라이선스 diff --git a/README.md b/README.md index 5f297ce..834de5c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ VibeSpec does not generate or deploy the product's implementation code. It produ - [Live demos](docs/live-demos.md) - [Architecture and data boundaries](docs/architecture.md) - [Development and testing](docs/development.md) +- [Release acceptance](docs/releasing.md) - [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) ## License diff --git a/acceptance/hosts/README.md b/acceptance/hosts/README.md new file mode 100644 index 0000000..d2cd570 --- /dev/null +++ b/acceptance/hosts/README.md @@ -0,0 +1,17 @@ +# Real-host acceptance evidence + +Each released VibeSpec version must include portable acceptance evidence from: + +- at least one Claude-family host: `claude-code` or `cowork`; and +- at least one Codex-family host: `codex-cli` or `codex-desktop`. + +Store records under `acceptance/hosts/vX.Y.Z/`. Generate them with the verifier shipped in the installed plugin. Do not hand-edit records and do not commit raw logs, product artifacts, prompts, absolute paths, usernames, or task-folder names. + +The release gate validates the exact plugin version and runtime-bundle digest, then requires both host families: + +```text +cd plugins/vibespec/skills/vibespec +npm run check:host-acceptance +``` + +See [Releasing VibeSpec](../../docs/releasing.md) for the real-host procedure. diff --git a/docs/README.md b/docs/README.md index 838fe36..3407148 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,4 +5,5 @@ - **Explore every live example:** [Live demos](live-demos.md) - **Understand the SOT and plugin architecture:** [Architecture](architecture.md) - **Build and test the repository:** [Development](development.md) +- **Run the release gates:** [Releasing VibeSpec](releasing.md) - **Generate consumer app plans:** [Consumer app generation profile](reference/consumer-app-generation-profile.md) diff --git a/docs/development.md b/docs/development.md index a3d1f5f..9bf6974 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,6 +56,8 @@ npm run package:plugin - `npm run check:all` adds the Chrome or Edge browser-flow suite. - `npm run package:plugin` writes the minimal runtime bundle to `.dist/vibespec`. +Real Claude/Codex sessions are not launched by normal CI. Before tagging a release, generate the two required portable host records and run `npm run check:host-acceptance`. Follow [Releasing VibeSpec](releasing.md) for the full procedure. + ## Validate plans and trees Validate a generated or edited SOT: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4b0c323..9cc54ee 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,7 +50,7 @@ In the ChatGPT desktop app, open **Codex -> Plugins**, choose the `vibespec` mar Open a new task on a writable folder. Select **VibeSpec** in Cowork, invoke `/vibespec:vibespec` in Claude Code, or invoke `$vibespec` in Codex. Then request: "Create a compact meeting-room booking plan and save both `outputs/meeting-room.sot.json` and `outputs/meeting-room.html`." In reduced mode, VibeSpec may provide only the JSON and includes the unchanged viewer only when the host can access the installed viewer asset. -The loaded skill owns discovery of its installed path and runs doctor/preflight before it uses repository scripts. Do not guess or hard-code ``. For optional host acceptance after the host or loaded skill reports that path, run: +The loaded skill owns discovery of its installed path and runs doctor/preflight before it uses repository scripts. Do not guess or hard-code ``. For host acceptance after the host or loaded skill reports that path, run: ```text node /scripts/doctor.mjs --json @@ -62,7 +62,7 @@ Full-mode acceptance requires doctor/preflight to pass, both output files to exi node /scripts/verify-host-output.mjs outputs/meeting-room.sot.json outputs/meeting-room.html --host --record host-acceptance/.json ``` -The verifier checks that the host-created JSON is structurally valid and exactly matches the SOT embedded in the HTML. Repository CI cannot launch desktop hosts, so a release is described as host-accepted only after this check has run on that host. If VibeSpec is unavailable in a new task, reopen the task after installation or restart the desktop app. +The verifier checks that the host-created JSON is structurally valid and exactly matches the SOT embedded in the HTML. It writes a portable record with artifact hashes instead of local paths. Repository CI cannot launch desktop hosts, so each release requires one Claude-family and one Codex-family record produced by real full-mode sessions. Maintainers should follow [Releasing VibeSpec](releasing.md). If VibeSpec is unavailable in a new task, reopen the task after installation or restart the desktop app. ## Manual invocation diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..b47d686 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,66 @@ +# Releasing VibeSpec + +## Required gates + +A release candidate is ready to tag only after: + +1. `npm run check:all` passes from `plugins/vibespec/skills/vibespec`. +2. A fresh full-mode installation succeeds on at least one Claude-family host. +3. A fresh full-mode installation succeeds on at least one Codex-family host. +4. Both portable records match the exact plugin version and `npm run check:host-acceptance` passes. +5. The **Host acceptance release gate** GitHub Actions workflow passes for that version. + +Claude-family hosts are `claude-code` and `cowork`. Codex-family hosts are `codex-cli` and `codex-desktop`. Reduced-mode Cowork output does not satisfy the release gate because it cannot prove validated embedded HTML. + +## Release sequence + +1. Merge the release candidate and version bump to `main`, but do not create the tag yet. +2. Install that `main` candidate in fresh Claude and Codex sessions and generate independent artifacts. +3. Commit only the two portable evidence records. Do not change plugin runtime files after acceptance. +4. Run the local gate and the manual GitHub Actions gate. +5. Tag and publish the exact evidence commit. + +If any runtime-bundle file changes after acceptance, its digest changes and the gate rejects the old records. Repeat both real-host checks before tagging. + +## Canonical acceptance task + +Use a new writable task folder and a fresh task or session for each host. Install or update the release candidate, invoke VibeSpec explicitly, and request: + +> Create a compact meeting-room booking plan and save both `outputs/meeting-room.sot.json` and `outputs/meeting-room.html`. + +The host must load the installed skill, run its doctor/preflight, and create both files. Do not reuse artifacts created by another host. + +## Generate portable evidence + +After the host reports its installed VibeSpec skill directory, run the verifier from that installed plugin: + +```text +node /scripts/verify-host-output.mjs outputs/meeting-room.sot.json outputs/meeting-room.html --host --record host-acceptance/.json +``` + +The verifier proves that the SOT is structurally valid and exactly matches the SOT embedded in the HTML. Its record contains the installed runtime-bundle digest plus artifact basenames and SHA-256 hashes; it excludes local paths and raw task content. The bundle digest prevents evidence from being reused after runtime files change without a version bump. + +Copy the generated record into the source repository without editing it: + +```text +acceptance/hosts/vX.Y.Z/.json +``` + +Repeat in the other host family. Multiple records are allowed, but at least one accepted record per family is required. + +## Run the release gate + +Confirm that the package and both plugin manifests already carry the intended version. Then run: + +```text +cd plugins/vibespec/skills/vibespec +npm run check:host-acceptance +``` + +The command fails when evidence is missing, malformed, from another plugin version, or contains fields outside the portable contract. + +Push the records and run **Actions -> Host acceptance release gate -> Run workflow** with the same `X.Y.Z` version. This workflow validates committed evidence; it does not launch Claude or Codex. + +## Privacy and audit boundary + +Commit only the generated portable JSON records. Keep raw host logs and generated product artifacts private unless they are intentionally published as release assets. Never add workstation paths, credentials, personal prompts, or customer plans to acceptance evidence. diff --git a/docs/superpowers/plans/2026-08-10-host-acceptance-release-gate.md b/docs/superpowers/plans/2026-08-10-host-acceptance-release-gate.md new file mode 100644 index 0000000..e1d9636 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-host-acceptance-release-gate.md @@ -0,0 +1,32 @@ +# Host Acceptance Release Gate Implementation Plan + +**Goal:** Require portable Claude-family and Codex-family real-host evidence before a VibeSpec release. + +**Architecture:** Extend the existing installed verifier to emit privacy-safe, versioned evidence. Add a repository-aware gate that validates a version directory and connect it to tests, documentation, package scripts, and a manual release-readiness workflow. + +## Task 1: Evidence contract and verifier + +- Add shared host/family and evidence validation helpers. +- Write failing tests for hashes, version metadata, and path privacy. +- Update `verify-host-output.mjs` to emit the portable contract. +- Keep JSON/HTML equivalence and structural validation unchanged. + +## Task 2: Release gate + +- Write failing tests for missing families, mismatched versions, malformed evidence, and a valid pair. +- Add `check-host-acceptance.mjs` with library and CLI entry points. +- Add an npm script that checks `acceptance/hosts/v/` by default. + +## Task 3: Documentation and workflow + +- Add the committed evidence directory contract and a non-sensitive example policy. +- Update first-install and development/release documentation. +- Add a manually triggered GitHub Actions workflow with a version input. +- Ensure repository path audits accept portable records and reject workstation paths. + +## Task 4: Verification and publication + +- Run focused host acceptance tests. +- Run `npm run check:all`. +- Review the diff for private paths and generated artifacts. +- Commit and push the feature branch. diff --git a/docs/superpowers/specs/2026-08-10-host-acceptance-release-gate-design.md b/docs/superpowers/specs/2026-08-10-host-acceptance-release-gate-design.md new file mode 100644 index 0000000..f905844 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-host-acceptance-release-gate-design.md @@ -0,0 +1,67 @@ +# Host Acceptance Release Gate Design + +**Date:** 2026-08-10 +**Status:** Approved + +## Goal + +Make real-host acceptance a release contract instead of an informal checklist. Every release must carry portable evidence from at least one Claude-family host and one Codex-family host. + +## Acceptance policy + +- Claude family: `claude-code` or `cowork`. +- Codex family: `codex-cli` or `codex-desktop`. +- A release is host-accepted only when one full-mode record from each family passes. +- Evidence must match the exact plugin version being released. +- Reduced-mode output cannot satisfy the release gate because it cannot prove validated embedded HTML. +- CI validates evidence; it does not claim to launch desktop hosts. + +## Evidence layout + +Committed evidence lives under: + +```text +acceptance/hosts/vX.Y.Z/.json +``` + +Each record contains only portable facts: + +- evidence contract version +- plugin version and deterministic runtime-bundle SHA-256 +- host and host family +- acceptance result and timestamp +- artifact basenames and SHA-256 digests +- SOT title and schema version + +Absolute paths, usernames, task folders, prompts, and raw host logs are excluded. Raw artifacts may remain private or be attached separately to a GitHub Release. + +## Tooling + +`verify-host-output.mjs` remains the command run after a real host creates JSON and HTML. It will emit the portable record directly and derive the plugin version from the installed package. + +A new `check-host-acceptance.mjs` command will: + +1. Read the expected release version. +2. Load all JSON records from `acceptance/hosts/vX.Y.Z/`. +3. Reject malformed records, unknown hosts, absolute paths, failed records, version mismatches, and runtime-bundle digest mismatches. +4. Require at least one Claude-family and one Codex-family record. +5. Print a concise release-readiness summary. + +## CI and release boundary + +Normal pull-request CI must test the evidence contract and verifier without requiring current release evidence. A separate release-readiness command is the hard gate used immediately before tagging or publishing. This avoids making every development commit depend on manually produced host evidence while preventing a release from being described as accepted without it. + +The repository will provide a manually triggered GitHub Actions workflow that validates the committed evidence for the requested version. The workflow verifies records only; the real host sessions must already have been run. + +## Failure behavior + +- Missing either family: fail. +- Record version differs from requested version: fail. +- Record contains a local absolute path: fail. +- Artifact hash or required metadata missing: fail. +- Duplicate records are allowed when all are valid; one passing record per family is sufficient. +- Unknown extra fields are rejected so the evidence contract cannot drift silently. + +## Verification + +Tests cover portable evidence generation, artifact mismatch rejection, privacy rejection, version mismatch, missing host family, valid two-family acceptance, and the actual release-readiness CLI exit code. diff --git a/plugins/vibespec/.claude-plugin/plugin.json b/plugins/vibespec/.claude-plugin/plugin.json index 0283a84..c808d28 100644 --- a/plugins/vibespec/.claude-plugin/plugin.json +++ b/plugins/vibespec/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibespec", - "version": "0.17.1", + "version": "0.17.2", "description": "VibeSpec is a free, open-source AI product-planning plugin for Claude and Codex. It turns product ideas into editable SOTs with PRDs, feature specs, IA, user flows, and semantic assurance for KPI evidence. VibeSpec은 Claude와 Codex용 무료 오픈소스 AI 제품 기획 플러그인입니다. 공식 사이트: https://vbspec.com/.", "author": { "name": "chjoel0621" }, "license": "MIT", diff --git a/plugins/vibespec/.codex-plugin/plugin.json b/plugins/vibespec/.codex-plugin/plugin.json index 1b8b8a4..63830f3 100644 --- a/plugins/vibespec/.codex-plugin/plugin.json +++ b/plugins/vibespec/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibespec", - "version": "0.17.1", + "version": "0.17.2", "description": "VibeSpec is a free, open-source AI product-planning plugin for Claude and Codex. It turns product ideas into editable SOTs with PRDs, feature specs, IA, user flows, and semantic assurance for KPI evidence. Official site: https://vbspec.com/.", "author": { "name": "chjoel0621", diff --git a/plugins/vibespec/skills/vibespec/package-plugin.mjs b/plugins/vibespec/skills/vibespec/package-plugin.mjs index 0fd9160..33175a2 100644 --- a/plugins/vibespec/skills/vibespec/package-plugin.mjs +++ b/plugins/vibespec/skills/vibespec/package-plugin.mjs @@ -2,25 +2,11 @@ import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { basename, dirname, join, parse, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { runtimeBundleEntries } from "./scripts/lib/runtime-bundle.mjs"; const skillRoot = dirname(fileURLToPath(import.meta.url)); const pluginRoot = resolve(skillRoot, "..", ".."); -const entries = [ - [".claude-plugin/plugin.json"], - [".codex-plugin/plugin.json"], - ["LICENSE"], - ["README.md"], - ["skills/vibespec/SKILL.md"], - ["skills/vibespec/agents"], - ["skills/vibespec/assets/viewer.html"], - ["skills/vibespec/references"], - ["skills/vibespec/scripts"], - ["skills/vibespec/src/js/00-config.js"], - ["skills/vibespec/src/js/05-c14n.js"], - ["skills/vibespec/src/js/20-state.js"] -]; - function safeOutput(path) { const output = resolve(path); const root = parse(output).root; @@ -35,7 +21,7 @@ export function packagePlugin(outputPath = join(skillRoot, ".dist", "vibespec")) const output = safeOutput(outputPath); if (existsSync(output)) rmSync(output, { recursive: true, force: true }); mkdirSync(output, { recursive: true }); - for (const [relative] of entries) { + for (const relative of runtimeBundleEntries) { const source = join(pluginRoot, relative); if (!existsSync(source)) throw new Error(`missing runtime bundle entry ${source}`); const destination = join(output, relative); diff --git a/plugins/vibespec/skills/vibespec/package.json b/plugins/vibespec/skills/vibespec/package.json index ce631eb..bd4629d 100644 --- a/plugins/vibespec/skills/vibespec/package.json +++ b/plugins/vibespec/skills/vibespec/package.json @@ -1,7 +1,7 @@ { "name": "vibespec-viewer", "private": true, - "version": "0.17.1", + "version": "0.17.2", "description": "Modular source for the VibeSpec viewer. Build inlines src/ into a single self-contained assets/viewer.html.", "type": "module", "scripts": { @@ -21,10 +21,11 @@ "migrate": "node scripts/migrate-sot.mjs", "inspect": "node scripts/inspect.mjs", "doctor": "node scripts/doctor.mjs", + "check:host-acceptance": "node scripts/check-host-acceptance.mjs", "package:plugin": "node package-plugin.mjs", - "test": "node tests/run-tests.mjs && node tests/tree.test.mjs && node tests/rebase.test.mjs && node tests/product-map.test.mjs && node tests/workspace.test.mjs && node tests/change-plan.test.mjs && node tests/sot-query.test.mjs && node tests/content-review.test.mjs && node tests/semantic-review.test.mjs && node ../../../../evaluation/semantic-assurance/evaluate.mjs && node tests/migration.test.mjs && node tests/inspect.test.mjs && node tests/merge.test.mjs && node tests/embed-sot.test.mjs && node tests/demo-tree.test.mjs && node tests/host-smoke.mjs && node tests/host-runtime.test.mjs && node tests/package-plugin.test.mjs && node tests/installed-plugin-smoke.mjs", + "test": "node tests/run-tests.mjs && node tests/tree.test.mjs && node tests/rebase.test.mjs && node tests/product-map.test.mjs && node tests/workspace.test.mjs && node tests/change-plan.test.mjs && node tests/sot-query.test.mjs && node tests/content-review.test.mjs && node tests/semantic-review.test.mjs && node ../../../../evaluation/semantic-assurance/evaluate.mjs && node tests/migration.test.mjs && node tests/inspect.test.mjs && node tests/merge.test.mjs && node tests/embed-sot.test.mjs && node tests/demo-tree.test.mjs && node tests/host-smoke.mjs && node tests/host-runtime.test.mjs && node tests/host-acceptance.test.mjs && node tests/package-plugin.test.mjs && node tests/installed-plugin-smoke.mjs", "test:browser": "npm run check:browser", - "check": "node build.mjs && node --check .build/app.js && node tests/run-tests.mjs && node tests/tree.test.mjs && node tests/rebase.test.mjs && node tests/product-map.test.mjs && node tests/workspace.test.mjs && node tests/change-plan.test.mjs && node tests/sot-query.test.mjs && node tests/content-review.test.mjs && node tests/semantic-review.test.mjs && node ../../../../evaluation/semantic-assurance/evaluate.mjs && node tests/migration.test.mjs && node tests/inspect.test.mjs && node tests/merge.test.mjs && node tests/embed-sot.test.mjs && node tests/demo-tree.test.mjs && node tests/plugin-contract.mjs && node tests/host-smoke.mjs && node tests/host-runtime.test.mjs && node tests/package-plugin.test.mjs && node tests/installed-plugin-smoke.mjs", + "check": "node build.mjs && node --check .build/app.js && node tests/run-tests.mjs && node tests/tree.test.mjs && node tests/rebase.test.mjs && node tests/product-map.test.mjs && node tests/workspace.test.mjs && node tests/change-plan.test.mjs && node tests/sot-query.test.mjs && node tests/content-review.test.mjs && node tests/semantic-review.test.mjs && node ../../../../evaluation/semantic-assurance/evaluate.mjs && node tests/migration.test.mjs && node tests/inspect.test.mjs && node tests/merge.test.mjs && node tests/embed-sot.test.mjs && node tests/demo-tree.test.mjs && node tests/plugin-contract.mjs && node tests/host-smoke.mjs && node tests/host-runtime.test.mjs && node tests/host-acceptance.test.mjs && node tests/package-plugin.test.mjs && node tests/installed-plugin-smoke.mjs", "check:browser": "node tests/browser-flow.mjs && node tests/browser-ia.mjs && node tests/browser-catalog.mjs && node tests/browser-initiative.mjs && node tests/browser-map.mjs && node tests/browser-semantic.mjs && node tests/browser-file-connection.mjs", "check:repo": "node --test ../../../../tools/tests/path-leak.test.mjs ../../../../tools/tests/markdown-links.test.mjs ../../../../tools/tests/marketing-runtime.test.mjs && node ../../../../tools/check-path-leaks.mjs ../../../.. && node ../../../../tools/check-doc-links.mjs ../../../..", "check:all": "npm run check:repo && npm run check && npm run check:browser" diff --git a/plugins/vibespec/skills/vibespec/scripts/check-host-acceptance.mjs b/plugins/vibespec/skills/vibespec/scripts/check-host-acceptance.mjs new file mode 100644 index 0000000..2a723e2 --- /dev/null +++ b/plugins/vibespec/skills/vibespec/scripts/check-host-acceptance.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { validateHostEvidence } from "./lib/host-acceptance.mjs"; +import { runtimeBundleDigest } from "./lib/runtime-bundle.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const skillRoot = resolve(scriptDir, ".."); +const pluginRoot = resolve(skillRoot, "../.."); +const repositoryRoot = resolve(pluginRoot, "../.."); +const pluginVersion = JSON.parse(readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8")).version; + +export function checkHostAcceptance(directory, expectedVersion, expectedPluginDigest = runtimeBundleDigest(pluginRoot)) { + const evidenceDir = resolve(directory); + if (!existsSync(evidenceDir)) throw new Error(`missing host acceptance directory: ${evidenceDir}`); + if (basename(evidenceDir) !== `v${expectedVersion}`) throw new Error(`host acceptance directory must be named v${expectedVersion}`); + const files = readdirSync(evidenceDir).filter(name => name.endsWith(".json")).sort(); + if (!files.length) throw new Error(`no host acceptance records in ${evidenceDir}`); + const records = files.map(name => { + let evidence; + try { + evidence = JSON.parse(readFileSync(join(evidenceDir, name), "utf8")); + } catch (cause) { + throw new Error(`${name}: invalid JSON: ${cause.message}`); + } + try { + const valid = validateHostEvidence(evidence, expectedVersion); + if (valid.pluginDigest !== expectedPluginDigest) throw new Error(`pluginDigest does not match the current runtime bundle`); + return valid; + } catch (cause) { + throw new Error(`${name}: ${cause.message}`); + } + }); + const families = { + claude: records.filter(item => item.hostFamily === "claude").map(item => item.host), + codex: records.filter(item => item.hostFamily === "codex").map(item => item.host) + }; + if (!families.claude.length) throw new Error("missing Claude-family host acceptance evidence"); + if (!families.codex.length) throw new Error("missing Codex-family host acceptance evidence"); + return { kind: "vibespec-host-acceptance-gate", valid: true, version: expectedVersion, recordCount: records.length, families }; +} + +function argument(argv, name) { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : null; +} + +function main(argv) { + const json = argv.includes("--json"); + const version = argument(argv, "--version") || pluginVersion; + const directory = argument(argv, "--dir") || join(repositoryRoot, "acceptance", "hosts", `v${version}`); + try { + if (version !== pluginVersion) throw new Error(`requested release ${version} does not match installed plugin ${pluginVersion}`); + const result = checkHostAcceptance(directory, version); + if (json) console.log(JSON.stringify(result, null, 2)); + else console.log(`[host-acceptance-gate] PASS v${version}: Claude ${result.families.claude.join(", ")} | Codex ${result.families.codex.join(", ")}`); + } catch (cause) { + console.error(`[host-acceptance-gate] FAIL v${version}: ${cause.message}`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main(process.argv.slice(2)); diff --git a/plugins/vibespec/skills/vibespec/scripts/lib/host-acceptance.mjs b/plugins/vibespec/skills/vibespec/scripts/lib/host-acceptance.mjs new file mode 100644 index 0000000..4bd6078 --- /dev/null +++ b/plugins/vibespec/skills/vibespec/scripts/lib/host-acceptance.mjs @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +export const HOST_ACCEPTANCE_CONTRACT = "host-acceptance-v1"; + +export const HOST_FAMILIES = Object.freeze({ + "claude-code": "claude", + cowork: "claude", + "codex-cli": "codex", + "codex-desktop": "codex" +}); + +const topLevelKeys = ["kind", "contractVersion", "pluginVersion", "pluginDigest", "host", "hostFamily", "accepted", "checkedAt", "artifacts", "title", "schemaVersion"]; +const artifactKeys = ["file", "sha256"]; +const digestPattern = /^[a-f0-9]{64}$/; +const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function exactKeys(value, expected, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + const unknown = actual.filter(key => !wanted.includes(key)); + const missing = wanted.filter(key => !actual.includes(key)); + if (unknown.length) throw new Error(`${label} has unknown field(s): ${unknown.join(", ")}`); + if (missing.length) throw new Error(`${label} is missing field(s): ${missing.join(", ")}`); +} + +function portableFileName(value, label) { + if (typeof value !== "string" || !value || basename(value) !== value || /[\\/]/.test(value)) { + throw new Error(`${label} must be a portable artifact basename`); + } +} + +function artifact(value, label) { + exactKeys(value, artifactKeys, label); + portableFileName(value.file, `${label}.file`); + if (!digestPattern.test(value.sha256)) throw new Error(`${label}.sha256 must be a lowercase SHA-256 digest`); +} + +function containsAbsolutePath(value) { + if (typeof value === "string") return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value) || /^\/(?:Users|home|tmp|var)\//.test(value); + if (Array.isArray(value)) return value.some(containsAbsolutePath); + if (value && typeof value === "object") return Object.values(value).some(containsAbsolutePath); + return false; +} + +export function sha256File(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +export function hostFamily(host) { + const family = HOST_FAMILIES[host]; + if (!family) throw new Error(`unsupported host ${JSON.stringify(host)}`); + return family; +} + +export function validateHostEvidence(evidence, expectedVersion = null) { + exactKeys(evidence, topLevelKeys, "host acceptance evidence"); + if (containsAbsolutePath(evidence)) throw new Error("host acceptance evidence must not contain an absolute local path"); + if (evidence.kind !== "vibespec-host-acceptance") throw new Error("host acceptance evidence.kind is invalid"); + if (evidence.contractVersion !== HOST_ACCEPTANCE_CONTRACT) throw new Error(`unsupported acceptance contract ${JSON.stringify(evidence.contractVersion)}`); + if (!versionPattern.test(evidence.pluginVersion)) throw new Error("host acceptance evidence.pluginVersion is invalid"); + if (!digestPattern.test(evidence.pluginDigest)) throw new Error("host acceptance evidence.pluginDigest must be a lowercase SHA-256 digest"); + if (expectedVersion && evidence.pluginVersion !== expectedVersion) { + throw new Error(`host acceptance evidence.pluginVersion ${evidence.pluginVersion} does not match release ${expectedVersion}`); + } + const family = hostFamily(evidence.host); + if (evidence.hostFamily !== family) throw new Error(`hostFamily must be ${family} for ${evidence.host}`); + if (evidence.accepted !== true) throw new Error("host acceptance evidence must record accepted: true"); + if (typeof evidence.checkedAt !== "string" || Number.isNaN(Date.parse(evidence.checkedAt)) || new Date(evidence.checkedAt).toISOString() !== evidence.checkedAt) { + throw new Error("host acceptance evidence.checkedAt must be an ISO timestamp"); + } + if (typeof evidence.title !== "string" || !evidence.title.trim()) throw new Error("host acceptance evidence.title is required"); + if (typeof evidence.schemaVersion !== "string" || !evidence.schemaVersion) throw new Error("host acceptance evidence.schemaVersion is required"); + exactKeys(evidence.artifacts, ["sot", "html"], "host acceptance evidence.artifacts"); + artifact(evidence.artifacts.sot, "host acceptance evidence.artifacts.sot"); + artifact(evidence.artifacts.html, "host acceptance evidence.artifacts.html"); + return evidence; +} diff --git a/plugins/vibespec/skills/vibespec/scripts/lib/runtime-bundle.mjs b/plugins/vibespec/skills/vibespec/scripts/lib/runtime-bundle.mjs new file mode 100644 index 0000000..aa8d62f --- /dev/null +++ b/plugins/vibespec/skills/vibespec/scripts/lib/runtime-bundle.mjs @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +export const runtimeBundleEntries = Object.freeze([ + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", + "LICENSE", + "README.md", + "skills/vibespec/SKILL.md", + "skills/vibespec/agents", + "skills/vibespec/assets/viewer.html", + "skills/vibespec/references", + "skills/vibespec/scripts", + "skills/vibespec/src/js/00-config.js", + "skills/vibespec/src/js/05-c14n.js", + "skills/vibespec/src/js/20-state.js" +]); + +function filesUnder(path) { + if (!statSync(path).isDirectory()) return [path]; + return readdirSync(path, { withFileTypes: true }) + .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + .flatMap(entry => filesUnder(join(path, entry.name))); +} + +const textExtensions = new Set([".html", ".js", ".json", ".md", ".mjs", ".txt", ".yaml", ".yml"]); + +function canonicalBytes(file) { + const bytes = readFileSync(file); + const name = file.split(/[\\/]/).at(-1); + if (textExtensions.has(extname(file).toLowerCase()) || name === "LICENSE") return Buffer.from(bytes.toString("utf8").replaceAll("\r\n", "\n")); + return bytes; +} + +export function runtimeBundleDigest(pluginRoot) { + const hash = createHash("sha256"); + const files = runtimeBundleEntries.flatMap(entry => filesUnder(join(pluginRoot, entry))) + .map(file => ({ file, portablePath: relative(pluginRoot, file).replaceAll("\\", "/") })) + .sort((a, b) => a.portablePath < b.portablePath ? -1 : a.portablePath > b.portablePath ? 1 : 0); + for (const { file, portablePath } of files) { + hash.update(portablePath); + hash.update("\0"); + hash.update(canonicalBytes(file)); + hash.update("\0"); + } + return hash.digest("hex"); +} diff --git a/plugins/vibespec/skills/vibespec/scripts/verify-host-output.mjs b/plugins/vibespec/skills/vibespec/scripts/verify-host-output.mjs index 43daeb8..a407fec 100644 --- a/plugins/vibespec/skills/vibespec/scripts/verify-host-output.mjs +++ b/plugins/vibespec/skills/vibespec/scripts/verify-host-output.mjs @@ -1,14 +1,18 @@ #!/usr/bin/env node import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { stableStringify } from "./lib/c14n.mjs"; +import { HOST_ACCEPTANCE_CONTRACT, hostFamily, sha256File, validateHostEvidence } from "./lib/host-acceptance.mjs"; +import { runtimeBundleDigest } from "./lib/runtime-bundle.mjs"; import { validateSot } from "./validate-sot.mjs"; -const hosts = new Set(["claude-code", "cowork", "codex-cli", "codex-desktop"]); +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = resolve(scriptDir, "../../.."); +const pluginVersion = JSON.parse(readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8")).version; -export function verifyHostOutput(sotPath, htmlPath, host) { - if (!hosts.has(host)) throw new Error(`unsupported host ${JSON.stringify(host)}`); +export function verifyHostOutput(sotPath, htmlPath, host, options = {}) { + const family = hostFamily(host); const sotFile = resolve(sotPath); const htmlFile = resolve(htmlPath); if (!existsSync(sotFile)) throw new Error(`missing SOT: ${sotFile}`); @@ -21,16 +25,22 @@ export function verifyHostOutput(sotPath, htmlPath, host) { if (!match) throw new Error("HTML has no embedded-sot payload"); const embedded = JSON.parse(match[1]); if (stableStringify(embedded) !== stableStringify(sot)) throw new Error("HTML embedded SOT differs from the JSON file"); - return { + return validateHostEvidence({ kind: "vibespec-host-acceptance", + contractVersion: HOST_ACCEPTANCE_CONTRACT, + pluginVersion, + pluginDigest: runtimeBundleDigest(pluginRoot), host, + hostFamily: family, accepted: true, - checkedAt: new Date().toISOString(), - sot: sotFile, - html: htmlFile, + checkedAt: options.checkedAt || new Date().toISOString(), + artifacts: { + sot: { file: basename(sotFile), sha256: sha256File(sotFile) }, + html: { file: basename(htmlFile), sha256: sha256File(htmlFile) } + }, title: sot.title, schemaVersion: sot.schemaVersion - }; + }); } function main(argv) { diff --git a/plugins/vibespec/skills/vibespec/tests/host-acceptance.test.mjs b/plugins/vibespec/skills/vibespec/tests/host-acceptance.test.mjs new file mode 100644 index 0000000..bbd6636 --- /dev/null +++ b/plugins/vibespec/skills/vibespec/tests/host-acceptance.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { checkHostAcceptance } from "../scripts/check-host-acceptance.mjs"; +import { verifyHostOutput } from "../scripts/verify-host-output.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const skillRoot = join(here, ".."); +const tempDir = mkdtempSync(join(tmpdir(), "vibespec-host-acceptance-")); +const pluginVersion = JSON.parse(readFileSync(join(skillRoot, "..", "..", ".claude-plugin", "plugin.json"), "utf8")).version; +const nextPatchVersion = pluginVersion.replace(/(\d+)$/, value => String(Number(value) + 1)); + +function createOutput(name) { + const sotPath = join(tempDir, `${name}.sot.json`); + const htmlPath = join(tempDir, `${name}.html`); + writeFileSync(sotPath, readFileSync(join(here, "fixtures", "valid-minimal.sot.json"))); + const embedded = spawnSync(process.execPath, [join(skillRoot, "scripts", "embed-sot.mjs"), join(skillRoot, "assets", "viewer.html"), sotPath, htmlPath], { encoding: "utf8" }); + assert.equal(embedded.status, 0, embedded.stderr || embedded.stdout); + return { sotPath, htmlPath }; +} + +function writeEvidence(dir, name, evidence) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${name}.json`), JSON.stringify(evidence, null, 2)); +} + +try { + const output = createOutput("meeting-room"); + const claude = verifyHostOutput(output.sotPath, output.htmlPath, "claude-code", { checkedAt: "2026-08-10T00:00:00.000Z" }); + assert.equal(claude.contractVersion, "host-acceptance-v1"); + assert.equal(claude.pluginVersion, pluginVersion); + assert.match(claude.pluginDigest, /^[a-f0-9]{64}$/); + assert.equal(claude.hostFamily, "claude"); + assert.equal(claude.artifacts.sot.file, "meeting-room.sot.json"); + assert.match(claude.artifacts.sot.sha256, /^[a-f0-9]{64}$/); + assert.match(claude.artifacts.html.sha256, /^[a-f0-9]{64}$/); + assert.equal(JSON.stringify(claude).includes(tempDir), false, "portable evidence must not expose its source directory"); + assert.equal(JSON.stringify(claude).includes("\\"), false, "portable evidence must not contain Windows path separators"); + console.log("[host-acceptance] PASS verifier emits versioned, hashed, portable evidence"); + + const codex = verifyHostOutput(output.sotPath, output.htmlPath, "codex-desktop", { checkedAt: "2026-08-10T00:01:00.000Z" }); + const validDir = join(tempDir, "valid", `v${pluginVersion}`); + writeEvidence(validDir, "claude-code", claude); + writeEvidence(validDir, "codex-desktop", codex); + const accepted = checkHostAcceptance(validDir, pluginVersion); + assert.equal(accepted.valid, true); + assert.deepEqual(accepted.families, { claude: ["claude-code"], codex: ["codex-desktop"] }); + console.log("[host-acceptance] PASS one Claude-family and one Codex-family record satisfy the release gate"); + + const missingDir = join(tempDir, "missing", `v${pluginVersion}`); + writeEvidence(missingDir, "claude-code", claude); + assert.throws(() => checkHostAcceptance(missingDir, pluginVersion), /missing Codex-family/i); + console.log("[host-acceptance] PASS release gate rejects a missing host family"); + + const mismatchDir = join(tempDir, "mismatch", `v${nextPatchVersion}`); + writeEvidence(mismatchDir, "claude-code", claude); + writeEvidence(mismatchDir, "codex-desktop", codex); + assert.throws(() => checkHostAcceptance(mismatchDir, nextPatchVersion), /pluginVersion/i); + console.log("[host-acceptance] PASS release gate rejects evidence from another plugin version"); + + assert.throws(() => checkHostAcceptance(validDir, pluginVersion, "0".repeat(64)), /pluginDigest/); + console.log("[host-acceptance] PASS release gate rejects evidence from different runtime bytes"); + + const privateDir = join(tempDir, "private", `v${pluginVersion}`); + const privatePath = ["C:", "Users", "someone", "meeting-room.sot.json"].join("\\"); + writeEvidence(privateDir, "claude-code", { ...claude, title: privatePath }); + writeEvidence(privateDir, "codex-desktop", codex); + assert.throws(() => checkHostAcceptance(privateDir, pluginVersion), /absolute local path/i); + console.log("[host-acceptance] PASS release gate rejects private or non-contract path fields"); + + const cli = spawnSync(process.execPath, [join(skillRoot, "scripts", "check-host-acceptance.mjs"), "--version", pluginVersion, "--dir", validDir, "--json"], { encoding: "utf8" }); + assert.equal(cli.status, 0, cli.stderr || cli.stdout); + assert.equal(JSON.parse(cli.stdout).valid, true); + console.log("[host-acceptance] PASS release gate CLI validates the committed evidence directory"); + + const wrongRelease = spawnSync(process.execPath, [join(skillRoot, "scripts", "check-host-acceptance.mjs"), "--version", nextPatchVersion, "--dir", mismatchDir], { encoding: "utf8" }); + assert.equal(wrongRelease.status, 1); + assert.match(wrongRelease.stderr, /does not match installed plugin/); + console.log("[host-acceptance] PASS release gate CLI binds evidence to the installed plugin version"); +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/plugins/vibespec/skills/vibespec/tests/package-plugin.test.mjs b/plugins/vibespec/skills/vibespec/tests/package-plugin.test.mjs index a33732c..23ea22f 100644 --- a/plugins/vibespec/skills/vibespec/tests/package-plugin.test.mjs +++ b/plugins/vibespec/skills/vibespec/tests/package-plugin.test.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { packagePlugin } from "../package-plugin.mjs"; +import { runtimeBundleDigest } from "../scripts/lib/runtime-bundle.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const temp = mkdtempSync(join(tmpdir(), "vibespec-package-")); @@ -19,6 +20,7 @@ function run(script, args, cwd) { try { packagePlugin(bundle); const skill = join(bundle, "skills", "vibespec"); + const sourcePlugin = join(skillRootFromHere(), "..", ".."); for (const path of [ ".codex-plugin/plugin.json", ".claude-plugin/plugin.json", @@ -36,12 +38,22 @@ try { assert.equal(existsSync(join(bundle, path)), false, `development artifact leaked into bundle: ${path}`); } assert.deepEqual(readdirSync(join(skill, "src", "js")).sort(), ["00-config.js", "05-c14n.js", "20-state.js"]); + assert.equal(runtimeBundleDigest(bundle), runtimeBundleDigest(sourcePlugin), "packaged runtime digest must match the source release candidate"); + const digestBeforeLineEndingChange = runtimeBundleDigest(bundle); + const bundledReadme = join(bundle, "README.md"); + writeFileSync(bundledReadme, readFileSync(bundledReadme, "utf8").replace(/\r?\n/g, "\r\n")); + assert.equal(runtimeBundleDigest(bundle), digestBeforeLineEndingChange, "runtime digest must ignore platform line-ending conversion"); const product = join(temp, "product"); mkdirSync(product, { recursive: true }); cpSync(join(here, "fixtures", "tree", "main.sot.json"), join(product, "main.sot.json")); run(join(skill, "scripts", "doctor.mjs"), [product, "--json"], product); run(join(skill, "scripts", "validate-sot.mjs"), [join(product, "main.sot.json")], product); + const viewer = join(product, "main.html"); + run(join(skill, "scripts", "embed-sot.mjs"), [join(skill, "assets", "viewer.html"), join(product, "main.sot.json"), viewer], product); + const accepted = JSON.parse(run(join(skill, "scripts", "verify-host-output.mjs"), [join(product, "main.sot.json"), viewer, "--host", "codex-cli", "--json"], product)); + assert.equal(accepted.hostFamily, "codex"); + assert.match(accepted.artifacts.sot.sha256, /^[a-f0-9]{64}$/); run(join(skill, "scripts", "review-semantic.mjs"), [join(product, "main.sot.json"), "--json"], product); run(join(skill, "scripts", "workspace.mjs"), [product], product); run(join(skill, "scripts", "migrate-sot.mjs"), [join(product, "main.sot.json"), "--out", join(product, "migrated.sot.json")], product); @@ -49,3 +61,7 @@ try { } finally { rmSync(temp, { recursive: true, force: true }); } + +function skillRootFromHere() { + return join(here, ".."); +}