diff --git a/README.md b/README.md index 4f30608..c69e6e4 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,24 @@ Two things make it different from the rest of the terminal-agent shelf: **Aether Agent is in beta.** Updates are shipping quickly. > **What `npm i -g` gives you today: 0.1.0.** The npm `latest` dist-tag is still -> `0.1.0`; **0.2.0 is on `main` but not yet published**. So the features described -> under [v0.2.0 in the release notes](RELEASE_NOTES.md) — portable handoffs, -> `--resume` reaching the brain, `aether agent --local ""` working straight -> after an install — are **not** in the package the command above installs. Until -> the 0.2.0 release is cut, build from source to get them: +> `0.1.0`, and 0.1.0 is the only version ever published. **`main` is 0.3.0 and is +> not on the registry** — neither was 0.2.0, which was written up but never +> released and is now superseded. So the features described under +> [v0.3.0 in the release notes](RELEASE_NOTES.md) — the `aether review` → +> `aether ship` rail, `aether sessions`, skills enforced inside real runs, +> `aether skills`, `aether capabilities`, `aether support-bundle`, portable handoffs, `--resume` +> reaching the brain, `aether agent --local ""` working straight after an +> install — are **not** in the package the command above installs. Until a +> `v0.3.0` release is published, build from source to get them: > > ```bash > git clone https://github.com/AetherAI3/aether-agent > cd aether-agent && npm ci && npm run build && npm link > ``` +> +> Publishing is owner-gated; the exact steps, the commit the tag must point at, +> and the packed tarball's digest are in +> [`docs/releases/OPERATOR-PACKET-v0.3.0.md`](docs/releases/OPERATOR-PACKET-v0.3.0.md). ## Carry the work across models and machines @@ -101,7 +109,7 @@ aether agent --local # …same terminal, nothing leaves the machi > Running the separate Python brain instead — Aether's open-source (Apache-2.0) **[Unlimited Context](https://github.com/AetherAI3/Unlimited-Context-LLM)** engine, which gives an Ollama model a billion-token working memory — is opt-in with `AETHER_LOCAL_BRAIN=python` once you have installed it. It is not bundled with the npm package. -> Prefer the installer UI? Download [`install.sh`](install.sh) or [`install.ps1`](install.ps1), inspect it, then run it locally. Both follow npm's `latest` dist-tag, which today is **0.1.0**; `AETHER_VERSION=` (shell) or `-Version ` (PowerShell) pins an exact **published** release, so a version that is not on the registry yet — 0.2.0 included — will fail with `No matching version found`. The canonical npm command above verifies registry integrity and disables lifecycle scripts; there are no native or runtime dependencies and no daemon. +> Prefer the installer UI? Download [`install.sh`](install.sh) or [`install.ps1`](install.ps1), inspect it, then run it locally. Both follow npm's `latest` dist-tag, which today is **0.1.0**; `AETHER_VERSION=` (shell) or `-Version ` (PowerShell) pins an exact **published** release, so a version that is not on the registry yet — 0.2.0 and 0.3.0 included — will fail with `No matching version found`. The canonical npm command above verifies registry integrity and disables lifecycle scripts; there are no native or runtime dependencies and no daemon. ## Models & pricing @@ -161,6 +169,9 @@ aether models # list models + orchestrators aether resume # replay the last session in this workspace aether resume export # write a portable handoff for another machine aether agent --resume # continue it — on any model, with the context +aether sessions # every session in this project, and where it can resume +aether review # see what changed, pick it, commit exactly that +aether ship # push the head branch and open the pull request ``` Flags you can set when launching the REPL (or pass with an inline task `aether agent ""` for one-shot autonomous mode): diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f0bc61b..9c6ef21 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,242 @@ +# Aether Agent v0.3.0 — the work reaches a pull request + +**August 22, 2026** + +0.2.0 was never published. It was written up on August 19, and then `main` kept +moving: a skills runtime, a capability contract, a redacted support bundle, a +command-registration seam, a review-to-pull-request rail, a project session +library, and the wiring that finally puts skills inside a real run all landed on +top of the version that was already spoken for. Rather than quietly widen 0.2.0 +to mean two different things, this release takes the next number and describes +everything actually on `main`. + +Covers `477f0fc..a845479` — every commit merged after the v0.2.0 notes were +written, and everything the v0.2.0 notes described, which was never shipped +either. + +## New + +- **A review → commit → pull request rail.** `aether review` reads the + repository's real state, lets you pick what goes in, commits exactly that, and + `aether ship` publishes the head branch and opens the pull request. + - `aether review [stage|unstage|revert|commit|diff|verify]`, with `--files`, + `--hunks`, `-m`, `--base` (#94, #102). + - `aether ship [--title t] [--body b] [--base b]` pushes HEAD — and only HEAD — + and opens the PR against the branch it actually resolved (#95, #102). + - **`--approve ` is the authority boundary.** `--yes` on its own never + approves a destructive or a publishing step; the action has to be named + (#102). + - The state is read in one pass: repository root, remote identity, head and + base revisions, commits ahead and behind, and every changed path. The push + URL is read separately with `git remote get-url --push`, so a configured + `pushurl` cannot publish somewhere you were never shown, and an unresolvable + base leaves ahead/behind **unknown rather than zero** (#93). + - A verification record stores the verify gate's result together with the head + commit and a digest of the working tree, and compares that identity *before* + it looks at the exit code — so neither a stale green nor a stale red can be + rendered as current, and nothing upgrades unknown or stale to verified + (#93, #97). + - Changed files carry their added/removed line counts beside the state rather + than inside it (#101). +- **`aether sessions`** — the project session library. `list`, `inspect`, + `continue`, `export`, `archive` and `clean`, as a width-aware table on a + terminal and tab-separated columns with a fixed field order when piped. An + index beside the session directories makes "what was I doing here" cheap, but + the per-session manifest stays the authority, so a lost or corrupt index costs + time and never information. Where a session can be continued is answered as one + of six distinct states — ready, stale branch, moved checkout, another + workspace, missing checkout, archived — because the remedies differ, and the + same facts appear as a PROJECT CONTINUITY block on entry. A count nobody + recorded prints `unknown`, not `0`. Nothing here deletes: `archive` sets a flag + and `clean` drops index rows for sessions already gone (#99). +- **Skills and `AGENTS.md` are inside real runs now, and their policy is + enforced.** The runtime shipped in #72 with no production call site — a run + never saw a skill, never saw `AGENTS.md`, and never enforced a tool policy. + One seam now composes the brief before a brain is chosen, so the hosted and + local paths carry the byte-identical string, and the refusal runs immediately + before this host executes a tool. A skill only ever **subtracts** from the tool + surface: the guard runs before the operator permission gate, never instead of + it, so nothing a manifest says can add a tool, add a permission, or skip a + confirmation. A skill matched automatically contributes context but not policy + — only an explicit `--skill ` narrows (#100). +- **Agent skills** — `aether skills` inspects, trusts and manages skills, and six + are built into the package: `review-pr`, `fix-ci`, `ship`, `doctor-project`, + `research-and-implement`, `frontend-from-screenshot`. Skills are discovered, + schema-validated, lazily loaded and trust-locked; an untrusted skill is not + silently run. +- **`aether capabilities`** — the capability contract this build actually + implements, and, with `--available`, what is reachable right now. A surface the + build does not have reads as absent, not as unchecked. +- **`aether support-bundle`** — a redacted diagnostic bundle you can hand to + someone without handing over your credentials or your file contents. +- **A command-registration seam.** A command now carries its own help text, its + own flag table and its own loader in one entry, so adding one is a single edit + instead of three that have to agree. Flag collisions are load-time errors + rather than last-writer-wins, and reachability is structural rather than + asserted by a regex over the source. You feel this as the three `doctor` fixes + below — those flags were lost precisely because the old shape let a command's + flags and its dispatch drift apart. + +## Carried forward from the unpublished 0.2.0 + +- **Handoffs** — `aether resume export` writes one portable file: the task, the + model that ran it, the verify gate's verdict, how many tests were still + failing, the files that changed, the verification command, and the repository + it belongs to. Continue anywhere with `aether agent --resume `, on + whatever model you want. No absolute paths, no file contents, no shell + commands, no credential-shaped values ride along. +- **`--resume` reaches the brain** — the prior session becomes a continuation + brief the model reads before its own instruction. With no new task, the run + continues the original one. +- **`aether agent --local ""` works after a plain npm install** — the + one-shot offline form used to die with `spawn python ENOENT`. It now drives the + Ollama brain that ships in the package. `AETHER_LOCAL_BRAIN=python` opts back in. +- **Session logs stopped redacting your file paths** — the credential filter + matched `pat` inside `path`. Real credential keys are still redacted. +- **`npm run demo:handoff`** — a deterministic end-to-end proof: two sessions, + two models, two checkouts, one verify gate, no account and no model download. + See [`docs/demo/handoff.md`](docs/demo/handoff.md). + +## Fixed + +- **A coding run no longer turns into a chat about your code without saying so.** + When the server answers 403/404 to a dev-session request — which is what + `api.aethersystems.net` does today, with agent dev sessions disabled — CloudBrain + treated it as "legacy server" and rerouted the run onto the one-way chat + stream. That path runs its tools **server-side against the cloud vault**, so a + session asked to work in your checkout quietly became a conversation about it: + normal header, plausible reply, **exit 0**, and nothing anywhere saying the + transport had changed. A `ROUTING_DRIFT` banner now prints before any model + output, carrying the status, the server's own sanitized detail, the + consequence in plain words, and what to do about it; `--json` carries it + structurally as `kind:"routing_drift"` (#105). +- **`aether auth login` opens the approval page on Windows.** The win32 launcher + used `explorer.exe` for URLs as well as file paths, which opens a File Explorer + window rather than the default browser — so the device-approval page never + appeared and the login poll sat on *"Waiting for approval in your browser…"* + forever. URLs now go through `rundll32.exe url.dll,FileProtocolHandler`, the + no-shell equivalent of a shell-execute on a URL, with the URL kept as a single + argv element. A URL containing control characters or whitespace is refused + outright (#103). +- **The stored credential cannot be redirected through a planted link, or torn + in half by a crash.** The token store guarded its reads and writes with + `O_NOFOLLOW ?? 0`, and `O_NOFOLLOW` is not defined on Windows — so the guard + collapsed to `0` there and a symlink or directory junction planted at the token + path was followed on both read and write, handing over the session token or + capturing the next one. A junction needs no privilege to create. Reads and + writes now `lstat` the path first and refuse a link on every platform, and the + write is a `0600` exclusive temp file, fsynced and renamed over the target + instead of truncate-then-write, so a crash mid-write can no longer leave an + empty token file or let a concurrent reader see half a credential. Clearing the + token removes a planted link rather than whatever it pointed at (#104). +- **Ctrl+C stops a local turn.** The abort signal now reaches local runs instead + of being dropped at the chat boundary. +- **`/limit` is a real stop boundary**, and unknown spend is reported as unknown + rather than as zero — so a session nobody measured no longer looks like a + session that spent nothing. +- **`/rollback` stopped lying about HEAD** and stopped accepting a count it never + used. +- **`--repo` is validated and fetched** rather than reused blind, and the + worktree is pinned to the fetched revision; an unknown base is refused instead + of guessed. +- **Tool execution is async with process-tree teardown** — cancelling a run kills + the whole tree, not just the shell that fronted it, so `npm test` or a compiler + no longer keeps running after you stopped it. +- **Ollama's own `OLLAMA_HOST` format is accepted**, and the request timeout stays + armed through the body read instead of expiring at the headers. +- **Ollama tool results are correlated by id**, schemas are generated, and steer + is no longer faked on the local path. +- **CLI startup no longer blocks on an unbounded `git status`** in a large or + slow repository. +- **The hosted dev-session protocol version the server answers is actually + checked**, instead of the version the client hoped for. +- **`aether doctor --live` now actually runs the live proof.** It never had. The + CLI's argv parse swallowed any flag a command had not declared, so `--live` + was stripped before `doctor` saw it: the command quietly ran the fast + configured-only report and **exited 0**, presenting a live end-to-end + verification that was never performed. `--deep`, `--dry-run`, `--no-ui` and + `--only ` were lost the same way. +- **`aether doctor --fix` is reachable at all.** The whole repair path was + unreachable, and because the global `--yes` never arrived either, + `aether doctor --fix --yes` answered *"re-run with `--yes`"* to someone who had + just passed it. `--fix` still changes nothing without `--yes`, and still shows + its repair plan first. +- **A mistyped command no longer costs you a model call.** Command lookup + lowercased the token while dispatch was case-sensitive, so `aether Vault` fell + past the typo guard into a chat turn and billed it. Wrong case now reaches the + "did you mean" guard, as it always should have. + +## Behaviour changes + +- **A run that needs local authority now fails closed, with the new exit code + 3.** `aether agent`, and any run that pinned `--model`, will no longer fall back + to the chat stream when the dev session is refused: no chat-stream request is + issued at all and the process exits **3**, a newly documented code in + `COMMANDS.md`. This is a deliberate exit-status change — a script that treated + a degraded run as success will now see a failure, which is the point. Chat-shaped + runs that pinned nothing still degrade, but they announce it. `--local` (Ollama) + and the auth paths are untouched (#105). +- **A symlinked config directory is now refused when writing the token.** Saving + a credential validates the config directory first: it must be a real + directory, not a link, owned by you, and not group- or world-writable. If you + deliberately symlink or junction `~/.config/aether` — onto another drive, into + a dotfiles checkout, across a container mount — `aether auth login` now **fails + loudly** instead of writing your token through the link. Replace the link with + a real directory, or point `AETHER_CONFIG_DIR` at one. This is deliberate: on + Windows a directory junction needs no privilege to create, which makes + redirecting the config directory the most reachable form of the attack #104 + closes. The ownership and permission half of the check is POSIX-only — Node + does not expose Windows ACLs — but the link refusal itself applies everywhere + (#104). +- **Reading the token no longer throws on a planted link; it reports no token.** + A read that encounters a link is treated as "not signed in" rather than + surfacing a credential, so the recovery path is `aether auth login`, not an + error nobody can act on (#104). + +## Authentication + +Stated with its provenance, because half of this lives in a server this +repository cannot test: + +- **Device-grant login works end to end again.** The repository-side half of that + is #103 above: before it, the approval page never opened on Windows, so the + flow could not complete there at all. The other half — the API accepting + long-lived `aek_` tokens — is a **server-side** change. It is verified by + operators against the deployed API and is **not** proven by any test in this + repository, which has no live credential. +- **`aether auth logout` ends the session on the server, not just on disk.** The + client posts to `/auth/logout` with the stored token before clearing the local + credential. That call is **best-effort**: if the server is unreachable the + local credential is still cleared, so a successful `Logged out.` is proof the + credential is gone from this machine, and not by itself proof the server + honoured it. The client call is not new in this range — what changed is on the + server side. + +## Availability — read this before upgrading + +**0.3.0 is not on npm.** At the time these notes were written the registry served +exactly one version of `aether-agents`, `0.1.0`, and `latest` resolved to `0.1.0`. +Neither 0.2.0 nor 0.3.0 has ever been published, and no GitHub release exists for +either. So `npm i -g aether-agents --ignore-scripts` installs **0.1.0**, and none +of the above is in it. + +Until a `v0.3.0` release is published, build from source: + +```bash +git clone https://github.com/AetherAI3/aether-agent +cd aether-agent && npm ci && npm run build && npm link +``` + +Publishing is founder-owned: it needs a `v0.3.0` tag on the release commit, a published +GitHub release, the `npm-production` environment and an `NPM_TOKEN`. The exact +sequence, with the packed tarball's digest and manifest, is in +[`docs/releases/OPERATOR-PACKET-v0.3.0.md`](docs/releases/OPERATOR-PACKET-v0.3.0.md). + +When 0.3.0 is published it upgrades in place: no configuration changes, no +migration, and 0.1.x session logs are read unchanged. + +--- + # Aether Agent v0.2.0 — the work outlives the session **August 19, 2026** @@ -28,19 +267,12 @@ decide when it's done. two sessions, two models, two checkouts, one verify gate, no account and no model download. See [`docs/demo/handoff.md`](docs/demo/handoff.md). -**Availability.** 0.2.0 is on `main`, but it is **not yet published to npm** — -the registry’s `latest` dist-tag still resolves to 0.1.0, so a plain -`npm i -g aether-agents` installs 0.1.0 and none of the above. Until the 0.2.0 -release is cut, build it from source: - -```bash -git clone https://github.com/AetherAI3/aether-agent -cd aether-agent && npm ci && npm run build && npm link -``` - -Once 0.2.0 is published, `npm i -g aether-agents --ignore-scripts` will upgrade in -place: no configuration changes, no migration, and 0.1.x session logs are read -unchanged. +**Superseded — 0.2.0 was never released.** No `v0.2.0` tag, no GitHub release +and no npm version ever existed for it. `main` kept moving after these notes +were written, so the work above ships as part of **[v0.3.0](#aether-agent-v030--skills-and-a-release-that-matches-the-repository)** +instead of widening 0.2.0 to mean two different things. This entry is kept as +the record of what was written on August 19, not as an install instruction — +see the v0.3.0 availability section above. --- diff --git a/docs/releases/2026-08-22.md b/docs/releases/2026-08-22.md new file mode 100644 index 0000000..96d8cba --- /dev/null +++ b/docs/releases/2026-08-22.md @@ -0,0 +1,139 @@ +# Release notes — 2026-08-22 + +**v0.3.0 — skills, and a release that matches the repository.** + +Release commit: the merge commit of PR #96 into `main` — this entry is written +against `a845479082e5e1b07337aae95833767d9f8ab4e5`, the `main` merged into the +release branch. Re-cut the candidate on the merge commit before tagging. + +Commit range: `477f0fc..a845479` (28 commits, 2026-08-19 → 2026-08-22) + +## Why 0.3.0 and not 0.2.0 + +The v0.2.0 notes were written on 2026-08-19 at `477f0fc` and describe handoffs, +`--resume` reaching the brain, and `--local ""` working out of the box. +They were never published — there is no `v0.2.0` tag, no GitHub release, and +`aether-agents@0.2.0` has never existed on npm. + +Seventeen commits landed after those notes. One of them, #72, is a feature: +`aether skills`, `aether capabilities`, `aether support-bundle`, six built-in +skills, and the runtime behind them. Under semver a backwards-compatible feature +addition is a minor bump, and reusing 0.2.0 would mean two materially different +artifacts answer to one version — including a `aether-agents-0.2.0.tgz` that was +committed to the repository root in #83 and deleted in #90, whose contents +predate the skills runtime entirely. That is the identity defect this release +exists to close, so the version moves rather than the meaning. + +`0.2.0` is retired unused. Nothing will ever be published under it. + +## What landed after the v0.2.0 notes + +Features: + +- **#72** — skill runtime, instruction resolver, capability contract, redacted + support bundle, and six built-in skills. Adds the `skills`, `capabilities` and + `support-bundle` commands. +- **#98** — command-registration seam. A command carries its own help metadata, + flag table and loader in one entry; flag collisions become load-time errors and + reachability becomes structural instead of a regex over `main.ts`. + +User-visible fixes: + +- **#73** — `--repo` mirrors are validated and fetched instead of reused blind. +- **#74** — `/rollback` stops accepting a count it never used and stops + misreporting HEAD. +- **#75** — Ollama tool results correlated by id, schemas generated, steer no + longer faked. +- **#77** — the abort signal reaches local turns, so Ctrl+C works. +- **#78** — `/limit` is a real stop boundary; unknown spend reports as unknown, + not as zero. +- **#83** — a `--repo` worktree is pinned to the fetched revision; an unknown + base is refused. +- **#84** — async tool execution with process-tree teardown. +- **#88** — Ollama's own `OLLAMA_HOST` format is accepted; the timeout stays + armed through the body read. +- **#89** — CLI startup no longer blocks on an unbounded `git status`. +- **#91** — the dev-session protocol version the server answers is actually + checked. +- **#98** — three defects, all shipped in the same commit as the seam that makes + them impossible to reintroduce: + - `aether doctor --live` never received `--live`. The argv parse is + non-strict, so a flag the command had not declared was captured globally and + stripped from what the command was handed. `--live` ran the fast + configured-only report and exited 0, presenting a live end-to-end proof that + was never performed — the exact "unknown rendered as verified" failure this + codebase forbids. `--deep`, `--dry-run`, `--no-ui` and `--only` were lost the + same way, and the entire `--fix` repair path was unreachable. + - `aether doctor --fix --yes` replied "re-run with `--yes`", because the global + `--yes` never reached the command either. + - Command lookup lowercased the token while dispatch was case-sensitive, so a + wrong-case token for an unmigrated command fell through to chat and billed a + paid model turn instead of reaching the typo guard. + +Not user-visible, recorded for completeness: + +- **#82, #85, #87** — tests only (doctor hanging-backend property, release + canaries made executable gates, brain-parity seam and canary 7). +- **#86** — the ship rail module and canary 5. Landed unwired; **#102 has since + wired it**, so `aether ship` invokes it and it is user-visible in this release. +- **#90** — documentation honesty and repo hygiene; deleted the committed + `aether-agents-0.2.0.tgz`, moved `_loopstate/` under `docs/loops/`. + + +## What landed after the candidate was first cut + +Eleven more commits reached `main` on 2026-08-22, while PR #96 was open. They are +in this release, and the notes above were regenerated against them. + +Features: + +- **#93** — structured repository state, and a verification record bound to the + head commit plus a working-tree digest. Identity is compared before the exit + code, so a stale green or a stale red is never rendered as current. +- **#94** — stage, unstage, revert and commit exactly the selection made over + that state. +- **#95** — `ship`: publish HEAD and only HEAD, and bind the work to the branch + that carries it. +- **#97** — one writer for the verification record, and the single condition it + writes under. +- **#101** — added/removed line counts carried beside the repository state + rather than inside it. +- **#102** — the rail becomes reachable: `aether review` and `aether ship` are + registered commands, and `--approve ` is the declared authority + boundary — `--yes` alone never approves a destructive or a publishing step. +- **#99** — `aether sessions`: the project session library (list / inspect / + continue / export / archive / clean), an index whose authority is the + per-session manifest, six distinct continuity states, and a PROJECT CONTINUITY + block on entry. Nothing on this path deletes a session, a worktree or a branch. +- **#100** — skills and `AGENTS.md` composed into real runs, through one seam, + with the tool policy enforced at the two places this host executes a tool. A + skill only subtracts from the tool surface; the guard runs before the operator + permission gate, never instead of it. + +User-visible fixes: + +- **#103** — win32 URLs are opened with `rundll32 url.dll,FileProtocolHandler` + instead of `explorer.exe`, which opened a File Explorer window rather than the + browser and left `aether auth login` waiting for an approval page that never + appeared. URLs containing control characters or whitespace are refused. +- **#104** — the token store no longer follows a symlink or junction planted at + the token path (`O_NOFOLLOW` is undefined on Windows, so the old guard + collapsed to `0` there), and writes go through a `0600` exclusive temp file, + fsync and rename instead of truncate-then-write. Carries a **behaviour + change**: a symlinked config directory is refused when writing the token. See + the "Behaviour changes" section of [`../../RELEASE_NOTES.md`](../../RELEASE_NOTES.md). +- **#105** — CloudBrain refuses the silent dev-session → chat-stream downgrade. + A 403/404 on the dev-session request used to reroute the run onto the one-way + chat stream, whose tools run server-side against the cloud vault — a coding + session became a chat about the code, at exit 0, with no line saying so. It now + prints a `ROUTING_DRIFT` banner before any model output, and a run needing + local authority fails closed with the **new exit code 3**. Carries a + **behaviour change**: see the "Behaviour changes" section of + [`../../RELEASE_NOTES.md`](../../RELEASE_NOTES.md). + +## Availability + +Not published. At the time of writing the npm registry served exactly one +version of `aether-agents` — `0.1.0` — and `dist-tags.latest` resolved to +`0.1.0`. Publishing is founder-owned and is specified in +[`OPERATOR-PACKET-v0.3.0.md`](OPERATOR-PACKET-v0.3.0.md). diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md new file mode 100644 index 0000000..a30b4a3 --- /dev/null +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -0,0 +1,377 @@ +# Operator packet — Aether Agent v0.3.0 + +Everything a founder needs to publish this release, and everything that was +proven before asking. Nothing in this packet was executed against the registry +or against a git ref: creating the tag, publishing the release, and publishing +to npm are founder-owned and are listed at the end, unrun. + +| | | +|---|---| +| Package | `aether-agents` | +| Proposed tag | `v0.3.0` | +| Branch base | `a845479082e5e1b07337aae95833767d9f8ab4e5` (`origin/main`, after #105) | +| Release commit | the merge commit of this PR into `main` — **re-run the candidate on it before tagging** (§6.2) | +| Evidence commit | `fb96ee44b03f37a386954a32412728fa7e98a046` | +| Tarball | `aether-agents-0.3.0.tgz` | +| Tarball sha256 | `70a48aca8baa8b63f551980256eafa42531cd22fc5ca1146829d31f8b4bd2e4d` | +| Tarball size | 739,977 bytes packed / 3,022,168 unpacked | +| Packed entries | 575 | + +## 1. Semantic version decision + +**0.2.0 → 0.3.0 (minor).** + +`main` contains a backwards-compatible feature addition that the v0.2.0 notes +never described: PR #72 added the skills runtime and three new CLI commands — +`aether skills`, `aether capabilities`, `aether support-bundle` — plus six +built-in skills shipped inside the package. Semver makes that a minor bump. + +Reusing `0.2.0` was rejected. `0.2.0` is already the name of a specific, dated, +written-up artifact: the RELEASE_NOTES.md entry of 2026-08-19, the README's +availability paragraph, `install.sh`'s pin example, and a packed +`aether-agents-0.2.0.tgz` that lived in the repository root from #83 until #90 +deleted it. That tarball predates the skills runtime entirely. Publishing a +different tarball under the same version would make two materially different +artifacts answer to one name — the identity defect this release exists to close. + +`0.2.0` is retired unused. It will never be published. + +## 2. What the release covers + +Commit range `477f0fc..a845479` — 28 commits, 2026-08-19 08:39 EDT through +2026-08-22 21:48 EDT — plus everything the unpublished v0.2.0 notes described. + +- 4 feature waves: #72 (skills runtime and its three commands), #98 + (command-registration seam), #93/#94/#95/#97/#101/#102 (the review → commit → + pull request rail, and the two commands that expose it), and #99 + (`aether sessions`) — plus #100, which is what finally puts skills and + `AGENTS.md` inside a real run and enforces their tool policy. +- 13 user-visible fix commits: #73, #74, #75, #77, #78, #83, #84, #88, #89, + #91, #103, #104, #105. +- 3 test-only commits: #82, #85, #87. +- 1 documentation/hygiene commit: #90. + +Two of these carry **behaviour changes** — three changes in total, all named in +the "Behaviour changes" section of the release notes. #105 adds exit code 3 for +a run that refuses to degrade into a chat. #104 refuses a symlinked config +directory when writing the token, and reports a planted token link as "no +token" rather than throwing. An operator upgrading a scripted install should +read that section before this one. + +Per-PR detail: [`2026-08-22.md`](2026-08-22.md). + +**This range moved twice after the candidate was first cut, and that is the +normal case.** #98 was squash-merged to `main` while PR #96 was open; then the +review/ship rail, `aether sessions`, the skills wiring, the opener and token-store +fixes and finally #105 landed the same way. The notes and this packet were +regenerated against each new base rather than tagged against the old one. Any +lane that lands before the tag is created moves it again — which is why step 2 +of §6 re-runs the candidate on the merge commit rather than trusting this +packet's digest. `test/release_coherence.test.ts` fails the build if a +user-visible command reaches the registry without either a release note or a +named exemption (§4), so the next lane to land cannot repeat this silently. + +## 3. State of the world when this packet was written + +Read live, 2026-08-22: + +``` +$ npm view aether-agents versions --json +[ + "0.1.0" +] + +$ npm view aether-agents dist-tags --json +{ + "latest": "0.1.0" +} + +$ gh release list -R AetherAI3/aether-agent +(no output — zero releases) + +$ git tag -l +frozen-seam-v1 -> 48e8be477b22d86136126f40328b2856a1a07d7f +v0.1.0 -> f11bfe6b6c09fd36956958873d9bf4ad941b0fb7 +``` + +There is no `v0.2.0` tag. There is no GitHub release of any version. `0.1.0` is +the only version that has ever existed on npm. + +## 4. Evidence + +Reproduce with: + +```bash +npm run release:candidate -- --out rc.json +``` + +That runs `.github/workflows/release.yml`'s sequence against a detached +`git worktree` of `HEAD`, before any tag exists: `npm ci --ignore-scripts` → +`npm audit --audit-level=high` → typecheck → build → tests → `verify:production +--tag v0.3.0` → `npm pack` → global install of **that tarball** into a clean +prefix → CLI proofs run from the installed package. + +Recorded run — commit `fb96ee44b03f37a386954a32412728fa7e98a046`, `commitBound: true`, +`ok: true`, process exit 0: + +``` +PASS commit-identity — fb96ee44b03f37a386954a32412728fa7e98a046 +PASS stage-commit — detached worktree of that commit +PASS npm-ci-ignore-scripts — found 0 vulnerabilities +PASS npm-audit-high — found 0 vulnerabilities +PASS typecheck — tsc --noEmit exit 0 +PASS build — copied 18 built-in skill assets → dist/src/skills/builtin +PASS release-tests — 4 release test files, exit 0 +NOT-RUN npm-test — NOT RUN here — the full suite is release.yml's gate. + This report says nothing about it. +PASS verify-production — {"ok":true,"package":"aether-agents","version":"0.3.0", + "packedFiles":575,"packedBytes":3022168,"workflows":3} +PASS pack — aether-agents-0.3.0.tgz + sha256:70a48aca8baa8b63f551980256eafa42531cd22fc5ca1146829d31f8b4bd2e4d +PASS install-tarball — /node_modules/aether-agents +PASS installed --version — 0.3.0 +PASS installed --help — 49 lines, lists skills, capabilities, resume, agent, doctor +PASS installed skills list — aether/frontend-from-screenshot@1.0.0 builtin enabled … +PASS installed capabilities — instructions unavailable (agent_skills_disabled) +PASS installed demo:handoff — independent test run in machine-b/slugify: green + +RELEASE CANDIDATE OK +``` + +The last five lines all ran the CLI that `npm install --global` placed on disk +from that exact tarball, in a clean prefix — not `dist/` in a source checkout. + +**The digest changed when the base moved, and that is the point.** This packet +has now recorded three different digests for the same version number, one per +base: `25f33524…` at `a63e1c6e` over 524 entries, `8c5c119d…` at `426b124` over +527, and `70a48aca…` at the head that merges `a845479` over 575. Each lane that +landed genuinely changed the packed content — #98 added the dispatch table, +#93–#102 added the review/ship rail, #99 the session library, #105 the routing +guard — so the digest moved with it. A digest that had survived those changes +would have meant the pack was not reading the tree. + +One thing the digest is deliberately insensitive to: **this packet is not in the +tarball.** The `files` allowlist is `dist/src` plus README, COMMANDS, LICENSE and +NOTICE, so `docs/` ships to nobody and writing this number down here cannot +change it. That is why the digest above, measured at the evidence commit, still +describes the commit that records it. +had survived that change would have meant the pack was not reading the tree. + +Neither figure is a cross-machine reproducibility claim (see §5), and neither is +the digest a founder should tag against: §6.2 re-runs the candidate on the merge +commit, because any lane landing before the tag moves this number again. + +Independently, `npm run typecheck` exits 0 and the release-owned test files — +`version`, `release_coherence`, `release_canaries`, `production_hardening` — +report 25 pass / 0 fail. The full suite is 1464 tests: 1460 pass, 0 fail, 4 +skipped. + +### Mutation check on the load-bearing gate + +`test/release_coherence.test.ts` asserts that every feature the release notes +promise has its code inside the file list `npm pack` would ship. To show that +gate is real, `"!dist/src/commands/skills.js"` was added to the `files` +allowlist, which silently drops `aether skills` from the tarball: + +``` +verify:production -> {"ok":true, ... "packedFiles":526} MISSED IT +release_coherence -> FAIL: dist/src/commands/skills.js + (agent skills runtime — `aether skills`) +``` + +Restored: 527 packed files, 10/10 pass. The pre-existing production gate does not +catch a dropped feature, because it does not know what the notes promised. + +### Packaged file manifest + +527 entries, 2,459,474 bytes unpacked. Five files at the package root, everything +else under `dist/src/` — the allowlist is `dist/src` plus four documents, and +nothing else reaches a user. + +| Path | Entries | Size | +|---|---:|---:| +| `COMMANDS.md`, `LICENSE`, `NOTICE.md`, `README.md`, `package.json` | 5 | — | +| `dist/src/core/**` | 273 | 1305.8 KiB | +| `dist/src/ui/**` | 111 | 331.2 KiB | +| `dist/src/commands/**` | 105 | 648.5 KiB | +| `dist/src/skills/**` (six built-in skills) | 18 | 21.7 KiB | +| `dist/src/generated/**` | 3 | 15.0 KiB | +| `dist/src/{index,main,types,version}.*` | 12 | 25.7 KiB | + +By extension: 168 `.js`, 168 `.d.ts`, 168 `.js.map`, 13 `.json`, 9 `.md`, 1 +extensionless. Source maps ship, as they did in 0.1.0; that is existing policy, +unchanged by this release. + +No compiled tests, no `.env`, no `.tgz`, no `dist/scripts`. `verify-production` +rejects each of those by name and the pack report above confirms their absence. + +### Commands that ship without a release note + +Gate B above runs notes → package: a claim with no code behind it fails. The +inverse — a user-visible command that ships with **no claim anywhere in the +notes** — is the direction that actually keeps happening, and it happened to this +very release while its PR was open (#98). `release_coherence` now enforces both +directions: every visible command in the CLI registry must be announced by some +release note, or named here with a reason. + +These 15 commands ship in 0.3.0 without a release note. All predate the release +log or were announced by capability rather than by command token in an earlier +entry. **None of them is new in this release:** + +`aether help`, `aether chat`, `aether run`, `aether agents`, `aether github`, +`aether vault`, `aether workflow`, `aether memory`, `aether image`, +`aether video`, `aether output`, `aether audit`, `aether receipt`, `aether mcp`, +`aether config`. + +Two changes since the candidate was first cut, both made by the gate rather +than by hand: + +- **`aether auth` left the list.** The v0.3.0 notes now carry an Authentication + section naming `aether auth login` and `aether auth logout`, so it is + announced, and the list refuses to keep an exemption for a command that is. +- **`aether output` joined it.** It was never announced by command token; it was + covered by the 2026-08-14 entry as "durable media output history". It only + became visible here because the announcement matcher was tightened (below). + +The four commands this release DOES add — `aether review`, `aether ship`, +`aether sessions`, and the already-announced `aether doctor` — are all announced +in the v0.3.0 entry, so none of them appears above. + +`login` and `logout` are exempt by rule: the registry marks them `hidden`, so +they are not surfaced in `aether --help` and there is no surface to announce. + +The list is enforced in both directions — a stale entry fails, and an entry that +*is* announced fails — so it cannot rot into a permanent bypass that quietly +absorbs the next unannounced command. **If a lane lands a new command before the +tag is cut, the build fails until it is either announced or added here.** + +#### The announcement matcher was vacuous, and is not any more + +Worth reading before trusting the list above. The matcher counted a command as +announced if the notes contained its name in bare backticks *anywhere*. When +#102 landed `aether review` and `aether ship`, the gate passed both of them +immediately — not because they were announced, but because the notes mention the +built-in **skills** named `review-pr` and `ship`. The gate built to catch an +unannounced command would have let the two headline commands of this release +through in silence. + +It now requires the form a user actually types, `aether `. A test asserts +the matcher rejects `review-pr` as an announcement of `review` and `` `ship` `` +as an announcement of `ship`, so this cannot regress into coverage that is not +there. Tightening it is what surfaced `aether output`. + +#### Mutation check on the inverse gate + +A command was added to the CLI registry and mentioned nowhere: + +``` +{ name: "teleport", args: "", summary: "beam the working tree somewhere", + section: "System" } + +release_coherence -> FAIL: no user-visible command ships without either a + release note or a named exemption + + [ 'teleport — beam the working tree somewhere' ] + - [] +``` + +`verify:production` reported `ok:true` throughout. Removed: 9/9 pass. + +## 5. What is NOT proven + +Named as unproven rather than omitted: + +- **npm availability of 0.3.0.** Nothing here contacted the registry to publish. + Until step 6 completes, `npm i -g aether-agents` installs `0.1.0`. +- **The full `npm test` suite inside the candidate run.** The release-candidate + run executes the release-owned test files only and reports `npm-test` as + `not-run`; `--full-tests` includes it. The suite *was* run separately on this + machine at the evidence commit — 1464 tests, 1460 pass, 0 fail, 4 skipped — + and that reading is recorded in §4 and in the PR body. It is a local reading: + `release.yml` and the PR's required checks are the authority. +- **Anything about the deployed API.** Two claims in the release notes' + Authentication section describe a *server*, not this package: that the API + accepts long-lived `aek_` tokens, and that `/auth/logout` actually ends the + session. This repository has no live credential in CI and no test asserts + either. They are marked in the notes as operator-verified, and they should be + re-checked against production before the release is announced. What this lane + can say is narrower: `npm run smoke` on this machine reached + `https://api.aethersystems.net/cloud` and got an authenticated cloud turn + back, which is consistent with the first claim and proves nothing about the + second. +- **That `aether agent` works against production today.** #105 exists because + it does not: `AETHER_AGENT_DEV_ENABLED` is unset on `api.aethersystems.net`, + so a dev session is refused and — as of this release — the run now **exits 3** + instead of silently becoming a chat. That is the correct behaviour and it is + still a broken end-to-end path. It is a server configuration gap, not + something this tag fixes. +- **Reproducibility of the tarball digest across machines.** The digest below is + what this machine produced. It is recorded so the CI-built tarball can be + compared against it, not asserted to be byte-identical on other hosts. +- **`npm audit` against future advisories.** The audit result is a reading taken + at pack time, not a standing property. + +## 6. Founder-owned actions + +These are the only remaining steps, and none of them were run from this lane. +`AA-REL-01` created no tag, published no release, and contacted no registry. + +1. **Merge this PR to `main`.** Note the merge commit SHA; the tag must point at + it, and `release.yml` refuses to publish a tag that is not an ancestor of + `origin/main`. + +2. **Re-run the candidate on the merge commit**, so the tag is created against + evidence for the exact commit being tagged: + + ```bash + git fetch origin main && git checkout + npm ci --ignore-scripts && npm run release:candidate -- --out rc-final.json + ``` + + Confirm `"ok": true` and `"commitBound": true`. + +3. **Create the tag on that commit:** + + ```bash + git tag -a v0.3.0 -m "v0.3.0 — skills, and a release that matches the repository" + git push origin v0.3.0 + ``` + +4. **Publish a GitHub release for `v0.3.0`**, body taken from + [`2026-08-22.md`](2026-08-22.md). Publication — not tag creation — is what + triggers `release.yml`. + +5. **Confirm the prerequisites `release.yml` needs before publishing the + release**, because a missing one fails the run after the release is already + public: + - the `npm-production` environment exists on the repository; + - `NPM_TOKEN` is set in it (the workflow asserts the secret is non-empty + before it publishes); + - the workflow's `id-token: write` / `attestations: write` permissions are + not restricted by an organisation policy. + +6. **Watch the `Release npm package` workflow.** It re-runs the whole sequence on + the tag, attests provenance, uploads the tarball plus a CycloneDX SBOM as a + 90-day artifact, and only then runs `npm publish --provenance`. + +7. **Verify availability from the registry, not from the workflow log:** + + ```bash + npm view aether-agents versions --json # must now include 0.3.0 + npm view aether-agents dist-tags --json # latest must be 0.3.0 + ``` + + Compare the published tarball's sha256 with the digest in this packet and + with the workflow artifact's. + +8. **Only after step 7 succeeds**, update the availability language in + `README.md` and `RELEASE_NOTES.md` to say 0.3.0 installs from npm. Until that + proof exists, the repository must keep saying `npm i -g` gives you `0.1.0` — + `test/release_coherence.test.ts` enforces that the claim cannot be added + without the registry actually serving it. + +## 7. Credentials + +No Aether credential, npm token or GitHub token was used, read, or written by +the release-candidate run. `release.yml` reaches `NPM_TOKEN` only inside the +`npm-production` environment, and the checkout step sets +`persist-credentials: false` so no token is left in the runner's git config. diff --git a/docs/releases/README.md b/docs/releases/README.md index b621ee7..88c3fde 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -6,8 +6,13 @@ per PR. For the live command reference, see [COMMANDS.md](../COMMANDS.md). +Each release also carries an operator packet — `OPERATOR-PACKET-v.md` — +recording the commit the tag must point at, the packed tarball's digest and file +manifest, the evidence gathered, and the founder-owned steps that publish it. + ## Index -- [2026-08-19](2026-08-19.md) — **v0.2.0**: portable handoffs, `--resume` reaches the brain, `--local ""` works out of the box. +- [2026-08-22](2026-08-22.md) — **v0.3.0**: the `aether review` → `aether ship` rail, `aether sessions`, skills enforced inside real runs, `aether skills`, `aether capabilities`, `aether support-bundle`, a coding run that refuses to become a chat, plus the unpublished 0.2.0 work and twelve fixes. Packet: [OPERATOR-PACKET-v0.3.0.md](OPERATOR-PACKET-v0.3.0.md). +- [2026-08-19](2026-08-19.md) — **v0.2.0** *(never released; superseded by v0.3.0)*: portable handoffs, `--resume` reaches the brain, `--local ""` works out of the box. - [2026-08-14](2026-08-14.md) — Durable media output history, one safe opener, and `aether doctor` v2 (fast / `--live` / `--fix`). - [2026-06-09](2026-06-09.md) — Aether Agent rebrand + slash-command console (PRs #4–#16). diff --git a/package-lock.json b/package-lock.json index 59a4be2..97e3821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aether-agents", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aether-agents", - "version": "0.1.0", + "version": "0.3.0", "license": "Apache-2.0", "bin": { "aether": "dist/src/main.js", diff --git a/package.json b/package.json index 07647cb..1122e2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aether-agents", - "version": "0.2.0", + "version": "0.3.0", "description": "Open-source terminal coding agent — runs on hosted frontier models (Claude, GPT, DeepSeek, Kimi, Gemma) or fully offline via Ollama. Edits your code, runs your tests, and verifies the result.", "type": "module", "bin": { @@ -34,6 +34,7 @@ "test": "npm run build && node --test --test-isolation=none \"dist/test/**/*.test.js\"", "smoke": "npm run build && node -e \"import('./dist/src/core/smoke.js').then(m=>m.smokeMain()).then(c=>process.exit(c)).catch(e=>{console.error(e);process.exit(1)})\"", "verify:production": "npm run build && node dist/scripts/verify-production.js", + "release:candidate": "npm run build && node dist/scripts/release-candidate.js", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "prepack": "npm run build", "demo:handoff": "npm run build && node dist/scripts/handoff-demo.js" diff --git a/scripts/release-candidate.ts b/scripts/release-candidate.ts new file mode 100644 index 0000000..44179a4 --- /dev/null +++ b/scripts/release-candidate.ts @@ -0,0 +1,488 @@ +// scripts/release-candidate.ts — run the production release sequence locally, +// against a specific commit, and emit evidence bound to that commit. +// +// npm run release:candidate +// npm run release:candidate -- --out rc.json --full-tests +// +// Why this exists. `.github/workflows/release.yml` only runs after a founder has +// already created a tag and published a GitHub release. Everything it checks is +// therefore checked too late to change the decision. This script runs the same +// sequence, in the same order, before the tag exists — so the tag can be created +// against evidence instead of hope. +// +// Three properties it does not compromise on: +// +// 1. COMMIT-BOUND. The sequence runs against a detached `git worktree` of a +// specific commit, never against the dirty checkout you are sitting in. A +// candidate produced from uncommitted edits proves nothing about what a tag +// would resolve to, so a dirty tree is refused unless you pass --allow-dirty +// (which marks the report commitBound:false rather than pretending). +// +// 2. THE TARBALL IS THE ARTIFACT. Every CLI proof runs the binary that `npm +// install` placed on disk from the packed tarball — not `dist/` in a source +// checkout, which contains files the package allowlist excludes. A committed +// tarball is exactly the defect #83 introduced; a source-tree "smoke test" +// is the same class of lie in a different wrapper. +// +// 3. UNKNOWN IS NOT PASS. Every step lands in the report as "pass", "fail", or +// "not-run". A step that was skipped — the full test suite without +// --full-tests, for instance — is reported as not-run and can never be read +// as a green tick. +// +// No string is ever handed to a shell: every child process is spawned with an +// argument array, including the npm invocations (npm is reached through its own +// JS entrypoint under `node`, which also avoids the .cmd shell requirement on +// Windows). + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type StepStatus = "pass" | "fail" | "not-run"; + +export interface StepResult { + name: string; + status: StepStatus; + /** Why it is not-run, or what failed. Empty on a clean pass. */ + detail: string; + durationMs: number; +} + +export interface PackedFile { + path: string; + size: number; +} + +export interface CandidateReport { + package: string; + version: string; + proposedTag: string; + commit: string; + commitSubject: string; + commitBound: boolean; + generatedAt: string; + tarball: { filename: string; sha256: string; bytes: number; unpackedBytes: number; entryCount: number } | null; + manifest: PackedFile[]; + steps: StepResult[]; + ok: boolean; +} + +// ── process helpers ───────────────────────────────────────────────────────── + +/** + * npm's own JS entrypoint, so npm can be run as `node ...` with a plain + * argument array. Spawning `npm.cmd` would require shell:true on Windows, which + * concatenates arguments back into a command string — the exact construction the + * repository forbids. + */ +export function npmCliPath(): string { + const fromEnv = process.env["npm_execpath"]; + if (fromEnv && fromEnv.endsWith(".js") && existsSync(fromEnv)) return fromEnv; + const nodeDir = dirname(process.execPath); + const candidates = [ + join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js"), + join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ]; + for (const candidate of candidates) if (existsSync(candidate)) return resolve(candidate); + throw new Error("could not locate npm's JS entrypoint; run this through `npm run release:candidate`"); +} + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +function run(command: string, args: string[], cwd: string, timeoutMs = 900_000): RunResult { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + timeout: timeoutMs, + stdio: ["ignore", "pipe", "pipe"], + // No shell. Ever. Arguments stay arguments. + shell: false, + }); + if (result.error) return { code: null, stdout: result.stdout ?? "", stderr: String(result.error.message) }; + return { code: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; +} + +function tail(text: string, lines = 12): string { + return text.trim().split(/\r?\n/).slice(-lines).join("\n").trim(); +} + +/** + * The part of a node:test run that says what broke. + * + * The last N lines of a test run are the summary and, often, the tail of the + * PASSING output — so a plain tail() of a failed suite can read as if it + * succeeded. Prefer the failure block; fall back to the tail only when there + * isn't one. + */ +function failureExcerpt(stdout: string, lines = 30): string { + const marker = stdout.indexOf("failing tests:"); + if (marker >= 0) return tail(stdout.slice(marker), lines); + const failed = stdout + .split(/\r?\n/) + .filter((line) => /^\s*(?:✖|not ok\b)/.test(line) || /AssertionError|Error:/.test(line)); + return failed.length > 0 ? failed.slice(0, lines).join("\n") : tail(stdout, lines); +} + +// ── the runner ────────────────────────────────────────────────────────────── + +class Sequence { + readonly steps: StepResult[] = []; + private failed = false; + + /** Record a step. Once one fails, every later step is recorded as not-run. */ + step(name: string, body: () => string | undefined): boolean { + if (this.failed) { + this.steps.push({ name, status: "not-run", detail: "an earlier step failed", durationMs: 0 }); + return false; + } + const started = Date.now(); + try { + const detail = body(); + this.steps.push({ name, status: "pass", detail: detail ?? "", durationMs: Date.now() - started }); + return true; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.steps.push({ name, status: "fail", detail, durationMs: Date.now() - started }); + this.failed = true; + return false; + } + } + + skip(name: string, why: string): void { + this.steps.push({ name, status: "not-run", detail: why, durationMs: 0 }); + } + + get ok(): boolean { + return !this.failed && this.steps.every((s) => s.status !== "fail"); + } +} + +export interface CandidateOptions { + /** Proceed against a dirty tree; the report is then marked commitBound:false. */ + allowDirty?: boolean; + /** Run the whole `npm test` suite (release.yml does; it is slow off CI). */ + fullTests?: boolean; + /** Where scratch worktrees, packs and install prefixes go. */ + scratchRoot?: string; +} + +export function runReleaseCandidate(repoRoot: string, options: CandidateOptions = {}): CandidateReport { + const seq = new Sequence(); + const npmCli = npmCliPath(); + const npm = (args: string[], cwd: string, timeoutMs?: number): RunResult => + run(process.execPath, [npmCli, ...args], cwd, timeoutMs); + + const manifestPath = join(repoRoot, "package.json"); + const pkg = JSON.parse(readFileSync(manifestPath, "utf8")) as { name: string; version: string }; + const version = pkg.version; + const proposedTag = `v${version}`; + + let commit = ""; + let commitSubject = ""; + let commitBound = true; + + seq.step("commit-identity", () => { + const head = run("git", ["rev-parse", "HEAD"], repoRoot); + if (head.code !== 0) throw new Error(`git rev-parse HEAD failed: ${tail(head.stderr)}`); + commit = head.stdout.trim(); + const subject = run("git", ["log", "-1", "--format=%s", commit], repoRoot); + commitSubject = subject.code === 0 ? subject.stdout.trim() : ""; + const status = run("git", ["status", "--porcelain"], repoRoot); + if (status.code !== 0) throw new Error(`git status failed: ${tail(status.stderr)}`); + const dirty = status.stdout.trim(); + if (dirty) { + commitBound = false; + if (!options.allowDirty) { + throw new Error( + `working tree is dirty, so this candidate would not be bound to ${commit.slice(0, 8)}:\n${tail(dirty, 20)}`, + ); + } + return `PROCEEDING AGAINST A DIRTY TREE (--allow-dirty): evidence is NOT bound to ${commit.slice(0, 8)}`; + } + return `${commit} — ${commitSubject}`; + }); + + const scratchRoot = options.scratchRoot ?? tmpdir(); + mkdirSync(scratchRoot, { recursive: true }); + const scratch = mkdtempSync(join(scratchRoot, "aether-rc-")); + const stage = join(scratch, "src"); + const packDir = join(scratch, "pack"); + const prefix = join(scratch, "prefix"); + let worktreeAdded = false; + + let tarball: CandidateReport["tarball"] = null; + let manifest: PackedFile[] = []; + + const report = (): CandidateReport => ({ + package: pkg.name, + version, + proposedTag, + commit, + commitSubject, + commitBound, + generatedAt: new Date().toISOString(), + tarball, + manifest, + steps: seq.steps, + ok: seq.ok && commitBound, + }); + + try { + // 1. Stage the commit. release.yml checks out the tag into a clean tree; + // a detached worktree is the same thing without needing the tag to exist. + seq.step("stage-commit", () => { + if (options.allowDirty && !commitBound) { + cpSyncTree(repoRoot, stage); + return "copied the dirty working tree (--allow-dirty)"; + } + const added = run("git", ["worktree", "add", "--detach", stage, commit], repoRoot); + if (added.code !== 0) throw new Error(`git worktree add failed: ${tail(added.stderr || added.stdout)}`); + worktreeAdded = true; + return stage; + }); + + // 2..4 mirror release.yml: ci --ignore-scripts, audit, then the suite. + seq.step("npm-ci-ignore-scripts", () => { + const r = npm(["ci", "--ignore-scripts"], stage); + if (r.code !== 0) throw new Error(`npm ci failed: ${tail(r.stderr || r.stdout)}`); + return tail(r.stdout, 2); + }); + + seq.step("npm-audit-high", () => { + const r = npm(["audit", "--audit-level=high"], stage); + if (r.code !== 0) throw new Error(`npm audit --audit-level=high failed: ${tail(r.stdout || r.stderr)}`); + return tail(r.stdout, 3); + }); + + seq.step("typecheck", () => { + const r = npm(["run", "typecheck"], stage); + if (r.code !== 0) throw new Error(`typecheck failed: ${tail(r.stderr || r.stdout)}`); + return "tsc --noEmit exit 0"; + }); + + seq.step("build", () => { + const r = npm(["run", "build"], stage); + if (r.code !== 0) throw new Error(`build failed: ${tail(r.stderr || r.stdout)}`); + return tail(r.stdout, 2); + }); + + if (options.fullTests) { + seq.step("npm-test", () => { + const r = npm(["test"], stage, 3_600_000); + if (r.code !== 0) throw new Error(`npm test failed: ${failureExcerpt(r.stdout || r.stderr)}`); + return tail(r.stdout, 6); + }); + seq.skip("release-tests", "covered by the full npm test run"); + } else { + // The whole suite is release.yml's gate and stays so. Here we run only the + // release-owned files, and say plainly that the rest was NOT run. + seq.step("release-tests", () => { + const files = RELEASE_TEST_FILES.map((name) => join(stage, "dist", "test", name)).filter((p) => existsSync(p)); + if (files.length !== RELEASE_TEST_FILES.length) { + const missing = RELEASE_TEST_FILES.filter((n) => !existsSync(join(stage, "dist", "test", n))); + throw new Error(`compiled release tests missing: ${missing.join(", ")}`); + } + const r = run(process.execPath, ["--test", ...files], stage, 600_000); + if (r.code !== 0) throw new Error(`release tests failed: ${failureExcerpt(r.stdout || r.stderr)}`); + return `${RELEASE_TEST_FILES.length} release test files, exit 0`; + }); + seq.skip( + "npm-test", + "NOT RUN here — the full suite is release.yml's gate. This report says nothing about it.", + ); + } + + // 5. The production policy gate, bound to the tag we intend to create. + seq.step("verify-production", () => { + const r = npm(["run", "verify:production", "--", "--tag", proposedTag], stage); + if (r.code !== 0) throw new Error(`verify:production failed: ${tail(r.stdout || r.stderr, 30)}`); + // verify:production builds first, so its own JSON verdict is the LAST + // line; a 2-line tail would report the build's output as the result. + return tail(r.stdout, 1); + }); + + // 6. Pack, and take the digest of the exact bytes. + seq.step("pack", () => { + mkdirSync(packDir, { recursive: true }); + const r = npm(["pack", "--json", "--ignore-scripts", "--pack-destination", packDir], stage); + if (r.code !== 0) throw new Error(`npm pack failed: ${tail(r.stderr || r.stdout)}`); + const parsed = JSON.parse(extractJson(r.stdout)) as Array<{ + filename: string; + size: number; + unpackedSize: number; + entryCount: number; + files: PackedFile[]; + }>; + const packed = parsed[0]; + if (!packed?.filename) throw new Error("npm pack produced no filename"); + const file = join(packDir, packed.filename); + const bytes = readFileSync(file); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + tarball = { + filename: packed.filename, + sha256, + bytes: bytes.byteLength, + unpackedBytes: packed.unpackedSize, + entryCount: packed.entryCount, + }; + manifest = packed.files.map((f) => ({ path: f.path.replaceAll("\\", "/"), size: f.size })); + return `${packed.filename} sha256:${sha256}`; + }); + + // 7. Install THAT tarball into a clean prefix. Everything after this point + // exercises the installed package, not the source checkout. + seq.step("install-tarball", () => { + if (!tarball) throw new Error("no tarball to install"); + const file = join(packDir, tarball.filename); + const r = npm(["install", "--global", "--prefix", prefix, file, "--ignore-scripts"], stage); + if (r.code !== 0) throw new Error(`global install failed: ${tail(r.stderr || r.stdout)}`); + if (!existsSync(installedPackageDir(prefix))) throw new Error(`install left no package at ${installedPackageDir(prefix)}`); + return installedPackageDir(prefix); + }); + + // Prefer the bin shim npm actually put on PATH — that is the entrypoint a + // user runs. Windows global installs produce aether.cmd, which would need a + // shell to invoke, so there we run the package's own main.js under node: + // same file the shim would reach, without building a command string. + const cli = (args: string[], cwd: string): RunResult => { + const shim = join(prefix, "bin", "aether"); + if (process.platform !== "win32" && existsSync(shim)) return run(shim, args, cwd, 180_000); + const entry = join(installedPackageDir(prefix), "dist", "src", "main.js"); + return run(process.execPath, [entry, ...args], cwd, 180_000); + }; + + seq.step("installed --version", () => { + const r = cli(["--version"], scratch); + if (r.code !== 0) throw new Error(`--version exited ${String(r.code)}: ${tail(r.stderr)}`); + const reported = r.stdout.trim(); + if (reported !== version) throw new Error(`installed CLI reported ${reported}, expected ${version}`); + return reported; + }); + + seq.step("installed --help", () => { + const r = cli(["--help"], scratch); + if (r.code !== 0) throw new Error(`--help exited ${String(r.code)}: ${tail(r.stderr)}`); + for (const command of HELP_MUST_LIST) { + if (!r.stdout.includes(command)) throw new Error(`--help does not list the ${command} command`); + } + return `${r.stdout.split(/\r?\n/).length} lines, lists ${HELP_MUST_LIST.join(", ")}`; + }); + + seq.step("installed skills list", () => { + const r = cli(["skills", "list"], scratch); + if (r.code !== 0) throw new Error(`skills list exited ${String(r.code)}: ${tail(r.stderr || r.stdout)}`); + if (!r.stdout.trim()) throw new Error("skills list printed nothing"); + return tail(r.stdout, 4); + }); + + seq.step("installed capabilities", () => { + const r = cli(["capabilities"], scratch); + if (r.code !== 0) throw new Error(`capabilities exited ${String(r.code)}: ${tail(r.stderr || r.stdout)}`); + if (!r.stdout.trim()) throw new Error("capabilities printed nothing"); + return tail(r.stdout, 4); + }); + + // 8. The handoff demo, driven against the INSTALLED package. The demo + // harness is not shipped (the allowlist is dist/src plus four docs), so + // the harness is copied beside the installed package and resolves the CLI + // and its imports from the package's own dist/src — the tarball's code, + // not the checkout's. + seq.step("installed demo:handoff", () => { + const source = join(stage, "dist", "scripts", "handoff-demo.js"); + if (!existsSync(source)) throw new Error(`built handoff demo missing at ${source}`); + const installed = installedPackageDir(prefix); + const target = join(installed, "dist", "scripts", "handoff-demo.js"); + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target); + const r = run(process.execPath, [target], scratch, 600_000); + if (r.code !== 0) throw new Error(`handoff demo exited ${String(r.code)}: ${tail(r.stdout || r.stderr, 30)}`); + return tail(r.stdout, 6); + }); + } finally { + // Cleanup must never replace the verdict. A locked file in a Windows global + // prefix is a housekeeping problem; throwing here would discard a completed + // report and read as a failed release candidate. + try { + if (worktreeAdded) run("git", ["worktree", "remove", "--force", stage], repoRoot); + rmSync(scratch, { recursive: true, force: true }); + } catch { + // leave the scratch directory behind rather than lose the evidence + } + } + + return report(); +} + +/** `/node_modules/aether-agents` on POSIX, `/node_modules/...` on Windows too. */ +function installedPackageDir(prefix: string): string { + return join(prefix, "node_modules", "aether-agents"); +} + +/** + * The JSON array from `npm pack --json`. + * + * npm writes notices to stderr, but a warning line on stdout containing a + * bracket would derail a first-bracket-to-last-bracket slice, so anchor on the + * first line that BEGINS a JSON array instead. + */ +function extractJson(stdout: string): string { + const lines = stdout.split(/\r?\n/); + const start = lines.findIndex((line) => line.trimStart().startsWith("[")); + if (start < 0) throw new Error("npm pack did not emit a JSON array"); + const text = lines.slice(start).join("\n"); + const end = text.lastIndexOf("]"); + if (end < 0) throw new Error("npm pack emitted an unterminated JSON array"); + return text.slice(0, end + 1); +} + +function cpSyncTree(from: string, to: string): void { + mkdirSync(to, { recursive: true }); + cpSync(from, to, { + recursive: true, + filter: (src) => !/[\\/](node_modules|dist|\.git)([\\/]|$)/.test(src), + }); +} + +/** + * Release-owned test files, by compiled name. These run in the default (fast) + * mode; the rest of the suite is release.yml's job and is reported not-run. + */ +export const RELEASE_TEST_FILES = [ + "version.test.js", + "release_coherence.test.js", + "release_canaries.test.js", + "production_hardening.test.js", +]; + +/** Commands `aether --help` must name, because the release notes promise them. */ +export const HELP_MUST_LIST = ["skills", "capabilities", "resume", "agent", "doctor"]; + +// ── CLI ───────────────────────────────────────────────────────────────────── + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === resolve(fileURLToPath(import.meta.url))) { + const argv = process.argv.slice(2); + const outAt = argv.indexOf("--out"); + const out = outAt >= 0 ? argv[outAt + 1] : undefined; + if (outAt >= 0 && !out) throw new Error("--out requires a path"); + const result = runReleaseCandidate(process.cwd(), { + allowDirty: argv.includes("--allow-dirty"), + fullTests: argv.includes("--full-tests"), + ...(process.env["AETHER_RC_SCRATCH"] ? { scratchRoot: process.env["AETHER_RC_SCRATCH"] } : {}), + }); + const text = `${JSON.stringify(result, null, 2)}\n`; + if (out) writeFileSync(out, text); + process.stdout.write(text); + for (const step of result.steps) { + process.stderr.write(`${step.status.toUpperCase().padEnd(8)} ${step.name}${step.detail ? ` — ${step.detail.split("\n")[0]}` : ""}\n`); + } + process.stderr.write(result.ok ? "\nRELEASE CANDIDATE OK\n" : "\nRELEASE CANDIDATE NOT OK\n"); + process.exit(result.ok ? 0 : 1); +} diff --git a/scripts/verify-production.ts b/scripts/verify-production.ts index 981e636..d03ad6c 100644 --- a/scripts/verify-production.ts +++ b/scripts/verify-production.ts @@ -170,7 +170,14 @@ function readJson(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")) as unknown; } -function createPackReport(root: string): PackReport { +/** + * What `npm pack` would actually ship from `root`, as a dry run. + * + * Exported because the source checkout's `dist/` is NOT the package — the files + * allowlist is `dist/src` plus four docs — so any gate reasoning about what a + * user receives has to ask npm rather than read the build directory. + */ +export function createPackReport(root: string): PackReport { const npmCli = process.env["npm_execpath"]; const command = npmCli ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm"; const args = npmCli diff --git a/src/version.ts b/src/version.ts index f26929c..b3510d2 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Single source of the CLI version (kept in lockstep with package.json). -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; diff --git a/test/release_canaries.test.ts b/test/release_canaries.test.ts index 86dac8a..a3868a1 100644 --- a/test/release_canaries.test.ts +++ b/test/release_canaries.test.ts @@ -12,15 +12,19 @@ // 4 remote freshness test/worktree.test.ts (real git, pinned base) // 5 fake-gh ship test/ship_rail.test.ts + test/review_ship_e2e.test.ts // 6 cap across reconnect HERE -// 7 brain parity NOT WRITABLE — see the end of this file +// 7 brain parity test/brain_parity.test.ts (injectable seam, #87) // -// 5 and 7 are deliberately absent rather than stubbed. A test that asserts -// nothing is worse than a gap, because it reads as coverage. +// This map was accurate when written and stopped being accurate two commits +// later: #86 landed the ship rail and canary 5 with it, #87 landed the parity +// seam and canary 7. The closing note that said both were unwritable outlived +// the condition it described. A stale "not covered" reads as a standing excuse +// not to write the test, which is how a gap survives being closed. import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { tmpWorkspace } from "./tmp_workspace.js"; import { ToolExecutor } from "../src/core/tool_executor.js"; import { ContextRegistry } from "../src/core/context_registry.js"; @@ -126,12 +130,37 @@ test("canary 6b: an unmeasured session is never reported as capped", () => { // entry points: a real repository, a real bare // remote, the refs that actually moved, and // the exact `gh pr create` argv. + +// ── The map above is enforced, not asserted in prose ──────────────────────── // -// ── The one that still cannot be written ──────────────────────────────────── -// -// Canary 7 — local/Ollama brain parity. -// LocalBrain spawns a Python module that is not vendored here and exposes no -// injectable transport, so no test can drive it. Comparing normalized -// transcripts needs a seam on the Python path first. -// -// It is tracked as a gap rather than stubbed green. +// Canaries 2, 4, 5 and 7 live in other files. A coverage map that only claims +// they exist is a comment, and comments do not fail when the file they name is +// deleted or renamed — which is precisely how the previous version of this +// header went stale in the other direction. +// It also went stale in this one: main's closing note still called canary 7 +// unwritable after #87 had landed the injectable parity seam and +// test/brain_parity.test.ts. Enforcing the map is what stops either kind of +// untruth from surviving a merge. + +test("every canary this file delegates is a real file with real assertions", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const delegated: Array<[string, string]> = [ + ["canary 2 (live-child cancel)", "process_tree.test.js"], + ["canary 4 (remote freshness)", "worktree.test.js"], + ["canary 5 (fake-gh ship, argv boundary)", "ship_rail.test.js"], + ["canary 5 (fake-gh ship, end to end)", "review_ship_e2e.test.js"], + ["canary 7 (brain parity)", "brain_parity.test.js"], + ]; + for (const [canary, file] of delegated) { + // Read the TypeScript source, not the compiled copy: a test file that + // compiled to an empty module would still exist on disk under dist/. + const source = join(here, "..", "..", "test", file.replace(/\.js$/, ".ts")); + assert.equal(existsSync(source), true, `${canary}: ${source} does not exist`); + const text = readFileSync(source, "utf8"); + assert.ok(/\bassert\./.test(text), `${canary}: ${file} contains no assertions`); + assert.ok( + (text.match(/^test\(/gm) ?? []).length > 0, + `${canary}: ${file} declares no top-level tests`, + ); + } +}); diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts new file mode 100644 index 0000000..7b381be --- /dev/null +++ b/test/release_coherence.test.ts @@ -0,0 +1,407 @@ +// Release coherence — the repository must describe exactly one release. +// +// The defect this file exists to make impossible: +// +// On 2026-08-20 the repository described FIVE different releases at once. +// package.json said 0.2.0. package-lock.json still said 0.1.0 (the #81 bump +// never touched it). RELEASE_NOTES.md's top entry described a 0.2.0 whose +// feature list was written before #72 added `aether skills` and +// `aether capabilities`. The npm registry served 0.1.0. And a packed +// aether-agents-0.2.0.tgz sat committed in the repo root from #83 until #90 +// deleted it. Nothing was lying on purpose; the four statements simply drifted +// apart because nothing compared them. +// +// Two gates, both asserting file contents on disk rather than prose: +// +// A. VERSION IDENTITY — every place that names the release names the same one. +// B. FEATURE REACHABILITY — for every feature the release notes claim, the +// code that implements it is inside the packed tarball's file list, and the +// command that exposes it is in the CLI registry. This is the user-visible +// objective stated as an assertion: the package users install contains the +// features the repository says it contains. +// +// Gate B is the load-bearing one. verify-production.ts checks that the package +// contains its entrypoints and nothing forbidden; it has no idea what the +// release notes promised, so a feature dropped from the allowlist passes it. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createPackReport } from "../scripts/verify-production.js"; +import { ALL_CLI_COMMANDS } from "../src/commands/cli_registry.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const read = (...parts: string[]): string => readFileSync(join(root, ...parts), "utf8"); + +const pkg = JSON.parse(read("package.json")) as { name: string; version: string }; +const VERSION = pkg.version; + +// ── Gate A: one release, named consistently ───────────────────────────────── + +test("the lockfile names the same version as the manifest, in both places", () => { + // package-lock.json carries the version twice: at the root and under + // packages[""]. #81 bumped package.json and neither of these, so `npm ci` + // installed a tree that disagreed with the manifest it was built from. + const lock = JSON.parse(read("package-lock.json")) as { + name: string; + version: string; + packages: Record; + }; + assert.equal(lock.name, pkg.name); + assert.equal(lock.version, VERSION, "package-lock.json root version drifted from package.json"); + assert.equal(lock.packages[""]?.version, VERSION, 'package-lock.json packages[""] version drifted'); +}); + +test("RELEASE_NOTES.md leads with the version the package declares", () => { + const notes = read("RELEASE_NOTES.md"); + const firstHeading = notes.split(/\r?\n/).find((line) => /^#\s+\S/.test(line)); + assert.ok(firstHeading, "RELEASE_NOTES.md has no heading"); + assert.ok( + firstHeading.includes(`v${VERSION}`), + `RELEASE_NOTES.md leads with ${JSON.stringify(firstHeading)}, which does not name v${VERSION}`, + ); +}); + +test("the dated release log has an entry for this version, and the index links it", () => { + const index = read("docs", "releases", "README.md"); + const rows = index + .split(/\r?\n/) + .filter((line) => /^- \[\d{4}-\d{2}-\d{2}\]\(/.test(line)); + assert.ok(rows.length > 0, "docs/releases/README.md has no index rows"); + + const head = rows[0]!; + assert.ok(head.includes(`v${VERSION}`), `the newest index row does not name v${VERSION}: ${head}`); + + const file = /\((\d{4}-\d{2}-\d{2}\.md)\)/.exec(head)?.[1]; + assert.ok(file, `the newest index row links no dated file: ${head}`); + const path = join(root, "docs", "releases", file); + assert.ok(existsSync(path), `docs/releases/${file} is linked from the index but does not exist`); + assert.ok( + readFileSync(path, "utf8").includes(`v${VERSION}`), + `docs/releases/${file} does not name v${VERSION}`, + ); + + // Every row in the index must link a file that exists — a dead link in the + // release log is how a release becomes unauditable later. + for (const row of rows) { + const linked = /\((\d{4}-\d{2}-\d{2}\.md)\)/.exec(row)?.[1]; + assert.ok(linked, `index row links no dated file: ${row}`); + assert.ok(existsSync(join(root, "docs", "releases", linked)), `missing docs/releases/${linked}`); + } +}); + +test("an operator packet exists for this version and binds a commit", () => { + const path = join(root, "docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); + assert.ok(existsSync(path), `no docs/releases/OPERATOR-PACKET-v${VERSION}.md`); + const packet = readFileSync(path, "utf8"); + assert.ok(packet.includes(`v${VERSION}`), "the operator packet does not name the proposed tag"); + assert.match(packet, /\b[0-9a-f]{40}\b/, "the operator packet names no full commit SHA"); + assert.match(packet, /sha256[:\s]/i, "the operator packet records no tarball digest"); +}); + +// ── Gate B: the package contains what the notes promise ───────────────────── + +/** + * Every claim the current release notes make, mapped to the evidence that would + * be missing if the claim were false for an installed user. + * + * `command` must appear in the CLI registry (so `aether --help` names it); + * `packaged` paths must all appear in the packed tarball's file list. + * + * Adding a headline feature to the notes without adding a row here is the drift + * this gate cannot catch by itself; the row is the contract. + */ +const FEATURE_MANIFEST: Array<{ claim: string; command?: string; packaged: string[] }> = [ + { + claim: "the review → commit → pull request rail — `aether review`", + command: "review", + packaged: [ + "dist/src/commands/review.js", + "dist/src/core/review_state.js", + "dist/src/core/review_actions.js", + "dist/src/core/verification_record.js", + "dist/src/core/diff_counts.js", + ], + }, + { + claim: "publishing the head branch and opening the pull request — `aether ship`", + command: "ship", + packaged: [ + "dist/src/commands/ship.js", + "dist/src/core/publish.js", + "dist/src/core/ship_record.js", + ], + }, + { + claim: "the project session library — `aether sessions`", + command: "sessions", + packaged: [ + "dist/src/commands/sessions.js", + "dist/src/core/session_index.js", + "dist/src/ui/continuity.js", + ], + }, + { + claim: "skills and AGENTS.md are composed into real runs, and their policy is enforced", + packaged: ["dist/src/core/skills/run_session.js"], + }, + { + claim: "win32 URLs open through rundll32, so the device-approval page appears", + packaged: ["dist/src/core/opener.js"], + }, + { + claim: "the token store refuses planted links and writes atomically", + packaged: ["dist/src/core/auth.js"], + }, + { + claim: "portable handoffs — `aether resume export` / `aether agent --resume`", + command: "resume", + packaged: ["dist/src/core/handoff.js", "dist/src/commands/resume.js"], + }, + { + claim: "agent skills runtime — `aether skills`", + command: "skills", + packaged: [ + "dist/src/commands/skills.js", + "dist/src/core/skills/skill_schema.js", + "dist/src/core/skills/skill_trust.js", + "dist/src/core/skills/skill_session.js", + ], + }, + { + claim: "built-in skills ship inside the package, not as a separate download", + packaged: [ + "dist/src/skills/builtin/review-pr/SKILL.md", + "dist/src/skills/builtin/fix-ci/SKILL.md", + "dist/src/skills/builtin/ship/SKILL.md", + ], + }, + { + claim: "capability contract — `aether capabilities`", + command: "capabilities", + packaged: ["dist/src/commands/capabilities.js", "dist/src/generated/agent_capabilities.js"], + }, + { + claim: "redacted support bundle — `aether support-bundle`", + command: "support-bundle", + packaged: ["dist/src/commands/support_bundle.js", "dist/src/core/support_bundle.js"], + }, + { + claim: "the offline path ships in the package (`aether agent --local`)", + packaged: ["dist/src/core/brain_ollama.js", "dist/src/core/ollama.js"], + }, + { + claim: "async tool execution with process-tree teardown", + packaged: ["dist/src/core/tool_executor.js"], + }, +]; + +test("every CLI command the release notes promise is in the CLI registry", () => { + // Read the registry STRUCTURALLY, by importing it, not by regex over its + // source. #98 replaced "reachability asserted by a regex over main.ts" with a + // real dispatch table for exactly this reason, and it immediately split the + // commands across two arrays — CLI_COMMANDS and DISPATCH_COMMANDS — which a + // source regex keyed on one of them would have silently stopped covering. + const registered = new Set(ALL_CLI_COMMANDS.map((command) => command.name)); + for (const feature of FEATURE_MANIFEST) { + if (!feature.command) continue; + assert.ok( + registered.has(feature.command), + `${feature.claim}: the CLI registry declares no "${feature.command}" command`, + ); + } +}); + +test( + "every feature the release notes promise is present in the packed tarball", + { timeout: 120_000 }, + () => { + // The source checkout's dist/ is NOT the package: the files allowlist is + // dist/src plus four docs, so dist/scripts and dist/test exist on disk and + // ship to nobody. Ask npm what would actually be packed. + const packed = createPackReport(root); + const paths = new Set(packed.files.map((file) => file.path.replaceAll("\\", "/"))); + + assert.equal(packed.version, VERSION, "npm pack reports a version the manifest does not"); + assert.equal(packed.name, pkg.name); + + const missing: string[] = []; + for (const feature of FEATURE_MANIFEST) { + for (const path of feature.packaged) { + if (!paths.has(path)) missing.push(`${path} (${feature.claim})`); + } + } + assert.deepEqual( + missing, + [], + `the release notes promise features whose code is not in the tarball:\n ${missing.join("\n ")}`, + ); + + // Guard the guard: if npm ever changed the shape of its file list, every + // lookup above would miss and the assertion would still read as coverage. + assert.ok(paths.has("package.json"), "the packed file list does not look like a file list"); + assert.ok(paths.size > 50, `only ${paths.size} packed paths — the pack report is not what this gate assumes`); + }, +); + +// ── Gate C: the package promises nothing the notes are silent about ───────── +// +// Gate B runs notes -> package: a claim with no code behind it fails. That is +// only one direction, and it is not the direction that actually keeps happening. +// +// The other direction is a user-visible command that ships with NO claim +// anywhere in the notes. It happened while this very lane's PR was open: #98 +// landed on main after the v0.3.0 notes were written, and the notes said nothing +// about it. It will happen again for every lane that lands between the note +// being written and the tag being cut. A release that silently ships a command +// nobody announced is the same defect as a note that promises one nobody built, +// arriving from the side nothing was watching. +// +// The rule: every user-visible command is either announced by SOME release note, +// or named here with a reason. An explicit list is fine. Silence is not. + +/** + * Commands that ship without a release note, each with the reason it is + * acceptable. Entries are enforced in both directions — a stale entry fails, + * and an entry that IS announced fails — so this cannot rot into a permanent + * bypass that quietly absorbs the next unannounced command. + * + * Every entry must also be named in the operator packet, so the founder cutting + * the tag reads the full unannounced set before creating it. + */ +const SHIPPED_WITHOUT_A_NOTE: Record = { + help: "predates the release log; the CLI has never shipped without it", + chat: "predates the release log", + run: "predates the release log", + agents: "predates the release log", + github: "predates the release log", + vault: "the June 2026 entry announces the vault surface, not the command token", + workflow: "the June 2026 entry announces workflows, not the command token", + memory: "the June 2026 entry announces the memory bridge, not the command token", + image: "the June 2026 entry announces media generation, not the command token", + video: "the June 2026 entry announces media generation, not the command token", + output: "the 2026-08-14 entry announces the durable media output history, not the command token", + audit: "the June 2026 entry announces the audit trail, not the command token", + receipt: "the June 2026 entry announces audit receipts, not the command token", + mcp: "the June 2026 entry announces the MCP manager, not the command token", + config: "predates the release log", +}; + +/** + * A command is announced only where the notes name the form a user actually + * types: `aether `. + * + * This used to also accept the name inside bare backticks, and that made the + * gate vacuous in exactly the case it was built for. When #102 landed + * `aether review` and `aether ship`, both were already "announced" — because + * the notes mention the built-in SKILLS named `review-pr` and `ship`. A gate + * that reports coverage it does not have is worse than no gate, so the bare + * backtick form is gone: a skill, a flag, or a hyphenated neighbour that merely + * contains the command's letters no longer speaks for the command. + */ +function announcedInNotes(notes: string, name: string): boolean { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp("aether " + escaped + "\\b").test(notes); +} + +test("no user-visible command ships without either a release note or a named exemption", () => { + const notes = read("RELEASE_NOTES.md"); + + // Hidden commands are exempt by rule: `login` and `logout` are legacy + // shortcuts deliberately kept out of `aether --help`, so there is no surface + // to announce. The exemption is narrow — it follows the registry's own + // `hidden` flag, not a list somebody maintains here. + const visible = ALL_CLI_COMMANDS.filter((command) => !command.hidden); + assert.ok(visible.length > 0, "the registry exposes no visible commands — this gate is not reading it"); + + const unannounced = visible + .filter((command) => !announcedInNotes(notes, command.name)) + .filter((command) => !(command.name in SHIPPED_WITHOUT_A_NOTE)) + .map((command) => `${command.name} — ${command.summary}`); + + assert.deepEqual( + unannounced, + [], + "these commands ship to users with no claim in RELEASE_NOTES.md and no named exemption:\n " + + `${unannounced.join("\n ")}\n` + + "Announce them in the current release entry, or add them to SHIPPED_WITHOUT_A_NOTE with a reason.", + ); +}); + +test("the announcement matcher does not accept a lookalike as an announcement", () => { + // Guard the guard. Every assertion in this section is only as good as this + // function, and its previous form said yes to all three of these. + assert.equal(announcedInNotes("the built-in `review-pr` skill", "review"), false); + assert.equal(announcedInNotes("six ship built in: `ship`, `fix-ci`", "ship"), false); + assert.equal(announcedInNotes("pass `--skills` to narrow the run", "skills"), false); + // ...and still says yes to a real announcement. + assert.equal(announcedInNotes("run `aether review --files x` to pick", "review"), true); + assert.equal(announcedInNotes("aether ship publishes HEAD", "ship"), true); +}); + +test("the unannounced-command list cannot rot into a permanent bypass", () => { + const notes = read("RELEASE_NOTES.md"); + const registered = new Map(ALL_CLI_COMMANDS.map((command) => [command.name, command])); + + for (const [name, reason] of Object.entries(SHIPPED_WITHOUT_A_NOTE)) { + // A stale entry is worse than no entry: it holds an exemption open for a + // command that no longer exists, and the next command to take that name + // inherits the silence. + assert.ok(registered.has(name), `SHIPPED_WITHOUT_A_NOTE names "${name}", which is not a registered command`); + assert.equal(registered.get(name)?.hidden ?? false, false, `"${name}" is hidden and needs no exemption`); + assert.ok(reason.trim().length > 10, `"${name}" is exempted without a real reason`); + assert.equal( + announcedInNotes(notes, name), + false, + `"${name}" IS announced in RELEASE_NOTES.md — remove it from SHIPPED_WITHOUT_A_NOTE ` + + "so the list keeps meaning what it says", + ); + } +}); + +test("the operator packet names every command that ships unannounced", () => { + // The founder creating the tag is the person who needs to know what goes out + // without a note. Keeping the list only in a test file would tell the release + // engineer and nobody else. + const packet = read("docs", "releases", `OPERATOR-PACKET-v${VERSION}.md`); + const missing = Object.keys(SHIPPED_WITHOUT_A_NOTE).filter( + (name) => !new RegExp("`aether " + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "`").test(packet), + ); + assert.deepEqual(missing, [], `the operator packet does not name these unannounced commands: ${missing.join(", ")}`); +}); + +test("release documents never state that this version is installable from npm", () => { + // This lane cannot prove registry availability, and an unpublished version is + // unknown, not available — never "supported" or "shipped". Any sentence + // promising that `npm i -g aether-agents` yields the CURRENT version must not + // exist until a publish has actually been observed on the registry. + // + // The claim is matched against a NORMALIZED document — blockquote markers + // stripped and whitespace collapsed — because both files wrap prose across + // lines and inside `>` quotes, so a line-anchored regex would miss the exact + // sentence it exists to catch. The window after the install command is bounded + // so the collapsed document cannot make the match vacuously greedy. + const escaped = VERSION.replaceAll(".", "\\."); + const claim = new RegExp( + `npm\\s+i(?:nstall)?\\s+-g\\s+["']?aether-agents.{0,160}?\\b(?:gives you|gives|installs|resolves to|is now|upgrades you to)\\b.{0,40}?${escaped}`, + "i", + ); + const normalize = (text: string): string => text.replace(/^\s*>\s?/gm, "").replace(/\s+/g, " "); + for (const file of ["README.md", "RELEASE_NOTES.md"]) { + assert.equal( + claim.test(normalize(read(file))), + false, + `${file} claims npm serves ${VERSION} without registry proof`, + ); + } + + // Guard the guard: the same pattern must fire on a sentence that DOES make the + // claim, or the four assertions above prove only that the regex is broken. + assert.equal( + claim.test(normalize(`> A plain \`npm i -g aether-agents --ignore-scripts\`\n> now installs ${VERSION}.`)), + true, + "the availability tripwire does not match a claim it is supposed to catch", + ); +});