From 4902cb76c55e41e8dcc2f6c8120cb980c6735aab Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:28:24 +0100 Subject: [PATCH 01/12] feat: show the release cut in the launch preview and retire changelog abcd launch --dry-run now renders the cut the next launch ship would make beside its bundle report: the derived version, the deciding impact, the records, the guard and findings verdicts, in text under its own line and in --json under a new cut key (cut_error when it cannot be read). It is the same deterministic emit and render the changelog verb used, with no prose and no agent. A refused cut is reported and changes nothing about the dry run's exit. The changelog verb goes with no alias (adr-40). abcd changelog is an unknown command whose note names abcd launch --dry-run. Its placement, sentence, parity note and DOGFOODING recall entry go with it, and its tests move onto the preview: the tree-digest check, the refused cut still exiting 0, the JSON, the page set, the uncommitted-records refusal and the subdirectory read. The dry run now reads the checkout root wherever it is run, as the cut always has. Before, from a subdirectory it read the subdirectory as a repository that declares no artefact kind and refused before reaching the cut, which would have lost the subdirectory read the changelog verb had. The dry run's own targeted lines are dropped when the cut is read, because the cut lists the same intents and says which ones it moves. The pages, the composer agent, the brief and the principle that named the verb now name the preview. The launch chapter no longer says the preview always exits 0 (a peer finding): a --baseline that names no release tag exits 2, and an undeclared kind or a missing payload is refused. Step 3 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/01-product/04-scope.md | 2 +- .../brief/02-constraints/04-naming.md | 2 +- .../brief/04-surfaces/04-launch.md | 54 +++-- .abcd/development/brief/04-surfaces/README.md | 8 +- .../05-internals/04-universal-patterns.md | 2 +- .../brief/05-internals/08-skills.md | 2 +- .../pre-existing-is-not-a-defence.md | 4 +- .abcd/development/release/surface.json | 10 +- .abcd/rules.json | 1 - agents/release-changelog-composer.md | 2 +- commands/intent.md | 4 +- commands/launch.md | 47 +++-- docs/reference/cli/commands.md | 14 +- internal/core/release/emit.go | 3 +- internal/core/surface/manifest_test.go | 4 +- internal/core/surface/sentences.go | 5 +- internal/surface/cli/cli.go | 37 +++- internal/surface/cli/helpgroups.go | 4 +- internal/surface/cli/helpgroups_test.go | 2 +- .../surface/cli/intent_target_cli_test.go | 15 +- internal/surface/cli/launch_preflight.go | 37 ++++ internal/surface/cli/launch_preview_test.go | 190 ++++++++++++++++++ internal/surface/cli/ship.go | 42 +--- internal/surface/cli/ship_test.go | 106 ++++++---- internal/surface/cli/ship_uncommitted_test.go | 29 ++- internal/surface/cli/staleusage.go | 3 +- internal/surface/cli/surfaceparity_test.go | 1 - 27 files changed, 454 insertions(+), 176 deletions(-) create mode 100644 internal/surface/cli/launch_preview_test.go 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 4dd929642..8866157b7 100644 --- a/.abcd/development/brief/02-constraints/04-naming.md +++ b/.abcd/development/brief/02-constraints/04-naming.md @@ -52,7 +52,7 @@ 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`, and `update`. They are registered here so the exemption is on the record. diff --git a/.abcd/development/brief/04-surfaces/04-launch.md b/.abcd/development/brief/04-surfaces/04-launch.md index b32a4aab8..f143de5cd 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/README.md b/.abcd/development/brief/04-surfaces/README.md index 1848b1c8d..b4656736b 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -141,13 +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, and bare `abcd` -shows the installed version as the board's last line (itd-2610090831227812). The product +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 | 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/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/agents/release-changelog-composer.md b/agents/release-changelog-composer.md index 55add0802..6025da74a 100644 --- a/agents/release-changelog-composer.md +++ b/agents/release-changelog-composer.md @@ -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/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 eadad383d..0d153c5da 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/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/release/emit.go b/internal/core/release/emit.go index 3eac61c6a..c6e0dfeb3 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/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/cli.go b/internal/surface/cli/cli.go index 0c266bc51..b4be2a1eb 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -416,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" + @@ -436,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 @@ -473,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) @@ -498,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 { @@ -520,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", "", @@ -554,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/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 b761285d0..519eada3b 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 2f2a3f9df..c717f48f3 100644 --- a/internal/surface/cli/staleusage.go +++ b/internal/surface/cli/staleusage.go @@ -93,7 +93,8 @@ var pagesWithNoVerb = map[string]string{ // 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)", + "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. 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", From f5268bbcf89709b5a9099b07440d857d15f4176a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:37:52 +0100 Subject: [PATCH 02/12] docs: stop the manifest comment naming the retired changelog verb Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- internal/core/surface/manifest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2f90a032ce962c001dae6125df86bc71953d89f6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:43:03 +0100 Subject: [PATCH 03/12] fix: point the loop and the docs lint at ahoy install, not prepare-this-repo prepare-this-repo folds into the ahoy page's install section in the next commit, so its name leaves the two strings that send a reader to it. The implement loop's brief refusal for a base with no AGENTS.md now names /abcd:ahoy install, the plugin command whose preparation merges the conventions into AGENTS.md. It names the plugin command, not the bare binary call, because a default `abcd ahoy install` writes no AGENTS.md. The repo lint's warning for docs/ with no .abcd/docs-lint.json now says to run abcd ahoy install, the binary step that seeds that file. Step 4 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- internal/core/implement/loop/brief.go | 10 +++++---- .../implement/loop/conventionsremedy_test.go | 17 ++++++++------- internal/core/repolint/rule_docs.go | 6 +++--- internal/core/repolint/rule_refusal_test.go | 21 +++++++++++++++++++ 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/internal/core/implement/loop/brief.go b/internal/core/implement/loop/brief.go index 067ebc470..41837dbf1 100644 --- a/internal/core/implement/loop/brief.go +++ b/internal/core/implement/loop/brief.go @@ -42,16 +42,18 @@ const ( const ConventionsFile = "AGENTS.md" // conventionsRemedy is the remedy a brief refused for a base without -// ConventionsFile carries. prepare-this-repo runs in the host agent and has no -// binary verb, so the remedy names the plugin command. +// ConventionsFile carries. The preparation that merges the conventions into +// ConventionsFile runs in the host agent from the ahoy page's install section, +// around the binary's install, which by default writes no ConventionsFile, so +// the remedy names the plugin command. const conventionsRemedy = "write the repository's conventions into " + ConventionsFile + - " on the default branch (`/abcd:prepare-this-repo` sets one up)" + " on the default branch (`/abcd:ahoy install` sets one up)" // DecisionsLogRel is the append-only decision log the brief quotes entries of. const DecisionsLogRel = ".abcd/work/DECISIONS.md" // The markers the working-conventions section sits between in a managed -// repository's AGENTS.md (commands/prepare-this-repo.md). +// repository's AGENTS.md (commands/ahoy.md, the install section). const ( conventionsBegin = "" diff --git a/internal/core/implement/loop/conventionsremedy_test.go b/internal/core/implement/loop/conventionsremedy_test.go index b716f0c2e..7a8aa3772 100644 --- a/internal/core/implement/loop/conventionsremedy_test.go +++ b/internal/core/implement/loop/conventionsremedy_test.go @@ -6,20 +6,23 @@ import ( ) // assertConventionsRemedy checks a brief refusal for a base without AGENTS.md -// names the plugin command that writes one. prepare-this-repo has no binary -// verb, so a remedy telling the person to run `abcd prepare-this-repo` sends -// them to a command that exits 2 as unknown. +// names the command that writes one. The preparation workflow that merges the +// conventions into AGENTS.md runs in the host agent from the ahoy page's +// install section (spc-2610100613109045, step 4), so the remedy names +// /abcd:ahoy install. The retired prepare-this-repo page is named nowhere: its +// slash form is a page that no longer exists, and `abcd prepare-this-repo` was +// never a verb, so either sends the person to a command that refuses. func assertConventionsRemedy(t *testing.T, err error) { t.Helper() r := mustRefusal(t, err) if r.Stage != string(StageBrief) || !strings.Contains(r.Reason, ConventionsFile) { t.Fatalf("want the missing %s refused at the brief: %+v", ConventionsFile, r) } - if !strings.Contains(r.Remedy, "/abcd:prepare-this-repo") { - t.Errorf("the remedy names the plugin command /abcd:prepare-this-repo: %q", r.Remedy) + if !strings.Contains(r.Remedy, "/abcd:ahoy install") { + t.Errorf("the remedy names the plugin command /abcd:ahoy install: %q", r.Remedy) } - if strings.Contains(r.Remedy, "`abcd prepare-this-repo`") { - t.Errorf("the remedy names a binary verb that does not exist: %q", r.Remedy) + if strings.Contains(r.Remedy, "prepare-this-repo") { + t.Errorf("the remedy names the retired prepare-this-repo: %q", r.Remedy) } } diff --git a/internal/core/repolint/rule_docs.go b/internal/core/repolint/rule_docs.go index 2dbe312a3..4fe74f026 100644 --- a/internal/core/repolint/rule_docs.go +++ b/internal/core/repolint/rule_docs.go @@ -52,8 +52,8 @@ func (docsCurrency) Eval(ctx Context) ([]Finding, error) { cfgPath := filepath.Join(ctx.RepoRoot, ".abcd", "docs-lint.json") // The engine reuse needs the repo's docs-lint config. A repo with docs/ but // no committed config cannot be linted — but the rule must not then read as a - // silent pass. Surface a warn that the check could not run (a - // prepare-this-repo gap), not an absence of drift. + // silent pass. Surface a warn that the check could not run (a gap the + // install closes, since it seeds the config), not an absence of drift. if present, err := fsutil.Exists(cfgPath); err != nil { return nil, err } else if !present { @@ -61,7 +61,7 @@ func (docsCurrency) Eval(ctx Context) ([]Finding, error) { RuleID: "docs-currency", Severity: SeverityWarn, File: ".abcd/docs-lint.json", - Message: "docs/ is present but .abcd/docs-lint.json is missing — docs currency cannot be checked; run prepare-this-repo", + Message: "docs/ is present but .abcd/docs-lint.json is missing — docs currency cannot be checked; run abcd ahoy install, which seeds it", }}, nil } diff --git a/internal/core/repolint/rule_refusal_test.go b/internal/core/repolint/rule_refusal_test.go index a4c3a6b66..6bc632338 100644 --- a/internal/core/repolint/rule_refusal_test.go +++ b/internal/core/repolint/rule_refusal_test.go @@ -108,3 +108,24 @@ func TestPositioningMissingBlockIsAnError(t *testing.T) { file(".abcd/development/brief.md", "# no identity block here\n").commit().run() requireRefusal(t, res, "identity-positioning", "identity") } + +// A docs/ folder with no .abcd/docs-lint.json cannot be checked, and the +// warning names the command that seeds the config. prepare-this-repo folded +// into `ahoy install` (spc-2610100613109045, step 4), and the binary's install +// is what writes .abcd/docs-lint.json, so the message names it and not the +// retired page. +func TestDocsWithoutConfigNamesAhoyInstall(t *testing.T) { + res := newFixtureRepo(t).conforming(). + file("docs/index.md", "# docs\n"). + commit().run() + f := findingFor(res, "docs-currency") + if f == nil { + t.Fatalf("no docs-currency finding for docs/ without a config: findings=%+v skipped=%v", res.Findings, res.Skipped) + } + if !strings.Contains(f.Message, "abcd ahoy install") { + t.Errorf("the message does not name `abcd ahoy install`: %s", f.Message) + } + if strings.Contains(f.Message, "prepare-this-repo") { + t.Errorf("the message names the retired prepare-this-repo: %s", f.Message) + } +} From aeb442d2635b20dc8bf45b1ab3fc2a2fa6404c31 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:44:08 +0100 Subject: [PATCH 04/12] feat: fold prepare-this-repo into ahoy install and retire its page The product thinker's placement of 2026-10-09 folds prepare-this-repo into ahoy install (itd-2610090831227812, decision 6 of its spec). The fold is page-only: the binary and its refusals do not change. commands/ahoy.md's install section now carries the whole preparation workflow around the binary call it already made: the ownership refusal, the authority ordering, orient, audit, adopt, the working-conventions block, the downstream rule and the definition of done. The account of what the binary install does follows under its own heading. A repository already prepared goes straight to the binary half. commands/prepare-this-repo.md is deleted with no alias (adr-40). prepare-this-repo leaves pagesWithNoVerb and record-lint's host_delegated list. `abcd prepare-this-repo`, never a verb, is still an unknown command; it moves to retiredTokens, whose note names /abcd:ahoy install. The onboarding, tool-conventions-file, persona and hooks-arming tests now read commands/ahoy.md; a new page test holds each phase to the install section and the old page absent. The brief folds 15-prepare-this-repo.md into a section of 01-ahoy.md, drops register row 15 and the index entry, and repoints the host- delegated paragraphs, 08-skills.md, 04-naming.md's exemption list, 16-lint.md (whose opening named the page as live), 08-abcd.md, 14-ingest.md and 19-identity.md. The release-gate manifest drops the chapter and the page, with its checker count, prompt hash and the receipt example rehashed. The two review flags on the deleted chapter are removed: the sentences they named left with it. README and commands/lint.md name /abcd:ahoy install. The bootstrap test that refuses an unrunnable `ahoy install` in README no longer counts the slash command, which the host runs and whose page resolves the binary through the plugin root before PATH. Step 4 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- .../brief/02-constraints/04-naming.md | 2 +- .../development/brief/04-surfaces/01-ahoy.md | 147 ++++++++- .../development/brief/04-surfaces/08-abcd.md | 7 +- .../brief/04-surfaces/14-ingest.md | 4 +- .../brief/04-surfaces/15-prepare-this-repo.md | 186 ----------- .../development/brief/04-surfaces/16-lint.md | 5 +- .../brief/04-surfaces/19-identity.md | 2 +- .abcd/development/brief/04-surfaces/README.md | 26 +- .../brief/05-internals/08-skills.md | 28 +- .abcd/development/release-gate/manifest.json | 7 +- .../release-gate/receipt.example.json | 2 +- .abcd/record-lint.json | 3 +- .abcd/work/brief-review-flags.json | 16 - README.md | 2 +- commands/ahoy.md | 301 +++++++++++++++++- commands/lint.md | 3 +- commands/prepare-this-repo.md | 291 ----------------- internal/core/ahoy/attribution_hook_test.go | 2 +- .../core/ahoy/guard_template_naming_test.go | 2 +- internal/core/ahoy/onboarding_test.go | 44 +-- .../core/ahoy/persona_conventions_test.go | 17 +- internal/core/ahoy/preparation_page_test.go | 82 +++++ internal/core/ahoy/prepare_hookspath_test.go | 21 +- internal/core/lint/config.go | 2 +- internal/surface/cli/bootstrap_test.go | 8 +- internal/surface/cli/staleusage.go | 12 +- .../surface/cli/staleusage_pageverb_test.go | 32 +- 27 files changed, 650 insertions(+), 604 deletions(-) delete mode 100644 .abcd/development/brief/04-surfaces/15-prepare-this-repo.md delete mode 100644 commands/prepare-this-repo.md create mode 100644 internal/core/ahoy/preparation_page_test.go diff --git a/.abcd/development/brief/02-constraints/04-naming.md b/.abcd/development/brief/02-constraints/04-naming.md index 8866157b7..b1b1e02fe 100644 --- a/.abcd/development/brief/02-constraints/04-naming.md +++ b/.abcd/development/brief/02-constraints/04-naming.md @@ -54,7 +54,7 @@ The exemptions carrying a rationale of their own: The remaining surfaces are exempt for the plain reason that no maritime cognate adds meaning: `banlist`, `consult`, `decide`, `docs`, `guard`, `history`, `ideate`, `identity`, `ingest`, `lint`, `memory`, -`prepare-this-repo`, `reading`, `rules`, `site`, `spec`, and `update`. They +`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 diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 3a4a2feee..63a233f0f 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -50,7 +50,9 @@ the table above is the sub-verb set, and the modes are the bare verb's flags. - **Install** installs or updates abcd in this repo, covering first install and upgrade alike. It runs the detection pass, then an apply pass over the - resulting gaps. + resulting gaps. Through the plugin page it is also how a repository is + prepared to abcd's working conventions, a workflow the host agent runs around + that binary step (below). - **Uninstall** is reversible removal: the marker block, abcd's own `PATH` entry where abcd owns it, and the provenance record that proves that ownership. It leaves `.abcd/` entirely intact, never mutates the hook @@ -239,6 +241,126 @@ before they are written, refusing any finding. A value is read from stdin only, and never printed, logged or written to a record; a call's record names the credential it used. +## Preparing a repository: the host-run half of the install + +Take a repository you own and bring it up to abcd's working conventions in one +sitting: the install's plugin page audits first, shows you the gap report, and +only then adopts the three-tier `.abcd/` layout, a working-conventions section +in `AGENTS.md`, an identity block, and the commit gates. What you get back is a +repo a fresh agent session can build and test from `AGENTS.md` alone. What it +costs you is a sign-off at each phase, which is the point: nothing is adopted +before you have seen what it would change. + +This half was its own plugin page until the product thinker's ruling of +2026-10-09 folded it into the install (itd-2610090831227812): the retired +page's name is gone with no alias, and typing it at a shell gets a note naming +the install instead. The fold is page-only. The binary's install is +unchanged and gains no refusal: it checks no ownership, so the workflow's first +phase does, as before, and a refusal in the binary would break installs that +work today. A repository already prepared, its three tiers in place and its +`AGENTS.md` carrying the working-conventions markers, needs only the binary's +apply pass. + +The workflow is an **interim bridge**. abcd cannot yet manage repositories +directly, so the page does by hand what the CLI will later take over, in a +shape the CLI can adopt without unpicking. It runs in the host agent from the +install section of [`commands/ahoy.md`](../../../../commands/ahoy.md), always +on the current repository. + +### What the preparation does + +- **Refuses on repos the user does not own.** The first phase checks the origin + remote and stops entirely: no audit, no writes. Imposing these conventions on + a third-party repo would interfere with its own development principles. +- **Audits before it touches anything.** It produces a gap report covering + existing structure, documentation shape, decision and working-state hygiene, + principles followed or violated, and privacy, and presents it before adopting + anything. A tool's own conventions file it finds, one an agent tool reads in + place of `AGENTS.md`, is named as setup names it, as holding the owner's words + or as only repeating `AGENTS.md`, and the report points at setup's offer to + retire the second kind rather than acting on it. +- **Adopts the conventions.** The three-tier layout; a merged, never + overwritten, `AGENTS.md` carrying verified repo facts plus a marked + working-conventions block; a registered identity block; and the commit gates. + `AGENTS.md` is the one conventions file it writes: it makes no other tool's + conventions file, as a link or as a copy + ([adr-2610030814023326](../../decisions/adrs/2610030814023326-agents-md-is-the-one-conventions-file.md)). + +### Where the binary does the work + +The workflow is markdown, but three of its steps are the binary's, so the +result does not depend on an agent's memory of what a convention looks like. + +The JSON form of `abcd lint` supplies the engine-backed conformance core, +read-only, which the workflow then supplements with the structural and +principles judgement the binary does not make. The identity verb's initialiser +records the identity block: the markdown itself, which stays the source of +truth, plus `.abcd/positioning.json`, the pointer recording where that block +lives and which surfaces render from it. The install's apply pass is the adopt +phase's workhorse, and it does more than write the commit gates: everything +this chapter describes, from the repo's settings file to the copy of the binary +on `PATH`, lands in that one run. It runs a second time, with the attribution +flag, where the user opts in: that run installs the committed +`prepare-commit-msg` prompt asking every commit to declare whether a tool +assisted it, and the choice is recorded, so a later install without the flag +keeps the hook. The identity verb's render is the follow-on surface and writes +nothing: it proposes a correction as a diff, and adopting it is always the +product thinker's move. + +### The flow + +Four phases, each gated on the one before. + +0. **Refuse unless owned.** Origin-remote ownership check; stop if it fails. +1. **Orient.** Read the abcd record from the plugin root: the three-tier + README, the brief, principles, ADRs, intents, the `docs/` Diátaxis rules, and + the lint configs as patterns. +2. **Conformance lint.** Run `abcd lint` in its JSON form, supplement it, write + the gap report to the target's `.abcd/.work.local/scratch/`, and present it + before any change. +3. **Adopt.** Create the three tiers with a repo-specific `CONTEXT.md`; migrate + a historical `.work/` layout at the repo root if one is found, proposing the + mapping and waiting for sign-off, and never leaving a repo with both homes; + merge into `AGENTS.md`; scaffold the commit gates through the apply pass; + register the identity block, adopting one the repo already carries rather + than re-interviewing; and, only where the user says the repo requires AI + disclosure, install the attribution hook. + +A committed hook is not a running hook until git is pointed at it, once per +clone, and the page keys that step on the hooks-path state detection reports: +it arms an unarmed clone, leaves a foreign hooks dispatcher alone, and does +nothing in an armed one. A declined config change is reported as +scaffolded-but-unarmed rather than passed over silently. + +When abcd's own record has conflicting sources, the workflow trusts a fixed +authority order: `AGENTS.md`, then `work/CONTEXT.md`'s live-constraints section, +then ratified ADRs, then everything else read for understanding only. + +### The preparation's boundaries + +- **Nameless, self-contained output.** The working-conventions block written + into `AGENTS.md` never mentions abcd, the page, or any private repository: + the conventions read as the repo's own, between dated markers so later tooling + can find and replace them. The paths it states into the `.abcd/` layout are + the one trace of the tool, and the adopter accepts that namespace by adopting + the layout. The apply pass leaves the conventions files nameless as well + (below). +- **Never commit downstream assets.** Anything tooling will later provide + (persona data, lint-config JSON copied in by hand, content copied from the + abcd record) is applied, not copied. Only content about the target repository + is committed. The config files the apply pass seeds, `.abcd/docs-lint.json` + among them, are not downstream assets: each is the repository's own once it is + written, and the gates read it on every commit, so it is committed. +- **Privacy.** A `private-names.txt`, if present, is read-only context for the + audit and never reproduced in any committed or published artefact. +- **No secret-pattern hooks.** No hook the workflow scaffolds carries a + secret-pattern set or an absolute-path check. Absolute-path detection is the + binary's own `privacy-hygiene` lint rule, which the audit phase already runs. +- **Self-contained.** Every asset the adopt phase applies resolves from the + abcd record or from the binary, never from a path only one machine has: a + step reaching for one would silently do nothing on any other machine + (itd-162). + ## What abcd manages — repos and `~/.abcd.noindex/` abcd manages exactly one kind of folder, a **repository**, and keeps one @@ -827,7 +949,7 @@ both values, each with the one explanation `RetiredDocsTarget` holds, naming setting to `agents_md` or `skip` runs as any target change does and takes the block out of CLAUDE.md. The name-guard hooks and the ignore fence are the one sanctioned mention of abcd outside -`.abcd/` (ruled 2026-09-23; see prepare-this-repo). Every name-guard +`.abcd/` (ruled 2026-09-23; see the preparation's boundaries above). Every name-guard write is create-if-absent **and** contained: paths resolve through an `os.Root` opened at the repo, so a symlink committed at the hooks directory or at the local tier cannot land an artefact outside it. The private stub is written only where git @@ -983,6 +1105,27 @@ byte-identical to a fresh install save for the setup date. divergence is reported as its own gap, and with no terminal the install asks nothing, writes nothing and says so. +- **Given** a repo the user does not own, **when** they ask the plugin page + to install, **then** the preparation stops at the ownership phase with no + audit and no writes. +- **Given** an owned repo, **when** the preparation runs, **then** a gap report + exists under `.abcd/.work.local/scratch/` and was presented before anything + was adopted. +- **Given** sign-off, **when** it adopts, **then** the three-tier layout exists + with a repo-specific `CONTEXT.md`, `AGENTS.md` carries verified repo facts and + the marked nameless working-conventions section, the done-test passes (a fresh + agent can build and test from `AGENTS.md` alone), and any historical `.work/` + layout is fully migrated or fully left alone. +- **Given** sign-off, **when** it adopts, **then** one identity block is + recorded and registered, adopted where the repo already had one and + interviewed only where it did not, and the lint's identity report shows every + rendered surface against it. +- **Given** the adoption completes, **then** nothing from `private-names.txt` + and no abcd-internal content appears in any committed artefact, with one + sanctioned exception (ruled 2026-09-23): the name-guard hooks + (`.githooks/pre-commit`, `.githooks/pre-merge-commit`) and the `.gitignore` + fence name abcd, and cite none of its record ids. + ## Appendix: the shipped surface diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index b6b5f4f60..0d49e2a2f 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -128,7 +128,7 @@ is the front door today; an MCP server follows later, per [adr-23](../../decisions/adrs/0023-transport-agnostic-core.md)). Not every verb does: `consult` and `ingest` run as host-side markdown over the sources corpus, and reach the binary only through the `source` verb, which makes every write to -the corpus. `prepare-this-repo` is the mixed +the corpus. The install's preparation workflow, on the ahoy page, is the mixed case: its audit half runs `abcd lint`, and its adoption half is binary-backed too and writes — the identity verb's initialiser records the repo's identity block and registers the surfaces held to it, and the ahoy installer lays the hooks, the @@ -367,13 +367,14 @@ 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 +(`consult`, `ingest`) 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 +alias (`version`, whose page retired when the board took the version, and +`prepare-this-repo`, whose page retired into the install) 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 diff --git a/.abcd/development/brief/04-surfaces/14-ingest.md b/.abcd/development/brief/04-surfaces/14-ingest.md index abba2de67..9ac14354a 100644 --- a/.abcd/development/brief/04-surfaces/14-ingest.md +++ b/.abcd/development/brief/04-surfaces/14-ingest.md @@ -22,8 +22,8 @@ on an unknown command, and because a command page of that name exists, the binar adds a line saying the command has no binary verb and runs in the host agent, naming the `/abcd:ingest` invocation, never a rebuild or an update. For a host-delegated command that advice could never come true, because there is no Go verb for a rebuild to -bring in. Every host-delegated page has the same shape, `/abcd:consult` and -`/abcd:prepare-this-repo` alongside this one. What the note should say is that the +bring in. Every host-delegated page has the same shape, `/abcd:consult` +alongside this one. What the note should say is that the command runs in the host agent rather than at the CLI, and that is what the line says. ## Sub-verbs diff --git a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md b/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md deleted file mode 100644 index adfe195a4..000000000 --- a/.abcd/development/brief/04-surfaces/15-prepare-this-repo.md +++ /dev/null @@ -1,186 +0,0 @@ -# `/abcd:prepare-this-repo` — Repo Onboarding Bridge - -Take a repository you own and bring it up to abcd's working conventions in one -sitting: it audits first, shows you the gap report, and only then adopts the -three-tier `.abcd/` layout, a working-conventions section in `AGENTS.md`, an -identity block, and the commit gates. What you get back is a repo a fresh agent -session can build and test from `AGENTS.md` alone. What it costs you is a -sign-off at each phase, which is the point: nothing is adopted before you have -seen what it would change. - -It is an **interim bridge**. abcd cannot yet manage repositories directly, so -the command does by hand what the CLI will later take over, in a shape the CLI -can adopt without unpicking. - -It is **host-delegated**: no dedicated Go verb backs it, and there is no -bare-status render. The workflow runs in the host agent from -[`commands/prepare-this-repo.md`](../../../../commands/prepare-this-repo.md). -It takes no argument, always operating on the current repository. - -Because the binary carries no verb of this name, typing `abcd prepare-this-repo` -at a shell is an unknown command rather than a route in. That refusal -says the command has no binary verb and runs in the host agent, naming `/abcd:prepare-this-repo`: the right reading for a -surface that is host-delegated by design, because what the reader needs is the plugin -command above. - -## 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 command takes no argument and defines no sub-verb, and -its four phases run within one invocation. Because the command is -host-delegated, the rule's configuration exempts this table from the comparison -with the command tree, and from nothing else: the table is still required, and -any row it carries is still format-checked. - -## What it does - -- **Refuses on repos the user does not own.** The first phase checks the origin - remote and stops entirely: no audit, no writes. Imposing these conventions on - a third-party repo would interfere with its own development principles. -- **Audits before it touches anything.** It produces a gap report covering - existing structure, documentation shape, decision and working-state hygiene, - principles followed or violated, and privacy, and presents it before adopting - anything. A tool's own conventions file it finds, one an agent tool reads in - place of `AGENTS.md`, is named as setup names it, as holding the owner's words - or as only repeating `AGENTS.md`, and the report points at setup's offer to - retire the second kind rather than acting on it. -- **Adopts the conventions.** The three-tier layout; a merged, never - overwritten, `AGENTS.md` carrying verified repo facts plus a marked - working-conventions block; a registered identity block; and the commit gates. - `AGENTS.md` is the one conventions file it writes: it makes no other tool's - conventions file, as a link or as a copy - ([adr-2610030814023326](../../decisions/adrs/2610030814023326-agents-md-is-the-one-conventions-file.md)). - -## Where the binary does the work - -The command is markdown, but three of its steps are the binary's, so the result -does not depend on an agent's memory of what a convention looks like. - -The JSON form of `abcd lint` supplies the engine-backed conformance core, read-only, -which the command then supplements with the structural and principles judgement -the binary does not make. The identity verb's initialiser records the identity block: the -markdown itself, which stays the source of truth, plus `.abcd/positioning.json`, -the pointer recording where that block lives and which surfaces render from it. -The ahoy installer is the adopt phase's workhorse, and it does more than write -the commit gates. In one run it writes the repo's settings file with its -visibility, oracle backend and scan depth, writes its rule-loader overrides file, -installs a copy of the binary on `PATH`, records the repo in the machine's own -store, and offers to pin the git commit identity. abcd's own managed -rule-loader block names the tool, so it lands in `AGENTS.md`, and only there, -where the project chooses that docs target, and the repo classifies as managed on -its registry entry without it. A tool's own conventions file at the root, such as -`CLAUDE.md`, the installer never edits: one holding the repo's own words is named -in a warning, and one that only repeats `AGENTS.md` is offered for retirement at -a terminal and removed only on the answer `retire`. It runs a second time, with -the installer's attribution flag, where the user opts in: that run installs the committed `prepare-commit-msg` prompt asking every -commit to declare whether a tool assisted it, and the choice is recorded, so a -later install without the flag keeps the hook. The flag's spelling, like the docs -target's, is ahoy's shape, so it lives in the generated appendix of -[`01-ahoy.md`](01-ahoy.md#appendix-the-shipped-surface) and is not repeated here. - -The identity verb's render is the follow-on surface and writes nothing: it proposes -a correction as a diff, and adopting it is always the product thinker's move. - -## Flow - -Four phases, each gated on the one before. - -0. **Refuse unless owned.** Origin-remote ownership check; stop if it fails. -1. **Orient.** Read the abcd record from the plugin root: the three-tier - README, the brief, principles, ADRs, intents, the `docs/` Diátaxis rules, and - the lint configs as patterns. -2. **Conformance lint.** Run `abcd lint` in its JSON form, supplement it, write the gap - report to the target's `.abcd/.work.local/scratch/`, and present it before - any change. -3. **Adopt.** Create the three tiers with a repo-specific `CONTEXT.md`; migrate - a historical `.work/` layout at the repo root if one is found, proposing the - mapping and waiting for sign-off, and never leaving a repo with both homes; - merge into `AGENTS.md`; scaffold the commit gates; register the identity - block, adopting one the repo already carries rather than re-interviewing; and, - only where the user says the repo requires AI disclosure, install the - attribution hook. - -A committed hook is not a running hook until `git config core.hooksPath -.githooks` points git at it, once per clone. A declined config change is -reported as scaffolded-but-unarmed rather than passed over silently. - -When abcd's own record has conflicting sources, the command trusts a fixed -authority order: `AGENTS.md`, then `work/CONTEXT.md`'s live-constraints section, -then ratified ADRs, then everything else read for understanding only. - -## Boundaries - -- **Nameless, self-contained output.** The working-conventions block written - into `AGENTS.md` never mentions abcd, this command, or any private repository: - the conventions read as the repo's own, between dated markers so later tooling - can find and replace them. The paths it states into the `.abcd/` layout are - the one trace of the tool, and the adopter accepts that namespace by adopting - the layout. The install leaves the conventions files nameless as well. The - name-guard hooks and the `.gitignore` fence it commits are the one sanctioned - mention outside that namespace: the hooks run the binary, the fence warns - against hand-editing, and both keep the markers detection finds an adopted - repository by (the product thinker's ruling of 2026-09-23). -- **Never commit downstream assets.** Anything tooling will later provide - (persona data, lint-config JSON copied in by hand, content copied from the - abcd record) is applied, not copied. Only content about the target repository - is committed. The config files the install seeds, `.abcd/docs-lint.json` - among them, are not downstream assets: each is the repository's own once it is - written (the docs-lint config carries its banned-names family and its - house-style answer), and the gates read it on every commit, so it is committed. -- **Privacy.** A `private-names.txt`, if present, is read-only context for the - audit and never reproduced in any committed or published artefact. -- **No secret-pattern hooks.** No hook this command scaffolds carries a - secret-pattern set or an absolute-path check. Absolute-path detection is the - binary's own `privacy-hygiene` lint rule, which the audit phase already runs. - -## Acceptance - -- **Given** a repo the user does not own, **when** they run - `/abcd:prepare-this-repo`, **then** it stops at the ownership phase with no - audit and no writes. -- **Given** an owned repo, **when** the command runs, **then** a gap report - exists under `.abcd/.work.local/scratch/` and was presented before anything - was adopted. -- **Given** sign-off, **when** it adopts, **then** the three-tier layout exists - with a repo-specific `CONTEXT.md`, `AGENTS.md` carries verified repo facts and - the marked nameless working-conventions section, the done-test passes (a fresh - agent can build and test from `AGENTS.md` alone), and any historical `.work/` - layout is fully migrated or fully left alone. -- **Given** sign-off, **when** it adopts, **then** one identity block is - recorded and registered, adopted where the repo already had one and - interviewed only where it did not, and the lint's identity report shows every rendered - surface against it. -- **Given** the adoption completes, **then** nothing from `private-names.txt` - and no abcd-internal content appears in any committed artefact, with one - sanctioned exception (ruled 2026-09-23): the name-guard hooks - (`.githooks/pre-commit`, `.githooks/pre-merge-commit`) and the `.gitignore` - fence name abcd, and cite none of its record ids. - -## Composition - -`/abcd:prepare-this-repo` is one of the three host-delegated user-facing -commands that carry no Go verb; [`/abcd:consult`](13-consult.md) and -[`/abcd:ingest`](14-ingest.md) are the others. Its end state is the `.abcd/` -layout the shipped abcd surfaces then operate over. - -## References - -- Plugin command: [`commands/prepare-this-repo.md`](../../../../commands/prepare-this-repo.md) -- The three-tier layout it adopts: [`../02-constraints/01-platform.md`](../02-constraints/01-platform.md) and the abcd `.abcd/README.md` -- The invariants the working-conventions block encodes: [`../02-constraints`](../02-constraints) - - - -It ships as a host-delegated command page: the command tree registers no `abcd prepare-this-repo` verb, so there are no flags and no sub-verbs to list. - - diff --git a/.abcd/development/brief/04-surfaces/16-lint.md b/.abcd/development/brief/04-surfaces/16-lint.md index 574b70c9f..e5869cf6f 100644 --- a/.abcd/development/brief/04-surfaces/16-lint.md +++ b/.abcd/development/brief/04-surfaces/16-lint.md @@ -9,7 +9,8 @@ order. It is **read-only** but for one target: bare and every other target perform zero writes, and the site target renders the site into its output directory when that directory holds no `index.html`, and gates what it rendered -([`22-site.md`](22-site.md)). Remediation stays with `/abcd:prepare-this-repo` +([`22-site.md`](22-site.md)). Remediation stays with the install's preparation +workflow ([`01-ahoy.md`](01-ahoy.md#preparing-a-repository-the-host-run-half-of-the-install)) and the technical facilitator. It answers a different question from `/abcd:ahoy`: `ahoy` reports whether the *tool* is installed and configured for a repo; `lint` reports whether the *repo* conforms. Two @@ -173,7 +174,7 @@ new dependency. - Plugin command: [`commands/lint.md`](../../../../commands/lint.md) - Design record: [`plans/2026-07-13-abcd-audit-verb.md`](../../plans/2026-07-13-abcd-audit-verb.md) - Intent: [`itd-85`](../../intents/drafts/itd-85-audit-verb.md) -- Onboarding consumer: [`15-prepare-this-repo.md`](15-prepare-this-repo.md) +- Onboarding consumer: [`01-ahoy.md`](01-ahoy.md#preparing-a-repository-the-host-run-half-of-the-install) diff --git a/.abcd/development/brief/04-surfaces/19-identity.md b/.abcd/development/brief/04-surfaces/19-identity.md index f035e2ed1..7a929566e 100644 --- a/.abcd/development/brief/04-surfaces/19-identity.md +++ b/.abcd/development/brief/04-surfaces/19-identity.md @@ -128,7 +128,7 @@ the recorded three-variant tagline drift this check exists to catch. - Plugin command: [`commands/identity.md`](../../../../commands/identity.md) - Spec: [`spc-19`](../../specs/closed/spc-19-your-repo-says-the-same-thing-about.md) - Intent: [`itd-102`](../../intents/shipped/itd-102-your-repo-says-the-same-thing-about.md) -- Onboarding consumer: [`15-prepare-this-repo.md`](15-prepare-this-repo.md) +- Onboarding consumer: [`01-ahoy.md`](01-ahoy.md#preparing-a-repository-the-host-run-half-of-the-install) - Conformance surface: [`16-lint.md`](16-lint.md) diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index b4656736b..adaa7ee30 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -26,7 +26,6 @@ are wiring rather than user-facing surface are listed separately under | 11 | `/abcd:history` | shipped | Keep session transcripts as a local, redacted corpus this project can study | [`11-history.md`](11-history.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) | | 16 | `/abcd:lint` | shipped | Check whether this repo still conforms to the working conventions | [`16-lint.md`](16-lint.md) | | 17 | `/abcd:guard` | shipped | Find out whether a shell command is safe to run, and what to run instead | [`17-guard.md`](17-guard.md) | | 18 | `/abcd:ideate` | shipped | Put a big, unproven idea through an admission gauntlet and record the verdict either way | [`18-ideate.md`](18-ideate.md) | @@ -309,11 +308,12 @@ present the result: the markdown is the surface, the binary is the engine. Those commands stay thin — they call `abcd --json` and format the result, and never reimplement behaviour that belongs in the core. -The three host-delegated commands named below are the exception, and are -configured as such rather than being silently different: `/abcd:consult`, -`/abcd:ingest` and `/abcd:prepare-this-repo` back onto no verb of their own, so -`consult.md` and `ingest.md` invoke the binary nowhere at all and carry the -workflow itself, and `prepare-this-repo.md` calls other verbs on the way through. +The two host-delegated commands named below are the exception, and are +configured as such rather than being silently different: `/abcd:consult` and +`/abcd:ingest` back onto no verb of their own, and carry the workflow itself. +The preparation workflow that brings an owned repository up to the conventions +runs in the host agent too, but from `ahoy.md`'s install section, around the +binary's install, so it backs a verb. The directory is **flat**, and that is load-bearing rather than tidiness. A harness maps each `commands/` subdirectory to an extra namespace segment, so a @@ -326,7 +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`. +`reading`, `reflect`, `report`, `scribe`, `site`, `source`, `update`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. @@ -344,11 +344,13 @@ the only reliable fix is a home outside the auto-discovery root. ## No skills **abcd ships zero skills** — the `/abcd:` surface is commands only, and there is -no `skills/` directory in the tree. `/abcd:consult`, `/abcd:ingest` and -`/abcd:prepare-this-repo` were once shipped as skills and are commands: each -mutates state — the sources corpus, its ledger, the target repo — which the -boundary rule makes command-shaped. They are **host-delegated** commands, with a -command page and no Go verb, so the workflow runs in the host agent. The +no `skills/` directory in the tree. `/abcd:consult` and `/abcd:ingest` were +once shipped as skills and are commands: each mutates state — the sources +corpus and its ledger — which the boundary rule makes command-shaped. They are +**host-delegated** commands, with a command page and no Go verb, so the +workflow runs in the host agent. The third workflow once shipped as a skill, +the preparation of a target repo, mutates that repo; its page retired into the +install section of `ahoy.md` (itd-2610090831227812). The skill/command boundary is documented in [`05-internals/08-skills.md`](../05-internals/08-skills.md). diff --git a/.abcd/development/brief/05-internals/08-skills.md b/.abcd/development/brief/05-internals/08-skills.md index 77c04b680..1cbfb05aa 100644 --- a/.abcd/development/brief/05-internals/08-skills.md +++ b/.abcd/development/brief/05-internals/08-skills.md @@ -7,11 +7,13 @@ makes the call at design time, and records where abcd came out. **abcd ships zero skills.** The `/abcd:` namespace is commands only, and there is no `skills/` directory in the tree. The three workflows once shipped as skills — -`consult`, `ingest`, and `prepare-this-repo` — are commands, because each mutates -state: the sources corpus, its ledger, the target repo. They live at -`commands/.md` with chapters [`13-consult.md`](../04-surfaces/13-consult.md), -[`14-ingest.md`](../04-surfaces/14-ingest.md), and -[`15-prepare-this-repo.md`](../04-surfaces/15-prepare-this-repo.md). +`consult`, `ingest`, and the preparation of a target repo — are commands, +because each mutates state: the sources corpus, its ledger, the target repo. +The first two live at `commands/.md` with chapters +[`13-consult.md`](../04-surfaces/13-consult.md) and +[`14-ingest.md`](../04-surfaces/14-ingest.md); the preparation runs from the +install section of `commands/ahoy.md`, its chapter +[`01-ahoy.md`](../04-surfaces/01-ahoy.md#preparing-a-repository-the-host-run-half-of-the-install). The plugin carries two further surface kinds this choice does not cover, because neither is something a person invokes by name: the agent prompts under `agents/`, @@ -64,19 +66,19 @@ output, any artefact mutation, any state change makes it a command. instead. Most of abcd's commands are **binary-backed**: a Go verb plus a `commands/` file. -Three are **host-delegated** — `/abcd:consult`, `/abcd:ingest` and -`/abcd:prepare-this-repo` — with a command page and no Go verb, so the workflow -runs in the host agent, and they have no bare-status render and no sub-verbs. That +Two are **host-delegated** — `/abcd:consult` and `/abcd:ingest` — with a +command page and no Go verb, so the workflow runs in the host agent, and they +have no bare-status render and no sub-verbs. That is the shape a command takes when its work is host-delegated rather than owned by 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: `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 -inside a workflow the host drives. See +command page: `completion`, `hook`, `rules`, `spec`, and `statusline`. Two +command pages carry no Go verb of their own name: the host-delegated two above. +A page that backs a verb can still carry a workflow the host drives: the ahoy +page's install section prepares a repository by running the binary's audit, +install and identity verbs as steps. See [`04-surfaces/`](../04-surfaces) for per-command detail. ## Skills are not in `04-surfaces/` diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index e950cebae..165227f48 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -32,7 +32,6 @@ ".abcd/development/brief/04-surfaces/11-history.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", ".abcd/development/brief/04-surfaces/16-lint.md", ".abcd/development/brief/04-surfaces/17-guard.md", ".abcd/development/brief/04-surfaces/18-ideate.md", @@ -87,10 +86,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 45, - "promptHash": "sha256:a2abc8e72b60541927a0dbd966577f5cdabc717a483a070cdfe1f8bcb8a290c1", + "checkerCount": 44, + "promptHash": "sha256:6fcb0365f73f07072dd3fa47c1eb18eab46c129a0253ffcc33325bf8d9262ea2", "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)\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,\nreading, 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 9f4993e20..6ec2b824c 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:e74436245455cbfff190c8bf842b7b08912960c822f540ece376373f9bf94c19", + "manifestHash": "sha256:7fc5efa9b0332a6833d93fa130c2ee9aa55b5bf84e1b8aa0fd4f9aa8c21deb64", "policy": { "detector": "iss35-brief-surface-crosscheck", "version": "1", diff --git a/.abcd/record-lint.json b/.abcd/record-lint.json index 9b17dee64..382957cf7 100644 --- a/.abcd/record-lint.json +++ b/.abcd/record-lint.json @@ -396,8 +396,7 @@ "snapshot": ".abcd/development/release/surface.json", "host_delegated": [ "consult", - "ingest", - "prepare-this-repo" + "ingest" ], "operator_internal": [ "spec", diff --git a/.abcd/work/brief-review-flags.json b/.abcd/work/brief-review-flags.json index 4da3d5c96..88dc63e08 100644 --- a/.abcd/work/brief-review-flags.json +++ b/.abcd/work/brief-review-flags.json @@ -121,22 +121,6 @@ "commit": "6538ab4a6ec349b800ef4b7f8b03819d84c055c4", "applied": "2026-10-04T23:45:50Z" }, - { - "chapter": "15-prepare-this-repo.md", - "sentence": "at a shell is an unknown command rather than a route in. Today that refusal also", - "replacement": "at a shell is an unknown command rather than a route in. That refusal", - "evidence": "internal/surface/cli/staleusage.go:86", - "commit": "6538ab4a6ec349b800ef4b7f8b03819d84c055c4", - "applied": "2026-10-04T23:45:50Z" - }, - { - "chapter": "15-prepare-this-repo.md", - "sentence": "blames a stale binary and asks for a rebuild, which is the wrong reading for a", - "replacement": "says the command has no binary verb and runs in the host agent, naming `/abcd:prepare-this-repo`: the right reading for a", - "evidence": "internal/surface/cli/staleusage.go:86", - "commit": "6538ab4a6ec349b800ef4b7f8b03819d84c055c4", - "applied": "2026-10-04T23:45:50Z" - }, { "chapter": "24-decide.md", "sentence": "and only the citation resolver, the `abcd \u003crecord-id\u003e` dispatch and `decide`", diff --git a/README.md b/README.md index b5cbb34a0..2ff909f9f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ The [install guide](docs/how-to/install.md) covers building from source and what ### Setup -In a plugin session, inside a repository you own, `/abcd:prepare-this-repo` audits the tree and adopts the working conventions: The three-tier `.abcd/` layout, an `AGENTS.md` router, and the commit gates. Bare `/abcd` (or `abcd` from a terminal) then shows where you are; the status board is read-only, so it is safe on any tree: +In a plugin session, inside a repository you own, `/abcd:ahoy install` audits the tree and adopts the working conventions: The three-tier `.abcd/` layout, an `AGENTS.md` router, and the commit gates. Bare `/abcd` (or `abcd` from a terminal) then shows where you are; the status board is read-only, so it is safe on any tree: ```text $ abcd diff --git a/commands/ahoy.md b/commands/ahoy.md index 2e542f2f0..450020d8e 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -20,7 +20,8 @@ Bare invocation, its `--dry-run`, `--remote` and `--providers` modes, and the asks before it does — and `credential --home` writes the chosen home only after the credential's verification call succeeds. A mode is a flag on the bare verb, one at a time; a distinct action is a -sub-verb. +sub-verb. `install` is also where a repository is prepared to abcd's working +conventions: that workflow runs in the host agent, around the binary's install. Read `$ARGUMENTS` for the sub-verb or the mode. No argument is the bare read-only detection pass below; the binary refuses any other word, `status` @@ -117,7 +118,303 @@ If there are actionable gaps, tell the user to run `/abcd:ahoy install` to apply them. If `folder_kind` is `unmanaged-folder`, note there is nothing to act on (not a git repo, no abcd markers). -## `install` — apply the outstanding gaps +## `install` — prepare the repository and apply the outstanding gaps + +`install` is how a repository is set up for abcd, the first time and every time +after, and it has two halves. The preparation is yours to run, in the host +agent: refuse a repository the user does not own, orient, audit, present the +gap report, then adopt the working conventions, one of whose steps is the +binary's install. The binary's install, `abcd ahoy install`, applies the gaps +the detection pass finds, and +[What the binary install does](#what-the-binary-install-does) below says how to +run it and relay it. The binary checks no ownership, so Phase 0 is the step +that does. + +A repository the preparation has already brought up to the conventions (the +three tiers in place and `AGENTS.md` carrying the working-conventions markers) +needs only the binary's half: go straight to it. + +The preparation is an interim bridge: abcd cannot yet manage repositories +directly, so it reads the abcd record and brings the current repository up to +its conventions in a way the abcd CLI can later take over without unpicking. It +supersedes the older `scaffold-repo` layout (`.work/` at the repo root); Phase 3 +migrates that layout when found. + +**Locate the record first.** This command file lives at +`/commands/ahoy.md` — the abcd repository root is two levels up +from it, the same root `${CLAUDE_PLUGIN_ROOT}` names (Phase 2 onward uses it +for the binary). Call that `$ABCD` below. Never assume any other checkout +location. + +### Phase 0 — Refuse unless the user owns this repo + +Check the origin remote (`git remote get-url origin`). If the repository is not +owned by the user (their account or an org they control) — or there is no +remote and the user cannot confirm ownership — **stop entirely**. No audit, no +local layer, no exceptions: imposing these conventions on a third-party repo +interferes with its own development principles. + +### Authority ordering + +Parts of the abcd record are still being reconciled. When sources conflict, +trust in this order: + +1. `$ABCD/AGENTS.md` — the binding conventions. +2. `$ABCD/.abcd/work/CONTEXT.md` — current phase and the **Live constraints / + sharp edges** section, which names what is not yet authoritative. +3. `$ABCD/.abcd/development/decisions/adrs/` — ratified decisions. +4. Everything else (brief, intents, research) — read for understanding, not as + gospel. + +### Phase 1 — Orient + +Read from `$ABCD`, delegating heavy reading to subagents where available: + +- `.abcd/README.md` — the three-tier layout (durability × sharing): + `development/` (durable record, committed), `work/` (shared working state, + committed), `.work.local/` (local ephemeral, gitignored). +- `.abcd/development/brief/` — the shape of a design brief (product, + constraints, evidence, surfaces, internals, delivery, glossary). +- `.abcd/development/principles/` — one principle per file, plus the + three-rung promotion ladder in its README. +- `.abcd/development/decisions/adrs/` — MADR, one `-.md` per + decision under a timestamp id (`adr-`). Session + decisions live in `work/DECISIONS.md`; architecture-shaping ones graduate. +- `.abcd/development/intents/` — lifecycle by directory (`drafts/` → + `planned/` → `shipped/` → `superseded/`). +- `docs/README.md` — strict Diátaxis, four directories, present tense only, + user-facing only. +- `.abcd/docs-lint.json` and `.abcd/record-lint.json` — study as patterns + (banned-token rules with severity, fix message, `allow_context` escapes). +- `.abcd/.work.local/private-names.txt` — **if present**: the banlist for the + Phase 2 privacy audit. Its contents are read-only context; never reproduce + them in anything committed or published. Do not read anything else under + `$ABCD/.abcd/.work.local/`. + +### Phase 2 — Audit + +The conformance core is **engine-backed**: run the binary's read-only audit and +build the gap report on its result, rather than hand-producing the whole thing. + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" lint --json +``` + +Its `findings` (each with a stable `ruleId`, `severity`, `file`, a `line` where +applicable, `message`, `fix`) cover the conventions the binary checks — the three-tier +`.abcd/` layout, an `AGENTS.md` router, durable decisions, docs currency, +privacy hygiene (absolute local paths in committed files), and identity +positioning (rendered surfaces match the canonical identity block). Its +`skipped` list names rules that did not apply (e.g. `docs-currency` where there +is no `docs/`, or `identity-positioning` where there is no `.abcd/positioning.json`). +The exit code is the tri-state (`0` clean, `1` warnings, `2` any error). + +Resolve the binary by the ladder under **Binary resolution** at the end of +this page. + +Then supplement with the judgement the binary does not make: + +- **Existing structure (mandatory):** any convention layer already present — + a legacy `.work/` + `.work.local/` at the root, an existing `AGENTS.md` + router, pre-commit config — and where each piece maps in the three-tier + layout. +- **Tools' own conventions files:** a file at the root that an agent tool + reads in place of `AGENTS.md` (`CLAUDE.md`, `.claude/CLAUDE.md`, + `GEMINI.md`, `.rules`, `.cursorrules`, `.github/copilot-instructions.md`), + named as setup names it. One that holds the owner's own words hides + `AGENTS.md` from that tool; it is reported, with the way to end it (move + those words into `AGENTS.md` and remove the file), and never edited, moved + or removed. One that only repeats `AGENTS.md` (a link to it, that link saved + as a plain file, an exact copy, a single `@AGENTS.md` line, or nothing once + abcd's block is taken out) is reported as one setup offers to retire: point + the technical facilitator at that offer, which `ahoy install` asks at a + terminal, and leave the file in place here. +- **Principles:** which abcd principles the repo follows, violates, or has no + opinion on — cite files as evidence. +- **Privacy beyond absolute paths:** real hostnames/usernames/emails, private + repo names, or `private-names.txt` matches the `privacy-hygiene` rule's v1 + scope does not yet cover. A finding on a deliberately illustrative line can be + waived with `abcd-lint:allow` on that line (the earlier `abcd-audit:allow` spelling is honoured too). + +Write the report — the `abcd lint` findings plus these supplements — to the +target's `.abcd/.work.local/scratch/` (create the directory via +`.git/info/exclude` if needed) and present it before touching anything. + +### Phase 3 — Adopt + +1. **Three tiers.** Create `.abcd/development/` (subdirectories only where + there is content), `.abcd/work/` with a repo-specific `CONTEXT.md` (what the + repo is, current phase, sharp edges — never copied from abcd) and an empty + `DECISIONS.md`, and `.abcd/.work.local/` (`NEXT.md`, `scratch/`, `logs/`) + excluded via `.git/info/exclude`. +2. **Legacy migration — propose, then wait.** If Phase 2 found a legacy + `.work/` layout, present the mapping (`.work/DECISIONS.md` → + `.abcd/work/DECISIONS.md`, and so on) and get explicit sign-off before + moving anything. Moves are content-preserving (`git mv`, then fold), never + delete-and-recreate. Hard rule: never leave both `.work/` and `.abcd/work/` + behind — complete the migration or do not start it. +3. **AGENTS.md.** Merge — never overwrite — into the repo's `AGENTS.md` + (create it if absent). It is the one conventions file this step writes: no + other tool's conventions file is made, as a link or as a copy, so every + tool's instructions live in `AGENTS.md` alone. A tool that reads + `AGENTS.md` only once its own settings name it is configured by the + technical facilitator in those settings, never by a file this step adds. + - Repo facts: what the repo is, exact build/test/lint commands (verified by + running them, including how to run a single test), boundaries, definition + of done. The done-test: a fresh agent session must be able to build and + test the repo from `AGENTS.md` alone. + - The working-conventions section below, between markers. An existing + attribution policy in the file stays authoritative over the template. +4. **Identity — detect first, interview only if there is nothing to detect.** + A project's positioning fragments silently: the README strapline, the + package or plugin manifest description, and the conventions file's opening + are edited at different moments until three surfaces tell three stories. + One canonical block stops that. + + **Detect before asking.** Look for an identity block the repo already + carries — three bullets (`- **Title:**`, `- **Tagline:**`, `- **Pitch:**`) + under a heading such as "Identity (canonical)", typically in a brief or + product chapter under `.abcd/development/`. If one exists, register it and + ask nothing: + + ```bash + "${CLAUDE_PLUGIN_ROOT}/abcd" identity init --file --heading "" + ``` + + `init` adopts the existing block byte-for-byte and writes only the pointer. + Re-interviewing over an answer the repo already gives is how a project ends + up with two canons. + + **Only if there is no block**, ask the product thinker once, under a + `Product` chip, in their own words: + + - **Title** (required) — what the project is called, as it should read in a + heading. + - **Tagline** (required) — one line saying what it is. This is the line + every surface is held to. + - **Pitch** (optional — offer to skip) — two or three sentences a newcomer + could read and know whether this is for them. + + Then record the answers: + + ```bash + "${CLAUDE_PLUGIN_ROOT}/abcd" identity init --title "…" --tagline "…" [--pitch "…"] + ``` + + This writes the markdown block (markdown stays the source of truth) and + `.abcd/positioning.json`, which records where the block lives and which + surfaces render from it. From then on `abcd lint` reports any surface that + drifts from it, and `abcd identity render` proposes the correction as a + diff. abcd never rewrites a surface itself — adopting a proposal is always + the product thinker's move. + +5. **Commit gates.** Scaffold them from the binary — every hook it writes is + embedded in it, so the step applies the same artefacts on a fresh clone as on + the machine that wrote them: + + ```bash + "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install + ``` + + Run it, and relay what it asks and reports, as + [What the binary install does](#what-the-binary-install-does) below says. + Among the rest, that writes the committed private name guard + (`.githooks/pre-commit` and its `pre-merge-commit` half), the gitignored + local banlist stub, and the `.gitattributes` line pinning the hooks to LF. A committed hook is not a + running hook until git is pointed at it, so read which hooks directory git + runs in this clone from the detection pass + (`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy --json`, `banlist.hooks_path`) and act + on it: + + - `unarmed` — point git at the committed hooks, once per clone: + + ```bash + git config core.hooksPath .githooks + ``` + + Report the hooks as scaffolded but unarmed if the user declines that + config change — never silently. + - `foreign` — a hooks path set outside the clone (a global or system hooks + dispatcher) is in force. Leave it alone: never set a local + `core.hooksPath` over it, which would shadow the dispatcher in this clone. + Tell the user the dispatcher has to call the hook in `.githooks/` for the + guard to run, as the bare detection pass above says. + - `armed` — nothing to do. +6. **Attribution (opt-in only).** If the user says this repo requires + AI disclosure, scaffold the prompt hook from the binary and add the + attribution section to `AGENTS.md`: + + ```bash + "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --attribution + ``` + + That writes `.githooks/prepare-commit-msg`, which seeds a commented + disclosure prompt into every commit message an editor opens, and records the + opt-in so a later plain `ahoy install` keeps the hook. It never writes a + value: which tool assisted, and which version, is a fact only the committer + has. Otherwise the default is no attribution, and nothing is written. + +### The working-conventions section + +Write this between `` and +`` markers (today's date; the markers let later +tooling find and replace the block). The section is **self-contained and +nameless**: it never mentions abcd, this page, or any private repository — +the conventions read as the repo's own. Adapt wording to the repo; keep the +substance: + +- Three-tier working state: `.abcd/development/` (durable record: ADRs in + `decisions/adrs/` using MADR under timestamp ids, dated plans and research notes), + `.abcd/work/` (committed: `CONTEXT.md` orientation, `DECISIONS.md` + append-only one-line decision log), `.abcd/.work.local/` (gitignored: + `NEXT.md` handover, `scratch/`, `logs/` — runtime artefacts go here, never + in tracked directories). +- Decisions: one dated line in `.abcd/work/DECISIONS.md`; promote + architecture-shaping decisions to ADRs. +- Docs: `docs/` is user-facing only, one Diátaxis type per page, present tense + only; British English prose, US English in code and commits. No stray root + markdown beyond the standard set. +- Privacy: no absolute local paths, hostnames, usernames, emails, tokens, or + private repository names in anything committed; repo-relative paths only. +- Examples and user stories take their personas from an open alphabetical + sequence — Alice, Bob, Carol, Dave, and on — that the repository extends + when it needs another; where the repository declares a persona roster, take + names and roles from it and pick by role. +- Refer to the product thinker and the technical facilitator as they/them in + every artefact. +- Never commit or push without being asked; substantive work goes on a branch + and PR; new dependencies need explicit sign-off first. + +### Never commit downstream + +Assets that will later be provided by tooling are applied, not copied: no +`personas.json`, no lint-config JSON copied in by hand, no content copied from +the abcd record. Commit only content that is about this repository. The config +files `abcd ahoy install` seeds, `.abcd/docs-lint.json` among them, are this +repository's own once written, and the gates read them on every commit, so they +are committed. + +### Definition of done + +- The gap report exists in the target's `.abcd/.work.local/scratch/` and was + presented to the technical facilitator. +- The three-tier layout exists with a repo-specific `CONTEXT.md`; any legacy + `.work/` layout was fully migrated (with sign-off) or fully left alone. +- `AGENTS.md` carries verified repo facts and the marked, nameless + working-conventions section; the done-test passes. +- One identity block is recorded and registered — adopted where the repo + already had one, interviewed only where it did not — and `abcd lint identity` + reports every registered surface as `ok`, or the drift it reports was shown + to the product thinker with the proposed diff. +- Nothing from `private-names.txt` and no abcd-internal content appears in any + committed or published artefact. +- Every asset the adopt phase applied resolved from this record or from the + binary. A step that reaches for a path only one machine has does not fail — + it silently does nothing, and the adoption degrades against loud-staging, so + the preparation above carries no home-relative path and the onboarding + self-containment check refuses one. + +### What the binary install does ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --json diff --git a/commands/lint.md b/commands/lint.md index e080e2da4..0d8eafabf 100644 --- a/commands/lint.md +++ b/commands/lint.md @@ -13,7 +13,8 @@ present the result. Bare `lint` and every target but one perform **zero writes**; `lint site` renders the site into its `--out` directory (default `./site`, under the working directory) when that directory holds no `index.html`, and leaves the render there. It reports gaps, it never fixes -them (remediation stays with `/abcd:prepare-this-repo`). +them (remediation stays with `/abcd:ahoy install`, which prepares a repository +to the conventions). `lint` is the one check, with targets. Bare, it runs every target that judges the repository: the working conventions, the docs (`docs-currency`), the identity diff --git a/commands/prepare-this-repo.md b/commands/prepare-this-repo.md deleted file mode 100644 index 3d5fbf281..000000000 --- a/commands/prepare-this-repo.md +++ /dev/null @@ -1,291 +0,0 @@ ---- -name: prepare-this-repo -description: Prepare the current repository to abcd's working conventions — audit it against the abcd record, then adopt the three-tier .abcd/ layout, an AGENTS.md conventions section, and the commit gates. Interim bridge until abcd manages repos directly. Use when the user asks to prepare, onboard, scaffold, or bootstrap a repo for agent work, or to bring a repo up to current conventions. Owned repos only — refuses everywhere else. -block: people ---- - -# Prepare this repo - -An interim bridge: abcd cannot yet manage repositories directly, so this command -reads the abcd record and brings the current repository up to its conventions -in a way the abcd CLI can later take over without unpicking. It supersedes the -older `scaffold-repo` layout (`.work/` at the repo root); Phase 3 migrates that -layout when found. - -**Locate the record first.** This command file lives at -`/commands/prepare-this-repo.md` — the abcd repository root is two -levels up from it, the same root `${CLAUDE_PLUGIN_ROOT}` names (Phase 2 onward -uses it for the binary). Call that `$ABCD` below. Never assume any other -checkout location. - -## Phase 0 — Refuse unless the user owns this repo - -Check the origin remote (`git remote get-url origin`). If the repository is not -owned by the user (their account or an org they control) — or there is no -remote and the user cannot confirm ownership — **stop entirely**. No audit, no -local layer, no exceptions: imposing these conventions on a third-party repo -interferes with its own development principles. - -## Authority ordering - -Parts of the abcd record are still being reconciled. When sources conflict, -trust in this order: - -1. `$ABCD/AGENTS.md` — the binding conventions. -2. `$ABCD/.abcd/work/CONTEXT.md` — current phase and the **Live constraints / - sharp edges** section, which names what is not yet authoritative. -3. `$ABCD/.abcd/development/decisions/adrs/` — ratified decisions. -4. Everything else (brief, intents, research) — read for understanding, not as - gospel. - -## Phase 1 — Orient - -Read from `$ABCD`, delegating heavy reading to subagents where available: - -- `.abcd/README.md` — the three-tier layout (durability × sharing): - `development/` (durable record, committed), `work/` (shared working state, - committed), `.work.local/` (local ephemeral, gitignored). -- `.abcd/development/brief/` — the shape of a design brief (product, - constraints, evidence, surfaces, internals, delivery, glossary). -- `.abcd/development/principles/` — one principle per file, plus the - three-rung promotion ladder in its README. -- `.abcd/development/decisions/adrs/` — MADR, one `-.md` per - decision under a timestamp id (`adr-`). Session - decisions live in `work/DECISIONS.md`; architecture-shaping ones graduate. -- `.abcd/development/intents/` — lifecycle by directory (`drafts/` → - `planned/` → `shipped/` → `superseded/`). -- `docs/README.md` — strict Diátaxis, four directories, present tense only, - user-facing only. -- `.abcd/docs-lint.json` and `.abcd/record-lint.json` — study as patterns - (banned-token rules with severity, fix message, `allow_context` escapes). -- `.abcd/.work.local/private-names.txt` — **if present**: the banlist for the - Phase 2 privacy audit. Its contents are read-only context; never reproduce - them in anything committed or published. Do not read anything else under - `$ABCD/.abcd/.work.local/`. - -## Phase 2 — Audit - -The conformance core is **engine-backed**: run the binary's read-only audit and -build the gap report on its result, rather than hand-producing the whole thing. - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" lint --json -``` - -Its `findings` (each with a stable `ruleId`, `severity`, `file`, a `line` where -applicable, `message`, `fix`) cover the conventions the binary checks — the three-tier -`.abcd/` layout, an `AGENTS.md` router, durable decisions, docs currency, -privacy hygiene (absolute local paths in committed files), and identity -positioning (rendered surfaces match the canonical identity block). Its -`skipped` list names rules that did not apply (e.g. `docs-currency` where there -is no `docs/`, or `identity-positioning` where there is no `.abcd/positioning.json`). -The exit code is the tri-state (`0` clean, `1` warnings, `2` any error). - -**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. - -Then supplement with the judgement the binary does not make: - -- **Existing structure (mandatory):** any convention layer already present — - a legacy `.work/` + `.work.local/` at the root, an existing `AGENTS.md` - router, pre-commit config — and where each piece maps in the three-tier - layout. -- **Tools' own conventions files:** a file at the root that an agent tool - reads in place of `AGENTS.md` (`CLAUDE.md`, `.claude/CLAUDE.md`, - `GEMINI.md`, `.rules`, `.cursorrules`, `.github/copilot-instructions.md`), - named as setup names it. One that holds the owner's own words hides - `AGENTS.md` from that tool; it is reported, with the way to end it (move - those words into `AGENTS.md` and remove the file), and never edited, moved - or removed. One that only repeats `AGENTS.md` (a link to it, that link saved - as a plain file, an exact copy, a single `@AGENTS.md` line, or nothing once - abcd's block is taken out) is reported as one setup offers to retire: point - the technical facilitator at that offer, which `ahoy install` asks at a - terminal, and leave the file in place here. -- **Principles:** which abcd principles the repo follows, violates, or has no - opinion on — cite files as evidence. -- **Privacy beyond absolute paths:** real hostnames/usernames/emails, private - repo names, or `private-names.txt` matches the `privacy-hygiene` rule's v1 - scope does not yet cover. A finding on a deliberately illustrative line can be - waived with `abcd-lint:allow` on that line (the earlier `abcd-audit:allow` spelling is honoured too). - -Write the report — the `abcd lint` findings plus these supplements — to the -target's `.abcd/.work.local/scratch/` (create the directory via -`.git/info/exclude` if needed) and present it before touching anything. - -## Phase 3 — Adopt - -1. **Three tiers.** Create `.abcd/development/` (subdirectories only where - there is content), `.abcd/work/` with a repo-specific `CONTEXT.md` (what the - repo is, current phase, sharp edges — never copied from abcd) and an empty - `DECISIONS.md`, and `.abcd/.work.local/` (`NEXT.md`, `scratch/`, `logs/`) - excluded via `.git/info/exclude`. -2. **Legacy migration — propose, then wait.** If Phase 2 found a legacy - `.work/` layout, present the mapping (`.work/DECISIONS.md` → - `.abcd/work/DECISIONS.md`, and so on) and get explicit sign-off before - moving anything. Moves are content-preserving (`git mv`, then fold), never - delete-and-recreate. Hard rule: never leave both `.work/` and `.abcd/work/` - behind — complete the migration or do not start it. -3. **AGENTS.md.** Merge — never overwrite — into the repo's `AGENTS.md` - (create it if absent). It is the one conventions file this step writes: no - other tool's conventions file is made, as a link or as a copy, so every - tool's instructions live in `AGENTS.md` alone. A tool that reads - `AGENTS.md` only once its own settings name it is configured by the - technical facilitator in those settings, never by a file this step adds. - - Repo facts: what the repo is, exact build/test/lint commands (verified by - running them, including how to run a single test), boundaries, definition - of done. The done-test: a fresh agent session must be able to build and - test the repo from `AGENTS.md` alone. - - The working-conventions section below, between markers. An existing - attribution policy in the file stays authoritative over the template. -4. **Identity — detect first, interview only if there is nothing to detect.** - A project's positioning fragments silently: the README strapline, the - package or plugin manifest description, and the conventions file's opening - are edited at different moments until three surfaces tell three stories. - One canonical block stops that. - - **Detect before asking.** Look for an identity block the repo already - carries — three bullets (`- **Title:**`, `- **Tagline:**`, `- **Pitch:**`) - under a heading such as "Identity (canonical)", typically in a brief or - product chapter under `.abcd/development/`. If one exists, register it and - ask nothing: - - ```bash - "${CLAUDE_PLUGIN_ROOT}/abcd" identity init --file --heading "" - ``` - - `init` adopts the existing block byte-for-byte and writes only the pointer. - Re-interviewing over an answer the repo already gives is how a project ends - up with two canons. - - **Only if there is no block**, ask the product thinker once, under a - `Product` chip, in their own words: - - - **Title** (required) — what the project is called, as it should read in a - heading. - - **Tagline** (required) — one line saying what it is. This is the line - every surface is held to. - - **Pitch** (optional — offer to skip) — two or three sentences a newcomer - could read and know whether this is for them. - - Then record the answers: - - ```bash - "${CLAUDE_PLUGIN_ROOT}/abcd" identity init --title "…" --tagline "…" [--pitch "…"] - ``` - - This writes the markdown block (markdown stays the source of truth) and - `.abcd/positioning.json`, which records where the block lives and which - surfaces render from it. From then on `abcd lint` reports any surface that - drifts from it, and `abcd identity render` proposes the correction as a - diff. abcd never rewrites a surface itself — adopting a proposal is always - the product thinker's move. - -5. **Commit gates.** Scaffold them from the binary — every hook it writes is - embedded in it, so the step applies the same artefacts on a fresh clone as on - the machine that wrote them: - - ```bash - "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install - ``` - - That writes the committed private name guard (`.githooks/pre-commit` and its - `pre-merge-commit` half), the gitignored local banlist stub, and the - `.gitattributes` line pinning the hooks to LF. A committed hook is not a - running hook until git is pointed at it, so read which hooks directory git - runs in this clone from the detection pass - (`"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy --json`, `banlist.hooks_path`) and act - on it: - - - `unarmed` — point git at the committed hooks, once per clone: - - ```bash - git config core.hooksPath .githooks - ``` - - Report the hooks as scaffolded but unarmed if the user declines that - config change — never silently. - - `foreign` — a hooks path set outside the clone (a global or system hooks - dispatcher) is in force. Leave it alone: never set a local - `core.hooksPath` over it, which would shadow the dispatcher in this clone. - Tell the user the dispatcher has to call the hook in `.githooks/` for the - guard to run, as `/abcd:ahoy` says. - - `armed` — nothing to do. -6. **Attribution (opt-in only).** If the user says this repo requires - AI disclosure, scaffold the prompt hook from the binary and add the - attribution section to `AGENTS.md`: - - ```bash - "${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install --attribution - ``` - - That writes `.githooks/prepare-commit-msg`, which seeds a commented - disclosure prompt into every commit message an editor opens, and records the - opt-in so a later plain `ahoy install` keeps the hook. It never writes a - value: which tool assisted, and which version, is a fact only the committer - has. Otherwise the default is no attribution, and nothing is written. - -### The working-conventions section - -Write this between `` and -`` markers (today's date; the markers let later -tooling find and replace the block). The section is **self-contained and -nameless**: it never mentions abcd, this command, or any private repository — -the conventions read as the repo's own. Adapt wording to the repo; keep the -substance: - -- Three-tier working state: `.abcd/development/` (durable record: ADRs in - `decisions/adrs/` using MADR under timestamp ids, dated plans and research notes), - `.abcd/work/` (committed: `CONTEXT.md` orientation, `DECISIONS.md` - append-only one-line decision log), `.abcd/.work.local/` (gitignored: - `NEXT.md` handover, `scratch/`, `logs/` — runtime artefacts go here, never - in tracked directories). -- Decisions: one dated line in `.abcd/work/DECISIONS.md`; promote - architecture-shaping decisions to ADRs. -- Docs: `docs/` is user-facing only, one Diátaxis type per page, present tense - only; British English prose, US English in code and commits. No stray root - markdown beyond the standard set. -- Privacy: no absolute local paths, hostnames, usernames, emails, tokens, or - private repository names in anything committed; repo-relative paths only. -- Examples and user stories take their personas from an open alphabetical - sequence — Alice, Bob, Carol, Dave, and on — that the repository extends - when it needs another; where the repository declares a persona roster, take - names and roles from it and pick by role. -- Refer to the product thinker and the technical facilitator as they/them in - every artefact. -- Never commit or push without being asked; substantive work goes on a branch - and PR; new dependencies need explicit sign-off first. - -### Never commit downstream - -Assets that will later be provided by tooling are applied, not copied: no -`personas.json`, no lint-config JSON copied in by hand, no content copied from -the abcd record. Commit only content that is about this repository. The config -files `abcd ahoy install` seeds, `.abcd/docs-lint.json` among them, are this -repository's own once written, and the gates read them on every commit, so they -are committed. - -## Definition of done - -- The gap report exists in the target's `.abcd/.work.local/scratch/` and was - presented to the technical facilitator. -- The three-tier layout exists with a repo-specific `CONTEXT.md`; any legacy - `.work/` layout was fully migrated (with sign-off) or fully left alone. -- `AGENTS.md` carries verified repo facts and the marked, nameless - working-conventions section; the done-test passes. -- One identity block is recorded and registered — adopted where the repo - already had one, interviewed only where it did not — and `abcd lint identity` - reports every registered surface as `ok`, or the drift it reports was shown - to the product thinker with the proposed diff. -- Nothing from `private-names.txt` and no abcd-internal content appears in any - committed or published artefact. -- Every asset the adopt phase applied resolved from this record or from the - binary. A step that reaches for a path only one machine has does not fail — - it silently does nothing, and the adoption degrades against loud-staging, so - this file carries no home-relative path and the onboarding self-containment - check refuses one. diff --git a/internal/core/ahoy/attribution_hook_test.go b/internal/core/ahoy/attribution_hook_test.go index 84f81f7d5..ff1a25096 100644 --- a/internal/core/ahoy/attribution_hook_test.go +++ b/internal/core/ahoy/attribution_hook_test.go @@ -114,7 +114,7 @@ func TestAttributionHookIsOptInAndIdempotent(t *testing.T) { }) t.Run("opting in on an already-installed repo", func(t *testing.T) { - // The exact two-step sequence prepare-this-repo's adopt phase prescribes: + // The exact two-step sequence the ahoy page's adopt phase prescribes: // scaffold the commit gates, then opt into attribution. On the second run // there is no other safe-autocreate gap open, so a step keyed on the // CATEGORY's approval writes nothing — the flag becomes a silent no-op in the diff --git a/internal/core/ahoy/guard_template_naming_test.go b/internal/core/ahoy/guard_template_naming_test.go index 3a0d23f4a..a47fb12f1 100644 --- a/internal/core/ahoy/guard_template_naming_test.go +++ b/internal/core/ahoy/guard_template_naming_test.go @@ -11,7 +11,7 @@ import ( // repository, because they run the binary — but the ruling covers the markers, // not abcd's own record ids. An adopter's repository holds none of abcd's // records, so an id or a path into abcd's design record is abcd-internal content -// that resolves to nothing there, and prepare-this-repo's acceptance keeps it out +// that resolves to nothing there, and the preparation workflow's acceptance keeps it out // of every committed artefact. func TestScaffoldedGuardHooksCiteNoAbcdRecord(t *testing.T) { recordID := regexp.MustCompile(`\b(itd|spc|iss|adr|rcp|rfc)-[0-9]+\b`) diff --git a/internal/core/ahoy/onboarding_test.go b/internal/core/ahoy/onboarding_test.go index ce04aceb7..28b9233b7 100644 --- a/internal/core/ahoy/onboarding_test.go +++ b/internal/core/ahoy/onboarding_test.go @@ -1,20 +1,11 @@ package ahoy import ( - "os" - "path/filepath" "regexp" "strings" "testing" ) -// onboardingArtefacts are the committed files the adopt phase reads and applies -// from. Every asset one of them names must resolve from inside this record or from -// the binary; a path outside both is a template only the machine that wrote it has. -var onboardingArtefacts = []string{ - filepath.Join("..", "..", "..", "commands", "prepare-this-repo.md"), -} - // machineLocalRefs returns the 1-based line numbers of every machine-local path // reference in data: a home-relative `~/` path, or the private templates directory // the adopt phase used to reach into. @@ -43,15 +34,14 @@ func machineLocalRefs(data []byte) []int { // did nothing at all, and the adoption silently degraded against loud-staging. // Every asset it applies now resolves from this record or from the binary. func TestOnboardingIsSelfContained(t *testing.T) { - for _, rel := range onboardingArtefacts { - data, err := os.ReadFile(rel) - if err != nil { - t.Fatalf("cannot read onboarding artefact %s: %v", rel, err) - } - if hits := machineLocalRefs(data); len(hits) > 0 { - t.Errorf("%s references a machine-local path on line(s) %v; every adopt-phase asset must resolve "+ - "from this record or from the binary, never from a path only one machine has", rel, hits) - } + // The preparation workflow on the ahoy page is what the adopt phase reads and + // applies from. Every asset it names must resolve from inside this record or + // from the binary; a path outside both is a template only the machine that + // wrote it has. The page's later account of the binary install names the + // binary's own user-scope paths, which are not assets the workflow applies. + if hits := machineLocalRefs([]byte(preparationWorkflow(t))); len(hits) > 0 { + t.Errorf("%s's preparation workflow references a machine-local path on line(s) %v (counted from the install heading); "+ + "every adopt-phase asset must resolve from this record or from the binary, never from a path only one machine has", ahoyPage, hits) } } @@ -121,13 +111,14 @@ func toolFileInstructions(page string) []string { return hits } -// TestPrepareThisRepoScaffoldsNoToolConventionsFile is the page half of -// itd-2610030814013772's A1 (spc-2610031156364295, step 6): prepare-this-repo -// creates AGENTS.md and no tool's own conventions file, link or copy, since +// TestAhoyPageScaffoldsNoToolConventionsFile is the page half of +// itd-2610030814013772's A1 (spc-2610031156364295, step 6): the preparation +// workflow, which folded into the ahoy page's install section +// (spc-2610100613109045, step 4), creates AGENTS.md and no tool's own conventions file, link or copy, since // AGENTS.md is the one conventions file abcd writes // (adr-2610030814023326). The detector is watched fire first on the shapes the // page once carried, so an emptied scan cannot leave the gate green. -func TestPrepareThisRepoScaffoldsNoToolConventionsFile(t *testing.T) { +func TestAhoyPageScaffoldsNoToolConventionsFile(t *testing.T) { for _, bad := range []string{ "3. **AGENTS.md.** Merge into the repo's `AGENTS.md`\n (create it if absent, with `CLAUDE.md` as a symlink to it):\n", "Then run `ln -s AGENTS.md GEMINI.md` so Gemini CLI reads it.\n", @@ -164,13 +155,8 @@ func TestPrepareThisRepoScaffoldsNoToolConventionsFile(t *testing.T) { } } - rel := filepath.Join("..", "..", "..", "commands", "prepare-this-repo.md") - data, err := os.ReadFile(rel) - if err != nil { - t.Fatalf("cannot read %s: %v", rel, err) - } - for _, hit := range toolFileInstructions(string(data)) { + for _, hit := range toolFileInstructions(readAhoyPage(t)) { t.Errorf("%s instructs making a tool's own conventions file; it creates AGENTS.md and no other, "+ - "link or copy (adr-2610030814023326):\n %s", rel, hit) + "link or copy (adr-2610030814023326):\n %s", ahoyPage, hit) } } diff --git a/internal/core/ahoy/persona_conventions_test.go b/internal/core/ahoy/persona_conventions_test.go index a94089501..11a5ba382 100644 --- a/internal/core/ahoy/persona_conventions_test.go +++ b/internal/core/ahoy/persona_conventions_test.go @@ -1,8 +1,6 @@ package ahoy import ( - "os" - "path/filepath" "regexp" "strings" "testing" @@ -12,19 +10,16 @@ import ( // that are "always" or "only" a fixed set, or a ban on "other names". var conventionsPersonaCap = regexp.MustCompile(`(?i)\balways\b|\bonly\b|other names|\bfixed\b`) -// TestPrepareThisRepoConventionsKeepThePersonaSequenceOpen pins that the -// working-conventions block prepare-this-repo writes into a prepared +// TestAhoyInstallConventionsKeepThePersonaSequenceOpen pins that the +// working-conventions block the ahoy page's preparation workflow writes into a +// prepared // repository's AGENTS.md teaches the same open persona sequence the bundled // INTENTS rule does (iss-2610040758394205). A cap left in that block would // contradict the injected rule from inside the repository's own conventions // file, and forbid the fourth persona the issue was captured for. -func TestPrepareThisRepoConventionsKeepThePersonaSequenceOpen(t *testing.T) { - rel := filepath.Join("..", "..", "..", "commands", "prepare-this-repo.md") - data, err := os.ReadFile(rel) - if err != nil { - t.Fatalf("cannot read %s: %v", rel, err) - } - page := string(data) +func TestAhoyInstallConventionsKeepThePersonaSequenceOpen(t *testing.T) { + rel := ahoyPage + page := preparationWorkflow(t) start := strings.Index(page, "### The working-conventions section") if start < 0 { t.Fatalf("%s carries no working-conventions section", rel) diff --git a/internal/core/ahoy/preparation_page_test.go b/internal/core/ahoy/preparation_page_test.go new file mode 100644 index 000000000..04b3445db --- /dev/null +++ b/internal/core/ahoy/preparation_page_test.go @@ -0,0 +1,82 @@ +package ahoy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ahoyPage is the command page that carries the preparation workflow: +// prepare-this-repo folded into its install section (itd-2610090831227812, +// spc-2610100613109045 step 4), around the binary call that section makes. +var ahoyPage = filepath.Join("..", "..", "..", "commands", "ahoy.md") + +// The headings that bound the preparation workflow on the ahoy page: it opens +// the install section and ends where the page turns to what the binary install +// itself does, whose user-scope paths (`~/.local/bin`, the home folder) are +// the binary's own and no asset the workflow applies. +const ( + installHeading = "## `install`" + binaryInstallHeading = "### What the binary install does" +) + +// readAhoyPage returns the ahoy page's text. +func readAhoyPage(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(ahoyPage) + if err != nil { + t.Fatalf("cannot read %s: %v", ahoyPage, err) + } + return string(data) +} + +// preparationWorkflow returns the part of the ahoy page the host follows to +// prepare a repository: from the install heading to the heading on what the +// binary install does. +func preparationWorkflow(t *testing.T) string { + t.Helper() + page := readAhoyPage(t) + start := strings.Index(page, installHeading) + if start < 0 { + t.Fatalf("%s has no %q section", ahoyPage, installHeading) + } + rest := page[start:] + end := strings.Index(rest, binaryInstallHeading) + if end < 0 { + t.Fatalf("%s's install section has no %q heading closing the preparation workflow", ahoyPage, binaryInstallHeading) + } + return rest[:end] +} + +// TestAhoyInstallCarriesThePreparation is A11's page half: `ahoy install` does +// what prepare-this-repo did, and that page is gone. Each phase the retired page +// ran is held to the install section by the words that carry it, so a later +// edit that drops one is named. +func TestAhoyInstallCarriesThePreparation(t *testing.T) { + if _, err := os.Stat(filepath.Join("..", "..", "..", "commands", "prepare-this-repo.md")); err == nil { + t.Errorf("commands/prepare-this-repo.md still exists; it folded into the ahoy page's install section") + } else if !os.IsNotExist(err) { + t.Fatal(err) + } + section := strings.Join(strings.Fields(preparationWorkflow(t)), " ") + for _, want := range []struct{ phase, text string }{ + {"the ownership refusal", "git remote get-url origin"}, + {"the authority ordering", "Authority ordering"}, + {"the orientation", ".abcd/development/principles/"}, + {"the engine-backed audit", `"${CLAUDE_PLUGIN_ROOT}/abcd" lint --json`}, + {"the gap report", ".abcd/.work.local/scratch/"}, + {"the legacy migration", "never leave both `.work/` and `.abcd/work/`"}, + {"the AGENTS.md merge", "Merge — never overwrite"}, + {"the identity block", "identity init"}, + {"the commit gates", `"${CLAUDE_PLUGIN_ROOT}/abcd" ahoy install`}, + {"the attribution opt-in", "ahoy install --attribution"}, + {"the working-conventions section", "### The working-conventions section"}, + {"the downstream rule", "### Never commit downstream"}, + {"the definition of done", "### Definition of done"}, + } { + if !strings.Contains(section, want.text) { + t.Errorf("the ahoy page's install section lost %s (%q)", want.phase, want.text) + } + } +} diff --git a/internal/core/ahoy/prepare_hookspath_test.go b/internal/core/ahoy/prepare_hookspath_test.go index 3af05db35..78bcc1e80 100644 --- a/internal/core/ahoy/prepare_hookspath_test.go +++ b/internal/core/ahoy/prepare_hookspath_test.go @@ -1,8 +1,6 @@ package ahoy import ( - "os" - "path/filepath" "reflect" "regexp" "strconv" @@ -28,7 +26,7 @@ var stateBullet = regexp.MustCompile("^\\s*- `([a-z]+)`") // above it. var numberedStep = regexp.MustCompile(`^\s*\d+\.\s`) -// hooksPathAdviceFaults returns what the prepare-this-repo page gets wrong about +// hooksPathAdviceFaults returns what the ahoy page gets wrong about // arming the committed hooks, one sentence each; none means the page sets a local // core.hooksPath only in the unarmed branch and leaves a foreign one alone. // @@ -81,18 +79,15 @@ func hooksPathAdviceFaults(page string) []string { return faults } -// TestPrepareThisRepoArmsHooksOnlyWhenUnarmed is iss-2610080546210831's detector. -// The page's commit-gates step told every prepared repository to run +// TestAhoyPageArmsHooksOnlyWhenUnarmed is iss-2610080546210831's detector. The +// preparation workflow's commit-gates step, on the prepare-this-repo page until +// it folded into the ahoy page's install section (spc-2610100613109045, step 4), told every prepared repository to run // `git config core.hooksPath .githooks` with no condition, which shadows a global // hooks dispatcher: the exact advice ahoy refuses to give when hooks_path reads // foreign. The page must key the command on the state ahoy reports. -func TestPrepareThisRepoArmsHooksOnlyWhenUnarmed(t *testing.T) { - data, err := os.ReadFile(filepath.Join("..", "..", "..", "commands", "prepare-this-repo.md")) - if err != nil { - t.Fatalf("cannot read the prepare-this-repo page: %v", err) - } - for _, f := range hooksPathAdviceFaults(string(data)) { - t.Errorf("commands/prepare-this-repo.md: %s", f) +func TestAhoyPageArmsHooksOnlyWhenUnarmed(t *testing.T) { + for _, f := range hooksPathAdviceFaults(readAhoyPage(t)) { + t.Errorf("commands/ahoy.md: %s", f) } } @@ -108,7 +103,7 @@ func TestPrepareHooksPathFieldIsWhatAhoyEmits(t *testing.T) { return strings.Split(f.Tag.Get("json"), ",")[0] } if got := tag(DetectionResult{}, "Banlist") + "." + tag(BanlistHealth{}, "HooksPath"); got != "banlist.hooks_path" { - t.Errorf("ahoy --json carries the hooks path state at %q; the prepare-this-repo page reads banlist.hooks_path", got) + t.Errorf("ahoy --json carries the hooks path state at %q; the ahoy page reads banlist.hooks_path", got) } } diff --git a/internal/core/lint/config.go b/internal/core/lint/config.go index 2f34e4dae..0ed6c68fe 100644 --- a/internal/core/lint/config.go +++ b/internal/core/lint/config.go @@ -168,7 +168,7 @@ type RuleConfig struct { // grain unarmed (the pre-spc-27 surface-grain check only). Snapshot string `json:"snapshot"` // HostDelegated lists surfaces whose workflow runs in the host agent with - // no Go verb (consult, ingest, prepare-this-repo): their sub-verb tables + // no Go verb (consult, ingest): their sub-verb tables // are format-checked only, never compared to the cobra tree. Explicit // config, never a hard-coded skip — an unlisted surface gets the full // comparison. diff --git a/internal/surface/cli/bootstrap_test.go b/internal/surface/cli/bootstrap_test.go index 16a83b064..2b19301fe 100644 --- a/internal/surface/cli/bootstrap_test.go +++ b/internal/surface/cli/bootstrap_test.go @@ -511,11 +511,17 @@ func bootstrapShSingleQuote(s string) string { // rather than searching for the good form is deliberate: a text may hold the // runnable instruction AND still leave the unrunnable one standing beside it, // and only an every-occurrence check sees the second one. +// +// The plugin's slash command, `/abcd:ahoy install`, is not a shell invocation: +// the host runs it in a plugin session, and its page resolves the binary +// through the plugin root before PATH, so it runs in the very state this +// check guards. It is the person's route into a repository's preparation +// (spc-2610100613109045, step 4), so it is not counted. func bootstrapEveryInvocationIsPathQualified(body, prefix string) []string { parts := strings.Split(body, "ahoy install") var bad []string for _, before := range parts[:len(parts)-1] { - if strings.HasSuffix(before, prefix) { + if strings.HasSuffix(before, prefix) || strings.HasSuffix(before, "/abcd:") { continue } if len(before) > 80 { diff --git a/internal/surface/cli/staleusage.go b/internal/surface/cli/staleusage.go index c717f48f3..ee3c7dffa 100644 --- a/internal/surface/cli/staleusage.go +++ b/internal/surface/cli/staleusage.go @@ -81,10 +81,9 @@ const dispatcherPage = "abcd" // this set, so a new host-delegated page is added here or the parity check // reads its missing verb as drift. var pagesWithNoVerb = map[string]string{ - dispatcherPage: "`abcd` is the binary itself, not one of its commands — the page /abcd:abcd documents the bare call; did you mean `abcd ` (or bare `abcd` for the status board)?", - "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", + dispatcherPage: "`abcd` is the binary itself, not one of its commands — the page /abcd:abcd documents the bare call; did you mean `abcd ` (or bare `abcd` for the status board)?", + "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", } // retiredTokens are top-level tokens whose page or verb was retired with no @@ -93,8 +92,9 @@ var pagesWithNoVerb = map[string]string{ // 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)", + "prepare-this-repo": "`prepare-this-repo` folded into ahoy install: run /abcd:ahoy install, which prepares the repository in the host agent around the binary's `abcd ahoy install`", + "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. diff --git a/internal/surface/cli/staleusage_pageverb_test.go b/internal/surface/cli/staleusage_pageverb_test.go index 26e8f926d..d095c8a55 100644 --- a/internal/surface/cli/staleusage_pageverb_test.go +++ b/internal/surface/cli/staleusage_pageverb_test.go @@ -11,7 +11,7 @@ import ( // iss-2609240519471816). // // commands/abcd.md documents the bare dispatcher, and the host-delegated pages -// (consult, ingest, prepare-this-repo) document workflows the host agent runs +// (consult, ingest) document workflows the host agent runs // with no Go verb at all. The stale-usage note reads "a page exists for the // token" as "the surface is newer than this binary", which is false for every // one of them: rebuilding or updating adds no verb, because none was ever @@ -120,3 +120,33 @@ func TestRetiredVersionTokenNamesWhatReplacedIt(t *testing.T) { } } } + +// TestRetiredPrepareThisRepoTokenNamesWhatReplacedIt is the prepare-this-repo +// half of spc-2610100613109045's old-names rule (step 4): the page folded into +// the ahoy page's install section, so `abcd prepare-this-repo`, never a verb, +// is still an unknown command, and its note names `ahoy install` rather than +// the retired page, and never calls the binary stale for not knowing it. +func TestRetiredPrepareThisRepoTokenNamesWhatReplacedIt(t *testing.T) { + if _, ok := pagesWithNoVerb["prepare-this-repo"]; ok { + t.Fatalf("pagesWithNoVerb still names prepare-this-repo, whose page is retired") + } + root := stalePluginRoot(t) + setExecutable(t, filepath.Join(root, "abcd")) + code, stdout, stderr := runMain(t, "prepare-this-repo") + if code != 2 || stdout != "" { + t.Fatalf("`abcd prepare-this-repo` exit = %d, stdout %q; want exit 2 and an empty stdout", code, stdout) + } + if !strings.Contains(stderr, "unknown command") { + t.Errorf("`abcd prepare-this-repo` is not refused as an unknown command:\n%s", stderr) + } + for _, want := range []string{"/abcd:ahoy install", "abcd ahoy install"} { + if !strings.Contains(stderr, want) { + t.Errorf("the refusal does not name %q:\n%s", want, stderr) + } + } + for _, not := range []string{"/abcd:prepare-this-repo", "predates", "make build", "abcd update"} { + if strings.Contains(stderr, not) { + t.Errorf("the refusal names %q:\n%s", not, stderr) + } + } +} From 076b764b30369839690d4138899a0c202014d30a Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:44:20 +0100 Subject: [PATCH 05/12] docs: correct the ahoy chapter's status alias and its user-scope tree Two peer findings on 01-ahoy.md, folded in with step 4's change to that chapter. The chapter called `status` a plugin-page alias for the bare form. It is not: commands/ahoy.md hands every word to the binary, which refuses `status` like any other word it does not register, so `abcd ahoy status` is an unknown command on both surfaces. The user-scope tree, declared the one inventory, left out files the shipped code reads or writes under the home folder: oracle-routing.json, interviews/, cache-attestation and statusline.json, which the symlink rule in 03-configuration.md already lists, and backups/, where the status-line change keeps its copies of the harness settings file. Step 4 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- .../development/brief/04-surfaces/01-ahoy.md | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 63a233f0f..0cc766121 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -43,10 +43,11 @@ commands. The slash command dispatches every sub-verb and mode but the identity check, the write verbs included, and each announces that it writes before it runs. The identity check is a plain command-line entrypoint, because its exit code is the whole point of it and its home is a -pre-commit hook or CI rather than a conversation. `status` is a plugin-page -alias for the bare form and has no CLI sub-command behind it: `abcd ahoy -status` is refused as an unknown command. Every other word ships on the CLI: -the table above is the sub-verb set, and the modes are the bare verb's flags. +pre-commit hook or CI rather than a conversation. `status` is a word neither +surface takes: the plugin page passes it to the binary, which refuses it like +any other word it does not register, so `abcd ahoy status` is an unknown +command. The table above is the sub-verb set, and the modes are the bare verb's +flags. - **Install** installs or updates abcd in this repo, covering first install and upgrade alike. It runs the detection pass, then an apply pass over the @@ -392,6 +393,12 @@ earlier version wrote wider is narrowed when abcd next writes it inbox/ reports managed repositories filed back to abcd, -.md; promoted/ keeps the ones filed as captures (itd-2609221656361680) + interviews/ the setup answers that change the machine (the + status line, the machine's routing table), one + record per install run that asked them + backups/ copies of the harness's settings file, taken + before the status line changes it; the newest 10 + are kept config.json the machine layer of the layered configuration, read-only except for the provider blocks (oracle.api.) the provider setup writes, @@ -421,8 +428,20 @@ earlier version wrote wider is narrowed when abcd next writes it between the bundled domains and each repo's .abcd/rules.json, read-only; abcd never creates it (itd-117) + oracle-routing.json the machine's model-tier routing table, written + owner-only on the install's answered consent and + left by uninstall (itd-2609170822093401) + statusline.json the status line's settings: the elements switched + on and the command the harness ran before abcd + took the row; written on the status-line offer's + consent, left by uninstall path-entry the abcd copy this machine owns, the one PATH binary a hook will run + cache-attestation the bootstrap's record that the plugin data + directory's cached binary matched the published + release manifest, written only after that check + passed; install promotes the cache to the owned + PATH copy only when the record agrees trusted-roots foreign-uid configuration roots the caller vouches for local-transcript-roots checkouts whose transcripts are pulled in to /.abcd/.work.local/transcripts/ instead From c56be93dd05ca58159dc44020948baf92b6b4138 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:14:01 +0100 Subject: [PATCH 06/12] fix: bump the changelog composer's prompt version for the preview rename Step 3 repointed the composer's preview source at the launch preview's cut without the prompt_version bump and agents changelog entry record-lint's agent_contract rule requires. It also adds changelog to the brief's list of names retired with no alias. Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/agents/CHANGELOG.md | 10 ++++++++++ .abcd/development/brief/04-surfaces/08-abcd.md | 7 ++++--- agents/release-changelog-composer.md | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) 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/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index b6b5f4f60..4e172105a 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -373,9 +373,10 @@ 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 +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. diff --git a/agents/release-changelog-composer.md b/agents/release-changelog-composer.md index 6025da74a..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] From df4c1e302c6e2c6c1104b8fdf8239cc4a2a5ba99 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:33:03 +0100 Subject: [PATCH 07/12] fix: start the ahoy install page from the detection pass, not the markers The shortcut straight to the binary's half keyed on the working-conventions markers, which only the host-run preparation writes, so a repository adopted through the binary install alone, abcd's own checkout among them, would have been walked through the whole preparation again. The page now starts from the detection pass: a managed repository goes to the binary's half unless the person asks to prepare it, and an unmanaged one starts at Phase 0. The brief's mirror sentence follows, and the spec's line on the loop hint now says /abcd:ahoy install, matching what shipped. Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- .abcd/development/brief/04-surfaces/01-ahoy.md | 6 +++--- ...10100613109045-a-person-who-types-abcd-sees-only-the.md | 4 +++- commands/ahoy.md | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/01-ahoy.md b/.abcd/development/brief/04-surfaces/01-ahoy.md index 0cc766121..ed4eb5ebf 100644 --- a/.abcd/development/brief/04-surfaces/01-ahoy.md +++ b/.abcd/development/brief/04-surfaces/01-ahoy.md @@ -258,9 +258,9 @@ page's name is gone with no alias, and typing it at a shell gets a note naming the install instead. The fold is page-only. The binary's install is unchanged and gains no refusal: it checks no ownership, so the workflow's first phase does, as before, and a refusal in the binary would break installs that -work today. A repository already prepared, its three tiers in place and its -`AGENTS.md` carrying the working-conventions markers, needs only the binary's -apply pass. +work today. The detection pass decides where the page starts: a managed +repository needs only the binary's apply pass unless the person asks to prepare +it, and an unmanaged one starts at the workflow's first phase. The workflow is an **interim bridge**. abcd cannot yet manage repositories directly, so the page does by hand what the CLI will later take over, in a diff --git a/.abcd/development/specs/open/spc-2610100613109045-a-person-who-types-abcd-sees-only-the.md b/.abcd/development/specs/open/spc-2610100613109045-a-person-who-types-abcd-sees-only-the.md index 2c829c1ed..ab42628f3 100644 --- a/.abcd/development/specs/open/spc-2610100613109045-a-person-who-types-abcd-sees-only-the.md +++ b/.abcd/development/specs/open/spc-2610100613109045-a-person-who-types-abcd-sees-only-the.md @@ -303,7 +303,9 @@ binary would break installs that work now. Code that names the page or its verb changes in the same step. The loop's conventions-file hint in `internal/core/implement/loop/brief.go` and `issuebrief.go` tells the reader to run `abcd prepare-this-repo`, which was -never a verb, so it becomes `abcd ahoy install`. Also: +never a verb, so it becomes `/abcd:ahoy install`: the page's preparation is what +writes the conventions file, and a default binary `abcd ahoy install` writes no +`AGENTS.md` (iss-2610101707453618). Also: `internal/core/repolint/rule_docs.go`'s message; `commands/lint.md`'s pointer; `pagesWithNoVerb`; `host_delegated` in `.abcd/record-lint.json` and the comment in `internal/core/lint/config.go`. `onboarding_test.go` and diff --git a/commands/ahoy.md b/commands/ahoy.md index 450020d8e..62fff7be8 100644 --- a/commands/ahoy.md +++ b/commands/ahoy.md @@ -130,9 +130,10 @@ the detection pass finds, and run it and relay it. The binary checks no ownership, so Phase 0 is the step that does. -A repository the preparation has already brought up to the conventions (the -three tiers in place and `AGENTS.md` carrying the working-conventions markers) -needs only the binary's half: go straight to it. +Which half to start with comes from the detection pass above. A +`managed-repo` needs only the binary's half: go straight to it, unless the user +asked to prepare the repository or adopt the conventions. An `unmanaged-repo` +starts at Phase 0. The preparation is an interim bridge: abcd cannot yet manage repositories directly, so it reads the abcd record and brings the current repository up to From a1ba39e93dcef98291f0246f894e9611cea4b644 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:37:47 +0100 Subject: [PATCH 08/12] feat: merge memory and source into one library verb The product thinker's placement of 2026-10-09 merges the sources commands into one person's command named library (itd-2610090831227812, P4). This step carries the memory and source sub-trees; consult and ingest fold into the library page in the next step. abcd library is one top-level verb under Records (decision 1) carrying both sub-trees, and every sub-verb keeps its name (decision 7): init, add, declassify, ledger, sync-banlist and cite-check act on the sources corpus, ingest, ask and lint on the checkout's memory store. The two stores and their core packages stay unconnected (decision 10). Bare library renders both stores, an unreadable one as a line naming what would create it, and refuses only when neither reads (decision 8), so bare library inside a checkout with no corpus renders where bare source exited 3. abcd memory and abcd source are unknown commands with no alias (adr-40); retiredTokens names library for each. The guard's abcd-source-ledger-flip entry matches library ledger --flip, so an agent still cannot flip a citation line. Both pre-commit hook copies run library sync-banlist. The corpus and memory strings a reader acts on, the provenance a new memory page records, the sentence and example manifests, the DOGFOODING recall list and the help table name library. commands/library.md replaces memory.md and source.md; consult.md and ingest.md are repointed at library, and guard.md, banlist.md and update.md's network list name it. The brief folds 04-surfaces/07-memory.md and 33-source.md into 07-library.md, with one register row, and the index, help table, bare-invocation paragraph, product chapters, naming exemptions and internals chapters follow. The release-gate manifest swaps the two chapters for the one, rehashed with its receipt example. The shipped itd-36's two links to the folded chapter point at 07-library.md so they resolve. The snapshot, CLI reference and chapter appendix are regenerated. Step 5 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- .../brief/01-product/01-press-release.md | 2 +- .../brief/01-product/02-context.md | 2 +- .../brief/01-product/03-mental-model.md | 2 +- .../development/brief/01-product/04-scope.md | 2 +- .../brief/02-constraints/02-dependencies.md | 2 +- .../brief/02-constraints/04-naming.md | 2 +- .../brief/04-surfaces/07-library.md | 501 ++++++++++++++++ .../brief/04-surfaces/07-memory.md | 200 ------- .../development/brief/04-surfaces/08-abcd.md | 2 +- .../brief/04-surfaces/11-history.md | 2 +- .../brief/04-surfaces/13-consult.md | 18 +- .../brief/04-surfaces/14-ingest.md | 8 +- .../brief/04-surfaces/20-banlist.md | 2 +- .../brief/04-surfaces/33-source.md | 298 ---------- .abcd/development/brief/04-surfaces/README.md | 16 +- .../brief/05-internals/03-configuration.md | 4 +- .../brief/05-internals/07-memory.md | 26 +- .../05-internals/09-provenance-substrate.md | 16 +- .../06-delivery/02-verification-matrix.md | 2 +- .../shipped/itd-36-memory-unification.md | 4 +- .abcd/development/release-gate/manifest.json | 9 +- .../release-gate/receipt.example.json | 2 +- .abcd/development/release/surface.json | 558 +++++++++--------- .abcd/rules.json | 3 +- .githooks/pre-commit | 16 +- commands/banlist.md | 2 +- commands/consult.md | 17 +- commands/guard.md | 2 +- commands/ingest.md | 15 +- commands/library.md | 249 ++++++++ commands/memory.md | 118 ---- commands/source.md | 137 ----- commands/update.md | 2 +- docs/reference/cli/commands.md | 447 +++++++------- docs/reference/terminology.md | 4 +- internal/core/ahoy/defaults/pre-commit | 16 +- internal/core/banlist/hook_sources_test.go | 16 +- internal/core/guard/defaults/guard.json | 16 +- internal/core/guard/sourceflip_test.go | 20 +- internal/core/memory/ask.go | 8 +- internal/core/memory/bare.go | 16 +- internal/core/memory/ingest.go | 4 +- internal/core/memory/lint.go | 4 +- internal/core/memory/rendernaming_test.go | 4 +- internal/core/source/source.go | 4 +- internal/core/source/source_test.go | 4 +- internal/core/surface/examples.go | 9 +- internal/core/surface/sentences.go | 45 +- internal/surface/cli/banlist.go | 2 +- internal/surface/cli/barerender.go | 3 - internal/surface/cli/cli.go | 100 ++-- .../cli/error_pathleak_surface_test.go | 6 +- internal/surface/cli/helpgroups.go | 11 +- internal/surface/cli/helpgroups_test.go | 2 +- .../cli/json_empty_collections_test.go | 18 +- internal/surface/cli/library_test.go | 154 +++++ internal/surface/cli/memory_drift_test.go | 10 +- .../memory_ingest_licence_termsafe_test.go | 2 +- .../surface/cli/memory_ingest_surface_test.go | 4 +- internal/surface/cli/memory_root_test.go | 51 +- internal/surface/cli/source.go | 174 ++++-- internal/surface/cli/source_surface_test.go | 57 +- internal/surface/cli/staleusage.go | 2 + 63 files changed, 1826 insertions(+), 1628 deletions(-) create mode 100644 .abcd/development/brief/04-surfaces/07-library.md delete mode 100644 .abcd/development/brief/04-surfaces/07-memory.md delete mode 100644 .abcd/development/brief/04-surfaces/33-source.md create mode 100644 commands/library.md delete mode 100644 commands/memory.md delete mode 100644 commands/source.md create mode 100644 internal/surface/cli/library_test.go diff --git a/.abcd/development/brief/01-product/01-press-release.md b/.abcd/development/brief/01-product/01-press-release.md index 5eaae7dd7..6af908cd9 100644 --- a/.abcd/development/brief/01-product/01-press-release.md +++ b/.abcd/development/brief/01-product/01-press-release.md @@ -2,7 +2,7 @@ ## Press Release -> **abcd helps a product thinker realise an intent as a high-fidelity prototype or demonstrator — carrying the why from idea to shipped reality.** State what should exist, why, for whom, and what good looks like, as a press-release intent (`/abcd:intent ""`); abcd holds that why through planning (`/abcd:intent plan`), the readiness gate (`/abcd:intent ready`) and delivery, and when the work lands it is audited against the intent's own acceptance criteria (`/abcd:intent audit`), with the verdict recorded on the intent, so what was promised and what was delivered are read side by side. The lifeboat is a key capability of the same product: point `/abcd:disembark pack ` at any repository — one that has grown unwieldy, or one that died years ago and that abcd never managed — and abcd reads it without writing a single byte back, packing its hard-won knowledge — decisions, principles, pitfalls, the graveyard of what failed, the spine of what was built and why — into a portable lifeboat artefact at ``, alongside a coverage report that names what it could *not* ground and the questions a human must answer. `/abcd:disembark probe ` renders that coverage verdict on its own, and `/abcd:embark from ` unpacks a lifeboat into a clean foundation with the same wisdom but none of the cruft. A small command surface rounds out the workflow: install (`/abcd:ahoy install`) and public release (`/abcd:launch ship`); `/abcd:capture` keeps discovered issues structured as the project evolves; and `/abcd:memory` curates a queryable knowledge substrate from those same sources. Bare invocation of any command shows status + suggested next actions; state-mutating actions require an explicit sub-verb (the universal abcd convention). +> **abcd helps a product thinker realise an intent as a high-fidelity prototype or demonstrator — carrying the why from idea to shipped reality.** State what should exist, why, for whom, and what good looks like, as a press-release intent (`/abcd:intent ""`); abcd holds that why through planning (`/abcd:intent plan`), the readiness gate (`/abcd:intent ready`) and delivery, and when the work lands it is audited against the intent's own acceptance criteria (`/abcd:intent audit`), with the verdict recorded on the intent, so what was promised and what was delivered are read side by side. The lifeboat is a key capability of the same product: point `/abcd:disembark pack ` at any repository — one that has grown unwieldy, or one that died years ago and that abcd never managed — and abcd reads it without writing a single byte back, packing its hard-won knowledge — decisions, principles, pitfalls, the graveyard of what failed, the spine of what was built and why — into a portable lifeboat artefact at ``, alongside a coverage report that names what it could *not* ground and the questions a human must answer. `/abcd:disembark probe ` renders that coverage verdict on its own, and `/abcd:embark from ` unpacks a lifeboat into a clean foundation with the same wisdom but none of the cruft. A small command surface rounds out the workflow: install (`/abcd:ahoy install`) and public release (`/abcd:launch ship`); `/abcd:capture` keeps discovered issues structured as the project evolves; and `/abcd:library` keeps the documents you consult and curates a queryable knowledge substrate from those same sources. Bare invocation of any command shows status + suggested next actions; state-mutating actions require an explicit sub-verb (the universal abcd convention). > > "I know what I want built and I can tell in a minute whether a prototype got it right. What I could never do was hold that line through the engineering. abcd takes the why as I wrote it, builds against it, and shows me what was promised next to what was delivered," said Iris, a product thinker. > diff --git a/.abcd/development/brief/01-product/02-context.md b/.abcd/development/brief/01-product/02-context.md index 492eb0e71..5f0651499 100644 --- a/.abcd/development/brief/01-product/02-context.md +++ b/.abcd/development/brief/01-product/02-context.md @@ -10,7 +10,7 @@ The commands this context turns on are these; the generated [CLI reference](../. - **`/abcd:launch ship`** — cut a curated release from the single repo (the packaging filter denies `.abcd/**`, so the released binaries do not carry it), scrub for PII/secrets, stamp the version, update the marketplace entry. Bare `/abcd:launch` refuses and asks for `--dry-run`; the `--dry-run` flag previews the bundle, its secret scan and the release gates, and writes only its pre-flight report, to the local tier. - **`/abcd:intent`** — bare quoted `/abcd:intent ""` is the canonical create (spc-30/itd-46), plus the sub-verbs that plan, gate, audit, hold, target and reclassify an intent (`consistency` per itd-48, which superseded itd-31; the CLI reference lists them all), with `refine` / `grill` / `ship` / `shape` remaining design targets. `new` is not a sub-verb: `abcd intent new` is refused and names the quoted create. There is no plain `list` sub-verb — it is folded into the bare render per SD001. Manages **intents** (press-release-format intent docs at `.abcd/development/intents/{drafts,planned,shipped,disciplines,superseded}/`). `plan` promotes an intent to `planned/` and plans the work as a spec on the native spec store ([adr-26](../../decisions/adrs/0026-native-spec-layer-ccpm-backend.md); the companion harness `ccpm` as the deeper backend); `ship` drives that spec to completion (or the full pipeline if from drafts/); a spec-close hook reconciles standalone/bundle intents planned → shipped automatically on a successful close (spc-28 `intent_lifecycle.reconcile`); disciplines move from drafts/ to disciplines/ on plan and stay there. Bare `/abcd:intent` shows status+help. - **`/abcd:capture`** — capture / list / promote / resolve / wontfix issues (the structured `.abcd/work/issues/` ledger). Issues live at `.abcd/work/issues/{open,resolved,wontfix}/iss-N-.md`. See itd-4. The cross-corpus synthesist (`/abcd:dredge`) is a draft intent, itd-25. -- **`/abcd:memory`** — curate a queryable knowledge substrate (`ingest` external sources / `ask` queries / `lint` health-checks) from specs, ADRs, reviews, and memory. See itd-36 and [`05-internals/07-memory.md`](../05-internals/07-memory.md). +- **`/abcd:library`** — keep the sources corpus (`init` / `add` / `declassify` / `ledger` / `sync-banlist` / `cite-check`, per itd-76) and curate a queryable memory store (`ingest` external sources / `ask` queries / `lint` health-checks) from specs, ADRs, reviews, and memory, two stores under one command (itd-2610090831227812). See itd-36, [`04-surfaces/07-library.md`](../04-surfaces/07-library.md) and [`05-internals/07-memory.md`](../05-internals/07-memory.md). **Designed, not built: the `intent grill` sub-verb** (beside `refine`, also a design target; `abcd intent` registers neither, and [`04-surfaces/README.md`](../04-surfaces/README.md) holds the design target): diff --git a/.abcd/development/brief/01-product/03-mental-model.md b/.abcd/development/brief/01-product/03-mental-model.md index 7fce0f0d0..f144912d1 100644 --- a/.abcd/development/brief/01-product/03-mental-model.md +++ b/.abcd/development/brief/01-product/03-mental-model.md @@ -96,7 +96,7 @@ abcd already covers Mapping (press release + acceptance criteria) and Justificat ## The project is a growing system -abcd is itself a growing system that shows the symptoms of all growing systems: surface count grows (the user-facing command design target — `/abcd:ahoy`, `/abcd:disembark`, `/abcd:embark`, `/abcd:launch`, `/abcd:intent`, `/abcd:capture`, `/abcd:memory` — plus many sub-verbs); agent count grows (the catalog already lists more than a dozen agents, several with multiple roles each — see [`05-internals/01-agents.md`](../05-internals/01-agents.md)); cross-cutting concerns proliferate (the active roster in [`intents/disciplines/`](../../intents/disciplines/) keeps growing); internal vocabulary grows (`lifeboat`, `voyage`, `logbook`, `discipline`, `kind`, `surface_history`, `page class`, `source class`, `quotation budget`, …). Every term adds expressive power AND opacity for newcomers. +abcd is itself a growing system that shows the symptoms of all growing systems: surface count grows (the user-facing command design target — `/abcd:ahoy`, `/abcd:disembark`, `/abcd:embark`, `/abcd:launch`, `/abcd:intent`, `/abcd:capture`, `/abcd:library` — plus many sub-verbs); agent count grows (the catalog already lists more than a dozen agents, several with multiple roles each — see [`05-internals/01-agents.md`](../05-internals/01-agents.md)); cross-cutting concerns proliferate (the active roster in [`intents/disciplines/`](../../intents/disciplines/) keeps growing); internal vocabulary grows (`lifeboat`, `voyage`, `logbook`, `discipline`, `kind`, `surface_history`, `page class`, `source class`, `quotation budget`, …). Every term adds expressive power AND opacity for newcomers. Three failure modes the brief actively guards against: diff --git a/.abcd/development/brief/01-product/04-scope.md b/.abcd/development/brief/01-product/04-scope.md index 22b47ac56..93757dc0d 100644 --- a/.abcd/development/brief/01-product/04-scope.md +++ b/.abcd/development/brief/01-product/04-scope.md @@ -12,7 +12,7 @@ The scope this brief covers is the intents on the lifecycle shelves: what has sh - `/abcd:launch` — public promotion — see [`04-surfaces/04-launch.md`](../04-surfaces/04-launch.md) - `/abcd:intent` — press-release intent capture — see [`04-surfaces/05-intent.md`](../04-surfaces/05-intent.md) - `/abcd:capture` — issue ledger — see [`04-surfaces/06-capture.md`](../04-surfaces/06-capture.md) -- `/abcd:memory` — multi-upstream curated knowledge substrate (per itd-36) — see [`05-internals/07-memory.md`](../05-internals/07-memory.md) +- `/abcd:library` — the sources corpus (per itd-76) and the multi-upstream curated memory store (per itd-36), two stores under one command (itd-2610090831227812) — see [`04-surfaces/07-library.md`](../04-surfaces/07-library.md) and [`05-internals/07-memory.md`](../05-internals/07-memory.md) - `/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) diff --git a/.abcd/development/brief/02-constraints/02-dependencies.md b/.abcd/development/brief/02-constraints/02-dependencies.md index 0b80a28aa..75cfa141f 100644 --- a/.abcd/development/brief/02-constraints/02-dependencies.md +++ b/.abcd/development/brief/02-constraints/02-dependencies.md @@ -61,7 +61,7 @@ environment every abcd subprocess gets, its own output discarded. Armed with no gitleaks binary installed is one state with one consequence per consumer. A release (`launch`) and a lifeboat (`disembark pack`) refuse on it, the privacy lint reports it as an error, and the write paths — transcript -capture, issue capture, memory ingest — write with the native scanner and name +capture, issue capture, the memory store's ingest — write with the native scanner and name the gap in their receipt. A gitleaks run that fails, a report the scanner cannot place, or a configured path the adapter refuses degrades the scanner instead: every consumer treats that as it treats a broken `pii.json`. diff --git a/.abcd/development/brief/02-constraints/04-naming.md b/.abcd/development/brief/02-constraints/04-naming.md index b1b1e02fe..915bbce2f 100644 --- a/.abcd/development/brief/02-constraints/04-naming.md +++ b/.abcd/development/brief/02-constraints/04-naming.md @@ -53,7 +53,7 @@ The exemptions carrying a rationale of their own: The remaining surfaces are exempt for the plain reason that no maritime cognate adds meaning: `banlist`, `consult`, `decide`, `docs`, `guard`, -`history`, `ideate`, `identity`, `ingest`, `lint`, `memory`, +`history`, `ideate`, `identity`, `ingest`, `library`, `lint`, `reading`, `rules`, `site`, `spec`, and `update`. They are registered here so the exemption is on the record. diff --git a/.abcd/development/brief/04-surfaces/07-library.md b/.abcd/development/brief/04-surfaces/07-library.md new file mode 100644 index 000000000..7a4d2c125 --- /dev/null +++ b/.abcd/development/brief/04-surfaces/07-library.md @@ -0,0 +1,501 @@ +# `/abcd:library` — The Sources Corpus and the Memory Store + +Two stores under one command, kept apart on purpose. The **sources corpus** keeps +the documents a person reads for a decision, including material they are not free +to name in public, and records which source shaped which decision. The **memory +store** keeps what a project learns from what it reads, as distilled, cited pages +inside the repository. One command reaches both, and nothing connects them: +nothing distils a corpus document into the memory store, and nothing moves a +memory page into the corpus (spc-2610100613109045, decision 10). + +The command merges the two that came before it, `source` and `memory`, under +the product thinker's ruling of 2026-10-09 that the sources commands become one +person's command named `library` +([itd-2610090831227812](../../intents/planned/itd-2610090831227812-a-person-who-types-abcd-sees-only-the.md)). +Under [adr-40](../../decisions/adrs/0040-review-audit-lint-are-three-verbs.md) neither old name survives as +an alias. Every sub-verb kept its name (decision 7), so the memory store's +`ingest`, which distils a source into pages, sits beside the corpus's `add`, +which registers a document. The host-delegated pages +[`/abcd:consult`](13-consult.md) and [`/abcd:ingest`](14-ingest.md) carry the +corpus's judgement (what to search for, which class a document is, which +keywords matter) and call these verbs for every write; the page named `ingest` +means the corpus's `add`, not the memory store's `ingest`. + +## 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 | +|---|---|---| +| `add` | — | shipped | +| `ask` | — | shipped | +| `cite-check` | gate | shipped | +| `declassify` | — | shipped | +| `ingest` | — | shipped | +| `init` | — | shipped | +| `ledger` | — | shipped | +| `lint` | lint | shipped | +| `sync-banlist` | — | shipped | + +`add`, `cite-check`, `declassify`, `init`, `ledger` and `sync-banlist` act on the +sources corpus; `ask`, `ingest` and `lint` act on the memory store. + +## Bare `abcd library` + +Bare `abcd library` is read-only and renders both stores: the corpus (where it +is, how many sources sit in each class, the ledgers and their line counts, and +any entry whose folder and metadata disagree) and the checkout's memory store +(below). A store that cannot be read is one line saying why and naming what +would create it: `abcd library init` for a missing corpus, a checkout for the +memory store. The command refuses, exit 2, only when neither can be read +(decision 8). So inside a checkout with no corpus it renders the memory store +and a line about the corpus, and outside every checkout with a corpus it +renders the corpus and a line about the memory store. The `--json` envelope +carries `corpus` and `memory`, each null when that store could not be read, with +`corpus_note` or `memory_note` saying why. The corpus is the default one under +the user-level home, `~/.abcd.noindex/sources`, unless `--corpus` names another +directory. + +## The sources corpus + +Read anything that bears on a decision, including material you are not free to +name in public, and never let a helpful footnote name it. The corpus is a +personal collection of documents in a local-only git repository; an append-only +ledger per repository records which source shaped which decision; every +confidential source's names are banned at commit time; and a ledger line becomes +a public citation only when a person decides to and the source permits it. + +The cost is two habits: declaring a source's class once, when it is added, and +recording an influence when a source genuinely shapes a decision. The payoff is +that nothing confidential can reach a commit by accident, and the day a paper is +published the whole influence trail behind it is already written. + +### The store + +The corpus is a directory the person owns, outside every repository, and itself a +git repository with no remote. Its history is the tamper-evidence layer: every +write a verb makes is committed there. + +| path | holds | +|---|---| +| `sources.json` | a CSL-JSON array; each entry's `custom` block carries `confidential`, `permission_status`, `keywords`, `aliases`, `ban_authors` and `file` | +| `confidential//` | the original document, its extracted text as `text.md`, and anything derived from them | +| `public//` | the same shape for a freely citable source | +| `ledger/.jsonl` | one repository's influence records, one JSON object per line | + +**The folder is the classification.** A source's class is declared once, when it +is added: the add requires the class, confidential or public, and never defaults, +because a forgotten flag must not file a confidential document as public. Derived +artefacts (summaries, notes) live in the source's folder and inherit its class. +The entry's `confidential` flag mirrors the folder, and a corpus where the two +disagree (a folder moved by hand, an entry edited by hand) is refused wholesale by +the banlist sync and the scan, naming each key to repair. That is the safe +direction: the block already written keeps banning. + +A repository's ledger is named by the first twelve hex digits of its root commit, +which every clone and worktree of one repository shares whatever its directory is +called, unless the invocation names it. + +Creating the corpus is its own explicit step, and it refuses a location inside +another repository's working tree, where one `git add -A` would carry documents into it. Nothing +fetches and nothing converts: a Markdown or plain-text document is its own text, +any other needs its extracted text passed alongside, and a URL alone registers a +metadata stub. + +### Keys are the only handle output carries + +Every refusal, every guard message and every scan names a source by its key and +nothing else. So a confidential source's key must not name it: the add refuses a +key containing one of its aliases, its title or a longer title word, or an +author's name, and asks for an opaque key such as `conf2026a`. The identifying +strings themselves can travel in a metadata file or on stdin, so they stay out of +the process list and the shell history. + +### Recording influence, and citing + +Recording an influence appends `{ts, repo, decision_ref, claim, source_key, +locator, influence, cited_publicly}` with `cited_publicly` false, plus `used_in`, +the repository paths the influence landed in, when the caller names them, and commits it. There is no edit +path: a correction is a new line naming, in `corrects`, the line it corrects. + +A public citation needs both gates of +[adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md). The source grants +the right: its folder is under `public/` and its `permission_status` is +`citable`, the one value in the closed vocabulary that grants it. The person +exercises the right: the flip of line N checks the first gate, refuses naming it +when it fails, and on success appends a new line, a copy of line N with +`cited_publicly` true and `flips` naming N. An agent never runs the flip. The +binary cannot tell a person from an agent, so that rule lives in the command +pages. + +Declassification is how a confidential source becomes citable when it is +published: its folder moves `confidential/ → public/` by `git mv`, its entry's +flag and permission follow, and both land in one corpus commit. The next banlist +sync drops its strings, and its ledger lines become flippable. + +### The guard + +The banlist sync projects every confidential source into a fenced block of this +repository's untracked private banlist, `.abcd/.work.local/private-names.txt` (the +private layer of [`/abcd:banlist`](20-banlist.md)): the title and each alias +always, the authors only where the entry sets `ban_authors`. Lines outside the +fence, hand-written or verb-added, survive every sync. The by-hand sync creates +the store when there is something to ban; the refresh mode updates a store that +already exists and declares the keyed format, and never creates one, because a +store created by a commit's side effect would write every confidential title into +a repository's local tier without the person asking. The committed pre-commit +guard runs the sync in its refresh mode before it reads the store, so the block +is never more than one commit stale, and then refuses a staged commit carrying a +banned string by key. The refresh's own line is relayed on the commit, never +discarded: its count is how a refresh that wrote fewer patterns than the last +one shows itself. + +The two copies of the guard find the binary differently. The copy this +repository runs builds `./cmd/abcd` from the checkout, as its commit-msg hook +does, and never runs an installed abcd, which in a source checkout is the last +release and may lack the verb entirely; a tree that does not build is one +warning line. The copy `abcd ahoy` scaffolds refreshes only on opt-in, per +clone: the repo-local git setting `abcd.sourcesBinary` names an absolute path +to the binary, and nothing else is consulted, neither `PATH` nor an +environment variable. How a scaffolded hook finds abcd, and whether it does so +by default, is a ruling owed to the product thinker +(iss-2609250834251447); opt-in decides none of it, and the ruling can widen +it. Without the setting, AC3 holds in this repository and in a managed +repository that opted in, and the scaffolded guard names the setting on one +line. + +A legacy private store is not refreshed. The guard names the banlist verb's +migration ([`20-banlist.md`](20-banlist.md)) on the first commit that meets it +and not again, since the store's format line already says "legacy" on every +commit. + +Each phrase is projected into the pattern language the guard enforces: literal, +case-insensitive, and whitespace-flexible across ASCII and Unicode space +separators, with a non-ASCII letter written as its case-fold alternatives because +the guard's C-locale engine folds ASCII only. The boundary is a test on the +neighbouring bytes, never `\b`: POSIX extended expressions do not define it, and +RE2's ASCII-only reading finds no word start before a non-ASCII initial. The neighbour test +errs towards matching: a phrase beside a non-ASCII letter still counts as found. +A phrase with fewer than three letters or digits is refused, because it would +ban ordinary words. + +The scan reads a file or stdin through the same projection and the same engine +as the guard, so a text it calls clean is a text the guard would pass. It +reports each finding by key, field (`title`, `alias-N`, `author-N`), line and +byte offset, and never by the text matched, so its report is safe to relay. The +offset is the guard engine's own: it counts from the start of the whole text, +not from the start of the line, to the start of the matched span, which can be +the one byte before the phrase that bounds it. It exits 1 when anything is found. + +### Absence is loud + +With no corpus at the configured location every corpus verb but the creating one says +so on one line and exits 3, a code distinct from a refusal, so a script can tell +"nothing to check against" from "checked and clean". The guard says so on one line +and lets the commit proceed; the sync's refresh mode, which the guard runs, does +the same and exits 0, as it does for a repository with no private store. A +corpus the guard cannot refresh (no binary built or opted in, a binary without +the verb, or a refresh that fails) is one line on the commit, and the store is +checked as it stands. + +### What it cannot enforce + +The mechanical layer blocks literal identifying strings on one line of a staged +file. It cannot see a paraphrase that identifies a source without naming it, a +name split across a line break, or a spelling the entry does not carry (an +unrecorded alias, a decomposed Unicode form). Those are the consultation rule's +job and the human review before anything is published. Durability is bounded +too: a corpus with no remote survives the loss of its disk only through the +person's own backups and offline `git bundle` snapshots, which abcd documents and +cannot perform. + +## The memory store + +Keep what a project learns from the things it reads, in a form that is still +usable a year later: distilled pages rather than stored documents, each carrying +the citation, licence and content hash it came from, so an answer drawn out of +the store can always be traced back to a source and re-checked. The store lives +in the repo at `.abcd/memory/`, so it travels with the project and is reviewable +in a diff. + +The cost is the discipline: sources are distilled and discarded by default +rather than hoarded, the quotation budget is a curation rule rather than a +storage limit, and licence and provenance are recorded at ingest instead of +reconstructed later. + +The **substrate spec** (page-class enum, source-class taxonomy, curator +behaviour, lifecycle class) is +[`05-internals/07-memory.md`](../05-internals/07-memory.md). This section is the +surface contract: what the user types and what happens. + +> **Provenance, and a warning about ids.** The memory store traces to itd-36, +> which sits in `intents/shipped/`. The write core and the lint family were +> specified in a predecessor store whose `spc-38` and `spc-39` collide with live +> ids in this repo's own store. So in this section the store an id belongs to is +> read from the sentence around it, never from the number. + +**The store every memory verb addresses is the checkout's, resolved from the working directory and never taken to be it.** The front door asks [`gitutil.CheckoutRoot`](../../../../internal/gitutil/repo.go) before it builds a request — the same resolution `capture`, `decide` and `spec` address their stores through: git's toplevel where git will name one, and a refusal in the two remaining states rather than a guess (a repo-shaped tree git will not answer for, and no repository above at all). So a render from a package directory reports the checkout's pages instead of `store not present`, an ingest lands in the checkout's substrate instead of laying a second one beneath the caller, and outside a checkout `ingest`, `ask` and `lint` exit **2** having read nothing and written nothing. It deliberately does not fall through to a marker walk, which would accept any directory carrying the name (iss-2609090947359464). A memory store found **below** the checkout root, on the chain between the caller and it, is named on stderr and left untouched — the deposit an unresolved front door leaves behind, reported to the person standing over it rather than stepped over in silence. + +**Its half of bare `abcd library`** renders the store's state and nothing else: how many +pages there are by class, when the last ingest happened, the recent +contradictions, per-source quotation-budget headroom, and drift. It never +mutates and never rebuilds an index. Drift is a line saying that the catalogue +or the contradictions register no longer hash-matches what the store's pages +would render, naming the ingest as the verb that rebuilds it, so a reader +knows the numbers are stale rather than wrong; the text board prints +each line in the words the JSON's `drift` list carries, and a current store +prints none. Headroom is read-only in the same +spirit: a fresh index shows per-source warn and block headroom, a drifted one +says to run the lint, and an absent or unreadable one says the headroom is +unavailable rather than guessing at it. + +**Ingesting a source** — a local path or an https URL — registers it as typed +pages with citation frontmatter, and appends to the ingest log. The host agent +is the distiller: it reads the source, produces the distilled pages, and passes +them to the binary as JSON, which is load-bearing and required for a +source the store has not seen (an already-known source re-ingests from the +registry without it). The binary computes provenance, licence and content hash, +validates every page, and writes atomically. + +Three refusals matter, because the store copies fetched text and its licence +verbatim into a durable artefact. A plaintext `http://` source is refused by +name, and a redirect that leaves https is refused per hop. A credential the URL +carries (basic-auth userinfo, or a query key such as `token` or `api_key`) is +stripped before the fetched address becomes the stored origin, and masked in +every fetch-failure message. And PDF is a later seam: the binary rejects +PDF sources with a clear error until a text-extraction dependency is wired. + +**The original is not stored by default.** Ingest can opt into keeping +it under `.abcd/memory/sources/`. The licence gate that would police publishing +such a file belongs to the lifeboat, not to launch (adr-28): launch excludes +`.abcd/**` wholesale, so it never publishes the files the gate checks. That gate +is not built, and the shipped ingest classifies restrictive licences without +ever refusing on them. + +**Asking a question** synthesises an answer with per-citation provenance, every +citation naming its source class, citation and content hash. The host-produced +answer can be filed back as a new memory page, and the retrieval depth is the +caller's to set. + +**The store lint** is the curator health-check over the whole store. It +always crawls the full store, rebuilds the regenerable coverage index, and +writes its findings to a run log under `.abcd/.work.local/logs/memory/`. It +mutates no memory-store state, and its exit code is the decision: blockers exit +non-zero, warnings alone exit 0, because most of what it reports is curator +advice rather than a fault. + +Seven codes ship, in four families: per-page and cumulative quotation budgets +with a diagnostic for the case where coverage cannot be computed (`MQ001`, +`MQ002`, `MQ003`); source-class findings raised page by page, one where a page +rests on a single class of source and one where a page mixes classes without +saying how it weighs them against each other (`MS001`, `MS002`); a missing +licence on an external source (`ML001`); and secret or identity residue in +stored text (`MR001`). `MR001` is the read side of the write-time redactor, run +over every page, the source registry and each stored original, and over every +page name, which it judges as the write side judges a filename: split into its +parts and held to the hard-fail bar alone. It names the kind and the line, never +the span, and the lint never rewrites the store. The write-time redactor carries +a repository's armed gitleaks (`.abcd/config/gitleaks.json`): its findings are +masked with the native ones, a gitleaks run that fails refuses the ingest, and +armed with no binary installed the ingest writes on the native scanner and its +`scan_gap` names what is missing +([dependencies](../02-constraints/02-dependencies.md)). + +Four of the seven can stop the run. `MR001` is the sharpest: residue in the +store is a fault, never advice. `ML001` and `MS002` join it, because a source +with no licence and a page that silently blends trust levels are both defects in +what the store claims rather than suggestions about how to curate it. `MQ002` +blocks only in its strict form, when one source's quoted coverage passes the +block threshold on unambiguous single-source attribution alone; the same +coverage reached through passages attributable to several sources is capped at a +warning, because that arithmetic cannot prove any one source was over-quoted. +Everything else, `MS001` included, is advisory and leaves the exit code at zero. + +The quotation budget is applied as curation discipline at distil time and +computed at lint time. Nothing enforces it at ingest. + +### What ships, and what does not + +The write core (bare, ingest and ask) and the lint family are on the binary. +Contradictions are rendered by the write core's own reconciliation and surfaced +by the bare render; orphan and stale-claim audits are deferred. + +Not built: a curator role on the `principle-distiller` agent (the agent and the +disembark sub-verb it feeds ship; this role does not), ingest and ask run +reports, user-scope memory outside the repo, and the automatic extraction of a +spec's modification grammar into the store at spec completion. + +### Composition with adjacent surfaces + +- **`/abcd:disembark`** is designed to export curated project memory and + provenance into the lifeboat. What the lifeboat is specified to carry is the + curated provenance surface named in + [`02-disembark.md`](02-disembark.md), not a verbatim copy of the store; + declaring an exact payload is deferred to the disembark spec that wires the + packer (adr-28). The recovery-humility framing applies: the lifeboat is the + floor of recoverable theory, not the theory. +- **`/abcd:embark`** carrying the store forward is designed behaviour. The + source-class enum carries forward, and the receiver runs the store lint after + unpacking to check that quotation budgets and licences have not drifted. +- **`/abcd:launch`** does not consume the licence gate at all: the public + payload excludes `.abcd/**` wholesale as policy, so the launch preview + surfaces no licence verdict (see + [`04-launch.md § 2`](04-launch.md#2-curated-release-artefact-default-deny)). + +**Tight coupling**, logged as a principal risk: itd-36 is structurally +non-decomposable, because the sub-verbs need the schema, the schema needs the +curator role, and the curator role needs the lint codes. A partial ship is not +meaningful. + +## Acceptance + +- **Given** a document and its metadata, **when** it is added as confidential, + **then** the bibliography gains its CSL-JSON entry with the `custom` block, and + the document and its extracted text land under `confidential//`. +- **Given** a consulted source that shaped a decision, **when** the influence is + recorded, **then** one line is appended with `cited_publicly` false, and a + correction is another new line. +- **Given** confidential entries in the corpus, **when** a commit runs in a + managed repository, **then** the guard refreshes the generated block (titles + and aliases always, authors only under `ban_authors`) and refuses a commit + carrying a banned string. +- **Given** text about to leave the machine, **when** the scan reads it, + **then** offending sources are reported by key only. +- **Given** a ledger line whose source lacks citation permission, **when** a flip + is attempted, **then** it is refused naming the failing gate; with permission + present the flip succeeds as a new line. +- **Given** a machine with no corpus, **when** abcd runs in a managed repository, + **then** every corpus-dependent step says so on one line, and none fails. +- **Given** a confidential source that is published, **when** it is declassified, + **then** the next refresh drops its strings and its ledger lines become + eligible for the flip. + +## References + +- Plugin command: [`commands/library.md`](../../../../commands/library.md) +- The host-delegated halves of the corpus: [`13-consult.md`](13-consult.md), + [`14-ingest.md`](14-ingest.md) +- The private banlist layer the guard shares: [`20-banlist.md`](20-banlist.md) +- The corpus's trust boundary: [adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md), + brief invariant 9 + ([`../02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)) +- [`05-internals/07-memory.md`](../05-internals/07-memory.md): the memory store's substrate spec +- [`05-internals/09-provenance-substrate.md`](../05-internals/09-provenance-substrate.md): provenance and licence subsystem +- Consuming intents: [itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md) (the corpus), + [itd-36](../../intents/shipped/itd-36-memory-unification.md) (the memory store), and + [itd-2610090831227812](../../intents/planned/itd-2610090831227812-a-person-who-types-abcd-sees-only-the.md) (the merge) +- [`research/related-work.md § Karpathy LLM Wiki`](../../research/related-work.md#karpathy-llm-wiki--pattern-source-for-abcdmemory): pattern source for the memory store + + + +## Appendix: the shipped surface + +_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ + +### `abcd library` + +Sub-verbs: `abcd library add`, `abcd library ask`, `abcd library cite-check`, `abcd library declassify`, `abcd library ingest`, `abcd library init`, `abcd library ledger`, `abcd library lint`, `abcd library sync-banlist`. + +| Flag | Type | +|---|---| +| `--corpus` | string | + +### `abcd library add` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--alias` | stringArray | +| `--author` | stringArray | +| `--ban-authors` | bool | +| `--confidential` | bool | +| `--key` | string | +| `--keywords` | stringArray | +| `--meta` | string | +| `--permission` | string | +| `--public` | bool | +| `--text` | string | +| `--title` | string | +| `--type` | string | +| `--url` | string | +| `--venue` | string | +| `--year` | int | + +### `abcd library ask` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--file-back` | bool | +| `--page-json` | string | +| `--top-n` | int | + +### `abcd library cite-check` + +Sub-verbs: none. + +Flags: none. + +### `abcd library declassify` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--permission` | string | + +### `abcd library ingest` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--keep-original` | bool | +| `--pages-json` | string | + +### `abcd library init` + +Sub-verbs: none. + +Flags: none. + +### `abcd library ledger` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--claim` | string | +| `--corrects` | int | +| `--decision` | string | +| `--flip` | int | +| `--influence` | string | +| `--list` | bool | +| `--locator` | string | +| `--repo` | string | +| `--source` | string | +| `--used-in` | stringArray | + +### `abcd library lint` + +Sub-verbs: none. + +Flags: none. + +### `abcd library sync-banlist` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--refresh` | bool | + + diff --git a/.abcd/development/brief/04-surfaces/07-memory.md b/.abcd/development/brief/04-surfaces/07-memory.md deleted file mode 100644 index 8695465d1..000000000 --- a/.abcd/development/brief/04-surfaces/07-memory.md +++ /dev/null @@ -1,200 +0,0 @@ -# `/abcd:memory` — Curated Knowledge Substrate - -Keep what a project learns from the things it reads, in a form that is still -usable a year later: distilled pages rather than stored documents, each carrying -the citation, licence and content hash it came from, so an answer drawn out of -the store can always be traced back to a source and re-checked. The store lives -in the repo at `.abcd/memory/`, so it travels with the project and is reviewable -in a diff. - -The cost is the discipline: sources are distilled and discarded by default -rather than hoarded, the quotation budget is a curation rule rather than a -storage limit, and licence and provenance are recorded at ingest instead of -reconstructed later. - -The **substrate spec** (page-class enum, source-class taxonomy, curator -behaviour, lifecycle class) is -[`05-internals/07-memory.md`](../05-internals/07-memory.md). This file is the -surface contract: what the user types and what happens. - -> **Provenance, and a warning about ids.** The surface traces to itd-36, which -> sits in `intents/shipped/`. The write core and the lint family were specified -> in a predecessor store whose `spc-38` and `spc-39` collide with live ids in -> this repo's own store. So on this page the store an id belongs to is read from -> the sentence around it, never from the number. - -## 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 | -|---|---|---| -| `ask` | — | shipped | -| `ingest` | — | shipped | -| `lint` | lint | shipped | - - -**The store every verb here addresses is the checkout's, resolved from the working directory and never taken to be it.** The front door asks [`gitutil.CheckoutRoot`](../../../../internal/gitutil/repo.go) before it builds a request — the same resolution `capture`, `decide` and `spec` address their stores through: git's toplevel where git will name one, and a refusal in the two remaining states rather than a guess (a repo-shaped tree git will not answer for, and no repository above at all). So a render from a package directory reports the checkout's pages instead of `store not present`, an ingest lands in the checkout's substrate instead of laying a second one beneath the caller, and outside a checkout every verb exits **2** having read nothing and written nothing. It deliberately does not fall through to a marker walk, which would accept any directory carrying the name (iss-2609090947359464). A memory store found **below** the checkout root, on the chain between the caller and it, is named on stderr and left untouched — the deposit an unresolved front door leaves behind, reported to the person standing over it rather than stepped over in silence. - -**Bare `/abcd:memory`** renders the store's state and nothing else: how many -pages there are by class, when the last ingest happened, the recent -contradictions, per-source quotation-budget headroom, and drift. It never -mutates and never rebuilds an index. Drift is a line saying that the catalogue -or the contradictions register no longer hash-matches what the store's pages -would render, naming the ingest as the verb that rebuilds it, so a reader -knows the numbers are stale rather than wrong; the text board prints -each line in the words the JSON's `drift` list carries, and a current store -prints none. Headroom is read-only in the same -spirit: a fresh index shows per-source warn and block headroom, a drifted one -says to run the lint, and an absent or unreadable one says the headroom is -unavailable rather than guessing at it. - -**Ingesting a source** — a local path or an https URL — registers it as typed -pages with citation frontmatter, and appends to the ingest log. The host agent -is the distiller: it reads the source, produces the distilled pages, and passes -them to the binary as JSON, which is load-bearing and required for a -source the store has not seen (an already-known source re-ingests from the -registry without it). The binary computes provenance, licence and content hash, -validates every page, and writes atomically. - -Three refusals matter, because the store copies fetched text and its licence -verbatim into a durable artefact. A plaintext `http://` source is refused by -name, and a redirect that leaves https is refused per hop. A credential the URL -carries (basic-auth userinfo, or a query key such as `token` or `api_key`) is -stripped before the fetched address becomes the stored origin, and masked in -every fetch-failure message. And PDF is a later seam: the binary rejects -PDF sources with a clear error until a text-extraction dependency is wired. - -**The original is not stored by default.** Ingest can opt into keeping -it under `.abcd/memory/sources/`. The licence gate that would police publishing -such a file belongs to the lifeboat, not to launch (adr-28): launch excludes -`.abcd/**` wholesale, so it never publishes the files the gate checks. That gate -is not built, and the shipped ingest classifies restrictive licences without -ever refusing on them. - -**Asking a question** synthesises an answer with per-citation provenance, every -citation naming its source class, citation and content hash. The host-produced -answer can be filed back as a new memory page, and the retrieval depth is the -caller's to set. - -**The store lint** is the curator health-check over the whole store. It -always crawls the full store, rebuilds the regenerable coverage index, and -writes its findings to a run log under `.abcd/.work.local/logs/memory/`. It -mutates no memory-store state, and its exit code is the decision: blockers exit -non-zero, warnings alone exit 0, because most of what it reports is curator -advice rather than a fault. - -Seven codes ship, in four families: per-page and cumulative quotation budgets -with a diagnostic for the case where coverage cannot be computed (`MQ001`, -`MQ002`, `MQ003`); source-class findings raised page by page, one where a page -rests on a single class of source and one where a page mixes classes without -saying how it weighs them against each other (`MS001`, `MS002`); a missing -licence on an external source (`ML001`); and secret or identity residue in -stored text (`MR001`). `MR001` is the read side of the write-time redactor, run -over every page, the source registry and each stored original, and over every -page name, which it judges as the write side judges a filename: split into its -parts and held to the hard-fail bar alone. It names the kind and the line, never -the span, and the lint never rewrites the store. The write-time redactor carries -a repository's armed gitleaks (`.abcd/config/gitleaks.json`): its findings are -masked with the native ones, a gitleaks run that fails refuses the ingest, and -armed with no binary installed the ingest writes on the native scanner and its -`scan_gap` names what is missing -([dependencies](../02-constraints/02-dependencies.md)). - -Four of the seven can stop the run. `MR001` is the sharpest: residue in the -store is a fault, never advice. `ML001` and `MS002` join it, because a source -with no licence and a page that silently blends trust levels are both defects in -what the store claims rather than suggestions about how to curate it. `MQ002` -blocks only in its strict form, when one source's quoted coverage passes the -block threshold on unambiguous single-source attribution alone; the same -coverage reached through passages attributable to several sources is capped at a -warning, because that arithmetic cannot prove any one source was over-quoted. -Everything else, `MS001` included, is advisory and leaves the exit code at zero. - -The quotation budget is applied as curation discipline at distil time and -computed at lint time. Nothing enforces it at ingest. - -## What ships, and what does not - -The write core (bare, ingest and ask) and the lint family are on the binary. -Contradictions are rendered by the write core's own reconciliation and surfaced -by the bare render; orphan and stale-claim audits are deferred. - -Not built: a curator role on the `principle-distiller` agent (the agent and the -disembark sub-verb it feeds ship; this role does not), ingest and ask run -reports, user-scope memory outside the repo, and the automatic extraction of a -spec's modification grammar into the store at spec completion. - -## Composition with adjacent surfaces - -- **`/abcd:disembark`** is designed to export curated project memory and - provenance into the lifeboat. What the lifeboat is specified to carry is the - curated provenance surface named in - [`02-disembark.md`](02-disembark.md), not a verbatim copy of the store; - declaring an exact payload is deferred to the disembark spec that wires the - packer (adr-28). The recovery-humility framing applies: the lifeboat is the - floor of recoverable theory, not the theory. -- **`/abcd:embark`** carrying the store forward is designed behaviour. The - source-class enum carries forward, and the receiver runs the store lint after - unpacking to check that quotation budgets and licences have not drifted. -- **`/abcd:launch`** does not consume the licence gate at all: the public - payload excludes `.abcd/**` wholesale as policy, so the launch preview - surfaces no licence verdict (see - [`04-launch.md § 2`](04-launch.md#2-curated-release-artefact-default-deny)). - -**Tight coupling**, logged as a principal risk: itd-36 is structurally -non-decomposable, because the sub-verbs need the schema, the schema needs the -curator role, and the curator role needs the lint codes. A partial ship is not -meaningful. - -## References - -- [`05-internals/07-memory.md`](../05-internals/07-memory.md): substrate spec -- [`05-internals/09-provenance-substrate.md`](../05-internals/09-provenance-substrate.md): provenance and licence subsystem -- [`../../intents/shipped/itd-36-memory-unification.md`](../../intents/shipped/itd-36-memory-unification.md): the full intent spec with acceptance criteria -- [`research/related-work.md § Karpathy LLM Wiki`](../../research/related-work.md#karpathy-llm-wiki--pattern-source-for-abcdmemory): pattern source - - - -## Appendix: the shipped surface - -_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ - -### `abcd memory` - -Sub-verbs: `abcd memory ask`, `abcd memory ingest`, `abcd memory lint`. - -Flags: none. - -### `abcd memory ask` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--file-back` | bool | -| `--page-json` | string | -| `--top-n` | int | - -### `abcd memory ingest` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--keep-original` | bool | -| `--pages-json` | string | - -### `abcd memory lint` - -Sub-verbs: none. - -Flags: none. - - diff --git a/.abcd/development/brief/04-surfaces/08-abcd.md b/.abcd/development/brief/04-surfaces/08-abcd.md index df107c25c..d08506072 100644 --- a/.abcd/development/brief/04-surfaces/08-abcd.md +++ b/.abcd/development/brief/04-surfaces/08-abcd.md @@ -127,7 +127,7 @@ Binary-backed `/abcd:` verbs route through the transport-agnostic core (the CLI is the front door today; an MCP server follows later, per [adr-23](../../decisions/adrs/0023-transport-agnostic-core.md)). Not every verb does: `consult` and `ingest` run as host-side markdown over the sources corpus, -and reach the binary only through the `source` verb, which makes every write to +and reach the binary only through the `library` verb, which makes every write to the corpus. The install's preparation workflow, on the ahoy page, is the mixed case: its audit half runs `abcd lint`, and its adoption half is binary-backed too and writes — the identity verb's initialiser records the repo's identity diff --git a/.abcd/development/brief/04-surfaces/11-history.md b/.abcd/development/brief/04-surfaces/11-history.md index 7fe357a62..5e61c90ca 100644 --- a/.abcd/development/brief/04-surfaces/11-history.md +++ b/.abcd/development/brief/04-surfaces/11-history.md @@ -351,7 +351,7 @@ quietly on disk. ## Composition The store is the substrate a later harvest is meant to read: history captures -raw sessions, and the design is that `memory` distils curated knowledge out of +raw sessions, and the design is that the memory store distils curated knowledge out of them. **Nothing does that yet.** No shipped surface reads the store but `history` itself, and the memory store's ingest takes a document or a web address, never a stored transcript. The store is keyed per repo, so transcripts never leak across diff --git a/.abcd/development/brief/04-surfaces/13-consult.md b/.abcd/development/brief/04-surfaces/13-consult.md index 1f6771c6c..ebc4a37e8 100644 --- a/.abcd/development/brief/04-surfaces/13-consult.md +++ b/.abcd/development/brief/04-surfaces/13-consult.md @@ -15,8 +15,8 @@ It is **host-delegated**: the workflow runs in the host agent from [`commands/consult.md`](../../../../commands/consult.md), and there is no `abcd consult` verb and no bare-status render of its own. Reading the corpus is plain search with `grep` and file reads; every write — a ledger line, the -banlist sync, the pre-share scan — goes through the `abcd source` verbs -([`33-source.md`](33-source.md)). +banlist sync, the pre-share scan — goes through the `abcd library` corpus verbs +([`07-library.md`](07-library.md)). ## Sub-verbs @@ -43,7 +43,7 @@ The corpus lives at `~/.abcd.noindex/sources/`, each source held as a folder `//` under `confidential/` or `public/`. **The path IS the classification**: any hit under `confidential/` falls under the hard rule below. Metadata lives in `sources.json` as CSL-JSON. If the corpus is absent -(`abcd source` exits 3), the command says so and stops; it never creates it. +(bare `abcd library` renders no corpus, only a line saying so), the command says so and stops; it never creates it. ## Flow @@ -80,11 +80,11 @@ discussed freely there. ## Guard wiring -The rule is backed mechanically rather than trusted alone, by the `abcd source` -verbs. `sync-banlist` maintains a generated block in the repo's untracked +The rule is backed mechanically rather than trusted alone, by the `abcd library` +corpus verbs. `sync-banlist` maintains a generated block in the repo's untracked `.abcd/.work.local/private-names.txt`, which the repo's committed pre-commit guard refreshes on every commit, where that store already exists and the guard can run -the verb (see [`33-source.md`](33-source.md)), and then enforces. `cite-check` scans a document before +the verb (see [`07-library.md`](07-library.md)), and then enforces. `cite-check` scans a document before it is shared and exits non-zero when a confidential identifier is present, naming only the key, so the report itself is safe to relay. Both read the same projection of the corpus through the same matcher as the guard, so a scan and a @@ -102,7 +102,7 @@ absence of complaint. the banlist verb's private layer (itd-74 / spc-20). The sources sync owns one fenced block in it and rewrites only that block, so hand-added and verb-added lines outside the fence survive every sync. See [`20-banlist.md`](20-banlist.md) -for the store itself and [`33-source.md`](33-source.md) for the projection. +for the store itself and [`07-library.md`](07-library.md) for the projection. **The mechanical layer is narrower than the hard rule, deliberately.** Patterns are derived from every confidential entry's title and aliases always, and from @@ -139,13 +139,13 @@ names. `/abcd:consult` is the read-and-record side of the sources system; [`/abcd:ingest`](14-ingest.md) is the write side that adds a source; and -[`/abcd:source`](33-source.md) is the binary both call. The three share the +[`/abcd:library`](07-library.md) is the binary both call. The three share the corpus and its ledger. ## References - Plugin command: [`commands/consult.md`](../../../../commands/consult.md) -- The verbs behind every write, and the store's schema: [`33-source.md`](33-source.md) +- The verbs behind every write, and the store's schema: [`07-library.md`](07-library.md) - Write side of the same corpus: [`14-ingest.md`](14-ingest.md) - The banlist store the guard wiring shares: [`20-banlist.md`](20-banlist.md) - The trust boundary the hard rule restates: diff --git a/.abcd/development/brief/04-surfaces/14-ingest.md b/.abcd/development/brief/04-surfaces/14-ingest.md index 9ac14354a..258a6adaf 100644 --- a/.abcd/development/brief/04-surfaces/14-ingest.md +++ b/.abcd/development/brief/04-surfaces/14-ingest.md @@ -12,8 +12,8 @@ the read side and the provenance recorder. It is a **host-delegated command**: a markdown workflow that runs in the host agent. There is no top-level `abcd ingest` verb, no bare-status render, and no -CLI flags of its own. The write it ends in is the source verb's add -([`33-source.md`](33-source.md)), which stores and classifies what the host hands +CLI flags of its own. The write it ends in is the library verb's add +([`07-library.md`](07-library.md)), which stores and classifies what the host hands it and fetches and converts nothing. @@ -71,7 +71,7 @@ five steps. inherits the class by location, record an influence edge if a live decision motivated the ingest, and tell the user the key and class. -If the corpus is absent (`abcd source` exits 3), the command says so and stops. +If the corpus is absent (bare `abcd library` renders no corpus, only a line saying so), the command says so and stops. It never creates it. ## Confidentiality contract @@ -111,7 +111,7 @@ contract. - Plugin command: [`commands/ingest.md`](../../../../commands/ingest.md) - Read side of the same corpus: [`13-consult.md`](13-consult.md) -- The verb behind the write, and the corpus contract: [`33-source.md`](33-source.md) +- The verb behind the write, and the corpus contract: [`07-library.md`](07-library.md) diff --git a/.abcd/development/brief/04-surfaces/20-banlist.md b/.abcd/development/brief/04-surfaces/20-banlist.md index 05c56917a..e2ac2164a 100644 --- a/.abcd/development/brief/04-surfaces/20-banlist.md +++ b/.abcd/development/brief/04-surfaces/20-banlist.md @@ -131,7 +131,7 @@ maintains them inside a fenced generated block in the same file, refusing a lega store that carries entries and leaving every line outside its block untouched. So a hand-added private entry and the corpus sync write into one store without either clobbering the other, and a hand-written key that collides with one the sync owns -is refused rather than overwritten. See [`33-source.md`](33-source.md) for the +is refused rather than overwritten. See [`07-library.md`](07-library.md) for the corpus side of that contract. Leading and trailing ASCII spaces and tabs are stripped, and so are a trailing diff --git a/.abcd/development/brief/04-surfaces/33-source.md b/.abcd/development/brief/04-surfaces/33-source.md deleted file mode 100644 index 77ce2e8d8..000000000 --- a/.abcd/development/brief/04-surfaces/33-source.md +++ /dev/null @@ -1,298 +0,0 @@ -# `/abcd:source` — The Sources Corpus and Its Provenance Ledger - -Read anything that bears on a decision, including material you are not free to -name in public, and never let a helpful footnote name it. `/abcd:source` keeps a -personal corpus of documents in a local-only git repository, records which source -shaped which decision in an append-only ledger per repository, bans every -confidential source's names at commit time, and turns a ledger line into a public -citation only when a person decides to and the source permits it. - -The cost is two habits: declaring a source's class once, when it is added, and -recording an influence when a source genuinely shapes a decision. The payoff is -that nothing confidential can reach a commit by accident, and the day a paper is -published the whole influence trail behind it is already written. - -It is the binary half of the sources system. The host-delegated pages -[`/abcd:consult`](13-consult.md) and [`/abcd:ingest`](14-ingest.md) carry the -judgement (what to search for, which class a document is, which keywords matter) -and call these verbs for every write. - -## 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 | -|---|---|---| -| `add` | — | shipped | -| `cite-check` | gate | shipped | -| `declassify` | — | shipped | -| `init` | — | shipped | -| `ledger` | — | shipped | -| `sync-banlist` | — | shipped | - -Bare `abcd source` is read-only: where the corpus is, how many sources sit in -each class, the ledgers and their line counts, and any entry whose folder and -metadata disagree. The corpus is the default one under the user-level home, -`~/.abcd.noindex/sources`, unless the invocation names another directory. - -## The store - -The corpus is a directory the person owns, outside every repository, and itself a -git repository with no remote. Its history is the tamper-evidence layer: every -write a verb makes is committed there. - -| path | holds | -|---|---| -| `sources.json` | a CSL-JSON array; each entry's `custom` block carries `confidential`, `permission_status`, `keywords`, `aliases`, `ban_authors` and `file` | -| `confidential//` | the original document, its extracted text as `text.md`, and anything derived from them | -| `public//` | the same shape for a freely citable source | -| `ledger/.jsonl` | one repository's influence records, one JSON object per line | - -**The folder is the classification.** A source's class is declared once, when it -is added: the add requires the class, confidential or public, and never defaults, -because a forgotten flag must not file a confidential document as public. Derived -artefacts (summaries, notes) live in the source's folder and inherit its class. -The entry's `confidential` flag mirrors the folder, and a corpus where the two -disagree (a folder moved by hand, an entry edited by hand) is refused wholesale by -the banlist sync and the scan, naming each key to repair. That is the safe -direction: the block already written keeps banning. - -A repository's ledger is named by the first twelve hex digits of its root commit, -which every clone and worktree of one repository shares whatever its directory is -called, unless the invocation names it. - -Creating the corpus is its own explicit step, and it refuses a location inside -another repository's working tree, where one `git add -A` would carry documents into it. Nothing -fetches and nothing converts: a Markdown or plain-text document is its own text, -any other needs its extracted text passed alongside, and a URL alone registers a -metadata stub. - -## Keys are the only handle output carries - -Every refusal, every guard message and every scan names a source by its key and -nothing else. So a confidential source's key must not name it: the add refuses a -key containing one of its aliases, its title or a longer title word, or an -author's name, and asks for an opaque key such as `conf2026a`. The identifying -strings themselves can travel in a metadata file or on stdin, so they stay out of -the process list and the shell history. - -## Recording influence, and citing - -Recording an influence appends `{ts, repo, decision_ref, claim, source_key, -locator, influence, cited_publicly}` with `cited_publicly` false, plus `used_in`, -the repository paths the influence landed in, when the caller names them, and commits it. There is no edit -path: a correction is a new line naming, in `corrects`, the line it corrects. - -A public citation needs both gates of -[adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md). The source grants -the right: its folder is under `public/` and its `permission_status` is -`citable`, the one value in the closed vocabulary that grants it. The person -exercises the right: the flip of line N checks the first gate, refuses naming it -when it fails, and on success appends a new line, a copy of line N with -`cited_publicly` true and `flips` naming N. An agent never runs the flip. The -binary cannot tell a person from an agent, so that rule lives in the command -pages. - -Declassification is how a confidential source becomes citable when it is -published: its folder moves `confidential/ → public/` by `git mv`, its entry's -flag and permission follow, and both land in one corpus commit. The next banlist -sync drops its strings, and its ledger lines become flippable. - -## The guard - -The banlist sync projects every confidential source into a fenced block of this -repository's untracked private banlist, `.abcd/.work.local/private-names.txt` (the -private layer of [`/abcd:banlist`](20-banlist.md)): the title and each alias -always, the authors only where the entry sets `ban_authors`. Lines outside the -fence, hand-written or verb-added, survive every sync. The by-hand sync creates -the store when there is something to ban; the refresh mode updates a store that -already exists and declares the keyed format, and never creates one, because a -store created by a commit's side effect would write every confidential title into -a repository's local tier without the person asking. The committed pre-commit -guard runs the sync in its refresh mode before it reads the store, so the block -is never more than one commit stale, and then refuses a staged commit carrying a -banned string by key. The refresh's own line is relayed on the commit, never -discarded: its count is how a refresh that wrote fewer patterns than the last -one shows itself. - -The two copies of the guard find the binary differently. The copy this -repository runs builds `./cmd/abcd` from the checkout, as its commit-msg hook -does, and never runs an installed abcd, which in a source checkout is the last -release and may lack the verb entirely; a tree that does not build is one -warning line. The copy `abcd ahoy` scaffolds refreshes only on opt-in, per -clone: the repo-local git setting `abcd.sourcesBinary` names an absolute path -to the binary, and nothing else is consulted, neither `PATH` nor an -environment variable. How a scaffolded hook finds abcd, and whether it does so -by default, is a ruling owed to the product thinker -(iss-2609250834251447); opt-in decides none of it, and the ruling can widen -it. Without the setting, AC3 holds in this repository and in a managed -repository that opted in, and the scaffolded guard names the setting on one -line. - -A legacy private store is not refreshed. The guard names the banlist verb's -migration ([`20-banlist.md`](20-banlist.md)) on the first commit that meets it -and not again, since the store's format line already says "legacy" on every -commit. - -Each phrase is projected into the pattern language the guard enforces: literal, -case-insensitive, and whitespace-flexible across ASCII and Unicode space -separators, with a non-ASCII letter written as its case-fold alternatives because -the guard's C-locale engine folds ASCII only. The boundary is a test on the -neighbouring bytes, never `\b`: POSIX extended expressions do not define it, and -RE2's ASCII-only reading finds no word start before a non-ASCII initial. The neighbour test -errs towards matching: a phrase beside a non-ASCII letter still counts as found. -A phrase with fewer than three letters or digits is refused, because it would -ban ordinary words. - -The scan reads a file or stdin through the same projection and the same engine -as the guard, so a text it calls clean is a text the guard would pass. It -reports each finding by key, field (`title`, `alias-N`, `author-N`), line and -byte offset, and never by the text matched, so its report is safe to relay. The -offset is the guard engine's own: it counts from the start of the whole text, -not from the start of the line, to the start of the matched span, which can be -the one byte before the phrase that bounds it. It exits 1 when anything is found. - -## Absence is loud - -With no corpus at the configured location every verb but the creating one says -so on one line and exits 3, a code distinct from a refusal, so a script can tell -"nothing to check against" from "checked and clean". The guard says so on one line -and lets the commit proceed; the sync's refresh mode, which the guard runs, does -the same and exits 0, as it does for a repository with no private store. A -corpus the guard cannot refresh (no binary built or opted in, a binary without -the verb, or a refresh that fails) is one line on the commit, and the store is -checked as it stands. - -## What it cannot enforce - -The mechanical layer blocks literal identifying strings on one line of a staged -file. It cannot see a paraphrase that identifies a source without naming it, a -name split across a line break, or a spelling the entry does not carry (an -unrecorded alias, a decomposed Unicode form). Those are the consultation rule's -job and the human review before anything is published. Durability is bounded -too: a corpus with no remote survives the loss of its disk only through the -person's own backups and offline `git bundle` snapshots, which abcd documents and -cannot perform. - -## Acceptance - -- **Given** a document and its metadata, **when** it is added as confidential, - **then** the bibliography gains its CSL-JSON entry with the `custom` block, and - the document and its extracted text land under `confidential//`. -- **Given** a consulted source that shaped a decision, **when** the influence is - recorded, **then** one line is appended with `cited_publicly` false, and a - correction is another new line. -- **Given** confidential entries in the corpus, **when** a commit runs in a - managed repository, **then** the guard refreshes the generated block (titles - and aliases always, authors only under `ban_authors`) and refuses a commit - carrying a banned string. -- **Given** text about to leave the machine, **when** the scan reads it, - **then** offending sources are reported by key only. -- **Given** a ledger line whose source lacks citation permission, **when** a flip - is attempted, **then** it is refused naming the failing gate; with permission - present the flip succeeds as a new line. -- **Given** a machine with no corpus, **when** abcd runs in a managed repository, - **then** every corpus-dependent step says so on one line, and none fails. -- **Given** a confidential source that is published, **when** it is declassified, - **then** the next refresh drops its strings and its ledger lines become - eligible for the flip. - -## References - -- Plugin command: [`commands/source.md`](../../../../commands/source.md) -- The host-delegated halves: [`13-consult.md`](13-consult.md), - [`14-ingest.md`](14-ingest.md) -- The private banlist layer the guard shares: [`20-banlist.md`](20-banlist.md) -- The trust boundary: [adr-41](../../decisions/adrs/0041-corpus-trust-boundary.md), - brief invariant 9 - ([`../02-constraints/03-invariants.md`](../02-constraints/03-invariants.md)) -- Consuming intent: [itd-76](../../intents/shipped/itd-76-source-provenance-ledger.md) - - - -## Appendix: the shipped surface - -_Generated from the command tree; a drift test fails `go test` when this appendix and the tree disagree. It lists flags and sub-verbs only. What each flag means is in the [CLI reference](../../../../docs/reference/cli/commands.md), and exit codes, output fields and behaviour are the prose's to state._ - -### `abcd source` - -Sub-verbs: `abcd source add`, `abcd source cite-check`, `abcd source declassify`, `abcd source init`, `abcd source ledger`, `abcd source sync-banlist`. - -| Flag | Type | -|---|---| -| `--corpus` | string | - -### `abcd source add` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--alias` | stringArray | -| `--author` | stringArray | -| `--ban-authors` | bool | -| `--confidential` | bool | -| `--key` | string | -| `--keywords` | stringArray | -| `--meta` | string | -| `--permission` | string | -| `--public` | bool | -| `--text` | string | -| `--title` | string | -| `--type` | string | -| `--url` | string | -| `--venue` | string | -| `--year` | int | - -### `abcd source cite-check` - -Sub-verbs: none. - -Flags: none. - -### `abcd source declassify` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--permission` | string | - -### `abcd source init` - -Sub-verbs: none. - -Flags: none. - -### `abcd source ledger` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--claim` | string | -| `--corrects` | int | -| `--decision` | string | -| `--flip` | int | -| `--influence` | string | -| `--list` | bool | -| `--locator` | string | -| `--repo` | string | -| `--source` | string | -| `--used-in` | stringArray | - -### `abcd source sync-banlist` - -Sub-verbs: none. - -| Flag | Type | -|---|---| -| `--refresh` | bool | - - diff --git a/.abcd/development/brief/04-surfaces/README.md b/.abcd/development/brief/04-surfaces/README.md index adaa7ee30..fd54cdd23 100644 --- a/.abcd/development/brief/04-surfaces/README.md +++ b/.abcd/development/brief/04-surfaces/README.md @@ -19,7 +19,7 @@ are wiring rather than user-facing surface are listed separately under | 4 | `/abcd:launch` | shipped | Preview what a release would ship, and cut one by deriving its version and changelog from the record | [`04-launch.md`](04-launch.md) | | 5 | `/abcd:intent` | shipped | Say what you want to build as a press release, and find out whether it is ready to implement | [`05-intent.md`](05-intent.md) | | 6 | `/abcd:capture` | shipped | Get an observation out of your head and into a ledger in one line, and act on it later | [`06-capture.md`](06-capture.md) | -| 7 | `/abcd:memory` | shipped | Curate what the project knows from outside sources, and query it | [`07-memory.md`](07-memory.md) | +| 7 | `/abcd:library` | shipped | Keep the documents you consult and the cited pages distilled from what the project reads, record what each source changed, and ban the confidential ones' names at commit time | [`07-library.md`](07-library.md) | | 8 | `/abcd` | shipped | Find out where you are, or what one record id is and what to do with it | [`08-abcd.md`](08-abcd.md) | | 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) | @@ -43,7 +43,6 @@ are wiring rather than user-facing surface are listed separately under | 30 | `/abcd:inbox` | shipped | Read the reports managed repositories filed, and promote one to a capture that names the sender only by its root-commit key | [`30-inbox.md`](30-inbox.md) | | 31 | `/abcd:lab` | shipped | Run a lab against a pinned snapshot of the repository and harvest what it found, with the evidence kept out of the repository | [`31-lab.md`](31-lab.md) | | 32 | `/abcd:scribe` | shipped | Build the ledger scribe's context from the ledger alone, and ingest what it transcribed without letting it author anything | [`32-scribe.md`](32-scribe.md) | -| 33 | `/abcd:source` | shipped | Keep the documents you consult in a local corpus, record what each one changed, and ban the confidential ones' names at commit time | [`33-source.md`](33-source.md) | | 34 | `/abcd:build` | shipped | Start the loop that takes one READY intent to delivered, refusing while a question is open or a peer holds it | [`34-build.md`](34-build.md) | | 35 | `/abcd:drain` | shipped | Work the open ledger unattended: preview with `--dry-run` which open issues a machine may fix alone and in what order, then run it one lane at a time, routing every hand-back by its kind | [`35-drain.md`](35-drain.md) | | 36 | `/abcd:dashboard` | shipped | Open the project from a phone or another computer on your own Tailscale network, through a server that answers only the devices Tailscale names | [`36-dashboard.md`](36-dashboard.md) | @@ -136,7 +135,7 @@ the command's sentence (the section below). | Block | Group | Verbs | |---|---|---| | people | set-up | `ahoy`, `dashboard`, `update`, and the framework's `help` and `completion` | -| people | records | `build`, `capture`, `decide`, `ideate`, `intent`, `memory`, `source` | +| people | records | `build`, `capture`, `decide`, `ideate`, `intent`, `library` | | people | checks | `lab`, `reading` | | people | portability | `disembark`, `embark` | | people | release | `launch` | @@ -215,10 +214,7 @@ starts a run for; the run's state renders through `abcd implement status`. Bare `abcd reflect` prints its usage, because its one operand is the cut release whose retrospective seed it renders. Bare `abcd drain` is the run itself, one move per invocation, so it is not a render; -what a drain would do renders through its dry run. Bare -`abcd source` renders the corpus under the user-level home rather than anything -in the repository, so where there is no corpus it refuses naming `abcd source -init`. Bare `abcd identity` and bare `abcd ahoy remote` list their sub-verbs, +what a drain would do renders through its dry run. Bare `abcd identity` and bare `abcd ahoy remote` list their sub-verbs, because their reports are `abcd lint identity` and `abcd ahoy --remote`. Bare `abcd report` opens the editor on a terminal and refuses anywhere else, because it files a report rather than rendering one, and bare `abcd statusline` renders abcd's row only in a managed @@ -325,8 +321,8 @@ 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`, -`reading`, `reflect`, `report`, `scribe`, `site`, `source`, `update`. +`ingest`, `intent`, `lab`, `launch`, `library`, `lint`, `mode`, `peers`, +`reading`, `reflect`, `report`, `scribe`, `site`, `update`. `abcd.md` is the bare `/abcd` status board; every other file is `/abcd:`. @@ -414,7 +410,7 @@ abcd keeps two kinds of remembered material, and they do different jobs. The **l | How it comes back | The person asks ("what do my sources say about pacing?") and gets answers that cite the item they came from | Brought into a session automatically when a prompt matches the words the note was saved with | | Where it can go | Into a decision as a cited source; a confidential item is never named in anything public | Into the project's record, when a note is promoted so that every agent receives it | -A library answer can become a memory note ("source X says Y", with its citation), but the two are not one store with two names: the library is for what a decision may need to cite, and memory is for what an agent would otherwise relearn. The glossary entries [library](../glossary/core/library.md) and [memory note](../glossary/core/memory-note.md) define each term. The library merges what the sources, memory, consult and ingest pages hold today (itd-2610090831227812); memory notes are designed in itd-2610091918433290, and its open questions name where notes are stored and what an agent may write without the person's yes. +A library answer can become a memory note ("source X says Y", with its citation), but the two are not one store with two names: the library is for what a decision may need to cite, and memory is for what an agent would otherwise relearn. The glossary entries [library](../glossary/core/library.md) and [memory note](../glossary/core/memory-note.md) define each term. The `library` command holds what the `source` and `memory` commands held, and the consult and ingest pages fold into it next (itd-2610090831227812); memory notes are designed in itd-2610091918433290, and its open questions name where notes are stored and what an agent may write without the person's yes. ## Where to find related design diff --git a/.abcd/development/brief/05-internals/03-configuration.md b/.abcd/development/brief/05-internals/03-configuration.md index 702c04c05..a9f03aa1e 100644 --- a/.abcd/development/brief/05-internals/03-configuration.md +++ b/.abcd/development/brief/05-internals/03-configuration.md @@ -914,7 +914,7 @@ Two artefacts are absent from this table by construction rather than by exceptio user-scope and the lifeboat is out-of-tree output, so no gitignore rule applies to either under any visibility. -¹ New projects use `.abcd/memory/`, the substrate `abcd memory` curates. The +¹ New projects use `.abcd/memory/`, the substrate `abcd library` curates through its memory verbs. The root-level `memory/` is the legacy snapshot pattern some existing projects maintain by hand; abcd respects it if present and never writes to it. @@ -942,7 +942,7 @@ clone carries, because it is out-of-tree output. **Memory locations to keep straight.** Curated memory exists at both scopes, and there is one non-abcd location alongside them: the repo-scope `.abcd/memory/` is -the **primary** store, holding the curated summaries `abcd memory ingest` writes +the **primary** store, holding the curated summaries `abcd library ingest` writes and the canonical input for principle distillation; the user-scope `~/.abcd.noindex/memory/` is a **later phase**, which will hold personal preferences and cross-project principles with no single repo home (nothing in the binary resolves it today); diff --git a/.abcd/development/brief/05-internals/07-memory.md b/.abcd/development/brief/05-internals/07-memory.md index 551d9d56e..182ac2e18 100644 --- a/.abcd/development/brief/05-internals/07-memory.md +++ b/.abcd/development/brief/05-internals/07-memory.md @@ -48,9 +48,9 @@ Every memory page declares its source class via typed `source:` frontmatter. The | `source.class` | Upstream | Mutability | |---|---|---| | `session_memory` | Vendor session memory (Claude / opencode / future), distilled by the `internal/core/memory` adapter via `dev-sync memory` | Re-distilled on each sync; lossy upstream | -| `external_pdf` | User-ingested PDF via `/abcd:memory ingest ` | Immutable post-ingest (original NOT stored unless `--keep-original`) | -| `external_transcript` | User-ingested transcript via `/abcd:memory ingest ` | Immutable post-ingest | -| `external_article` | User-ingested article (URL or local) via `/abcd:memory ingest ` | Immutable post-ingest | +| `external_pdf` | User-ingested PDF via `/abcd:library ingest ` | Immutable post-ingest (original NOT stored unless `--keep-original`) | +| `external_transcript` | User-ingested transcript via `/abcd:library ingest ` | Immutable post-ingest | +| `external_article` | User-ingested article (URL or local) via `/abcd:library ingest ` | Immutable post-ingest | | `oracle_review` | Synced from `.abcd/work/reviews/` (RepoPrompt / codex review artefacts) | Immutable post-sync | | `work_notes` | Curated from working notes in `.abcd/work/` (shared, committed) | User-mutable upstream | | `issue_ledger` | Synthesised entries from the `.abcd/work/issues/` ledger (per itd-4 capture) | Immutable post-create | @@ -102,18 +102,18 @@ recall: [, ...] `source.weighting_note` nests under `source:`, is required exactly when a page mixes ≥ 2 source classes, and never appears on a single-source page. -`recall` is the retrieval key (per itd-39): the prompt-router hook keyword-matches incoming prompts against it. `principle-distiller` populates `recall` at curation time from the page's domain and salient terms; absent or empty `recall` means the page is reachable only via `/abcd:memory ask`, never auto-injected. +`recall` is the retrieval key (per itd-39): the prompt-router hook keyword-matches incoming prompts against it. `principle-distiller` populates `recall` at curation time from the page's domain and salient terms; absent or empty `recall` means the page is reachable only via `/abcd:library ask`, never auto-injected. **Curator rules** (enforced by `principle-distiller` + lint): -- Pages with single-source-class synthesis are flagged as **low cross-validation** (advisory, lint code `MS001`; not blocking). Surfaces in `/abcd:memory lint` (spc-39). +- Pages with single-source-class synthesis are flagged as **low cross-validation** (advisory, lint code `MS001`; not blocking). Surfaces in `/abcd:library lint` (spc-39). - Pages mixing source classes (e.g., `session_memory` + `external_pdf`) require a `weighting_note` field acknowledging the asymmetric trust gradient between class types. Lint blocks (lint code `MS002`) until the note is added. - Pages with `class: external_*` without a `licence` field block at lint (lint code `ML001`). `unknown` is acceptable — must be explicit. - Stored text carrying a secret or identity span the write-time store redactor would refuse or rewrite blocks at lint (lint code `MR001`) — pages, `.sources_index.json` and text kept-originals alike. Page names — each page file's and each registry back-link's — are judged by the write side's own filename verdict (`filenameHardFailKinds`: the name, its type/domain/slug components and every underscore suffix, at the hard-fail bar alone), because `_` is a word character and a token embedded in a name has no boundary the free-text scan can anchor on. A back-link that is a well-formed page name is kept out of the registry's free-text scan, as the write side keeps it out of the leaf walk, so a prose-shaped name the hostname heuristic happens to match is not reported as network residue. Lint names the kind and the line, never the span, and never rewrites the store; the operator repairs. ## 4. Default-no-originals + `--keep-original` -`/abcd:memory ingest ` reads the source, distils into typed entity/topic pages with citation frontmatter, and **discards the original by default**. Source path + hash recorded for re-ingest only. +`/abcd:library ingest ` reads the source, distils into typed entity/topic pages with citation frontmatter, and **discards the original by default**. Source path + hash recorded for re-ingest only. `--keep-original` flag opts the user into storing the original at `.abcd/memory/sources/.`. The spc-38 restrictive-licence gate refuses to publish anything under `.abcd/memory/sources/` unless an explicit allowlist entry is written. Per adr-28 this gate is the **lifeboat's** (`/abcd:disembark`), NOT the launch payload's: `04-launch.md § 2` excludes the entire `.abcd/` namespace from the public launch payload wholesale, so launch never publishes `.abcd/memory/sources/` and is not the gate's consumer. At launch the gate is future/inert; its real consumer is the lifeboat, the surface that publishes curated project memory/provenance (adr-35). @@ -151,22 +151,22 @@ total_source_coverage_pct(source_hash) = |---|---|---| | Existing memory pages | `.abcd/memory/__.md` | Read for cross-source dedup (topic-hash) | | Vendor session memory | `internal/core/memory` adapter (Claude / opencode) | Distill into `session_memory` pages (existing behaviour, unchanged) | -| External sources (new at itd-36) | `/abcd:memory ingest` invocations | Distill into typed `external_*` pages with citation frontmatter | +| External sources (new at itd-36) | `/abcd:library ingest` invocations | Distill into typed `external_*` pages with citation frontmatter | | `spec_modification_grammar` (new at itd-37) | A spec's `## Modification Grammar` section at spec completion | Append per-spec page; run domain-curator pass to update `modification_grammar_.md` | | `dredge_synthesis` (itd-25, a draft intent) | `/abcd:dredge synth` runs | Write synthesised entries to memory with `source.class: dredge_synthesis` | **The agent count stays at 15** (per itd-31 precedent: agent count grows by user-facing responsibility, not by audit subtype). `principle-distiller` gains role responsibilities, not agent peers. Soft-cap risk acknowledged: if the third role ever burst coherence, fork the curator role into a sibling agent. -## 7. `/abcd:memory` command +## 7. The memory verbs of `/abcd:library` User-facing surface (per itd-36): | Verb | Behaviour | |---|---| -| Bare `/abcd:memory` | Status + help + render of current memory state (page count by class, last-ingest timestamp, recent contradictions). Per the [bare-command-as-render discipline](../02-constraints/04-naming.md). Quotation-budget headroom renders per external source READ-ONLY from the spc-39 `.coverage_index.json`: a fingerprint-fresh index shows per-source warn/block headroom, fingerprint drift shows a "stale — run /abcd:memory lint" hint, an absent index an info line, and a malformed index/crawl failure a non-fatal "headroom unavailable" line. The bare render never rebuilds or mutates the index (the `lint` sub-verb is the sole writer). | -| `/abcd:memory ingest ` | Read external source, distil into typed pages with citation, append to log. A remote source must be https (admission and every redirect hop), the posture of `abcd update`; a credential the URL carries — basic-auth userinfo or a credential-shaped query key — is stripped before the fetched address becomes the stored origin and title, and masked in every fetch-failure message (the transport's own `*url.Error` is unwrapped to its cause, so it cannot re-print the address behind the mask). Default: do NOT store original. Flag: `--keep-original` (opt-in storage; the lifeboat licence-gate allowlist is required before a kept original could ever publish — via `/abcd:disembark`, not launch; adr-28). | -| `/abcd:memory ask ` | Query memory by domain + class; synthesise answer with citations; optionally file result back. | -| `/abcd:memory lint` **(spc-39)** | Full-store curator health-check: ALWAYS crawls the whole repo store (the cumulative `MQ002` needs the full corpus), runs the `MQ`/`MS`/`ML`/`MR` family (`MQ001`/`MQ002`/`MQ003` quotation budgets + coverage, `MS001`/`MS002` source-class advisories, `ML001` missing licences, `MR001` secret or identity residue in stored text), rebuilds the regenerable `.coverage_index.json`, and writes its run report to `.abcd/.work.local/logs/memory/lint-/report.{json,md}` in the gitignored local-ephemeral tier, per the iss-36 and iss-56 adjudication resolved as iss-73. Mutates NO memory-store state — the coverage index + that report are its only writes. Exit: blockers → nonzero; warn-only → exit 0 (curator advisory; the recorded divergence from the standalone grammar, see [`06-lint.md §2`](06-lint.md#2-severity-model)). **Not part of spc-39:** contradiction surfacing is RENDERED by spc-38's reconciliation into `contradictions.md` (surfaced by the bare render, not lint-coded), and orphan/stale-claim audits are DEFERRED to a later intent — `lint` runs neither. | +| Bare `/abcd:library` (its memory half) | Render of current memory state, beside the sources corpus (page count by class, last-ingest timestamp, recent contradictions). Per the [bare-command-as-render discipline](../02-constraints/04-naming.md). Quotation-budget headroom renders per external source READ-ONLY from the spc-39 `.coverage_index.json`: a fingerprint-fresh index shows per-source warn/block headroom, fingerprint drift shows a "stale — run `abcd library lint`" hint, an absent index an info line, and a malformed index/crawl failure a non-fatal "headroom unavailable" line. The bare render never rebuilds or mutates the index (the `lint` sub-verb is the sole writer). | +| `/abcd:library ingest ` | Read external source, distil into typed pages with citation, append to log. A remote source must be https (admission and every redirect hop), the posture of `abcd update`; a credential the URL carries — basic-auth userinfo or a credential-shaped query key — is stripped before the fetched address becomes the stored origin and title, and masked in every fetch-failure message (the transport's own `*url.Error` is unwrapped to its cause, so it cannot re-print the address behind the mask). Default: do NOT store original. Flag: `--keep-original` (opt-in storage; the lifeboat licence-gate allowlist is required before a kept original could ever publish — via `/abcd:disembark`, not launch; adr-28). | +| `/abcd:library ask ` | Query memory by domain + class; synthesise answer with citations; optionally file result back. | +| `/abcd:library lint` **(spc-39)** | Full-store curator health-check: ALWAYS crawls the whole repo store (the cumulative `MQ002` needs the full corpus), runs the `MQ`/`MS`/`ML`/`MR` family (`MQ001`/`MQ002`/`MQ003` quotation budgets + coverage, `MS001`/`MS002` source-class advisories, `ML001` missing licences, `MR001` secret or identity residue in stored text), rebuilds the regenerable `.coverage_index.json`, and writes its run report to `.abcd/.work.local/logs/memory/lint-/report.{json,md}` in the gitignored local-ephemeral tier, per the iss-36 and iss-56 adjudication resolved as iss-73. Mutates NO memory-store state — the coverage index + that report are its only writes. Exit: blockers → nonzero; warn-only → exit 0 (curator advisory; the recorded divergence from the standalone grammar, see [`06-lint.md §2`](06-lint.md#2-severity-model)). **Not part of spc-39:** contradiction surfacing is RENDERED by spc-38's reconciliation into `contradictions.md` (surfaced by the bare render, not lint-coded), and orphan/stale-claim audits are DEFERRED to a later intent — `lint` runs neither. | ## 8. Cross-cutting integration @@ -194,7 +194,7 @@ This is what makes "memory never overflows" an enforced property rather than a h **Per-prompt, not sticky.** Consistent with itd-3, recall is independent per prompt — no cross-prompt "pin this page" mode in scope. A session-sticky mode is a later candidate (see itd-39 Open Questions). -**Diagnostic (a design target; not built).** `abcd memory recall [keyword]` would show which pages a prompt or keyword surfaces and in which bracket — explainability, per the bare-command-as-render discipline. The shipped surface carries `ingest`, `ask` and `lint` and nothing else. +**Diagnostic (a design target; not built).** A `recall [keyword]` sub-verb of `abcd library` would show which pages a prompt or keyword surfaces and in which bracket — explainability, per the bare-command-as-render discipline. The shipped surface carries `ingest`, `ask` and `lint` and nothing else. See [`../../intents/drafts/itd-39-scope-aware-memory-retrieval.md`](../../intents/drafts/itd-39-scope-aware-memory-retrieval.md) for the full intent, acceptance criteria, and the architectural-audit reconciliation with itd-3's global-rules rejection. diff --git a/.abcd/development/brief/05-internals/09-provenance-substrate.md b/.abcd/development/brief/05-internals/09-provenance-substrate.md index f540f6cde..a69fab195 100644 --- a/.abcd/development/brief/05-internals/09-provenance-substrate.md +++ b/.abcd/development/brief/05-internals/09-provenance-substrate.md @@ -1,6 +1,6 @@ # Provenance / Licence Substrate -A separable plumbing layer used by both `/abcd:memory ingest` (per itd-36) and `/abcd:loot` (a draft intent, itd-26). Owns: licence detection, citation generation, source-hash registry, the restrictive-licence publish gate (per adr-28 the gate's consumer is the lifeboat `/abcd:disembark`, future/inert at launch — see § 4). +A separable plumbing layer used by both `/abcd:library ingest` (per itd-36) and `/abcd:loot` (a draft intent, itd-26). Owns: licence detection, citation generation, source-hash registry, the restrictive-licence publish gate (per adr-28 the gate's consumer is the lifeboat `/abcd:disembark`, future/inert at launch — see § 4). ## Why it exists as a separate component doc @@ -8,7 +8,7 @@ Without explicit separation, the failure mode is: memory ingest ships with a "te Two consumers, distinct verbs, one substrate (per the surface-vs-substrate principle established in idea-1 R4): -- **`/abcd:memory ingest`** (itd-36) — knowledge upstreams. +- **`/abcd:library ingest`** (itd-36) — knowledge upstreams. - **`/abcd:loot`** (a draft intent, itd-26) — code vendoring. User moments differ; underlying licence-tracking machinery is the same. @@ -27,7 +27,7 @@ Steps 2–3 run only when the detector is given a `source_root` directory (`dete A single identifier is matched case-insensitively and stored in canonical SPDX casing via a small local canonical-ID map; an ID outside the map is stored verbatim and flagged `unrecognised`. Compound expressions (`MIT OR Apache-2.0`, `... WITH `) are stored verbatim, never split; `classify_licence_expression` is the single classifier (`permissive | restrictive | unknown | unrecognised` — any restrictive token anywhere makes the expression restrictive) that the publish-gate policy layers on top of. -**`unknown` is acceptable but must be explicit.** The substrate never silently labels an unknown licence as a permissive default. Lint code `ML001` (spc-39 — the substrate writes `licence: unknown`; spc-39 lints it) blocks on missing licence field; `unknown` value passes the lint but surfaces in `/abcd:memory lint` and `/abcd:loot lint` as a flag for human review. +**`unknown` is acceptable but must be explicit.** The substrate never silently labels an unknown licence as a permissive default. Lint code `ML001` (spc-39 — the substrate writes `licence: unknown`; spc-39 lints it) blocks on missing licence field; `unknown` value passes the lint but surfaces in `/abcd:library lint` and `/abcd:loot lint` as a flag for human review. ## 2. Citation generation @@ -45,7 +45,7 @@ citation: ingested_by: "" ``` -Citation is read-only after creation; updates require a new ingest pass: `/abcd:memory ingest ` run again on the source, which takes no reingest flag (`/abcd:loot`, a draft intent, has no verb). A reingest of changed content produces a new hash → a new registry entry; a reingest of identical content is a registry hit (counters bump, see § 3) — neither overwrites the prior citation. +Citation is read-only after creation; updates require a new ingest pass: `/abcd:library ingest ` run again on the source, which takes no reingest flag (`/abcd:loot`, a draft intent, has no verb). A reingest of changed content produces a new hash → a new registry entry; a reingest of identical content is a registry hit (counters bump, see § 3) — neither overwrites the prior citation. ## 3. Source-hash registry @@ -76,7 +76,7 @@ Citation is read-only after creation; updates require a new ingest pass: `/abcd: **Why per-content-hash, not per-URL.** Two URLs may serve the same content; same URL may serve different content over time. Hash is the stable identity. Re-ingest of the same content produces same hash → updates `last_ingest` + bumps `ingest_count`; does NOT add a duplicate entry and never drops the durable `source_token_count`/`token_count_version` fields. -**Cross-consumer sharing:** memory ingest and loot share one `sources_index.json`. Shared fields (`origin`, `licence`, `source_token_count`, `token_count_version`, counters) live at the top level of each hash entry; a `consumers` map holds one key per producing verb (`memory`, `loot`), each carrying its OWN `class`/`citation`/`ingested_at`/`pages`. A source that is loot-vendored (code) AND memory-ingested (documentation/notes about that code) yields ONE entry with BOTH keys present — memory never inherits loot's citation, and vice versa. The per-consumer `pages` list lets a re-ingest registry hit name its memory pages without a crawl. Every leaf a memory write introduces into this file (an origin, a licence, a consumer's citation) passes through the store redactor before it is written, and so does every KEY it introduces — a key carrying a secret span is refused rather than rewritten, since renaming a key renames the field the reader looks up. The one exclusion is each consumer's `pages` list: those are page filenames the store RESOLVES (an unnamed page is pruned), their charset is already bounded by the page-name grammar, and rewriting one would unlink a file that keeps its real name. `/abcd:memory lint` scans the file as stored (`MR001`). +**Cross-consumer sharing:** memory ingest and loot share one `sources_index.json`. Shared fields (`origin`, `licence`, `source_token_count`, `token_count_version`, counters) live at the top level of each hash entry; a `consumers` map holds one key per producing verb (`memory`, `loot`), each carrying its OWN `class`/`citation`/`ingested_at`/`pages`. A source that is loot-vendored (code) AND memory-ingested (documentation/notes about that code) yields ONE entry with BOTH keys present — memory never inherits loot's citation, and vice versa. The per-consumer `pages` list lets a re-ingest registry hit name its memory pages without a crawl. Every leaf a memory write introduces into this file (an origin, a licence, a consumer's citation) passes through the store redactor before it is written, and so does every KEY it introduces — a key carrying a secret span is refused rather than rewritten, since renaming a key renames the field the reader looks up. The one exclusion is each consumer's `pages` list: those are page filenames the store RESOLVES (an unnamed page is pruned), their charset is already bounded by the page-name grammar, and rewriting one would unlink a file that keeps its real name. `/abcd:library lint` scans the file as stored (`MR001`). ## 4. Restrictive-licence publish gate (lifeboat consumer; future/inert at launch) @@ -96,16 +96,16 @@ The gate's substrate integration (consumed by the lifeboat, not launch): Lint codes referenced below (`ML*`, `MQ*`) ship with spc-39 (the write/lint split, ADR-13) — spc-38's substrate writes `licence: unknown` explicitly; spc-39 lints it. -- **Given** an external source with a declared SPDX licence visible to the detector (an in-file `SPDX-License-Identifier:` header; or a `LICENSE` file / package manifest when the ingest provides a `source_root` — see § 1), **when** the user runs `/abcd:memory ingest `, **then** the resulting memory page(s) carry `source.licence: ` AND a registry entry exists at `.abcd/memory/.sources_index.json[]` whose `consumers` map contains the `memory` key. +- **Given** an external source with a declared SPDX licence visible to the detector (an in-file `SPDX-License-Identifier:` header; or a `LICENSE` file / package manifest when the ingest provides a `source_root` — see § 1), **when** the user runs `/abcd:library ingest `, **then** the resulting memory page(s) carry `source.licence: ` AND a registry entry exists at `.abcd/memory/.sources_index.json[]` whose `consumers` map contains the `memory` key. - **Given** an external source with no declared licence, **when** ingest runs, **then** the page carries `source.licence: unknown` (explicit) AND lint code `ML001` (spc-39) does NOT fire (unknown is acceptable; missing-field would be the violation). -- **Given** the same source content ingested by both `/abcd:memory ingest` (as documentation) and `/abcd:loot` (as code), **when** both have run, **then** the registry has ONE entry with `ingest_count: 2` whose `consumers` map holds BOTH the `memory` and `loot` keys, each with its own `class`/`citation`/`ingested_at`/`pages`. +- **Given** the same source content ingested by both `/abcd:library ingest` (as documentation) and `/abcd:loot` (as code), **when** both have run, **then** the registry has ONE entry with `ingest_count: 2` whose `consumers` map holds BOTH the `memory` and `loot` keys, each with its own `class`/`citation`/`ingested_at`/`pages`. - **Given** a gated payload (the lifeboat consumer's, per adr-28 — NOT the launch publish payload) includes a file with `citation.licence: GPL-3.0` AND the project is being published as MIT, **when** the gate runs, **then** it refuses with a licence-mismatch error AND lists the offending file + its citation. Override via `--accept-licence-risk` logs the override. (At launch the gate is future/inert; the launch dry-run renders no verdict of it.) - **Given** itd-36 ships with the substrate but itd-26 hasn't shipped, **when** the substrate code is exercised, **then** all citation/licence/registry behaviour works for memory ingest AND the substrate is verified to handle the loot-future case via mock invocations in test fixtures (substrate is separable; memory ingest doesn't depend on loot existing). ## 6. Composition with adjacent surfaces - **`internal/core/memory` adapter** ([`02-adapters.md`](02-adapters.md)) reads vendor session memory and writes session-memory-class pages. Does NOT consume the provenance substrate (session memory is internal, not external). -- **`/abcd:memory ingest`** (this substrate's primary consumer) consumes for: licence detection on every external source, citation generation on every page, registry update on every ingest. +- **`/abcd:library ingest`** (this substrate's primary consumer) consumes for: licence detection on every external source, citation generation on every page, registry update on every ingest. - **`/abcd:loot`** (the later consumer) consumes for: licence detection on vendored repos, citation generation in `.abcd/development/loot/.md`, registry update on every vendor pass. - **`/abcd:disembark` (lifeboat)** is the gate's real consumer (adr-28): it reads the registry for restrictive-licence enforcement over the curated memory/provenance it publishes; does not write to it. At launch the gate is future/inert — `/abcd:launch` excludes `.abcd/` wholesale and is not the gate's consumer (the launch dry-run renders no verdict of it either). diff --git a/.abcd/development/brief/06-delivery/02-verification-matrix.md b/.abcd/development/brief/06-delivery/02-verification-matrix.md index 17172dfc0..3d024ee04 100644 --- a/.abcd/development/brief/06-delivery/02-verification-matrix.md +++ b/.abcd/development/brief/06-delivery/02-verification-matrix.md @@ -89,7 +89,7 @@ table is a gap in the table, never evidence that the capability is ungated.** | Memory residue lint | A stored page, a registry leaf or a kept original carrying a span the redactor would refuse or rewrite is a blocker; the finding names the kind and the line, never the span, and the lint rewrites nothing. A degraded scanner is itself a blocker rather than an error | | Memory quotation and source-class lints | Per-page and cumulative quotation budgets fire on contiguous quotation length and on deduplicated source coverage across pages citing one source. Single-source-class synthesis is advisory; cross-class synthesis without a weighting note blocks, as does an external page missing its licence field | | Memory schema extension | Existing flat-named pages are not renamed; the index is generated over them and the default source class is backfilled | -| Memory bare render | Bare `abcd memory` shows status — page count by class, last ingest, recent contradictions, quotation headroom — and never mutates state | +| Memory bare render | The memory half of bare `abcd library` shows status — page count by class, last ingest, recent contradictions, quotation headroom — and never mutates state | | Provenance substrate | The source-hash registry updates on every ingest, with an SPDX licence extracted from the declared source or an explicit unknown; one registry serves every consumer | | Modification grammar (itd-37) | **(staged)** in full. Every spec would carry a `## Modification Grammar` section with its three sub-headings and the ripple axis, checked by concreteness lints; the auditor would fire a boilerplate check on text that could equally describe a different spec; and at spec completion the distiller would write the per-spec and per-domain pages. No committed spec carries the section, `abcd spec` scaffolds none, the codes exist in no lint, and nothing writes the pages | | Vocabulary registration (itd-37) | Every term introduced in a spec's vocabulary delta appears in the same spec in the registry that fits it — the canonical glossary or the reserved-vocabulary register. `VR001` is reserved for the check and unimplemented, so registration is enforced by review | diff --git a/.abcd/development/intents/shipped/itd-36-memory-unification.md b/.abcd/development/intents/shipped/itd-36-memory-unification.md index 146e3e9d4..ee648a8a8 100644 --- a/.abcd/development/intents/shipped/itd-36-memory-unification.md +++ b/.abcd/development/intents/shipped/itd-36-memory-unification.md @@ -38,7 +38,7 @@ There's a real gap: **per-project durable knowledge has multiple legitimate upst ## What's In Scope -- **`/abcd:memory` command** with sub-verbs `ingest` / `ask` / `lint`. Bare invocation renders state per the universal bare-command-as-render discipline. Surface contract: [`04-surfaces/07-memory.md`](../../brief/04-surfaces/07-memory.md). Substrate spec: [`05-internals/07-memory.md`](../../brief/05-internals/07-memory.md). +- **`/abcd:memory` command** with sub-verbs `ingest` / `ask` / `lint`. Bare invocation renders state per the universal bare-command-as-render discipline. Surface contract: [`04-surfaces/07-library.md`](../../brief/04-surfaces/07-library.md), the memory verbs having merged into `/abcd:library` (itd-2610090831227812). Substrate spec: [`05-internals/07-memory.md`](../../brief/05-internals/07-memory.md). - **Schema extension on `.abcd/memory/`** (NOT migration): three new conventional sibling files (`index.md` generated catalog, `log.md` append-only ingest history, `contradictions.md` curator-surfaced register) plus typed `source:` frontmatter on every memory page. Existing flat-named pages preserved; schema extends in place. - **Typed `source:` frontmatter** with closed-enum source-class taxonomy: `session_memory` / `external_pdf` / `external_transcript` / `external_article` / `oracle_review` / `work_notes` / `issue_ledger` / `dredge_synthesis` / `spec_modification_grammar` / `modification_grammar`. Every external source carries `citation` + `licence` + `source_hash` + `ingested_at`. - **Default-no-originals + `--keep-original`**: ingest reads → distils → discards by default; flag opts into storage at `.abcd/memory/sources/.`; the lifeboat licence gate refuses to surface `sources/` files without an explicit allowlist entry. @@ -205,7 +205,7 @@ Gap audit: - Full assessment in a local working note (unmigrated), with 5-round review trail (chat `adversarial-review-llm-w-4970CE`); pre-review framings preserved in chat record. - [`05-internals/07-memory.md`](../../brief/05-internals/07-memory.md) — substrate spec (page-class enum, source classes, curator behaviour, lifecycle class). - [`05-internals/09-provenance-substrate.md`](../../brief/05-internals/09-provenance-substrate.md) — provenance/licence subsystem shared with itd-26 loot. -- [`04-surfaces/07-memory.md`](../../brief/04-surfaces/07-memory.md) — surface contract for `/abcd:memory`. +- [`04-surfaces/07-library.md`](../../brief/04-surfaces/07-library.md) — surface contract for `/abcd:memory`, now the memory verbs of `/abcd:library`. - [`research/related-work.md § Karpathy LLM Wiki`](../../research/related-work.md#karpathy-llm-wiki--pattern-source-for-abcdmemory) — pattern source. - [`itd-1-acceptance-gates.md`](../disciplines/itd-1-acceptance-gates.md) — companion discipline; this intent's acceptance criteria conform to its Given-When-Then shape. - [`itd-37-modification-grammar.md`](../disciplines/itd-37-modification-grammar.md) — companion discipline; ships alongside itd-36 (page-classes `spec_modification_grammar` + `modification_grammar` are part of itd-36's source-class enum). diff --git a/.abcd/development/release-gate/manifest.json b/.abcd/development/release-gate/manifest.json index 165227f48..c56cc9ad9 100644 --- a/.abcd/development/release-gate/manifest.json +++ b/.abcd/development/release-gate/manifest.json @@ -25,7 +25,7 @@ ".abcd/development/brief/04-surfaces/04-launch.md", ".abcd/development/brief/04-surfaces/05-intent.md", ".abcd/development/brief/04-surfaces/06-capture.md", - ".abcd/development/brief/04-surfaces/07-memory.md", + ".abcd/development/brief/04-surfaces/07-library.md", ".abcd/development/brief/04-surfaces/08-abcd.md", ".abcd/development/brief/04-surfaces/09-reflect.md", ".abcd/development/brief/04-surfaces/10-docs.md", @@ -46,7 +46,6 @@ ".abcd/development/brief/04-surfaces/30-inbox.md", ".abcd/development/brief/04-surfaces/31-lab.md", ".abcd/development/brief/04-surfaces/32-scribe.md", - ".abcd/development/brief/04-surfaces/33-source.md", ".abcd/development/brief/04-surfaces/34-build.md", ".abcd/development/brief/04-surfaces/35-drain.md", ".abcd/development/brief/04-surfaces/36-dashboard.md", @@ -86,10 +85,10 @@ "probe": "list skills/ (abcd ships zero skills; the directory is empty or absent)" } ], - "checkerCount": 44, - "promptHash": "sha256:6fcb0365f73f07072dd3fa47c1eb18eab46c129a0253ffcc33325bf8d9262ea2", + "checkerCount": 43, + "promptHash": "sha256:b2c3afe393eac4fc11590b9cf2c8980542c8ae14f637f412b9f7570747c86f7d", "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,\nreading, 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.", + "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, library, lint, mode, peers,\nreading, reflect, report, scribe, site, 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 6ec2b824c..efbc4cb0b 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:7fc5efa9b0332a6833d93fa130c2ee9aa55b5bf84e1b8aa0fd4f9aa8c21deb64", + "manifestHash": "sha256:e5e0c21f81b34266dde7638a865d43366341ea50104ef7bd19abfe325e0df436", "policy": { "detector": "iss35-brief-surface-crosscheck", "version": "1", diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 384fb5a7e..8fab983ed 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -2634,14 +2634,14 @@ "flags": [] }, { - "path": "abcd lint", + "path": "abcd library", "hidden": false, - "group": "agents", - "block": "agents", - "sentence": "Check this repository against the conventions, every target but outbound: Writes nothing; refuses with exit 2 on an error finding and exit 1 on warnings alone.", + "group": "records", + "block": "people", + "sentence": "Render the sources corpus and the checkout's memory store, read-only: Writes nothing; refuses only when neither store can be read.", "flags": [ { - "name": "root", + "name": "corpus", "shorthand": "", "type": "string", "required": false, @@ -2650,53 +2650,179 @@ ] }, { - "path": "abcd lint docs", + "path": "abcd library add", "hidden": false, - "sentence": "Lint the docs for change-narration, broken links, citations, and stray root markdown: Writes nothing; refuses a tree with a blocker finding.", + "sentence": "Register a source under its class folder, with its entry and text: Writes the corpus and commits it; refuses without one of --confidential or --public.", "flags": [ { - "name": "config", + "name": "alias", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "author", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "ban-authors", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "confidential", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "key", "shorthand": "", "type": "string", "required": false, "hidden": false }, { - "name": "release-gate", + "name": "keywords", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false + }, + { + "name": "meta", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "permission", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "public", "shorthand": "", "type": "bool", "required": false, "hidden": false }, { - "name": "root", + "name": "text", "shorthand": "", "type": "string", "required": false, "hidden": false + }, + { + "name": "title", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "type", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "url", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "venue", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "year", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false } ] }, { - "path": "abcd lint identity", + "path": "abcd library ask", "hidden": false, - "sentence": "Show this repository's identity block and every surface held to it: Writes nothing; refuses a repository that records no identity block.", + "sentence": "Query memory and synthesise a cited answer: Writes a memory page only with --file-back; refuses outside a git checkout.", + "flags": [ + { + "name": "file-back", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "page-json", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "top-n", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd library cite-check", + "hidden": false, + "sentence": "Scan text for confidential sources and report each hit by key only: Writes nothing; refuses without a corpus, and exits 1 on a hit.", "flags": [] }, { - "path": "abcd lint outbound", + "path": "abcd library declassify", "hidden": false, - "sentence": "Judge one outbound text against the session-URL and tool-footer policy: Writes nothing; refuses a text carrying either with exit 1.", + "sentence": "Move a published confidential source to public/ in one visible commit: Writes the corpus and commits it; refuses a key that is not confidential.", "flags": [ { - "name": "label", + "name": "permission", "shorthand": "", "type": "string", "required": false, "hidden": false + } + ] + }, + { + "path": "abcd library ingest", + "hidden": false, + "sentence": "Distil a local file or an https source into cited memory pages: Writes the pages; refuses a URL that is not https.", + "flags": [ + { + "name": "keep-original", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false }, { - "name": "root", + "name": "pages-json", "shorthand": "", "type": "string", "required": false, @@ -2705,69 +2831,172 @@ ] }, { - "path": "abcd lint site", + "path": "abcd library init", "hidden": false, - "sentence": "Gate the built website, rendering it first when absent: Writes only inside the output directory; refuses a site failing any gate with exit 1.", + "sentence": "Create an empty sources corpus, a git repository with no remote: Writes the corpus in one commit; refuses an existing corpus or one inside another working tree.", + "flags": [] + }, + { + "path": "abcd library ledger", + "hidden": false, + "sentence": "Append an influence line to this repository's ledger, or flip one to cited: Writes one ledger line; refuses a flip for a source that is not public and citable.", "flags": [ { - "name": "out", + "name": "claim", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "corrects", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + }, + { + "name": "decision", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "flip", + "shorthand": "", + "type": "int", + "required": false, + "hidden": false + }, + { + "name": "influence", "shorthand": "", "type": "string", "required": false, "hidden": false + }, + { + "name": "list", + "shorthand": "", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "locator", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "repo", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "source", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "used-in", + "shorthand": "", + "type": "stringArray", + "required": false, + "hidden": false } ] }, { - "path": "abcd memory", + "path": "abcd library lint", "hidden": false, - "group": "records", - "block": "people", - "sentence": "Render the memory store's status: Writes nothing; refuses outside a git checkout.", + "sentence": "Health-check the whole memory store: Writes a lint report; refuses a store with a blocker finding.", "flags": [] }, { - "path": "abcd memory ask", + "path": "abcd library sync-banlist", "hidden": false, - "sentence": "Query memory and synthesise a cited answer: Writes a memory page only with --file-back; refuses outside a git checkout.", + "sentence": "Project confidential titles and aliases into the private banlist: Writes the store's generated block; refuses a corpus whose folders and entries disagree.", "flags": [ { - "name": "file-back", + "name": "refresh", "shorthand": "", "type": "bool", "required": false, "hidden": false - }, + } + ] + }, + { + "path": "abcd lint", + "hidden": false, + "group": "agents", + "block": "agents", + "sentence": "Check this repository against the conventions, every target but outbound: Writes nothing; refuses with exit 2 on an error finding and exit 1 on warnings alone.", + "flags": [ { - "name": "page-json", + "name": "root", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, + { + "path": "abcd lint docs", + "hidden": false, + "sentence": "Lint the docs for change-narration, broken links, citations, and stray root markdown: Writes nothing; refuses a tree with a blocker finding.", + "flags": [ + { + "name": "config", "shorthand": "", "type": "string", "required": false, "hidden": false }, { - "name": "top-n", + "name": "release-gate", "shorthand": "", - "type": "int", + "type": "bool", + "required": false, + "hidden": false + }, + { + "name": "root", + "shorthand": "", + "type": "string", "required": false, "hidden": false } ] }, { - "path": "abcd memory ingest", + "path": "abcd lint identity", "hidden": false, - "sentence": "Distil a local file or an https source into cited memory pages: Writes the pages; refuses a URL that is not https.", + "sentence": "Show this repository's identity block and every surface held to it: Writes nothing; refuses a repository that records no identity block.", + "flags": [] + }, + { + "path": "abcd lint outbound", + "hidden": false, + "sentence": "Judge one outbound text against the session-URL and tool-footer policy: Writes nothing; refuses a text carrying either with exit 1.", "flags": [ { - "name": "keep-original", + "name": "label", "shorthand": "", - "type": "bool", + "type": "string", "required": false, "hidden": false }, { - "name": "pages-json", + "name": "root", "shorthand": "", "type": "string", "required": false, @@ -2776,10 +3005,18 @@ ] }, { - "path": "abcd memory lint", + "path": "abcd lint site", "hidden": false, - "sentence": "Health-check the whole memory store: Writes a lint report; refuses a store with a blocker finding.", - "flags": [] + "sentence": "Gate the built website, rendering it first when absent: Writes only inside the output directory; refuses a site failing any gate with exit 1.", + "flags": [ + { + "name": "out", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] }, { "path": "abcd mode", @@ -3113,251 +3350,6 @@ } ] }, - { - "path": "abcd source", - "hidden": false, - "group": "records", - "block": "people", - "sentence": "Render the sources corpus and its ledgers, read-only: Writes nothing; refuses without a corpus, exit 3, naming `abcd source init`.", - "flags": [ - { - "name": "corpus", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - } - ] - }, - { - "path": "abcd source add", - "hidden": false, - "sentence": "Register a source under its class folder, with its entry and text: Writes the corpus and commits it; refuses without one of --confidential or --public.", - "flags": [ - { - "name": "alias", - "shorthand": "", - "type": "stringArray", - "required": false, - "hidden": false - }, - { - "name": "author", - "shorthand": "", - "type": "stringArray", - "required": false, - "hidden": false - }, - { - "name": "ban-authors", - "shorthand": "", - "type": "bool", - "required": false, - "hidden": false - }, - { - "name": "confidential", - "shorthand": "", - "type": "bool", - "required": false, - "hidden": false - }, - { - "name": "key", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "keywords", - "shorthand": "", - "type": "stringArray", - "required": false, - "hidden": false - }, - { - "name": "meta", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "permission", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "public", - "shorthand": "", - "type": "bool", - "required": false, - "hidden": false - }, - { - "name": "text", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "title", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "type", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "url", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "venue", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "year", - "shorthand": "", - "type": "int", - "required": false, - "hidden": false - } - ] - }, - { - "path": "abcd source cite-check", - "hidden": false, - "sentence": "Scan text for confidential sources and report each hit by key only: Writes nothing; refuses without a corpus, and exits 1 on a hit.", - "flags": [] - }, - { - "path": "abcd source declassify", - "hidden": false, - "sentence": "Move a published confidential source to public/ in one visible commit: Writes the corpus and commits it; refuses a key that is not confidential.", - "flags": [ - { - "name": "permission", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - } - ] - }, - { - "path": "abcd source init", - "hidden": false, - "sentence": "Create an empty sources corpus, a git repository with no remote: Writes the corpus in one commit; refuses an existing corpus or one inside another working tree.", - "flags": [] - }, - { - "path": "abcd source ledger", - "hidden": false, - "sentence": "Append an influence line to this repository's ledger, or flip one to cited: Writes one ledger line; refuses a flip for a source that is not public and citable.", - "flags": [ - { - "name": "claim", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "corrects", - "shorthand": "", - "type": "int", - "required": false, - "hidden": false - }, - { - "name": "decision", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "flip", - "shorthand": "", - "type": "int", - "required": false, - "hidden": false - }, - { - "name": "influence", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "list", - "shorthand": "", - "type": "bool", - "required": false, - "hidden": false - }, - { - "name": "locator", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "repo", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "source", - "shorthand": "", - "type": "string", - "required": false, - "hidden": false - }, - { - "name": "used-in", - "shorthand": "", - "type": "stringArray", - "required": false, - "hidden": false - } - ] - }, - { - "path": "abcd source sync-banlist", - "hidden": false, - "sentence": "Project confidential titles and aliases into the private banlist: Writes the store's generated block; refuses a corpus whose folders and entries disagree.", - "flags": [ - { - "name": "refresh", - "shorthand": "", - "type": "bool", - "required": false, - "hidden": false - } - ] - }, { "path": "abcd spec", "hidden": false, diff --git a/.abcd/rules.json b/.abcd/rules.json index 5b9afc63f..c7629867d 100644 --- a/.abcd/rules.json +++ b/.abcd/rules.json @@ -75,8 +75,8 @@ "intent", "lab", "launch", + "library", "lint", - "memory", "mode", "peers", "reading", @@ -85,7 +85,6 @@ "rules", "scribe", "site", - "source", "spec", "statusline", "update", diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 61dc4fddd..2f6041345 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -478,7 +478,7 @@ if [ -n "$primary_banlist" ]; then fi # --- itd-76: refresh the sources corpus's generated block ---------------------- -# The user-level sources corpus (`abcd source`) projects every confidential +# The user-level sources corpus (`abcd library`) projects every confidential # source's title and aliases (and, where an entry opts in, its authors) into a # generated block of this working tree's private store. The block is refreshed # HERE, after the store has passed the tampering refusals above and before it is @@ -489,7 +489,7 @@ fi # * no corpus — CI, a fresh clone, every machine that never made one; # * no private store in this working tree — the refresh updates a store, it never # creates one, because creating it writes every confidential title into this -# repository's local tier, which is the person's act (`abcd source +# repository's local tier, which is the person's act (`abcd library # sync-banlist`, by hand), not a side effect of a commit; # * a legacy store — named with its migration on the first commit that meets it # and not again, because a line printed on every commit is a line the @@ -503,13 +503,13 @@ fi # The binary is THIS checkout's own ./cmd/abcd, built the way .githooks/commit-msg # builds it, and never an installed abcd: in a source checkout an installed abcd is # whatever was last released, stale by construction and possibly without the -# `source` verb at all. The corpus location is the default one; relocating the +# `library` verb at all. The corpus location is the default one; relocating the # user-level home is a separate design (itd-77). sources_corpus="${HOME:-}/.abcd.noindex/sources" sources_noticed="$banlist_dir/sources-legacy-noticed" # sources_relay runs one refresh binary and relays what it says, prefixed. A binary -# that has no `source sync-banlist --refresh` is ONE line naming the remedy, not a +# that has no `library sync-banlist --refresh` is ONE line naming the remedy, not a # failure, and its own error text is not relayed. sources_relay() { _bin="$1" @@ -517,13 +517,13 @@ sources_relay() { _rc=0 _out=$(unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX - "$_bin" source sync-banlist --refresh 2>&1) || _rc=$? + "$_bin" library sync-banlist --refresh 2>&1) || _rc=$? if [ "$_rc" -ne 0 ]; then - _help=$("$_bin" source sync-banlist --help 2>&1 || true) + _help=$("$_bin" library sync-banlist --help 2>&1 || true) case $_help in *--refresh*) ;; *) - echo "pre-commit: $_what has no 'source sync-banlist --refresh', so the sources banlist block was not refreshed (skipped)" >&2 + echo "pre-commit: $_what has no 'library sync-banlist --refresh', so the sources banlist block was not refreshed (skipped)" >&2 return 0 ;; esac @@ -592,7 +592,7 @@ sources_refresh() { if [ -z "${HOME:-}" ] || [ ! -d "$sources_corpus" ]; then echo "pre-commit: no sources corpus at ~/.abcd.noindex/sources — generated banlist block not refreshed (skipped)" >&2 elif [ ! -f "$banlist" ]; then - echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd source sync-banlist' here to opt in (skipped)" >&2 + echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd library sync-banlist' here to opt in (skipped)" >&2 else sources_first="" IFS= read -r sources_first < "$banlist" || true diff --git a/commands/banlist.md b/commands/banlist.md index 55fd27c10..8e11a8798 100644 --- a/commands/banlist.md +++ b/commands/banlist.md @@ -125,7 +125,7 @@ becomes line 1 and every whole-line pattern keeps its exact bytes under the key guard already names it by, `entry-`, so nothing it matches changes. Comments and blank lines survive. `migrated` is false when the store was already keyed, and nothing is written then. Report the entry count; the binary prints no -pattern, and neither should you. `abcd source sync-banlist` and the pre-commit +pattern, and neither should you. `abcd library sync-banlist` and the pre-commit guard's refresh of the sources block both name this command for a legacy store. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install diff --git a/commands/consult.md b/commands/consult.md index 165dd4ac1..70f5835ee 100644 --- a/commands/consult.md +++ b/commands/consult.md @@ -10,10 +10,11 @@ A local-only corpus at `~/.abcd.noindex/sources/` holds source documents (workin papers, private-repo notes, PDFs, books) the agent may **consult** but must never **cite** publicly. Metadata lives in `sources.json` (CSL-JSON; the `custom` block carries `confidential`, `permission_status`, `keywords`, -`aliases`, `ban_authors`). Every write goes through the `abcd source` verbs -(`/abcd:source`); reading is plain search. First run -`"${CLAUDE_PLUGIN_ROOT}/abcd" source --json`: exit 3 means there is no corpus — -say so and stop, because this command never creates it. +`aliases`, `ban_authors`). Every write goes through the `abcd library` verbs +(`/abcd:library`); reading is plain search. First run +`"${CLAUDE_PLUGIN_ROOT}/abcd" library --json`: a null `corpus`, whose +`corpus_note` says so, means there is no corpus — say so and stop, because this +command never creates it. ## Hard rule (overrides convenience, always) @@ -52,7 +53,7 @@ contradicts it, supplies a method, or shapes background understanding — not mere incidental reading), record ONE line: ```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source ledger --decision "" \ +"${CLAUDE_PLUGIN_ROOT}/abcd" library ledger --decision "" \ --claim "" --source \ --influence supports|contradicts|method|background \ [--locator ""] [--used-in ]... --json @@ -75,17 +76,17 @@ decision, and the line number, so they can decide about citing. ## Guard wiring - The repository's committed pre-commit guard runs - `abcd source sync-banlist --refresh` on every commit (in a managed + `abcd library sync-banlist --refresh` on every commit (in a managed repository, once the clone opts in with `git config --local abcd.sourcesBinary /absolute/path/to/abcd`), which regenerates a fenced block of confidential titles and aliases in the untracked `.abcd/.work.local/private-names.txt` when that store already exists; leakage is then blocked mechanically, not just by this command's rule. The first sync in a repository, and the one after adding or declassifying a - source, is `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist`, run by hand so + source, is `"${CLAUDE_PLUGIN_ROOT}/abcd" library sync-banlist`, run by hand so the block is current before the next commit. - Before any document that drew on confidential material is committed, posted, - or otherwise shared, run `"${CLAUDE_PLUGIN_ROOT}/abcd" source cite-check ` + or otherwise shared, run `"${CLAUDE_PLUGIN_ROOT}/abcd" library cite-check ` (exit 1 = a confidential identifier is present; its report names only the CSL key, the field and the position, so the report itself is safe to relay). It runs the same matcher as the guard, and it covers literal strings only. diff --git a/commands/guard.md b/commands/guard.md index a6e219933..c0e8c93e5 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -461,7 +461,7 @@ through a variable or a file, or taken from a `ps | grep` chain, is not seen. Every command of a string a shell is handed with such output in its words is read as handed it, so `sh -c 'kill 4242' _ "$(pgrep …)"` is a **block** too. -A verb that is the person's own act is left to the person. `abcd source ledger +A verb that is the person's own act is left to the person. `abcd library ledger --flip N` records that a human chose to cite a source publicly, so an agent running it is a **block** (`abcd-source-ledger-flip`); recording a line and listing the ledger are not. The entry names the program `abcd`, so it matches diff --git a/commands/ingest.md b/commands/ingest.md index ae4a0c7de..22e856c14 100644 --- a/commands/ingest.md +++ b/commands/ingest.md @@ -8,12 +8,13 @@ block: people # Ingest a source Register a URL or local document in the corpus at `~/.abcd.noindex/sources/`. The -`abcd source add` verb does the deterministic half (store, classify, commit); +`abcd library add` verb does the deterministic half (store, classify, commit); you do the judgment half (fetching, converting, clean metadata, real keywords, confidentiality, quality check). abcd fetches and converts nothing. The confidentiality hard rule from `/abcd:consult` applies here in full. First run -`"${CLAUDE_PLUGIN_ROOT}/abcd" source --json`: exit 3 means there is no corpus — -say so and stop, because this command never creates it. +`"${CLAUDE_PLUGIN_ROOT}/abcd" library --json`: a null `corpus`, whose +`corpus_note` says so, means there is no corpus — say so and stop, because this +command never creates it. ## 1. Read the document first @@ -65,7 +66,7 @@ Two rules hold for author names: ## 3. Register ```sh -"${CLAUDE_PLUGIN_ROOT}/abcd" source add [file] --key --confidential|--public \ +"${CLAUDE_PLUGIN_ROOT}/abcd" library add [file] --key --confidential|--public \ --type [--year YYYY] [--venue ] [--url ] \ [--permission ] [--ban-authors] [--text ] \ --meta --json @@ -82,7 +83,7 @@ Two rules hold for author names: read the piece: topics, named tools/techniques, the claims it makes. Never generic filler ("AI", "software"). -There is no one-argument quick path, and none is assumed here: `source add` +There is no one-argument quick path, and none is assumed here: `library add` with explicit flags is the registrar's front door, and the better one anyway, since you have just read the source and hold metadata a bare fetch could not recover. @@ -99,14 +100,14 @@ the verb does not make). If the source is webloc/link-only there is no body ## 5. Close out -- Confidential ingest → run `"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist` +- Confidential ingest → run `"${CLAUDE_PLUGIN_ROOT}/abcd" library sync-banlist` in every guarded repo the session touches (a guard that refreshes the block does so on the next commit, but only a store that already exists). - If the user wants a summary or review kept: write it to the source's own folder (`summary.md`, notes as siblings) — derived artifacts inherit the source's class by location, never anywhere else. - If the ingest was motivated by a live decision, record the influence edge in - the ledger per `/abcd:consult` (`abcd source ledger`). + the ledger per `/abcd:consult` (`abcd library ledger`). - Tell the user the key and class you registered. **Binary resolution.** Run `"${CLAUDE_PLUGIN_ROOT}/abcd"` — a plugin install diff --git a/commands/library.md b/commands/library.md new file mode 100644 index 000000000..94ba5f609 --- /dev/null +++ b/commands/library.md @@ -0,0 +1,249 @@ +--- +name: library +description: "Render the sources corpus and the checkout's memory store, read-only: Writes nothing; refuses only when neither store can be read." +argument-hint: "[] | init | add [document] --key K --confidential|--public [...] | declassify | ledger [--decision D --claim C --source K --influence I | --flip N | --list] | sync-banlist [--refresh] | cite-check | ingest [--keep-original] | ask | lint" +block: people +--- + +# `/abcd:library` — the sources corpus and the memory store + +Two stores under one command, never connected to each other: + +- **The sources corpus**, at `~/.abcd.noindex/sources/` (another with + `--corpus`), holds documents the user may consult but is not always free to + cite: working papers, private notes, purchased reports. The folder a source + sits in — `confidential//` or `public//` — is its classification. + Its ledger records which source shaped which decision in this repository. + Its verbs are `init`, `add`, `declassify`, `ledger`, `sync-banlist` and + `cite-check`. `/abcd:consult` and `/abcd:ingest` are the judgement halves and + call these verbs for every write. +- **The memory store**, at the checkout's `.abcd/memory/`, holds typed, cited + pages distilled from a source. Its verbs are `ingest`, `ask` and `lint`. + Nothing moves a corpus document into the memory store: the memory store's + `ingest` distils a source into pages, and the corpus's `add` registers a + document. + +## The hard rule + +Never write a confidential source's title, aliases, authors or any identifying +description into anything tracked by git or sent anywhere external: commits, +pull-request and issue text, docs, code comments, pasted output. Name a source by +its **key** only — every output of this command does. Conversation with the user +is exempt. + +## Status (bare, read-only) + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library --json +``` + +The envelope carries each store, or a note saying why it could not be read: + +- `corpus`: report `present`, the `confidential` and `public` counts, each + ledger's `repo` and `lines`, and every entry in `problems` by key and reason. A + non-zero `remotes` is a warning to relay: documents and ledgers never leave + this machine. When `corpus` is null, `corpus_note` says why — with no corpus, + say so; only the user decides to create one, with `library init`. +- `memory`: summarise `pages` and `by_class` (page count per source class), + `last_ingest`, any `contradictions`, per-source `headroom` lines, and every + `drift` line verbatim — each says the index or the contradictions register is + stale and names the ingest that rebuilds it. When `memory` is null, + `memory_note` says why: outside a checkout there is no memory store. + +The command exits 2 only when neither store can be read. The bare render never +rebuilds or mutates the memory store's coverage index. + +## The sources corpus + +### Register a source + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library add --key --confidential|--public \ + --meta [--type ] [--year YYYY] [--venue V] [--url U] \ + [--permission ] [--ban-authors] [--text ] --json +``` + +- The class is required and never defaulted. When in doubt, ask whichever of the + product thinker or the technical facilitator is adding the source, under the + chip naming their role. +- For a confidential source, put the title, aliases and authors in a `--meta` + JSON file (`{"title": …, "aliases": […], "author": [{"family": …, "given": …}], + "keywords": […]}`) so they stay out of argv, and choose an **opaque** key + (`conf2026a`): the verb refuses a key that contains an alias, the title or a + longer title word, or an author's name. +- abcd fetches and converts nothing. A `.md` or `.txt` document is its own text; + anything else needs `--text` with the extracted text; a URL alone registers a + metadata stub. + +### Record an influence + +When a source meaningfully shapes a decision (supports, contradicts, supplies a +method, or shapes background): + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library ledger --decision "" \ + --claim "" --source --influence supports|contradicts|method|background \ + [--locator "§2"] [--used-in ]... --json +``` + +The line lands with `cited_publicly: false` and is committed in the corpus. A +correction is a new line: add `--corrects `. Tell the user which key was +recorded against which decision and the line number. + +**Never run `ledger --flip`.** It is the user's act of citing a line publicly. It +checks the source first — folder under `public/` and `permission_status` citable +— and refuses naming the failing gate. If the user asks how to cite, tell them +the command; do not run it for them. The shell guard refuses it from an agent +(`abcd-source-ledger-flip`). + +`ledger --list` prints the ledger, numbered. The ledger is named by this +checkout's root commit unless `--repo` names it. + +### Keep the guard current + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library sync-banlist --json +``` + +Projects every confidential source's title and aliases (authors only under +`ban_authors`) into this repository's untracked private banlist, as a fenced block +it owns. Run by hand, it creates the store when there is something to ban. The +committed pre-commit guard runs `library sync-banlist --refresh` on every commit +(in a managed repository, only once the clone opts in with +`git config --local abcd.sourcesBinary /absolute/path/to/abcd`), which updates a +store that already exists and never creates one, so the first sync in a +repository is always the user's own, and running it by hand also matters after an +add or a declassification, before the next commit. A store in the legacy format is +not refreshed: the guard and the verb both name `abcd banlist migrate`. A refusal +naming keys means folders and entries disagree: repair those entries (the block +already written keeps banning meanwhile). A guard committed before the verb was +named `library` still runs `source sync-banlist`; `ahoy` reports it as a gap and +`ahoy install` rewrites it. + +### Scan before sharing + +Before any text that drew on confidential material is posted, shared or pasted +anywhere git does not gate: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library cite-check --json # or - for stdin +``` + +Exit 1 means a finding: each names the source's key, the field (`title`, +`alias-N`, `author-N`), the line and the byte offset — never the matched text, so +the report is safe to relay. The offset counts from the start of the whole text, +not from the start of the line, and it can point at the one byte before the +phrase that bounds it. Reword generically and scan again. A clean scan +covers literal strings only, never an identifying paraphrase. + +### Declassify a published source + +When the user says a confidential source is now published and citable: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library declassify [--permission ] --json +``` + +The folder moves to `public/` in one corpus commit; then run `sync-banlist`. + +### No corpus + +Every corpus verb but `init` exits 3 with one line when there is no corpus at the +location. Say so and stop; never create a corpus the user did not ask for. + +## The memory store + +**The store addressed is the checkout's, from anywhere in the tree.** Every +memory verb resolves the repository root before it reads or writes, so a render +run from a package directory reports the checkout's pages rather than "store not +present", and an ingest lands in the checkout's store rather than laying a second +one under the directory you happen to be standing in. Outside a repository there +is no store to address: `ingest`, `ask` and `lint` exit **2**, read nothing and +write nothing, because pages filed outside every checkout are committed by +nothing and read by nothing. If a memory store also exists below the repository +root, the command names it on stderr and leaves it alone; relay that line, +because pages sitting there are read by no `ask`, no `lint`, and no reader of the +checkout's store. + +### Ingest a source + +Distil an external source (transcript / article / URL) into typed, cited +memory pages. A remote source must be **https**: a plaintext `http://` source +is refused by name, and a redirect that leaves https is refused per hop, because +the store copies a fetched source's text — and the licence header lifted out of +it — verbatim into durable provenance. PDF is a later-phase seam: the binary +rejects a PDF source with a clear error, because no text-extraction dependency +is wired. + +A credential carried by the URL never reaches the store: basic-auth userinfo +(`https://user:pass@host/doc`) and credential-shaped query keys (`token`, +`api_key`, `apikey`, `access_token`, `password`, `secret`, case-insensitive — +names that carry a secret in essentially every usage, so an addressing +parameter such as `?key=` is never truncated) are stripped before the fetched address becomes the stored +origin and title, and masked in every fetch-failure message — including the +transport's own error, which is unwrapped to its cause so it cannot re-print +the address behind the mask. The rest of the address is reproduced unchanged. + +**You** are the distiller: read the source, produce the +`DistilledPage` JSON array, and pass it to the binary via `--pages-json` +(a file, or `-` for stdin). The binary computes the provenance, licence, and +content hash, validates every page, and writes atomically. + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library ingest --pages-json distilled.json --json +``` + +Add `--keep-original` to retain the source at +`.abcd/memory/sources/.` (the lifeboat licence gate — not launch — +governs its export). Report `status`, `licence`, and the written `pages`, and +`scan_gap` whenever it is present: the repository armed gitleaks in +`.abcd/config/gitleaks.json` and the binary is not installed, so the pages were +redacted by the native scanner alone. A gitleaks run that fails refuses the +ingest. An +already-known source re-ingests from the registry with no `--pages-json`. + +### Ask memory + +Deterministic retrieval over the store, then a cited answer: + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library ask "" --json +``` + +The default answer is the deterministic citation-renderer over the top-ranked +pages; every citation references `source_class`, `citation`, and `source_hash`. +`--top-n` sets the retrieval depth; `0` uses the pinned default. Optionally +file the answer back as a new page with `--file-back --page-json ` (one +`DistilledPage` object you produce from the retrieved matches). +Report the `answer` and, if present, the `file_back` result. + +### Lint + +Full-store curator health-check — per-page quotation budgets, cumulative source +coverage, source-class and licence advisories, and secret or identity residue +in stored text (`MR001`, a blocker: the store's write-time redactor run over +every page, the sources registry and each text kept-original, and every page +name — each page file's and each registry back-link's — judged as the write +side judges a filename, for a hard-fail secret alone; the finding names the kind +and the line, never the span, and lint never rewrites the store): + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" library lint --json +``` + +It rebuilds the regenerable `.coverage_index.json` and writes a report under +`.abcd/.work.local/logs/memory/lint-/`. `report_dir`, `store_path` and each +finding's `file` are named relative to the repository. Summarise `summary.blockers` / +`summary.warnings` / `summary.infos` and each finding's `code` and `message`. +Blockers exit nonzero; warn-only exits 0. + +**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. + +**User input:** $ARGUMENTS diff --git a/commands/memory.md b/commands/memory.md deleted file mode 100644 index 40a219c3d..000000000 --- a/commands/memory.md +++ /dev/null @@ -1,118 +0,0 @@ ---- -name: memory -description: "Render the memory store's status: Writes nothing; refuses outside a git checkout." -argument-hint: "[] | ingest [--keep-original] | ask | lint" -block: people ---- - -# `/abcd:memory` — curated knowledge substrate - -The per-project compounding-curated knowledge substrate at `.abcd/memory/`. -Bare invocation **performs zero writes**. - -**The substrate addressed is the checkout's, from anywhere in the tree.** Every -verb here resolves the repository root before it reads or writes, so a bare -render run from a package directory reports the checkout's pages rather than -"store not present", and an ingest lands in the checkout's store rather than -laying a second one under the directory you happen to be standing in. Outside a -repository there is no substrate to address: the verb exits **2**, reads -nothing and writes nothing, because pages filed outside every checkout are -committed by nothing and read by nothing. If a memory store also exists below -the repository root, the verb names it on stderr and leaves it alone; relay that -line, because pages sitting there are read by no `ask`, no `lint`, and no reader -of the checkout's store. - -## Status (bare) - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" memory --json -``` - -Summarise the JSON: `pages` and `by_class` (page count per source class), -`last_ingest`, any `contradictions`, per-source `headroom` lines, and every -`drift` line verbatim — each says the index or the contradictions register is -stale and names the ingest that rebuilds it. The bare render never rebuilds or -mutates the coverage index. - -## Ingest a source - -Distil an external source (transcript / article / URL) into typed, cited -memory pages. A remote source must be **https**: a plaintext `http://` source -is refused by name, and a redirect that leaves https is refused per hop, because -the store copies a fetched source's text — and the licence header lifted out of -it — verbatim into durable provenance. PDF is a later-phase seam: the binary -rejects a PDF source with a clear error, because no text-extraction dependency -is wired. - -A credential carried by the URL never reaches the store: basic-auth userinfo -(`https://user:pass@host/doc`) and credential-shaped query keys (`token`, -`api_key`, `apikey`, `access_token`, `password`, `secret`, case-insensitive — -names that carry a secret in essentially every usage, so an addressing -parameter such as `?key=` is never truncated) are stripped before the fetched address becomes the stored -origin and title, and masked in every fetch-failure message — including the -transport's own error, which is unwrapped to its cause so it cannot re-print -the address behind the mask. The rest of the address is reproduced unchanged. - -**You** are the distiller: read the source, produce the -`DistilledPage` JSON array, and pass it to the binary via `--pages-json` -(a file, or `-` for stdin). The binary computes the provenance, licence, and -content hash, validates every page, and writes atomically. - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" memory ingest --pages-json distilled.json --json -``` - -Add `--keep-original` to retain the source at -`.abcd/memory/sources/.` (the lifeboat licence gate — not launch — -governs its export). Report `status`, `licence`, and the written `pages`, and -`scan_gap` whenever it is present: the repository armed gitleaks in -`.abcd/config/gitleaks.json` and the binary is not installed, so the pages were -redacted by the native scanner alone. A gitleaks run that fails refuses the -ingest. An -already-known source re-ingests from the registry with no `--pages-json`. - -## Ask memory - -Deterministic retrieval over the store, then a cited answer: - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" memory ask "" --json -``` - -The default answer is the deterministic citation-renderer over the top-ranked -pages; every citation references `source_class`, `citation`, and `source_hash`. -`--top-n` sets the retrieval depth; `0` uses the pinned default. Optionally -file the answer back as a new page with `--file-back --page-json ` (one -`DistilledPage` object you produce from the retrieved matches). -Report the `answer` and, if present, the `file_back` result. - -## Lint - -Full-store curator health-check — per-page quotation budgets, cumulative source -coverage, source-class and licence advisories, and secret or identity residue -in stored text (`MR001`, a blocker: the store's write-time redactor run over -every page, the sources registry and each text kept-original, and every page -name — each page file's and each registry back-link's — judged as the write -side judges a filename, for a hard-fail secret alone; the finding names the kind -and the line, never the span, and lint never rewrites the store): - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" memory lint --json -``` - -It rebuilds the regenerable `.coverage_index.json` and writes a report under -`.abcd/.work.local/logs/memory/lint-/`. `report_dir`, `store_path` and each -finding's `file` are named relative to the repository. Summarise `summary.blockers` / -`summary.warnings` / `summary.infos` and each finding's `code` and `message`. -Blockers exit nonzero; warn-only exits 0. - -**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. - -**User input:** $ARGUMENTS diff --git a/commands/source.md b/commands/source.md deleted file mode 100644 index f82bf38d7..000000000 --- a/commands/source.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: source -description: "Render the sources corpus and its ledgers, read-only: Writes nothing; refuses without a corpus, exit 3, naming `abcd source init`." -argument-hint: "[init | add [document] --key K --confidential|--public [...] | declassify | ledger [--decision D --claim C --source K --influence I | --flip N | --list] | sync-banlist [--refresh] | cite-check ]" -block: people ---- - -# `/abcd:source` — the sources corpus and its ledger - -The corpus holds documents the user may consult but is not always free to cite: -working papers, private notes, purchased reports. The folder a source sits in — -`confidential//` or `public//` — is its classification. The ledger -records which source shaped which decision in this repository. `/abcd:consult` -and `/abcd:ingest` are the judgement halves and call these verbs for every write. - -## The hard rule - -Never write a confidential source's title, aliases, authors or any identifying -description into anything tracked by git or sent anywhere external: commits, -pull-request and issue text, docs, code comments, pasted output. Name a source by -its **key** only — every output of this verb does. Conversation with the user is -exempt. - -## Render the corpus (bare, read-only) - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source --json -``` - -Report `present`, the `confidential` and `public` counts, each ledger's `repo` and -`lines`, and every entry in `problems` by key and reason. A non-zero `remotes` is -a warning to relay: documents and ledgers never leave this machine. Exit 3 means -there is no corpus: say so, and stop. Only the user decides to create one, with -`source init`. - -## Register a source - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source add --key --confidential|--public \ - --meta [--type ] [--year YYYY] [--venue V] [--url U] \ - [--permission ] [--ban-authors] [--text ] --json -``` - -- The class is required and never defaulted. When in doubt, ask whichever of the - product thinker or the technical facilitator is adding the source, under the - chip naming their role. -- For a confidential source, put the title, aliases and authors in a `--meta` - JSON file (`{"title": …, "aliases": […], "author": [{"family": …, "given": …}], - "keywords": […]}`) so they stay out of argv, and choose an **opaque** key - (`conf2026a`): the verb refuses a key that contains an alias, the title or a - longer title word, or an author's name. -- abcd fetches and converts nothing. A `.md` or `.txt` document is its own text; - anything else needs `--text` with the extracted text; a URL alone registers a - metadata stub. - -## Record an influence - -When a source meaningfully shapes a decision (supports, contradicts, supplies a -method, or shapes background): - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source ledger --decision "" \ - --claim "" --source --influence supports|contradicts|method|background \ - [--locator "§2"] [--used-in ]... --json -``` - -The line lands with `cited_publicly: false` and is committed in the corpus. A -correction is a new line: add `--corrects `. Tell the user which key was -recorded against which decision and the line number. - -**Never run `ledger --flip`.** It is the user's act of citing a line publicly. It -checks the source first — folder under `public/` and `permission_status` citable -— and refuses naming the failing gate. If the user asks how to cite, tell them -the command; do not run it for them. - -`ledger --list` prints the ledger, numbered. The ledger is named by this -checkout's root commit unless `--repo` names it. - -## Keep the guard current - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source sync-banlist --json -``` - -Projects every confidential source's title and aliases (authors only under -`ban_authors`) into this repository's untracked private banlist, as a fenced block -it owns. Run by hand, it creates the store when there is something to ban. The -committed pre-commit guard runs `sync-banlist --refresh` on every commit (in a -managed repository, only once the clone opts in with -`git config --local abcd.sourcesBinary /absolute/path/to/abcd`), which updates a -store that already exists and never creates one, so the first sync in a -repository is always the user's own, and running it by hand also matters after an -add or a declassification, before the next commit. A store in the legacy format is -not refreshed: the guard and the verb both name `abcd banlist migrate`. A refusal naming keys means folders and entries disagree: repair -those entries (the block already written keeps banning meanwhile). - -## Scan before sharing - -Before any text that drew on confidential material is posted, shared or pasted -anywhere git does not gate: - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source cite-check --json # or - for stdin -``` - -Exit 1 means a finding: each names the source's key, the field (`title`, -`alias-N`, `author-N`), the line and the byte offset — never the matched text, so -the report is safe to relay. The offset counts from the start of the whole text, -not from the start of the line, and it can point at the one byte before the -phrase that bounds it. Reword generically and scan again. A clean scan -covers literal strings only, never an identifying paraphrase. - -## Declassify a published source - -When the user says a confidential source is now published and citable: - -```bash -"${CLAUDE_PLUGIN_ROOT}/abcd" source declassify [--permission ] --json -``` - -The folder moves to `public/` in one corpus commit; then run `sync-banlist`. - -## No corpus - -Every verb but `init` exits 3 with one line when there is no corpus at the -location. Say so and stop; never create a corpus the user did not ask for. - -**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. - -**User input:** $ARGUMENTS diff --git a/commands/update.md b/commands/update.md index d74289852..4dc099996 100644 --- a/commands/update.md +++ b/commands/update.md @@ -40,7 +40,7 @@ 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 +cite refresh`; a URL by `library 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 diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b976e81f9..7cd5d84e8 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -211,7 +211,7 @@ Convert a legacy private store (.abcd/.work.local/private-names.txt with no '# abcd-banlist: keyed' first line, every line a whole-line pattern) to the keyed format: the declaration becomes line 1, and each pattern keeps its exact bytes under the key the guard already names it by, entry-. Comments and blank lines -survive. add, remove and `abcd source sync-banlist` refuse a legacy store with entries +survive. add, remove and `abcd library sync-banlist` refuse a legacy store with entries until it is migrated. A keyed store is left alone. No pattern is printed. #### `abcd banlist remove` @@ -2888,102 +2888,146 @@ configuration names runs; a submodule moved to a different commit still does. --route stringArray route one agent for this run: =[@][?k=v,...], tier one of local | economy | frontier | host-decides (one per agent this invocation dispatches, and each invocation dispatches one; wins over every accepted routing table for this run alone, and the receipt records it verbatim) ``` -### `abcd lint` +### `abcd library` -Check this repository against the conventions, every target but outbound: Writes nothing; refuses with exit 2 on an error finding and exit 1 on warnings alone. +Render the sources corpus and the checkout's memory store, read-only: Writes nothing; refuses only when neither store can be read. -**Usage:** `abcd lint [flags]` +**Usage:** `abcd library` -**Flags:** +Two stores under one verb, never connected to each other. -``` - --root string repo root to lint (default: current working directory) -``` +The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one +append-only influence ledger per repository, in a local-only git repository with no +remote (~/.abcd.noindex/sources by default; --corpus names another). The folder a source sits +in — confidential// or public// — is its classification. Its verbs are init, +add, declassify, ledger, sync-banlist and cite-check. -#### `abcd lint docs` +Consult freely, cite deliberately: confidential entries are projected into this +repository's untracked private banlist (sync-banlist), which the committed pre-commit +guard refreshes and enforces; cite-check clears text before it leaves the machine; and +a ledger line becomes a public citation only when the source permits it AND a person +flips the line (adr-41). No output names a confidential source except by key. Every +corpus verb but init exits 3 when there is no corpus. -Lint the docs for change-narration, broken links, citations, and stray root markdown: Writes nothing; refuses a tree with a blocker finding. +The checkout's memory store (.abcd/memory/): cited pages distilled from a source with +ingest, queried with ask, and health-checked with lint. Its verbs refuse outside a +checkout. -**Usage:** `abcd lint docs [flags]` +Bare `abcd library` is read-only and renders both: a store that cannot be read is one +line naming what would create it, and the verb refuses only when neither can be read. **Flags:** ``` - --config string path to docs-lint.json (default: /.abcd/docs-lint.json) - --release-gate run as the release gate: a citation past its staleness threshold blocks instead of warning (release-time only) - --root string repo root to lint (default: current working directory) + --corpus string the sources corpus directory (absolute; default ~/.abcd.noindex/sources) ``` -#### `abcd lint identity` - -Show this repository's identity block and every surface held to it: Writes nothing; refuses a repository that records no identity block. - -**Usage:** `abcd lint identity` - -#### `abcd lint outbound` +#### `abcd library add` -Judge one outbound text against the session-URL and tool-footer policy: Writes nothing; refuses a text carrying either with exit 1. +Register a source under its class folder, with its entry and text: Writes the corpus and commits it; refuses without one of --confidential or --public. -**Usage:** `abcd lint outbound [FILE] [flags]` +**Usage:** `abcd library add [document] [flags]` -Judge one outbound artefact — a commit message, a pull-request body, an issue, a -comment, a release note — against abcd's outbound policy: never a live -agent-session URL, never a tool's own attribution footer. +Register a source: write its CSL-JSON entry (with the custom block), store the document +as original. and its extracted text as text.md under confidential// or +public//, and commit the corpus. The class is declared here, once: exactly one +of --confidential or --public is required. abcd converts nothing and fetches nothing: +a Markdown or text document is its own text, any other needs --text, and a URL +alone registers a metadata stub. -Reads FILE, or standard input when FILE is absent or `-`. It REPORTS and REFUSES; -it never rewrites the text it was given, because the text belongs to whoever -wrote it. Exit 0 clean, 1 the artefact is refused, 2 the check could not run. +A confidential entry's title, aliases and (under --ban-authors) authors become banned +phrases, so each must hold at least three letters or digits, and its key must not +contain any of them — the key is what every refusal and scan prints. Pass those +strings with --meta FILE (or --meta - on stdin) to keep them out of argv and shell +history. **Flags:** ``` - --label string what the artefact is (commit-message, pr-body, issue, comment) — it names the artefact in the report (default "outbound-artefact") - --root string repo root supplying the scanner configuration (default: current working directory) + --alias stringArray another identifying name for a confidential source (repeatable) + --author stringArray an author, "Family, Given" or a literal name (repeatable) + --ban-authors also ban the authors' names (a confidential source whose authorship is itself identifying) + --confidential file the source under confidential/ (exclusive with --public) + --key string the source key: lowercase, opaque for a confidential source (e.g. conf2026a) + --keywords stringArray retrieval keywords, comma-separated (repeatable) + --meta string a JSON file (or - for stdin) with "title", "aliases", "author" and "keywords" + --permission string permission_status: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default by class) + --public file the source under public/ (exclusive with --confidential) + --text string the extracted text of a non-text document + --title string the exact title (for a confidential source prefer --meta) + --type string the CSL item type (default document) + --url string the canonical URL (recorded, never fetched) + --venue string the container title (journal, site, publisher) + --year int the year of issue ``` -#### `abcd lint site` +#### `abcd library ask` -Gate the built website, rendering it first when absent: Writes only inside the output directory; refuses a site failing any gate with exit 1. +Query memory and synthesise a cited answer: Writes a memory page only with --file-back; refuses outside a git checkout. -**Usage:** `abcd lint site [flags]` +**Usage:** `abcd library ask [flags]` **Flags:** ``` - --out string built output directory to check (rendered first if absent) (default "site") + --file-back file the synthesised answer back as a new memory page + --page-json string the answer page dict as JSON (file path, or - for stdin) + --top-n int retrieval depth (0 uses the pinned default) ``` -### `abcd memory` +**Example:** -Render the memory store's status: Writes nothing; refuses outside a git checkout. +``` +abcd library ask "why do record ids carry a timestamp?" +``` -**Usage:** `abcd memory` +#### `abcd library cite-check` -#### `abcd memory ask` +Scan text for confidential sources and report each hit by key only: Writes nothing; refuses without a corpus, and exits 1 on a hit. -Query memory and synthesise a cited answer: Writes a memory page only with --file-back; refuses outside a git checkout. +**Usage:** `abcd library cite-check ` -**Usage:** `abcd memory ask [flags]` +Scan a file, or stdin with -, for every confidential source's title, aliases and +opted-in authors, through the private banlist's matcher — the engine the pre-commit +guard runs. Offenders are reported by key, field, line and byte offset, never by the +text matched, so the report is safe to relay. The offset counts bytes from the start +of the whole text, not from the start of the line, to the start of the matched span, +which can be the one byte before the phrase that bounds it. Exit 1 when anything is found. + +**Example:** + +``` +abcd library cite-check draft.md +``` + +#### `abcd library declassify` + +Move a published confidential source to public/ in one visible commit: Writes the corpus and commits it; refuses a key that is not confidential. + +**Usage:** `abcd library declassify [flags]` + +Declassify a confidential source once it is published: `git mv` its folder from +confidential/ to public/ and set the entry's confidential flag and permission_status +(citable unless --permission says otherwise), in one corpus commit. The next +sync-banlist drops its strings, and its ledger lines become flippable. **Flags:** ``` - --file-back file the synthesised answer back as a new memory page - --page-json string the answer page dict as JSON (file path, or - for stdin) - --top-n int retrieval depth (0 uses the pinned default) + --permission string permission_status after the move: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default citable) ``` **Example:** ``` -abcd memory ask "why do record ids carry a timestamp?" +abcd library declassify example-paper-2026 ``` -#### `abcd memory ingest` +#### `abcd library ingest` Distil a local file or an https source into cited memory pages: Writes the pages; refuses a URL that is not https. -**Usage:** `abcd memory ingest [flags]` +**Usage:** `abcd library ingest [flags]` **Flags:** @@ -2995,14 +3039,145 @@ Distil a local file or an https source into cited memory pages: Writes the pages **Example:** ``` -abcd memory ingest https://example.com/paper.pdf +abcd library ingest https://example.com/paper.pdf ``` -#### `abcd memory lint` +#### `abcd library init` + +Create an empty sources corpus, a git repository with no remote: Writes the corpus in one commit; refuses an existing corpus or one inside another working tree. + +**Usage:** `abcd library init` + +Create the corpus at its location (0700): a git repository with no remote, an empty +sources.json and a README, in one commit. Refuses an existing corpus, a non-empty +directory, and a location inside another repository's working tree. + +#### `abcd library ledger` + +Append an influence line to this repository's ledger, or flip one to cited: Writes one ledger line; refuses a flip for a source that is not public and citable. + +**Usage:** `abcd library ledger [flags]` + +Append one influence record — {ts, repo, decision_ref, claim, source_key, locator, +influence, cited_publicly: false} — to this repository's ledger in the corpus, and +commit it. The ledger is append-only: a correction is a new line (--corrects N). + +--flip N is the person's act of citing line N publicly. It checks the source first +(adr-41 gate 1: the folder is public/ and permission_status is citable), refuses +naming the failing gate, and on success appends a NEW line with cited_publicly true. +An agent never runs it. --list prints the ledger, numbered. + +The repository is named by its root commit's first twelve hex digits unless --repo +names it. + +**Flags:** + +``` + --claim string what was decided or claimed + --corrects int the line this record corrects + --decision string the decision influenced: a DECISIONS.md date, an ADR or intent id, or free text + --flip int cite line N publicly (the person's act; checks the source's permission first) + --influence string the influence: supports | contradicts | method | background + --list print the ledger, numbered (read-only) + --locator string where in the source (pp., §) + --repo string the ledger's repository handle (default: this checkout's root commit, 12 hex digits) + --source string the source key + --used-in stringArray a repository-relative path the influence landed in (repeatable) +``` + +#### `abcd library lint` Health-check the whole memory store: Writes a lint report; refuses a store with a blocker finding. -**Usage:** `abcd memory lint` +**Usage:** `abcd library lint` + +#### `abcd library sync-banlist` + +Project confidential titles and aliases into the private banlist: Writes the store's generated block; refuses a corpus whose folders and entries disagree. + +**Usage:** `abcd library sync-banlist [flags]` + +Regenerate the corpus's block in this repository's untracked private banlist +(.abcd/.work.local/private-names.txt, the banlist verb's private layer): every +confidential source's title and aliases, and its authors under ban_authors, as +whitespace-flexible, case-insensitive phrases. Lines outside the block survive. +A corpus whose folders and entries disagree is refused and nothing is written. + +--refresh is the pre-commit guard's mode: it updates a private store that already +exists and declares the keyed format, and never creates one. With no corpus, no store +or a legacy store (migrate it with `abcd banlist migrate`) it says so on one line and +exits 0. + +**Flags:** + +``` + --refresh the guard's mode: update an existing store only; an absent corpus or store is a one-line notice and exit 0 +``` + +### `abcd lint` + +Check this repository against the conventions, every target but outbound: Writes nothing; refuses with exit 2 on an error finding and exit 1 on warnings alone. + +**Usage:** `abcd lint [flags]` + +**Flags:** + +``` + --root string repo root to lint (default: current working directory) +``` + +#### `abcd lint docs` + +Lint the docs for change-narration, broken links, citations, and stray root markdown: Writes nothing; refuses a tree with a blocker finding. + +**Usage:** `abcd lint docs [flags]` + +**Flags:** + +``` + --config string path to docs-lint.json (default: /.abcd/docs-lint.json) + --release-gate run as the release gate: a citation past its staleness threshold blocks instead of warning (release-time only) + --root string repo root to lint (default: current working directory) +``` + +#### `abcd lint identity` + +Show this repository's identity block and every surface held to it: Writes nothing; refuses a repository that records no identity block. + +**Usage:** `abcd lint identity` + +#### `abcd lint outbound` + +Judge one outbound text against the session-URL and tool-footer policy: Writes nothing; refuses a text carrying either with exit 1. + +**Usage:** `abcd lint outbound [FILE] [flags]` + +Judge one outbound artefact — a commit message, a pull-request body, an issue, a +comment, a release note — against abcd's outbound policy: never a live +agent-session URL, never a tool's own attribution footer. + +Reads FILE, or standard input when FILE is absent or `-`. It REPORTS and REFUSES; +it never rewrites the text it was given, because the text belongs to whoever +wrote it. Exit 0 clean, 1 the artefact is refused, 2 the check could not run. + +**Flags:** + +``` + --label string what the artefact is (commit-message, pr-body, issue, comment) — it names the artefact in the report (default "outbound-artefact") + --root string repo root supplying the scanner configuration (default: current working directory) +``` + +#### `abcd lint site` + +Gate the built website, rendering it first when absent: Writes only inside the output directory; refuses a site failing any gate with exit 1. + +**Usage:** `abcd lint site [flags]` + +**Flags:** + +``` + --out string built output directory to check (rendered first if absent) (default "site") +``` ### `abcd mode` @@ -3461,178 +3636,6 @@ Take the website from this checkout to a live address: Writes its files, and the --yes confirm the forge and host changes without being asked (never the install of a missing gh); without it an unanswered run declines them ``` -### `abcd source` - -Render the sources corpus and its ledgers, read-only: Writes nothing; refuses without a corpus, exit 3, naming `abcd source init`. - -**Usage:** `abcd source` - -The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one -append-only influence ledger per repository, in a local-only git repository with no -remote (~/.abcd.noindex/sources by default; --corpus names another). The folder a source sits -in — confidential// or public// — is its classification. - -Consult freely, cite deliberately: confidential entries are projected into this -repository's untracked private banlist (sync-banlist), which the committed pre-commit -guard refreshes and enforces; cite-check clears text before it leaves the machine; and -a ledger line becomes a public citation only when the source permits it AND a person -flips the line (adr-41). No output names a confidential source except by key. - -Bare `abcd source` is read-only. Exit 3 when there is no corpus, on every verb but init. - -**Flags:** - -``` - --corpus string the corpus directory (absolute; default ~/.abcd.noindex/sources) -``` - -#### `abcd source add` - -Register a source under its class folder, with its entry and text: Writes the corpus and commits it; refuses without one of --confidential or --public. - -**Usage:** `abcd source add [document] [flags]` - -Register a source: write its CSL-JSON entry (with the custom block), store the document -as original. and its extracted text as text.md under confidential// or -public//, and commit the corpus. The class is declared here, once: exactly one -of --confidential or --public is required. abcd converts nothing and fetches nothing: -a Markdown or text document is its own text, any other needs --text, and a URL -alone registers a metadata stub. - -A confidential entry's title, aliases and (under --ban-authors) authors become banned -phrases, so each must hold at least three letters or digits, and its key must not -contain any of them — the key is what every refusal and scan prints. Pass those -strings with --meta FILE (or --meta - on stdin) to keep them out of argv and shell -history. - -**Flags:** - -``` - --alias stringArray another identifying name for a confidential source (repeatable) - --author stringArray an author, "Family, Given" or a literal name (repeatable) - --ban-authors also ban the authors' names (a confidential source whose authorship is itself identifying) - --confidential file the source under confidential/ (exclusive with --public) - --key string the source key: lowercase, opaque for a confidential source (e.g. conf2026a) - --keywords stringArray retrieval keywords, comma-separated (repeatable) - --meta string a JSON file (or - for stdin) with "title", "aliases", "author" and "keywords" - --permission string permission_status: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default by class) - --public file the source under public/ (exclusive with --confidential) - --text string the extracted text of a non-text document - --title string the exact title (for a confidential source prefer --meta) - --type string the CSL item type (default document) - --url string the canonical URL (recorded, never fetched) - --venue string the container title (journal, site, publisher) - --year int the year of issue -``` - -#### `abcd source cite-check` - -Scan text for confidential sources and report each hit by key only: Writes nothing; refuses without a corpus, and exits 1 on a hit. - -**Usage:** `abcd source cite-check ` - -Scan a file, or stdin with -, for every confidential source's title, aliases and -opted-in authors, through the private banlist's matcher — the engine the pre-commit -guard runs. Offenders are reported by key, field, line and byte offset, never by the -text matched, so the report is safe to relay. The offset counts bytes from the start -of the whole text, not from the start of the line, to the start of the matched span, -which can be the one byte before the phrase that bounds it. Exit 1 when anything is found. - -**Example:** - -``` -abcd source cite-check draft.md -``` - -#### `abcd source declassify` - -Move a published confidential source to public/ in one visible commit: Writes the corpus and commits it; refuses a key that is not confidential. - -**Usage:** `abcd source declassify [flags]` - -Declassify a confidential source once it is published: `git mv` its folder from -confidential/ to public/ and set the entry's confidential flag and permission_status -(citable unless --permission says otherwise), in one corpus commit. The next -sync-banlist drops its strings, and its ledger lines become flippable. - -**Flags:** - -``` - --permission string permission_status after the move: citable | no-public-citation | internal-never-cite | ai-generated-never-cite | ask-author (default citable) -``` - -**Example:** - -``` -abcd source declassify example-paper-2026 -``` - -#### `abcd source init` - -Create an empty sources corpus, a git repository with no remote: Writes the corpus in one commit; refuses an existing corpus or one inside another working tree. - -**Usage:** `abcd source init` - -Create the corpus at its location (0700): a git repository with no remote, an empty -sources.json and a README, in one commit. Refuses an existing corpus, a non-empty -directory, and a location inside another repository's working tree. - -#### `abcd source ledger` - -Append an influence line to this repository's ledger, or flip one to cited: Writes one ledger line; refuses a flip for a source that is not public and citable. - -**Usage:** `abcd source ledger [flags]` - -Append one influence record — {ts, repo, decision_ref, claim, source_key, locator, -influence, cited_publicly: false} — to this repository's ledger in the corpus, and -commit it. The ledger is append-only: a correction is a new line (--corrects N). - ---flip N is the person's act of citing line N publicly. It checks the source first -(adr-41 gate 1: the folder is public/ and permission_status is citable), refuses -naming the failing gate, and on success appends a NEW line with cited_publicly true. -An agent never runs it. --list prints the ledger, numbered. - -The repository is named by its root commit's first twelve hex digits unless --repo -names it. - -**Flags:** - -``` - --claim string what was decided or claimed - --corrects int the line this record corrects - --decision string the decision influenced: a DECISIONS.md date, an ADR or intent id, or free text - --flip int cite line N publicly (the person's act; checks the source's permission first) - --influence string the influence: supports | contradicts | method | background - --list print the ledger, numbered (read-only) - --locator string where in the source (pp., §) - --repo string the ledger's repository handle (default: this checkout's root commit, 12 hex digits) - --source string the source key - --used-in stringArray a repository-relative path the influence landed in (repeatable) -``` - -#### `abcd source sync-banlist` - -Project confidential titles and aliases into the private banlist: Writes the store's generated block; refuses a corpus whose folders and entries disagree. - -**Usage:** `abcd source sync-banlist [flags]` - -Regenerate the corpus's block in this repository's untracked private banlist -(.abcd/.work.local/private-names.txt, the banlist verb's private layer): every -confidential source's title and aliases, and its authors under ban_authors, as -whitespace-flexible, case-insensitive phrases. Lines outside the block survive. -A corpus whose folders and entries disagree is refused and nothing is written. - ---refresh is the pre-commit guard's mode: it updates a private store that already -exists and declares the keyed format, and never creates one. With no corpus, no store -or a legacy store (migrate it with `abcd banlist migrate`) it says so on one line and -exits 0. - -**Flags:** - -``` - --refresh the guard's mode: update an existing store only; an absent corpus or store is a one-line notice and exit 0 -``` - ### `abcd spec` Render the spec store's status: Writes nothing; refuses outside a git checkout. diff --git a/docs/reference/terminology.md b/docs/reference/terminology.md index d383469bd..780428d57 100644 --- a/docs/reference/terminology.md +++ b/docs/reference/terminology.md @@ -55,14 +55,14 @@ deep-linked. | **Grounding** | Supplying a model use-case-specific information at inference time so responses connect to accurate, verifiable sources.[^ground] | **ADAPTS** — abcd's version is *cite-or-be-dropped* (adr-35): a claim in a synthesised artefact carries its source citation or does not ship, and whatever could not be grounded is reported loudly as coverage blanks rather than papered over. | | **Guardrails** | Programmable controls on an application's inputs and outputs — validation, filtering, dialogue constraints — enforced outside the model.[^guard] | **ADAPTS** — abcd's equivalents are deterministic gates: lint families, receipt gates, and pre-commit guards all fail closed. The pre-tool-use shell-hazard guard (`abcd guard`) deliberately does not — it is *fail-open-loud*, a mistake filter rather than a security boundary (adr-42): a guard that cannot answer warns unmissably and lets the command run, because a broken guard must never brick a session. Two principles sharpen the industry sense: *enforcement claims are facts* (a claimed gate must exist) and *guards prove themselves* (a guard ships with the test that shows it fires). | | **Human-in-the-loop** | Human-oversight capability for intervention in an AI system's decision cycle; EU law's term of art is "human oversight".[^hitl] | **ADAPTS** — *verifier selects, gates decide*: a model verdict is only ever a proposal; admission is decided by deterministic gates plus the human's adoption. Planning an intent is defined as the human's sign-off act, machine-checked before implementation may start. | -| **Memory** | An agent's capability to retain and retrieve information beyond one context window: short-term session context plus long-term knowledge persisted across sessions.[^mem] | **USES** — the per-project memory substrate (`.abcd/memory/`) with its own verb: raw sources are curated into a compounding knowledge artefact by a single writing curator, and retrieval is on demand rather than context-flooding: `abcd memory ask` ranks the pages by the words a question shares with each page's classes, domain and summary, and returns the top five with their citations. | +| **Memory** | An agent's capability to retain and retrieve information beyond one context window: short-term session context plus long-term knowledge persisted across sessions.[^mem] | **USES** — the per-project memory substrate (`.abcd/memory/`) under the library verb: raw sources are curated into a compounding knowledge artefact by a single writing curator, and retrieval is on demand rather than context-flooding: `abcd library ask` ranks the pages by the words a question shares with each page's classes, domain and summary, and returns the top five with their citations. | | **Model Context Protocol (MCP)** | Open JSON-RPC protocol standardising how LLM applications connect to tools and data through hosts, clients, and servers (specification revision 2025-11-25).[^mcp] | **USES** — MCP is one of the four recorded oracle-adapter shapes, and an MCP front door over the transport-agnostic core is a recorded later step (adr-24, adr-25). No MCP server ships today; the architecture is built so that adding one changes no core code. | | **Multi-agent systems** | Multiple interacting autonomous agents coordinating to solve tasks beyond any individual agent's capability — three decades of distributed-AI literature that current usage narrows.[^mas] | **ADAPTS** — abcd's multi-agent shape exists for *independence*, not parallelism: the evaluator-outside-the-loop principle requires that whoever judges a change is not whoever proposed it. Cross-agent work coordination is a draft design (itd-33). | | **Observability** | A common telemetry schema for GenAI systems — spans, metrics, and events for inference, agents, and tools; the semantic conventions are pre-stable (pinned here at v1.41.1, the last tagged release carrying them).[^otel] | **WATCHING** — the record has a native, local, redacted transcript corpus (adr-29) and mid-run telemetry is out of the record (dropped with the superseded itd-29 until someone wants it), and a learned per-request model router is not adopted (itd-17, superseded): the model per role is a configured tier, and closed-option judgements are routed to a decision adapter measured in a lab first (itd-2609221009495079, planned). No tracing or metrics stack is adopted. | | **Orchestration** | Coordinating which agents run, in what order, and how control and results flow between them.[^orch] | **ADAPTS** — abcd is *host-delegated* (adr-25): the deterministic core prepares work and hands a prompt to the host's own dispatch. abcd owns the prompt; the host owns models, credentials, and execution — the orchestration-substrate role is deliberately declined. | | **Policy-as-code** | Expressing enforcement policy in declarative, machine-evaluated form, decoupling policy decisions from application logic; the policy *engine* is the component that decides grant or deny.[^pac] | **USES** — abcd's policy is committed JSON configuration: per-repo rules, docs-lint, record-lint, and backend-seam selection, evaluated deterministically in the core. The record practises this without using the phrase; this page is where the mapping is made. | | **Prompt injection** | A vulnerability where user prompts or ingested content alter an LLM's behaviour in unintended ways — directly or indirectly (OWASP LLM01:2025).[^pi] | **USES** — recorded defences on both sides of the boundary: agents that read untrusted input must carry injection-canary fixtures (the itd-5 discipline), and a verb that mutates state fails closed on anything not exactly recognised. The canary lint (reserved code PQ006) and automated canary execution are recorded design targets, not yet shipped. | -| **Retrieval-augmented generation (RAG)** | Combining a generator with a retriever over an external index, so generation draws on non-parametric knowledge.[^rag] | **ADAPTS** — the sources corpus takes the plain-store shape: per-source folders, extracted text, grep-based consult, maintained by the `abcd source` verbs; retrieval is recorded as a pluggable seam (iss-26) where a RAG backend is one opt-in adapter, never the default. | +| **Retrieval-augmented generation (RAG)** | Combining a generator with a retriever over an external index, so generation draws on non-parametric knowledge.[^rag] | **ADAPTS** — the sources corpus takes the plain-store shape: per-source folders, extracted text, grep-based consult, maintained by the `abcd library` verbs; retrieval is recorded as a pluggable seam (iss-26) where a RAG backend is one opt-in adapter, never the default. | | **Sandboxing** | An OS-enforced boundary restricting an agent's filesystem and network access, so it can act autonomously without unrestricted host access.[^sand] | **ADAPTS** — abcd's containment is structural rather than OS-level: read-only probes proven by before-and-after tree hashes, a destination safety gate that refuses directories abcd did not produce, and refusals that write nothing. OS-level sandboxes belong to the host. | | **Tamper-evidence** | Append-only log integrity via Merkle trees, so any instance of a log can be proven a superset of any earlier instance.[^tamper] | **WATCHING** — receipts are hash-anchored and manifests verified today; a compliance-grade hash chain over conversation and edit history is a draft (itd-16), and tamper-evident receipts are tracked as iss-141. | | **Tool use** | A model emits structured, schema-conformant calls to declared functions; the application executes them and returns results ("function calling" in some vendors' vocabulary).[^tooluse] | **ADAPTS** — abcd sits on the other side of the mechanism: it *is* the tool. A single binary of verbs over a transport-agnostic core that returns structured results and knows nothing about who called it (adr-23); thin front doors render those results per surface. | diff --git a/internal/core/ahoy/defaults/pre-commit b/internal/core/ahoy/defaults/pre-commit index 9f981d9a8..464eb53af 100644 --- a/internal/core/ahoy/defaults/pre-commit +++ b/internal/core/ahoy/defaults/pre-commit @@ -411,7 +411,7 @@ if [ -n "$primary_banlist" ]; then fi # --- the sources corpus: refresh its generated block -------------------------- -# abcd's user-level sources corpus (`abcd source`) projects every confidential +# abcd's user-level sources corpus (`abcd library`) projects every confidential # source's title and aliases (and, where an entry opts in, its authors) into a # generated block of this working tree's private store. This guard can refresh the # block before it reads the store, so the check is never more than one commit @@ -432,13 +432,13 @@ fi # * no opt-in, or a setting that is not an absolute path to an executable file; # * no private store in this working tree — the refresh updates a store, it never # creates one, because creating it writes every confidential title into this -# repository's local tier, which is the person's act (`abcd source +# repository's local tier, which is the person's act (`abcd library # sync-banlist`, by hand), not a side effect of a commit; # * a legacy store — named with its migration on the first commit that meets it # and not again, because a line printed on every commit is a line the # committer learns to skip (the store's own format line below says "legacy" on # every commit regardless); -# * a binary that has no `source sync-banlist --refresh`. +# * a binary that has no `library sync-banlist --refresh`. # Otherwise the refresh runs and its own output is RELAYED, never discarded: its # one-line count is how a refresh that wrote fewer patterns than the last one # becomes visible, and its refusals name keys only. @@ -446,7 +446,7 @@ sources_corpus="${HOME:-}/.abcd.noindex/sources" sources_noticed="$banlist_dir/sources-legacy-noticed" # sources_relay runs one refresh binary and relays what it says, prefixed. A binary -# that has no `source sync-banlist --refresh` is ONE line naming the remedy, not a +# that has no `library sync-banlist --refresh` is ONE line naming the remedy, not a # failure, and its own error text is not relayed. sources_relay() { _bin="$1" @@ -454,13 +454,13 @@ sources_relay() { _rc=0 _out=$(unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX - "$_bin" source sync-banlist --refresh 2>&1) || _rc=$? + "$_bin" library sync-banlist --refresh 2>&1) || _rc=$? if [ "$_rc" -ne 0 ]; then - _help=$("$_bin" source sync-banlist --help 2>&1 || true) + _help=$("$_bin" library sync-banlist --help 2>&1 || true) case $_help in *--refresh*) ;; *) - echo "pre-commit: $_what has no 'source sync-banlist --refresh', so the sources banlist block was not refreshed; point the setting at an abcd that has it, or unset it (skipped)" >&2 + echo "pre-commit: $_what has no 'library sync-banlist --refresh', so the sources banlist block was not refreshed; point the setting at an abcd that has it, or unset it (skipped)" >&2 return 0 ;; esac @@ -493,7 +493,7 @@ else if [ "$sources_bin_ok" -eq 0 ]; then echo "pre-commit: abcd.sourcesBinary is not an absolute path to an executable file, so the sources banlist block was not refreshed (skipped)" >&2 elif [ ! -f "$banlist" ]; then - echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd source sync-banlist' here to opt in (skipped)" >&2 + echo "pre-commit: this working tree has no private store at $banlist, and the sources refresh never creates one; run 'abcd library sync-banlist' here to opt in (skipped)" >&2 else sources_first="" IFS= read -r sources_first < "$banlist" || true diff --git a/internal/core/banlist/hook_sources_test.go b/internal/core/banlist/hook_sources_test.go index ca169adff..560cd7a3f 100644 --- a/internal/core/banlist/hook_sources_test.go +++ b/internal/core/banlist/hook_sources_test.go @@ -82,7 +82,7 @@ func makeCorpusDir(t *testing.T) { // repository's copy builds. type refresher struct{ sh, gocode string } -const countLine = "abcd source sync-banlist — 1 confidential source, 1 pattern in .abcd/.work.local/private-names.txt (rewrote the block)" +const countLine = "abcd library sync-banlist — 1 confidential source, 1 pattern in .abcd/.work.local/private-names.txt (rewrote the block)" const writeEntrySh = "printf '# abcd-banlist: keyed\\nsources/conf2026a/title widgetworks\\n' > .abcd/.work.local/private-names.txt; echo '" + countLine + "'" @@ -97,12 +97,12 @@ var ( // the real one's does) and refuses the refresh. failsNamingAKey = refresher{ sh: `case "$*" in *--help*) echo ' --refresh the refresh mode'; exit 0 ;; esac -echo 'abcd source sync-banlist: the corpus disagrees on conf2026a (nothing written)' >&2; exit 2`, +echo 'abcd library sync-banlist: the corpus disagrees on conf2026a (nothing written)' >&2; exit 2`, gocode: `if strings.Contains(strings.Join(os.Args, " "), "--help") { os.Stdout.WriteString(" --refresh the guard's mode\n") return } - os.Stderr.WriteString("abcd source sync-banlist: the corpus disagrees on conf2026a (nothing written)\n") + os.Stderr.WriteString("abcd library sync-banlist: the corpus disagrees on conf2026a (nothing written)\n") os.Exit(2)`, } ) @@ -225,7 +225,7 @@ func TestPreCommitHook_NeverRunsAnInstalledAbcd(t *testing.T) { } // TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking is AC3 at the guard: the -// copy's own binary runs `source sync-banlist --refresh` BEFORE the store is read, +// copy's own binary runs `library sync-banlist --refresh` BEFORE the store is read, // so an entry the refresh writes is enforced on this very commit, and the binary's // one-line count is relayed, so a refresh that wrote fewer patterns is visible. func TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking(t *testing.T) { @@ -239,7 +239,7 @@ func TestPreCommitHook_RefreshesTheSourcesBlockBeforeChecking(t *testing.T) { if err != nil { t.Fatalf("the guard did not run its refresh binary\n%s", out) } - if strings.TrimSpace(string(got)) != "source sync-banlist --refresh" { + if strings.TrimSpace(string(got)) != "library sync-banlist --refresh" { t.Fatalf("the guard ran the binary with %q", got) } if !blocked || !strings.Contains(out, "sources/conf2026a/title") { @@ -262,7 +262,7 @@ func TestPreCommitHook_AFailedRefreshIsRelayedAndTheStoreStillGuards(t *testing. r := newGuardRepo(t, hook, privateFormatDecl+"\nhand-key widgetworks\n") failsNamingAKey.install(t, r, name) blocked, out := commitNote(r, "note.md", "the widgetworks draft\n") - if !strings.Contains(out, "pre-commit: abcd source sync-banlist: the corpus disagrees on conf2026a") { + if !strings.Contains(out, "pre-commit: abcd library sync-banlist: the corpus disagrees on conf2026a") { t.Fatalf("the refresh's refusal was not relayed\n%s", out) } if !strings.Contains(out, "checked as it stands") { @@ -318,7 +318,7 @@ func TestPreCommitHook_TemplateRefusesAnUnusableOptIn(t *testing.T) { } // TestPreCommitHook_TemplateNamesABinaryWithoutTheVerbOnce: an opted-in binary that -// predates the source verb is ONE line naming the remedy — never a "refresh failed" +// predates the library verb is ONE line naming the remedy — never a "refresh failed" // warning, which on every commit would teach the committer to ignore it. func TestPreCommitHook_TemplateNamesABinaryWithoutTheVerbOnce(t *testing.T) { hook := guardCopies(t)["template"] @@ -334,7 +334,7 @@ echo 'abcd: unknown flag: --refresh' >&2; exit 1`} if strings.Contains(out, "failed") || strings.Contains(out, "unknown flag") { t.Fatalf("a binary without the verb reads as a failure\n%s", out) } - if strings.Count(out, "has no 'source sync-banlist --refresh'") != 1 { + if strings.Count(out, "has no 'library sync-banlist --refresh'") != 1 { t.Fatalf("a binary without the verb is not one remedy line\n%s", out) } } diff --git a/internal/core/guard/defaults/guard.json b/internal/core/guard/defaults/guard.json index b62ffb084..8e08aa52f 100644 --- a/internal/core/guard/defaults/guard.json +++ b/internal/core/guard/defaults/guard.json @@ -543,23 +543,23 @@ "tier": "blocker", "pattern": { "command": "abcd", - "subcommand": "source", + "subcommand": "library", "subcommand2": "ledger", "value_flags": ["--corpus"], "flags": ["--flip"] }, "why": "Flipping a source-ledger line to public citation is the person's own act: the ledger records that a human chose to cite that source, so a flip an agent makes writes a false record of who decided.", - "successor": "Leave this one to the person: tell them which ledger line you believe is ready to cite publicly and why, and let them run `abcd source ledger --flip N` themselves.", + "successor": "Leave this one to the person: tell them which ledger line you believe is ready to cite publicly and why, and let them run `abcd library ledger --flip N` themselves.", "fixtures": { "known_bad": [ - "abcd source ledger --flip 3", - "abcd source ledger --flip=3", - "abcd source --corpus /srv/corpus ledger --flip 3" + "abcd library ledger --flip 3", + "abcd library ledger --flip=3", + "abcd library --corpus /srv/corpus ledger --flip 3" ], "known_good": [ - "abcd source ledger --list", - "abcd source ledger --decision adr-7 --claim c --source k --influence i", - "abcd capture \"an agent ran abcd source ledger --flip 3\"" + "abcd library ledger --list", + "abcd library ledger --decision adr-7 --claim c --source k --influence i", + "abcd capture \"an agent ran abcd library ledger --flip 3\"" ] } } diff --git a/internal/core/guard/sourceflip_test.go b/internal/core/guard/sourceflip_test.go index 462f49ef5..57c1c104d 100644 --- a/internal/core/guard/sourceflip_test.go +++ b/internal/core/guard/sourceflip_test.go @@ -11,15 +11,15 @@ import "testing" // line, listing the ledger, and every other source verb stay allowed. func TestSourceLedgerFlipIsLeftToThePerson(t *testing.T) { runVerdictCases(t, []verdictCase{ - {`abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, - {`abcd source ledger --flip=3`, VerdictBlock, "abcd-source-ledger-flip"}, - {`abcd --json source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, - {`abcd source --corpus /srv/corpus ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, - {`"$CLAUDE_PLUGIN_ROOT"/abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, - {`sh -c 'abcd source ledger --flip 3'`, VerdictBlock, "abcd-source-ledger-flip"}, - {`abcd source ledger --list`, VerdictAllow, ""}, - {`abcd source ledger --decision adr-7 --claim c --source k --influence i`, VerdictAllow, ""}, - {`abcd source cite-check README.md`, VerdictAllow, ""}, - {`abcd capture "an agent ran abcd source ledger --flip 3"`, VerdictAllow, ""}, + {`abcd library ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd library ledger --flip=3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd --json library ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd library --corpus /srv/corpus ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`"$CLAUDE_PLUGIN_ROOT"/abcd library ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip"}, + {`sh -c 'abcd library ledger --flip 3'`, VerdictBlock, "abcd-source-ledger-flip"}, + {`abcd library ledger --list`, VerdictAllow, ""}, + {`abcd library ledger --decision adr-7 --claim c --source k --influence i`, VerdictAllow, ""}, + {`abcd library cite-check README.md`, VerdictAllow, ""}, + {`abcd capture "an agent ran abcd library ledger --flip 3"`, VerdictAllow, ""}, }) } diff --git a/internal/core/memory/ask.go b/internal/core/memory/ask.go index 1d8f79304..50c55756a 100644 --- a/internal/core/memory/ask.go +++ b/internal/core/memory/ask.go @@ -23,7 +23,7 @@ const AskTopN = 5 var askFilterRe = regexp.MustCompile(`(?i)\b(class|domain):([A-Za-z0-9_-]+)`) -const filedBy = "abcd memory ask --file-back" +const filedBy = "abcd library ask --file-back" // AskCitation carries the per-source provenance facts of one matched page. // Fields are "" when absent (a legacy / backfilled page), never invented. @@ -272,12 +272,12 @@ func queryPages(repoRoot, question string, topN int) ([]MatchedPage, error) { } // AskReportHeading heads every ask render. It is the BINARY invocation, not the -// `/abcd:memory ask` slash command, because this is core: the same bytes are +// `/abcd:library ask` slash command, because this is core: the same bytes are // rendered whether the caller came through the plugin surface, the bare CLI, or // a later transport, and a heading naming one front door is a falsehood in the -// other two (iss-44). A reader who ran `abcd memory ask` in a terminal must not +// other two (iss-44). A reader who ran `abcd library ask` in a terminal must not // be told they invoked a plugin command they may not even have installed. -const AskReportHeading = "abcd memory ask" +const AskReportHeading = "abcd library ask" // citationTruncatedMarker ends a citation JSON cut at the page-value cap. The // cap cuts mid-JSON, so the cut is said out loud rather than left to read as diff --git a/internal/core/memory/bare.go b/internal/core/memory/bare.go index dade51d00..1a61a4760 100644 --- a/internal/core/memory/bare.go +++ b/internal/core/memory/bare.go @@ -105,10 +105,10 @@ func Bare(repoRoot string) (BareStatus, error) { // sentence names the verb that heals it (every ingest reconciles the // index and the register before it writes). if stale["index.md"] { - status.Drift = append(status.Drift, "the index is stale — run `abcd memory ingest` to rebuild it") + status.Drift = append(status.Drift, "the index is stale — run `abcd library ingest` to rebuild it") } if stale["contradictions.md"] { - status.Drift = append(status.Drift, "the contradictions register is stale — run `abcd memory ingest` to rebuild it") + status.Drift = append(status.Drift, "the contradictions register is stale — run `abcd library ingest` to rebuild it") } } @@ -167,18 +167,18 @@ func bareHeadroomLines(store *storeHandle) []string { raw, err := store.read(coverageIndexName, maxRegistryBytes) if err != nil { - return []string{header + " coverage index not built yet — run `abcd memory lint`"} + return []string{header + " coverage index not built yet — run `abcd library lint`"} } var stored map[string]any if json.Unmarshal(raw, &stored) != nil { - return []string{header + " headroom unavailable — run `abcd memory lint`"} + return []string{header + " headroom unavailable — run `abcd library lint`"} } storedFP, okFP := stored["fingerprint"].(string) sources, okSrc := stored["sources"].(map[string]any) unavailable, okUn := stored["unavailable"].(map[string]any) budgetBlock, okBud := stored["quotation_budget"].(map[string]any) if !okFP || !okSrc || !okUn || !okBud { - return []string{header + " headroom unavailable — run `abcd memory lint`"} + return []string{header + " headroom unavailable — run `abcd library lint`"} } // Read-only crawl over the same typed pages the lint crawls. @@ -199,7 +199,7 @@ func bareHeadroomLines(store *storeHandle) []string { } currentFP := computeFingerprint(pages, registry, referenced, budget) if currentFP != storedFP { - return []string{header + " coverage index stale — run `abcd memory lint`"} + return []string{header + " coverage index stale — run `abcd library lint`"} } if len(sources) == 0 && len(unavailable) == 0 { return []string{header + " no external-source coverage yet"} @@ -226,12 +226,12 @@ func bareHeadroomLines(store *storeHandle) []string { for _, sh := range srcKeys { cov, ok := sources[sh].(map[string]any) if !ok { - return []string{header + " headroom unavailable — run `abcd memory lint`"} + return []string{header + " headroom unavailable — run `abcd library lint`"} } covTotal, okT := num(cov["coverage_total"]) covUnamb, okU := num(cov["coverage_unambiguous"]) if !okT || !okU { - return []string{header + " headroom unavailable — run `abcd memory lint`"} + return []string{header + " headroom unavailable — run `abcd library lint`"} } lines = append(lines, fmt.Sprintf(" %s: warn %s, block %s", short12(sh), fmtSignedPct(warnPct-covTotal), fmtSignedPct(blockPct-covUnamb))) diff --git a/internal/core/memory/ingest.go b/internal/core/memory/ingest.go index 16ce4a71b..25c2dae02 100644 --- a/internal/core/memory/ingest.go +++ b/internal/core/memory/ingest.go @@ -26,7 +26,7 @@ import ( // -> discard/keep-original. Every pre-dispatch failure raises before the single // WritePages call — no orphan registry row, no partial page. -const ingestedBy = "abcd memory ingest" +const ingestedBy = "abcd library ingest" const ( maxFetchBytes = 10 * 1024 * 1024 @@ -729,7 +729,7 @@ func acquireSource(repoRoot, source string, fetcher Fetcher, pdf PDFExtractor) ( // admission rule belongs to the core either way. if scheme := remoteScheme(source); scheme != "" && !isURL(source) { return sourceMaterial{}, newIngestError( - "refusing to fetch %s: memory ingest requires https, not %s — a plaintext source, and the licence header the store copies into its provenance, can be rewritten in transit", + "refusing to fetch %s: library ingest requires https, not %s — a plaintext source, and the licence header the store copies into its provenance, can be rewritten in transit", redactedSource(source), scheme) } if isURL(source) { diff --git a/internal/core/memory/lint.go b/internal/core/memory/lint.go index da4bdc136..40f2042b0 100644 --- a/internal/core/memory/lint.go +++ b/internal/core/memory/lint.go @@ -15,7 +15,7 @@ import ( "github.com/intentdriven/abcd/internal/termsafe" ) -// lint.go — the `abcd memory lint` verb (fn-39): a full-store curator +// lint.go — the `abcd library lint` verb (fn-39): a full-store curator // health-check. Page-local checks (MR001/MS001/MS002/ML001/MQ001/MQ003) per // typed page, a residue pass (MR001) over the sources registry and the text // kept-originals, plus a corpus pass (MQ002 + per-source MQ003) that rebuilds @@ -717,7 +717,7 @@ func findingsToMaps(findings []Finding) []any { // LintReportHeading heads the run-log report, on the same rule as // AskReportHeading: core names the binary invocation, never one front door. -const LintReportHeading = "abcd memory lint" +const LintReportHeading = "abcd library lint" // renderLintReportMD renders report.md, the local-tier file an operator opens in // a pager or a markdown viewer. Every free-text field — the store path, each diff --git a/internal/core/memory/rendernaming_test.go b/internal/core/memory/rendernaming_test.go index 5bab63b25..98cf2414d 100644 --- a/internal/core/memory/rendernaming_test.go +++ b/internal/core/memory/rendernaming_test.go @@ -11,7 +11,7 @@ import ( ) // TestRendersNameTheBinaryNotAFrontDoor is the iss-44 detector for the third -// parity instance: plain-CLI `abcd memory ask` output headed as a plugin slash +// parity instance: plain-CLI `abcd library ask` output headed as a plugin slash // command. internal/core is transport-agnostic — the same bytes are rendered // through the plugin surface, the bare CLI, and any later transport — so a // render that spells a slash command tells two of those three callers something @@ -48,7 +48,7 @@ func TestRendersNameTheBinaryNotAFrontDoor(t *testing.T) { } t.Errorf("%s:%d names the `/abcd:` plugin surface in a rendered string, from the "+ "transport-agnostic core:\n\t%s\nRenders spell the binary invocation "+ - "(`abcd memory ask`), so the same bytes stay true whichever front door produced "+ + "(`abcd library ask`), so the same bytes stay true whichever front door produced "+ "them (iss-44).", name, fset.Position(lit.Pos()).Line, text) return true }) diff --git a/internal/core/source/source.go b/internal/core/source/source.go index 6b0806f83..eeecc99fc 100644 --- a/internal/core/source/source.go +++ b/internal/core/source/source.go @@ -201,7 +201,7 @@ func loadCorpus(dir string) (*Corpus, error) { return nil, err } if fi, err := os.Lstat(filepath.Join(dir, ".git")); err != nil || !fi.IsDir() { - return nil, fmt.Errorf("%w: the corpus directory is not a git repository (run `abcd source init` on an empty location)", ErrCorpusInvalid) + return nil, fmt.Errorf("%w: the corpus directory is not a git repository (run `abcd library init` on an empty location)", ErrCorpusInvalid) } data, err := fsutil.ReadGuarded(filepath.Join(dir, SourcesFile), maxSourcesBytes) if err != nil { @@ -376,7 +376,7 @@ is ever committed to a project repository (adr-41). - ledger/.jsonl append-only influence records, one file per repository The folder a source sits in IS its classification. Maintain the corpus with -` + "`abcd source`" + ` (add, declassify, ledger, sync-banlist, cite-check); a hand +` + "`abcd library`" + ` (add, declassify, ledger, sync-banlist, cite-check); a hand edit that leaves an entry and its folder disagreeing is refused by every step that derives a ban from the corpus until it is repaired. diff --git a/internal/core/source/source_test.go b/internal/core/source/source_test.go index f0bfa4f2d..c8cbe8220 100644 --- a/internal/core/source/source_test.go +++ b/internal/core/source/source_test.go @@ -566,7 +566,7 @@ func TestInitRefusesInsideAnotherRepository(t *testing.T) { // TestTheRepositoryGuardRefreshesWithItsOwnBuild is AC3 through this repository's // real guard, end to end (iss-2609252007414882): a clone whose hooks path is this // checkout's .githooks builds ./cmd/abcd from the checkout and runs ITS -// `source sync-banlist --refresh`, so a source added to the corpus after the last +// `library sync-banlist --refresh`, so a source added to the corpus after the last // by-hand sync is banned on the very next commit, and the build's one-line count // is relayed. No installed abcd is involved: the only abcd this test can reach is // the one the hook builds. @@ -620,7 +620,7 @@ func TestTheRepositoryGuardRefreshesWithItsOwnBuild(t *testing.T) { if err == nil { t.Fatalf("the guard let a source added after the last sync through:\n%s", out) } - if !strings.Contains(string(out), "pre-commit: abcd source sync-banlist — 2 confidential sources") { + if !strings.Contains(string(out), "pre-commit: abcd library sync-banlist — 2 confidential sources") { t.Errorf("the refresh's count line was not relayed:\n%s", out) } if !strings.Contains(string(out), "sources/conf2026b/title") { diff --git a/internal/core/surface/examples.go b/internal/core/surface/examples.go index e3a318e01..44cede28a 100644 --- a/internal/core/surface/examples.go +++ b/internal/core/surface/examples.go @@ -96,8 +96,10 @@ var examples = map[string]string{ "abcd launch archive": "abcd launch archive --out dist", "abcd launch manifests": "abcd launch manifests --tree public", - "abcd memory ask": `abcd memory ask "why do record ids carry a timestamp?"`, - "abcd memory ingest": "abcd memory ingest https://example.com/paper.pdf", + "abcd library ask": `abcd library ask "why do record ids carry a timestamp?"`, + "abcd library cite-check": "abcd library cite-check draft.md", + "abcd library declassify": "abcd library declassify example-paper-2026", + "abcd library ingest": "abcd library ingest https://example.com/paper.pdf", "abcd reading assemble": "abcd reading assemble --position widening --target HEAD", "abcd reading ingest": "abcd reading ingest --reading-json reading.json", @@ -109,9 +111,6 @@ var examples = map[string]string{ "abcd scribe assemble": "abcd scribe assemble --run rdg-2609010000000001 --dispositions dispositions.md", "abcd scribe ingest": "abcd scribe ingest --scribe-json scribe.json --dispositions dispositions.md", - "abcd source cite-check": "abcd source cite-check draft.md", - "abcd source declassify": "abcd source declassify example-paper-2026", - "abcd spec close": "abcd spec close spc-2609010000000001", } diff --git a/internal/core/surface/sentences.go b/internal/core/surface/sentences.go index da8e9aaa7..74f72af88 100644 --- a/internal/core/surface/sentences.go +++ b/internal/core/surface/sentences.go @@ -293,6 +293,27 @@ var sentences = map[string]string{ "abcd launch ship": "Cut a release, deriving its version and records from what shipped: " + "Writes the CHANGELOG heading, RELEASE.md, and the archive pin; refuses a cut its gates stop.", + "abcd library": "Render the sources corpus and the checkout's memory store, read-only: " + + "Writes nothing; refuses only when neither store can be read.", + "abcd library add": "Register a source under its class folder, with its entry and text: " + + "Writes the corpus and commits it; refuses without one of --confidential or --public.", + "abcd library ask": "Query memory and synthesise a cited answer: " + + "Writes a memory page only with --file-back; refuses outside a git checkout.", + "abcd library cite-check": "Scan text for confidential sources and report each hit by key only: " + + "Writes nothing; refuses without a corpus, and exits 1 on a hit.", + "abcd library declassify": "Move a published confidential source to public/ in one visible commit: " + + "Writes the corpus and commits it; refuses a key that is not confidential.", + "abcd library ingest": "Distil a local file or an https source into cited memory pages: " + + "Writes the pages; refuses a URL that is not https.", + "abcd library init": "Create an empty sources corpus, a git repository with no remote: " + + "Writes the corpus in one commit; refuses an existing corpus or one inside another working tree.", + "abcd library ledger": "Append an influence line to this repository's ledger, or flip one to cited: " + + "Writes one ledger line; refuses a flip for a source that is not public and citable.", + "abcd library lint": "Health-check the whole memory store: " + + "Writes a lint report; refuses a store with a blocker finding.", + "abcd library sync-banlist": "Project confidential titles and aliases into the private banlist: " + + "Writes the store's generated block; refuses a corpus whose folders and entries disagree.", + "abcd lint": "Check this repository against the conventions, every target but outbound: " + "Writes nothing; refuses with exit 2 on an error finding and exit 1 on warnings alone.", "abcd lint docs": "Lint the docs for change-narration, broken links, citations, and stray root markdown: " + @@ -304,15 +325,6 @@ var sentences = map[string]string{ "abcd lint site": "Gate the built website, rendering it first when absent: " + "Writes only inside the output directory; refuses a site failing any gate with exit 1.", - "abcd memory": "Render the memory store's status: " + - "Writes nothing; refuses outside a git checkout.", - "abcd memory ask": "Query memory and synthesise a cited answer: " + - "Writes a memory page only with --file-back; refuses outside a git checkout.", - "abcd memory ingest": "Distil a local file or an https source into cited memory pages: " + - "Writes the pages; refuses a URL that is not https.", - "abcd memory lint": "Health-check the whole memory store: " + - "Writes a lint report; refuses a store with a blocker finding.", - "abcd mode": "Print or set whose answer the agent loop is waiting on: " + "Writes the state only when setting it; refuses an unknown state or a checkout with no local tier.", @@ -353,21 +365,6 @@ var sentences = map[string]string{ "abcd site setup": "Take the website from this checkout to a live address: " + "Writes its files, and the forge and host changes once confirmed; refuses a folder abcd does not manage.", - "abcd source": "Render the sources corpus and its ledgers, read-only: " + - "Writes nothing; refuses without a corpus, exit 3, naming `abcd source init`.", - "abcd source add": "Register a source under its class folder, with its entry and text: " + - "Writes the corpus and commits it; refuses without one of --confidential or --public.", - "abcd source cite-check": "Scan text for confidential sources and report each hit by key only: " + - "Writes nothing; refuses without a corpus, and exits 1 on a hit.", - "abcd source declassify": "Move a published confidential source to public/ in one visible commit: " + - "Writes the corpus and commits it; refuses a key that is not confidential.", - "abcd source init": "Create an empty sources corpus, a git repository with no remote: " + - "Writes the corpus in one commit; refuses an existing corpus or one inside another working tree.", - "abcd source ledger": "Append an influence line to this repository's ledger, or flip one to cited: " + - "Writes one ledger line; refuses a flip for a source that is not public and citable.", - "abcd source sync-banlist": "Project confidential titles and aliases into the private banlist: " + - "Writes the store's generated block; refuses a corpus whose folders and entries disagree.", - "abcd spec": "Render the spec store's status: " + "Writes nothing; refuses outside a git checkout.", "abcd spec close": "Close a spec, and ship its intent when no open spec names it: " + diff --git a/internal/surface/cli/banlist.go b/internal/surface/cli/banlist.go index cd0e0b519..e150c8595 100644 --- a/internal/surface/cli/banlist.go +++ b/internal/surface/cli/banlist.go @@ -68,7 +68,7 @@ func newBanlistMigrateCommand(asJSON *bool) *cobra.Command { "'# abcd-banlist: keyed' first line, every line a whole-line pattern) to the keyed\n" + "format: the declaration becomes line 1, and each pattern keeps its exact bytes under\n" + "the key the guard already names it by, entry-. Comments and blank lines\n" + - "survive. add, remove and `abcd source sync-banlist` refuse a legacy store with entries\n" + + "survive. add, remove and `abcd library sync-banlist` refuse a legacy store with entries\n" + "until it is migrated. A keyed store is left alone. No pattern is printed.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/internal/surface/cli/barerender.go b/internal/surface/cli/barerender.go index dc03eb4c3..c0ccd36bc 100644 --- a/internal/surface/cli/barerender.go +++ b/internal/surface/cli/barerender.go @@ -43,9 +43,6 @@ var bareRenderExceptions = map[string]string{ "a terminal and refuses (exit 2) anywhere else", "scribe": "a parent whose sub-verbs act on a named reading run or on a scribe's " + "returned output; with no operand it has no state of its own to render, so bare prints its sub-verbs", - "source": "its state is the user-level corpus, not the repository: with a corpus bare " + - "renders it read-only, and with none, this test's isolated home included, it refuses " + - "(exit 3) naming `abcd source init`", "statusline": "its row belongs to a managed repository: there bare renders it with or " + "without the payload (TestStatuslineEmptyStdinStillRendersTheBadge), and anywhere else, " + "this test's scratch repository included, abcd has no row and bare prints nothing of " + diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index b4be2a1eb..ce6ab5f8e 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -586,8 +586,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand(newCaptureCommand(&asJSON)) root.AddCommand(newBanlistCommand(&asJSON)) - root.AddCommand(newSourceCommand(&asJSON)) - root.AddCommand(newMemoryCommand(&asJSON)) + root.AddCommand(newLibraryCommand(&asJSON)) root.AddCommand(newRulesCommand(&asJSON)) root.AddCommand(newHookCommand(&asJSON)) root.AddCommand(newHistoryCommand(&asJSON)) @@ -6158,61 +6157,50 @@ func memoryStoreRoot(cmd *cobra.Command) (string, error) { } root, err := gitutil.CheckoutRoot(cwd, "the memory store") if err != nil { - return "", &exitError{Code: 2, Msg: "abcd memory: " + err.Error() + " (nothing read, nothing written)"} + return "", &exitError{Code: 2, Msg: "abcd library: " + err.Error() + " (nothing read, nothing written)"} } for _, note := range strayStoreNotes(cwd, root, memory.RelDir, "memory store") { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd memory: %s\n", termsafe.Sanitize(note)) + fmt.Fprintf(cmd.ErrOrStderr(), "abcd library: %s\n", termsafe.Sanitize(note)) } return root, nil } -// newMemoryCommand builds the `memory` sub-tree over internal/core/memory. Bare -// `memory` renders read-only store status; ingest/ask/lint are the mutating and -// diagnostic verbs (04-surfaces/07). The distiller (ingest) and synthesizer -// (ask) are host-delegated seams: the .5 skill emits validated DistilledPage -// JSON, which this surface feeds through --pages-json / --page-json. -func newMemoryCommand(asJSON *bool) *cobra.Command { - memoryCmd := &cobra.Command{ - Use: "memory", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - repoRoot, err := memoryStoreRoot(cmd) - if err != nil { - return err - } - st, err := memory.Bare(repoRoot) - if err != nil { - return err - } - return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { - fmt.Fprintf(w, "abcd memory — %d page(s)", st.Pages) - if !st.StorePresent { - fmt.Fprintf(w, " (store not present)") - } - fmt.Fprintln(w) - for _, c := range st.ByClass { - // Class labels come from page frontmatter; contradiction/headroom lines - // are read verbatim from repo files — all untrusted terminal output. - fmt.Fprintf(w, " %s: %d\n", termsafe.Sanitize(c.Class), c.Count) - } - if st.LastIngest != "" { - fmt.Fprintf(w, " last ingest: %s\n", termsafe.Sanitize(st.LastIngest)) - } - // Drift is the board's one call to action, and it is printed in the - // words the JSON carries (iss-2609091647582259). - for _, line := range st.Drift { - fmt.Fprintf(w, " %s\n", line) - } - for _, line := range st.Contradictions { - fmt.Fprintf(w, " contradiction: %s\n", termsafe.Sanitize(line)) - } - for _, line := range st.Headroom { - fmt.Fprintf(w, " %s\n", termsafe.Sanitize(line)) - } - }) - }, +// renderMemoryStatus is the memory half of bare `library`: the read-only status +// of the checkout's store (04-surfaces/07-library.md). +func renderMemoryStatus(w io.Writer, st memory.BareStatus) { + fmt.Fprintf(w, " memory: %d page(s)", st.Pages) + if !st.StorePresent { + fmt.Fprintf(w, " (store not present)") + } + fmt.Fprintln(w) + for _, c := range st.ByClass { + // Class labels come from page frontmatter; contradiction/headroom lines + // are read verbatim from repo files — all untrusted terminal output. + fmt.Fprintf(w, " %s: %d\n", termsafe.Sanitize(c.Class), c.Count) } + if st.LastIngest != "" { + fmt.Fprintf(w, " last ingest: %s\n", termsafe.Sanitize(st.LastIngest)) + } + // Drift is the board's one call to action, and it is printed in the + // words the JSON carries (iss-2609091647582259). + for _, line := range st.Drift { + fmt.Fprintf(w, " %s\n", line) + } + for _, line := range st.Contradictions { + fmt.Fprintf(w, " contradiction: %s\n", termsafe.Sanitize(line)) + } + for _, line := range st.Headroom { + fmt.Fprintf(w, " %s\n", termsafe.Sanitize(line)) + } +} +// addMemorySubcommands puts the memory store's verbs under `library`, over +// internal/core/memory: ingest/ask/lint are the mutating and diagnostic verbs +// (04-surfaces/07-library.md), and the store's status is half of bare +// `library`. The distiller (ingest) and synthesizer (ask) are host-delegated +// seams: the host emits validated DistilledPage JSON, which this surface feeds +// through --pages-json / --page-json. +func addMemorySubcommands(lib *cobra.Command, asJSON *bool) { // ingest [--keep-original] [--pages-json ] var pagesJSON string var keepOriginalFlag bool @@ -6234,7 +6222,7 @@ func newMemoryCommand(asJSON *bool) *cobra.Command { return err } if err := render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd memory ingest — %s\n", res.Status) + fmt.Fprintf(w, "abcd library ingest — %s\n", res.Status) fmt.Fprintf(w, " content hash: %s\n", res.ContentHash) // The licence is free text lifted verbatim from the ingested // source's bytes (an SPDX line or an HTTP License: header), not a @@ -6265,7 +6253,7 @@ func newMemoryCommand(asJSON *bool) *cobra.Command { } ingestCmd.Flags().BoolVar(&keepOriginalFlag, "keep-original", false, "store the original at .abcd/memory/sources/.") ingestCmd.Flags().StringVar(&pagesJSON, "pages-json", "", "DistilledPage JSON array (file path, or - for stdin)") - memoryCmd.AddCommand(ingestCmd) + lib.AddCommand(ingestCmd) // ask [--top-n N] [--file-back] [--page-json ] var topN int @@ -6303,10 +6291,10 @@ func newMemoryCommand(asJSON *bool) *cobra.Command { askCmd.Flags().IntVar(&topN, "top-n", 0, "retrieval depth (0 uses the pinned default)") askCmd.Flags().BoolVar(&fileBack, "file-back", false, "file the synthesised answer back as a new memory page") askCmd.Flags().StringVar(&pageJSON, "page-json", "", "the answer page dict as JSON (file path, or - for stdin)") - memoryCmd.AddCommand(askCmd) + lib.AddCommand(askCmd) // lint — full-store curator health-check; blockers exit nonzero. - memoryCmd.AddCommand(&cobra.Command{ + lib.AddCommand(&cobra.Command{ Use: "lint", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -6319,7 +6307,7 @@ func newMemoryCommand(asJSON *bool) *cobra.Command { return err } if err := render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd memory lint — %d blocker(s), %d warning(s), %d info(s)\n", + fmt.Fprintf(w, "abcd library lint — %d blocker(s), %d warning(s), %d info(s)\n", res.Summary.Blockers, res.Summary.Warnings, res.Summary.Infos) for _, f := range res.Findings { fmt.Fprintf(w, " %s [%s] %s:%d %s\n", f.Code, f.Severity, termsafe.Sanitize(f.File), f.Line, termsafe.Sanitize(f.Message)) @@ -6335,8 +6323,6 @@ func newMemoryCommand(asJSON *bool) *cobra.Command { return nil }, }) - - return memoryCmd } // pagesJSONDistiller is the ingest transport seam: it lazily reads the @@ -6658,7 +6644,7 @@ func newErrorEnvelope(msg string, code int) errorEnvelope { // working directory is redacted first so a path under it reads "./…", not "~/…". // // This is NOT a universal absolute-path scrub: a verb that echoes a user-supplied -// absolute path lying outside both roots (e.g. `memory ingest /tmp/x`) still +// absolute path lying outside both roots (e.g. `library ingest /tmp/x`) still // surfaces it — that path carries no developer identity, and sanitising such // verb-level echoes is tracked separately (iss-81). Scrubbing here rather than by // regex is deliberate: this error surface also carries URLs (fetch failures) that diff --git a/internal/surface/cli/error_pathleak_surface_test.go b/internal/surface/cli/error_pathleak_surface_test.go index 9fec13aa7..72cf07f81 100644 --- a/internal/surface/cli/error_pathleak_surface_test.go +++ b/internal/surface/cli/error_pathleak_surface_test.go @@ -39,7 +39,7 @@ func TestJSONErrorEnvelopeNoAbsolutePathLeak(t *testing.T) { }{ { name: "memory ask --page-json missing file", - args: []string{"memory", "ask", "q", "--file-back", "--page-json", absMissing, "--json"}, + args: []string{"library", "ask", "q", "--file-back", "--page-json", absMissing, "--json"}, base: "page.json", }, } @@ -206,7 +206,7 @@ func TestMemoryIngestSuccessEnvelopeNoAbsolutePathLeak(t *testing.T) { } var stdout, stderr bytes.Buffer - code := Run([]string{"memory", "ingest", src, "--pages-json", pages, "--json"}, &stdout, &stderr) + code := Run([]string{"library", "ingest", src, "--pages-json", pages, "--json"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected a zero exit; code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -276,7 +276,7 @@ func TestMemoryIngestErrorNoAbsolutePathLeakOutsideRoots(t *testing.T) { missing := filepath.Join(outside, "secret-notes.txt") var stdout, stderr bytes.Buffer - code := Run([]string{"memory", "ingest", missing, "--json"}, &stdout, &stderr) + code := Run([]string{"library", "ingest", missing, "--json"}, &stdout, &stderr) if code == 0 { t.Fatalf("expected a non-zero exit for a missing source; stdout=%q stderr=%q", stdout.String(), stderr.String()) } diff --git a/internal/surface/cli/helpgroups.go b/internal/surface/cli/helpgroups.go index fd1f0cd8d..12a9f8c7d 100644 --- a/internal/surface/cli/helpgroups.go +++ b/internal/surface/cli/helpgroups.go @@ -97,10 +97,10 @@ type helpPlacement struct { // inbox, implement, history, site, docs, scribe, identity, guard, hook and // 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 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 +// memory and source have merged into library, a person's verb under records +// (P4 and decision 1), and changelog into launch as its preview +// (`launch --dry-run`, P4), so neither old name has a 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 // under set-up. cobra's own `help` and `completion` are filed under set-up by @@ -115,8 +115,7 @@ var helpPlacements = map[string]helpPlacement{ "decide": {group: groupRecords}, "ideate": {group: groupRecords}, "intent": {group: groupRecords}, - "memory": {group: groupRecords}, - "source": {group: groupRecords}, + "library": {group: groupRecords}, "lab": {group: groupChecks}, "reading": {group: groupChecks}, "disembark": {group: groupPortability}, diff --git a/internal/surface/cli/helpgroups_test.go b/internal/surface/cli/helpgroups_test.go index f2c46edc0..fd4556d94 100644 --- a/internal/surface/cli/helpgroups_test.go +++ b/internal/surface/cli/helpgroups_test.go @@ -87,7 +87,7 @@ func TestRootHelpListsThePersonsGroups(t *testing.T) { // spc-2610100613109045's audit) and the spec's decision 1 on the groups. for group, want := range map[string][]string{ "Set-up:": {"ahoy", "completion", "dashboard", "help", "update"}, - "Records:": {"build", "capture", "decide", "ideate", "intent", "memory", "source"}, + "Records:": {"build", "capture", "decide", "ideate", "intent", "library"}, "Checks:": {"lab", "reading"}, "Portability:": {"disembark", "embark"}, "Release:": {"launch"}, diff --git a/internal/surface/cli/json_empty_collections_test.go b/internal/surface/cli/json_empty_collections_test.go index 2c710bffc..7ecb91248 100644 --- a/internal/surface/cli/json_empty_collections_test.go +++ b/internal/surface/cli/json_empty_collections_test.go @@ -17,12 +17,13 @@ func TestJSONCollectionsAreEmptyArraysNotNull(t *testing.T) { name string args []string fields []string + within string // the envelope field holding the collections, "" for the top level }{ - {"capture", []string{"capture", "--json"}, []string{"recent_open"}}, - {"capture-list", []string{"capture", "list", "--open", "--json"}, []string{"issues", "skipped"}}, - {"spec", []string{"spec", "--json"}, []string{"specs"}}, - {"intent", []string{"intent", "--json"}, []string{"linked", "intents"}}, - {"memory", []string{"memory", "--json"}, []string{"by_class", "contradictions", "drift"}}, + {"capture", []string{"capture", "--json"}, []string{"recent_open"}, ""}, + {"capture-list", []string{"capture", "list", "--open", "--json"}, []string{"issues", "skipped"}, ""}, + {"spec", []string{"spec", "--json"}, []string{"specs"}, ""}, + {"intent", []string{"intent", "--json"}, []string{"linked", "intents"}, ""}, + {"library", []string{"library", "--json"}, []string{"by_class", "contradictions", "drift"}, "memory"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -34,6 +35,13 @@ func TestJSONCollectionsAreEmptyArraysNotNull(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &obj); err != nil { t.Fatalf("%v output not a JSON object: %v\n%s", tc.args, err, stdout.String()) } + if tc.within != "" { + inner := obj[tc.within] + obj = nil + if err := json.Unmarshal(inner, &obj); err != nil || obj == nil { + t.Fatalf("%v: field %q is not a JSON object: %s", tc.args, tc.within, inner) + } + } for _, f := range tc.fields { raw, ok := obj[f] if !ok { diff --git a/internal/surface/cli/library_test.go b/internal/surface/cli/library_test.go new file mode 100644 index 000000000..60226daf3 --- /dev/null +++ b/internal/surface/cli/library_test.go @@ -0,0 +1,154 @@ +package cli + +import ( + "encoding/json" + "path/filepath" + "sort" + "strings" + "testing" +) + +// library_test.go — step 5 of spc-2610100613109045: the memory and source +// sub-trees are one person's verb, library (itd-2610090831227812, P4). Every +// sub-verb keeps its name (decision 7), bare library renders both halves +// (decision 8), and the two old names are unknown commands with no alias +// (adr-40), their notes naming what replaced them. + +// TestLibraryCarriesEveryMemoryAndSourceSubVerb holds the merge whole: each +// sub-verb memory and source registered is a sub-verb of library under the same +// name, and nothing else is. +func TestLibraryCarriesEveryMemoryAndSourceSubVerb(t *testing.T) { + root := NewRootCommand() + var lib []string + found := false + for _, c := range root.Commands() { + if c.Name() != "library" { + continue + } + found = true + for _, sub := range c.Commands() { + lib = append(lib, sub.Name()) + } + } + if !found { + t.Fatal("the binary registers no library verb") + } + sort.Strings(lib) + want := []string{ + // memory's sub-verbs + "ask", "ingest", "lint", + // source's sub-verbs + "add", "cite-check", "declassify", "init", "ledger", "sync-banlist", + } + sort.Strings(want) + if strings.Join(lib, " ") != strings.Join(want, " ") { + t.Errorf("library's sub-verbs = %v, want %v", lib, want) + } +} + +// TestMemoryAndSourceAreUnknownCommands is the old-names rule for step 5: both +// spellings refuse as unknown commands, the note names library, and neither +// calls the binary stale for not knowing a name that was retired. +func TestMemoryAndSourceAreUnknownCommands(t *testing.T) { + for _, old := range []string{"memory", "source"} { + t.Run(old, func(t *testing.T) { + root := stalePluginRoot(t) + setExecutable(t, filepath.Join(root, "abcd")) + code, stdout, stderr := runMain(t, old) + if code != 2 || stdout != "" { + t.Fatalf("`abcd %s` exit = %d, stdout %q; want exit 2 and an empty stdout", old, code, stdout) + } + if !strings.Contains(stderr, "unknown command") { + t.Errorf("`abcd %s` is not refused as an unknown command:\n%s", old, stderr) + } + if !strings.Contains(stderr, "abcd library") { + t.Errorf("the refusal does not name the library verb 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) + } + } + }) + } +} + +// libraryJSON is the bare --json envelope: each half present, or a note saying +// why it could not be read. +type libraryJSON struct { + Corpus json.RawMessage `json:"corpus"` + CorpusNote string `json:"corpus_note"` + Memory json.RawMessage `json:"memory"` + MemoryNote string `json:"memory_note"` +} + +// TestBareLibraryRendersBothHalves is decision 8: bare library renders the +// corpus and the checkout's memory store, an unreadable half is one line naming +// what would create it, and the verb refuses only when neither half reads. Bare +// source exited 3 with no corpus; bare library inside a checkout renders. +func TestBareLibraryRendersBothHalves(t *testing.T) { + t.Run("no corpus inside a checkout renders the memory half and a corpus line", func(t *testing.T) { + home, _ := sourceCheckout(t) + code, stdout, stderr := runLibrary(t) + if code != 0 { + t.Fatalf("bare library with no corpus inside a checkout exited %d, want 0\n%s%s", code, stdout, stderr) + } + if !strings.Contains(stdout, "memory") || !strings.Contains(stdout, "page") { + t.Errorf("the memory half is not rendered:\n%s", stdout) + } + if !strings.Contains(stdout, "no sources corpus") || !strings.Contains(stdout, "abcd library init") { + t.Errorf("the corpus half is not one line naming `abcd library init`:\n%s", stdout) + } + noHomePath(t, home, stdout+stderr) + + code, stdout, stderr = runLibrary(t, "--json") + if code != 0 { + t.Fatalf("bare library --json exited %d\n%s%s", code, stdout, stderr) + } + var env libraryJSON + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + t.Fatalf("not JSON: %v\n%s", err, stdout) + } + if string(env.Corpus) != "null" || !strings.Contains(env.CorpusNote, "abcd library init") { + t.Errorf("corpus = %s, corpus_note = %q; want null and a note naming `abcd library init`", env.Corpus, env.CorpusNote) + } + if len(env.Memory) == 0 || string(env.Memory) == "null" || env.MemoryNote != "" { + t.Errorf("memory = %s, memory_note = %q; want the store's status and no note", env.Memory, env.MemoryNote) + } + }) + + t.Run("a corpus outside a checkout renders the corpus half and a memory line", func(t *testing.T) { + _, repo := sourceCheckout(t) + if code, out, errb := runLibrary(t, "init"); code != 0 { + t.Fatalf("library init: exit %d\n%s%s", code, out, errb) + } + t.Chdir(filepath.Dir(repo)) // the temp HOME: no checkout above it + code, stdout, stderr := runLibrary(t) + if code != 0 { + t.Fatalf("bare library with a corpus outside a checkout exited %d, want 0\n%s%s", code, stdout, stderr) + } + if !strings.Contains(stdout, "corpus") || !strings.Contains(stdout, "0 confidential, 0 public") { + t.Errorf("the corpus half is not rendered:\n%s", stdout) + } + if !strings.Contains(stdout, "memory") || !strings.Contains(stdout, "checkout") { + t.Errorf("the memory half is not one line naming the checkout it needs:\n%s", stdout) + } + }) + + t.Run("neither half refuses", func(t *testing.T) { + _, repo := sourceCheckout(t) + t.Chdir(filepath.Dir(repo)) + code, stdout, stderr := runLibrary(t) + if code == 0 { + t.Fatalf("bare library with neither half readable exited 0:\n%s%s", stdout, stderr) + } + if stdout != "" { + t.Errorf("a refusal wrote to stdout: %q", stdout) + } + for _, want := range []string{"no sources corpus", "abcd library init", "repository"} { + if !strings.Contains(stderr, want) { + t.Errorf("the refusal does not name %q:\n%s", want, stderr) + } + } + }) +} diff --git a/internal/surface/cli/memory_drift_test.go b/internal/surface/cli/memory_drift_test.go index 9e5fa28c7..6d38d8c53 100644 --- a/internal/surface/cli/memory_drift_test.go +++ b/internal/surface/cli/memory_drift_test.go @@ -22,12 +22,12 @@ func TestMemoryBoardPrintsItsDriftOnTheTextRender(t *testing.T) { if len(st.Drift) == 0 { t.Fatalf("fixture drift: a store with no index.md reports no drift in --json: %+v", st) } - text := string(runCLI(t, "memory")) + text := string(runCLI(t, "library")) for _, line := range st.Drift { if !strings.Contains(text, line) { t.Errorf("the text board omits the drift line the JSON carries, %q:\n%s", line, text) } - if !strings.Contains(line, "abcd memory ingest") { + if !strings.Contains(line, "abcd library ingest") { t.Errorf("drift line %q does not name the verb that heals it", line) } } @@ -40,15 +40,15 @@ func TestMemoryBoardPrintsItsDriftOnTheTextRender(t *testing.T) { func TestMemoryBoardIsQuietAboutDriftWhenTheStoreIsCurrent(t *testing.T) { repo, _ := memoryStoreFixture(t) src, pages := ingestOperands(t, repo) - if out, err := runCLIErr(t, "memory", "ingest", src, "--pages-json", pages); err != nil { - t.Fatalf("memory ingest: %v\n%s", err, out) + if out, err := runCLIErr(t, "library", "ingest", src, "--pages-json", pages); err != nil { + t.Fatalf("library ingest: %v\n%s", err, out) } st := memoryBoard(t) if len(st.Drift) != 0 { t.Fatalf("a store an ingest just re-rendered reports drift in --json: %q", st.Drift) } - if text := string(runCLI(t, "memory")); strings.Contains(text, "stale") { + if text := string(runCLI(t, "library")); strings.Contains(text, "stale") { t.Errorf("a current store's text board reports staleness:\n%s", text) } } diff --git a/internal/surface/cli/memory_ingest_licence_termsafe_test.go b/internal/surface/cli/memory_ingest_licence_termsafe_test.go index e9de2b6fd..2d3c3f798 100644 --- a/internal/surface/cli/memory_ingest_licence_termsafe_test.go +++ b/internal/surface/cli/memory_ingest_licence_termsafe_test.go @@ -46,7 +46,7 @@ func TestMemoryIngestSanitizesLicence(t *testing.T) { } var stdout, stderr bytes.Buffer - code := Run([]string{"memory", "ingest", src, "--pages-json", pages}, &stdout, &stderr) + code := Run([]string{"library", "ingest", src, "--pages-json", pages}, &stdout, &stderr) if code != 0 { t.Fatalf("expected a clean ingest\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) } diff --git a/internal/surface/cli/memory_ingest_surface_test.go b/internal/surface/cli/memory_ingest_surface_test.go index 1794d3a43..86fe58961 100644 --- a/internal/surface/cli/memory_ingest_surface_test.go +++ b/internal/surface/cli/memory_ingest_surface_test.go @@ -10,7 +10,7 @@ import ( // TestMemoryIngestKeepOriginalPartialFailure proves the iss-30 fix is wired to // the CLI surface: when --keep-original fails after the pages are durably -// written, `abcd memory ingest` reports the successful ingest with a warning, +// written, `abcd library ingest` reports the successful ingest with a warning, // exits non-zero, and leaks no absolute path. The failure is forced by making // the kept-originals directory a regular file. func TestMemoryIngestKeepOriginalPartialFailure(t *testing.T) { @@ -37,7 +37,7 @@ func TestMemoryIngestKeepOriginalPartialFailure(t *testing.T) { } var stdout, stderr bytes.Buffer - code := Run([]string{"memory", "ingest", src, "--keep-original", "--pages-json", pages}, &stdout, &stderr) + code := Run([]string{"library", "ingest", src, "--keep-original", "--pages-json", pages}, &stdout, &stderr) if code == 0 { t.Fatalf("expected a non-zero exit when --keep-original fails\nstdout: %s\nstderr: %s", stdout.String(), stderr.String()) } diff --git a/internal/surface/cli/memory_root_test.go b/internal/surface/cli/memory_root_test.go index caf1ec958..85b79f4c9 100644 --- a/internal/surface/cli/memory_root_test.go +++ b/internal/surface/cli/memory_root_test.go @@ -59,15 +59,20 @@ func writeMemoryPage(t *testing.T, root, name string) { } } -// memoryBoard runs bare `memory --json` and returns the rendered status. +// memoryBoard runs bare `library --json` and returns its memory half. func memoryBoard(t *testing.T) memory.BareStatus { t.Helper() - var st memory.BareStatus - out := runCLI(t, "memory", "--json") - if err := json.Unmarshal(out, &st); err != nil { - t.Fatalf("memory --json: not JSON: %v\n%s", err, out) + var env struct { + Memory *memory.BareStatus `json:"memory"` } - return st + out := runCLI(t, "library", "--json") + if err := json.Unmarshal(out, &env); err != nil { + t.Fatalf("library --json: not JSON: %v\n%s", err, out) + } + if env.Memory == nil { + t.Fatalf("library --json carries no memory half:\n%s", out) + } + return *env.Memory } // ingestOperands writes a source file and the distiller payload `memory ingest` @@ -103,10 +108,10 @@ func TestMemoryFromSubdirectoryReportsTheCheckoutStore(t *testing.T) { t.Chdir(sub) st := memoryBoard(t) if !st.StorePresent { - t.Errorf("`abcd memory` from a subdirectory reports the store absent; the checkout's store is right there") + t.Errorf("`abcd library` from a subdirectory reports the store absent; the checkout's store is right there") } if st.Pages != 1 { - t.Errorf("`abcd memory` from a subdirectory reports %d page(s), want 1 — it addressed a store that is not the checkout's", st.Pages) + t.Errorf("`abcd library` from a subdirectory reports %d page(s), want 1 — it addressed a store that is not the checkout's", st.Pages) } } @@ -119,9 +124,9 @@ func TestMemoryIngestFromSubdirectoryWritesIntoTheCheckoutStore(t *testing.T) { src, pages := ingestOperands(t, repo) t.Chdir(sub) - out, err := runCLIErr(t, "memory", "ingest", src, "--pages-json", pages) + out, err := runCLIErr(t, "library", "ingest", src, "--pages-json", pages) if err != nil { - t.Fatalf("`abcd memory ingest` from a subdirectory: %v\n%s", err, out) + t.Fatalf("`abcd library ingest` from a subdirectory: %v\n%s", err, out) } if _, statErr := os.Stat(filepath.Join(repo, filepath.FromSlash(memory.RelDir), "topic_auth_tokens.md")); statErr != nil { t.Errorf("the ingested page is not in the checkout's memory store: %v", statErr) @@ -139,7 +144,7 @@ func TestMemoryAskFileBackFromSubdirectoryWritesIntoTheCheckoutStore(t *testing. // A filed-back answer cites the pages it was synthesised from, so the store // is seeded through a real ingest at the root rather than by hand. src, pages := ingestOperands(t, repo) - if out, err := runCLIErr(t, "memory", "ingest", src, "--pages-json", pages); err != nil { + if out, err := runCLIErr(t, "library", "ingest", src, "--pages-json", pages); err != nil { t.Fatalf("seeding ingest: %v\n%s", err, out) } page := filepath.Join(repo, "answer.json") @@ -149,9 +154,9 @@ func TestMemoryAskFileBackFromSubdirectoryWritesIntoTheCheckoutStore(t *testing. } t.Chdir(sub) - out, err := runCLIErr(t, "memory", "ask", "what does the auth rotation page say", "--file-back", "--page-json", page) + out, err := runCLIErr(t, "library", "ask", "what does the auth rotation page say", "--file-back", "--page-json", page) if err != nil { - t.Fatalf("`abcd memory ask --file-back` from a subdirectory: %v\n%s", err, out) + t.Fatalf("`abcd library ask --file-back` from a subdirectory: %v\n%s", err, out) } if _, statErr := os.Stat(filepath.Join(repo, filepath.FromSlash(memory.RelDir), "topic_auth_answer.md")); statErr != nil { t.Errorf("the filed-back page is not in the checkout's memory store: %v", statErr) @@ -171,12 +176,12 @@ func TestMemoryLintFromSubdirectoryReadsAndReportsInsideTheCheckout(t *testing.T StorePath string `json:"store_path"` ReportDir string `json:"report_dir"` } - out, err := runCLIErr(t, "memory", "lint", "--json") + out, err := runCLIErr(t, "library", "lint", "--json") if err != nil { - t.Fatalf("`abcd memory lint` from a subdirectory: %v\n%s", err, out) + t.Fatalf("`abcd library lint` from a subdirectory: %v\n%s", err, out) } if jerr := json.Unmarshal(out, &res); jerr != nil { - t.Fatalf("memory lint --json: not JSON: %v\n%s", jerr, out) + t.Fatalf("library lint --json: not JSON: %v\n%s", jerr, out) } // Both are reported relative to the checkout (iss-81), so the run log is // found by joining report_dir onto the checkout's root: a lint rooted at @@ -205,9 +210,9 @@ func TestMemoryOutsideAnyRepositoryRefuses(t *testing.T) { src, pages := ingestOperands(t, plain) for _, args := range [][]string{ - {"memory"}, - {"memory", "ingest", src, "--pages-json", pages}, - {"memory", "lint"}, + {"library"}, + {"library", "ingest", src, "--pages-json", pages}, + {"library", "lint"}, } { out, err := runCLIErr(t, args...) if err == nil { @@ -240,7 +245,7 @@ func TestMemoryRefusesARepoShapedTreeGitWillNotAnswerFor(t *testing.T) { } t.Chdir(planted) - out, err := runCLIErr(t, "memory") + out, err := runCLIErr(t, "library") if err == nil { t.Fatalf("a repo-shaped tree git will not answer for was accepted as a root:\n%s", out) } @@ -262,10 +267,10 @@ func TestMemoryFromTheCheckoutRootIsUnchanged(t *testing.T) { st := memoryBoard(t) if !st.StorePresent { - t.Fatalf("`abcd memory` at the checkout root reports the store absent") + t.Fatalf("`abcd library` at the checkout root reports the store absent") } if st.Pages != 2 { - t.Fatalf("`abcd memory` at the checkout root reports %d page(s), want 2", st.Pages) + t.Fatalf("`abcd library` at the checkout root reports %d page(s), want 2", st.Pages) } } @@ -280,7 +285,7 @@ func TestMemoryReportsAStrayMemoryStoreBelowTheCheckoutRoot(t *testing.T) { orphan := filepath.Join(sub, filepath.FromSlash(memory.RelDir), "topic_auth_orphan.md") t.Chdir(sub) - out, err := runCLIErr(t, "memory") + out, err := runCLIErr(t, "library") if err != nil { t.Fatalf("memory: %v\n%s", err, out) } diff --git a/internal/surface/cli/source.go b/internal/surface/cli/source.go index 0c69da9cb..7b4d36bf7 100644 --- a/internal/surface/cli/source.go +++ b/internal/surface/cli/source.go @@ -1,9 +1,11 @@ package cli -// source.go is the front door onto internal/core/source — the personal sources -// corpus and its provenance ledger (itd-76, spc-31). +// source.go is the `library` front door (itd-2610090831227812): onto +// internal/core/source — the personal sources corpus and its provenance ledger +// (itd-76, spc-31) — and, through addMemorySubcommands in cli.go, onto the +// checkout's memory store. The two stores stay unconnected under the one verb. // -// Exit codes: 0 done; 1 cite-check found a confidential source in the text; 2 a +// Exit codes for the corpus verbs: 0 done; 1 cite-check found a confidential source in the text; 2 a // refusal (a bad operand, a failed gate, a corpus that disagrees with itself); // 3 there is no corpus at the configured location — the distinct no-corpus code // every verb but `init` returns, so a script can tell "nothing to check against" @@ -25,6 +27,7 @@ import ( "github.com/intentdriven/abcd/internal/abcdhome" "github.com/intentdriven/abcd/internal/core/banlist" + "github.com/intentdriven/abcd/internal/core/memory" "github.com/intentdriven/abcd/internal/core/source" "github.com/intentdriven/abcd/internal/fsutil" "github.com/intentdriven/abcd/internal/gitutil" @@ -39,88 +42,141 @@ const maxScanBytes = 64 << 20 func sourceCorpusDir(flag string) (string, error) { if flag != "" { if !strings.HasPrefix(flag, "/") { - return "", &exitError{Code: 2, Msg: "abcd source: --corpus must be an absolute path"} + return "", &exitError{Code: 2, Msg: "abcd library: --corpus must be an absolute path"} } return flag, nil } dir, err := source.DefaultDir() if err != nil { - return "", &exitError{Code: 2, Msg: "abcd source: " + err.Error()} + return "", &exitError{Code: 2, Msg: "abcd library: " + err.Error()} } return dir, nil } // sourceError maps a core error to the verb's exit code and one line. The // corpus directory keeps RedactHome, not fsutil.DisplayPath: it is the store the -// person names with --corpus or creates with `source init`, and a base name +// person names with --corpus or creates with `library init`, and a base name // would not say where to create or find it. func sourceError(verb, dir string, err error) error { if errors.Is(err, source.ErrNoCorpus) { - return &exitError{Code: 3, Msg: fmt.Sprintf("abcd source %s: no sources corpus at %s — nothing read or written (create one with `abcd source init`)", + return &exitError{Code: 3, Msg: fmt.Sprintf("abcd library %s: no sources corpus at %s — nothing read or written (create one with `abcd library init`)", verb, fsutil.RedactHome(dir))} } - return &exitError{Code: 2, Msg: "abcd source " + verb + ": " + termsafe.Sanitize(scrubPaths(err))} + return &exitError{Code: 2, Msg: "abcd library " + verb + ": " + termsafe.Sanitize(scrubPaths(err))} } -// newSourceCommand builds the `source` verb. -func newSourceCommand(asJSON *bool) *cobra.Command { +// libraryStatus is bare `library`'s envelope: each half, or the one line saying +// why it could not be read and what would create it (spc-2610100613109045, +// decision 8). A half that reads has no note; a half that does not is null. +type libraryStatus struct { + Corpus *source.StatusReport `json:"corpus"` + CorpusNote string `json:"corpus_note,omitempty"` + Memory *memory.BareStatus `json:"memory"` + MemoryNote string `json:"memory_note,omitempty"` +} + +// newLibraryCommand builds the `library` verb: the user-level sources corpus and +// the checkout's memory store, one person's command over two stores that stay +// unconnected (itd-2610090831227812, P4). Every sub-verb keeps the name it had +// under `source` or `memory` (decision 7). +func newLibraryCommand(asJSON *bool) *cobra.Command { var corpusFlag string cmd := &cobra.Command{ - Use: "source", - Long: "The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one\n" + + Use: "library", + Long: "Two stores under one verb, never connected to each other.\n\n" + + "The personal sources corpus: documents you may consult, a CSL-JSON bibliography, and one\n" + "append-only influence ledger per repository, in a local-only git repository with no\n" + "remote (" + abcdhome.Display("sources") + " by default; --corpus names another). The folder a source sits\n" + - "in — confidential// or public// — is its classification.\n\n" + + "in — confidential// or public// — is its classification. Its verbs are init,\n" + + "add, declassify, ledger, sync-banlist and cite-check.\n\n" + "Consult freely, cite deliberately: confidential entries are projected into this\n" + "repository's untracked private banlist (sync-banlist), which the committed pre-commit\n" + "guard refreshes and enforces; cite-check clears text before it leaves the machine; and\n" + "a ledger line becomes a public citation only when the source permits it AND a person\n" + - "flips the line (adr-41). No output names a confidential source except by key.\n\n" + - "Bare `abcd source` is read-only. Exit 3 when there is no corpus, on every verb but init.", + "flips the line (adr-41). No output names a confidential source except by key. Every\n" + + "corpus verb but init exits 3 when there is no corpus.\n\n" + + "The checkout's memory store (.abcd/memory/): cited pages distilled from a source with\n" + + "ingest, queried with ask, and health-checked with lint. Its verbs refuse outside a\n" + + "checkout.\n\n" + + "Bare `abcd library` is read-only and renders both: a store that cannot be read is one\n" + + "line naming what would create it, and the verb refuses only when neither can be read.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { dir, err := sourceCorpusDir(corpusFlag) if err != nil { return err } - st, err := source.Status(dir) - if err != nil { - return sourceError("status", dir, err) + var st libraryStatus + // The corpus half. A corpus that is absent or that disagrees with + // itself is one line; the reason is the core's, already free of a + // confidential string. + switch cs, cerr := source.Status(dir); { + case cerr == nil && cs.Present: + cs.Dir = fsutil.RedactHome(cs.Dir) + st.Corpus = &cs + case cerr == nil || errors.Is(cerr, source.ErrNoCorpus): + st.CorpusNote = fmt.Sprintf("no sources corpus at %s — create one with `abcd library init`", fsutil.RedactHome(dir)) + default: + st.CorpusNote = "the sources corpus cannot be read: " + termsafe.Sanitize(scrubPaths(cerr)) } - if !st.Present { - return sourceError("status", dir, source.ErrNoCorpus) + // The memory half. Outside a checkout there is no store to read. + if repoRoot, merr := memoryStoreRoot(cmd); merr != nil { + st.MemoryNote = "no memory store here: " + strings.TrimPrefix(merr.Error(), "abcd library: ") + } else if ms, merr := memory.Bare(repoRoot); merr != nil { + st.MemoryNote = "the memory store cannot be read: " + termsafe.Sanitize(scrubPaths(merr)) + } else { + st.Memory = &ms } - st.Dir = fsutil.RedactHome(st.Dir) - return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { renderSourceStatus(w, st) }) + if st.Corpus == nil && st.Memory == nil { + return &exitError{Code: 2, Msg: "abcd library: neither store can be read — " + st.CorpusNote + "; " + st.MemoryNote} + } + return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { renderLibraryStatus(w, st) }) }, } - cmd.PersistentFlags().StringVar(&corpusFlag, "corpus", "", "the corpus directory (absolute; default "+abcdhome.Display("sources")+")") + cmd.PersistentFlags().StringVar(&corpusFlag, "corpus", "", "the sources corpus directory (absolute; default "+abcdhome.Display("sources")+")") cmd.AddCommand(newSourceInitCommand(asJSON, &corpusFlag)) cmd.AddCommand(newSourceAddCommand(asJSON, &corpusFlag)) cmd.AddCommand(newSourceDeclassifyCommand(asJSON, &corpusFlag)) cmd.AddCommand(newSourceLedgerCommand(asJSON, &corpusFlag)) cmd.AddCommand(newSourceSyncBanlistCommand(asJSON, &corpusFlag)) cmd.AddCommand(newSourceCiteCheckCommand(asJSON, &corpusFlag)) + addMemorySubcommands(cmd, asJSON) return cmd } +// renderLibraryStatus is bare `library`'s text: the corpus half, then the +// memory half, each either rendered or one line. +func renderLibraryStatus(w io.Writer, st libraryStatus) { + fmt.Fprintln(w, "abcd library") + if st.Corpus != nil { + renderSourceStatus(w, *st.Corpus) + } else { + fmt.Fprintf(w, " corpus: %s\n", st.CorpusNote) + } + if st.Memory != nil { + renderMemoryStatus(w, *st.Memory) + } else { + fmt.Fprintf(w, " memory: %s\n", st.MemoryNote) + } +} + func renderSourceStatus(w io.Writer, st source.StatusReport) { - fmt.Fprintf(w, "abcd source — corpus at %s\n", st.Dir) - fmt.Fprintf(w, " sources: %d confidential, %d public\n", st.Confidential, st.Public) + fmt.Fprintf(w, " corpus: %s\n", st.Dir) + fmt.Fprintf(w, " sources: %d confidential, %d public\n", st.Confidential, st.Public) if len(st.Ledgers) == 0 { - fmt.Fprintln(w, " ledgers: none") + fmt.Fprintln(w, " ledgers: none") } for _, l := range st.Ledgers { - fmt.Fprintf(w, " ledger: %s (%d line%s)\n", l.Repo, l.Lines, pluralS(l.Lines)) + fmt.Fprintf(w, " ledger: %s (%d line%s)\n", l.Repo, l.Lines, pluralS(l.Lines)) } if len(st.Problems) == 0 { - fmt.Fprintln(w, " problems: none") + fmt.Fprintln(w, " problems: none") } for _, p := range st.Problems { - fmt.Fprintf(w, " problem: %s — %s\n", termsafe.Sanitize(p.Key), termsafe.Sanitize(p.Reason)) + fmt.Fprintf(w, " problem: %s — %s\n", termsafe.Sanitize(p.Key), termsafe.Sanitize(p.Reason)) } if st.Remotes > 0 { - fmt.Fprintf(w, " WARNING: the corpus repository has %d remote(s); documents and ledgers never leave this machine (adr-41) — remove it\n", st.Remotes) + fmt.Fprintf(w, " WARNING: the corpus repository has %d remote(s); documents and ledgers never leave this machine (adr-41) — remove it\n", st.Remotes) } } @@ -142,7 +198,7 @@ func newSourceInitCommand(asJSON *bool, corpusFlag *string) *cobra.Command { } st.Dir = fsutil.RedactHome(st.Dir) return render(cmd.OutOrStdout(), *asJSON, st, func(w io.Writer) { - fmt.Fprintf(w, "abcd source init — corpus created at %s (a git repository with no remote)\n", st.Dir) + fmt.Fprintf(w, "abcd library init — corpus created at %s (a git repository with no remote)\n", st.Dir) }) }, } @@ -193,7 +249,7 @@ func newSourceAddCommand(asJSON *bool, corpusFlag *string) *cobra.Command { class := "" switch { case confidential && public: - return &exitError{Code: 2, Msg: "abcd source add: --confidential and --public are exclusive"} + return &exitError{Code: 2, Msg: "abcd library add: --confidential and --public are exclusive"} case confidential: class = source.ClassConfidential case public: @@ -210,7 +266,7 @@ func newSourceAddCommand(asJSON *bool, corpusFlag *string) *cobra.Command { if meta != "" { m, err := readSourceMeta(cmd, meta) if err != nil { - return &exitError{Code: 2, Msg: "abcd source add: " + err.Error()} + return &exitError{Code: 2, Msg: "abcd library add: " + err.Error()} } if m.Title != "" { req.Title = m.Title @@ -224,12 +280,12 @@ func newSourceAddCommand(asJSON *bool, corpusFlag *string) *cobra.Command { return sourceError("add", dir, err) } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd source add — %s registered %s (%s), committed in the corpus\n", res.Key, res.Class, res.Permission) + fmt.Fprintf(w, "abcd library add — %s registered %s (%s), committed in the corpus\n", res.Key, res.Class, res.Permission) for _, f := range res.Files { fmt.Fprintf(w, " %s\n", f) } if res.Class == source.ClassConfidential { - fmt.Fprintln(w, " next: abcd source sync-banlist, in every repository you work in") + fmt.Fprintln(w, " next: abcd library sync-banlist, in every repository you work in") } }) }, @@ -306,8 +362,8 @@ func newSourceDeclassifyCommand(asJSON *bool, corpusFlag *string) *cobra.Command return sourceError("declassify", dir, err) } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd source declassify — %s moved to %s (%s), committed in the corpus\n", res.Key, res.Folder, res.Permission) - fmt.Fprintln(w, " next: abcd source sync-banlist, in every repository you work in") + fmt.Fprintf(w, "abcd library declassify — %s moved to %s (%s), committed in the corpus\n", res.Key, res.Folder, res.Permission) + fmt.Fprintln(w, " next: abcd library sync-banlist, in every repository you work in") }) }, } @@ -328,11 +384,11 @@ func ledgerRepo(flag string) (string, error) { } root, err := gitutil.CheckoutRoot(cwd, "the ledger's repository") if err != nil { - return "", &exitError{Code: 2, Msg: "abcd source ledger: " + err.Error() + " (or name the ledger with --repo)"} + return "", &exitError{Code: 2, Msg: "abcd library ledger: " + err.Error() + " (or name the ledger with --repo)"} } sha := gitutil.RootCommit(root) if !gitutil.IsFullSHA(sha) { - return "", &exitError{Code: 2, Msg: "abcd source ledger: this checkout has no commit to name its ledger by; name it with --repo"} + return "", &exitError{Code: 2, Msg: "abcd library ledger: this checkout has no commit to name its ledger by; name it with --repo"} } return sha[:12], nil } @@ -375,7 +431,7 @@ func newSourceLedgerCommand(asJSON *bool, corpusFlag *string) *cobra.Command { lines = []source.AppendResult{} } return render(cmd.OutOrStdout(), *asJSON, lines, func(w io.Writer) { - fmt.Fprintf(w, "abcd source ledger — %s, %d line%s\n", r, len(lines), pluralS(len(lines))) + fmt.Fprintf(w, "abcd library ledger — %s, %d line%s\n", r, len(lines), pluralS(len(lines))) for _, l := range lines { mark := "" switch { @@ -394,7 +450,7 @@ func newSourceLedgerCommand(asJSON *bool, corpusFlag *string) *cobra.Command { return sourceError("ledger", dir, err) } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd source ledger — line %d cites line %d publicly (%s), committed in the corpus\n", res.Line, flip, res.Record.SourceKey) + fmt.Fprintf(w, "abcd library ledger — line %d cites line %d publicly (%s), committed in the corpus\n", res.Line, flip, res.Record.SourceKey) }) } res, err := source.Append(source.AppendRequest{Corpus: dir, Repo: r, DecisionRef: decision, Claim: claim, @@ -403,9 +459,9 @@ func newSourceLedgerCommand(asJSON *bool, corpusFlag *string) *cobra.Command { return sourceError("ledger", dir, err) } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { - fmt.Fprintf(w, "abcd source ledger — line %d: %s → %s (%s), committed in the corpus\n", + fmt.Fprintf(w, "abcd library ledger — line %d: %s → %s (%s), committed in the corpus\n", res.Line, res.Record.SourceKey, termsafe.Sanitize(res.Record.DecisionRef), res.Record.Influence) - fmt.Fprintln(w, " cited_publicly: false — citing it is the person's call (abcd source ledger --flip)") + fmt.Fprintln(w, " cited_publicly: false — citing it is the person's call (abcd library ledger --flip)") }) }, } @@ -449,21 +505,21 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman } root, err := gitutil.CheckoutRoot(cwd, "the private banlist") if err != nil { - return &exitError{Code: 2, Msg: "abcd source sync-banlist: " + err.Error() + " (nothing written)"} + return &exitError{Code: 2, Msg: "abcd library sync-banlist: " + err.Error() + " (nothing written)"} } res, err := source.SyncBanlist(dir, root, source.SyncOptions{Refresh: refresh}) if refresh && errors.Is(err, source.ErrNoCorpus) { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: no sources corpus at %s — generated banlist block not refreshed (skipped)\n", + fmt.Fprintf(cmd.ErrOrStderr(), "abcd library sync-banlist: no sources corpus at %s — generated banlist block not refreshed (skipped)\n", fsutil.RedactHome(dir)) return nil } if refresh && errors.Is(err, banlist.ErrNoStore) { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: no private store at %s — the refresh never creates one; run `abcd source sync-banlist` to opt this repository in (skipped)\n", + fmt.Fprintf(cmd.ErrOrStderr(), "abcd library sync-banlist: no private store at %s — the refresh never creates one; run `abcd library sync-banlist` to opt this repository in (skipped)\n", banlist.PrivateRelPath) return nil } if refresh && errors.Is(err, banlist.ErrLegacyStore) { - fmt.Fprintf(cmd.ErrOrStderr(), "abcd source sync-banlist: %s predates the keyed format — migrate it once with `abcd banlist migrate` (not refreshed)\n", + fmt.Fprintf(cmd.ErrOrStderr(), "abcd library sync-banlist: %s predates the keyed format — migrate it once with `abcd banlist migrate` (not refreshed)\n", banlist.PrivateRelPath) return nil } @@ -478,7 +534,7 @@ func newSourceSyncBanlistCommand(asJSON *bool, corpusFlag *string) *cobra.Comman case res.Block.Changed: state = "rewrote the block" } - fmt.Fprintf(w, "abcd source sync-banlist — %d confidential source%s, %d pattern%s in %s (%s)\n", + fmt.Fprintf(w, "abcd library sync-banlist — %d confidential source%s, %d pattern%s in %s (%s)\n", res.Sources, pluralS(res.Sources), res.Block.Entries, pluralS(res.Block.Entries), res.Block.Path, state) }) }, @@ -512,7 +568,7 @@ func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command text, err = fsutil.ReadGuarded(args[0], maxScanBytes) } if err != nil { - return &exitError{Code: 2, Msg: "abcd source cite-check: the text cannot be read (absent, oversize, or not a regular file)"} + return &exitError{Code: 2, Msg: "abcd library cite-check: the text cannot be read (absent, oversize, or not a regular file)"} } rep, err := source.CiteCheck(dir, text) if err != nil { @@ -520,10 +576,10 @@ func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command } if rerr := render(cmd.OutOrStdout(), *asJSON, rep, func(w io.Writer) { if rep.Clean() { - fmt.Fprintf(w, "abcd source cite-check — clean against %d confidential source%s\n", rep.Sources, pluralS(rep.Sources)) + fmt.Fprintf(w, "abcd library cite-check — clean against %d confidential source%s\n", rep.Sources, pluralS(rep.Sources)) return } - fmt.Fprintf(w, "abcd source cite-check — %d finding%s (the matched text is withheld)\n", len(rep.Findings), pluralS(len(rep.Findings))) + fmt.Fprintf(w, "abcd library cite-check — %d finding%s (the matched text is withheld)\n", len(rep.Findings), pluralS(len(rep.Findings))) for _, f := range rep.Findings { fmt.Fprintf(w, " %s %s line %d, at byte %d of the text\n", f.Source, f.Field, f.Line, f.Offset) } @@ -538,18 +594,24 @@ func newSourceCiteCheckCommand(asJSON *bool, corpusFlag *string) *cobra.Command } } +// memorySubVerbs are library's sub-verbs over the memory store, which handles no +// confidential corpus string, so their flag errors keep cobra's wording. +var memorySubVerbs = map[string]bool{"ingest": true, "ask": true, "lint": true} + // applySourceFlagErrors withholds the offending token from a flag-parse failure on -// the source verbs, as the banlist verbs do: an argument read as a flag may be a -// confidential title or alias, and cobra's own message quotes it. It runs after the -// tree-wide usage tagging, which would otherwise replace it. +// bare library and its corpus verbs, as the banlist verbs do: an argument read as a +// flag may be a confidential title or alias, and cobra's own message quotes it. It +// runs after the tree-wide usage tagging, which would otherwise replace it. func applySourceFlagErrors(root *cobra.Command) { for _, cmd := range root.Commands() { - if cmd.Name() != "source" { + if cmd.Name() != "library" { continue } cmd.SetFlagErrorFunc(sourceFlagError) for _, sub := range cmd.Commands() { - sub.SetFlagErrorFunc(sourceFlagError) + if !memorySubVerbs[sub.Name()] { + sub.SetFlagErrorFunc(sourceFlagError) + } } } } diff --git a/internal/surface/cli/source_surface_test.go b/internal/surface/cli/source_surface_test.go index 0ccd89315..22abc88c8 100644 --- a/internal/surface/cli/source_surface_test.go +++ b/internal/surface/cli/source_surface_test.go @@ -31,27 +31,28 @@ func sourceCheckout(t *testing.T) (home, repo string) { return home, repo } -func runSource(t *testing.T, args ...string) (code int, stdout, stderr string) { +func runLibrary(t *testing.T, args ...string) (code int, stdout, stderr string) { t.Helper() var out, errb bytes.Buffer - code = Run(append([]string{"source"}, args...), &out, &errb) + code = Run(append([]string{"library"}, args...), &out, &errb) return code, out.String(), errb.String() } // TestSourceNoCorpusSaysSoOnEveryVerb is AC6 at the front door: with no corpus, -// every verb says so on one line and exits with the distinct no-corpus code, and -// the guard's refresh mode says so and exits 0. Nothing creates the corpus. +// every corpus verb says so on one line and exits with the distinct no-corpus +// code, and the guard's refresh mode says so and exits 0. Nothing creates the +// corpus. Bare library renders the memory store beside a line about the corpus +// instead (TestBareLibraryRendersBothHalves). func TestSourceNoCorpusSaysSoOnEveryVerb(t *testing.T) { home, _ := sourceCheckout(t) for _, args := range [][]string{ - {}, {"ledger", "--list"}, {"sync-banlist"}, {"cite-check", "-"}, {"declassify", "conf2026a"}, {"add", "--key", "k2026x", "--title", "Some Title", "--public", "--url", "https://example.com/x"}, } { - code, stdout, stderr := runSource(t, args...) + code, stdout, stderr := runLibrary(t, args...) if code != 3 { t.Errorf("%v: exit %d, want 3\n%s%s", args, code, stdout, stderr) } @@ -60,7 +61,7 @@ func TestSourceNoCorpusSaysSoOnEveryVerb(t *testing.T) { } noHomePath(t, home, stdout+stderr) } - code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + code, stdout, stderr := runLibrary(t, "sync-banlist", "--refresh") if code != 0 || strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "no sources corpus") { t.Fatalf("refresh with no corpus: exit %d\n%s%s", code, stdout, stderr) } @@ -84,24 +85,24 @@ func TestSourceEndToEnd(t *testing.T) { t.Fatal(err) } - if code, out, errs := runSource(t, "init"); code != 0 { + if code, out, errs := runLibrary(t, "init"); code != 0 { t.Fatalf("init: %d %s%s", code, out, errs) } - code, out, errs := runSource(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential", "--json") + code, out, errs := runLibrary(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential", "--json") if code != 0 { t.Fatalf("add confidential: %d %s%s", code, out, errs) } if strings.Contains(out+errs, "Harbour") { t.Fatalf("add echoes the confidential title:\n%s%s", out, errs) } - if code, out, errs := runSource(t, "add", "--key", "survey2026", "--title", "An Open Survey", "--public", "--url", "https://example.com/survey"); code != 0 { + if code, out, errs := runLibrary(t, "add", "--key", "survey2026", "--title", "An Open Survey", "--public", "--url", "https://example.com/survey"); code != 0 { t.Fatalf("add public: %d %s%s", code, out, errs) } - if code, _, errs := runSource(t, "add", notes, "--key", "x2026", "--title", "No Class Given"); code != 2 || !strings.Contains(errs, "class") { + if code, _, errs := runLibrary(t, "add", notes, "--key", "x2026", "--title", "No Class Given"); code != 2 || !strings.Contains(errs, "class") { t.Fatalf("add with no class: %d %s", code, errs) } - code, out, errs = runSource(t, "ledger", "--decision", "adr-41", "--claim", "append-only", "--source", "conf2026a", "--influence", "supports", "--json") + code, out, errs = runLibrary(t, "ledger", "--decision", "adr-41", "--claim", "append-only", "--source", "conf2026a", "--influence", "supports", "--json") if code != 0 { t.Fatalf("ledger: %d %s%s", code, out, errs) } @@ -115,11 +116,11 @@ func TestSourceEndToEnd(t *testing.T) { if err := json.Unmarshal([]byte(out), &line); err != nil || line.Line != 1 || line.Record.CitedPublicly || len(line.Record.Repo) != 12 { t.Fatalf("ledger output: %v %s", err, out) } - if code, out, errs := runSource(t, "ledger", "--flip", "1"); code != 2 || !strings.Contains(out+errs, "gate 1") { + if code, out, errs := runLibrary(t, "ledger", "--flip", "1"); code != 2 || !strings.Contains(out+errs, "gate 1") { t.Fatalf("flip of an unpermitted line: %d %s%s", code, out, errs) } - if code, out, errs := runSource(t, "sync-banlist"); code != 0 || !strings.Contains(out, "1 confidential source") { + if code, out, errs := runLibrary(t, "sync-banlist"); code != 0 || !strings.Contains(out, "1 confidential source") { t.Fatalf("sync-banlist: %d %s%s", code, out, errs) } store, err := os.ReadFile(filepath.Join(repo, ".abcd", ".work.local", "private-names.txt")) @@ -131,7 +132,7 @@ func TestSourceEndToEnd(t *testing.T) { if err := os.WriteFile(doc, []byte("We follow the quiet harbour working notes here.\n"), 0o600); err != nil { t.Fatal(err) } - code, out, errs = runSource(t, "cite-check", doc) + code, out, errs = runLibrary(t, "cite-check", doc) if code != 1 || !strings.Contains(out, "conf2026a") || strings.Contains(strings.ToLower(out+errs), "harbour") { t.Fatalf("cite-check: %d\n%s%s", code, out, errs) } @@ -141,20 +142,20 @@ func TestSourceEndToEnd(t *testing.T) { t.Fatalf("cite-check clean: %d %s%s", code, out, errs) } - if code, out, errs := runSource(t, "declassify", "conf2026a"); code != 0 { + if code, out, errs := runLibrary(t, "declassify", "conf2026a"); code != 0 { t.Fatalf("declassify: %d %s%s", code, out, errs) } - if code, out, errs := runSource(t, "sync-banlist", "--refresh"); code != 0 { + if code, out, errs := runLibrary(t, "sync-banlist", "--refresh"); code != 0 { t.Fatalf("refresh: %d %s%s", code, out, errs) } store, _ = os.ReadFile(filepath.Join(repo, ".abcd", ".work.local", "private-names.txt")) if strings.Contains(string(store), "sources/conf2026a") { t.Fatalf("declassified key survived the refresh:\n%s", store) } - if code, out, errs := runSource(t, "ledger", "--flip", "1"); code != 0 { + if code, out, errs := runLibrary(t, "ledger", "--flip", "1"); code != 0 { t.Fatalf("flip after declassification: %d %s%s", code, out, errs) } - code, out, errs = runSource(t, "--json") + code, out, errs = runLibrary(t, "--json") if code != 0 || !strings.Contains(out, `"present": true`) { t.Fatalf("status: %d %s%s", code, out, errs) } @@ -165,7 +166,7 @@ func runSourceStdin(t *testing.T, stdin *bytes.Buffer, args ...string) (int, str t.Helper() var out, errb bytes.Buffer root := NewRootCommand() - root.SetArgs(append([]string{"source"}, args...)) + root.SetArgs(append([]string{"library"}, args...)) root.SetOut(&out) root.SetErr(&errb) root.SetIn(stdin) @@ -194,10 +195,10 @@ func confidentialCorpus(t *testing.T) { if err := os.WriteFile(meta, []byte(`{"title":"Quiet Harbour Working Notes","aliases":["harbourwatch"]}`), 0o600); err != nil { t.Fatal(err) } - if code, out, errs := runSource(t, "init"); code != 0 { + if code, out, errs := runLibrary(t, "init"); code != 0 { t.Fatalf("init: %d %s%s", code, out, errs) } - if code, out, errs := runSource(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential"); code != 0 { + if code, out, errs := runLibrary(t, "add", notes, "--key", "conf2026a", "--meta", meta, "--confidential"); code != 0 { t.Fatalf("add confidential: %d %s%s", code, out, errs) } } @@ -210,23 +211,23 @@ func confidentialCorpus(t *testing.T) { func TestSourceRefreshNeverCreatesTheStore(t *testing.T) { home, repo := sourceCheckout(t) confidentialCorpus(t) - code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + code, stdout, stderr := runLibrary(t, "sync-banlist", "--refresh") if code != 0 { t.Fatalf("refresh with no store: exit %d\n%s%s", code, stdout, stderr) } if _, err := os.Stat(filepath.Join(repo, ".abcd", ".work.local")); !os.IsNotExist(err) { t.Fatalf("the refresh created the local tier or the store (%v)", err) } - if strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "abcd source sync-banlist") { + if strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "abcd library sync-banlist") { t.Fatalf("the refresh does not say, on one line, that it created nothing\n%s%s", stdout, stderr) } noHomePath(t, home, stdout+stderr) // The by-hand sync is what creates it, and a refresh then updates it. - if code, out, errs := runSource(t, "sync-banlist"); code != 0 { + if code, out, errs := runLibrary(t, "sync-banlist"); code != 0 { t.Fatalf("by-hand sync: %d %s%s", code, out, errs) } - if code, out, errs := runSource(t, "sync-banlist", "--refresh"); code != 0 || !strings.Contains(out, "1 confidential source") { + if code, out, errs := runLibrary(t, "sync-banlist", "--refresh"); code != 0 || !strings.Contains(out, "1 confidential source") { t.Fatalf("refresh of an existing store: %d %s%s", code, out, errs) } } @@ -240,11 +241,11 @@ func TestSourceRefreshNamesTheMigrationForALegacyStore(t *testing.T) { confidentialCorpus(t) legacy := "# notes\nwidgetworks\n" writeRel(t, repo, ".abcd/.work.local/private-names.txt", legacy) - code, stdout, stderr := runSource(t, "sync-banlist", "--refresh") + code, stdout, stderr := runLibrary(t, "sync-banlist", "--refresh") if code != 0 || strings.Count(stdout+stderr, "\n") != 1 || !strings.Contains(stderr, "abcd banlist migrate") { t.Fatalf("refresh over a legacy store: exit %d\n%s%s", code, stdout, stderr) } - code, stdout, stderr = runSource(t, "sync-banlist") + code, stdout, stderr = runLibrary(t, "sync-banlist") if code != 2 || !strings.Contains(stdout+stderr, "abcd banlist migrate") { t.Fatalf("by-hand sync over a legacy store: exit %d\n%s%s", code, stdout, stderr) } diff --git a/internal/surface/cli/staleusage.go b/internal/surface/cli/staleusage.go index ee3c7dffa..fad2506e3 100644 --- a/internal/surface/cli/staleusage.go +++ b/internal/surface/cli/staleusage.go @@ -94,6 +94,8 @@ var pagesWithNoVerb = map[string]string{ var retiredTokens = map[string]string{ "prepare-this-repo": "`prepare-this-repo` folded into ahoy install: run /abcd:ahoy install, which prepares the repository in the host agent around the binary's `abcd ahoy install`", "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", + "memory": "`memory` merged into the library verb: run `abcd library` for the memory store's status beside the sources corpus, and `abcd library ingest`, `ask` or `lint` for the store's verbs", + "source": "`source` merged into the library verb: run `abcd library` for the sources corpus beside the memory store, and `abcd library init`, `add`, `declassify`, `ledger`, `sync-banlist` or `cite-check` for the corpus's verbs", "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)", } From b485ce706b58e2f922cf75b468c3c97fa3da7586 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:38:02 +0100 Subject: [PATCH 09/12] feat: report and rewrite a guard hook that runs the old refresh verb A pre-commit guard committed before the sources verbs became library runs `source sync-banlist --refresh`. A binary without that verb makes the hook skip the refresh with one stderr line, so the confidential-name block silently stops being refreshed (spc-2610100613109045, decision 13). ahoy now reports an abcd-owned hook naming the old verb as the gap banlist.hook_retired_verb, and ahoy install rewrites that spelling in place, leaving every other line, the maintainer's own included. A hook without abcd's marker is never edited. Step 5 of spc-2610100613109045. Assisted-by: Claude:claude-opus-5-5 --- internal/core/ahoy/banlist_scaffold.go | 55 ++++++++ internal/core/ahoy/hook_retired_verb_test.go | 126 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/core/ahoy/hook_retired_verb_test.go diff --git a/internal/core/ahoy/banlist_scaffold.go b/internal/core/ahoy/banlist_scaffold.go index 92a75fdef..48b702659 100644 --- a/internal/core/ahoy/banlist_scaffold.go +++ b/internal/core/ahoy/banlist_scaffold.go @@ -428,6 +428,12 @@ type BanlistHealth struct { // Meaningful only where abcd owns the hooks: pinning the EOL of a directory whose // contents are someone else's is not abcd's call. HookEOLPinned bool `json:"hook_eol_pinned"` + // HookRetiredVerb reports an abcd-owned pre-commit hook that still runs the + // sources refresh as `source sync-banlist`, the spelling the verb had before + // it became library (spc-2610100613109045, decision 13). A binary without + // that verb makes the hook skip the refresh with one stderr line, so the + // confidential-name block stops being refreshed with no other sign. + HookRetiredVerb bool `json:"hook_retired_verb"` // PublicConfigIgnored is git's verdict on the config's PATH, whether or not a // file is there yet. It is what makes the absent-family gap honest: writing a // config into a path git ignores delivers no enforcement, so abcd does not offer @@ -497,6 +503,7 @@ func detectBanlistHealth(cwd string) BanlistHealth { h.MergeHook = classifyGuardHook(root, GuardMergeHookRelPath) h.PublicFamily = classifyPublicFamily(cwd, root, ign.public) h.HookEOLPinned = gitattributesPinsHookEOL(cwd, root) + h.HookRetiredVerb = h.Hook == HookInstalled && hookNamesRetiredVerb(root) sum, serr := banlist.SummarisePrivate(cwd) if serr != nil { // A store that exists and cannot be read for what it is: present, and health @@ -634,6 +641,17 @@ func detectBanlistScaffold(h BanlistHealth) []Gap { Resolvable: true, }) } + if h.HookRetiredVerb { + gaps = append(gaps, Gap{ + ID: hookRetiredVerbGapID, Category: SafeAutocreate, Scope: "repo", + Title: "the committed guard runs the sources refresh under its retired name", + Detail: GuardHookRelPath + " runs `" + retiredRefreshVerb + "`, and the verb is now `" + refreshVerb + + "`: a binary without the old name makes the hook skip the refresh with one stderr line, so the confidential-name block stops being refreshed.", + FixHint: "ahoy install rewrites `" + retiredRefreshVerb + "` to `" + refreshVerb + "` in the hook and leaves every other line as it is.", + Required: true, + Resolvable: true, + }) + } gaps = append(gaps, publicFamilyGaps(h)...) gaps = append(gaps, privateStoreGaps(h)...) return gaps @@ -912,6 +930,9 @@ func (a *applyCtx) stepBanlist() { if guardOwned { a.pinHookEOL(root) } + if guardOwned && a.has(hookRetiredVerbGapID) { + a.rewriteRetiredVerb(root) + } if a.has("banlist.private_stub_missing") && storePathIsSafe(a.cwd) { // 0700/0600: the directory holding private patterns is no more readable than // the patterns are, matching the store the banlist verbs write. @@ -1032,3 +1053,37 @@ func (a *applyCtx) createContained(kind writeKind, root *os.Root, rel string, da a.note(kind, filepath.Join(a.cwd, filepath.FromSlash(rel))) return true } + +// The sources refresh the guard runs, under the name the verb has and the one it +// had before memory and source became library (spc-2610100613109045, +// decision 13). hookRetiredVerbGapID is the gap an abcd-owned hook raises while +// it still runs the old name. +const ( + refreshVerb = "library sync-banlist" + retiredRefreshVerb = "source sync-banlist" + hookRetiredVerbGapID = "banlist.hook_retired_verb" +) + +// hookNamesRetiredVerb reports whether the pre-commit hook still names the +// retired refresh. It reads through the containment root, as classifyHook does. +func hookNamesRetiredVerb(root *os.Root) bool { + data, err := fsutil.ReadGuardedInRoot(root, GuardHookRelPath, maxAhoyFileBytes) + return err == nil && bytes.Contains(data, []byte(retiredRefreshVerb)) +} + +// rewriteRetiredVerb replaces the retired refresh spelling in abcd's own hook +// and changes nothing else: the hook is committed and may carry lines the +// maintainer added, so it is edited in place rather than re-seeded from the +// template. It re-reads the hook and re-checks the marker before writing, so a +// hook that changed hands since detection is left alone. +func (a *applyCtx) rewriteRetiredVerb(root *os.Root) { + data, err := fsutil.ReadGuardedInRoot(root, GuardHookRelPath, maxAhoyFileBytes) + if err != nil || !guardHookMarkerRe.Match(data) || !bytes.Contains(data, []byte(retiredRefreshVerb)) { + return + } + out := bytes.ReplaceAll(data, []byte(retiredRefreshVerb), []byte(refreshVerb)) + if err := fsutil.WriteFileAtomicPreserveModeInRoot(root, GuardHookRelPath, out); err != nil { + return + } + a.note(writeNameGuard, filepath.Join(a.cwd, filepath.FromSlash(GuardHookRelPath))) +} diff --git a/internal/core/ahoy/hook_retired_verb_test.go b/internal/core/ahoy/hook_retired_verb_test.go new file mode 100644 index 000000000..260d14d9e --- /dev/null +++ b/internal/core/ahoy/hook_retired_verb_test.go @@ -0,0 +1,126 @@ +package ahoy + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// TestAhoyReportsAHookNamingTheOldVerb is decision 13 of spc-2610100613109045: +// the sources refresh in a committed guard hook ran `source sync-banlist`, and +// once the verb is library a hook committed before the rename would stop +// refreshing its confidential-name block, with one stderr line as the only sign. +// ahoy reports such a hook as a gap, install rewrites the old spelling in place +// (every other byte, the maintainer's own lines included, survives), and the +// gap is gone afterwards. +func TestAhoyReportsAHookNamingTheOldVerb(t *testing.T) { + setupHermetic(t) + if !bytes.Contains(guardHookTemplate, []byte("library sync-banlist")) { + t.Fatal("the scaffolded guard does not run `library sync-banlist`; a fresh install would carry the old verb") + } + if bytes.Contains(guardHookTemplate, []byte("source sync-banlist")) { + t.Fatal("the scaffolded guard still names `source sync-banlist`") + } + + repo := gittest.NewRepo(t).Root() + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if g := findGap(det.Gaps, hookRetiredVerbGapID); g != nil { + t.Fatalf("a freshly scaffolded hook is reported as naming the old verb: %+v", *g) + } + + // The hook as a release before the rename committed it, plus a line of the + // maintainer's own that the rewrite must keep. + hook := filepath.Join(repo, filepath.FromSlash(GuardHookRelPath)) + const ownLine = "# the maintainer's own gate, kept across the rewrite\n" + old := strings.ReplaceAll(string(guardHookTemplate), "library sync-banlist", "source sync-banlist") + ownLine + if err := os.WriteFile(hook, []byte(old), 0o755); err != nil { + t.Fatal(err) + } + + det, err = Detect(repo) + if err != nil { + t.Fatal(err) + } + if det.Banlist.Hook != HookInstalled { + t.Fatalf("hook state = %q; the old hook is still abcd's own", det.Banlist.Hook) + } + g := findGap(det.Gaps, hookRetiredVerbGapID) + if g == nil { + t.Fatal("a committed hook running `source sync-banlist` raises no gap") + } + if !g.Required || !g.Resolvable || g.Category != SafeAutocreate { + t.Errorf("gap = %+v; want a required, resolvable safe-autocreate gap that install closes", *g) + } + for _, want := range []string{"source sync-banlist", "library sync-banlist"} { + if !strings.Contains(g.Detail+g.FixHint, want) { + t.Errorf("the gap does not name %q: %+v", want, *g) + } + } + + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(hook) + if err != nil { + t.Fatal(err) + } + if want := strings.ReplaceAll(old, "source sync-banlist", "library sync-banlist"); string(got) != want { + t.Errorf("install did not rewrite the old spelling alone; the hook now differs from the expected rewrite") + } + if !strings.Contains(string(got), ownLine) { + t.Errorf("the rewrite dropped the maintainer's own line") + } + fi, err := os.Stat(hook) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm()&0o111 == 0 { + t.Errorf("the rewritten hook is no longer executable: mode %v", fi.Mode().Perm()) + } + det, err = Detect(repo) + if err != nil { + t.Fatal(err) + } + if g := findGap(det.Gaps, hookRetiredVerbGapID); g != nil { + t.Errorf("the gap survives the install that rewrote the hook: %+v", *g) + } +} + +// TestAhoyLeavesAForeignHookNamingTheOldVerbAlone: the rewrite is for abcd's +// own hook. A hook without abcd's marker is the maintainer's, and naming the old +// verb does not make it abcd's to edit. +func TestAhoyLeavesAForeignHookNamingTheOldVerbAlone(t *testing.T) { + setupHermetic(t) + repo := gittest.NewRepo(t).Root() + hook := filepath.Join(repo, filepath.FromSlash(GuardHookRelPath)) + if err := os.MkdirAll(filepath.Dir(hook), 0o755); err != nil { + t.Fatal(err) + } + const foreign = "#!/usr/bin/env bash\nabcd source sync-banlist --refresh\n" + if err := os.WriteFile(hook, []byte(foreign), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(repo, installOpts(), RefusingPrompter{}); err != nil { + t.Fatal(err) + } + det, err := Detect(repo) + if err != nil { + t.Fatal(err) + } + if g := findGap(det.Gaps, hookRetiredVerbGapID); g != nil { + t.Errorf("a foreign hook is reported as abcd's to rewrite: %+v", *g) + } + if got, _ := os.ReadFile(hook); string(got) != foreign { + t.Errorf("install edited a foreign hook") + } +} From fe63ea2d44ce76e25fa15bd52ad44914e79d7287 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:38:03 +0100 Subject: [PATCH 10/12] chore: capture the two meanings ingest has under library Refs: iss-2610110233253365 Assisted-by: Claude:claude-opus-5-5 --- ...365-on-the-library-surface-ingest-now-names.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2610110233253365-on-the-library-surface-ingest-now-names.md diff --git a/.abcd/work/issues/open/iss-2610110233253365-on-the-library-surface-ingest-now-names.md b/.abcd/work/issues/open/iss-2610110233253365-on-the-library-surface-ingest-now-names.md new file mode 100644 index 000000000..ff44285bc --- /dev/null +++ b/.abcd/work/issues/open/iss-2610110233253365-on-the-library-surface-ingest-now-names.md @@ -0,0 +1,15 @@ +--- +schema_version: 1 +id: "iss-2610110233253365" +slug: "on-the-library-surface-ingest-now-names" +severity: "minor" +category: "ux" +source: "agent-observation" +found_during: "verb split step 5 (spc-2610100613109045)" +origin: researcher-authored +production_mode: hand-written +found_at: "commands/library.md" +remedy: "Ask the product thinker whether the memory store's ingest is renamed (distil, say) or the page's word changes when consult and ingest fold into the library page, so ingest keeps one meaning; a rename is outside itd-2610090831227812's scope (decision 7)." +--- + +On the library surface 'ingest' now names two different acts: abcd library ingest distils a source into the memory store's cited pages, while the /abcd:ingest page (to fold into the library page in step 6 of spc-2610100613109045) means registering a document in the sources corpus, which is abcd library add. Every sub-verb kept its name under decision 7, so a person who learned ingest as register will run the distiller, and one who reads the page title will look for a corpus verb named ingest. From 2f9e6221ded06f3555c68618f3669c0fcf6a4434 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:51:44 +0100 Subject: [PATCH 11/12] docs: keep the library chapter's prose free of sub-verb and flag spellings The hand-written region of a surface chapter states no sub-verb and no flag, which live only in the generated appendix (TestSurfaceChapterProseStatesNoShape). The folded library chapter named both in its new paragraphs; they now describe the verbs in words. Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/07-library.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.abcd/development/brief/04-surfaces/07-library.md b/.abcd/development/brief/04-surfaces/07-library.md index 7a4d2c125..745952b3e 100644 --- a/.abcd/development/brief/04-surfaces/07-library.md +++ b/.abcd/development/brief/04-surfaces/07-library.md @@ -14,12 +14,12 @@ person's command named `library` ([itd-2610090831227812](../../intents/planned/itd-2610090831227812-a-person-who-types-abcd-sees-only-the.md)). Under [adr-40](../../decisions/adrs/0040-review-audit-lint-are-three-verbs.md) neither old name survives as an alias. Every sub-verb kept its name (decision 7), so the memory store's -`ingest`, which distils a source into pages, sits beside the corpus's `add`, -which registers a document. The host-delegated pages +ingest, which distils a source into pages, sits beside the corpus's add, which +registers a document. The host-delegated pages [`/abcd:consult`](13-consult.md) and [`/abcd:ingest`](14-ingest.md) carry the corpus's judgement (what to search for, which class a document is, which -keywords matter) and call these verbs for every write; the page named `ingest` -means the corpus's `add`, not the memory store's `ingest`. +keywords matter) and call these verbs for every write; the page named ingest +means the corpus's add, not the memory store's ingest (iss-2610110233253365). ## Sub-verbs @@ -43,8 +43,9 @@ means the corpus's `add`, not the memory store's `ingest`. | `lint` | lint | shipped | | `sync-banlist` | — | shipped | -`add`, `cite-check`, `declassify`, `init`, `ledger` and `sync-banlist` act on the -sources corpus; `ask`, `ingest` and `lint` act on the memory store. +Six of the verbs act on the sources corpus: creating it, adding a source, +declassifying one, the ledger, the banlist sync and the scan. Three act on the +memory store: ingesting a source, asking a question and the store lint. ## Bare `abcd library` @@ -52,15 +53,15 @@ Bare `abcd library` is read-only and renders both stores: the corpus (where it is, how many sources sit in each class, the ledgers and their line counts, and any entry whose folder and metadata disagree) and the checkout's memory store (below). A store that cannot be read is one line saying why and naming what -would create it: `abcd library init` for a missing corpus, a checkout for the -memory store. The command refuses, exit 2, only when neither can be read +would create it: the corpus's creating verb for a missing corpus, a checkout +for the memory store. The command refuses, exit 2, only when neither can be read (decision 8). So inside a checkout with no corpus it renders the memory store and a line about the corpus, and outside every checkout with a corpus it -renders the corpus and a line about the memory store. The `--json` envelope +renders the corpus and a line about the memory store. The JSON envelope carries `corpus` and `memory`, each null when that store could not be read, with `corpus_note` or `memory_note` saying why. The corpus is the default one under -the user-level home, `~/.abcd.noindex/sources`, unless `--corpus` names another -directory. +the user-level home, `~/.abcd.noindex/sources`, unless the invocation names +another directory. ## The sources corpus @@ -240,7 +241,7 @@ surface contract: what the user types and what happens. > ids in this repo's own store. So in this section the store an id belongs to is > read from the sentence around it, never from the number. -**The store every memory verb addresses is the checkout's, resolved from the working directory and never taken to be it.** The front door asks [`gitutil.CheckoutRoot`](../../../../internal/gitutil/repo.go) before it builds a request — the same resolution `capture`, `decide` and `spec` address their stores through: git's toplevel where git will name one, and a refusal in the two remaining states rather than a guess (a repo-shaped tree git will not answer for, and no repository above at all). So a render from a package directory reports the checkout's pages instead of `store not present`, an ingest lands in the checkout's substrate instead of laying a second one beneath the caller, and outside a checkout `ingest`, `ask` and `lint` exit **2** having read nothing and written nothing. It deliberately does not fall through to a marker walk, which would accept any directory carrying the name (iss-2609090947359464). A memory store found **below** the checkout root, on the chain between the caller and it, is named on stderr and left untouched — the deposit an unresolved front door leaves behind, reported to the person standing over it rather than stepped over in silence. +**The store every memory verb addresses is the checkout's, resolved from the working directory and never taken to be it.** The front door asks [`gitutil.CheckoutRoot`](../../../../internal/gitutil/repo.go) before it builds a request — the same resolution `capture`, `decide` and `spec` address their stores through: git's toplevel where git will name one, and a refusal in the two remaining states rather than a guess (a repo-shaped tree git will not answer for, and no repository above at all). So a render from a package directory reports the checkout's pages instead of `store not present`, an ingest lands in the checkout's substrate instead of laying a second one beneath the caller, and outside a checkout every memory verb but the bare render exits **2** having read nothing and written nothing. It deliberately does not fall through to a marker walk, which would accept any directory carrying the name (iss-2609090947359464). A memory store found **below** the checkout root, on the chain between the caller and it, is named on stderr and left untouched — the deposit an unresolved front door leaves behind, reported to the person standing over it rather than stepped over in silence. **Its half of bare `abcd library`** renders the store's state and nothing else: how many pages there are by class, when the last ingest happened, the recent From 46ace4b5ad10a23784c6901f36cf2e2f979ee324 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:08:54 +0100 Subject: [PATCH 12/12] fix: keep the guard refusing the retired source ledger flip The flip refusal named only the library spelling, so an agent could flip a citation line through any binary older than the library verb, which still answers `abcd source ledger --flip` and is often reachable (the plugin root's copy, an older build). A second entry refuses the retired spelling with the same reason, watched fail first. The guard page names both. The per-byte work bar grows with the registry, as its comment says; the new entry takes the hostile shape to about 24.7, so the bar moves from 24 to 28, still far below the hundred a dropped bound costs. Refs: itd-2610090831227812 Assisted-by: Claude:claude-opus-5-5 --- commands/guard.md | 8 +++++--- internal/core/guard/defaults/guard.json | 23 +++++++++++++++++++++++ internal/core/guard/sourceflip_test.go | 16 ++++++++++++++++ internal/core/guard/work_test.go | 6 ++++-- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/commands/guard.md b/commands/guard.md index c0e8c93e5..4cdfd2603 100644 --- a/commands/guard.md +++ b/commands/guard.md @@ -464,9 +464,11 @@ read as handed it, so `sh -c 'kill 4242' _ "$(pgrep …)"` is a **block** too. A verb that is the person's own act is left to the person. `abcd library ledger --flip N` records that a human chose to cite a source publicly, so an agent running it is a **block** (`abcd-source-ledger-flip`); recording a line and -listing the ledger are not. The entry names the program `abcd`, so it matches -the binary on `PATH` and the plugin root's by basename, not a `go run` of the -source or a copy under another name. +listing the ledger are not. The retired spelling, `abcd source ledger --flip N`, +is a **block** too (`abcd-source-ledger-flip-retired`), because a binary older +than the library verb still answers it. The entries name the program `abcd`, +so they match the binary on `PATH` and the plugin root's by basename, not a +`go run` of the source or a copy under another name. A recursive delete of the filesystem root or the home directory (`/`, `/*`, `~`, `$HOME`, `${HOME}`, each also with a trailing `/`, `/*` or `/*/`, and the diff --git a/internal/core/guard/defaults/guard.json b/internal/core/guard/defaults/guard.json index 8e08aa52f..b5df76e30 100644 --- a/internal/core/guard/defaults/guard.json +++ b/internal/core/guard/defaults/guard.json @@ -562,6 +562,29 @@ "abcd capture \"an agent ran abcd library ledger --flip 3\"" ] } + }, + "abcd-source-ledger-flip-retired": { + "tier": "blocker", + "pattern": { + "command": "abcd", + "subcommand": "source", + "subcommand2": "ledger", + "value_flags": ["--corpus"], + "flags": ["--flip"] + }, + "why": "The retired spelling of the ledger flip: a binary older than the library verb still answers it, and such a binary is often reachable (the plugin root's copy, an older build). Flipping a source-ledger line to public citation is the person's own act, so a flip an agent makes writes a false record of who decided.", + "successor": "Leave this one to the person: tell them which ledger line you believe is ready to cite publicly and why, and let them run `abcd library ledger --flip N` themselves.", + "fixtures": { + "known_bad": [ + "abcd source ledger --flip 3", + "abcd source ledger --flip=3", + "abcd source --corpus /srv/corpus ledger --flip 3" + ], + "known_good": [ + "abcd source ledger --list", + "abcd capture \"an agent ran abcd source ledger --flip 3\"" + ] + } } } } diff --git a/internal/core/guard/sourceflip_test.go b/internal/core/guard/sourceflip_test.go index 57c1c104d..f7a32c288 100644 --- a/internal/core/guard/sourceflip_test.go +++ b/internal/core/guard/sourceflip_test.go @@ -23,3 +23,19 @@ func TestSourceLedgerFlipIsLeftToThePerson(t *testing.T) { {`abcd capture "an agent ran abcd library ledger --flip 3"`, VerdictAllow, ""}, }) } + +// TestRetiredSourceLedgerFlipIsStillLeftToThePerson holds the retired spelling +// to the same refusal: a binary older than the library verb still answers +// `abcd source ledger --flip`, and such a binary is often reachable (the +// plugin root's copy, an older build), so the flip it writes is the same +// false record of who decided. +func TestRetiredSourceLedgerFlipIsStillLeftToThePerson(t *testing.T) { + runVerdictCases(t, []verdictCase{ + {`abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip-retired"}, + {`abcd source ledger --flip=3`, VerdictBlock, "abcd-source-ledger-flip-retired"}, + {`abcd source --corpus /srv/corpus ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip-retired"}, + {`"$CLAUDE_PLUGIN_ROOT"/abcd source ledger --flip 3`, VerdictBlock, "abcd-source-ledger-flip-retired"}, + {`abcd source ledger --list`, VerdictAllow, ""}, + {`abcd capture "an agent ran abcd source ledger --flip 3"`, VerdictAllow, ""}, + }) +} diff --git a/internal/core/guard/work_test.go b/internal/core/guard/work_test.go index 26fca8a57..bcefb8ffb 100644 --- a/internal/core/guard/work_test.go +++ b/internal/core/guard/work_test.go @@ -32,10 +32,12 @@ const linearWorkBar = 6.0 // grows with the registry: an unknown program name followed by unknown // dash-words (`$(a) -$(b) x` repeated) measured about 18 units per byte with // fourteen bundled entries and about 20 with sixteen, the two rm-target -// entries of iss-2609282105242542. The bar sits at 24, which keeps that shape +// entries of iss-2609282105242542, and about 24.7 once the retired spelling of +// the ledger flip took an entry of its own beside the library one +// (spc-2610100613109045, step 5). The bar sits at 28, which keeps that shape // under it with room for a few more entries and stays far below the hundred a // dropped bound costs. -const workPerByteBar = 24.0 +const workPerByteBar = 28.0 // checkWork runs one check over line against the bundled registry and returns // the work the guard counted doing it (tally in work.go): bytes tokenized, bytes