Compare commits
9 Commits
8f0960e134
..
v1.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
| f5c53b8693 | |||
| 735565b9be | |||
| 9aaff26e3a | |||
| 852f900b53 | |||
| 1baba79c96 | |||
| 42bd29d654 | |||
| 3a44e81cad | |||
| 35964abd01 | |||
| 6353d59e63 |
@@ -172,3 +172,46 @@ jobs:
|
||||
|
||||
- name: Vendored pi-extensions skill floor matches the package
|
||||
run: bash scripts/check-skill-floor.sh
|
||||
|
||||
doc-drift:
|
||||
# Gate hand-maintained doc claims against the build files they describe.
|
||||
# Its own job for the same reason as skill-floor: "the docs lie" should be a
|
||||
# distinct red name, not a line buried in a job about workflow syntax.
|
||||
#
|
||||
# The gap it closes, measured 2026-09-10 while preparing v1.9.0 — five
|
||||
# claims had rotted, every one of them a fact written by hand in a file
|
||||
# nothing verified:
|
||||
# * README.md's "Version pins" table was wrong on ALL THREE rows (pi
|
||||
# 0.84.4 vs 0.85.1, pi-atelier v0.10.0 vs v0.10.1, mempalace 3.8.0 vs
|
||||
# 3.9.0) — and that table exists specifically to be the reviewable
|
||||
# record of what the repo freezes on purpose, so a wrong row destroys
|
||||
# the only thing it is for.
|
||||
# * README.md listed already-shipped typst PDF export under "Planned for
|
||||
# an upcoming minor release", marked "(shipped in Unreleased/base)".
|
||||
# * DOCKER_HUB.md claimed "Node.js v22" while v1.9.0 ships Node 24.
|
||||
#
|
||||
# DOCKER_HUB.md is why this is a gate and not a habit. It is PUBLISHED —
|
||||
# update-description POSTs it to Docker Hub as full_description on every tag
|
||||
# — and it had gone eight releases (v1.8.6 -> v1.9.0) untouched. Nothing
|
||||
# generates it and nothing checked it, so the only thing keeping it true was
|
||||
# someone remembering. It is also read from the TAG, so a fix pushed to main
|
||||
# after tagging never reaches the published page.
|
||||
#
|
||||
# Cheap and hermetic on purpose: every check compares a doc string against a
|
||||
# value that exists in this repo, so no network, no token, no built image,
|
||||
# and no sibling clone. Claims that genuinely need a running container (image
|
||||
# sizes, the "N mempalace_* tools" count) are deliberately left out — a gate
|
||||
# that cannot evaluate a claim honestly would have to guess, and a guessing
|
||||
# gate is worse than none. Assert those in scripts/smoke-test.sh instead.
|
||||
#
|
||||
# Exit codes 0 in sync / 1 drift / 2 cannot-run, matching lint-shell.sh and
|
||||
# check-skill-floor.sh. A renamed ARG makes the gate blind, so that is a red
|
||||
# 2, not a green tick.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Doc claims match the build files
|
||||
run: bash scripts/check-doc-drift.sh
|
||||
|
||||
@@ -92,7 +92,44 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
is hashed into `base_tag`, so it costs a base rebuild (~67 min); and if the
|
||||
section the phrase canary names has changed, re-pin it in
|
||||
`scripts/smoke-test.sh`.
|
||||
3. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. **Update the docs this release makes stale — BEFORE you tag.** Rename
|
||||
`CHANGELOG.md`'s `## Unreleased` to `## vX.Y.Z — YYYY-MM-DD` (em dash, as
|
||||
every prior release heading uses), then run the gate:
|
||||
|
||||
```bash
|
||||
bash scripts/check-doc-drift.sh # 0 in sync / 1 drift / 2 cannot run
|
||||
```
|
||||
|
||||
It compares README.md's version-pin table against the ARGs it names, and
|
||||
DOCKER_HUB.md's Node claim against `ARG NODE_VERSION`, plus Hub's
|
||||
25 000-char limit, unsubstituted `{{PLACEHOLDERS}}`, and stale `Unreleased`
|
||||
pointers in user-facing docs.
|
||||
|
||||
**Why before and not after:** `docker-publish.yml` runs `actions/checkout@v4`
|
||||
with no `ref:`, so every job reads `github.ref` — the **tag**. A doc fix
|
||||
pushed to `main` after tagging does not reach the release, and for
|
||||
`DOCKER_HUB.md` it does not reach the published Hub page either, because
|
||||
`update-description` POSTs that file as Docker Hub's `full_description` from
|
||||
the tag's tree. Getting it in afterwards means re-pointing the tag, which is
|
||||
its own hazard (v1.8.14 went `601fc98` → `361babd` and broke deploy
|
||||
verification until `git fetch --tags --force`).
|
||||
|
||||
The gate is deliberately narrow — it only checks claims verifiable from files
|
||||
in this repo. Still eyeball, because these are NOT gated:
|
||||
- counts and sizes (`~1.1 GB`, "N `mempalace_*` tools", "7 extensions") —
|
||||
they need a running image; assert them in `scripts/smoke-test.sh` instead
|
||||
- feature prose that quietly became false, e.g. a "Planned for an upcoming
|
||||
release" section describing something that already shipped
|
||||
- `Dockerfile.base`'s `# BASE_REBUILD_DATE:` marker. Ungated on purpose:
|
||||
`base_tag` hashes Dockerfile.base's content, comments included, so
|
||||
demanding it be current would force a ~60 min base rebuild on a release
|
||||
that touched no base files. **Fix it when the base is already rebuilding —
|
||||
then it is free.**
|
||||
|
||||
Measured cost of skipping this, 2026-09-10 (v1.9.0): five stale claims, one
|
||||
of them published. README's pin table was wrong on all three rows, and
|
||||
DOCKER_HUB.md — untouched for eight releases — still said Node v22 while the
|
||||
image shipped Node 24.
|
||||
4. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
@@ -243,8 +280,10 @@ shipped the same image bytes); preventatively fixed for `PI_VERSION` +
|
||||
image. Verifies binaries, repo clones, runtime deployment (waits for
|
||||
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
|
||||
the parallel-build-load race documented in opencode-devbox c6f9d11
|
||||
2026-06-08), and image size threshold (3500 MB; revisit after a few
|
||||
releases as actuals settle).
|
||||
2026-06-08), build-time leftovers (see below), and image size threshold
|
||||
(3800 MB in `SIZE_THRESHOLD_MB`; revisit after a few releases as actuals
|
||||
settle — this doc said 3500 until 2026-09-11, after the bar had already
|
||||
moved twice).
|
||||
|
||||
If smoke fails on size threshold but build is otherwise fine: bump
|
||||
`SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and
|
||||
@@ -252,6 +291,27 @@ re-run. The threshold exists to catch *runaway* growth (an accidental
|
||||
texlive bake-in, a forgotten chrome dependency), not to block ordinary
|
||||
upstream bumps.
|
||||
|
||||
**The size gate is not a substitute for naming the residue.** It carries
|
||||
~225 MB of deliberate margin, so v1.9.1 shipped +131 MB of pure build
|
||||
residue — 110 MB of it npm's own download cache under `/root/.npm`, the
|
||||
rest foreign platform packages — and stayed green. Four named assertions
|
||||
now cover that ground: no foreign npm-11 platform packages beyond the
|
||||
host arch (`@esbuild/*`, `@mariozechner/clipboard-*`), no `/root/.npm` in
|
||||
the image, and — because the prune's real risk is *removing something
|
||||
needed*, not size — esbuild must compile TS and clipboard must load its
|
||||
native binding at **every** install site.
|
||||
|
||||
Two failure shapes to copy from those, both of which bit here:
|
||||
- `test ! -d /root/.npm` on mode-700 `/root` passes for a **permission**
|
||||
error, so the cache assertion refuses to run as non-root. Watch for
|
||||
this in any assertion about a path you may not be allowed to read.
|
||||
- `node -e 'require("esbuild")'` resolves by walking up from the CURRENT
|
||||
DIRECTORY, so it fails with `MODULE_NOT_FOUND` from `/workspace` on a
|
||||
perfectly healthy image (esbuild is nested inside the pi trees;
|
||||
`NODE_PATH` is unset). Always path-qualify: `require("<abs>/esbuild")`.
|
||||
A runbook shipped the bare form with "if this fails, revert the
|
||||
release" attached, and it duly went red for the wrong reason.
|
||||
|
||||
## Build pipeline notes
|
||||
|
||||
- **Two-phase**: base + variant. Base is rebuilt only when
|
||||
|
||||
+416
-1
@@ -11,7 +11,408 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## v1.9.0 — 2026-09-10
|
||||
## v1.9.2 — 2026-09-14
|
||||
|
||||
**The v1.9.1 residual is attributed and fixed: it was mostly npm's own download
|
||||
cache, not the platform binaries it looked like.** v1.9.1 pruned the 26 foreign
|
||||
`@esbuild/<platform>` directories that npm 11 installs, which fixed the 431 MB
|
||||
size-gate failure — but the image still shipped **+131 MB compressed over
|
||||
v1.8.14**, nearly all of it in the single pi/extensions install layer (87 MB →
|
||||
206 MB). v1.9.1's notes recorded the leftover as an open item with an explicit
|
||||
hypothesis (the `@mariozechner/clipboard-*` family, same npm 11 behaviour, a
|
||||
different package) and an explicit warning that the hypothesis was **not** a
|
||||
measured cause. It was measured on 2026-09-11, after recreating onto v1.9.1, and
|
||||
the hypothesis accounted for only a sixth of it:
|
||||
|
||||
| item | v1.8.14 | v1.9.1 | delta |
|
||||
|---|---|---|---|
|
||||
| `/root/.npm/_cacache` — the build's npm download cache | 35.2 MB | 145.3 MB | **+110 MB** |
|
||||
| `@mariozechner/clipboard-*` foreign platform packages (2 sites) | 0 MB | 21.1 MB | **+21 MB** |
|
||||
| variant install layer, uncompressed total | 270.8 MB | 401.9 MB | +131 MB |
|
||||
|
||||
That is the whole delta with no unexplained remainder. Both items are now
|
||||
deleted in the **same layer** that creates them, in both the main install RUN and
|
||||
the studio RUN:
|
||||
|
||||
- **`purge_build_caches`** — `npm cache clean --force` plus `rm -rf /root/.npm`.
|
||||
npm 11 caches every platform tarball it downloads, including the ones the
|
||||
prune then deletes, so the cache grew far faster than the installed tree.
|
||||
Nothing at runtime reads it: the build runs as root, the container runs as
|
||||
`developer` with its own cache under `$HOME`.
|
||||
- **`prune_foreign_esbuild` → `prune_foreign_natives`** — now covers both
|
||||
measured families. For clipboard the keep-set is `clipboard-linux-$arch-gnu`
|
||||
**and** `-musl`, because its napi-rs loader chooses between them at runtime
|
||||
from its own `isMusl()` probe; the musl package is a 420-byte stub, so keeping
|
||||
it is free insurance. The bare `@mariozechner/clipboard` wrapper has no
|
||||
hyphen suffix and cannot match the pattern.
|
||||
|
||||
**Verified on arm64 before writing the patch, which is why the order was
|
||||
update-then-patch:** a widened `rm -rf` glob is the worst possible change to
|
||||
write against a tree you cannot inspect, and there is no docker CLI inside the
|
||||
container — but after a recreate the container *is* the image. The prune was
|
||||
exercised against a copy of the real trees with foreign directories fabricated
|
||||
back in (aix-ppc64, android-arm64, darwin-arm64, win32-x64, linux-x64): all
|
||||
removed, host `linux-arm64` kept at both sites, 21 MB freed, and
|
||||
`require('@mariozechner/clipboard')` still loads and exports all 18 functions.
|
||||
`esbuild.transformSync` still compiles TS at both sites. v1.9.1's own arm64
|
||||
validation of the esbuild prune also passed here — CI could only smoke-test
|
||||
amd64.
|
||||
|
||||
**Two sentinel assertions in `smoke-test.sh`, because the size gate did not
|
||||
catch this.** The gate has ~225 MB of deliberate margin, so 131 MB of pure
|
||||
build residue stayed green. There are now named PASS/FAIL checks for *foreign
|
||||
platform packages beyond the host arch* and for *`/root/.npm` being shipped* —
|
||||
the latter deliberately refuses to run as non-root, because `test ! -d
|
||||
/root/.npm` on mode-700 `/root` would otherwise pass for the wrong reason. The
|
||||
size-failure diagnostics now also list cache paths: they previously enumerated
|
||||
only `node_modules` and `/opt`, where these bytes were not.
|
||||
|
||||
**Also fixed: the prune's own progress line was mangled.** `-printf '%f\\n'`
|
||||
reaches the shell with both backslashes (confirmed from the published image's
|
||||
recorded `created_by`), so `find` emitted a literal backslash and `tr` then ate
|
||||
the `n` out of the name — v1.9.1 printed `esbuild platform dirs kept: li
|
||||
ux-arm64`. Single backslash now.
|
||||
|
||||
Deliberately **not** changed: `/tmp/node-compile-cache` (1.3 MB). The manifest
|
||||
RUN at the end of `Dockerfile.variant` calls `pi --version` again, so deleting
|
||||
it earlier only relocates those bytes into that layer — today's manifest layer
|
||||
is 128 kB precisely because it finds the cache warm.
|
||||
|
||||
**Two functional assertions as well, after the runbook command left for the
|
||||
next machine failed for the wrong reason.** v1.9.1's open item prescribed
|
||||
`node -e 'require("esbuild").transformSync(...)'` as the post-boot check, with
|
||||
"if this fails, the prune removed something needed → revert to v1.8.14". Run
|
||||
from `/workspace` it fails with `MODULE_NOT_FOUND` on a perfectly good image:
|
||||
`require` resolves by walking up from the current directory, esbuild lives
|
||||
nested inside the two pi trees, and global installs are not on node's require
|
||||
path (`NODE_PATH` is unset). The check that verified the prune last time only
|
||||
passed because the shell happened to be inside the tree. Smoke now does it
|
||||
properly and CI owns it: for every install site found in the image (so the
|
||||
studio variant's third site is covered automatically), esbuild must compile TS
|
||||
and `@mariozechner/clipboard` must load with its native binding attached — the
|
||||
latter is the real proof for the clipboard prune, since napi-rs resolves the
|
||||
platform package at `require()` time. Both were verified as a four-way matrix:
|
||||
green on the real image *from `/workspace`*, and red against copies of the same
|
||||
packages with the host platform binary removed (`The package
|
||||
"@esbuild/linux-arm64" could not be found`).
|
||||
|
||||
Touches `Dockerfile.variant` and `scripts/smoke-test.sh` only: `Dockerfile.base`
|
||||
is unchanged, so this needs no base rebuild and should **ride the next release**
|
||||
rather than burn a cycle of its own.
|
||||
|
||||
> **Superseded by the entry below:** that entry refreshes the vendored mempalace
|
||||
> skill snapshot, which **is** hashed into `base_tag`. The release as a whole now
|
||||
> costs a base rebuild (~67 min). The *size* work above still needs none of its
|
||||
> own; the two simply travel together now.
|
||||
|
||||
**A behaviour change reached the fleet without any release naming it, and a
|
||||
paragraph in these notes kept saying it had not.** v1.9.1 bakes
|
||||
`mempalace-toolkit` **`e68ee20`**, which contains **`e2b060a`** — requester-side
|
||||
ask withdrawal (`isWithdrawn`, RFC 003 §3.3 clause 4). So the behaviour has been
|
||||
live on every v1.9.1 device since 2026-09-10, while the v1.9.0 section of this
|
||||
file still read "not yet pinned … this image still pins `e45f6b4`" and the
|
||||
mempalace skill still told every agent, at session start, that a withdrawal is
|
||||
impossible: *"there is nothing anyone can do about it from the other end."*
|
||||
|
||||
The mechanism is the point, because it will do this again. `Dockerfile.variant`
|
||||
carries `ARG MEMPALACE_TOOLKIT_REF=main` and `docker-publish.yml` resolves it to
|
||||
a commit SHA at build time (`gitea_sha mempalace-toolkit`). A release therefore
|
||||
absorbs *whatever toolkit `main` holds at that moment*, and "what behaviour did
|
||||
this image gain?" is a question **nobody is structurally forced to answer**. This
|
||||
fleet already has the rule — a floating ref that pulls a behaviour change into
|
||||
the image must be named in the CHANGELOG *before* tagging. It was honoured for
|
||||
the feed-tick fix, which v1.9.1 names explicitly (`309980b`, `e68ee20`), and
|
||||
missed for the commit sitting in the same range.
|
||||
|
||||
Measured before being written, two independent routes, expectation recorded
|
||||
first ("label should read ≥ `e68ee20`, since the build at 22:00Z postdates that
|
||||
commit's 18:58Z"):
|
||||
|
||||
| route | result |
|
||||
|---|---|
|
||||
| Docker Hub config-blob label, `:v1.9.1-studio` and `:latest-studio` (same digest) | `se.jordbo.pi-devbox.mempalace-toolkit-ref = e68ee2071ca3ad39…` |
|
||||
| `git merge-base --is-ancestor e2b060a e68ee20` | ancestor — the fix is inside the baked ref |
|
||||
| baked `extensions/pi/mempalace.ts` sha256 vs v1.8.14's | `7c16fe14…` vs `dfca71e9…` — different bytes, so not the pre-fix file |
|
||||
| `grep -c isWithdrawn` on this v1.8.14 container's baked copy | `0` — confirms the split, and that tor-ms22 cannot exercise it |
|
||||
|
||||
The first attempt at that label read **empty**, and the empty result was a claim
|
||||
about the request, not the image: Docker Hub redirects blob fetches to a CDN and
|
||||
`curl` without `-L` returns 0 bytes with exit 0. A registry audit that reports
|
||||
"no labels" should be assumed to be missing `-L` until proven otherwise.
|
||||
|
||||
So the skill is updated rather than deferred (skillset **`e9e45f7`**, vendored
|
||||
here with `scripts/vendor-mempalace-skill.sh`, `44472 → 46045 B`). Two things
|
||||
were deliberate:
|
||||
|
||||
- **The "silence is not an answer" rule keeps its teeth.** An ask still stays
|
||||
owed until the *recipient's* terminal event; what is new is a release by the
|
||||
**asker**, explicitly marked. Stated that way round on purpose — the wrong
|
||||
reading of this change is "withdrawals happen, so I need not reply".
|
||||
- **The precondition ships with the rule**, because this skill is read on images
|
||||
that lack the behaviour (tor-ms22, right now):
|
||||
`grep -c isWithdrawn /opt/mempalace-toolkit/extensions/pi/mempalace.ts`, where
|
||||
`0` means the withdrawal will not reach the recipient's mailbox. Same shape as
|
||||
the provenance bullet's live-bridge check.
|
||||
|
||||
**The snapshot canary is re-pinned, and this time it fails on the old bytes
|
||||
instead of merely failing to notice them.** The retired pair (`"Diaries
|
||||
self-heal…"` present / `"Agent diaries live in"` absent) was still green against
|
||||
the new snapshot, so it was blind to this refresh exactly as the pre-v1.8.13 pair
|
||||
was blind to that one. The replacement is stronger than any predecessor here
|
||||
because **both witnesses come from the same upstream commit**: `e9e45f7` added
|
||||
`"Withdrawing an ask you sent"` and deleted `"nothing anyone can do about it from
|
||||
the other end"`, the sentence the new bullet contradicts. Directions were
|
||||
measured against both files rather than read off the diff (`new=1/old=0` and
|
||||
`new=0/old=1`), then the canary body was **executed** against each: new → `rc=0
|
||||
ok`, old → `rc=1` empty.
|
||||
|
||||
**`isWithdrawn` is no longer deployed-and-unproven — and the suite that pins it
|
||||
had been dark since the node 24 bump.** The rule was exercised on the released
|
||||
image against the live logstream on 2026-09-14, from a container recreated onto
|
||||
v1.9.1 (born 13:21:41Z, confirmed by entrypoint-written mtimes and docker-written
|
||||
`/etc` files agreeing to the second; `/proc/uptime` and `ps -o lstart=` were not
|
||||
used, per their retraction). Baked toolkit `e68ee20`, `grep -c isWithdrawn` = 3,
|
||||
mempalace.ts sha256 `7c16fe14…`, and the file pi actually loads verified to be
|
||||
that same path and hash rather than a stale copy.
|
||||
|
||||
The instrument matters as much as the result: the shipped extractor was lifted
|
||||
out of `scripts/test-owed-withdrawal.sh` and used to cut `TERMINAL_STATUS`,
|
||||
`isStrictlyAfter`, `isAnswered` and `isWithdrawn` out of the **baked**
|
||||
mempalace.ts by brace matching, then `deriveOwed`'s three queries were replayed
|
||||
with their exact shipped parameters over the same `/mcp` transport the extension
|
||||
uses. Shipped bytes, live data, no second copy of the logic. Baseline owed = 1,
|
||||
agreed by three independent routes (the extension's own wake-up card; a hand
|
||||
derivation of 23 candidates; the shipped predicates). Every expectation was
|
||||
recorded before its measurement:
|
||||
|
||||
| probe | predicted | observed |
|
||||
|---|---|---|
|
||||
| positive control planted | owed 1 → 2 | 2 |
|
||||
| requester withdraws it | back to 1 | 1, and `isAnswered=false isWithdrawn=true` |
|
||||
| **third party** retracts someone else's ask | no effect | still owed |
|
||||
| requester withdraws in **prose**, no marker | no effect | still owed |
|
||||
| cleanup by ordinary replies | owed == baseline | 1, then 0 |
|
||||
|
||||
The count returning to baseline is only arithmetic; the per-predicate verdict is
|
||||
what makes it a statement about mechanism. Probe A remained a raw candidate
|
||||
throughout and no terminal reply of ours existed on its correlation, so its
|
||||
removal is attributable to the withdrawal rule alone. Final attribution: A
|
||||
cleared by `isWithdrawn` only, the two negative controls cleared by `isAnswered`
|
||||
only — each probe retired by the predicate it was built to exercise. Both marker
|
||||
spellings now have live witnesses (`metadata.withdraws` naming the ask's event id,
|
||||
and naming its correlation). Unplanned and worth more than the probes: against
|
||||
live data the rule also fires on the incident it was written for — seq 112 is
|
||||
reported withdrawn-by-requester, i.e. mbp-m1-2020's seq 119 withdrawal now works,
|
||||
so the 41h false obligation cannot recur. Full record in the coordination log at
|
||||
`project/pi-devbox` seq 140; harness preserved as artifact
|
||||
`art_20260914T141704_a7a9a294a4bb`.
|
||||
|
||||
Not proven, and deliberately not claimed: the extension's **own in-process
|
||||
mailbox poll** surfacing a withdrawn ask. That poll fires at `agent_settled` when
|
||||
the agent is idle; two probes were left owed across the rest of the session to
|
||||
give it a window and it did not fire. Calling that confirmed would be a claim
|
||||
about the session's patience, not about the code.
|
||||
|
||||
**Toolkit pickup `dab989b` — the owed-set suite could not run on this image, and
|
||||
its own gate was why.** Reaching for the suite as corroboration exposed a second
|
||||
defect: its precondition line `node --experimental-strip-types --check "$SRC"`
|
||||
exits 2 on an unmodified mempalace.ts under node 24, and with `set -euo pipefail`
|
||||
that skipped all 17 assertions and both regression guards. The cause is narrower
|
||||
than the error suggests — it names the inline type-import, but `node --check` does
|
||||
not type-strip at all: a file containing only `const x: number = 1` fails
|
||||
identically, while executing the same file works. So the gate could never
|
||||
validate TypeScript on any node; v1.9.1's bump from v22.23.2 to v24.21.0 is the
|
||||
most likely trigger, though with no node 22 on the box that half stays labelled
|
||||
inference rather than measurement. It failed **closed** — loud exit 2, never
|
||||
vacuously green — which is the good direction, and the reason it went unnoticed is
|
||||
that nothing in CI runs this suite.
|
||||
|
||||
That matters more than a red test, because this suite is the only thing that makes
|
||||
`isWithdrawn`'s failure mode visible: a wrong rule there does not throw and does
|
||||
not log, it makes a real unanswered ask vanish from a mailbox forever. `dab989b`
|
||||
strips first and syntax-checks the emitted JS, and splits the exit codes so that
|
||||
`3` means the gate cannot run while `2` means the source does not parse —
|
||||
collapsing those is how the defect disguised itself as "mempalace.ts does not
|
||||
parse" while mempalace.ts was fine. Verified in six directions with expectations
|
||||
written first: clean source `rc=0` 17/17; malformed TypeScript `rc=2`; stripper
|
||||
made unavailable `rc=3` without the misleading message; and the four mutation
|
||||
kills back at e2b060a's counts of 3/1/2/1, so sensitivity is restored rather than
|
||||
asserted.
|
||||
|
||||
> **Cost, and it is smaller than it looks:** `MEMPALACE_TOOLKIT_REF` is resolved
|
||||
> by CI to the head of the toolkit's `main` and folded into the `base_tag` hash —
|
||||
> verified at `docker-publish.yml:126-128`, whose own comment gives the reason
|
||||
> ("otherwise a toolkit-only fix never lands"). So `dab989b` moves `base_tag` and
|
||||
> the next tag rebuilds the base, with nothing to remember to trigger. But it adds
|
||||
> no rebuild that was not already owed: `9aaff26` refreshed the vendored skill
|
||||
> snapshot under `rootfs/`, which is also hashed into `base_tag`, so a base
|
||||
> rebuild has been pending since before this fix existed. The toolkit pickup rides
|
||||
> along with it, and the same rebuild is what finally bakes skillset `e9e45f7` and
|
||||
> turns the snapshot canary above green against the image's own floor.
|
||||
|
||||
---
|
||||
|
||||
## v1.9.1 — 2026-09-10
|
||||
|
||||
**v1.9.0 was tagged but never published: its own smoke gate stopped it, and it
|
||||
was right to.** `build-base` succeeded, then `smoke` failed 90-passed/3-failed,
|
||||
and because `build-variant` needs `smoke`, both variants, `promote-base-latest`
|
||||
and `update-description` were skipped. No image reached the registry, so
|
||||
`latest` still pointed at v1.8.14. v1.9.1 carries everything listed under v1.9.0
|
||||
below, plus the three fixes here. Two of the three failures were self-inflicted
|
||||
by v1.9.0's own changes, and the third was a real regression that the Node bump
|
||||
dragged in — which is the case for keeping the gate strict.
|
||||
|
||||
**Failure 1 — the image was 431 MB over its size threshold, and npm 11 was the
|
||||
cause.** Node 22 → 24 brings npm 10 → 11, and npm 11 installs **every**
|
||||
`@esbuild/<platform>` optional binary rather than only the one matching the host:
|
||||
26 platform directories covering aix-ppc64, android, darwin, freebsd, netbsd,
|
||||
openbsd, win32, s390x, riscv64 and more, none of which this image can execute.
|
||||
Measured on pi-fork's dependency tree, same repo and same command:
|
||||
|
||||
| npm | packages | `node_modules` |
|
||||
| --- | --- | --- |
|
||||
| 10.9.8 | 136 | **165 MB** |
|
||||
| 11.19.0 | 169 | **449 MB** |
|
||||
|
||||
The 165 MB figure reproduces exactly what v1.8.14 shipped, which is what
|
||||
identified npm rather than the image as the variable. esbuild declares those
|
||||
binaries with `os`/`cpu` constraints, but npm 11 ignores them — and also ignores
|
||||
`--os`/`--cpu` flags and an `.npmrc` carrying `os=`/`cpu=` (all three measured,
|
||||
all three still produced 26 directories). So `Dockerfile.variant` now prunes
|
||||
explicitly, keeping only `linux-$(node -p process.arch)` so one line is correct
|
||||
on amd64 and arm64. Verified this removes dead weight and not function: after
|
||||
pruning, `esbuild.transformSync` still compiles TypeScript. The prune runs in the
|
||||
**same layer** as each `npm install` — deleting in a later `RUN` would leave the
|
||||
bytes in the earlier layer and shrink the image by nothing. Three sites are
|
||||
covered: the global pi install, pi-fork, and pi-studio (which pulls its own
|
||||
pi-coding-agent copy), for roughly 548 MB recovered in the non-studio variant and
|
||||
822 MB in studio. The threshold stays at 3800 MB deliberately: it caught a real
|
||||
regression, and raising it to accommodate one would have discarded the signal.
|
||||
|
||||
**Failure 2 — the om `node_modules` assertion was checking an npm artefact, not
|
||||
the software.** `pi-observational-memory` declares **zero** runtime dependencies:
|
||||
8 devDependencies (omitted by `--omit=dev`) and 4 peerDependencies, which pi
|
||||
itself provides. npm 10 still materialised a `node_modules` for it, but that
|
||||
directory contained exactly **one file** (`.package-lock.json`, 4 KB) and no
|
||||
nested `package.json` — 20 empty scope directories. npm 11 stopped creating it,
|
||||
so `test -d node_modules` went red while nothing about om had changed or broken.
|
||||
The assertion now checks what must actually hold — that the entry point pi loads
|
||||
exists — read out of the manifest pi itself reads (`package.json` →
|
||||
`pi.extensions`) rather than a hardcoded path that could drift. pi-fork keeps its
|
||||
`node_modules` check, because pi-fork has real dependencies where the directory's
|
||||
absence would mean something.
|
||||
|
||||
**Failure 3 — the skill-source annotation broke the assertion that reads it.**
|
||||
v1.9.0 taught `pi-devbox-version` to say *which* pi-extensions copy shipped
|
||||
(`baked (package copy)`, or a loud FALLBACK/MIXED marker). The smoke assertion
|
||||
matched `^ $s +baked$`, anchored at the end, so the annotation failed it even
|
||||
though the state reported was correct. The pattern now allows an optional
|
||||
` (...)` suffix, matched loosely on purpose: *which* copy shipped is already
|
||||
asserted authoritatively against the manifest field and its measured tree hash,
|
||||
and re-encoding that wording in a second regex would just add a second place to
|
||||
update. The lesson recorded rather than the fix alone: the display branches were
|
||||
tested in an isolated harness that passed, but the assertion **consuming** them
|
||||
was never run — harness-passes-therefore-consumer-passes was an assumption.
|
||||
|
||||
**A red size assertion now carries its own diagnostic.** Attributing the 431 MB
|
||||
took a full CI-log dig plus a local npm bisect, while the container knew where
|
||||
its bytes were the whole time. On failure the check now prints the largest
|
||||
layers, the largest directories, and a count of `@esbuild` platform directories
|
||||
as a sentinel for this exact regression recurring — the same principle the `run()`
|
||||
helper already applies to every other assertion.
|
||||
|
||||
No `Dockerfile.base` or `rootfs/` change, so the base fingerprint is untouched
|
||||
and `base-decide` reuses `base-0fb1256c7f99` built during the v1.9.0 attempt.
|
||||
|
||||
**A gate for documentation drift, because five claims rotted in one release and
|
||||
one of them was published.** Preparing v1.9.0 turned up a cluster of stale
|
||||
facts, all the same shape — a value written once by hand, in a file nothing
|
||||
verifies, about a number that lives somewhere else and moved:
|
||||
|
||||
- `README.md`'s "Version pins" table was wrong on **all three rows**: pi
|
||||
`0.84.4` vs `ARG PI_VERSION=0.85.1`, pi-atelier `v0.10.0` vs `v0.10.1`,
|
||||
mempalace `3.8.0` vs `3.9.0`. That table is the worst possible place for this,
|
||||
because it exists *specifically* to be the reviewable record of what the repo
|
||||
freezes deliberately — so a wrong row destroys the only thing it is for.
|
||||
- `README.md` listed already-shipped typst PDF export under "Planned for an
|
||||
upcoming minor release", carrying the self-contradicting marker "(shipped in
|
||||
Unreleased/base)". The **fourth** instance of the stale-`Unreleased`-pointer
|
||||
class this changelog already documented three of.
|
||||
- `DOCKER_HUB.md` claimed "Node.js v22" while v1.9.0 ships Node 24.
|
||||
|
||||
The last one is why this became a gate rather than a resolution to be careful.
|
||||
`DOCKER_HUB.md` is **published**: `update-description` POSTs it to Docker Hub as
|
||||
`full_description` on every tag. It had gone **eight releases** (v1.8.6 →
|
||||
v1.9.0) without a touch. Nothing generates it — CI only substitutes
|
||||
`{{PI_VERSION}}` — and nothing checked it, so the sole mechanism keeping it true
|
||||
was whoever remembered. Worse, it is read from the **tag**, so the stale page
|
||||
published with v1.9.0 anyway and the fix could only ride the next release.
|
||||
|
||||
**New: `scripts/check-doc-drift.sh` + a `doc-drift` job in `lint.yml`.** Seven
|
||||
checks, all comparing a doc string to a value that exists in this repo, so it
|
||||
needs no network, no token, no built image, and no sibling clone:
|
||||
|
||||
- README's three pin-table rows vs the ARGs they name *by name*
|
||||
- `DOCKER_HUB.md`'s Node claim vs `ARG NODE_VERSION`
|
||||
- placeholders CI will not substitute — the publish step greps for leftovers of
|
||||
`{{PI_VERSION}}` only, so any *second* token sails through and publishes
|
||||
literally
|
||||
- `DOCKER_HUB.md` under Docker Hub's 25 000-char `full_description` limit
|
||||
(previously discoverable only as a non-200 *after* the full build)
|
||||
- `Unreleased` appearing in a user-facing doc, which is always a pointer that
|
||||
outlived what it pointed at
|
||||
|
||||
Exit codes match `lint-shell.sh` and `check-skill-floor.sh`: `0` in sync, `1`
|
||||
drift, `2` cannot run — a renamed ARG makes the gate blind, which is a red `2`,
|
||||
never a green tick. Verified with **15 controls**: every check fails when its
|
||||
claim is broken, the real v1.9.0 Node bug is caught, and two false-positive
|
||||
controls pass — the first version of the placeholder check wrongly flagged
|
||||
`README.md:900`'s `docker inspect --format '{{json .Config.Labels}}'`, a Go
|
||||
template in a legitimate example, so the pattern is now anchored to the
|
||||
UPPER_SNAKE convention CI actually substitutes. **The gate was wrong, not the
|
||||
doc** — which is the whole reason a gate gets negative controls.
|
||||
|
||||
Deliberately **not** gated, and the reasons matter more than the list:
|
||||
|
||||
- Counts and sizes (`~1.1 GB`, "N `mempalace_*` tools", "7 extensions") need a
|
||||
running image. A gate that cannot evaluate a claim honestly would have to
|
||||
guess, and a guessing gate is worse than none — assert these in
|
||||
`scripts/smoke-test.sh`, where a real image exists.
|
||||
- `Dockerfile.base`'s `# BASE_REBUILD_DATE:` marker, itself stale (2026-07-13,
|
||||
three base rebuilds ago). `base_tag` hashes Dockerfile.base's *content*,
|
||||
comments included, so demanding it be current would force a ~60 min base
|
||||
rebuild on a release that touched no base files at all. It is free to fix
|
||||
while the base is *already* rebuilding, and expensive at any other moment.
|
||||
That cost asymmetry is now written into the release checklist rather than
|
||||
enforced.
|
||||
|
||||
**Release checklist step 3 rewritten** (`AGENTS.md`) around the mechanism that
|
||||
made this expensive: `docker-publish.yml` runs `actions/checkout@v4` with no
|
||||
`ref:`, so every job reads `github.ref` — the tag. Docs must be correct *before*
|
||||
tagging; afterwards the only routes are re-pointing the tag (its own hazard —
|
||||
v1.8.14 went `601fc98` → `361babd` and broke deploy verification until
|
||||
`git fetch --tags --force`) or waiting for the next release. The step now also
|
||||
names what the gate cannot see, so "gate is green" is not mistaken for "docs are
|
||||
true". The same reflex went into the `ci-release-watcher` skill, as the first
|
||||
correctness rule — it is the only one that expires once the tag exists.
|
||||
|
||||
Also fixed in passing: README's `pi-devbox-version` sample was v1.5.0-era and
|
||||
structurally outdated (it predated the `palace:` line the surrounding prose
|
||||
advertises, the `pi-atelier` component, and the whole `skills:` block). Replaced
|
||||
with real observed output rather than hand-written text. `DOCKER_HUB.md`'s "7
|
||||
user-facing extensions" was **verified correct**; its "29 `mempalace_*` tools"
|
||||
is stale (a live client shows 45) but left alone rather than corrected on a
|
||||
guess, since that count cannot be attributed to the baked 3.9.0 server without
|
||||
measuring it.
|
||||
|
||||
## v1.9.0 — 2026-09-10 (tagged, never published — superseded by v1.9.1)
|
||||
|
||||
> This tag exists in git but no image was ever pushed for it: `smoke` failed
|
||||
> three assertions and skipped every downstream job. Everything below ships in
|
||||
> **v1.9.1**, whose entry explains the three failures and their fixes. Kept as
|
||||
> its own section rather than folded away, because the tag is real and someone
|
||||
> will eventually find it and wonder why Docker Hub has no v1.9.0.
|
||||
|
||||
**`shellcheck` is now in the image, because the release gate it depends on could
|
||||
not be run by anyone.** v1.8.14 made shell lint a release gate: `scripts/lint-shell.sh`
|
||||
@@ -92,6 +493,20 @@ derivation's `mine` query at the newest end (`order: "desc"`); with the previous
|
||||
default `asc` + `limit: 100`, a device passing 100 authored events would have its
|
||||
recent replies fall out of the join window and see answered asks resurface.
|
||||
|
||||
> **Corrected 2026-09-14 (`pi@tor-ms22`, the device in the measured cost above).**
|
||||
> "This image still pins `e45f6b4`" and "until an image bakes `e2b060a` or later"
|
||||
> were true when written on 2026-09-09 and are **false for the running fleet**.
|
||||
> **v1.9.1 bakes `e68ee20`**, a descendant of `e2b060a`, so requester-side
|
||||
> withdrawal is LIVE wherever v1.9.1 runs. Measured from the published image's own
|
||||
> label (`se.jordbo.pi-devbox.mempalace-toolkit-ref`) rather than from these
|
||||
> notes, and cross-checked by ancestry and by the baked `mempalace.ts` sha
|
||||
> differing from v1.8.14's. Nothing here was mis-stated on purpose:
|
||||
> `ARG MEMPALACE_TOOLKIT_REF=main` is resolved to a commit SHA by CI at build
|
||||
> time, so the release absorbed the commit without anybody having to name it,
|
||||
> while this paragraph went on asserting it had not. Left standing rather than
|
||||
> rewritten — the sentence is the evidence for how the drift happened. See
|
||||
> Unreleased.
|
||||
|
||||
**Four small packages, each chosen from a gap that was measured rather than
|
||||
imagined.** All four were picked by looking back at a real session — the
|
||||
`gitea.egl.lan`/FreeIPA debugging of 2026-09-09..10 — and asking which absences
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
||||
uv run --with jupyterlab jupyter lab --no-browser --port 8888
|
||||
uv run --with marimo marimo edit
|
||||
```
|
||||
- **Node.js** v22 + npm (used by pi itself)
|
||||
- **Node.js** v24 LTS + npm (used by pi itself)
|
||||
- **Rust** — `rustup-init` is on PATH; install toolchains on demand
|
||||
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
|
||||
|
||||
|
||||
+90
-1
@@ -196,6 +196,71 @@ RUN set -e && \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
# prune_foreign_natives: npm 11 (shipped with Node 24) installs EVERY optional
|
||||
# platform package of a native dependency, not just the one matching the host.
|
||||
# TWO families are affected in this image, and BOTH have been measured — add a
|
||||
# family here only after measuring it, never by widening the pattern on a hunch:
|
||||
#
|
||||
# @esbuild/<platform> 26 dirs, 284 MB (found first, v1.9.1)
|
||||
# @mariozechner/clipboard-<triple> 11 dirs, 12 MB per site, 10 MB foreign
|
||||
#
|
||||
# esbuild declares those with os/cpu constraints, but npm 11 ignores them and
|
||||
# ALSO ignores --os/--cpu and an npmrc carrying os=/cpu= (all three measured).
|
||||
# So prune explicitly, keeping only the host platform, computed from
|
||||
# `node -p process.arch` so one line stays correct on amd64 and arm64.
|
||||
# Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB,
|
||||
# and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped.
|
||||
#
|
||||
# WHY THE CLIPBOARD FAMILY WAS ADDED (2026-09-11): v1.9.1 pruned @esbuild only
|
||||
# and still shipped +131 MB compressed over v1.8.14. That residual was
|
||||
# attributed by listing the PUBLISHED arm64 layer tarballs straight from the
|
||||
# registry (there is no docker CLI inside the container, so `docker history`
|
||||
# was not available): +110 MB /root/.npm/_cacache (purged below) and +21 MB of
|
||||
# clipboard platform packages across the two install sites — 131 MB total, so
|
||||
# the delta is now fully accounted for with no unexplained remainder.
|
||||
#
|
||||
# Keeping linux-$arch-{gnu,musl} is deliberate: clipboard's napi-rs loader
|
||||
# tries ./<name>.node then the platform package, per platform in try/catch, and
|
||||
# chooses gnu vs musl at runtime from its own isMusl() probe — so both host-arch
|
||||
# branches must survive. The musl package is a 420-byte stub, i.e. free. The
|
||||
# bare wrapper `@mariozechner/clipboard` has no hyphen suffix and therefore
|
||||
# cannot match the regex below. Verified on arm64 against a copy of the real
|
||||
# tree before this was written: after pruning to those two,
|
||||
# require('@mariozechner/clipboard') still loads and exports all 18 functions.
|
||||
# esbuild likewise still compiles TS via transformSync at both install sites.
|
||||
# This removes dead weight, not function.
|
||||
#
|
||||
# MUST run in the SAME layer as the npm installs above: deleting in a later RUN
|
||||
# leaves the bytes in this layer and shrinks the image by nothing.
|
||||
# NOTE the single backslash in -printf '%f\n': Docker passes '\\n' through
|
||||
# verbatim, so v1.9.1's doubled version printed a mangled "li ux-arm64"
|
||||
# (find emitted a literal backslash, then `tr` translated the n out of the
|
||||
# name). Confirmed from the published image's own recorded created_by.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
} && \
|
||||
# purge_build_caches: the build's own download caches are NOT free — they land
|
||||
# in whichever layer created them. Measured on the published v1.9.1 arm64
|
||||
# variant layer: root/.npm/_cacache was 145.2 MB of a 401.9 MB layer (35.2 MB
|
||||
# in v1.8.14), the single biggest item in the +131 MB residual, because npm 11
|
||||
# caches every platform tarball it fetched — including the ones just pruned.
|
||||
# Nothing at runtime reads it: the build runs as root, the container runs as
|
||||
# `developer` with its own cache under $HOME (and $HOME/.pi is a volume).
|
||||
# DELIBERATELY NOT purged here: /tmp/node-compile-cache (1.3 MB, written by
|
||||
# `pi --version` below). The manifest RUN at the end of this file calls
|
||||
# `pi --version` again, so deleting it here only relocates those bytes into
|
||||
# that layer instead of removing them from the image — measured, not assumed:
|
||||
# today the manifest layer is 128 kB precisely because it finds the cache warm.
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
@@ -209,6 +274,8 @@ RUN set -e && \
|
||||
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
@@ -309,6 +376,26 @@ ARG PI_STUDIO_REF=main
|
||||
ARG PI_STUDIO_VERSION=none
|
||||
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
set -e; \
|
||||
# Same prune + cache purge as the main install RUN — see the comments there.
|
||||
# They have to be redefined because shell functions do not survive across
|
||||
# layers, and they have to run in THIS layer because pi-studio's npm install
|
||||
# happens here: deleting in a later RUN would leave the bytes in this layer
|
||||
# and shrink nothing. pi-studio pulls its own pi-coding-agent copy, so it is
|
||||
# a third ~274 MB site on top of the two in the non-studio variant — and its
|
||||
# npm install refills /root/.npm, which the main RUN emptied in ITS layer.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
}; \
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
}; \
|
||||
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
|
||||
git -C /opt/pi-studio init -q && \
|
||||
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
||||
@@ -320,6 +407,8 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
||||
fi
|
||||
|
||||
@@ -392,7 +481,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
||||
# Dockerfile.base, so no folding into the base hash is required — nor would
|
||||
# it be correct, since this ARG changes nothing about the base's contents.)
|
||||
ARG SKILLSET_SNAPSHOT_REF=4d7c0ea9caeb3a1d6d9b04cf34f3fca5f9df4985
|
||||
ARG SKILLSET_SNAPSHOT_REF=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7
|
||||
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
|
||||
@@ -175,12 +175,10 @@ Currently published:
|
||||
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
|
||||
|
||||
Planned for an upcoming minor release:
|
||||
|
||||
- *(shipped in Unreleased/base)* **PDF export from Studio/pandoc** now works:
|
||||
the base image ships **`typst`** as the PDF engine (`pandoc --pdf-engine=typst`),
|
||||
a single ~30 MB static binary — no separate `-tex` variant needed.
|
||||
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
|
||||
Both variants ship **`typst`** as the pandoc PDF engine
|
||||
(`pandoc --pdf-engine=typst`), a single ~30 MB static binary, so PDF export from
|
||||
Studio/pandoc works out of the box — no separate `-tex` variant needed.
|
||||
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
|
||||
|
||||
## Using pi-studio (`-studio` variant)
|
||||
|
||||
@@ -919,16 +917,23 @@ through `jq` yourself:
|
||||
|
||||
```console
|
||||
$ pi-devbox-version
|
||||
pi-devbox v1.5.0
|
||||
built: 2026-07-13T17:53:16Z (source d68674d11e06)
|
||||
pi: 0.80.6
|
||||
pi-devbox v1.8.14
|
||||
built: 2026-09-08T21:54:07Z (source 361babd4fd61)
|
||||
pi: 0.85.1
|
||||
palace: 3.9.0
|
||||
components:
|
||||
pi-toolkit: 9a8f6faeaa08
|
||||
pi-extensions: 61c98e004e3d
|
||||
pi-fork: 4a09af4ef527
|
||||
pi-observational-memory: 27a5195eaf90
|
||||
mempalace-toolkit: 96699f2a1781
|
||||
pi-studio: 2ef38ef31cea
|
||||
pi-toolkit: adfb553f5c8a
|
||||
pi-extensions: 2610545c83bb
|
||||
pi-fork: e69725c39603
|
||||
pi-observational-memory: ce9fc982b3a2
|
||||
pi-atelier: 734258bbcb62
|
||||
mempalace-toolkit: e45f6b430181
|
||||
pi-studio: e04fc7aa3275
|
||||
skills:
|
||||
credential-incident-response baked
|
||||
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
|
||||
pi-devbox-environment baked
|
||||
pi-extensions baked
|
||||
```
|
||||
|
||||
It also flags **live drift** — if `pi --version` no longer matches what was
|
||||
@@ -1093,7 +1098,7 @@ persisted volumes survived, and pi runtime wiring is intact:
|
||||
```bash
|
||||
./scripts/recreate-sanity-check.sh # auto-detects variant
|
||||
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.84.4 # assert the pi coding agent version
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.85.1 # assert the pi coding agent version
|
||||
```
|
||||
|
||||
Those are **two different versions**, and the flags are not interchangeable:
|
||||
@@ -1132,9 +1137,9 @@ resolved to `latest` at build time:
|
||||
|
||||
| Component | Pin | Where |
|
||||
|---|---|---|
|
||||
| pi | `0.84.4` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.10.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.8.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
| pi | `0.85.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.10.1` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.9.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
|
||||
The objective is **not** to freeze versions. Bumping is routine — usually one
|
||||
line plus a changelog note. The objective is that adopting a new upstream
|
||||
|
||||
@@ -535,7 +535,10 @@ Two consequences worth internalising:
|
||||
- **"Seen, not doing it" is a legitimate ack** — `status="blocked"` or
|
||||
`"superseded"` plus the reason. Silence is not, and it is not merely rude:
|
||||
with no terminal event of yours to join to, the ask stays in the owed set
|
||||
indefinitely and there is nothing anyone can do about it from the other end.
|
||||
indefinitely. The *original requester* — and nobody else — can release it from
|
||||
the other end, but only by saying so explicitly: see **Withdrawing an ask you
|
||||
sent** below. That is a release by the asker, not an escape for the answerer.
|
||||
While the ask still stands, only *your* terminal event clears it.
|
||||
- **Nothing expires, and it should not.** An `open` with no terminal reply is
|
||||
still live by definition, and the finished threads are valuable history. If
|
||||
content is genuinely perishable ("do not push to main for the next hour"), say
|
||||
@@ -570,6 +573,24 @@ Two consequences worth internalising:
|
||||
be matched to it at all.
|
||||
- **Corrections are new events, never edits.** Say explicitly what you retract
|
||||
and name the id — drawer or event — that carried the withdrawn claim.
|
||||
- **Withdrawing an ask you sent: state it, never imply it.** Your release only
|
||||
counts when the terminal event (a) comes from the same `from_agent` that sent
|
||||
the ask, (b) is directed at that recipient exactly — never `*`, so a broadcast
|
||||
can neither oblige nor release, (c) carries a terminal status (`claimed` and
|
||||
`ready` are not terminal and do not release anything), (d) is strictly after
|
||||
the ask, (e) joins it via `ack_of` or the same `correlation_id`, **and (f)
|
||||
names that ask in `metadata.withdraws` or `metadata.closes`.** Prose in the
|
||||
body does not count, and neither does a bare terminal event on the
|
||||
correlation: inferring release from *any* terminal would let your own
|
||||
bookkeeping silently delete a real obligation, so the release must be stated.
|
||||
Needs toolkit ≥ `e2b060a` (image ≥ `v1.9.1`) — check with
|
||||
`grep -c isWithdrawn /opt/mempalace-toolkit/extensions/pi/mempalace.ts` and
|
||||
read `0` as "my withdrawal will have no effect on their mailbox". Measured
|
||||
cost of getting it wrong: a `v1.8.13` rollout ask was withdrawn by its sender,
|
||||
who recorded it as done; the recipient's derivation never saw the release and
|
||||
still reported the ask owed **41 hours later**, for a release that device
|
||||
never installed — and the asymmetry was invisible from the sender's side
|
||||
(RFC 003 §3.3 clause 4).
|
||||
- **Put a retraction where the reader will look.** A *directed open ask* reaches a
|
||||
live agent's mailbox; a **terminal-status event reaches no mailbox at all**, and
|
||||
a *drawer* is what a future semantic search finds. If you filed advice as a
|
||||
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-doc-drift.sh — fail when a hand-maintained doc claim contradicts the
|
||||
# build files it describes.
|
||||
#
|
||||
# THE DEFECT CLASS THIS EXISTS TO CATCH, measured 2026-09-10 while preparing
|
||||
# v1.9.0. Five separate claims had rotted, all of them the same shape: a fact
|
||||
# written once by hand, in a file nothing verifies, about a value that lives
|
||||
# somewhere else and moved.
|
||||
#
|
||||
# 1..3. README.md's "Version pins" table was wrong on EVERY row — pi `0.84.4`
|
||||
# vs ARG PI_VERSION=0.85.1, pi-atelier `v0.10.0` vs v0.10.1, mempalace
|
||||
# `3.8.0` vs 3.9.0. That table is the worst possible place for this: it
|
||||
# exists precisely to be the reviewable record of what is deliberately
|
||||
# frozen, so when it lies, the review it enables is worthless.
|
||||
# 4. README.md carried a "Planned for an upcoming minor release" section
|
||||
# listing typst PDF export, which had ALREADY SHIPPED, tagged with a
|
||||
# self-contradicting "(shipped in Unreleased/base)" marker. The
|
||||
# CHANGELOG had already documented three earlier instances of exactly
|
||||
# this stale-"Unreleased"-pointer class (see its v1.8.7 notes).
|
||||
# 5. DOCKER_HUB.md claimed "Node.js v22" while this release ships Node 24.
|
||||
# This one is the reason the gate exists at all: DOCKER_HUB.md is
|
||||
# PUBLISHED. `update-description` in docker-publish.yml POSTs it to Hub
|
||||
# as full_description on every tag, so unlike README.md — which no
|
||||
# workflow or gate reads — a stale claim here is what users see.
|
||||
#
|
||||
# WHY A GATE AND NOT "REMEMBER TO CHECK". DOCKER_HUB.md had gone eight releases
|
||||
# (v1.8.6 → v1.9.0) without a touch. Nothing generates it and nothing verifies
|
||||
# it; the only mechanism keeping it true was whoever remembered. That is the
|
||||
# same failure mode check-skill-floor.sh was written for, and the same fix:
|
||||
# convert "someone remembers" into "CI refuses".
|
||||
#
|
||||
# WHY THESE FIVE CHECKS AND NOT MORE. Every check here compares a doc string to
|
||||
# a value that EXISTS IN THIS REPO, so it can never be wrong about the world and
|
||||
# needs no network, no token, and no built image. Claims that require a running
|
||||
# container to verify (image sizes, the "N mempalace_* tools" count) are
|
||||
# deliberately NOT gated: a check that cannot be evaluated honestly at lint time
|
||||
# would either be skipped or guessed, and a guessing gate is worse than none.
|
||||
# If you want those, assert them in scripts/smoke-test.sh where a real image is
|
||||
# available.
|
||||
#
|
||||
# DELIBERATELY NOT GATED: Dockerfile.base's `# BASE_REBUILD_DATE:` comment, which
|
||||
# is also stale (2026-07-13, three base rebuilds ago). base_tag is a hash of
|
||||
# Dockerfile.base's CONTENT plus rootfs/, comments included, so a gate that
|
||||
# demanded that comment be current would force a ~60 min base rebuild on any
|
||||
# release that touched no base files at all. Fix it when you are already
|
||||
# rebuilding the base — then it is free. This is a real cost asymmetry, not
|
||||
# laziness.
|
||||
#
|
||||
# EXIT CODES (same contract as lint-shell.sh and check-skill-floor.sh):
|
||||
# 0 every checked claim matches
|
||||
# 1 at least one claim has drifted
|
||||
# 2 cannot run (a file or ARG this gate reads is missing/unparseable)
|
||||
# A gate that cannot run must not pass, so a missing input is 2, never 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
README="README.md"
|
||||
HUB="DOCKER_HUB.md"
|
||||
DF_VARIANT="Dockerfile.variant"
|
||||
DF_BASE="Dockerfile.base"
|
||||
|
||||
# Docker Hub rejects a full_description longer than this. docker-publish.yml has
|
||||
# no size check of its own; it only notices via a non-200 from the API, i.e.
|
||||
# after paying the whole build. Catching it here makes it a 2-second failure.
|
||||
HUB_MAX_CHARS=25000
|
||||
|
||||
WARN_ONLY=0
|
||||
FAILURES=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: check-doc-drift.sh [--warn-only] [-h|--help]
|
||||
|
||||
Compares hand-written claims in README.md and DOCKER_HUB.md against the build
|
||||
files they describe (Dockerfile.base, Dockerfile.variant).
|
||||
|
||||
--warn-only Report drift but exit 0 (advisory use, e.g. a local pre-push hook).
|
||||
|
||||
Exit: 0 = in sync, 1 = drift, 2 = cannot run.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--warn-only) WARN_ONLY=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for f in "$README" "$HUB" "$DF_VARIANT" "$DF_BASE"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::$f not found (cwd $PWD). Cannot evaluate doc drift, so this is exit 2, not a pass."
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Read `ARG NAME=value` from a Dockerfile. Exit 2 when absent: if the ARG this
|
||||
# gate is built around has been renamed, the gate is measuring nothing and must
|
||||
# say so rather than silently comparing against an empty string.
|
||||
read_arg() {
|
||||
local file="$1" name="$2" value
|
||||
value="$(sed -n "s/^ARG ${name}=\\(.*\\)\$/\\1/p" "$file" | head -1)"
|
||||
if [ -z "$value" ]; then
|
||||
echo "::error::ARG ${name} not found in ${file}. It was probably renamed;" >&2
|
||||
echo "::error::update check-doc-drift.sh to match, because this gate is now blind." >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# One row of README's "Version pins" table: `| pi | `0.85.1` | ... |`
|
||||
read_pin_row() {
|
||||
sed -n "s/^| $1 | \`\\([^\`]*\`*\\)\` |.*/\\1/p" "$README" | head -1
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAILURES=$((FAILURES + 1))
|
||||
echo "::error::$1"
|
||||
}
|
||||
|
||||
ok() { printf ' OK %s\n' "$1"; }
|
||||
|
||||
echo "Checking hand-maintained doc claims against the build files they describe."
|
||||
echo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1-3. README's version-pin table vs the ARGs it names by name.
|
||||
# ---------------------------------------------------------------------------
|
||||
check_pin() {
|
||||
local label="$1" documented="$2" actual="$3" where="$4"
|
||||
if [ -z "$documented" ]; then
|
||||
fail "README.md: no '| $label |' row found in the version-pin table. Either the
|
||||
table was restructured (update this gate) or the row was dropped (restore it)."
|
||||
return
|
||||
fi
|
||||
if [ "$documented" != "$actual" ]; then
|
||||
fail "README.md version-pin table is stale for $label: says '$documented',
|
||||
$where says '$actual'. Fix the table — it is the reviewable record of what
|
||||
this repo deliberately freezes, so a wrong row defeats its only purpose."
|
||||
return
|
||||
fi
|
||||
ok "README pin $label = $actual"
|
||||
}
|
||||
|
||||
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
||||
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
||||
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
||||
|
||||
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
||||
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
||||
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. DOCKER_HUB.md's Node claim vs ARG NODE_VERSION. This is the published page,
|
||||
# so it is the one whose staleness reaches users.
|
||||
# ---------------------------------------------------------------------------
|
||||
NODE_ACTUAL="$(read_arg "$DF_BASE" NODE_VERSION)"
|
||||
NODE_DOCUMENTED="$(sed -n 's/.*\*\*Node\.js\*\* v\([0-9][0-9]*\).*/\1/p' "$HUB" | head -1)"
|
||||
if [ -z "$NODE_DOCUMENTED" ]; then
|
||||
fail "$HUB: could not find a '**Node.js** vNN' claim. If the wording changed,
|
||||
update this gate; do not leave the published page unverified."
|
||||
elif [ "$NODE_DOCUMENTED" != "$NODE_ACTUAL" ]; then
|
||||
fail "$HUB claims Node v$NODE_DOCUMENTED but ARG NODE_VERSION=$NODE_ACTUAL.
|
||||
This file is PUBLISHED to Docker Hub by update-description on every tag,
|
||||
and it is read from the TAG — so fix it before tagging, not after."
|
||||
else
|
||||
ok "$HUB Node claim = v$NODE_ACTUAL"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Placeholders CI will not substitute. docker-publish.yml substitutes exactly
|
||||
# {{PI_VERSION}} and then greps for leftovers of that ONE token, so any other
|
||||
# {{...}} sails through the guard and is published literally.
|
||||
# ---------------------------------------------------------------------------
|
||||
UNKNOWN_PLACEHOLDERS="$(grep -o '{{[A-Za-z0-9_]*}}' "$HUB" | sort -u | grep -v '^{{PI_VERSION}}$' || true)"
|
||||
if [ -n "$UNKNOWN_PLACEHOLDERS" ]; then
|
||||
fail "$HUB contains placeholders CI does not substitute, which would be
|
||||
published verbatim: $(echo "$UNKNOWN_PLACEHOLDERS" | tr '\n' ' ')
|
||||
docker-publish.yml only fills {{PI_VERSION}}; add substitution there first."
|
||||
else
|
||||
ok "$HUB has no placeholders beyond {{PI_VERSION}}"
|
||||
fi
|
||||
|
||||
# Match only the UPPER_SNAKE placeholder convention CI uses. A bare '{{' search
|
||||
# is WRONG here, and the first version of this check proved it by failing on
|
||||
# README.md:900 — `docker inspect --format '{{json .Config.Labels}}'`, a Go
|
||||
# template in a legitimate example, not a placeholder. The gate was wrong, not
|
||||
# the doc. Keep this anchored to [A-Z] so Go/Jinja/Handlebars examples pass.
|
||||
README_PLACEHOLDERS="$(grep -o '{{[A-Z][A-Z0-9_]*}}' "$README" | sort -u || true)"
|
||||
if [ -n "$README_PLACEHOLDERS" ]; then
|
||||
fail "$README contains placeholder(s) nothing substitutes, so they would render
|
||||
literally for every reader: $(echo "$README_PLACEHOLDERS" | tr '\n' ' ')
|
||||
Only DOCKER_HUB.md gets substitution, and only for {{PI_VERSION}}."
|
||||
else
|
||||
ok "$README has no unsubstituted placeholders"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Hub full_description length.
|
||||
# ---------------------------------------------------------------------------
|
||||
HUB_CHARS="$(wc -c < "$HUB" | tr -d ' ')"
|
||||
if [ "$HUB_CHARS" -gt "$HUB_MAX_CHARS" ]; then
|
||||
fail "$HUB is $HUB_CHARS chars, over Docker Hub's $HUB_MAX_CHARS-char
|
||||
full_description limit. update-description would fail with a non-200 AFTER
|
||||
the full build. Trim it — this file is the essentials-only page, and
|
||||
README.md is the long form on purpose."
|
||||
else
|
||||
ok "$HUB is $HUB_CHARS chars (limit $HUB_MAX_CHARS)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Stale "Unreleased" pointers. "Unreleased" is a CHANGELOG-only concept; in
|
||||
# a user-facing doc it is always a pointer that outlived what it pointed at.
|
||||
# This class has now bitten five times, hence a gate rather than vigilance.
|
||||
# ---------------------------------------------------------------------------
|
||||
STALE_MARKERS="$(grep -n 'Unreleased' "$README" "$HUB" || true)"
|
||||
if [ -n "$STALE_MARKERS" ]; then
|
||||
fail "'Unreleased' appears in a user-facing doc, which is always a stale
|
||||
pointer once the thing ships (it has happened five times here):
|
||||
${STALE_MARKERS//$'\n'/$'\n' }
|
||||
State the fact directly, or move it to CHANGELOG.md where 'Unreleased' means something."
|
||||
else
|
||||
ok "no stale 'Unreleased' pointers in $README or $HUB"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
echo "OK: every checked doc claim matches the build files."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::$FAILURES doc claim(s) have drifted from the build files."
|
||||
echo
|
||||
echo "Docs are read from the TAG, not from main: docker-publish.yml checks out"
|
||||
echo "github.ref, so a fix pushed after tagging does not reach the release or the"
|
||||
echo "Hub page. Update the docs BEFORE you tag."
|
||||
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then
|
||||
echo "(--warn-only: exiting 0 anyway)"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
+121
-4
@@ -28,6 +28,9 @@
|
||||
# (human, --json, --quiet)
|
||||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||||
# client bundle present + registered via `pi install`
|
||||
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
|
||||
# - no build-time npm cache (/root/.npm) shipped in the image
|
||||
# - esbuild compiles + clipboard native loads at every install site
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
@@ -315,8 +318,24 @@ run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
run "pi-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||||
# om is checked differently from pi-fork ON PURPOSE. It declares ZERO runtime
|
||||
# dependencies: 8 devDependencies (omitted by --omit=dev) and 4 peerDependencies,
|
||||
# which pi itself provides. npm 10 still materialised a node_modules for it, but
|
||||
# that directory held exactly ONE file (.package-lock.json, 4 KB) and no nested
|
||||
# package.json at all — 20 empty scope dirs. npm 11 stopped creating it, so the
|
||||
# old `test -d node_modules` assertion went red on v1.9.0 while nothing about om
|
||||
# had changed or broken. It was asserting an npm artefact, not a property of the
|
||||
# shipped software. What actually has to hold is that the entry point pi loads
|
||||
# exists, so assert THAT, straight out of the manifest pi reads
|
||||
# (package.json -> pi.extensions), rather than a hardcoded path that could drift.
|
||||
run "pi-observational-memory clone + declared pi entry point" \
|
||||
"test -f /opt/pi-observational-memory/package.json && \
|
||||
node -e 'const p=require(\"/opt/pi-observational-memory/package.json\"),f=require(\"fs\"),h=require(\"path\"); \
|
||||
const l=(p.pi&&p.pi.extensions)||[]; \
|
||||
if(!l.length){console.error(\"package.json declares no pi.extensions\");process.exit(1)} \
|
||||
for(const e of l){const t=h.resolve(\"/opt/pi-observational-memory\",e); \
|
||||
if(!f.existsSync(t)){console.error(\"declared entry missing: \"+t);process.exit(1)}} \
|
||||
console.log(\"entries ok: \"+l.join(\",\"))'"
|
||||
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's
|
||||
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
|
||||
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
|
||||
@@ -678,7 +697,22 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
|
||||
# (forgotten bump AND re-vendored stale snapshot). Upstream content behind this
|
||||
# refresh: the bare project-name wing convention and the <harness>@<device>
|
||||
# added_by rule.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Diaries self-heal; plain drawers do not" "$f" && ! grep -q "Agent diaries live in" "$f" && echo ok'
|
||||
#
|
||||
# Unreleased: RE-PINNED again on refresh e9e09d9 -> e9e45f7. The retired pair was
|
||||
# still green against the new snapshot (the diaries section was untouched), so it
|
||||
# was blind to this refresh for the same reason the v1.8.13 pair was blind to
|
||||
# that one. The replacement pair is unusually strong because BOTH witnesses come
|
||||
# out of the same upstream commit: skillset e9e45f7 ADDED the "Withdrawing an ask
|
||||
# you sent" bullet and DELETED the sentence "there is nothing anyone can do about
|
||||
# it from the other end" that the new bullet contradicts. Directions were
|
||||
# MEASURED against both files, not read off the diff: "Withdrawing an ask you
|
||||
# sent" is new=1/old=0, "nothing anyone can do about it from the other end" is
|
||||
# new=0/old=1. A canary whose negative witness was removed by the very commit it
|
||||
# pins fails loudly on the OLD bytes instead of merely failing to notice them,
|
||||
# which is the property every previous pair here lacked. Upstream content:
|
||||
# requester-side ask withdrawal became DEPLOYED behaviour once v1.9.1 baked
|
||||
# mempalace-toolkit e68ee20 (>= e2b060a) through the floating MEMPALACE_TOOLKIT_REF.
|
||||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Withdrawing an ask you sent" "$f" && ! grep -q "nothing anyone can do about it from the other end" "$f" && echo ok'
|
||||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||
# baked tree must be what resolves, for all four vendored skills.
|
||||
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
@@ -697,9 +731,17 @@ exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)
|
||||
'out=$(pi-devbox-version)
|
||||
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
|
||||
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
|
||||
echo "$out" | grep -qE "^ $s +baked$" \
|
||||
echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|
||||
|| { echo "$s not reported as baked" >&2; exit 1; }
|
||||
done; echo ok'
|
||||
# The optional " (...)" above is what pi-extensions now appends to say WHICH copy
|
||||
# shipped — "baked (package copy)", or a loud FALLBACK/MIXED annotation. Without
|
||||
# allowing it, adding that annotation turned this assertion red on v1.9.0 even
|
||||
# though the state it reported was the correct one. The suffix is deliberately
|
||||
# matched loosely rather than pinned to "(package copy)", because WHICH copy
|
||||
# shipped is already asserted authoritatively above, against the manifest field
|
||||
# and its measured tree hash, and duplicating that here in a regex would just
|
||||
# create a second place to update whenever the wording changes.
|
||||
# The boot banner must NOT carry the section: entrypoint-user.sh prints the
|
||||
# version FIRST, before the baked links exist and long before the skillset
|
||||
# deploy + reconcile run last, so anything it said about skill sources would be
|
||||
@@ -855,6 +897,55 @@ exec_test "pi-fork extensions floor is [] (forks cannot write to the palace)" \
|
||||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||||
|
||||
# ── Build-time leftovers (npm 11 bloat sentinels) ─────────────────────
|
||||
# Both of these are worth a PASS/FAIL assertion rather than a size-gate
|
||||
# diagnostic, because the size gate has ~225 MB of deliberate margin: v1.9.1
|
||||
# shipped +131 MB of pure build residue and stayed green. These name the
|
||||
# residue directly, so a regression is legible instead of merely "bigger".
|
||||
echo ""
|
||||
echo "── Build-time leftovers ──"
|
||||
|
||||
# npm 11 installs EVERY optional platform package of a native dependency, not
|
||||
# just the host's (it ignores os/cpu, --os/--cpu and npmrc os=/cpu=). Two
|
||||
# families are affected and pruned in Dockerfile.variant: @esbuild/<platform>
|
||||
# and @mariozechner/clipboard-<triple>. Keep-set is the host arch only, plus
|
||||
# clipboard's gnu AND musl (its napi loader picks between them at runtime).
|
||||
# Runs as root because the image declares no USER; that is also what lets the
|
||||
# cache assertion below read /root.
|
||||
run "no foreign platform packages (npm 11 sentinel)" \
|
||||
'arch=$(node -p process.arch); bad=$(find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) ! -name "linux-$arch" ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" -prune -print 2>/dev/null); if [ -n "$bad" ]; then echo "foreign platform dirs shipped:" >&2; echo "$bad" >&2; du -sm $bad 2>/dev/null | sort -rn | head -5 >&2; exit 1; fi; echo ok'
|
||||
|
||||
# The build's own npm download cache is not free: it lands in the layer that
|
||||
# created it. v1.9.1 shipped 145 MB of /root/.npm/_cacache (35 MB in v1.8.14)
|
||||
# — the largest single item in its +131 MB residual, and invisible to the
|
||||
# size-gate diagnostics because those only looked under node_modules and /opt.
|
||||
# Nothing at runtime reads it: root's cache, while the container runs as
|
||||
# `developer`. NOTE the assertion must run as root or a permission error on
|
||||
# mode-700 /root would make `test ! -d` pass for the wrong reason.
|
||||
run "no build-time npm cache shipped (/root/.npm)" \
|
||||
'test "$(id -u)" = "0" || { echo "assertion needs root to read /root" >&2; exit 1; }; if [ -e /root/.npm ]; then echo "/root/.npm shipped: $(du -sm /root/.npm | cut -f1) MB" >&2; exit 1; fi; echo ok'
|
||||
|
||||
# The prune's risk is not "too big" but "removed something needed", and only a
|
||||
# FUNCTIONAL check covers that. These load the natives from every install site
|
||||
# found in the image, so they also scale to the studio variant's third site.
|
||||
#
|
||||
# NOTE THE PATH-QUALIFIED require(). The obvious form, `node -e
|
||||
# 'require("esbuild")...'`, resolves by walking up from the CURRENT DIRECTORY —
|
||||
# so it fails with MODULE_NOT_FOUND from /workspace on a perfectly good image,
|
||||
# because esbuild lives nested inside the pi trees and global installs are not
|
||||
# on node's require path (NODE_PATH is unset). That exact command was left in a
|
||||
# runbook as "if this fails, revert the release", and it duly failed for the
|
||||
# wrong reason on the first machine that ran it. A check must fail only for the
|
||||
# thing it is checking.
|
||||
run "esbuild works at every install site (prune removed weight, not function)" \
|
||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no esbuild install found at all" >&2; exit 1; fi; for d in $sites; do node -e "require(\"$d\").transformSync(\"const x:number=1\",{loader:\"ts\"})" || { echo "esbuild broken at $d" >&2; exit 1; }; done; echo ok'
|
||||
|
||||
# Clipboard is the family pruned second, and its napi-rs loader picks its native
|
||||
# binding at require() time — so a successful load IS the proof that the kept
|
||||
# platform package is the one this image needs.
|
||||
run "clipboard native loads at every install site" \
|
||||
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no @mariozechner/clipboard install found at all" >&2; exit 1; fi; for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done; echo ok'
|
||||
|
||||
# ── Image size ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Image size ──"
|
||||
@@ -882,6 +973,32 @@ elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))
|
||||
# A bare "too big" verdict cost a full CI-log dig plus a local npm bisect to
|
||||
# attribute the v1.9.0 overshoot (+431 MB, which turned out to be npm 11
|
||||
# installing 26 @esbuild platform binaries per pi-coding-agent copy). The
|
||||
# container already knows where its bytes are, so make it say so: the biggest
|
||||
# layers, and the biggest directories under the paths that historically grow.
|
||||
# Same principle as the run() helper above — a red assertion should carry its
|
||||
# own diagnostic rather than send the next reader spelunking.
|
||||
echo " ── largest layers (docker history) ──"
|
||||
docker history --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" 2>/dev/null \
|
||||
| grep -vE '^0B' | head -12 | sed 's/^/ /' | cut -c1-160
|
||||
echo " ── largest directories in the image ──"
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'du -sm /usr/lib/node_modules/* /opt/* /usr/local/share/ms-playwright 2>/dev/null | sort -rn | head -12' \
|
||||
2>/dev/null | sed 's/^/ /' || echo " (could not inspect directories)"
|
||||
echo " ── build caches that should not be in the image ──"
|
||||
# v1.9.1's residual was 145 MB of npm cache under /root, and the du list
|
||||
# above cannot see it: it enumerates node_modules and /opt only. A gate whose
|
||||
# diagnostic looks only where the bytes were LAST time sends the next reader
|
||||
# spelunking again, so name the cache paths explicitly.
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'du -sm /root/.npm /root/.cache /tmp/node-compile-cache /home/developer/.npm 2>/dev/null | sort -rn' \
|
||||
2>/dev/null | sed 's/^/ /' || true
|
||||
echo " ── foreign platform dirs (npm 11 regression sentinel) ──"
|
||||
docker run --rm --entrypoint sh "$IMAGE" -c \
|
||||
'find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) -printf "%f\n" 2>/dev/null | sort | uniq -c | sort -rn | head' \
|
||||
2>/dev/null | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user