From 7ea6aee6ff80fd1a0748c8798ee6130dc30c88af Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:31:27 +0100 Subject: [PATCH 1/5] feat: show the installed version as the board's last line Bare abcd, its markdown form and --json now carry the installed version, read from the same core.VersionInfo that abcd --version reports, with no network and no subprocess. The version is the last line of both views and both forms, a list item in markdown so the fence rule holds, and --json gains a version field. The first line stays the view label alone. Step 2 of spc-2610100613109045 (decision 4). Assisted-by: Claude:claude-opus-5-5 --- internal/core/board/board.go | 24 +++++++++++++++ internal/core/board/board_test.go | 24 ++++++++++----- internal/surface/cli/board.go | 4 +++ internal/surface/cli/board_view_test.go | 39 ++++++++++++++++++++++++ internal/surface/cli/board_width_test.go | 5 ++- internal/surface/cli/cli.go | 5 +-- 6 files changed, 91 insertions(+), 10 deletions(-) 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/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..0c266bc51 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) } }) From 0541dcae5995f050f2f4878a9e7a695d69f01d3e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:39:40 +0100 Subject: [PATCH 2/5] feat: retire the version page now the board shows the version commands/version.md goes with no alias (adr-40): the board's last line names the installed version, and the board page now carries what an agent still needed from the version page: running abcd --version for the install mode, vintage and staleness, saying a superseded root first, and recovering when no binary resolves. abcd --version is untouched. abcd version stays an unknown command. Its note now names the root flag and the board from a retiredTokens set, since it no longer has a page to sit in pagesWithNoVerb. The brief folds 12-version.md into 08-abcd.md's new section on the version, drops register row 12 and the index entry, and repoints the links that named the chapter. The release-gate manifest drops the chapter and the page, with its checker count, prompt hash and the receipt example rehashed. README points a new install at the board. Step 2 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- .../brief/02-constraints/04-naming.md | 4 +- .../development/brief/04-surfaces/01-ahoy.md | 2 +- .../development/brief/04-surfaces/08-abcd.md | 131 +++++++++++++++ .../brief/04-surfaces/12-version.md | 149 ------------------ .../brief/04-surfaces/21-update.md | 4 +- .abcd/development/brief/04-surfaces/README.md | 7 +- .abcd/development/release-gate/manifest.json | 7 +- .../release-gate/receipt.example.json | 2 +- README.md | 4 +- commands/abcd.md | 54 +++++++ commands/version.md | 95 ----------- internal/surface/cli/help_truth_test.go | 2 +- internal/surface/cli/staleusage.go | 17 +- .../surface/cli/staleusage_pageverb_test.go | 42 ++++- internal/surface/cli/supersededroot_test.go | 2 +- 15 files changed, 253 insertions(+), 269 deletions(-) delete mode 100644 .abcd/development/brief/04-surfaces/12-version.md delete mode 100644 commands/version.md diff --git a/.abcd/development/brief/02-constraints/04-naming.md b/.abcd/development/brief/02-constraints/04-naming.md index a0150fedd..4dd929642 100644 --- a/.abcd/development/brief/02-constraints/04-naming.md +++ b/.abcd/development/brief/02-constraints/04-naming.md @@ -54,8 +54,8 @@ The exemptions carrying a rationale of their own: The remaining surfaces are exempt for the plain reason that no maritime cognate adds meaning: `banlist`, `changelog`, `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/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index 0af40b1de..b6b5f4f60 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -299,6 +299,137 @@ 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) 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..1848b1c8d 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) | @@ -147,7 +146,8 @@ the command's sentence (the section below). 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). 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 @@ -324,8 +324,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/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/README.md b/README.md index e5ab3748b..87e1fb43f 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. 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/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/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/staleusage.go b/internal/surface/cli/staleusage.go index 4cdc03817..2f2a3f9df 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,15 @@ 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{ + "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 +122,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 +148,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) From 2a9f2fdfe2ebb3e01cdb4a0b99022cce769f40d6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:40:49 +0100 Subject: [PATCH 3/5] docs: stop the appendix comment saying the version page runs the flag The version page retired in the previous commit, so the comment on the root-flag sentence names it in the past tense. The generic sentence stays for any surface that ships as a root flag. Assisted-by: Claude:claude-opus-5-5 --- internal/core/surface/appendix.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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." From 5b02c6b48067eb36506f7934a0941cfcf7df6975 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:12:14 +0100 Subject: [PATCH 4/5] docs: end the README's board sample with the version line The README tells a new install that the board's last line names the installed version, and its own sample board ended on the count line. Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 87e1fb43f..b5cbb34a0 100644 --- a/README.md +++ b/README.md @@ -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. From daa7f92be0ed29f8d9d4a05d5aee0da8e0452b3f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:12:54 +0100 Subject: [PATCH 5/5] docs: keep the list of network-touching commands on the update page The version page held adr-38's list of every command that reaches the network, and retiring the page left it on no page. The update page is where the release origin is already reached, so the list moves there. Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- commands/update.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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