From 7973a70cadd58d9649cfe743382cdb9b5efdeb54 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:46:03 -0400 Subject: [PATCH 1/8] release: cut v0.3.0, and make the package prove it contains what the notes claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 2026-08-20 this 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 at 477f0fc, before #72 added `aether skills`, `aether capabilities` and `aether support-bundle`. The npm registry served 0.1.0 and nothing else. And a packed aether-agents-0.2.0.tgz sat in the repo root from #83 until #90 deleted it, containing a build with no skills runtime in it at all. Nothing was lying on purpose. The four statements drifted apart because nothing compared them. 0.2.0 -> 0.3.0, not 0.2.1 ------------------------- #72 is a backwards-compatible feature addition — a skills runtime, three new CLI commands, six built-in skills shipped inside the package — that the v0.2.0 notes never mentioned. Semver makes that a minor bump. Reusing 0.2.0 was rejected on identity grounds. 0.2.0 already names a specific dated artifact: the August 19 notes entry, the README availability paragraph, install.sh's pin example, and that committed tarball. Publishing different contents under the same version would make two materially different artifacts answer to one name, which is the defect this release exists to close. 0.2.0 is retired unused; it will never be published. Reconciled ---------- - package.json, package-lock.json (both version fields) and src/version.ts all say 0.3.0. - RELEASE_NOTES.md leads with v0.3.0 covering 477f0fc..ed094dc: one feature, nine user-visible fixes, three test-only commits, one unwired module (#86 ship rail, which no command invokes), one docs commit. The v0.2.0 entry is marked superseded rather than rewritten — it is the record of what was written that day, not an install instruction. - README and the release log name 0.3.0, and both still say plainly that `npm i -g aether-agents` gives you 0.1.0, because it does. New gate: test/release_coherence.test.ts ---------------------------------------- Gate A asserts every place that names the release names the same one — including both package-lock version fields, which is the drift nothing was watching. Gate B is the load-bearing one: for every feature the release notes claim, the code behind it must be present in the file list `npm pack` would actually ship, and the command exposing it must be in the CLI registry. The source checkout's dist/ is NOT the package — the allowlist is dist/src plus four docs. Mutation-checked. Adding "!dist/src/commands/skills.js" to the files allowlist silently drops `aether skills` from the tarball: verify:production -> {"ok":true, ... "packedFiles":523} MISSED IT release_coherence -> FAIL: dist/src/commands/skills.js (agent skills runtime — `aether skills`) Restored: 7/7 pass, 524 packed files. New: npm run release:candidate ------------------------------ release.yml only runs after a founder has already tagged and published, so everything it checks is checked too late to change the decision. This runs the same sequence, in the same order, before the tag exists — against a detached git worktree of a specific commit, never the dirty checkout. A dirty tree is refused unless --allow-dirty, which marks the report commitBound:false rather than pretending. Every CLI proof runs the binary npm installed from the packed tarball into a clean prefix: --version, --help, `skills list`, `capabilities`, and the handoff demo (the demo harness is not shipped, so it is placed beside the installed package and resolves the CLI and its imports from the tarball's own dist/src). Skipped steps are recorded as "not-run" with a reason and can never read as a green tick — the full npm test suite is release.yml's gate and says so in the report. No string is handed to a shell: npm is reached through its own JS entrypoint under node, so every argument stays an argument. Also ---- - scripts/verify-production.ts exports createPackReport, so a gate reasoning about what a user receives asks npm instead of reading the build directory. - test/release_canaries.test.ts's coverage map said canaries 5 and 7 were unwritable. #86 and #87 wrote them two commits later; the excuse outlived the condition. The map is now enforced by a test that reads the delegated files. - docs/releases/OPERATOR-PACKET-v0.3.0.md: the founder-owned publish sequence, with what is proven and what is explicitly not. Gates at this commit: npm run typecheck exit 0 release + production test files 21 pass / 0 fail npm run verify:production --tag v0.3.0 ok Full npm test NOT run locally (a tool_executor test does not return on this Windows box); CI is the authority. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 +- RELEASE_NOTES.md | 115 +++++- docs/releases/2026-08-22.md | 67 ++++ docs/releases/OPERATOR-PACKET-v0.3.0.md | 178 +++++++++ docs/releases/README.md | 7 +- package-lock.json | 4 +- package.json | 3 +- scripts/release-candidate.ts | 488 ++++++++++++++++++++++++ scripts/verify-production.ts | 9 +- src/version.ts | 2 +- test/release_canaries.test.ts | 54 ++- test/release_coherence.test.ts | 234 ++++++++++++ 12 files changed, 1137 insertions(+), 43 deletions(-) create mode 100644 docs/releases/2026-08-22.md create mode 100644 docs/releases/OPERATOR-PACKET-v0.3.0.md create mode 100644 scripts/release-candidate.ts create mode 100644 test/release_coherence.test.ts diff --git a/README.md b/README.md index 4f30608..bd979e2 100644 --- a/README.md +++ b/README.md @@ -35,16 +35,23 @@ 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) — `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 +108,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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f0bc61b..9d8d623 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,99 @@ +# Aether Agent v0.3.0 — skills, and a release that matches the repository + +**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, and +nine user-visible fixes 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..ed094dc` — 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 + +- **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. + +## 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 + +- **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. + +## 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 `ed094dc`, 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 +124,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..d7f4c0e --- /dev/null +++ b/docs/releases/2026-08-22.md @@ -0,0 +1,67 @@ +# Release notes — 2026-08-22 + +**v0.3.0 — skills, and a release that matches the repository.** + +Release commit: `ed094dc8885945e69f66e166e854142005bf1d62` +Commit range: `477f0fc..ed094dc` (16 commits, 2026-08-19 → 2026-08-20) + +## 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. + +Sixteen 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 + +Feature: + +- **#72** — skill runtime, instruction resolver, capability contract, redacted + support bundle, and six built-in skills. Adds the `skills`, `capabilities` and + `support-bundle` commands. + +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. + +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: no command + invokes it, so it changes no behaviour in this release. +- **#90** — documentation honesty and repo hygiene; deleted the committed + `aether-agents-0.2.0.tgz`, moved `_loopstate/` under `docs/loops/`. + +## 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..c97def7 --- /dev/null +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -0,0 +1,178 @@ +# 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 | `ed094dc8885945e69f66e166e854142005bf1d62` (`origin/main`) | +| Release commit | PENDING — the merge commit of this PR into `main` | +| Evidence commit | PENDING | +| Tarball | PENDING | +| Tarball sha256 | PENDING | +| Packed entries | PENDING | + +## 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..ed094dc` — 16 commits, 2026-08-19 08:39 EDT through +2026-08-20 07:47 EDT — plus everything the unpublished v0.2.0 notes described. + +- 1 feature commit: #72. +- 9 user-visible fixes: #73, #74, #75, #77, #78, #83, #84, #88, #89, #91. +- 3 test-only commits: #82, #85, #87. +- 1 unwired module: #86 (ship rail; no command invokes it, so it changes no + behaviour in this release). +- 1 documentation/hygiene commit: #90. + +Per-PR detail: [`2026-08-22.md`](2026-08-22.md). + +## 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. + +PENDING — evidence table filled from the recorded run. + +### Packaged file manifest + +PENDING + +## 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 on this machine.** The release-candidate run + executes the release-owned test files only and reports `npm-test` as + `not-run`. The whole suite is `release.yml`'s gate and CI's reading is the + authority. Run `npm run release:candidate -- --full-tests` to include it. +- **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..4d7ae82 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**: `aether skills`, `aether capabilities`, `aether support-bundle`, plus the unpublished 0.2.0 work and nine 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 52a50d1..9e42e00 100644 --- a/test/release_canaries.test.ts +++ b/test/release_canaries.test.ts @@ -10,17 +10,21 @@ // 2 live-child cancel test/process_tree.test.ts (whole tree, by pid) // 3 reconnect replay HERE // 4 remote freshness test/worktree.test.ts (real git, pinned base) -// 5 fake-gh ship NOT WRITABLE — see the end of this file +// 5 fake-gh ship test/ship_rail.test.ts (argv asserted, #86) // 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"; @@ -113,17 +117,31 @@ test("canary 6b: an unmeasured session is never reported as capped", () => { assert.equal(reg.usageStatus(), "unknown"); }); -// ── The two that cannot be written yet ────────────────────────────────────── +// ── The map above is enforced, not asserted in prose ──────────────────────── // -// Canary 5 — fake-gh ship. -// There is no PR-creation path to exercise. repo.ts's prCreateHint returns a -// STRING for the user to run; no subprocess ever invokes `gh pr create`. A -// fake-gh harness would assert against code that does not exist. This canary -// arrives with the review/ship rail, not before it. -// -// 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. -// -// Both are tracked as gaps 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. + +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)", "ship_rail.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..5fe0b7d --- /dev/null +++ b/test/release_coherence.test.ts @@ -0,0 +1,234 @@ +// 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"; + +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: "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", () => { + const registry = read("src", "commands", "cli_registry.ts"); + for (const feature of FEATURE_MANIFEST) { + if (!feature.command) continue; + assert.ok( + new RegExp(`name:\\s*"${feature.command}"`).test(registry), + `${feature.claim}: cli_registry.ts 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`); + }, +); + +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", + ); +}); From 7168f9b273d7901d63c8a490dbcc1987fcc893ba Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:48:03 -0400 Subject: [PATCH 2/8] docs(release): fill the operator packet with the commit-bound candidate evidence Run against a63e1c6e in a detached worktree: commitBound true, ok true, exit 0. Tarball aether-agents-0.3.0.tgz, 524 entries, 589,829 bytes packed / 2,435,029 unpacked, sha256 25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d. The digest came out identical from an uncommitted tree and from the detached worktree, which is recorded as content stability across a docs-only commit and explicitly NOT as a cross-machine reproducibility claim. Also records the mutation check: dropping dist/src/commands/skills.js from the files allowlist leaves verify:production reporting ok:true at 523 files, and fails release_coherence by name. Co-Authored-By: Claude Opus 5 (1M context) --- docs/releases/OPERATOR-PACKET-v0.3.0.md | 88 +++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index c97def7..fb0db65 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -10,11 +10,12 @@ to npm are founder-owned and are listed at the end, unrun. | Package | `aether-agents` | | Proposed tag | `v0.3.0` | | Branch base | `ed094dc8885945e69f66e166e854142005bf1d62` (`origin/main`) | -| Release commit | PENDING — the merge commit of this PR into `main` | -| Evidence commit | PENDING | -| Tarball | PENDING | -| Tarball sha256 | PENDING | -| Packed entries | PENDING | +| Release commit | the merge commit of this PR into `main` — **re-run the candidate on it before tagging** (§6.2) | +| Evidence commit | `a63e1c6e590352dcf5bc2607218e9ca59e1cff33` | +| Tarball | `aether-agents-0.3.0.tgz` | +| Tarball sha256 | `25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d` | +| Tarball size | 589,829 bytes packed / 2,435,029 unpacked | +| Packed entries | 524 | ## 1. Semantic version decision @@ -89,11 +90,84 @@ That runs `.github/workflows/release.yml`'s sequence against a detached --tag v0.3.0` → `npm pack` → global install of **that tarball** into a clean prefix → CLI proofs run from the installed package. -PENDING — evidence table filled from the recorded run. +Recorded run — commit `a63e1c6e590352dcf5bc2607218e9ca59e1cff33`, `commitBound: +true`, `ok: true`, process exit 0: + +``` +PASS commit-identity — a63e1c6e590352dcf5bc2607218e9ca59e1cff33 +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":524,"packedBytes":2435029,"workflows":3} +PASS pack — aether-agents-0.3.0.tgz + sha256:25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d +PASS install-tarball — /node_modules/aether-agents +PASS installed --version — 0.3.0 +PASS installed --help — 46 lines, lists skills, capabilities, resume, agent, doctor +PASS installed skills list — aether/frontend-from-screenshot@1.0.0 builtin enabled … +PASS installed capabilities — instructions supported +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 was produced twice: once from an uncommitted working tree and once +from the detached worktree of `a63e1c6e`, and both runs produced +`25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d`. That is +evidence the packed content is stable across the docs-only commit, not a claim of +cross-machine reproducibility (see §5). + +Independently, `npm run typecheck` exits 0 and the release-owned test files — +`version`, `release_coherence`, `release_canaries`, `production_hardening` — +report 21 pass / 0 fail. + +### 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":523} MISSED IT +release_coherence -> FAIL: dist/src/commands/skills.js + (agent skills runtime — `aether skills`) +``` + +Restored: 524 packed files, 7/7 pass. The pre-existing production gate does not +catch a dropped feature, because it does not know what the notes promised. ### Packaged file manifest -PENDING +524 entries, 2,435,029 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/**` | 270 | 1290.8 KiB | +| `dist/src/ui/**` | 111 | 331.2 KiB | +| `dist/src/commands/**` | 105 | 637.1 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 | 28.1 KiB | + +By extension: 167 `.js`, 167 `.d.ts`, 167 `.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. ## 5. What is NOT proven From 22aa02141fba158927cb1f01e4344cfa3e8f1a01 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:31:44 -0400 Subject: [PATCH 3/8] release: cover #98 in the notes, and make the coherence gate bidirectional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ground moved while PR #96 was open. #98 squash-merged to main, main became 426b124, and the v0.3.0 notes said nothing about it — which is this lane's own defect arriving from the direction the lane did not gate. WORK A: the notes now cover #98 ------------------------------- Its user-visible half is three fixes, and the first is the exact failure class this codebase forbids: - `aether doctor --live` never received --live. main.ts parses non-strictly, 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 verification that was never performed. Unknown rendered as verified. --deep, --dry-run, --no-ui and --only were lost the same way, and the whole --fix repair path was unreachable. - `aether doctor --fix --yes` answered "re-run with --yes" to someone who had just passed it, because the global --yes never arrived either. - Command lookup lowercased the token while dispatch was case-sensitive, so `aether Vault` fell past the typo guard into a chat turn and billed a paid model call. The seam itself is announced too: a command now carries its own help metadata, flag table and loader in one entry, flag collisions are load-time errors, and reachability is structural rather than a regex over main.ts's source. Range moves 477f0fc..ed094dc -> 477f0fc..426b124, 16 commits -> 17, across RELEASE_NOTES.md, docs/releases/2026-08-22.md and the operator packet. Fixed a miscount while re-deriving: the packet said "9 user-visible fixes" over a list of ten. WORK B: the gate now runs both directions ----------------------------------------- Gate B ran notes -> package: a claim with no code behind it fails. It could not see the inverse — a user-visible command that ships with NO claim anywhere — and the inverse is what actually keeps happening, once per lane that lands between the note being written and the tag being cut. Gate C: every non-hidden command in the CLI registry must be announced by some release note, or named in SHIPPED_WITHOUT_A_NOTE with a reason. An explicit list is fine; silence is not. It 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. Every entry must also appear in the operator packet, because the founder cutting the tag is the person who needs to know what goes out unannounced. 15 commands are on it today; all predate the release log or were announced by capability in the June 2026 entry, and none is new in this release. Mutation-checked, per the controller's instruction. Added to the registry: { name: "teleport", args: "", summary: "beam the working tree somewhere", section: "System" } verify:production -> {"ok":true, ... "packedFiles":527} MISSED IT release_coherence -> FAIL: no user-visible command ships without either a release note or a named exemption + [ 'teleport — beam the working tree somewhere' ] - [] Registry restored byte-identical (md5 37b3f944…, `git status src/` empty), 10/10 pass. cli_registry.ts is read-only for this lane: it was mutated only in the working tree, never committed, and the restore is verified by digest. Also: Gate B now reads the registry by IMPORTING it instead of by regex over its source. #98 replaced "reachability asserted by a regex over main.ts" for exactly this reason, and it immediately split the commands across CLI_COMMANDS and DISPATCH_COMMANDS — a source regex keyed on one array would have silently stopped covering the other. doctor is in the second one. Gates at this commit: npm run typecheck exit 0 release_coherence 10 pass / 0 fail Co-Authored-By: Claude Opus 5 (1M context) --- RELEASE_NOTES.md | 35 +++++-- docs/releases/2026-08-22.md | 25 ++++- docs/releases/OPERATOR-PACKET-v0.3.0.md | 62 ++++++++++++- test/release_coherence.test.ts | 116 +++++++++++++++++++++++- 4 files changed, 220 insertions(+), 18 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9d8d623..ddc5c9b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,12 +3,13 @@ **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, and -nine user-visible fixes 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`. +moving: a skills runtime, a capability contract, a redacted support bundle, a +command-registration seam, and ten user-visible fixes 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..ed094dc` — every commit merged after the v0.2.0 notes were +Covers `477f0fc..426b124` — every commit merged after the v0.2.0 notes were written, and everything the v0.2.0 notes described, which was never shipped either. @@ -24,6 +25,13 @@ either. 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 @@ -68,6 +76,21 @@ either. 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. ## Availability — read this before upgrading @@ -84,7 +107,7 @@ 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 `ed094dc`, a published +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). diff --git a/docs/releases/2026-08-22.md b/docs/releases/2026-08-22.md index d7f4c0e..9805792 100644 --- a/docs/releases/2026-08-22.md +++ b/docs/releases/2026-08-22.md @@ -2,8 +2,8 @@ **v0.3.0 — skills, and a release that matches the repository.** -Release commit: `ed094dc8885945e69f66e166e854142005bf1d62` -Commit range: `477f0fc..ed094dc` (16 commits, 2026-08-19 → 2026-08-20) +Release commit: `426b12464c2a19549f421adb43348f83d028628e` +Commit range: `477f0fc..426b124` (17 commits, 2026-08-19 → 2026-08-22) ## Why 0.3.0 and not 0.2.0 @@ -12,7 +12,7 @@ The v0.2.0 notes were written on 2026-08-19 at `477f0fc` and describe handoffs, 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. -Sixteen commits landed after those notes. One of them, #72, is a feature: +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 @@ -25,11 +25,14 @@ exists to close, so the version moves rather than the meaning. ## What landed after the v0.2.0 notes -Feature: +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: @@ -49,6 +52,20 @@ User-visible fixes: - **#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: diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index fb0db65..4bbab14 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -9,7 +9,7 @@ to npm are founder-owned and are listed at the end, unrun. |---|---| | Package | `aether-agents` | | Proposed tag | `v0.3.0` | -| Branch base | `ed094dc8885945e69f66e166e854142005bf1d62` (`origin/main`) | +| Branch base | `426b12464c2a19549f421adb43348f83d028628e` (`origin/main`, after #98) | | Release commit | the merge commit of this PR into `main` — **re-run the candidate on it before tagging** (§6.2) | | Evidence commit | `a63e1c6e590352dcf5bc2607218e9ca59e1cff33` | | Tarball | `aether-agents-0.3.0.tgz` | @@ -38,11 +38,13 @@ artifacts answer to one name — the identity defect this release exists to clos ## 2. What the release covers -Commit range `477f0fc..ed094dc` — 16 commits, 2026-08-19 08:39 EDT through -2026-08-20 07:47 EDT — plus everything the unpublished v0.2.0 notes described. +Commit range `477f0fc..426b124` — 17 commits, 2026-08-19 08:39 EDT through +2026-08-22 11:12 EDT — plus everything the unpublished v0.2.0 notes described. -- 1 feature commit: #72. -- 9 user-visible fixes: #73, #74, #75, #77, #78, #83, #84, #88, #89, #91. +- 2 feature commits: #72 (skills runtime and its three commands) and #98 + (command-registration seam), the second of which also carries three + user-visible fixes. +- 10 user-visible fix commits: #73, #74, #75, #77, #78, #83, #84, #88, #89, #91. - 3 test-only commits: #82, #85, #87. - 1 unwired module: #86 (ship rail; no command invokes it, so it changes no behaviour in this release). @@ -50,6 +52,14 @@ Commit range `477f0fc..ed094dc` — 16 commits, 2026-08-19 08:39 EDT through Per-PR detail: [`2026-08-22.md`](2026-08-22.md). +**This range moved after the candidate was first cut.** #98 was squash-merged to +`main` while PR #96 was open, and the notes were updated to cover it. 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: @@ -169,6 +179,48 @@ 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 the June 2026 +entry. None of them is new in this release: + +`aether help`, `aether chat`, `aether run`, `aether agents`, `aether auth`, +`aether github`, `aether vault`, `aether workflow`, `aether memory`, +`aether image`, `aether video`, `aether audit`, `aether receipt`, `aether mcp`, +`aether config`. + +`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.** + +#### 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: diff --git a/test/release_coherence.test.ts b/test/release_coherence.test.ts index 5fe0b7d..e117700 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -30,6 +30,7 @@ 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"); @@ -157,12 +158,17 @@ const FEATURE_MANIFEST: Array<{ claim: string; command?: string; packaged: strin ]; test("every CLI command the release notes promise is in the CLI registry", () => { - const registry = read("src", "commands", "cli_registry.ts"); + // 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( - new RegExp(`name:\\s*"${feature.command}"`).test(registry), - `${feature.claim}: cli_registry.ts declares no "${feature.command}" command`, + registered.has(feature.command), + `${feature.claim}: the CLI registry declares no "${feature.command}" command`, ); } }); @@ -199,6 +205,110 @@ test( }, ); +// ── 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", + auth: "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", + 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 if any release note names it as `aether ` or ``. */ +function announcedInNotes(notes: string, name: string): boolean { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp("aether " + escaped + "\\b|`" + 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 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 From e0f94587dfae6876ba7cf7b92799956a9f36a537 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:35:45 -0400 Subject: [PATCH 4/8] docs(release): re-cut the candidate on the new base, and record the digest change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base moved to 426b124 (#98), so every number in the packet was taken against a tree that no longer exists. Re-run, commit-bound, at 22aa0214: commitBound true, ok true, exit 0 aether-agents-0.3.0.tgz sha256 8c5c119d93cabf49af0c49c97addb055308d508af93f8675a26b6f5c8ecba307 527 entries, 597,400 packed / 2,459,474 unpacked The digest CHANGED from the a63e1c6e run (25f33524…, 524 entries), and the packet now says why that is the correct outcome rather than a discrepancy: #98 added dist/src/core/command_dispatch.* and rewrote main.js, so the packed content is genuinely different. A digest that had survived that change would have meant the pack was not reading the tree. Both mutation checks were re-measured against this base rather than carried forward: skills drop verify:production {"ok":true, ... "packedFiles":526} MISSED IT release_coherence FAIL: dist/src/commands/skills.js restored -> 527 files, 10/10 pass teleport verify:production {"ok":true, ... "packedFiles":527} MISSED IT release_coherence FAIL: 'teleport — beam the working tree somewhere' restored -> registry byte-identical, 10/10 pass I had initially edited the skills figure from 523 to 526 by inference. That is a number nobody measured, so it was re-run before being written down. Release-owned suite at this commit: 24 pass / 0 fail. Co-Authored-By: Claude Opus 5 (1M context) --- docs/releases/OPERATOR-PACKET-v0.3.0.md | 47 ++++++++++++++----------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index 4bbab14..6bcaa80 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -11,11 +11,11 @@ to npm are founder-owned and are listed at the end, unrun. | Proposed tag | `v0.3.0` | | Branch base | `426b12464c2a19549f421adb43348f83d028628e` (`origin/main`, after #98) | | Release commit | the merge commit of this PR into `main` — **re-run the candidate on it before tagging** (§6.2) | -| Evidence commit | `a63e1c6e590352dcf5bc2607218e9ca59e1cff33` | +| Evidence commit | `22aa02141fba158927cb1f01e4344cfa3e8f1a01` | | Tarball | `aether-agents-0.3.0.tgz` | -| Tarball sha256 | `25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d` | -| Tarball size | 589,829 bytes packed / 2,435,029 unpacked | -| Packed entries | 524 | +| Tarball sha256 | `8c5c119d93cabf49af0c49c97addb055308d508af93f8675a26b6f5c8ecba307` | +| Tarball size | 597,400 bytes packed / 2,459,474 unpacked | +| Packed entries | 527 | ## 1. Semantic version decision @@ -100,11 +100,11 @@ That runs `.github/workflows/release.yml`'s sequence against a detached --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 `a63e1c6e590352dcf5bc2607218e9ca59e1cff33`, `commitBound: +Recorded run — commit `22aa02141fba158927cb1f01e4344cfa3e8f1a01`, `commitBound: true`, `ok: true`, process exit 0: ``` -PASS commit-identity — a63e1c6e590352dcf5bc2607218e9ca59e1cff33 +PASS commit-identity — 22aa02141fba158927cb1f01e4344cfa3e8f1a01 PASS stage-commit — detached worktree of that commit PASS npm-ci-ignore-scripts — found 0 vulnerabilities PASS npm-audit-high — found 0 vulnerabilities @@ -114,9 +114,9 @@ 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":524,"packedBytes":2435029,"workflows":3} + "packedFiles":527,"packedBytes":2459474,"workflows":3} PASS pack — aether-agents-0.3.0.tgz - sha256:25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d + sha256:8c5c119d93cabf49af0c49c97addb055308d508af93f8675a26b6f5c8ecba307 PASS install-tarball — /node_modules/aether-agents PASS installed --version — 0.3.0 PASS installed --help — 46 lines, lists skills, capabilities, resume, agent, doctor @@ -130,15 +130,20 @@ 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 was produced twice: once from an uncommitted working tree and once -from the detached worktree of `a63e1c6e`, and both runs produced -`25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d`. That is -evidence the packed content is stable across the docs-only commit, not a claim of -cross-machine reproducibility (see §5). +**The digest changed when the base moved, and that is the point.** An earlier +candidate at `a63e1c6e` — before #98 was on `main` — produced +`25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d` over 524 +entries. #98 added `dist/src/core/command_dispatch.*` and rewrote `main.js`, so +the packed content is genuinely different and the digest is too. A digest that +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 21 pass / 0 fail. +report 24 pass / 0 fail. ### Mutation check on the load-bearing gate @@ -148,31 +153,31 @@ 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":523} MISSED IT +verify:production -> {"ok":true, ... "packedFiles":526} MISSED IT release_coherence -> FAIL: dist/src/commands/skills.js (agent skills runtime — `aether skills`) ``` -Restored: 524 packed files, 7/7 pass. The pre-existing production gate does not +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 -524 entries, 2,435,029 bytes unpacked. Five files at the package root, everything +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/**` | 270 | 1290.8 KiB | +| `dist/src/core/**` | 273 | 1305.8 KiB | | `dist/src/ui/**` | 111 | 331.2 KiB | -| `dist/src/commands/**` | 105 | 637.1 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 | 28.1 KiB | +| `dist/src/{index,main,types,version}.*` | 12 | 25.7 KiB | -By extension: 167 `.js`, 167 `.d.ts`, 167 `.js.map`, 13 `.json`, 9 `.md`, 1 +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. From 4cbc92cf9e17ba4972ca951a499b6781ff73ff0c Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:47:56 -0400 Subject: [PATCH 5/8] release: regenerate the v0.3.0 truth against the merged main --- README.md | 8 +- RELEASE_NOTES.md | 113 ++++++++++++++++++++++-- docs/releases/2026-08-22.md | 55 +++++++++++- docs/releases/OPERATOR-PACKET-v0.3.0.md | 39 ++++++-- docs/releases/README.md | 2 +- test/release_coherence.test.ts | 69 ++++++++++++++- 6 files changed, 265 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bd979e2..c69e6e4 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ Two things make it different from the rest of the terminal-agent shelf: > `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) — `aether skills`, -> `aether capabilities`, `aether support-bundle`, portable handoffs, `--resume` +> [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: @@ -168,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 ddc5c9b..af96678 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,20 +1,64 @@ -# Aether Agent v0.3.0 — skills, and a release that matches the repository +# 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, and ten user-visible fixes 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`. +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..426b124` — every commit merged after the v0.2.0 notes were +Covers `477f0fc..2769778` — 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, @@ -55,6 +99,25 @@ either. ## Fixed +- **`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 @@ -92,6 +155,44 @@ either. 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 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 diff --git a/docs/releases/2026-08-22.md b/docs/releases/2026-08-22.md index 9805792..feff9f9 100644 --- a/docs/releases/2026-08-22.md +++ b/docs/releases/2026-08-22.md @@ -2,8 +2,11 @@ **v0.3.0 — skills, and a release that matches the repository.** -Release commit: `426b12464c2a19549f421adb43348f83d028628e` -Commit range: `477f0fc..426b124` (17 commits, 2026-08-19 → 2026-08-22) +Release commit: the merge commit of PR #96 into `main` — this entry is written +against `2769778af3b4f0dfa7d73c894cdb74cdabbe3cef`, the `main` merged into the +release branch. Re-cut the candidate on the merge commit before tagging. + +Commit range: `477f0fc..2769778` (27 commits, 2026-08-19 → 2026-08-22) ## Why 0.3.0 and not 0.2.0 @@ -71,11 +74,55 @@ 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: no command - invokes it, so it changes no behaviour in this release. +- **#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 + +Ten more commits reached `main` on 2026-08-22/23, 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). + ## Availability Not published. At the time of writing the npm registry served exactly one diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index 6bcaa80..f31a7b1 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -194,14 +194,28 @@ 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 the June 2026 -entry. None of them is new in this 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 auth`, -`aether github`, `aether vault`, `aether workflow`, `aether memory`, -`aether image`, `aether video`, `aether audit`, `aether receipt`, `aether mcp`, +`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. @@ -210,6 +224,21 @@ The list is enforced in both directions — a stale entry fails, and an entry th 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: diff --git a/docs/releases/README.md b/docs/releases/README.md index 4d7ae82..fc6cb57 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -12,7 +12,7 @@ manifest, the evidence gathered, and the founder-owned steps that publish it. ## Index -- [2026-08-22](2026-08-22.md) — **v0.3.0**: `aether skills`, `aether capabilities`, `aether support-bundle`, plus the unpublished 0.2.0 work and nine fixes. Packet: [OPERATOR-PACKET-v0.3.0.md](OPERATOR-PACKET-v0.3.0.md). +- [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`, plus the unpublished 0.2.0 work and eleven 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/test/release_coherence.test.ts b/test/release_coherence.test.ts index e117700..7b381be 100644 --- a/test/release_coherence.test.ts +++ b/test/release_coherence.test.ts @@ -114,6 +114,47 @@ test("an operator packet exists for this version and binds a commit", () => { * 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", @@ -235,23 +276,34 @@ const SHIPPED_WITHOUT_A_NOTE: Record = { chat: "predates the release log", run: "predates the release log", agents: "predates the release log", - auth: "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 if any release note names it as `aether ` or ``. */ +/** + * 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|`" + escaped + "\\b").test(notes); + return new RegExp("aether " + escaped + "\\b").test(notes); } test("no user-visible command ships without either a release note or a named exemption", () => { @@ -278,6 +330,17 @@ test("no user-visible command ships without either a release note or a named exe ); }); +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])); From fb96ee44b03f37a386954a32412728fa7e98a046 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:58:51 -0400 Subject: [PATCH 6/8] release: cover #105 in the v0.3.0 notes --- RELEASE_NOTES.md | 21 ++++++++++++++++++++- docs/releases/2026-08-22.md | 14 +++++++++++--- docs/releases/README.md | 2 +- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index af96678..9c6ef21 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ 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..2769778` — every commit merged after the v0.2.0 notes were +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. @@ -99,6 +99,17 @@ either. ## 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 @@ -157,6 +168,14 @@ either. ## 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 diff --git a/docs/releases/2026-08-22.md b/docs/releases/2026-08-22.md index feff9f9..96d8cba 100644 --- a/docs/releases/2026-08-22.md +++ b/docs/releases/2026-08-22.md @@ -3,10 +3,10 @@ **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 `2769778af3b4f0dfa7d73c894cdb74cdabbe3cef`, the `main` merged into the +against `a845479082e5e1b07337aae95833767d9f8ab4e5`, the `main` merged into the release branch. Re-cut the candidate on the merge commit before tagging. -Commit range: `477f0fc..2769778` (27 commits, 2026-08-19 → 2026-08-22) +Commit range: `477f0fc..a845479` (28 commits, 2026-08-19 → 2026-08-22) ## Why 0.3.0 and not 0.2.0 @@ -82,7 +82,7 @@ Not user-visible, recorded for completeness: ## What landed after the candidate was first cut -Ten more commits reached `main` on 2026-08-22/23, while PR #96 was open. They are +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: @@ -122,6 +122,14 @@ User-visible fixes: 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 diff --git a/docs/releases/README.md b/docs/releases/README.md index fc6cb57..88c3fde 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -12,7 +12,7 @@ manifest, the evidence gathered, and the founder-owned steps that publish it. ## Index -- [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`, plus the unpublished 0.2.0 work and eleven fixes. Packet: [OPERATOR-PACKET-v0.3.0.md](OPERATOR-PACKET-v0.3.0.md). +- [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). From 37ebf40fec36b7f3bb50495dc6e5186498358ca1 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:06:30 -0400 Subject: [PATCH 7/8] release: bind the v0.3.0 operator packet to the head that carries #105 Regenerates the packet against a845479 (origin/main after #105): base SHA, evidence commit, tarball digest, packed entry count, the recorded release-candidate run, and the coverage breakdown. Two corrections rather than refreshes: - Section 5 no longer lists the full test suite as unproven. It was run at the evidence commit (1464 tests, 1460 pass, 0 fail, 4 skipped); what is genuinely unproven is the suite INSIDE the candidate run, which reports npm-test as not-run by design. - Two new unproven entries name the deployed API. The notes' Authentication claims are about a server this repository cannot test, and #105 exists precisely because `aether agent` does not work end to end against production today. Both are marked rather than implied. --- docs/releases/OPERATOR-PACKET-v0.3.0.md | 113 ++++++++++++++++-------- 1 file changed, 76 insertions(+), 37 deletions(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index f31a7b1..82f3578 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -9,13 +9,13 @@ to npm are founder-owned and are listed at the end, unrun. |---|---| | Package | `aether-agents` | | Proposed tag | `v0.3.0` | -| Branch base | `426b12464c2a19549f421adb43348f83d028628e` (`origin/main`, after #98) | +| 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 | `22aa02141fba158927cb1f01e4344cfa3e8f1a01` | +| Evidence commit | `fb96ee44b03f37a386954a32412728fa7e98a046` | | Tarball | `aether-agents-0.3.0.tgz` | -| Tarball sha256 | `8c5c119d93cabf49af0c49c97addb055308d508af93f8675a26b6f5c8ecba307` | -| Tarball size | 597,400 bytes packed / 2,459,474 unpacked | -| Packed entries | 527 | +| Tarball sha256 | `70a48aca8baa8b63f551980256eafa42531cd22fc5ca1146829d31f8b4bd2e4d` | +| Tarball size | 740,014 bytes packed / 3,022,168 unpacked | +| Packed entries | 575 | ## 1. Semantic version decision @@ -38,27 +38,38 @@ artifacts answer to one name — the identity defect this release exists to clos ## 2. What the release covers -Commit range `477f0fc..426b124` — 17 commits, 2026-08-19 08:39 EDT through -2026-08-22 11:12 EDT — plus everything the unpublished v0.2.0 notes described. +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. -- 2 feature commits: #72 (skills runtime and its three commands) and #98 - (command-registration seam), the second of which also carries three - user-visible fixes. -- 10 user-visible fix commits: #73, #74, #75, #77, #78, #83, #84, #88, #89, #91. +- 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 unwired module: #86 (ship rail; no command invokes it, so it changes no - behaviour in this release). - 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 after the candidate was first cut.** #98 was squash-merged to -`main` while PR #96 was open, and the notes were updated to cover it. 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. +**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 @@ -100,11 +111,11 @@ That runs `.github/workflows/release.yml`'s sequence against a detached --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 `22aa02141fba158927cb1f01e4344cfa3e8f1a01`, `commitBound: -true`, `ok: true`, process exit 0: +Recorded run — commit `fb96ee44b03f37a386954a32412728fa7e98a046`, `commitBound: true`, +`ok: true`, process exit 0: ``` -PASS commit-identity — 22aa02141fba158927cb1f01e4344cfa3e8f1a01 +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 @@ -114,14 +125,14 @@ 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":527,"packedBytes":2459474,"workflows":3} + "packedFiles":575,"packedBytes":3022168,"workflows":3} PASS pack — aether-agents-0.3.0.tgz - sha256:8c5c119d93cabf49af0c49c97addb055308d508af93f8675a26b6f5c8ecba307 + sha256:70a48aca8baa8b63f551980256eafa42531cd22fc5ca1146829d31f8b4bd2e4d PASS install-tarball — /node_modules/aether-agents PASS installed --version — 0.3.0 -PASS installed --help — 46 lines, lists skills, capabilities, resume, agent, doctor +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 supported +PASS installed capabilities — instructions unavailable (agent_skills_disabled) PASS installed demo:handoff — independent test run in machine-b/slugify: green RELEASE CANDIDATE OK @@ -130,11 +141,20 @@ 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.** An earlier -candidate at `a63e1c6e` — before #98 was on `main` — produced -`25f33524bd866275674eccbf8cfe5706f14e925cb0ba35861dc6bc21a9245a2d` over 524 -entries. #98 added `dist/src/core/command_dispatch.*` and rewrote `main.js`, so -the packed content is genuinely different and the digest is too. A digest that +**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 @@ -143,7 +163,8 @@ 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 24 pass / 0 fail. +report 25 pass / 0 fail. The full suite is 1464 tests: 1460 pass, 0 fail, 4 +skipped. ### Mutation check on the load-bearing gate @@ -261,10 +282,28 @@ 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 on this machine.** The release-candidate run - executes the release-owned test files only and reports `npm-test` as - `not-run`. The whole suite is `release.yml`'s gate and CI's reading is the - authority. Run `npm run release:candidate -- --full-tests` to include it. +- **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. From 85248956e660bc10de80080c805235756ca21e10 Mon Sep 17 00:00:00 2001 From: DBarr3 <143002219+DBarr3@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:07:23 -0400 Subject: [PATCH 8/8] release: record the measured tarball size, not a rounded one The packed size in the packet header was written from npm's rounded "740.0 kB" notice rather than measured. The tarball is 739,977 bytes. A digest and a byte count that disagree are the same class of defect this packet exists to close. --- docs/releases/OPERATOR-PACKET-v0.3.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/OPERATOR-PACKET-v0.3.0.md b/docs/releases/OPERATOR-PACKET-v0.3.0.md index 82f3578..a30b4a3 100644 --- a/docs/releases/OPERATOR-PACKET-v0.3.0.md +++ b/docs/releases/OPERATOR-PACKET-v0.3.0.md @@ -14,7 +14,7 @@ to npm are founder-owned and are listed at the end, unrun. | Evidence commit | `fb96ee44b03f37a386954a32412728fa7e98a046` | | Tarball | `aether-agents-0.3.0.tgz` | | Tarball sha256 | `70a48aca8baa8b63f551980256eafa42531cd22fc5ca1146829d31f8b4bd2e4d` | -| Tarball size | 740,014 bytes packed / 3,022,168 unpacked | +| Tarball size | 739,977 bytes packed / 3,022,168 unpacked | | Packed entries | 575 | ## 1. Semantic version decision