Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .abcd/config/version-location.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"outcome": "accept",
"blocked": false,
"manifest_path": ".claude-plugin/plugin.json",
"json_pointer": "/version",
"version_property_clause": "claude-code-plugin-manifest.schema.json#/properties/version",
"marketplace_source_resolution_base": "repo_root",
"marketplace_source_to_root": "./"
}
16 changes: 9 additions & 7 deletions .abcd/development/brief/04-surfaces/04-launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
The shipped verb surface is the `--dry-run` flag on `abcd launch` — a read-only preview of the bundle and gates. Bare `abcd launch` never mutates state: it refuses (exit 1) with a hint to pass `--dry-run`; a bare-as-status render is a design target, unshipped. The sub-verb design:

- **`/abcd:launch ship`** — **partly shipped: the RELEASE CUT only** (itd-73 derived versioning + itd-67's changelog slice). `abcd launch ship` derives the version and the record set from what shipped since the newest tag, runs the surface guardrail, and — with `--changelog-json` — validates the host-composed prose against the record set (the completeness bijection) and writes the dated `CHANGELOG.md` heading `.github/workflows/auto-release.yml` turns into a tag; `commands/abcd/launch.md` carries the emit → compose → ingest orchestration and the `release-changelog-composer` agent it dispatches. The ARTEFACT half stays a design target (itd-65 gate suite + itd-72 publishing): `internal/core/launch` carries the `Ship` engine, unwired, and the verb neither packages, commits, tags, nor publishes. The design for that half: cut a curated release artefact from the one repo: run pre-flight gates, filter the artefact (default-deny, `.abcd/**` excluded by packaging), stamp the version, and on a `v*` tag publish a GitHub Release ([adr-28](../../decisions/adrs/0028-single-repo-curated-release.md)). The flow described in §§ 1–6 below is this sub-verb's behaviour. Flag-shaped modifiers `--allow-dirty` and `--allow-doc-warnings` belong to this sub-verb's design; the shipped verb accepts only `--dry-run` and the global `--json`. There is no version flag — the version is derived, never authored ([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md), see [§ 3](#3-versioning--marketplace)).
- **`/abcd:launch dry-run`** — shipped as the `--dry-run` flag (the plugin command `commands/abcd/launch.md` maps its `[dry-run]` argument hint onto `abcd launch --dry-run`; the binary has no `dry-run` subcommand). **Report-only preview, always exit-0** (a preview never blocks). It runs the parts of the pre-flight suite that exist today: as of spc-64 the **secret + PII scan gate** (the native scanners, see [§ 1](#1-pre-flight-gates)) runs for real in report-only mode and prints what it *would* refuse on (a finding, or a fail-closed reason such as "scanner unavailable"); the remaining gates (marker-block, `plugin.json` parse, documentation-auditor) are the gate-suite intent's (itd-65) and render as "(not yet implemented)". It also produces the would-be artefact manifest, without writing the release artefact. dry-run is **not** "ship minus publish": running the *full* gate suite and **hard-failing** on a finding (exit non-zero) is the full `ship` verb's behaviour (itd-65 + itd-72), not dry-run's.
- **`/abcd:launch dry-run`** — shipped as the `--dry-run` flag (the plugin command `commands/abcd/launch.md` maps its `[dry-run]` argument hint onto `abcd launch --dry-run`; the binary has no `dry-run` subcommand). **Report-only preview, always exit-0** (a preview never blocks). It runs the parts of the pre-flight suite that exist today: as of spc-64 the **secret + PII scan gate** (the native scanners, see [§ 1](#1-pre-flight-gates)) runs for real in report-only mode and prints what it *would* refuse on (a finding, or a fail-closed reason such as "scanner unavailable"); the **installability smoke** runs for real at its light tier (see [§ 1](#1-pre-flight-gates)); the remaining gates (marker-block, documentation-auditor) are the gate-suite intent's (itd-65) and render as "(not yet implemented)". It also produces the would-be artefact manifest, without writing the release artefact. dry-run is **not** "ship minus publish": running the *full* gate suite and **hard-failing** on a finding (exit non-zero) is the full `ship` verb's behaviour (itd-65 + itd-72), not dry-run's.

## 1. Pre-flight gates

Expand All @@ -17,7 +17,10 @@ The shipped verb surface is the `--dry-run` flag on `abcd launch` — a read-onl
- **TruffleHog** — opt-in deep scan when `scan.deep=true` — hard-fail (live credential verification)
- **Hook compliance** check — warn-fail
- **Marker block sanity** — hard-fail on malformed
- **`plugin.json` parse + `marketplace.json` references** — hard-fail
- **Installability smoke** (`plugin.json` parse + `marketplace.json` references) — hard-fail. Two tiers over ONE surface resolution (`internal/core/launch/installsurface.go`), so the deeper tier upgrades the assertions and never redefines the surface:
- **The declared surface** is the UNION of two registers, because the plugin-manifest schema defines every explicit key as declaring entries *in addition to* the convention directory: **convention** — `commands/**/*.md`, `agents/*.md` (a flat glob; iss-110 is the evidence), `skills/*/SKILL.md`, `hooks/hooks.json` — and **manifest** — the optional `commands`/`agents`/`skills`/`hooks` keys in `plugin.json`. A hook's `$CLAUDE_PLUGIN_ROOT`-rooted command adds the payload file it invokes; the plugin executable itself is install-supplied, so its absence from the payload proves nothing. Every entry records which register declared it. This is NOT the compatibility surface (`internal/core/surface`), which records manifest KEYS and discards values; installability is the mirror question, over the values.
- **Light tier** (itd-67, shipped) — both manifests parse, each local marketplace `source` resolves to a manifest whose name matches the listing, and every declared path the payload is responsible for is carried. Resolution reads the **resolved bundle**, so a file present in the tree but excluded from the payload fails here. `dry-run` reports it; `RenderPayload` refuses on it, because the render is the only step that materialises an artefact.
- **Deep tier** (itd-66, deferred) — over the same resolved list: import every shipped entrypoint and render each command's help/frontmatter in an isolated subprocess rooted at the rendered payload.
- **Dirty tree** — refuse unless `--allow-dirty`
- **OWASP / vulnerability check** (folded into the pre-flight suite) — warn-fail
- **Documentation auditor** (subagent) — runs over `docs/` to verify user-facing documentation is well-formed before release — warn-fail
Expand Down Expand Up @@ -79,11 +82,10 @@ the spc-77.1 decision artifact
(`.abcd/config/version-location.json`)
as `manifest_path` + `json_pointer` (see
[adr-19](../../decisions/adrs/0019-plugin-json-version-carve-out.md)). The
artifact is absent from the tree, and the shipped lockstep checker fails closed
on it — `dry-run` reports the lockstep contract unreadable — until spc-77.1
records the decision; a
`blocked: true` decision has no schema-valid location, so version-writing
refuses and the escalation stands. Concretely, `ship`:
artifact records spc-77.1's ACCEPT outcome — `.claude-plugin/plugin.json` at
`/version` — and the shipped lockstep checker fails closed when it is missing or
malformed; a `blocked: true` decision has no schema-valid location, so
version-writing refuses and the escalation stands. Concretely, `ship`:

1. Stamps the bumped version into the **release artefact** at the selected
`manifest_path` + `json_pointer` — the manifest renderer reads the decision
Expand Down
7 changes: 7 additions & 0 deletions .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@
"type": "string",
"required": false,
"hidden": false
},
{
"name": "payload-dir",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
}
]
},
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ and copy the binary anywhere on your `PATH`. Every release is built and
published by CI from the exact tagged commit, with the checksums generated
over the same bytes that are uploaded.

## Plugin

This repository is also its own plugin marketplace, so a compatible agent
harness can install the `/abcd:*` surface — the commands under
[`commands/`](commands/), the agents under [`agents/`](agents/) and the hook
wiring in [`hooks/`](hooks/) — straight from it. Add the marketplace, then
install the plugin:

```text
/plugin marketplace add REPPL/abcd-cli
/plugin install abcd@abcd-marketplace
```

`abcd-marketplace` is the marketplace name declared in
[`.claude-plugin/`](.claude-plugin/); `abcd` is the single plugin it lists,
sourced from the repository root. Pull the current state of the marketplace with:

```text
/plugin update abcd
```

The marketplace is served from the repository itself, so an install tracks the
repository rather than a versioned artefact: the manifests here carry no version
key, and a release publishes the `abcd` binaries and their checksums. The
commands drive the `abcd` binary, so keep the [install](#install) above in place
alongside the plugin.

## Build

```bash
Expand Down
17 changes: 17 additions & 0 deletions commands/abcd/launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ Then summarise the JSON for the user:
- `version` — the version the release would carry.
- `files` — how many files the bundle would include.
- `scan.hard_fails` — secret/PII findings that would block the release.
- `smoke.ok` — whether the payload would install: both plugin manifests parse,
the marketplace source resolves, and every declared command, agent, skill and
hook path is carried. `smoke.findings` names any path that is not.
- `would_publish` — whether every gate passes.
- `would_refuse_on` — if non-empty, the gates that would refuse, so the user
knows what to fix before a real launch.
Expand Down Expand Up @@ -92,8 +95,22 @@ Write the agent's payload to a file and hand it back to the binary:

```bash
abcd launch ship --changelog-json <path> # or - for stdin
abcd launch ship --changelog-json <path> --payload-dir <dir> # also stage the payload
```

With `--payload-dir` the binary additionally stages the release payload in that
directory — an empty directory outside the repository — with the derived version
stamped into the payload's copies of `plugin.json` and `marketplace.json`. The
repository's own manifests are never touched: they carry no version, and the
version belongs to the artefact. The staged payload is proved consistent before
the command returns, so a stamp that missed a pinned location is a refusal rather
than a published half-state. Every refusal the staging step can make is checked
BEFORE the dated heading is written, and a refusal that slips past that check
rolls the heading back — so a ship that exits non-zero leaves no release record
behind for the next attempt to trip over. Without the flag nothing is staged;
`--payload-dir` on its own (no `--changelog-json`) is an operand error, because
only a completed cut has a version to stamp.

The binary re-derives the cut, then proves the prose describes it — the
**completeness bijection**: the set of record ids the payload cites must equal
`(added ∪ removed)` minus the records marked `in_changelog: false`. On any
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,13 @@ Preview the public launch bundle and release gates (read-only)

Cut a release: derive the version and the record set from what shipped (exit 1 when the cut refuses)

**Usage:** `abcd launch ship [--changelog-json <file|->] [flags]`
**Usage:** `abcd launch ship [--changelog-json <file|->] [--payload-dir <dir>] [flags]`

**Flags:**

```
--changelog-json string path to the host-composed changelog JSON (or - for stdin); absent runs the deterministic emit step
--payload-dir string stage the versioned release payload in this directory (must be empty and outside the repository)
```

### `abcd memory`
Expand Down
80 changes: 29 additions & 51 deletions internal/core/launch/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ const versionLocationRelPath = ".abcd/config/version-location.json"

// DryRunRequest is the input to a dry-run.
type DryRunRequest struct {
RepoRoot string
VersionOverride string // --version; empty → read from the resolved manifests
ExistingTags []Semver // injected; nil → default `git tag -l v*` provider
RepoRoot string
// Version is the release version this launch would publish, SUPPLIED by the
// caller. adr-19 leaves no version key in the source tree, so there is
// nothing here for the core to read: the version is a fact about the release
// cut, and the front door that knows the cut injects it. Empty is honest —
// it means the caller could not name one, and retention says so.
Version string
ExistingTags []Semver // injected; nil → default `git tag -l v*` provider
}

// GateSummary records one gate's dry-run disposition.
Expand All @@ -30,6 +35,7 @@ type DryRunReport struct {
Scan scanner.ScanResult `json:"scan"`
Lockstep LockstepResult `json:"lockstep"`
Retention RetentionPlan `json:"retention"`
Smoke SmokeReport `json:"smoke"`
Gates []GateSummary `json:"gates"`
WouldPublish bool `json:"would_publish"` // always false in dry-run
WouldRefuseOn []string `json:"would_refuse_on,omitempty"`
Expand All @@ -51,23 +57,33 @@ func DryRun(req DryRunRequest) (DryRunReport, error) {
scan := scanBundle(req.RepoRoot, bundle)
report.Scan = scan

// The DEV polarity is the one the SOURCE TREE must satisfy: adr-19 keeps the
// version keys out of the committed manifests, so a public check here would
// accuse a correct repository of drift and prescribe the exact key the ADR
// forbids. The public polarity belongs over the rendered payload, where
// RenderPayload applies it to its own output.
vlPath := filepath.Join(req.RepoRoot, versionLocationRelPath)
lockstep := CheckLockstep(TreePublic, req.RepoRoot, vlPath)
lockstep := CheckLockstep(TreeDev, req.RepoRoot, vlPath)
report.Lockstep = lockstep

version := resolveVersion(req.VersionOverride, req.RepoRoot, lockstep)
report.Version = version
report.Version = req.Version
report.Retention = computeRetentionForReport(req.Version, req)

report.Retention = computeRetentionForReport(version, req)
// The smoke reads the RESOLVED BUNDLE, not the working tree: a file present
// in the tree but excluded from the payload is exactly the break it exists
// to catch. It subsumes itd-65's placeholder `plugin.json-parse` gate, which
// asserted a strict subset of what the light tier asserts.
smoke := SmokeLight(NewBundleTree(bundle))
report.Smoke = smoke

report.Gates = []GateSummary{
{Name: "secret+pii-scan", Status: "ran", Detail: scanDetail(scan)},
{Name: "marker-block", Status: "not_implemented", Detail: "Phase-5 deferred"},
{Name: "plugin.json-parse", Status: "not_implemented", Detail: "Phase-5 deferred"},
{Name: "installability-smoke", Status: "ran", Detail: smokeDetail(smoke)},
{Name: "documentation-auditor", Status: "not_implemented", Detail: "Phase-5 deferred"},
}

report.WouldRefuseOn = wouldRefuseOn(bundle, scan, lockstep, report.Retention)
report.WouldRefuseOn = wouldRefuseOn(bundle, scan, lockstep, report.Retention, smoke)
report.WouldPublish = false
return report, nil
}
Expand All @@ -86,46 +102,6 @@ func scanBundle(repoRoot string, bundle Bundle) scanner.ScanResult {
return res
}

// resolveVersion picks the version input: an explicit override, else the
// lockstep primary version when readable.
func resolveVersion(override, repoRoot string, lockstep LockstepResult) string {
if override != "" {
return override
}
if lockstep.OK {
if v := primaryVersion(repoRoot); v != "" {
return v
}
}
return ""
}

// primaryVersion reads the primary version string via the version-location
// contract (best-effort; empty when unreadable).
func primaryVersion(repoRoot string) string {
vlPath := filepath.Join(repoRoot, versionLocationRelPath)
decision, err := loadJSON(vlPath)
if err != nil {
return ""
}
mp, ptr, verr := validateVersionLocation(decision)
if verr != "" {
return ""
}
doc, err := loadJSON(filepath.Join(repoRoot, mp))
if err != nil {
return ""
}
v, present := resolvePointer(doc, ptr)
if !present {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}

// computeRetentionForReport builds the retention preview, resolving the existing
// tag list from the injected slice or the default git provider.
func computeRetentionForReport(version string, req DryRunRequest) RetentionPlan {
Expand All @@ -144,8 +120,9 @@ func computeRetentionForReport(version string, req DryRunRequest) RetentionPlan
}

// wouldRefuseOn collects everything a real ship WOULD block on: scan hard-fails,
// bundle rejections, lockstep drift/unreadable, and retention refusal.
func wouldRefuseOn(bundle Bundle, scan scanner.ScanResult, lockstep LockstepResult, retention RetentionPlan) []string {
// bundle rejections, lockstep drift/unreadable, retention refusal, and an
// uninstallable declared surface.
func wouldRefuseOn(bundle Bundle, scan scanner.ScanResult, lockstep LockstepResult, retention RetentionPlan, smoke SmokeReport) []string {
var reasons []string
if scan.Unavailable {
reasons = append(reasons, "scanner unavailable: "+scan.UnavailableReason)
Expand All @@ -165,6 +142,7 @@ func wouldRefuseOn(bundle Bundle, scan scanner.ScanResult, lockstep LockstepResu
if retention.Refused {
reasons = append(reasons, "retention refused: "+retention.RefusalReason)
}
reasons = append(reasons, smokeRefusals(smoke)...)
return reasons
}

Expand Down
Loading