diff --git a/.abcd/development/agents/CHANGELOG.md b/.abcd/development/agents/CHANGELOG.md index 2f962c278..4a8b16e61 100644 --- a/.abcd/development/agents/CHANGELOG.md +++ b/.abcd/development/agents/CHANGELOG.md @@ -12,6 +12,16 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. +## 2026-10-11 (itd-2610090831227812 — the changelog verb retires into the launch preview) + +### release-changelog-composer 0.4.3 + +PATCH: the read-only preview the definition names as a source of the cut is +now the `cut` member of `abcd launch --dry-run --json`, since `abcd changelog` +retired into the launch preview (spc-2610100613109045, step 3). The cut's +shape and every rule for composing from it are unchanged. Unmeasured, in the +`0.x` band. + ## 2026-10-10 (iss-2610091942156774 — sub-agents are taught the rm the guard refuses) ### cold-reading-comparative 0.1.4 diff --git a/.abcd/development/brief/01-product/04-scope.md b/.abcd/development/brief/01-product/04-scope.md index 2ab37eeb1..22b47ac56 100644 --- a/.abcd/development/brief/01-product/04-scope.md +++ b/.abcd/development/brief/01-product/04-scope.md @@ -16,7 +16,7 @@ The scope this brief covers is the intents on the lifecycle shelves: what has sh - `/abcd` — top-level where-am-i status board (per itd-20) — see [`04-surfaces/08-abcd.md`](../04-surfaces/08-abcd.md) - `/abcd:reflect` — release retrospective, a design target (itd-24, planned) — see [`04-surfaces/09-reflect.md`](../04-surfaces/09-reflect.md) -**Operator-internal commands** (wiring rather than user-facing surface): `/abcd:run` — the itd-29 autonomous-run operator surface (`status`/`pause`/`resume`/`preflight`; read-mostly over the pluggable autonomous-run seam ([adr-27](../../decisions/adrs/0027-autonomous-run-pluggable-seam.md)), v1 never starts or kills the loop) — is not built: itd-29 is superseded (by itd-2609201916151817, planned), and no `run` verb or `commands/run.md` is on any shipped surface. The operator-internal verbs the binary does register are `changelog`, `completion`, `hook`, `rules`, `spec` and `statusline`, and the class is defined by the absence of a `commands/` file rather than by the presence of one. See [`04-surfaces/README.md`](../04-surfaces/README.md) for the user-facing-vs-operator-internal boundary and the record that delivered each. +**Operator-internal commands** (wiring rather than user-facing surface): `/abcd:run` — the itd-29 autonomous-run operator surface (`status`/`pause`/`resume`/`preflight`; read-mostly over the pluggable autonomous-run seam ([adr-27](../../decisions/adrs/0027-autonomous-run-pluggable-seam.md)), v1 never starts or kills the loop) — is not built: itd-29 is superseded (by itd-2609201916151817, planned), and no `run` verb or `commands/run.md` is on any shipped surface. The operator-internal verbs the binary does register are `completion`, `hook`, `rules`, `spec` and `statusline`, and the class is defined by the absence of a `commands/` file rather than by the presence of one. See [`04-surfaces/README.md`](../04-surfaces/README.md) for the user-facing-vs-operator-internal boundary and the record that delivered each. **Intents: derived, never hand-counted here.** The shelves are the single source of what is shipped, committed and benched, and this page keeps no static copy of diff --git a/.abcd/development/brief/02-constraints/04-naming.md b/.abcd/development/brief/02-constraints/04-naming.md index a0150fedd..8866157b7 100644 --- a/.abcd/development/brief/02-constraints/04-naming.md +++ b/.abcd/development/brief/02-constraints/04-naming.md @@ -52,10 +52,10 @@ The exemptions carrying a rationale of their own: board. The remaining surfaces are exempt for the plain reason that no maritime cognate -adds meaning: `banlist`, `changelog`, `consult`, `decide`, `docs`, `guard`, +adds meaning: `banlist`, `consult`, `decide`, `docs`, `guard`, `history`, `ideate`, `identity`, `ingest`, `lint`, `memory`, -`prepare-this-repo`, `reading`, `rules`, `site`, `spec`, `update`, and -`version`. They are registered here so the exemption is on the record. +`prepare-this-repo`, `reading`, `rules`, `site`, `spec`, and `update`. They +are registered here so the exemption is on the record. **Reserved meta-development commands** (later phases; named now to prevent collisions): diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 0ee5e6493..3a4a2feee 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -882,7 +882,7 @@ vintage and staleness, and `superseded_root` when the note applies; the plugin command reads those from exactly this render, so they are a contract with the plugin surface rather than a convenience. The note is the one the version flag carries, under the same conditions -([`12-version.md`](12-version.md#a-superseded-plugin-root-names-itself)). +([`08-abcd.md`](08-abcd.md#a-superseded-plugin-root-names-itself)). **The dry run** renders the detection envelope as JSON and nothing else, so the plugin command can summarise state off the folder kind and the gaps and name diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index ab0a06627..9f742e259 100644 --- a/.abcd/development/brief/04-surfaces/04-launch.md +++ b/.abcd/development/brief/04-surfaces/04-launch.md @@ -12,9 +12,16 @@ a dated changelog heading and the release page and stops, and CI and a human tak And it never ships the design record: the payload is default-deny with the whole `.abcd/` namespace excluded structurally, so no include line can put it back. -The preview always exits 0, because a preview never blocks, and its one write is -its pre-flight report in the gitignored local tier. Bare `abcd launch` refuses -with a hint to ask for it. A repository that declares `kind: plugin` with no +The preview never blocks on what it finds: every finding a cut would refuse +on, and a refused cut, is reported and the preview exits 0. It exits non-zero +only when it cannot run at all: an undeclared or unknown artefact kind and a +plugin with no launch payload are refused, and a named baseline that is not a +release tag exits 2. Its one write is its pre-flight +report in the gitignored local tier. Beside the bundle report it renders the +release cut the next ship would make, the deterministic emit with no prose +(spc-2610100613109045, decision 5): the preview is the one way to see the cut +without starting one, and there is no separate changelog verb. Bare +`abcd launch` refuses with a hint to ask for the preview. A repository that declares `kind: plugin` with no launch payload (`.abcd/config/launch-payload.json`) has nothing to preview, and the preview says so and names the release path a repository that ships no plugin has: declaring its kind as `binary` or `application`, the scaffolded release @@ -143,7 +150,9 @@ admit (or nothing is armed), 1 when it would refuse, and 2 on a structural fault `commands/launch.md` carries the emit, compose and ingest orchestration over the `release-changelog-composer` agent, including the release page's retry loop. The -deterministic emit alone is `abcd changelog`, read-only and prose-free. +deterministic emit alone is the cut the preview renders, read-only and +prose-free; it is exactly what the ship's emit step +derives, through the same derivation and the same render. **Commit, tag and publish stay a design target** (itd-72's publishing). The verb neither commits, tags, nor publishes, so every step past the changelog @@ -226,9 +235,13 @@ kind exits 2 with nothing written. **The preview is spelled `dry-run`, and it is a flag, not a sub-verb.** The binary registers no `dry-run` subcommand under launch, and `commands/launch.md` names it as a flag. Its report is -preview-only and always exits 0. It is **not** "ship minus publish": it runs the -same gate suite the cut's render path runs and reports what that path would -refuse on, but refusing is the cut's. +preview-only, and what it finds changes no exit code (the opening of this +chapter names the cases where it cannot run). It is **not** "ship minus +publish": it runs the same gate suite the cut's render path runs and reports +what that path would refuse on, and it renders the cut the ship's emit step +would derive, but refusing is the cut's. The cut is read from the checkout +root wherever the preview is run, and so is the whole preview: run from a +subdirectory, it reads the same repository as from the root. ## 1. Pre-flight gates @@ -312,7 +325,12 @@ hard-fails, citations, a line for every gate row that did not run (its name, its status — `host-run`, `not_armed`, `not_implemented` — and why; the semantic-receipts row is always one) and whether it would publish, then a would-refuse-on line per refusal, a warning line per warn-tier concern, and -where its report landed. The JSON carries the gate detail. +where its report landed. After the bundle report it renders the cut, under its +own header line, exactly as the ship renders its emit: +the base and derived tags or `REFUSED`, the deciding records, the guard and +findings lines, the records added and removed, the release page's intents, +the targeted intents and every refusal. The JSON carries the gate detail, and +the cut under `cut` (or `cut_error` when the cut could not be read). The **manifest lockstep check** also runs for real on the preview, at its `dev` polarity over the working tree — the polarity adr-19 requires the committed @@ -511,8 +529,8 @@ binary whose placement or sentence disagrees with the snapshot committed at `HEAD` is refused as stale, and the refusal names each moved verb and each reworded sentence. -**Unfixed-findings guardrail.** The cut and the read-only `changelog` preview -both ask one further question of the cut: of the findings **this cycle** +**Unfixed-findings guardrail.** The cut and its read-only preview in +the launch preview both ask one further question of the cut: of the findings **this cycle** produced, is any of them consequential, still open, and unanswered? A cut that carries one is refused, and a refused cut carries no derived version at all. The gate fired on this release's own first cut, so it is live behaviour rather @@ -543,9 +561,8 @@ than a design target. single-use: the anchor moves at the next release and every waiver written against the old one lapses, so a deferred finding is re-asked rather than forgotten. Half a waiver does not stand. -- **What the render shows.** Every render of `abcd changelog` and of the launch - cut carries a findings line - (the verdict, the count of unfixed findings and +- **What the render shows.** Every render of the cut, in the launch preview and + in the ship, carries a findings line (the verdict, the count of unfixed findings and the anchor they were measured from, and the count deferred), then one line per waiver naming the record, its severity and its stated reason. A deferral an operator cannot see in the report they actually read is indistinguishable from @@ -764,13 +781,13 @@ performed by a human and by CI. nothing is published. - **Given** an issue record captured since the anchor tag, graded major or critical (or carrying no readable grade), still open and carrying no standing - waiver, **when** the cut or `changelog` runs, **then** the cut is refused under + waiver, **when** the cut or the preview runs, **then** the cut is refused under `unfixed-finding`, the refusal names every such record with its grade and path, no version is derived, and the findings line says how many were counted and from which anchor. - **Given** an issue record the anchor tag held in `open/`, graded major or critical (or carrying no readable grade), and present in no status directory at - HEAD, **when** the cut or `changelog` runs, **then** the cut is refused under + HEAD, **when** the cut or the preview runs, **then** the cut is refused under `deleted-finding`, the refusal names the record with the grade and path the anchor held and says it is in no status directory, and the three dispositions — a move to `resolved/`, a move to `wontfix/`, a re-slug inside `open/` — each go @@ -794,6 +811,13 @@ performed by a human and by CI. - **Given** findings in several gates at once, **when** the preview or the cut runs, **then** every one of them is reported in the one pass and in the pre-flight report. +- **Given** shipped records since the anchor tag, **when** the preview runs, + **then** it renders, beside the bundle report, the cut the ship would + emit: the derived version and deciding impact, the records, and the guard and + findings verdicts, in text and in its JSON; **given** a cut that + refuses, the preview renders the refusal and its exit is unchanged. **Given** + the retired changelog verb, the binary refuses it as an unknown command, and + the note names the preview. - **Given** a previous release tag, **when** the preview or a cut that renders a payload runs, **then** the report lists every payload path added, changed or removed since that tag's payload, each with its SHA-256; **given** no previous diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 0af40b1de..4e172105a 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -299,6 +299,138 @@ the peers are read through the implement loop (`loop.StatusLanes`, `loop.StatusPeers`), and a fault reading the peers omits the block with the reason on stderr, as it refuses build next. +## The version + +The board's last line is the installed version, as the version flag names it +(`abcd v0.13.4`), in both views and both forms, a list item in the markdown +form so the fence rule holds for it too; the JSON form carries it as `version` +(itd-2610090831227812, spc-2610100613109045 decision 4). It is read from the +same `core.VersionInfo` the flag reports, with no network and no subprocess +([adr-38](../../decisions/adrs/0038-implicit-checks-are-disk-only.md)), and +the first line stays the view label alone. The plugin's board page carries +what the retired version page told an agent: run the version flag for the +install mode, vintage and staleness, and say a superseded root first. + +The full answer is a flag on the root, where every tool keeps its version, and +not a verb (itd-2609212130136102); the appendix below lists it. It tells +whether the abcd you are running is the one you think it is: its version, how +it was installed, how old it is, and whether it has drifted from the reference +it should match. The whole answer is read off disk, so it costs nothing, works +offline, and writes nothing. The opt-in online check is a flag of the update +verb, which fetches the latest release exactly once, compares, and names the +source it consulted: it lives with the verb that takes the update it finds +([`21-update.md`](21-update.md)), and it is the only network touch either +answer makes, because abcd never fetches implicitly. + +### What the version flag reports + +The version flag prints a short block: the version line, then `install:` +(only when an install mode is resolvable), `vintage:` and `staleness:`. It is a +read-only render of the binary's own state, not a board for the repository, and +it answers alone: a record id beside the flag is refused rather than silently +dropped. The JSON form emits the same facts as `name`, `version`, `vintage` and +`staleness`, with `install_mode` present only when it resolves, and +`superseded_root` present only when the answering binary sits in a plugin root +other than the one this session resolves (see *A superseded plugin root names +itself* below); the plain render prints that note on a `note:` line. The update +verb's check prints the same report with a `check` object added. + +When the online check finds an update, the answer carries the command that takes it, +so the reader's next move is on screen rather than inferred: `abcd update` for +the one install shape the update verb can swap, and for every other shape the +remedy that shape's owner requires (the host's plugin update for a plugin-root +binary, a fresh ahoy installation for a stranded entry, the package manager's own command +for a Homebrew install). The classification is the disk-only one `abcd update` +itself dispatches on, so the online check keeps its single sanctioned fetch +([`21-update.md`](21-update.md)). + +**`staleness` is prose, not a token enum.** The field carries the same words the +plain render prints, because one derivation serves both and a second spelling +for the machine would be a second thing to keep true. A binary that matches its +reference reads `up to date`; one with no reference to compare against reads +`unknown`; one that has drifted reads `stale — ` followed by the comparison and +the reference. A consumer matches on the `stale` prefix and on `unknown` +verbatim; there is no `fresh` token to match. + +### A stale binary names itself + +The plugin surface and the binary ship from one release but drift apart: a +plugin update lands a newer surface before the bootstrap re-provisions the +binary, a cached root goes stale, a PATH copy outlives the root it was copied +from. A page then names a verb or flag the binary predates, and the CLI +framework's answer — `unknown command` or `unknown flag` — has the shape of a +typo rather than of a stale install. + +So an unknown command or flag carries a second line, derived from what the +binary can prove on disk alone and never from the network (adr-38). Where the +command surface beside the resolved plugin root documents the very verb or flag +that was refused, the line says the binary predates it and names the remedy for +where the binary sits. A page that documents no verb is not that evidence: the +dispatcher page `abcd.md` documents the bare call, and the host-delegated pages +(`consult`, `ingest`, `prepare-this-repo`) run in the host agent, so the line +for one of those tokens says what it is instead — `abcd ` for the +first, the `/abcd:` invocation for the rest — and never sends the reader +to rebuild or update. Neither is a `status` or `show` sub-verb under a record +verb (`capture`, `intent`, `spec`): the refusal names the record dispatcher, +`abcd `, which answers that question. A name retired with no +alias (`version`, whose page retired when the board took the version, and +`changelog`, which the launch preview replaced) is answered with what replaced +it, and never as a stale binary. Failing that evidence, the disk-only vintage +the version flag renders stands in. When neither says anything, the framework's line stands +byte-for-byte. The exit code, the stream and the JSON envelope are the +framework's own. + +### A superseded plugin root names itself + +The two shapes above are loud: the binary is asked for something it does not +have, so there is an error to hang a line on. A third shape has none. A plugin +root is named for the commit it was installed from, so every update mints a new +root and nothing prunes the old ones; a command page interpolates an absolute, +hash-pinned binary path into its own prose, and that path is designed to +expire. Between an update and the reload that re-interpolates it, following the +page runs a superseded binary that is still on disk, answers normally — exit 0, +no diagnostic — and reports a version that is true of that root and false of +this machine (iss-2609020113012227, refining iss-2608230943088357). + +What the disk proves, with no network and no heuristic, is the divergence: the +plugin root this session resolves — through the same ladder every other surface +uses, which prefers the environment's own plugin-root variable over the +executable's ancestors — against the plugin root the running binary sits in, +found by that ladder's own executable-ancestor walk and layout check. When those +are two different roots, the version flag's report (and the update verb's check, +which extends it) and bare `ahoy` add a `superseded_root` note naming both roots +by the commit each was installed from, in the plain render as well as in the +JSON form. It is a note beside the answer: the reported version, vintage and +staleness are unchanged, and nothing refuses. + +The two names are directory names read off the disk, so each passes through the +terminal sanitiser before it is printed: a control or bidirectional character in +one is replaced, never rendered. The command pages tell the agent to relay the +note as abcd printed it and never to rebuild the names from a path, which would +undo that. + +The note is silent in three cases. A binary inside no plugin root at all — a +PATH copy, a `go run` build — has no superseded root to name, and the vintage +comparison already covers it. A binary in the root this session resolves has +nothing to disclose. And a binary served from a source checkout of abcd says +nothing: a checkout is a valid plugin root (`hooks/` sits at its top), so a +developer running the `make build` artefact while a harness session resolves its +own cache root satisfies the divergence test, but a checkout is not named for a +commit it was installed from, and the note's remedy would point at the +plugin-root binary the dogfooding rule calls the stale one. That case is the +vintage comparison's, and the stale-binary line above keys its rebuild remedy on +the same source-checkout test. The guard is keyed on the root that served the +answer: a provisioned root answering into a session whose own root is a source +checkout still names itself. + +### Where the version comes from + +The version is **derived, never hand-authored**: it is read from the shipped +build, not from a literal in the record +([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md)). +`/abcd:launch` stamps the derived version into the release artefact; the +board and the version flag only report what is installed. + ## The board itself is not built > **Design target (itd-20, `intents/superseded/`, superseded by itd-121, `spec_id: null`).** Everything in diff --git a/.abcd/development/brief/04-surfaces/12-version.md b/.abcd/development/brief/04-surfaces/12-version.md deleted file mode 100644 index 0cd12536e..000000000 --- a/.abcd/development/brief/04-surfaces/12-version.md +++ /dev/null @@ -1,149 +0,0 @@ -# `/abcd:version` — Print the Installed Version - -Tell, in one command, whether the abcd you are running is the one you think it -is: its version, how it was installed, how old it is, and whether it has -drifted from the reference it should match. The whole answer is read off disk, -so it costs nothing, works offline, and writes nothing. - -The answer is a flag on the root, where every tool keeps its version, and not a -verb (itd-2609212130136102); the root's appendix in -[`08-abcd.md`](08-abcd.md) lists it. The opt-in online check is a flag of the -update verb, which fetches the latest release exactly once, compares, and names -the source it consulted: it lives with the verb that takes the update it finds -([`21-update.md`](21-update.md)), and it is the only network touch -either answer makes, because abcd never fetches implicitly -([adr-38](../../decisions/adrs/0038-implicit-checks-are-disk-only.md)). For one -release the retired verb answers with the flag, or with the check when asked for -it, and exits non-zero. - -## Sub-verbs - -> _Machine-checked (`surface_coverage`, spc-27): each row records the verb's -> adr-40 bucket (`lint` / `review` / `audit` / `gate`, or `—` for a -> non-assessment verb) and its existence (`shipped` / `staged`). The existence -> fact is verified against the committed command-tree snapshot in both -> directions. The bucket cell is checked for membership of the closed adr-40 -> vocabulary only: the snapshot carries no bucket field, so a bucket that is -> wrong but legal passes, and that cell stays a review-grain claim._ - -| Verb | Bucket | Status | -|---|---|---| - -The table is empty: the surface is a root flag and registers no sub-command. - -## Behaviour - -The version flag prints a short block: the version line, then `install:` -(only when an install mode is resolvable), `vintage:` and `staleness:`. It is a -read-only render of the binary's own state, not a board for the repository, and -it answers alone: a record id beside the flag is refused rather than silently -dropped. The JSON form emits the same facts as `name`, `version`, `vintage` and -`staleness`, with `install_mode` present only when it resolves, and -`superseded_root` present only when the answering binary sits in a plugin root -other than the one this session resolves (see *A superseded plugin root names -itself* below); the plain render prints that note on a `note:` line. The update -verb's check prints the same report with a `check` object added. - -When the online check finds an update, the answer carries the command that takes it, -so the reader's next move is on screen rather than inferred: `abcd update` for -the one install shape the update verb can swap, and for every other shape the -remedy that shape's owner requires (the host's plugin update for a plugin-root -binary, a fresh ahoy installation for a stranded entry, the package manager's own command -for a Homebrew install). The classification is the disk-only one `abcd update` -itself dispatches on, so the online check keeps its single sanctioned fetch -([`21-update.md`](21-update.md)). - -**`staleness` is prose, not a token enum.** The field carries the same words the -plain render prints, because one derivation serves both and a second spelling -for the machine would be a second thing to keep true. A binary that matches its -reference reads `up to date`; one with no reference to compare against reads -`unknown`; one that has drifted reads `stale — ` followed by the comparison and -the reference. A consumer matches on the `stale` prefix and on `unknown` -verbatim; there is no `fresh` token to match. - -## A stale binary names itself - -The plugin surface and the binary ship from one release but drift apart: a -plugin update lands a newer surface before the bootstrap re-provisions the -binary, a cached root goes stale, a PATH copy outlives the root it was copied -from. A page then names a verb or flag the binary predates, and the CLI -framework's answer — `unknown command` or `unknown flag` — has the shape of a -typo rather than of a stale install. - -So an unknown command or flag carries a second line, derived from what the -binary can prove on disk alone and never from the network (adr-38). Where the -command surface beside the resolved plugin root documents the very verb or flag -that was refused, the line says the binary predates it and names the remedy for -where the binary sits. A page that documents no verb is not that evidence: the -dispatcher page `abcd.md` documents the bare call, and the host-delegated pages -(`consult`, `ingest`, `prepare-this-repo`) run in the host agent, so the line -for one of those tokens says what it is instead — `abcd ` for the -first, the `/abcd:` invocation for the rest — and never sends the reader -to rebuild or update. Neither is a `status` or `show` sub-verb under a record -verb (`capture`, `intent`, `spec`): the refusal names the record dispatcher, -`abcd `, which answers that question. Failing that evidence, the disk-only vintage this verb -renders stands in. When neither says anything, the framework's line stands -byte-for-byte. The exit code, the stream and the JSON envelope are the -framework's own. - -## A superseded plugin root names itself - -The two shapes above are loud: the binary is asked for something it does not -have, so there is an error to hang a line on. A third shape has none. A plugin -root is named for the commit it was installed from, so every update mints a new -root and nothing prunes the old ones; a command page interpolates an absolute, -hash-pinned binary path into its own prose, and that path is designed to -expire. Between an update and the reload that re-interpolates it, following the -page runs a superseded binary that is still on disk, answers normally — exit 0, -no diagnostic — and reports a version that is true of that root and false of -this machine (iss-2609020113012227, refining iss-2608230943088357). - -What the disk proves, with no network and no heuristic, is the divergence: the -plugin root this session resolves — through the same ladder every other surface -uses, which prefers the environment's own plugin-root variable over the -executable's ancestors — against the plugin root the running binary sits in, -found by that ladder's own executable-ancestor walk and layout check. When those -are two different roots, the version flag's report (and the update verb's check, -which extends it) and bare `ahoy` add a `superseded_root` note naming both roots -by the commit each was installed from, in the plain render as well as in the -JSON form. It is a note beside the answer: the reported version, vintage and -staleness are unchanged, and nothing refuses. - -The two names are directory names read off the disk, so each passes through the -terminal sanitiser before it is printed: a control or bidirectional character in -one is replaced, never rendered. The command pages tell the agent to relay the -note as abcd printed it and never to rebuild the names from a path, which would -undo that. - -The note is silent in three cases. A binary inside no plugin root at all — a -PATH copy, a `go run` build — has no superseded root to name, and the vintage -comparison already covers it. A binary in the root this session resolves has -nothing to disclose. And a binary served from a source checkout of abcd says -nothing: a checkout is a valid plugin root (`hooks/` sits at its top), so a -developer running the `make build` artefact while a harness session resolves its -own cache root satisfies the divergence test, but a checkout is not named for a -commit it was installed from, and the note's remedy would point at the -plugin-root binary the dogfooding rule calls the stale one. That case is the -vintage comparison's, and the stale-binary line above keys its rebuild remedy on -the same source-checkout test. The guard is keyed on the root that served the -answer: a provisioned root answering into a session whose own root is a source -checkout still names itself. - -## Where the version comes from - -The version is **derived, never hand-authored**: it is read from the shipped -build, not from a literal in the record -([adr-31](../../decisions/adrs/0031-derived-versioning-from-intents.md)). -`/abcd:launch` stamps the derived version into the release artefact; -`/abcd:version` only reports what is installed. - -## References - -- Plugin command: [`commands/version.md`](../../../../commands/version.md) -- Derived versioning: [`04-launch.md § 3`](04-launch.md#3-versioning--marketplace) - - - -It ships as the root flag `--version`, listed in the bare `abcd` command's appendix: the command tree registers no `abcd version` verb, so there are no sub-verbs to list. - - diff --git a/.abcd/development/brief/04-surfaces/21-update.md b/.abcd/development/brief/04-surfaces/21-update.md index 9aef4b646..0b1104e9b 100644 --- a/.abcd/development/brief/04-surfaces/21-update.md +++ b/.abcd/development/brief/04-surfaces/21-update.md @@ -21,7 +21,7 @@ action in that moment. adr-38 admits it as a tier of its own: provisioning completes a chosen update, it never discovers one. The check is a flag of this verb (itd-2609212130136102): it prints the -version report [`12-version.md`](12-version.md) describes, fetches the latest +version report [`08-abcd.md`](08-abcd.md#what-the-version-flag-reports) describes, fetches the latest release's tag once, compares, and names its source, swapping nothing and taking no tag. When an update is available, its `next:` line names the command to type, chosen by the same on-disk classification this verb dispatches on, so the check @@ -161,7 +161,7 @@ documented shape, never a broken receipt. - Plugin command: [`commands/update.md`](../../../../commands/update.md) - Intent / spec: [itd-130](../../intents/shipped/itd-130-abcd-update-completes-a-chosen-update-in.md) / [spc-32](../../specs/closed/spc-32-abcd-update-completes-a-chosen-update-in.md) -- Staleness check it completes: [`12-version.md`](12-version.md) +- Staleness check it completes: [`08-abcd.md` § The version](08-abcd.md#the-version) diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index fc1f17fa4..b4656736b 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -24,7 +24,6 @@ are wiring rather than user-facing surface are listed separately under | 9 | `/abcd:reflect` | shipped | Look back on a cut release in a short interview, and file what it taught as its retrospective | [`09-reflect.md`](09-reflect.md) | | 10 | `/abcd:docs` | shipped | Find documentation that has gone stale, and maintain the citation baseline | [`10-docs.md`](10-docs.md) | | 11 | `/abcd:history` | shipped | Keep session transcripts as a local, redacted corpus this project can study | [`11-history.md`](11-history.md) | -| 12 | `/abcd:version` | shipped | Know which abcd this is, how it was installed, and whether it is behind | [`12-version.md`](12-version.md) | | 13 | `/abcd:consult` | shipped | Ask the local sources corpus what prior work says, and record what it changed | [`13-consult.md`](13-consult.md) | | 14 | `/abcd:ingest` | shipped | Put a document or URL into the sources corpus with its reference metadata | [`14-ingest.md`](14-ingest.md) | | 15 | `/abcd:prepare-this-repo` | shipped | Bring an owned repo up to abcd's conventions (interim bridge until abcd manages repos directly) | [`15-prepare-this-repo.md`](15-prepare-this-repo.md) | @@ -142,12 +141,16 @@ the command's sentence (the section below). | people | checks | `lab`, `reading` | | people | portability | `disembark`, `embark` | | people | release | `launch` | -| agents and hosts | — | `banlist`, `changelog`, `docs`, `drain`, `guard`, `guard hook`, `history`, `ideate record`, `identity`, `implement`, `inbox`, `intent audit ingest`, `intent consistency ingest`, `intent prepass`, `lint`, `mode`, `peers`, `reflect`, `report`, `rules`, `scribe`, `site`, `spec`, `statusline` | +| agents and hosts | — | `banlist`, `docs`, `drain`, `guard`, `guard hook`, `history`, `ideate record`, `identity`, `implement`, `inbox`, `intent audit ingest`, `intent consistency ingest`, `intent prepass`, `lint`, `mode`, `peers`, `reflect`, `report`, `rules`, `scribe`, `site`, `spec`, `statusline` | The placement is presentation. No verb is hidden, renamed, moved or nested by it, every verb runs the same whichever block lists it, and the group titles carry no adr-40 bucket meaning. `version` is the root's `--version` flag -(itd-2609212130136102), not a verb, so it is in neither block. The product +(itd-2609212130136102), not a verb, so it is in neither block, and bare `abcd` +shows the installed version as the board's last line (itd-2610090831227812). +`changelog` is in neither block either: it merged into `launch` as its preview, +and `abcd launch --dry-run` renders the cut it showed beside the bundle report +(itd-2610090831227812). The product thinker placed every verb on 2026-10-09 by who types it: a command a person needs to do their job is a person's, and only a command no person types is listed with the agents (itd-2610090831227812; its spec's verb audit cites each @@ -240,7 +243,6 @@ verb the binary registers apart from the framework's own `help`. | Verb | What it is | Delivered by | |---|---|---| -| `changelog` | The deterministic, read-only emit of the next release cut — derived version, record set, guardrail, no prose. Nothing on the plugin surface runs it: `commands/launch.md`'s emit → compose → ingest orchestration runs `launch ship --json`, and names this verb only as the read-only preview of the same cut. `launch ship` is the write half. | itd-73 (derived versioning) and itd-67's changelog slice, both in `intents/planned/`; documented in [`04-launch.md`](04-launch.md) | | `rules` | Renders the active rule set; a positional `DOMAIN` scopes to one. Read-only diagnostics over the hook-driven rule injection. | itd-3 (the modular rules loader); the loader it reports on is documented in [`05-internals/03-configuration.md`](../05-internals/03-configuration.md), and the verb in [The rules verb](#the-rules-verb) below | | `spec` | The native spec store: bare invocation is a read-only status board, and `spec close` closes a spec and ships its linked intent (`planned/` → `shipped/`) only when no open spec is left naming it — an intent owns one or more specs, and `--remainder ` mints the follow-on for a partial delivery in the same operation, carrying the closing spec's steps not marked landed. | itd-80 / spc-2 (intent lifecycle automation), adr-2609151513118583 (the 1:n relation); its lifecycle is documented in [`05-intent.md`](05-intent.md), and the verb in [The spec verb](#the-spec-verb) below | | `hook` | Hidden from `--help`: five host hook entrypoints, live-wired from `hooks/hooks.json`. `prompt-router` injects the rules a prompt matches and `prompt-router-reset` clears the per-session ledger so they inject again; `session-end` stages the session's own transcript, `subagent-stop` stages a finished sub-agent's transcript with its lineage, and `session-start` files both away and says how many reports wait in the inbox ([`29-report.md`](29-report.md)). The pre-tool-use adapter is `guard hook`, under `guard`. | itd-3 (the prompt router), itd-89 / spc-4 (the transcript clock), itd-103 / spc-16 (the guard hook); the transcript entrypoints are documented in [`11-history.md`](11-history.md), and the rule injection the router drives, with `prompt-router`'s two outputs — the injected block and the `--json` envelope that names the active-domain set for a client that snapshots rules — in [`05-internals/03-configuration.md`](../05-internals/03-configuration.md#the-prompt-routers-output); the generated CLI reference omits both router entrypoints by design | @@ -324,8 +326,7 @@ documents is then an unknown command (iss-161). One file per verb, directly unde `abcd`, `ahoy`, `banlist`, `build`, `capture`, `consult`, `dashboard`, `decide`, `disembark`, `docs`, `drain`, `embark`, `guard`, `history`, `ideate`, `identity`, `implement`, `inbox`, `ingest`, `intent`, `lab`, `launch`, `lint`, `memory`, `mode`, `peers`, -`prepare-this-repo`, `reading`, `reflect`, `report`, `scribe`, `site`, `source`, `update`, -`version`. +`prepare-this-repo`, `reading`, `reflect`, `report`, `scribe`, `site`, `source`, `update`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. diff --git a/.abcd/development/brief/05-internals/04-universal-patterns.md b/.abcd/development/brief/05-internals/04-universal-patterns.md index cbd295362..c43faf5ba 100644 --- a/.abcd/development/brief/05-internals/04-universal-patterns.md +++ b/.abcd/development/brief/05-internals/04-universal-patterns.md @@ -87,7 +87,7 @@ All inter-agent data is JSON; markdown is a render step at the end of each pass. **A design target: no shipped verb writes such a pair yet.** Every command emits `-report.json` (full structured detail) and `-report.md` (human skim summary, rendered from JSON). Both stored in `.abcd/.work.local/logs///`. -What ships today is narrower. Most verbs write no report at all: `lint` and its targets, `capture`, `intent`, `guard`, `--version`, `changelog`, `rules`, `spec`, `identity` and `banlist` are read-only renders, and `abcd lint` in particular is documented as performing zero writes. Where run output exists it lands in the local ephemeral tier under `.abcd/.work.local/logs///`, which is the shape the pattern generalises. +What ships today is narrower. Most verbs write no report at all: `lint` and its targets, `capture`, `intent`, `guard`, `--version`, `rules`, `spec`, `identity` and `banlist` are read-only renders, and `abcd lint` in particular is documented as performing zero writes. Where run output exists it lands in the local ephemeral tier under `.abcd/.work.local/logs///`, which is the shape the pattern generalises. ## 6. `.abcd/.work.local/logs/` layout diff --git a/.abcd/development/brief/05-internals/08-skills.md b/.abcd/development/brief/05-internals/08-skills.md index 4817d245b..77c04b680 100644 --- a/.abcd/development/brief/05-internals/08-skills.md +++ b/.abcd/development/brief/05-internals/08-skills.md @@ -72,7 +72,7 @@ the transport-agnostic core. The mapping between command pages and binary verbs is one-to-one in neither direction, and both exceptions are deliberate. Five verbs have a Go verb and no -command page: `changelog`, `completion`, `hook`, `rules`, and `spec`. Three +command page: `completion`, `hook`, `rules`, `spec`, and `statusline`. Three command pages carry no Go verb of their own name: the host-delegated three above. Two of those three call no part of the binary at all; `/abcd:prepare-this-repo` is the exception, running the binary's audit, install and identity verbs as steps diff --git a/.abcd/development/principles/pre-existing-is-not-a-defence.md b/.abcd/development/principles/pre-existing-is-not-a-defence.md index 8dbd0bc6f..cfe774d7f 100644 --- a/.abcd/development/principles/pre-existing-is-not-a-defence.md +++ b/.abcd/development/principles/pre-existing-is-not-a-defence.md @@ -91,8 +91,8 @@ a release cut it takes the set-difference of issue-ledger membership between the anchor tag's tree and HEAD, keyed on record id, and refuses the cut when any record that difference names is still in `open/` and graded `major` or `critical`. The refusal names every blocking record and all three ways out, and it -reaches `abcd changelog` and `abcd launch ship` through the same `refusals` -channel as the surface guardrail. Membership is read from git rather than from +reaches the cut `abcd launch --dry-run` previews and `abcd launch ship` through +the same `refusals` channel as the surface guardrail. Membership is read from git rather than from the timestamp inside a record id, so the gate's verdict is not decided by a field the record it is judging can edit. diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 6fb5f3df8..e950cebae 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -30,7 +30,6 @@ ".abcd/development/brief/04-surfaces/09-reflect.md", ".abcd/development/brief/04-surfaces/10-docs.md", ".abcd/development/brief/04-surfaces/11-history.md", - ".abcd/development/brief/04-surfaces/12-version.md", ".abcd/development/brief/04-surfaces/13-consult.md", ".abcd/development/brief/04-surfaces/14-ingest.md", ".abcd/development/brief/04-surfaces/15-prepare-this-repo.md", @@ -88,10 +87,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 46, - "promptHash": "sha256:5f7b7483c460d894116b7b860ca8def7b5ecda45a75eb56a89b2a03b63140ff2", + "checkerCount": 45, + "promptHash": "sha256:a2abc8e72b60541927a0dbd966577f5cdabc717a483a070cdfe1f8bcb8a290c1", "prompt": { - "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, build, capture, consult, dashboard, decide, disembark, docs, drain, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, lab, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, reflect, report, scribe, site, source, update, version)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, planning-interviewer, press-release-composer,\nprinciple-distiller, question-drafter, reflection-composer, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", + "context": "Repo root: the current working directory — use repo-relative paths\nthroughout, never absolute local paths. Ground truth is the SHIPPED surface,\nverified empirically: build the binary (make build produces bin/abcd--)\nand run it (`abcd --help` and `abcd --help`), and list commands/, agents/\nand skills/. abcd currently ships ZERO skills — the whole /abcd: surface is\ncommands under commands/ (abcd, ahoy, banlist, build, capture, consult, dashboard, decide, disembark, docs, drain, embark, guard,\nhistory, ideate, identity, implement, inbox, ingest, intent, lab, launch, lint, memory, mode, peers,\nprepare-this-repo, reading, reflect, report, scribe, site, source, update)\nand agent prompts under agents/ (cold-reading-comparative, cold-reading-detection, cold-reading-entailment,\ncold-reading-widening, docs-currency-reviewer, graveyard-interpreter,\nintent-auditor, lifeboat-reviewer, planning-interviewer, press-release-composer,\nprinciple-distiller, question-drafter, reflection-composer, release-changelog-composer, ruthless-reviewer, scribe,\nsecurity-reviewer, sota-researcher);\nskills/ is empty or absent. The brief chapters that carry surface claims are\npinned in this manifest's briefDocs: .abcd/development/brief/04-surfaces/*.md\n(the index README included) and the 02-constraints, 05-internals and 06-delivery\nchapters that count or enumerate verbs, sub-verbs, agents, hooks and the plugin\ntree. That list says where to LOOK, not what may be REPORTED: a surface claim\nin any other brief chapter is in scope, and a real surface whose only\ndocumented home lies outside the pinned list is reported with that location.\nReport DISCREPANCIES ONLY — where record and reality disagree, or one side is\nmissing. A brief row explicitly marked staged (its Status column is \"staged\")\n/ probe-only / later-phase is NOT a discrepancy; an unmarked claim about a\nsurface that does not exist IS. Do not fix anything.", "directionA": "Direction A. Read ${doc} fully. Extract every checkable claim\nabout the shipped surface (verbs, sub-verbs, flags, skill names, counts,\nfile layouts, \"abcd ships N ...\" statements) and verify each against\nreality. Return item=\"${doc}\" and the discrepancy list.", "directionB": "Direction B. The real surface \"${s.name}\" (${s.kind}) exists:\ninspect it (${s.probe}). Search the brief's surface chapters for its\ndocumented home (grep .abcd/development/brief/). If no brief row documents\nit — or the brief documents it under a wrong name/shape — that is a\ndiscrepancy. Return item=\"${s.name}\" and the discrepancy list (empty if\nproperly documented)." } diff --git a/.abcd/development/release-gate/receipt.example.json b/.abcd/development/release-gate/receipt.example.json index 2a84f1b9d..9f4993e20 100644 --- a/.abcd/development/release-gate/receipt.example.json +++ b/.abcd/development/release-gate/receipt.example.json @@ -8,7 +8,7 @@ "verificationResult": "PROMOTE", "judgeModel": "claude-opus-4-8", "tier": "full", - "manifestHash": "sha256:a813a6101cd62f2d1f4f2d9347b5e2c1a6823b0ec6d77a9169c1545dc762eabe", + "manifestHash": "sha256:e74436245455cbfff190c8bf842b7b08912960c822f540ece376373f9bf94c19", "policy": { "detector": "iss35-brief-surface-crosscheck", "version": "1", diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 226877295..384fb5a7e 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -942,14 +942,6 @@ } ] }, - { - "path": "abcd changelog", - "hidden": false, - "group": "agents", - "block": "agents", - "sentence": "Preview the next release cut's version, records, and guardrail verdict: Writes nothing; refuses outside a checkout, exiting 0 on a cut the gates would stop.", - "flags": [] - }, { "path": "abcd dashboard", "hidden": false, @@ -2479,7 +2471,7 @@ "hidden": false, "group": "release", "block": "people", - "sentence": "Preview the public launch bundle, its secret scan, and the release gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run.", + "sentence": "Preview the release cut, the launch bundle, its secret scan and the gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run.", "flags": [ { "name": "baseline", diff --git a/.abcd/rules.json b/.abcd/rules.json index 2b7bffa9d..5b9afc63f 100644 --- a/.abcd/rules.json +++ b/.abcd/rules.json @@ -58,7 +58,6 @@ "banlist", "build", "capture", - "changelog", "completion", "dashboard", "decide", diff --git a/README.md b/README.md index e5ab3748b..b5cbb34a0 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,10 @@ Wait for the confirmation that the marketplace was added, then: /plugin install abcd@abcd-marketplace ``` -Restart the session afterwards so the hooks load, then check what you got: +Restart the session afterwards so the hooks load, then check what you got: the board's last line names the installed version. ``` -/abcd:version +/abcd:abcd ``` Later, `/plugin update abcd` takes the latest cut release: the marketplace names that release's plugin archive by its SHA-256, and the harness refuses any other bytes. The plugin route needs Claude Code v2.1.224 or later. @@ -122,6 +122,7 @@ view for the product thinker ● building: nothing right now ○ next: nothing is ready • no more ready, nothing parked +abcd v0.13.4 ``` `abcd --view facilitator` shows the full board: the repository, the record, the work tiers, the presence line and every intent with its spec. diff --git a/agents/release-changelog-composer.md b/agents/release-changelog-composer.md index 55add0802..70ad5055b 100644 --- a/agents/release-changelog-composer.md +++ b/agents/release-changelog-composer.md @@ -1,7 +1,7 @@ --- name: release-changelog-composer description: Compose the prose of one release cut from the records that shipped in it — the changelog lines and the release page, every line and headline citing the record id it reports, so the binary can prove both documents describe exactly the cut. Host-delegated; feeds `abcd launch ship --changelog-json`. -prompt_version: 0.4.2 +prompt_version: 0.4.3 reads_untrusted_input: true capability_scope: task_classes: [surface_render] @@ -25,7 +25,7 @@ talk your way past — so write for the reader, and cite everything. ## What you read The **cut** — the emit step's JSON, produced by `abcd launch ship --json` (or the -read-only preview `abcd changelog --json`): +`cut` of the read-only preview `abcd launch --dry-run --json`): - `next_tag` — the derived version (e.g. `v0.4.1`). Copy it verbatim into your payload; never compute, guess, or "correct" it. diff --git a/commands/abcd.md b/commands/abcd.md index 479d63134..ce408ff8d 100644 --- a/commands/abcd.md +++ b/commands/abcd.md @@ -18,6 +18,41 @@ Run: Paste its output to the user unchanged, in one fenced block: add nothing inside the fence, and retell none of it outside the fence. The output is the view for the product thinker, a few plain lines: what is being built, the next three things to build, and how many more are ready or parked. It is drawn for a person to read as it stands. +## The version + +The board's last line is the installed abcd version, in both views and both +forms (`abcd v0.13.4`, a list item in the markdown form), and `--json` carries +it as `version`. It is the version `abcd --version` reports, read off the +binary with no network. When the user asks how abcd was installed, how old it +is, or whether it has drifted from the reference it should match, run: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" --version --json +``` + +and tell them the `name`, `version`, `vintage` and `staleness`, plus +`install_mode` when it is present. The key is omitted when no abcd-owned `PATH` +entry resolves (nothing installed yet, a foreign or dangling entry, or an +unresolved plugin root); then say abcd is not on `PATH` yet and point at +`ahoy install` (see *Binary resolution* below) rather than inventing a mode. +Whether a newer release exists is the update verb's check +(`/abcd:update`); ask it only when the user does. + +**If `superseded_root` is present, say it FIRST.** It means the binary that just +answered, and so the version on the board, is served from a plugin root other +than the one this session resolves: the version reported is true of that root +and false of this machine. A plugin root is named for the commit it was +installed from, so an absolute binary path pinned into a page expires on the +next update while the root it names stays on disk and keeps answering. Relay +the note as abcd printed it, without paraphrasing: it names both roots, each by +the commit its root was installed from, and abcd has already replaced any +control and bidirectional characters in those names. Never rebuild the names +from a path, and never re-decorate them. Then re-run the board through this +session's own plugin root (reload the plugin surface if the path this page gave +you is the stale one) before reporting a version at all. The note is silent +when the answering binary sits in a source checkout of abcd, whose currency the +`staleness` field already reports. + ## The view for the facilitator When the user asks for the full board, run: @@ -175,4 +210,23 @@ binary on `PATH`, run `ahoy install` through whichever rung just resolved: `"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or `go run ./cmd/abcd ahoy install` in a source checkout. +**When no binary resolves.** If every rung fails, the fix is **not** to install +Go. A compiler is a dependency of neither supported install route, which both +provision a prebuilt, checksum-verified binary. Tell the user to recover in this +order, no toolchain needed: + +1. Restart a session with network access. `hooks/bootstrap.sh` re-provisions the + plugin-root binary at the start of every session that can reach the release + origin; an empty `.bootstrap.attempt` marker with no binary beside it means a + previous provisioning began and did not finish, so a networked restart lands it. +2. Reinstall the plugin from its marketplace when its remote is stale (for example + one predating an organisation rename). Re-adding the marketplace re-points it at + the live release origin; the install guide gives the exact steps. +3. Install the CLI binary with the one-liner in the README, which downloads and + SHA-256-verifies the same prebuilt binary into `~/.local/bin`. + +`go run ./cmd/abcd` and `go build ./cmd/abcd` serve only a source checkout of this +repo (contributors) or a platform carrying no released binary; they are never a +prerequisite for a plugin or CLI user. + **User input:** $ARGUMENTS diff --git a/commands/intent.md b/commands/intent.md index 659989c6d..0137c8bea 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -789,8 +789,8 @@ intent only: a bundle is planned without one and each member targeted after); Refused with nothing written: a draft, a shipped, superseded or discipline record, and a value that is neither shape. A target is a report, never a gate: -`launch --dry-run` and the release cut (`launch ship`, `abcd changelog`) list -every targeted intent still planned, and neither refuses on one. The cut moves +`launch --dry-run`, which previews the cut, and the release cut (`launch ship`) +list every targeted intent still planned, and neither refuses on one. The cut moves every target it passes — `next`, or a tag at or below the release it cuts — to `next`, whatever the following release is numbered, in the same write as the changelog, and the dated section names the move. Closing the diff --git a/commands/launch.md b/commands/launch.md index cbe998d3e..64705c130 100644 --- a/commands/launch.md +++ b/commands/launch.md @@ -1,21 +1,22 @@ --- name: launch -description: "Preview the public launch bundle, its secret scan, and the release gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run." +description: "Preview the release cut, the launch bundle, its secret scan and the gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run." argument-hint: "[--dry-run [--deep-smoke] [--baseline ] [--fetch-baseline]] | ship [--changelog-json ] [--payload-dir ] [--allow-dirty] [--fetch-baseline] | archive --out [--tag ] [--verify] [--repository ] | manifests --tree public|dev [--root ] | receipts | scaffold [--confirm] [--dependency-reauthor]" block: people --- # `/abcd:launch` release preview and release cut -`abcd --help` lists `launch` in the person's release group. `changelog`, the -read-only preview of the same cut, is in the agents-and-hosts block of -`abcd --help --agent`, and its line there names this page. +`abcd --help` lists `launch` in the person's release group. The preview +(`--dry-run`) also renders the release cut the next ship would make; there is +no separate changelog command. Two flows over the abcd binary, kept apart on purpose: -- **preview** (`dry-run`) — the bundle, the scan, and the pre-flight gates. Its - one write is its pre-flight report, under the gitignored - `.abcd/.work.local/logs/launch/`. +- **preview** (`dry-run`) — the bundle, the scan, the pre-flight gates, and the + release cut: the version the release would carry, the records it is made of, + and the guard verdict. Its one write is its pre-flight report, under the + gitignored `.abcd/.work.local/logs/launch/`. - **ship** — the release cut: derive the version from what shipped, compose the changelog prose and the release page, write them. It writes the dated section of `CHANGELOG.md`, the release page `RELEASE.md`, the outgoing page's copy @@ -370,10 +371,22 @@ Then summarise the JSON for the user: - `targets` — every planned intent that names a release it must land by (`target_release`, written by `abcd intent target`): its `id`, `path`, the `target_release` it names, and `invalid` when the value is not a legal - target. The plain preview prints each on a `targeted:` line and the - pre-flight report lists them under *Targeted, not shipped*. A target is a - report and never a refusal: it adds nothing to `would_refuse_on`. Relay the - list, so the user sees what was meant to land and has not. + target. The plain preview prints each on a `targeted:` line, in the cut + below when the cut was read, and the pre-flight report lists them under + *Targeted, not shipped*. A target is a report and never a refusal: it adds + nothing to `would_refuse_on`. Relay the list, so the user sees what was + meant to land and has not. +- `cut` — the release cut the next `launch ship` would make, read from HEAD at + the checkout root: exactly what its emit step derives (*1. Emit the cut* + below), with no prose, so no agent runs in a preview. Relay the derived + `next_tag` and `impact`, the records under `added` and `removed`, which of + them the release page would cite (`in_press_release`), the guard verdict + (`guard`) and the findings verdict (`findings`). A refused cut (`ready` + false) carries its `refusals`, each naming what blocks it; relay them as + information. A refused cut changes nothing about the preview's exit, which + stays the bundle's. When the cut could not be read at all, `cut_error` says + why in its place. The plain preview renders the cut after the bundle report, + under its own `abcd launch --dry-run —` line, as `launch ship` renders it. This is preview-only: publishing is not driven from this command. @@ -415,7 +428,8 @@ the second marks the intents the release page must cite (the user-facing intents that entered `shipped/` since the base tag; never an issue, an `impact: internal` intent, a removed intent or anything still planned). The human render lists them under `release page:`, or says `release page: none` for a cut that ships fixes -alone. Read-only preview of the same thing: `abcd changelog --json`. +alone. Read-only preview of the same thing: the `cut` in +`abcd launch --dry-run --json`. **The cut is read from HEAD, so an uncommitted record move refuses it.** The record set comes out of git at HEAD, never the working tree, so a spec close or @@ -428,8 +442,8 @@ the same reader as the pre-flight's dirty-tree gate, narrowed to those folders: dirt anywhere else is not the derivation's concern (the ingest's pre-flight judges the whole tree), and `--allow-dirty` does not waive it, because the cut would still be read from HEAD. Commit the records (or discard the change) and -run again. `abcd changelog` carries the same refusal, rendered `REFUSED` with -the paths; as the preview it still exits 0. +run again. The preview (`launch --dry-run`) carries the same refusal in its +cut, rendered `REFUSED` with the paths, and its exit stays the bundle's. The cut also lists every planned intent that names a release it must land by (`targets`, one `targeted:` line each in the render, and `targets_error` when @@ -484,8 +498,9 @@ that no brief chapter names. ### The findings gate -Both renders — `abcd changelog` and `abcd launch ship` — carry two lines about -the issue ledger, and they are the two most easily skipped lines in the report: +Both renders of the cut — in `abcd launch --dry-run` and in `abcd launch ship` +— carry two lines about the issue ledger, and they are the two most easily +skipped lines in the report: ``` findings: failed (2 unfixed finding(s) captured since v0.7.0) (1 record(s) deleted from the ledger since v0.7.0) diff --git a/commands/update.md b/commands/update.md index ac4e31267..d74289852 100644 --- a/commands/update.md +++ b/commands/update.md @@ -36,6 +36,16 @@ Relay `version`, `vintage` and the `check` object: its `verdict`, the `latest` tag and its `source`, and — when an update is available — `next_step`, verbatim, which names the command that takes the update for this install's shape. +abcd never fetches implicitly (adr-38): the network is touched only when the +caller runs a command whose documented job includes a remote call. The release +origin is reached by `update --check`, `update`, and `launch --dry-run +--fetch-baseline` or `launch ship --fetch-baseline`; a cited source by `docs +cite refresh`; a URL by `memory ingest `; a provider's verification call by +`ahoy connect` and `ahoy credential --home`; a model provider by a +delegating verb routed to one (`reading ingest --dispatch` among them); the +site host and GitHub by `site setup`; and GitHub, through `gh` or `git`, by +`ahoy --remote`, `ahoy remote apply` and the implement loop's landing. + To take the update, run: ```bash diff --git a/commands/version.md b/commands/version.md deleted file mode 100644 index 64e6789a4..000000000 --- a/commands/version.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: version -description: "Print abcd's version, install mode, and vintage through abcd --version: Writes nothing; refuses any argument." -block: people ---- - -# `/abcd:version` - -Report the installed abcd version, install mode, and vintage — the running -binary's build revision (in a source checkout) or pinned version, and whether it -is up to date, stale, or of an undeterminable vintage relative to the on-disk -reference. This command performs **zero writes** and touches no network. - -The binary keeps its version where every tool keeps it, in a root flag, and this -page runs it. Run: - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" --version --json -``` - -Then tell the user the `name`, `version`, `vintage`, and `staleness` from the -JSON, plus `install_mode` when it is present — the key is omitted entirely when -no abcd-owned `PATH` entry is resolvable (nothing installed yet, a foreign or -dangling entry, or an unresolved plugin root). In that case say abcd is not on -`PATH` yet and point at `ahoy install` below, rather than inventing a mode. - -**If `superseded_root` is present, say it FIRST.** It means the binary that just -answered is served from a plugin root other than the one this session resolves, -so the version reported is true of that root and false of this machine. A plugin -root is named for the commit it was installed from, so an absolute binary path -pinned into a page expires on the next update while the root it names stays on -disk and keeps answering. Relay the note as abcd printed it, without -paraphrasing: it names both roots, each by the commit its root was installed -from, and abcd has already replaced any control and bidirectional characters in -those names. Never rebuild the names from a path, and never re-decorate them. -Then re-run the command through this session's own plugin root (reload the -plugin surface if the path this page gave you is the stale one) before reporting -a version at all. The note is silent when the answering binary sits in a source -checkout of abcd, whose currency the `staleness` field already reports. - -**Checking for a newer release.** Only when the user explicitly asks whether a -newer version exists, ask the update verb, which checks without swapping -anything: - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" update --check --json -``` - -It prints the same report with a `check` added. `update --check` reaches the network — it fetches the latest release once, compares, -and reports under `check` (with its `source` named). When an update is -available, `check.next_step` names the command that takes it — `abcd update` -for a binary the update verb can swap, the host's plugin update for a -plugin-root binary, or the package manager's own command for a Homebrew -install — chosen by the same on-disk classification `abcd update` dispatches -on. Relay it verbatim rather than paraphrasing; it is the one line the user -types next. abcd never fetches implicitly (adr-38): the network is touched -only when the caller runs a command whose documented job includes a remote -call. The release origin is reached by `update --check`, `update`, and -`launch --dry-run --fetch-baseline` or `launch ship --fetch-baseline`; a cited -source by `docs cite refresh`; a URL by `memory ingest `; a provider's -verification call by `ahoy connect` and `ahoy credential --home`; a -model provider by a delegating verb routed to one (`reading ingest --dispatch` -among them); the site host and GitHub by `site setup`; and GitHub, through `gh` -or `git`, by `ahoy --remote`, `ahoy remote apply` and the implement loop's -landing. - -**Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install -provisions the binary into the plugin root, so this is the rung that fires for a -plugin user. If that path does not exist, try `abcd` on `PATH`; if that fails -too, you are in a source checkout of this repo, where — and only there — -`go run ./cmd/abcd` works, the published payload carrying no `cmd/`. To put a -binary on `PATH`, run `ahoy install` through whichever rung just resolved: -`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`, `abcd ahoy install`, or -`go run ./cmd/abcd ahoy install` in a source checkout. - -**When no binary resolves.** If every rung fails, the fix is **not** to install -Go. A compiler is a dependency of neither supported install route, which both -provision a prebuilt, checksum-verified binary. Tell the user to recover in this -order, no toolchain needed: - -1. Restart a session with network access. `hooks/bootstrap.sh` re-provisions the - plugin-root binary at the start of every session that can reach the release - origin; an empty `.bootstrap.attempt` marker with no binary beside it means a - previous provisioning began and did not finish, so a networked restart lands it. -2. Reinstall the plugin from its marketplace when its remote is stale (for example - one predating an organisation rename). Re-adding the marketplace re-points it at - the live release origin; the install guide gives the exact steps. -3. Install the CLI binary with the one-liner in the README, which downloads and - SHA-256-verifies the same prebuilt binary into `~/.local/bin`. - -`go run ./cmd/abcd` and `go build ./cmd/abcd` serve only a source checkout of this -repo (contributors) or a platform carrying no released binary; they are never a -prerequisite for a plugin or CLI user. - -**User input:** $ARGUMENTS diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b7cc4577b..b976e81f9 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -633,12 +633,6 @@ Move an open issue to wontfix/ with the reason it is not acted on: Writes the mo abcd capture wontfix iss-2609010000000001 "the behaviour is the documented one" ``` -### `abcd changelog` - -Preview the next release cut's version, records, and guardrail verdict: Writes nothing; refuses outside a checkout, exiting 0 on a cut the gates would stop. - -**Usage:** `abcd changelog` - ### `abcd completion` Generate the autocompletion script for the specified shell @@ -2759,12 +2753,14 @@ abcd lab sweep lab-260901000000-0123abc ### `abcd launch` -Preview the public launch bundle, its secret scan, and the release gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run. +Preview the release cut, the launch bundle, its secret scan and the gates: Writes only its pre-flight report, to the local tier; refuses without --dry-run. **Usage:** `abcd launch [flags]` Preview the release bundle and run the release gates with --dry-run; nothing is -published. +published. The preview also renders the release cut the next `launch ship` would +make: the derived version, the deciding impact, the records, and the guard verdict. +A refused cut is reported, and changes nothing about the preview's exit. The dirty-tree gate compares the working tree with HEAD byte for byte. The repository's content filters (filter..clean, .smudge and .process) are switched off for that @@ -2783,7 +2779,7 @@ configuration names runs; a submodule moved to a different commit still does. ``` --baseline string the release tag the payload parity diff measures against (default: the newest release tag) --deep-smoke also run the installability smoke's deep tier: render every command, skill and agent page's help in an isolated subprocess (always on in the cut) - --dry-run preview the launch bundle and gates without publishing + --dry-run preview the launch bundle, its gates and the release cut without publishing --fetch-baseline read the parity baseline from the tag's published plugin archive, verified against the release's checksums.txt (a network fetch; default: a fresh render at the tag) ``` diff --git a/internal/core/board/board.go b/internal/core/board/board.go index f2075008a..c4950a7b7 100644 --- a/internal/core/board/board.go +++ b/internal/core/board/board.go @@ -15,6 +15,7 @@ import ( "regexp" "strings" + "github.com/intentdriven/abcd/internal/core" "github.com/intentdriven/abcd/internal/core/statusblock" "github.com/intentdriven/abcd/internal/core/statusline" "github.com/intentdriven/abcd/internal/termsafe" @@ -82,6 +83,11 @@ type Input struct { Dir string Status *statusblock.Block Rows []Row + // Version is the installed version, the same core.VersionInfo + // `abcd --version` reports; it is drawn as the board's last line in both + // views and both forms (spc-2610100613109045, decision 4), and the line is + // left out only when no version is handed in. + Version core.VersionInfo } // productMeasure caps how wide a title runs in the product thinker's view: the @@ -116,12 +122,30 @@ func Render(in Input, f Frame) []string { } else { out = append(out, product(in, f)...) } + if l := versionLine(in.Version, f); l != "" { + out = append(out, l) + } if f.Form == Text { out = fitWindow(out, f.Width) } return out } +// versionLine is the board's last line, the installed version as `abcd +// --version` names it (abcd v0.13.4): masked, since it is stamped at build +// time, and a list item in markdown, so the page's fence rule holds for it as +// for every other line. The first line stays the view label alone. +func versionLine(v core.VersionInfo, f Frame) string { + if v.Version == "" { + return "" + } + l := termsafe.Sanitize(strings.TrimSpace(v.Name + " " + v.Version)) + if f.Form == Markdown { + return "- " + l + } + return l +} + // escape matches one SGR colour sequence, the only escape the board draws. var escape = regexp.MustCompile("\x1b\\[[0-9;]*m") diff --git a/internal/core/board/board_test.go b/internal/core/board/board_test.go index 73ba58c73..48422cd9b 100644 --- a/internal/core/board/board_test.go +++ b/internal/core/board/board_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core" "github.com/intentdriven/abcd/internal/core/statusblock" "github.com/intentdriven/abcd/internal/core/statusline" "github.com/intentdriven/abcd/internal/textwidth" @@ -59,6 +60,9 @@ func fullRows() []Row { } } +// ver is the version the front door reads from core.NewVersion. +var ver = core.VersionInfo{Name: "abcd", Version: "v0.13.4"} + func check(t *testing.T, name string, got []string, want string) { t.Helper() if g := strings.Join(got, "\n") + "\n"; g != want { @@ -71,28 +75,31 @@ func check(t *testing.T, name string, got []string, want string) { // count line, with no record id, lane, target, command word or owed answer. func TestProductViewGolden(t *testing.T) { f := Frame{View: Product, Form: Text, Width: 80, Rung: Mono} - check(t, "three ready, 40 parked", Render(Input{Status: fixture(2, 40)}, f), `view for the product thinker + check(t, "three ready, 40 parked", Render(Input{Status: fixture(2, 40), Version: ver}, f), `view for the product thinker ● building: The board shows a product thinker what waits on them and what comes… ○ next: A 'what next?' menu under the board offers the next step, in a Terminal… ○ next: When abcd sets up a project it asks the owner whether abcd keeps its re… ○ next: Connecting a model service works out of the box • no more ready, 40 parked +abcd v0.13.4 `) - check(t, "fourteen ready, 40 parked", Render(Input{Status: fixture(13, 40)}, f), `view for the product thinker + check(t, "fourteen ready, 40 parked", Render(Input{Status: fixture(13, 40), Version: ver}, f), `view for the product thinker ● building: The board shows a product thinker what waits on them and what comes… ○ next: A 'what next?' menu under the board offers the next step, in a Terminal… ○ next: When abcd sets up a project it asks the owner whether abcd keeps its re… ○ next: Connecting a model service works out of the box • 11 more ready, 40 parked +abcd v0.13.4 `) - check(t, "nothing", Render(Input{Status: &statusblock.Block{}}, f), `view for the product thinker + check(t, "nothing", Render(Input{Status: &statusblock.Block{}, Version: ver}, f), `view for the product thinker ● building: nothing right now ○ next: nothing is ready • no more ready, nothing parked +abcd v0.13.4 `) handle := regexp.MustCompile(`\b(itd|iss|spc|adr|run|lane)-[0-9]`) command := regexp.MustCompile(`\babcd (build|capture|intent|implement|ahoy|peers|inbox|mode|spec|drain|lint|decide|update)\b`) - for _, l := range Render(Input{Status: fixture(13, 40)}, f) { + for _, l := range Render(Input{Status: fixture(13, 40), Version: ver}, f) { if handle.MatchString(l) || command.MatchString(l) || strings.Contains(l, "waiting on") || strings.Contains(l, "target") { t.Errorf("the product thinker's view carries a handle, a command or an owed answer: %q", l) } @@ -104,7 +111,7 @@ func TestProductViewGolden(t *testing.T) { // whose branch exists and whose spec is open marked in flight. func TestFacilitatorViewGolden(t *testing.T) { f := Frame{View: Facilitator, Form: Text, Width: 80, Rung: Mono} - check(t, "facilitator", Render(Input{Dir: "~/code/abcd", Status: fixture(2, 40), Rows: fullRows()}, f), `view for the facilitator + check(t, "facilitator", Render(Input{Dir: "~/code/abcd", Status: fixture(2, 40), Rows: fullRows(), Version: ver}, f), `view for the facilitator abcd — ~/code/abcd git repo: yes record: yes @@ -134,13 +141,14 @@ abcd — ~/code/abcd abcd keeps its records there itd-101 spc-101 Connecting a model service works out of the box Later: 40 intents +abcd v0.13.4 `) } // TestMarkdownFormGolden is A3's form: a list, never a table, no escape byte, // titles whole, in both views. func TestMarkdownFormGolden(t *testing.T) { - in := Input{Dir: "~/code/abcd", Status: fixture(2, 40), Rows: fullRows()[:2]} + in := Input{Dir: "~/code/abcd", Status: fixture(2, 40), Rows: fullRows()[:2], Version: ver} product := Render(in, Frame{View: Product, Form: Markdown, Width: 80, Rung: TrueColor}) check(t, "product markdown", product, `view for the product thinker @@ -149,6 +157,7 @@ func TestMarkdownFormGolden(t *testing.T) { - ○ next: When abcd sets up a project it asks the owner whether abcd keeps its records there - ○ next: Connecting a model service works out of the box - • no more ready, 40 parked +- abcd v0.13.4 `) facil := Render(in, Frame{View: Facilitator, Form: Markdown, Width: 80, Rung: TrueColor, ASCII: true}) check(t, "facilitator markdown", facil, `view for the facilitator @@ -165,6 +174,7 @@ func TestMarkdownFormGolden(t *testing.T) { - itd-100 spc-100 When abcd sets up a project it asks the owner whether abcd keeps its records there - itd-101 spc-101 Connecting a model service works out of the box - Later: 40 intents +- abcd v0.13.4 `) for _, l := range append(product, facil...) { if strings.ContainsRune(l, '\x1b') || strings.HasPrefix(strings.TrimSpace(l), "|") { @@ -184,7 +194,7 @@ func TestFenceSafeMarkdownTitles(t *testing.T) { rows := []Row{{Label: "git repo", Text: "```"}, {Label: "presence", Text: "x\n```", Items: []string{"y\n~~~ an item"}}} // The lines as a reader sees them: an element carrying a newline is // two lines on the page. - lines := strings.Split(strings.Join(Render(Input{Dir: "```", Status: b, Rows: rows}, Frame{View: v, Form: Markdown}), "\n"), "\n") + lines := strings.Split(strings.Join(Render(Input{Dir: "```", Status: b, Rows: rows, Version: core.VersionInfo{Name: "abcd", Version: "v1\n```"}}, Frame{View: v, Form: Markdown}), "\n"), "\n") if !strings.Contains(strings.Join(lines, "\n"), "close the fence") { t.Fatalf("view %d drew no title to hold the rule over:\n%s", v, strings.Join(lines, "\n")) } diff --git a/internal/core/release/emit.go b/internal/core/release/emit.go index b804ab172..c6ca19241 100644 --- a/internal/core/release/emit.go +++ b/internal/core/release/emit.go @@ -1,5 +1,6 @@ // Package release is the transport-agnostic composition of a release cut: the -// deterministic half of `abcd launch ship` and the whole of `abcd changelog`. +// deterministic half of `abcd launch ship`, and the cut `abcd launch --dry-run` +// previews. // // It is a composition, not a new domain. The version arithmetic, the record // set-difference, the tag anchor and the surface guardrail all live in diff --git a/internal/core/surface/appendix.go b/internal/core/surface/appendix.go index 7dfb75903..f8e5d2c81 100644 --- a/internal/core/surface/appendix.go +++ b/internal/core/surface/appendix.go @@ -93,9 +93,9 @@ func HostDelegatedSentence(path string) string { } // rootFlagSentence is the whole appendix of a chapter whose surface ships as a -// flag of the bare root rather than as a verb: /abcd:version runs the root's -// `--version` (iss-2609302306003610). The flag is listed in the bare command's -// own appendix, so it is named here and not tabled twice. +// flag of the bare root rather than as a verb, as the retired version page ran +// the root's `--version` (iss-2609302306003610). The flag is listed in the bare +// command's own appendix, so it is named here and not tabled twice. func rootFlagSentence(path, flag string) string { return "It ships as the root flag `--" + flag + "`, listed in the bare `abcd` command's appendix: the command tree registers no `" + path + "` verb, so there are no sub-verbs to list." diff --git a/internal/core/surface/manifest.go b/internal/core/surface/manifest.go index 1f8d08df3..4aeb878bb 100644 --- a/internal/core/surface/manifest.go +++ b/internal/core/surface/manifest.go @@ -71,7 +71,7 @@ const nameKey = "name" // An ABSENT manifest is not that case. It contributes no entries, because a repo // whose artefact is not a plugin — a binary, an application bundle, a library — // declares no plugin surface, and treating the absence as an unreadable payload -// stopped every caller of the snapshot before it ran, `abcd changelog` included +// stopped every caller of the snapshot before it ran, the cut preview included // (iss-2609100506255436). Where a plugin manifest lives is fixed; whether this // artefact has one is a per-repo fact, the same distinction adr-19 already drew // for the version location. Nor does absence hide a removal: a manifest that WAS diff --git a/internal/core/surface/manifest_test.go b/internal/core/surface/manifest_test.go index 63557ecc3..51ac1adff 100644 --- a/internal/core/surface/manifest_test.go +++ b/internal/core/surface/manifest_test.go @@ -219,8 +219,8 @@ func TestManifestEntriesUsesRepoRelativePaths(t *testing.T) { // per-repo fact — adr-19's version-location contract is abcd's own precedent for // declaring such a fact rather than assuming it. A repo whose artefact is a // binary, an application bundle or a library has no plugin manifest, and reading -// its absence as a broken payload is what stops `abcd changelog` before anything -// else runs. +// its absence as a broken payload is what stops the release cut and its preview +// before anything else runs. // // Nothing about the guardrail is weakened: the removal case // (TestManifestEntriesAbsenceStillReportsTheRemoval) is caught as a BREAK diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index 5ec8bf913..da8e9aaa7 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -90,9 +90,6 @@ var sentences = map[string]string{ "abcd capture wontfix": "Move an open issue to wontfix/ with the reason it is not acted on: " + "Writes the moved record; refuses an id this ledger does not hold.", - "abcd changelog": "Preview the next release cut's version, records, and guardrail verdict: " + - "Writes nothing; refuses outside a checkout, exiting 0 on a cut the gates would stop.", - "abcd decide": "Mint an ADR id and lay the record's empty skeleton: " + "Writes one proposed record into the decisions store; refuses a missing or unusable title.", @@ -283,7 +280,7 @@ var sentences = map[string]string{ "abcd lab sweep": "Sweep a lab's documents for every retracted pattern: " + "Writes the sweep artefact, and a finding and halt on an unapplied correction; refuses an unknown lab.", - "abcd launch": "Preview the public launch bundle, its secret scan, and the release gates: " + + "abcd launch": "Preview the release cut, the launch bundle, its secret scan and the gates: " + "Writes only its pre-flight report, to the local tier; refuses without --dry-run.", "abcd launch archive": "Render the release's plugin archive: " + "Writes the archive into --out; refuses a dirty tree without --verify, and exits 1 when --verify finds it unpinned.", diff --git a/internal/surface/cli/board.go b/internal/surface/cli/board.go index 7bf745432..ae7e4bf7e 100644 --- a/internal/surface/cli/board.go +++ b/internal/surface/cli/board.go @@ -25,6 +25,10 @@ type boardOutput struct { // View names the view asked for (spc-2610031844142274): product-thinker // or facilitator. --json carries every field whichever view it names. View string `json:"view"` + // Version is the installed version, the one `abcd --version` reports + // (spc-2610100613109045, decision 4): read from core.NewVersion, so it + // reads no network and runs no subprocess (adr-38). + Version string `json:"version"` // Statusline is present in a managed checkout and omitted — not null — // everywhere else, the collection convention of every --json envelope. Statusline *boardStatusline `json:"statusline,omitempty"` diff --git a/internal/surface/cli/board_view_test.go b/internal/surface/cli/board_view_test.go index 5aa60ec2e..b9ae59920 100644 --- a/internal/surface/cli/board_view_test.go +++ b/internal/surface/cli/board_view_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core" "github.com/intentdriven/abcd/internal/core/implement/loop" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/gittest" @@ -247,3 +248,41 @@ func TestAbcdPageRelaysTheMarkdownForm(t *testing.T) { t.Errorf("the /abcd page runs --json before the markdown form") } } + +// TestBoardShowsTheVersion is A9 at the front door (spc-2610100613109045, +// decision 4): the installed version is the board's last line in both views +// and both forms, read from the same core.VersionInfo `abcd --version` +// reports; --json carries it as `version`; and the first line stays the view +// label alone. +func TestBoardShowsTheVersion(t *testing.T) { + root := managedCheckout(t) + statusRecord(t, root) + orig := core.Version + core.Version = "v9.9.9" + t.Cleanup(func() { core.Version = orig }) + setBoardWidth(t, 80) + t.Setenv("NO_COLOR", "1") + for _, tc := range []struct { + args []string + first, last string + }{ + {nil, "view for the product thinker", "abcd v9.9.9"}, + {[]string{"--view", "facilitator"}, "view for the facilitator", "abcd v9.9.9"}, + {[]string{"--format", "markdown"}, "view for the product thinker", "- abcd v9.9.9"}, + {[]string{"--view", "facilitator", "--format", "markdown"}, "view for the facilitator", "- abcd v9.9.9"}, + } { + lines := strings.Split(strings.TrimRight(string(runCLI(t, tc.args...)), "\n"), "\n") + if lines[0] != tc.first { + t.Errorf("%v: the first line is %q, want the view label %q alone", tc.args, lines[0], tc.first) + } + if got := lines[len(lines)-1]; got != tc.last { + t.Errorf("%v: the last line is %q, want the version %q", tc.args, got, tc.last) + } + } + var got struct { + Version string `json:"version"` + } + if err := json.Unmarshal(runCLI(t, "--json"), &got); err != nil || got.Version != "v9.9.9" { + t.Errorf("--json version = %q (%v), want the version --version reports, v9.9.9", got.Version, err) + } +} diff --git a/internal/surface/cli/board_width_test.go b/internal/surface/cli/board_width_test.go index add5522c5..fc5b63f85 100644 --- a/internal/surface/cli/board_width_test.go +++ b/internal/surface/cli/board_width_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core" "github.com/intentdriven/abcd/internal/gittest" "github.com/intentdriven/abcd/internal/textwidth" ) @@ -72,7 +73,9 @@ func TestBoardWrapsEveryRowAtTheWindowWidth(t *testing.T) { if n := textwidth.Columns(l); n > cols { t.Errorf("at %d columns a line takes %d:\n%q\nin\n%s", cols, n, l, text) } - if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "abcd — ") && l != "view for the facilitator" { + // The label, the directory and the version line are the three + // rows drawn at column 0 (spc-2610100613109045, decision 4). + if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "abcd — ") && l != "view for the facilitator" && l != "abcd "+core.Version { t.Errorf("at %d columns a line starts at column 0:\n%q\nin\n%s", cols, l, text) } } diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index ca5ed5278..b4be2a1eb 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -348,7 +348,8 @@ func NewRootCommand() *cobra.Command { // plugin page relays it, and a reader that needs the path already // has its own working directory. st.Dir = fsutil.DisplayPath(st.Dir) - bo := boardOutput{StatusInfo: st, View: viewName(view), Statusline: boardPresence(cwd, cmd.ErrOrStderr()), Peers: boardPeers(cwd, cmd.ErrOrStderr()), Inbox: boardInbox(cmd.ErrOrStderr()), Oracle: boardOracle(cwd, cmd.ErrOrStderr()), Reviews: boardReviews(cwd, cmd.ErrOrStderr()), Status: boardStatus(cwd, cmd.ErrOrStderr())} + ver := core.NewVersion() + bo := boardOutput{StatusInfo: st, View: viewName(view), Version: ver.Version, Statusline: boardPresence(cwd, cmd.ErrOrStderr()), Peers: boardPeers(cwd, cmd.ErrOrStderr()), Inbox: boardInbox(cmd.ErrOrStderr()), Oracle: boardOracle(cwd, cmd.ErrOrStderr()), Reviews: boardReviews(cwd, cmd.ErrOrStderr()), Status: boardStatus(cwd, cmd.ErrOrStderr())} // One renderer draws both views in both forms; the surface reads // the window, the colour rung and the locale and hands them in // (spc-2610031844142274). In a pipe the board is drawn at Mono and @@ -363,7 +364,7 @@ func NewRootCommand() *cobra.Command { ASCII: !term.UTF8Locale(os.Getenv), } return render(cmd.OutOrStdout(), asJSON, bo, func(w io.Writer) { - for _, l := range board.Render(board.Input{Dir: st.Dir, Status: bo.Status, Rows: boardRows(st, bo)}, frame) { + for _, l := range board.Render(board.Input{Dir: st.Dir, Status: bo.Status, Rows: boardRows(st, bo), Version: ver}, frame) { fmt.Fprintln(w, l) } }) @@ -415,7 +416,9 @@ func NewRootCommand() *cobra.Command { launchCmd := &cobra.Command{ Use: "launch", Long: "Preview the release bundle and run the release gates with --dry-run; nothing is\n" + - "published.\n\n" + + "published. The preview also renders the release cut the next `launch ship` would\n" + + "make: the derived version, the deciding impact, the records, and the guard verdict.\n" + + "A refused cut is reported, and changes nothing about the preview's exit.\n\n" + "The dirty-tree gate compares the working tree with HEAD byte for byte. The repository's\n" + "content filters (filter..clean, .smudge and .process) are switched off for that\n" + "comparison, so no program a filter names runs. A filter the repository marks required\n" + @@ -435,6 +438,15 @@ func NewRootCommand() *cobra.Command { if !launchDryRun { return fmt.Errorf("abcd launch: pass --dry-run to preview the bundle (publishing is not wired at this stage)") } + // The preview carries the release cut, which is a fact about the + // repository, so the whole preview reads the checkout root wherever + // it is run, as the cut always has (iss-2609251713073532): from a + // subdirectory it no longer reads the subdirectory as a repository + // that declares nothing. Outside a checkout it reads the working + // directory, as it did, and refuses there as it did. + if root, rerr := gitutil.CheckoutRoot(cwd, "the release record"); rerr == nil { + cwd = root + } // The parity diff measures the payload against the previous // release's, rendered fresh at its tag unless --fetch-baseline asks // for the published archive (adr-38: the network answers only an @@ -472,7 +484,12 @@ func NewRootCommand() *cobra.Command { // Every preview leaves its pre-flight report in the local logs tier // (itd-65); the preview still refuses nothing and exits 0. rep.ReportPath, rep.ReportError = writePreflight(cwd, rep.PreflightReport(time.Now())) - return render(cmd.OutOrStdout(), asJSON, rep, func(w io.Writer) { + // Beside the bundle, the cut the release would make + // (spc-2610100613109045, decision 5). It is read after the bundle + // report, so a dry run that refuses still refuses as it did. + preview := launchPreview{DryRunReport: rep} + preview.Cut, preview.CutError = previewCut(cwd) + return render(cmd.OutOrStdout(), asJSON, preview, func(w io.Writer) { fmt.Fprintf(w, "abcd launch (dry-run) — version %s\n", rep.Version) fmt.Fprintf(w, " artefact kind: %s\n", rep.Kind) fmt.Fprintf(w, " scanned tree: %s\n", rep.ScannedTree) @@ -497,8 +514,12 @@ func NewRootCommand() *cobra.Command { } renderDeepSmoke(w, rep.DeepSmoke) renderParity(w, rep.Parity) - for _, tg := range rep.Targets { - fmt.Fprintf(w, " targeted: %s\n", targetLine(tg)) + // The cut below lists the same targeted intents and says which + // it moves, so they are listed here only when it was not read. + if preview.Cut == nil { + for _, tg := range rep.Targets { + fmt.Fprintf(w, " targeted: %s\n", targetLine(tg)) + } } fmt.Fprintf(w, " would publish: %v\n", rep.WouldPublish) for _, reason := range rep.WouldRefuseOn { @@ -519,10 +540,16 @@ func NewRootCommand() *cobra.Command { } else { fmt.Fprintf(w, " report: not written — %s\n", termsafe.Sanitize(rep.ReportError)) } + if preview.Cut == nil { + fmt.Fprintf(w, " cut: not read — %s\n", termsafe.Sanitize(preview.CutError)) + return + } + fmt.Fprintln(w) + renderCut(w, "abcd launch --dry-run", *preview.Cut) }) }, } - launchCmd.Flags().BoolVar(&launchDryRun, "dry-run", false, "preview the launch bundle and gates without publishing") + launchCmd.Flags().BoolVar(&launchDryRun, "dry-run", false, "preview the launch bundle, its gates and the release cut without publishing") launchCmd.Flags().BoolVar(&launchDeepSmoke, "deep-smoke", false, "also run the installability smoke's deep tier: render every command, skill and agent page's help in an isolated subprocess (always on in the cut)") launchCmd.Flags().StringVar(&launchBaseline, "baseline", "", @@ -553,7 +580,6 @@ func NewRootCommand() *cobra.Command { launchCmd.AddCommand(newLaunchSmokePagesCommand()) root.AddCommand(launchCmd) - root.AddCommand(newChangelogCommand(&asJSON)) // `reflect` is the release retrospective (itd-24): the seed a cut release's // interview opens from, and the one write that files it. root.AddCommand(newReflectCommand(&asJSON)) diff --git a/internal/surface/cli/help_truth_test.go b/internal/surface/cli/help_truth_test.go index 6a0b7ae96..58f3da0f9 100644 --- a/internal/surface/cli/help_truth_test.go +++ b/internal/surface/cli/help_truth_test.go @@ -62,6 +62,6 @@ func TestUpdateCheckHelpClaimsNoMonopolyOnTheNetwork(t *testing.T) { t.Fatalf("launch --help exited %d: %s", code, stderr.String()) } if !strings.Contains(stdout.String(), "--fetch-baseline") { - t.Fatalf("launch no longer carries --fetch-baseline; the network sentences in update --help and commands/version.md name it") + t.Fatalf("launch no longer carries --fetch-baseline; the network sentences in update --help and commands/update.md name it") } } diff --git a/internal/surface/cli/helpgroups.go b/internal/surface/cli/helpgroups.go index e07142c88..fd1f0cd8d 100644 --- a/internal/surface/cli/helpgroups.go +++ b/internal/surface/cli/helpgroups.go @@ -98,7 +98,8 @@ type helpPlacement struct { // statusline with agents. P2 keeps drain with agents until itd-82 ships. P3 // makes peers and mode agents only, P4 report, spec and rules, and P6 banlist. // memory and source stay person's verbs until they merge into library (P4); -// changelog stays with agents until it merges into launch. Neither surface caps +// changelog has merged into launch as its preview (`launch --dry-run`, P4), so +// it has no placement. Neither surface caps // the person's list (the 2026-10-09 menu ruling), and the plugin menu lists the // same commands (TestPluginPersonListEqualsTheCLIs). The groups are the spec's // decision 1: ideate under records, reading and lab under checks, dashboard @@ -126,7 +127,6 @@ var helpPlacements = map[string]helpPlacement{ // loader, and spec the user-facing half of the spec lifecycle (the spec's // decision 2). "banlist": {group: groupAgents, page: "commands/banlist.md"}, - "changelog": {group: groupAgents, page: "commands/launch.md"}, "docs": {group: groupAgents, page: "commands/docs.md"}, "guard": {group: groupAgents, page: "commands/guard.md"}, "guard hook": {page: "commands/guard.md"}, diff --git a/internal/surface/cli/helpgroups_test.go b/internal/surface/cli/helpgroups_test.go index 4b3252a06..f2c46edc0 100644 --- a/internal/surface/cli/helpgroups_test.go +++ b/internal/surface/cli/helpgroups_test.go @@ -96,7 +96,7 @@ func TestRootHelpListsThePersonsGroups(t *testing.T) { t.Errorf("%s lists %v, want %v", group, entries[group], want) } } - for _, agentVerb := range []string{"implement", "statusline", "changelog", "mode", "history", "lint", "reflect", "rules", "spec"} { + for _, agentVerb := range []string{"implement", "statusline", "mode", "history", "lint", "reflect", "rules", "spec"} { for _, group := range peopleGroupTitles { for _, got := range entries[group] { if got == agentVerb { diff --git a/internal/surface/cli/intent_target_cli_test.go b/internal/surface/cli/intent_target_cli_test.go index 7ddf79fbc..fc41e13f7 100644 --- a/internal/surface/cli/intent_target_cli_test.go +++ b/internal/surface/cli/intent_target_cli_test.go @@ -105,9 +105,15 @@ func TestLaunchPreviewAndCutListTheTargetedIntent(t *testing.T) { if err != nil { t.Fatalf("dry-run: %v\n%s", err, plain) } - if !strings.Contains(string(plain), "targeted: itd-91 targets v0.4.1, not shipped") { + // The plain preview lists it once, in the cut it renders beside the + // bundle report, which also says the cut moves it (spc-2610100613109045 + // step 3); the bundle half does not repeat the line. + if !strings.Contains(cutSection(t, plain), "targeted: itd-91 targets v0.4.1, not shipped") { t.Errorf("the plain preview must list the targeted intent:\n%s", plain) } + if n := strings.Count(string(plain), "itd-91 targets v0.4.1"); n != 1 { + t.Errorf("the plain preview lists the targeted intent %d times, want once:\n%s", n, plain) + } // The cut: the emit step lists it and stays ready (exit 0). emitted, err := shipIn(t, r, "launch", "ship") @@ -170,7 +176,7 @@ func targetedLines(out []byte) map[string]string { // TestTheCutsDryRunNamesTheTargetsItMoves is iss-2610020718369838 at the // front door: the cut's dry run — the emit step of the ship verb and the -// read-only changelog preview, which share one render — marks the target the +// cut `launch --dry-run` previews, which share one render — marks the target the // cut passes as moving to next, and leaves the target past the cut unmarked, // so a reader no longer compares each target with the derived tag by hand. // The JSON carries the same list. @@ -184,12 +190,15 @@ func TestTheCutsDryRunNamesTheTargetsItMoves(t *testing.T) { const moved = "itd-91 targets v0.4.1, not shipped (.abcd/development/intents/planned/itd-91-due.md); the cut moves it to next" const stands = "itd-93 targets v0.5.0, not shipped (.abcd/development/intents/planned/itd-93-later.md)" - for _, args := range [][]string{{"launch", "ship"}, {"changelog"}} { + for _, args := range [][]string{{"launch", "ship"}, {"launch", "--dry-run"}} { out, err := shipIn(t, r, args...) if code := exitCodeOf(err); code != 0 { t.Fatalf("%v: a target must not refuse the cut: exit = %d\n%s", args, code, out) } lines := targetedLines(out) + if args[1] == "--dry-run" { + lines = targetedLines([]byte(cutSection(t, out))) + } if lines["itd-91"] != moved { t.Errorf("%v: the target the cut reaches must be named as moving:\n got %q\nwant %q", args, lines["itd-91"], moved) } diff --git a/internal/surface/cli/launch_preflight.go b/internal/surface/cli/launch_preflight.go index 2366e3cb0..791d36702 100644 --- a/internal/surface/cli/launch_preflight.go +++ b/internal/surface/cli/launch_preflight.go @@ -7,8 +7,45 @@ import ( "github.com/intentdriven/abcd/internal/core/launch" "github.com/intentdriven/abcd/internal/core/lint" + "github.com/intentdriven/abcd/internal/core/release" + "github.com/intentdriven/abcd/internal/gitutil" ) +// launchPreview is what `abcd launch --dry-run` reports: the bundle report and, +// beside it, the release cut the next `launch ship` would make — its derived +// version, deciding impact, records, guard verdict and findings +// (spc-2610100613109045, decision 5; the preview that was `abcd changelog`). +// The report is embedded so its JSON shape is unchanged; the cut is an +// addition to it. The cut is the deterministic emit and nothing more: no prose +// is composed and no agent runs in a preview. +type launchPreview struct { + launch.DryRunReport + // Cut is the cut the release would make, present whenever it could be + // read. A refused cut is information here, as it was in the changelog + // verb, and changes no exit code the dry run gives. + Cut *release.Cut `json:"cut,omitempty"` + // CutError says why the cut could not be read, in place of Cut. It + // refuses nothing either: the dry run's verdict is the bundle's. + CutError string `json:"cut_error,omitempty"` +} + +// previewCut reads the cut the dry run renders. It is read from the checkout +// root, as `launch ship` reads it, so a dry run from a subdirectory reports +// the same records, baseline and anchor tag as one from the root +// (iss-2609251713073532). A cut that cannot be read is returned as the reason, +// already path-scrubbed, never as an error. +func previewCut(cwd string) (*release.Cut, string) { + root, err := gitutil.CheckoutRoot(cwd, "the release record") + if err != nil { + return nil, scrubPaths(err) + } + cut, err := emitCut(root) + if err != nil { + return nil, scrubPaths(err) + } + return &cut, "" +} + // noLaunchPayloadGuidance is what a repository that declares kind plugin but // no launch payload is told (iss-2608270559313719). A plugin's bundle is its // include set, so there is nothing to preview, gate or stage until it declares diff --git a/internal/surface/cli/launch_preview_test.go b/internal/surface/cli/launch_preview_test.go new file mode 100644 index 000000000..99d8ec048 --- /dev/null +++ b/internal/surface/cli/launch_preview_test.go @@ -0,0 +1,190 @@ +package cli + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// launch_preview_test.go — step 3 of spc-2610100613109045: `abcd changelog` +// merges into launch as a preview (decision 5). `abcd launch --dry-run` renders +// the deterministic cut beside its bundle report, a refused cut is information +// that changes no exit code the dry run gives, and the changelog verb is gone +// with no alias (adr-40). + +// previewFixture is shipFixture with the launch configuration a plugin's dry +// run reads, and no record shipped since the tag: each test adds the records +// its cut is made of. +func previewFixture(t *testing.T) *gittest.Repo { + t.Helper() + r := shipFixture(t) + r.Write(".abcd/config/artefact.json", `{"kind": "plugin"}`+"\n") + r.Write(".abcd/config/version-location.json", + `{"manifest_path": ".claude-plugin/plugin.json", "json_pointer": "/version"}`+"\n") + r.Write(".abcd/config/launch-payload.json", + `{"includes": [".claude-plugin", "CHANGELOG.md"]}`+"\n") + r.Commit("the release configuration") + refreshSurface(t, r) + return r +} + +// cutHeader is the first line of the cut the dry run renders. +const cutHeader = "abcd launch --dry-run — " + +// cutSection is the cut half of a plain dry run: everything from the cut's own +// header line on, so an assertion about the cut cannot be met by a line of the +// bundle report above it. +func cutSection(t *testing.T, out []byte) string { + t.Helper() + i := strings.Index(string(out), cutHeader) + if i < 0 { + t.Fatalf("the dry run renders no cut (no %q line):\n%s", cutHeader, out) + } + return string(out)[i:] +} + +// dryRunCut is the slice of the dry run's JSON these tests read: the bundle +// report's version beside the cut. +type dryRunCut struct { + Version string `json:"version"` + Cut *struct { + Ready bool `json:"ready"` + NextTag string `json:"next_tag"` + Impact string `json:"impact"` + Guard any `json:"guard"` + Refusals []struct { + Kind string `json:"kind"` + Reason string `json:"reason"` + } `json:"refusals"` + Added []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"added"` + } `json:"cut"` +} + +func decodeDryRunCut(t *testing.T, out []byte) dryRunCut { + t.Helper() + var got dryRunCut + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("launch --dry-run --json is not JSON: %v\n%s", err, out) + } + if got.Cut == nil { + t.Fatalf("launch --dry-run --json carries no cut:\n%s", out) + } + return got +} + +// TestLaunchDryRunRendersTheCut is A10's preview half: the dry run renders the +// cut the release would make, its derived version, its records and its guard +// verdict, in text and in --json, beside the bundle report; and a refused cut +// is reported as information, exit 0, as the dry run's exit is today. +func TestLaunchDryRunRendersTheCut(t *testing.T) { + t.Run("ready", func(t *testing.T) { + r := previewFixture(t) + r.Write(".abcd/development/intents/shipped/itd-73-derived-versioning.md", + "---\nid: itd-73\nimpact: additive\n---\n\n# A Version Is A Fact\n\nthe version is derived.\n") + r.Commit("ship an intent") + + out, err := shipIn(t, r, "launch", "--dry-run") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("exit = %d, want 0\n%s\n%v", code, out, err) + } + if !strings.HasPrefix(string(out), "abcd launch (dry-run) — version ") { + t.Errorf("the bundle report no longer leads the dry run:\n%s", out) + } + cut := cutSection(t, out) + for _, want := range []string{ + cutHeader + "v0.4.0 -> v0.4.1 (additive)", + "guard: ", + "itd-73", "A Version Is A Fact", + } { + if !strings.Contains(cut, want) { + t.Errorf("the dry run's cut does not mention %q:\n%s", want, out) + } + } + + js, err := shipIn(t, r, "launch", "--dry-run", "--json") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("--json exit = %d, want 0\n%s", code, js) + } + got := decodeDryRunCut(t, js) + if got.Version == "" { + t.Errorf("the bundle report's fields left the JSON:\n%s", js) + } + if !got.Cut.Ready || got.Cut.NextTag != "v0.4.1" || got.Cut.Impact != "additive" || got.Cut.Guard == nil { + t.Errorf("cut = %+v, want a ready v0.4.1 additive cut with its guard", *got.Cut) + } + if len(got.Cut.Added) != 1 || got.Cut.Added[0].ID != "itd-73" { + t.Errorf("cut.added = %+v, want itd-73", got.Cut.Added) + } + }) + + t.Run("refused", func(t *testing.T) { + r := previewFixture(t) + + out, err := shipIn(t, r, "launch", "--dry-run") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("a refused cut changed the dry run's exit: %d, want 0\n%s\n%v", code, out, err) + } + cut := cutSection(t, out) + for _, want := range []string{cutHeader + "REFUSED", "empty-cut"} { + if !strings.Contains(cut, want) { + t.Errorf("the dry run's cut does not mention %q:\n%s", want, out) + } + } + + js, err := shipIn(t, r, "launch", "--dry-run", "--json") + if code := exitCodeOf(err); code != 0 { + t.Fatalf("--json exit = %d, want 0\n%s", code, js) + } + if got := decodeDryRunCut(t, js); got.Cut.Ready || len(got.Cut.Refusals) == 0 { + t.Errorf("cut = %+v, want a refused cut with its refusal", *got.Cut) + } + }) +} + +// TestChangelogIsAnUnknownCommand is A10's removal half: with the preview in +// launch, `abcd changelog` is an unknown command (no alias, adr-40), and its +// note names what answers instead rather than calling the binary stale for not +// knowing it. +func TestChangelogIsAnUnknownCommand(t *testing.T) { + root := stalePluginRoot(t) + setExecutable(t, filepath.Join(root, "abcd")) + code, stdout, stderr := runMain(t, "changelog") + if code != 2 || stdout != "" { + t.Fatalf("`abcd changelog` exit = %d, stdout %q; want exit 2 and an empty stdout", code, stdout) + } + if !strings.Contains(stderr, "unknown command") { + t.Errorf("`abcd changelog` is not refused as an unknown command:\n%s", stderr) + } + if !strings.Contains(stderr, "abcd launch --dry-run") { + t.Errorf("the refusal does not name the preview that replaced it:\n%s", stderr) + } + for _, not := range []string{"predates", "make build", "abcd update"} { + if strings.Contains(stderr, not) { + t.Errorf("the refusal names %q:\n%s", not, stderr) + } + } +} + +// TestPreviewCutOutsideACheckoutNamesTheCheckout carries the retired changelog +// verb's outside-a-checkout refusal onto the preview: the cut the dry run +// would render is not read, and the reason names the missing checkout rather +// than git's bare exit status or the caller's path. +func TestPreviewCutOutsideACheckoutNamesTheCheckout(t *testing.T) { + dir := t.TempDir() + cut, reason := previewCut(dir) + if cut != nil { + t.Fatalf("a cut was read outside a checkout: %+v", *cut) + } + if !strings.Contains(reason, "not inside a git repository") || strings.Contains(reason, "exit status") { + t.Errorf("reason = %q, want it to name the missing checkout", reason) + } + if strings.Contains(reason, dir) { + t.Errorf("reason leaks an absolute path: %q", reason) + } +} diff --git a/internal/surface/cli/ship.go b/internal/surface/cli/ship.go index 0730782ca..9ac1c65c0 100644 --- a/internal/surface/cli/ship.go +++ b/internal/surface/cli/ship.go @@ -699,45 +699,9 @@ func runShipIngest(cmd *cobra.Command, cwd string, raw []byte, payloadDir string return nil } -// newChangelogCommand builds `abcd changelog`, the DETERMINISTIC-ONLY preview of -// the next release cut. -// -// It renders exactly what `abcd launch ship` would emit — the derived version, -// the deciding impact, the record list, and the guardrail status — and no prose: -// the changelog text is composed once, at the reviewed ship, never twice with a -// chance of disagreeing. It performs ZERO writes. -// -// It always exits 0 (2 only on a structural fault), which is the deliberate -// difference from `launch ship`. This is a status render in the shape of `abcd -// capture` bare and `abcd launch --dry-run`: a refused cut is information a -// reader asked for, not a gate they tripped. The gate is the ship verb. -func newChangelogCommand(asJSON *bool) *cobra.Command { - return &cobra.Command{ - Use: "changelog", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - cwd, err := os.Getwd() - if err != nil { - return err - } - // The same root the ship verb reads, from wherever it is run. - root, err := gitutil.CheckoutRoot(cwd, "the release record") - if err != nil { - return &exitError{Code: 2, Msg: "abcd changelog: " + scrubPaths(err)} - } - cut, err := emitCut(root) - if err != nil { - return &exitError{Code: 2, Msg: "abcd changelog: " + scrubPaths(err)} - } - return render(cmd.OutOrStdout(), *asJSON, cut, func(w io.Writer) { - renderCut(w, "abcd changelog", cut) - }) - }, - } -} - -// renderCut writes the human rendering of a cut, shared by the preview and the -// ship verb so the two can never describe the same repository differently. +// renderCut writes the human rendering of a cut, shared by the preview (`launch +// --dry-run`) and the ship verb so the two can never describe the same +// repository differently. // // Every value that came out of a record — a title, a path, a refusal quoting // either — is sanitised: record frontmatter and prose are author-supplied text diff --git a/internal/surface/cli/ship_test.go b/internal/surface/cli/ship_test.go index 344421d06..bebcb0840 100644 --- a/internal/surface/cli/ship_test.go +++ b/internal/surface/cli/ship_test.go @@ -268,10 +268,12 @@ func TestLaunchShipIngestOnARefusedCutExits1(t *testing.T) { } } -// TestChangelogPreviewWritesNothing is deliverable 4's contract, asserted the -// only way that means anything: hash the whole tree before and after. -func TestChangelogPreviewWritesNothing(t *testing.T) { - r := shipFixture(t) +// TestLaunchDryRunCutWritesNothing is deliverable 4's contract, carried from +// the retired changelog verb onto the preview that replaced it, and asserted +// the only way that means anything: hash the whole tree before and after (the +// local tier, where the dry run keeps its pre-flight report, aside). +func TestLaunchDryRunCutWritesNothing(t *testing.T) { + r := previewFixture(t) r.Write(".abcd/development/intents/shipped/itd-73-x.md", "---\nid: itd-73\nimpact: additive\n---\n# x\n") // A standing release page, so the digest proves the preview neither // rewrites it nor archives it. @@ -279,27 +281,30 @@ func TestChangelogPreviewWritesNothing(t *testing.T) { r.Commit("ship an intent") before := cliTreeDigest(t, r.Root()) - out, err := shipIn(t, r, "changelog") + out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 0 { t.Fatalf("exit = %d, want 0 (a preview always reports)\n%s", code, out) } + if !strings.Contains(cutSection(t, out), "v0.4.1") { + t.Errorf("the preview does not render the cut it read:\n%s", out) + } if after := cliTreeDigest(t, r.Root()); after != before { t.Errorf("the preview changed the working tree:\nbefore %s\nafter %s", before, after) } } -// TestChangelogPreviewRefusalStillExitsZero separates the preview from the gate: -// `abcd changelog` REPORTS a refused cut (like `launch --dry-run`), while -// `launch ship` exits non-zero on the same repository. -func TestChangelogPreviewRefusalStillExitsZero(t *testing.T) { - r := shipFixture(t) +// TestLaunchDryRunRefusedCutStillExitsZero separates the preview from the +// gate: `launch --dry-run` REPORTS a refused cut, as the retired changelog verb +// did, while `launch ship` exits non-zero on the same repository. +func TestLaunchDryRunRefusedCutStillExitsZero(t *testing.T) { + r := previewFixture(t) r.Commit("nothing shipped at all") - out, err := shipIn(t, r, "changelog") + out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 0 { t.Fatalf("exit = %d, want 0\n%s", code, out) } - if !strings.Contains(string(out), "empty-cut") { + if !strings.Contains(cutSection(t, out), "empty-cut") { t.Errorf("preview does not name the refusal:\n%s", out) } @@ -308,28 +313,36 @@ func TestChangelogPreviewRefusalStillExitsZero(t *testing.T) { } } -// TestChangelogPreviewJSON pins the machine surface the next stages read. -func TestChangelogPreviewJSON(t *testing.T) { - r := shipFixture(t) +// TestLaunchDryRunCutJSON pins the machine surface the next stages read: the +// cut sits under the dry run's `cut` key, in the shape the retired changelog +// verb emitted at the top level. +func TestLaunchDryRunCutJSON(t *testing.T) { + r := previewFixture(t) r.Write(".abcd/development/intents/shipped/itd-73-x.md", "---\nid: itd-73\nimpact: additive\n---\n\n# Title\n\nsummary.\n") r.Commit("ship an intent") - out, err := shipIn(t, r, "changelog", "--json") + out, err := shipIn(t, r, "launch", "--dry-run", "--json") if code := exitCodeOf(err); code != 0 { t.Fatalf("exit = %d, want 0\n%s", code, out) } - var got struct { - Ready bool `json:"ready"` - NextTag string `json:"next_tag"` - Added []struct { - ID string `json:"id"` - Title string `json:"title"` - } `json:"added"` + var preview struct { + Cut *struct { + Ready bool `json:"ready"` + NextTag string `json:"next_tag"` + Added []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"added"` + } `json:"cut"` } - if err := json.Unmarshal(out, &got); err != nil { - t.Fatalf("changelog --json is not JSON: %v\n%s", err, out) + if err := json.Unmarshal(out, &preview); err != nil { + t.Fatalf("launch --dry-run --json is not JSON: %v\n%s", err, out) + } + if preview.Cut == nil { + t.Fatalf("launch --dry-run --json carries no cut:\n%s", out) } + got := *preview.Cut if !got.Ready || got.NextTag != "v0.4.1" { t.Errorf("ready=%v next_tag=%q, want true v0.4.1", got.Ready, got.NextTag) } @@ -360,7 +373,7 @@ func TestLaunchShipWritesNothingYet(t *testing.T) { // refusal — and the diagnostic must be path-scrubbed, because it names files // under the caller's working directory. func TestShipStructuralFaultExits2(t *testing.T) { - for _, args := range [][]string{{"launch", "ship"}, {"changelog"}} { + for _, args := range [][]string{{"launch", "ship"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { t.Chdir(t.TempDir()) out, err := runCLIErr(t, args...) @@ -379,14 +392,17 @@ func TestShipStructuralFaultExits2(t *testing.T) { // checkout read the same records, baseline and anchor tag as from its root. // Handing the working directory over as the root made the preview report an // empty cut and a missing baseline there with exit 0 (iss-2609251713073532). +// The preview is `launch --dry-run` now, whose bundle half read the +// subdirectory as a repository declaring no artefact kind and refused before +// any cut was read, so it resolves the checkout root as the cut does. func TestCutReadsTheWholeCheckoutFromASubdirectory(t *testing.T) { - r := shipFixture(t) + r := previewFixture(t) r.Write(".abcd/development/intents/shipped/itd-73-derived-versioning.md", "---\nid: itd-73\nimpact: additive\n---\n\n# A Version Is A Fact\n\nthe version is derived.\n") r.Commit("ship an intent") sub := filepath.Join(r.Root(), ".abcd", "development") - for _, args := range [][]string{{"changelog"}, {"launch", "ship"}} { + for _, args := range [][]string{{"launch", "--dry-run"}, {"launch", "ship"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { t.Chdir(sub) out, err := runCLIErr(t, args...) @@ -402,10 +418,12 @@ func TestCutReadsTheWholeCheckoutFromASubdirectory(t *testing.T) { } } -// TestCutOutsideACheckoutNamesTheCheckout: outside any repository both verbs -// refuse naming what is missing, not with git's bare exit status. +// TestCutOutsideACheckoutNamesTheCheckout: outside any repository the cut +// refuses naming what is missing, not with git's bare exit status. The dry +// run's bundle half refuses there before the cut is read, as it always has; +// TestPreviewCutOutsideACheckoutNamesTheCheckout holds the cut's own reason. func TestCutOutsideACheckoutNamesTheCheckout(t *testing.T) { - for _, args := range [][]string{{"changelog"}, {"launch", "ship"}} { + for _, args := range [][]string{{"launch", "ship"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { t.Chdir(t.TempDir()) out, err := runCLIErr(t, args...) @@ -508,21 +526,21 @@ func TestRenderEntriesShowsAnUnreadableShippedIn(t *testing.T) { } } -// TestChangelogPreviewListsThePageSet: the read-only preview names the intents +// TestLaunchDryRunCutListsThePageSet: the read-only preview names the intents // the release page will be composed from, and marks them in the entry list. -func TestChangelogPreviewListsThePageSet(t *testing.T) { - r := shipFixture(t) +func TestLaunchDryRunCutListsThePageSet(t *testing.T) { + r := previewFixture(t) r.Write(".abcd/development/intents/shipped/itd-73-x.md", "---\nid: itd-73\nimpact: additive\n---\n\n# A Version Is A Fact\n") r.Write(".abcd/development/intents/shipped/itd-97-y.md", "---\nid: itd-97\nimpact: internal\n---\n# Plumbing\n") r.Write(".abcd/work/issues/resolved/iss-51-crash.md", "---\nid: iss-51\nimpact: fix\n---\n# x\n") r.Commit("ship a mixed cut") - out, err := shipIn(t, r, "changelog") + out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 0 { t.Fatalf("exit = %d, want 0\n%s", code, out) } - got := string(out) + got := cutSection(t, out) if !strings.Contains(got, "release page: 1 intent(s)") { t.Errorf("the preview does not count the page set:\n%s", got) } @@ -534,24 +552,24 @@ func TestChangelogPreviewListsThePageSet(t *testing.T) { t.Errorf("the entry list does not mark the page's intent:\n%s", got) } - jsonOut, err := shipIn(t, r, "changelog", "--json") + jsonOut, err := shipIn(t, r, "launch", "--dry-run", "--json") if exitCodeOf(err) != 0 || !strings.Contains(string(jsonOut), `"in_press_release": true`) { - t.Errorf("changelog --json does not carry in_press_release:\n%s", jsonOut) + t.Errorf("launch --dry-run --json does not carry in_press_release:\n%s", jsonOut) } } -// TestChangelogPreviewSaysNoPageForAFixesOnlyCut: with no user-facing intent the -// preview says no page will be written, and why. -func TestChangelogPreviewSaysNoPageForAFixesOnlyCut(t *testing.T) { - r := shipFixture(t) +// TestLaunchDryRunCutSaysNoPageForAFixesOnlyCut: with no user-facing intent +// the preview says no page will be written, and why. +func TestLaunchDryRunCutSaysNoPageForAFixesOnlyCut(t *testing.T) { + r := previewFixture(t) r.Write(".abcd/work/issues/resolved/iss-51-crash.md", "---\nid: iss-51\nimpact: fix\n---\n# x\n") r.Commit("a fix alone") - out, err := shipIn(t, r, "changelog") + out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 0 { t.Fatalf("exit = %d, want 0\n%s", code, out) } - if !strings.Contains(string(out), "release page: none (no user-facing intent shipped; RELEASE.md stays as it is)") { + if !strings.Contains(cutSection(t, out), "release page: none (no user-facing intent shipped; RELEASE.md stays as it is)") { t.Errorf("the preview does not say no page will be written:\n%s", out) } } diff --git a/internal/surface/cli/ship_uncommitted_test.go b/internal/surface/cli/ship_uncommitted_test.go index 2504f8e07..0b7b00f45 100644 --- a/internal/surface/cli/ship_uncommitted_test.go +++ b/internal/surface/cli/ship_uncommitted_test.go @@ -98,28 +98,37 @@ func TestLaunchShipIgnoresDirtOutsideTheRecords(t *testing.T) { } } -// TestChangelogPreviewReportsUncommittedRecords: the preview is a status render -// that always exits 0 and reports a refused cut as information, so it carries -// the same refusal — loudly, as REFUSED with the paths — rather than a cut that -// silently leaves the record out. -func TestChangelogPreviewReportsUncommittedRecords(t *testing.T) { +// TestLaunchDryRunReportsUncommittedRecords: the preview is a status render +// that reports a refused cut as information and keeps its own exit, so it +// carries the same refusal — loudly, as REFUSED with the paths — rather than a +// cut that silently leaves the record out. +func TestLaunchDryRunReportsUncommittedRecords(t *testing.T) { r := shipWithUncommittedClose(t) + r.Write(".abcd/config/artefact.json", `{"kind": "plugin"}`+"\n") + r.Write(".abcd/config/launch-payload.json", `{"includes": [".claude-plugin", "CHANGELOG.md"]}`+"\n") - out, err := shipIn(t, r, "changelog") + out, err := shipIn(t, r, "launch", "--dry-run") if code := exitCodeOf(err); code != 0 { - t.Fatalf("exit = %d, want 0\n%s", code, out) + t.Fatalf("exit = %d, want 0\n%s\n%v", code, out, err) } + cut := cutSection(t, out) for _, want := range []string{"REFUSED", "uncommitted-records", uncommittedIntent} { - if !strings.Contains(string(out), want) { + if !strings.Contains(cut, want) { t.Errorf("preview does not mention %q:\n%s", want, out) } } - out, err = shipIn(t, r, "changelog", "--json") + out, err = shipIn(t, r, "launch", "--dry-run", "--json") if code := exitCodeOf(err); code != 0 { t.Fatalf("--json exit = %d, want 0\n%s", code, out) } - if got := decodeCut(t, out); got.Ready || len(got.Refusals) != 1 || got.Refusals[0].Kind != "uncommitted-records" { + var preview struct { + Cut *cutJSON `json:"cut"` + } + if err := json.Unmarshal(out, &preview); err != nil || preview.Cut == nil { + t.Fatalf("launch --dry-run --json carries no cut (%v):\n%s", err, out) + } + if got := *preview.Cut; got.Ready || len(got.Refusals) != 1 || got.Refusals[0].Kind != "uncommitted-records" { t.Errorf("ready=%v refusals=%+v, want the uncommitted-records refusal", got.Ready, got.Refusals) } } diff --git a/internal/surface/cli/staleusage.go b/internal/surface/cli/staleusage.go index 4cdc03817..c717f48f3 100644 --- a/internal/surface/cli/staleusage.go +++ b/internal/surface/cli/staleusage.go @@ -38,7 +38,7 @@ import ( // than the binary, and the remedy follows where the binary sits: a source // checkout is rebuilt, a plugin-root binary is replaced by a plugin update, // a PATH copy takes `abcd update`. -// 2. Otherwise, the disk-only vintage comparison the `version` verb already +// 2. Otherwise, the disk-only vintage comparison `abcd --version` already // renders: behind the checkout tip (dogfood) or differing from the release // the plugin cache pinned. // 3. Otherwise nothing — cobra's line stands alone, byte-for-byte. @@ -85,7 +85,16 @@ var pagesWithNoVerb = map[string]string{ "consult": "`consult` has no binary verb — it runs in the host agent; invoke it as /abcd:consult", "ingest": "`ingest` has no binary verb — it runs in the host agent; invoke it as /abcd:ingest", "prepare-this-repo": "`prepare-this-repo` has no binary verb — it runs in the host agent; invoke it as /abcd:prepare-this-repo", - "version": "`version` is a root flag, not a verb: run `abcd --version` (asking for a newer release is the update verb's --check); /abcd:version runs it", +} + +// retiredTokens are top-level tokens whose page or verb was retired with no +// alias (adr-40, itd-2610090831227812), each with what answers instead. The +// command still refuses; the note only names the successor, so a binary that +// does not know a retired name is never called stale for it. A token here has +// no page, so it is not in pagesWithNoVerb. +var retiredTokens = map[string]string{ + "changelog": "`changelog` merged into launch as its preview: run `abcd launch --dry-run`, which renders the release cut (derived version, records, guard verdict) beside the bundle report", + "version": "`version` is a root flag, not a verb: run `abcd --version`, and bare `abcd` shows the installed version as the board's last line (asking for a newer release is the update verb's --check)", } // maxCommandPageBytes caps a command-page read; the pages are a few KiB. @@ -114,6 +123,9 @@ func staleUsageNote(root *cobra.Command, args []string, msg string) string { if what, noVerb := pagesWithNoVerb[skew.verb]; noVerb { return what } + if what, retired := retiredTokens[skew.verb]; retired { + return what + } } // A status/show sub-verb under a record verb is answered by the record // dispatcher, not by a newer binary (iss-2609190337466942). @@ -137,7 +149,7 @@ func staleUsageNote(root *cobra.Command, args []string, msg string) string { } } // No page names what was typed (a typo, or a surface no older than the - // binary): fall back to the vintage the `version` verb renders, git-only and + // binary): fall back to the vintage `abcd --version` renders, git-only and // disk-only. A fresh or undeterminable vintage says nothing. cwd, err := os.Getwd() if err != nil { diff --git a/internal/surface/cli/staleusage_pageverb_test.go b/internal/surface/cli/staleusage_pageverb_test.go index 61fbb551d..26e8f926d 100644 --- a/internal/surface/cli/staleusage_pageverb_test.go +++ b/internal/surface/cli/staleusage_pageverb_test.go @@ -76,13 +76,47 @@ func TestPageWithNoVerbSetLeavesRealStalenessAlone(t *testing.T) { // Every entry names a token the binary really lacks: a verb that later gains a // Go implementation must leave the set, or its own unknown-command path (a // genuinely stale binary) would be answered with "no binary verb". +// The same holds for a retired token: one the binary registers again is not +// retired. func TestPagesWithNoVerbNameNoRegisteredVerb(t *testing.T) { root := NewRootCommand() - for verb := range pagesWithNoVerb { - for _, c := range root.Commands() { - if c.Name() == verb || c.HasAlias(verb) { - t.Errorf("pagesWithNoVerb names %q, which the binary registers as a verb", verb) + for set, tokens := range map[string]map[string]string{"pagesWithNoVerb": pagesWithNoVerb, "retiredTokens": retiredTokens} { + for verb := range tokens { + for _, c := range root.Commands() { + if c.Name() == verb || c.HasAlias(verb) { + t.Errorf("%s names %q, which the binary registers as a verb", set, verb) + } } } } } + +// TestRetiredVersionTokenNamesWhatReplacedIt is the version half of +// spc-2610100613109045's old-names rule: with the version page retired, +// `abcd version` is still an unknown command (no alias, adr-40), and its note +// names what answers instead (the root flag and the board) rather than the +// retired page, and never calls the binary stale for not knowing it. +func TestRetiredVersionTokenNamesWhatReplacedIt(t *testing.T) { + if _, ok := pagesWithNoVerb["version"]; ok { + t.Fatalf("pagesWithNoVerb still names version, whose page is retired") + } + root := stalePluginRoot(t) + setExecutable(t, filepath.Join(root, "abcd")) + code, stdout, stderr := runMain(t, "version") + if code != 2 || stdout != "" { + t.Fatalf("`abcd version` exit = %d, stdout %q; want exit 2 and an empty stdout", code, stdout) + } + if !strings.Contains(stderr, "unknown command") { + t.Errorf("`abcd version` is not refused as an unknown command:\n%s", stderr) + } + for _, want := range []string{"abcd --version", "board"} { + if !strings.Contains(stderr, want) { + t.Errorf("the refusal does not name %q:\n%s", want, stderr) + } + } + for _, not := range []string{"/abcd:version", "predates", "make build", "abcd update"} { + if strings.Contains(stderr, not) { + t.Errorf("the refusal names %q:\n%s", not, stderr) + } + } +} diff --git a/internal/surface/cli/supersededroot_test.go b/internal/surface/cli/supersededroot_test.go index a32127155..c30b8e58f 100644 --- a/internal/surface/cli/supersededroot_test.go +++ b/internal/surface/cli/supersededroot_test.go @@ -259,7 +259,7 @@ func TestSupersededRootNoteIsSanitised(t *testing.T) { // carrying the note says to relay it as abcd printed it, names what the // sanitising removed, and forbids reconstructing the names. func TestCommandPagesRelayTheNoteAsPrinted(t *testing.T) { - for _, page := range []string{"version.md", "ahoy.md"} { + for _, page := range []string{"abcd.md", "ahoy.md"} { data, err := os.ReadFile(filepath.Join("..", "..", "..", "commands", page)) if err != nil { t.Fatal(err) diff --git a/internal/surface/cli/surfaceparity_test.go b/internal/surface/cli/surfaceparity_test.go index 7eec9d010..37c9e1d0b 100644 --- a/internal/surface/cli/surfaceparity_test.go +++ b/internal/surface/cli/surfaceparity_test.go @@ -37,7 +37,6 @@ const bareCommandFile = "abcd" // gap. A verb added here must be a verb a plugin user has no business invoking — // never a verb whose command file merely has not been written yet. var cliOnlyVerbs = map[string]string{ - "changelog": "deterministic release-cut input consumed by `launch ship`; the plugin surface orchestrates the cut through commands/launch.md", "hook": "operator-internal host adapter set, live-wired from hooks/hooks.json and invoked by the harness, never by a user", "rules": "operator-internal rule injection driven by the prompt hook; its bare render is read-only diagnostics", "spec": "internal spec-store tooling for the intent lifecycle; the user-facing half is commands/intent.md",