Compare commits

...

13 Commits

Author SHA1 Message Date
joakimp e8ddeaf89f skills: a gate that could pass without checking, and a mailbox that never empties
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 1m39s
Fixes the three blockers and seven should-fixes from pi@emb-7kj4vr4g's review
(logstream correlation skills-provenance-review, full text in
drawer_pi-devbox_reviews_e43e766641c9ec85217bc6ce). Every finding was
reproduced by execution here before being fixed; two were refined by that
reproduction rather than taken as given.

BLOCKER 1 — the provenance gate could print OK and exit 0 without verifying.
`git show <ref>:<path> | sha256sum` hashes EMPTY STDIN when the ref does not
resolve, so at_ref was never empty and the UNKNOWN branch was dead code.
Measured: a bogus ref reported MISMATCH — accusing the snapshot of lying when
the real cause was an incomplete clone, and the operator's natural remedy for
MISMATCH is to re-run the refresh, which rewrites provenance to silence the
complaint; and with a 0-byte snapshot against a 0-byte upstream file it printed
"OK: exactly skillset@aaaaaaa" with exit 0 for a ref that does not exist. The
script already had the sha_empty idiom and had applied it to blob_sha but not
to at_ref. Existence is now PROVEN with git cat-file -e before anything is
hashed, at two levels (ref resolves / path exists at it) because those deserve
different messages. Same defect class as the canary it replaces: a check that
can succeed without checking. A second, unflagged instance of the same pipeline
shape in blob_sha was found and fixed too.

Exit codes split, because the old contract failed the sanctioned case: 0
truthful (including stale, with a NOTICE), 1 a lying record only, 2 cannot
determine. AGENTS.md step 2 promised "the message distinguishes the two" and
was the thing this branch was breaking; rewritten to state all three.

BLOCKER 3 — VENDORED.md contradicted itself in the release whose stated
invariant is non-contradiction: its hand-maintained provenance line named
skillset 670f7f1, seven commits behind the ARG and itself the commit that told
agents to hand-stamp added_by — the withdrawn instruction this work exists to
stop shipping — while its cp recipe contradicted the "not cp" rule 20 lines
above. Line removed (nothing forced it to move when the ARGs did); 670f7f1 kept
only as a labelled cautionary example. The pi-extensions half was verified
redundant (CI require_sha resolves PI_EXTENSIONS_REF) before removal.

SHOULD-FIXES: `<root> --check`, the spelling VENDORED.md documented, silently
ran a REFRESH because only $1 was parsed (both tools now parse all args and
reject unknown ones); refresh at a detached/older HEAD silently rewound ref and
bytes (now refused unless the recorded ref is an ancestor, --force to override);
upstream_dirty was computed and never used in check mode; --no-skills --json
printed human text and broke jq; --help was a hardcoded sed range this branch
had already made stale; the fingerprint hashed SKILL.md alone so a live skill
differing only in a sibling file reported "identical", and pi-extensions already
ships two files, so it is now a per-skill TREE hash with the manifest field
renamed skillset_snapshot_tree_sha256; the --no-skills smoke assertion was
negative-only and passed on a crashed binary. mktemp+mv left files 0600 — CI was
unaffected since the index records 100644, so the blast radius was local builds
only, narrower than the review inferred.

Snapshot resynced c04cd15 -> 5fd0d5c so the no-clone fallback carries the
CORRECTED coordination protocol rather than the withdrawn one; --check is now OK
with no staleness notice, and the bidirectional canary re-verified against the
new bytes. Local validation is bash -n only (shellcheck, hadolint and actionlint
are all absent in this container) — CI remains the shellcheck gate.
2026-08-26 14:38:07 +02:00
joakimp 49a6534093 docs: write down the coordination channel the fleet already runs on
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 15s
The logstream has carried cross-machine work since 2026-08-18 — patch handoff,
review, a v1->v2 supersede — and nothing in this repo said it existed. That gap
had a measurable cost this morning: another host addressed a retraction to
pi@tor-ms22 by name and it was read only because the human said "read the
logstream", while the agent was actively rebuilding the thing it warned about.

Split by what each document is authoritative for, so there is one copy of each
claim rather than three that drift:

- README § Cross-machine agent coordination — what the CONTAINER needs.
  MEMPALACE_REMOTE_URL selects the shared palace; MEMPALACE_PI_DEVICE is what
  makes this machine reachable, because where every host is a thin client of one
  palace the stamped agent name is the only thing that distinguishes them. Stated
  as a rule with teeth: set both or neither, since a container missing the device
  var can read the log but is addressable by nobody.
- AGENTS.md release checklist step 2 — the vendored-snapshot refresh, as a
  MECHANISM in the document a releasing agent actually reads, not a comment
  hoping to be noticed. It says the refresh costs a base rebuild, that skipping
  it is legitimate (every enrolled host reads its live clone), and that skipping
  it silently is not.
- CHANGELOG — the three-way split itself, plus the measurement that shaped the
  ack contract: unfiltered, the mailbox returned 5 events, 4 of them finished
  broadcasts from eight days earlier; with status="open", exactly the 1 that
  needed an answer.

Norms live in the skillset skill (82a8d3c, already live on every host that mounts
the skillset — no rebuild) and mechanism in mempalace-toolkit's
extensions/pi/README.md (e70bef2, which also documents the edge stamper that
553d8657 shipped undocumented). Deliberately NOT duplicated here.

Consequence recorded rather than hidden: the skill edit lands in the skillset, so
this repo's SKILLSET_SNAPSHOT_REF now honestly reports itself behind, and
--check exits 1 with "has moved to 82a8d3c; the snapshot describes the older
c04cd15". That message is also fixed in this commit — it previously blamed "the
working tree" even when the tree was clean and only the ref had moved, which is
the same defect class as a canary pinned to a phrase the release deleted: a
message that names the wrong cause. Now distinguishes moved-HEAD from dirty-tree,
verified against both plus the in-sync case.
2026-08-26 12:51:26 +02:00
joakimp e070e0bcbf skills: record the vendored snapshot's provenance, and report which copy wins
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 16s
Found while verifying v1.8.7 from inside a fresh container: the baked mempalace
snapshot is read by no host on this fleet. devbox-skill-reconcile repoints
~/.agents/skills/mempalace at the mounted live clone (the v1.8.5 fix working as
designed), and all four compose stacks mount a workspace containing the
skillset. So the phrase canary that blocked v1.8.7's first tag polices a file
nobody opens, while the drift that could actually mislead an agent — a git pull
nobody ran in /workspace/skillset — was invisible from inside the container and
is invisible to CI by construction.

Record provenance instead of policing it, and move the check to where the
skillset actually is:

- Dockerfile.variant: ARG SKILLSET_SNAPSHOT_REF (the claim) + a sha256 of the
  shipped bytes measured in the manifest layer (the fact), as manifest siblings
  rather than components{} members, plus an OCI label. An ARG default, not a
  CI-resolved output: no credential for the private skillset, no change at any
  of the four variant build call sites, and a local docker build records what CI
  does. Variant-only, so no base rebuild — check-base-hash.sh scans
  Dockerfile.base alone, verified by running it.
- pi-devbox-version: a skills: section naming baked vs live <repo> @ <sha> per
  vendored skill, and for mempalace whether the live copy is identical to the
  baked fingerprint, at the same commit with uncommitted edits, or divergent.
  entrypoint-user.sh passes the new --no-skills, because the banner prints
  before the links exist and long before the reconcile runs.
- scripts/vendor-mempalace-skill.sh: refresh the file and rewrite the ref
  together (a cp without an ARG bump makes the manifest lie, which is worse than
  anonymity); --check verifies the claim against a real clone.
- 5 new smoke assertions (78 -> 83), mutation-tested through the real sh -c
  path: 6 fabricated manifests, where a well-formed hash of the wrong file
  proves the two manifest assertions are not redundant; the all-baked reporting
  test verified to FAIL against a live-skillset environment.

Reviewed mid-flight by pi@emb-7kj4vr4g over the logstream (correlation
skillset-vendor-drift), which retracted its own earlier recommendation of a
build-time byte-compare against skillset HEAD and supplied the better framing:
the invariant is NON-CONTRADICTION, not currency. Byte parity on a fallback
would have cost a resync commit plus a ~67-min base rebuild for each of the four
skillset commits pushed in one evening. Its warning also found a real bug here:
the script now CONSTRUCTS the snapshot from `git show HEAD:<path>` instead of
copying the working tree, because a clean `git diff` says nothing about an
untracked file — the one input the first draft would have recorded a false ref
for. Tested: untracked, unstaged and staged-but-uncommitted all refuse, atomically.

Also fixes three stale in-repo markers of the same class the canary belongs to
(true when written, silently false at release): two dangling "Unreleased"
pointers and a typst line still marked Unreleased five releases after v1.4.0.
2026-08-26 10:30:27 +02:00
pi dbb78798fb vendor: resync mempalace skill snapshot to skillset c04cd15
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 40s
c04cd15 ('the withdrawal only holds where the bridge is live') landed after the
v1.8.7 snapshot was taken, so the baked fallback was already 4 lines behind the
skillset within hours of publishing. It adds the caveat this fleet is currently
living in: the bridge is baked at image build, so a container on an image older
than the stamping commit satisfies both env gates while stamping nothing, and
hand-stamping is still the only signal a hand-filed drawer gets there. It also
gives the one-line test —
  grep -c MEMPALACE_PI_DEVICE "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"
which returns 0 on this v1.8.6 container, confirming the gap empirically.

Note what this instance proves about the canary fixed one commit ago: it still
PASSES on the refreshed copy, because both pinned phrases survived the edit. A
phrase canary cannot detect 'older than skillset main' — only a diff can. This is
the second drift in 24h and is the argument for the Still-open item (a CI job
diffing this file against the skillset repo, blocked on a clone credential for a
private repo). No re-pin was needed here.
2026-08-26 09:41:02 +02:00
pi f645e6654f smoke: fix the snapshot canary that blocked v1.8.7, and make it bidirectional
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 27s
Publish Docker Image / resolve-versions (push) Successful in 1m5s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Successful in 41m8s
Publish Docker Image / smoke (push) Successful in 4m49s
Publish Docker Image / smoke-studio (push) Successful in 18m28s
Publish Docker Image / build-variant (push) Successful in 15m46s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 20s
Publish Docker Image / build-variant-studio (push) Successful in 16m52s
Run 589 built the base cleanly and then failed both smoke jobs 81-passed/1-failed
on 'mempalace skill snapshot is current'. That canary greps a phrase from the
vendored mempalace skill to detect a stale snapshot, and the phrase it pinned was
'Attribute what you file yourself' — the heading of the hand-stamping instruction
that THIS release withdraws. So it fired correctly: the snapshot changed and the
expectation did not. Every publish job was skipped, so nothing reached the
registry and v1.8.7 was never consumed.

Rather than bump the string:

* the assertion is now BIDIRECTIONAL — the new phrase must be present AND the
  withdrawn one absent. A one-way canary only catches half the drift: it cannot
  notice a re-vendored stale snapshot that happens to contain the pinned phrase.
  Verified against v1.8.6's snapshot, which now correctly fails.
* the comment records the structural limit rather than just the fix: a phrase
  canary can only ever detect 'older than what I remembered to pin', never
  'older than skillset main'. Only a diff against the skillset repo can do that,
  which is now a Still-open item — it needs a CI clone credential for a private
  repo, i.e. a policy decision, not a code change.

Changelog consolidated: the SSH sidecar multiplexing default moves from
Unreleased into v1.8.7, since the retag will sit on a commit that contains it,
and the v1.8.7 summary now records the failed first attempt rather than quietly
presenting the second one as the whole story.
2026-08-26 08:09:09 +02:00
pi 657b1ad856 ssh sidecar: default to multiplexing, as a default and not an override
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 16s
A target whose ~/.ssh/config entry never mentioned ControlMaster got no
multiplexing from the sidecar (only ControlPath was supplied), so every ssh call
opened a fresh TCP connection. On 2026-08-25 that produced ~12 connections to
one host in 15 min and a fail2ban block that looked like an outage — the tell
being that HTTPS to the same estate stayed healthy.

The correctness of this depends entirely on WHERE the block goes. ssh_config is
first-value-wins:

  ControlPath   before the Include -> override (the user's value points at
                read-only ~/.ssh and cannot work in the container)
  ControlMaster after  the Include -> default  (an explicit per-host
                'ControlMaster no' must keep winning)

Force what is broken, default what is merely absent. The first draft put both in
the leading block and would have silently overridden an explicit 'no'.

Verified with ssh -G rather than from the man page, including the counterfactual:
under the shipped layout an explicit 'no' resolves to controlmaster false while a
silent host resolves to auto; under the rejected layout the 'no' host flips to
auto. So the test discriminates position, not presence. Plus a sandbox render of
the real script, bash -n, and shellcheck -S error (the v1.8.7 gate) clean.

Effect measured on 41 real host aliases: 22 silent entries gain auto+10m, 0
overridden. Note the fleet's one deliberate opt-out is written as absence plus a
comment ('# No ControlMaster — VPN means direct route'), which ssh cannot
distinguish from no opinion; that host now multiplexes, which its own comment
says is unnecessary rather than harmful.

Skill documents the sidecar-vs-~/.ssh trap (the failure misleads: read-only
ControlPath makes multiplexing look impossible rather than misconfigured) and
the stale-master recovery, ssh -O check / -O exit.
2026-08-25 23:09:46 +02:00
pi ebd0de0be2 changelog: v1.8.7 — device provenance reaches the fleet
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 17s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Successful in 42m4s
Publish Docker Image / smoke (push) Failing after 4m44s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 7m46s
Publish Docker Image / build-variant-studio (push) Has been skipped
The provenance fix's client half lives in mempalace-toolkit, which the image
clones at build time, so it only reaches the fleet through a tag. Records both
routes (extension via MEMPALACE_TOOLKIT_REF folded into base_tag; vendored skill
via rootfs), the three design points (stamp in the client not the agent; diary
marker in TEXT because metadata is invisible to readers; solitary devbox stamps
nothing), and why the allowlist is per tool (3.8.0 hard-fails -32602 on
undeclared args). Carries the CI-hardening work already sitting in Unreleased,
and a Still open block for the three known bounds.
2026-08-25 22:47:53 +02:00
pi 4f1aa0d0dd skills: refresh the vendored mempalace snapshot (withdrawn hand-stamping)
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Successful in 19s
VENDORED.md's freshness model for `mempalace` is "Option 2 only — refreshed
manually per release", and it had drifted since 2026-08-23. The stale snapshot
still carried the instruction to hand-stamp added_by="<harness>@<device>", which
skillset 73c7c8e withdrew: the pi bridge now stamps at the edge
(mempalace-toolkit 553d865), and RFC 001 §7.3.2 ranks agent-side stamping ❌
worst-possible.

That matters specifically for the fallback case this snapshot exists to serve — a
container started WITHOUT the private skillset mounted would otherwise be the
only kind of container still being taught to do it by hand.
2026-08-25 22:27:04 +02:00
joakimp 9e744d701f lint: shellcheck the repo's own shell scripts, not just workflow run: steps
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 15s
lint.yml has shellchecked every workflow `run:` step since the dash-vs-bash
incidents, but nothing had ever pointed shellcheck at entrypoint.sh, scripts/*.sh
or the extensionless tools under rootfs/usr/local/bin/. That gap is not
hypothetical: the skillset repo's ci-release-watcher template shipped
`echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)` for two months, where
the heredoc IS python's stdin (no script arg) so the load hit EOF and the
function silently returned nothing. shellcheck names exactly that at severity
ERROR — SC2259, "This redirection overrides piped input" — and could have named
it the whole time.

New step in the existing actionlint job, so no second container pull: shellcheck
-S error plus bash -n over every shell file, discovered as *.sh UNION a shebang
scan (the glob alone misses pi-devbox-version, devbox-skill-reconcile, dot-watch
and studio-expose; a shebang scan alone would miss a sourced fragment without
one). Fails loudly on a zero-file match, because a green tick over an empty set
is not a check.

Severity chosen by measurement, not taste: -S error is 0 findings across all 11
shell files today, so the gate is green on arrival with no cleanup, while
-S warning is NOT free (19x SC2088 tilde-in-quotes in recreate-sanity-check.sh
plus assorted SC2016, all intentional) and would train everyone to ignore the
job — the same reasoning as the SHELLCHECK_OPTS exclusions already on the
actionlint step.
2026-08-25 21:11:55 +02:00
joakimp 2b8c3a4db4 ci: audit MEMPALACE_VERSION the way PI_VERSION is audited
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 17s
Closes the item v1.8.6 (and v1.8.5 before it) listed as "Still open": the
palace pin was a literal string in Dockerfile.base with zero references in
docker-publish.yml, while PI_VERSION had a concreteness gate, a
published-on-registry check and a never-silently-adopt drift warning.

resolve-versions now applies all of those to MEMPALACE_VERSION, read from
Dockerfile.base so a local `docker build` and CI install the same version by
construction, plus one gate pi does not need: a YANKED release is refused,
because an exact pin installs one silently under PEP 592 and would have
shipped a withdrawn palace client to the whole fleet.

smoke gains `installed mempalace matches CI's audited pin` via a new
EXPECTED_MEMPALACE_VERSION threaded into both smoke jobs. It is not redundant
with `manifest mempalace_version matches the installed core`: that compares two
properties of one image and cannot notice that both are the wrong version. The
case this covers is a variant built FROM a cached base carrying an older pin —
internally consistent, silently stale.

Mutation-tested by extracting the shipped block out of the YAML and stubbing
curl: 9 cases covering every gate, then once end-to-end against live PyPI. That
found a real defect in the first draft — the yank message inlined a jq program
inside $(...) inside a double-quoted string, where the escaping broke the
filter (jq compile error) while the surrounding `exit 1` still fired: a gate
that looked correct and reported garbage.

Note: correcting Dockerfile.base's now-false "known gap, carried forward"
comment forces a base rebuild (~67 min) on the next tag. Leaving a comment
asserting the audit does not exist was the worse option.
2026-08-25 20:33:19 +02:00
joakimp cb7b8ad2ae smoke: assert manifest VALUES, not the presence of field names
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 17s
Four build-provenance assertions grepped the manifest for a field name and
never looked at the value:

    run_expect "manifest records pi_version" "cat …manifest.json" '"pi_version"'

which passes on {"pi_version": ""} and on {"pi_version": null}. The tell was
in its own passing output the whole time — `✅ manifest records pi_version (got
"pi_version")` echoes the key back as the thing it claims to have found. Found
while reading run 579's smoke log to confirm v1.8.6's new assertions had really
executed rather than merely gone green.

Now checked against values, and against ground truth where it exists:

- every required component key present, naming the one that vanished
- every component value a full 40-hex SHA (null allowed for pi-studio alone,
  which is legitimately absent in the non-studio variant)
- pi_version equal to `pi --version`, mirroring the mempalace ground-truth check
- release_tag non-empty; source_revision 40-hex and build_date ISO-8601 *when
  populated*, since both default empty on a plain local `docker build` and
  demanding them would fail honest local smoke runs
- --json compared byte-for-byte with the file, which is assertable because that
  mode is a verbatim cat; the old form grepped its output for "release_tag"

Key presence and value shape are deliberately SEPARATE assertions: a single
"all values are valid SHAs" loop passes vacuously on components:{}, because
jq's all() over an empty list is true. Combining them would reproduce the same
shape of hole as the three false greens already recorded in CHANGELOG.md.

Dropped `manifest has no unresolved ('unknown') components`: the 40-hex check
strictly subsumes it ("unknown" is not 40-hex, and only rev() emits it, feeding
components{} exclusively). Removed rather than kept, because a check that can
no longer fail independently is one more green tick that means nothing.

Mutation-tested twice rather than reasoned about: nine fabricated manifests
through the raw jq filters, then twelve through the shipped assertions using
the real `run` helper's `sh -c` quoting path — the quoting is load-bearing,
since a jq filter dying on a quoting error exits non-zero and looks exactly
like a caught defect. Measured on the same twelve defects: old caught 3, missed
9; new catches 12. Three legitimate variations stay green (empty
source_revision, empty build_date, null pi-studio).

Also corrects a factually wrong "Still open" bullet in the released v1.8.6
entry, which claimed pi-devbox-version's human output does not show
mempalace_version and that only --json surfaces it. Both halves are false: it
prints a `palace:` line with live-vs-baked drift annotation, verified against
fabricated manifests (match, skew, and pre-v1.8.6 absent-field cases). Left as
a struck-through correction rather than deleted, since v1.8.6 is published.
2026-08-25 17:15:39 +02:00
joakimp 93f986e90e v1.8.6: adopt pi 0.84.3 + mempalace 3.8.0, close the v1.8.5 doc/observability gaps
Lint / hadolint (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 15s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / actionlint (push) Successful in 1m9s
Publish Docker Image / build-base (push) Successful in 41m23s
Publish Docker Image / smoke (push) Successful in 4m50s
Publish Docker Image / smoke-studio (push) Successful in 5m5s
Publish Docker Image / build-variant (push) Successful in 15m46s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 15s
Publish Docker Image / build-variant-studio (push) Successful in 19m59s
Three coupled pieces of work, all of which ride on the base rebuild that the
mempalace bump forces anyway.

DRIFT ADOPTED
- pi 0.84.2 -> 0.84.3. Its release notes carry a "Breaking Changes" line
  (GoogleThinkingLevel -> GoogleApiThinkingLevel). Audited before adopting:
  zero references across all four vendored companions (pi-fork,
  pi-observational-memory, pi-atelier, pi-studio), so it is inert for us. The
  reason to adopt is two skill-discovery fixes that land directly on v1.8.5's
  vendored-skill work: nested Markdown skills inside grouping directories were
  not discovered, and root README.md/AGENTS.md in skill dirs were reported as
  broken skills.
- mempalace core 3.7.1 -> 3.8.0. Additive/reliability only. Its sync fix
  (#2320/#2322) stops sync --apply deleting drawers whose source_file was
  unreachable *at that moment* -- which does NOT relax the standing landmine
  against sync on the shared palace, because that landmine is about paths
  permanently absent from whichever host runs the sync. Different failure
  shape; the caution stands.

DOCS -- three defects, one of them public
- DOCKER_HUB.md advertised "neovim (LazyVim defaults)". Nothing in the image
  installs LazyVim; the only nvim config is a 19-line sysinit.vim. CI PATCHes
  this file into the Docker Hub description on every release, so this was a
  false claim published to the world. Removed.
- agent-browser + Playwright + Chromium is the single largest addition in the
  image (~625 MB) and had zero mentions in README, DOCKER_HUB or THIRD_PARTY --
  it was documented only to agents, in the AGENTS.md managed block. Now
  documented to humans, including the Chromium licence dimension.
- typst and socat appeared in README prose but not in the "What's inside"
  inventory. Added.

OBSERVABILITY -- the three gaps v1.8.5 listed as still open
- build-manifest.json now records mempalace core, read from the live binary
  (ground truth, not the build ARG). Placed as a sibling of pi_version rather
  than inside components{}, because pi-devbox-version renders that map through
  [0:12] and would truncate a version string.
- smoke asserts the pi-observational-memory clone actually CONTAINS the ce9fc98
  auth fix, pinned to src/runtime.ts. Deliberately not a repo-wide grep: two of
  the three markers also live under tests/, so the repo-wide form stays green
  with the fix site reverted. That is the third false-green of this exact family
  in this repo (canary phrase in both snapshots; reconciler fixture using a
  non-owned name; now this) -- pin containment checks to the fix site.
- smoke asserts the feeder's pi@<device> agent default behaviourally. The
  earlier audit concluded this needed a --print-config added upstream; it does
  not. AGENT is assigned before arg parsing, so `bash -x mempalace-pi-session
  --help` observes the real resolution with no toolkit change. Two-sided:
  device set => pi@<device>, unset => must not be pi@*.
- pi-devbox-version now prints a palace: line with the same live-vs-baked drift
  detection pi already had. This matters more than it looks: mempalace is the
  one component that is both client (here) and server (synlig), so skew between
  them is a real failure mode. Degrades quietly on pre-v1.8.6 images.

Deferred deliberately: a native arm64 act_runner on tor-ms22 (the current
runner is on synlig, x86_64, so every arm64 layer ships QEMU-emulated).
Analysis and caveats filed to the palace rather than actioned here.
2026-08-25 15:29:22 +02:00
joakimp 26f223568d .env.example: document MEMPALACE_PALACE_PATH and why nothing exports it
Lint / hadolint (push) Successful in 10s
Lint / actionlint (push) Successful in 50s
The only MemPalace variable the template never mentioned, and the one that
moves the feeders' stage as a side effect: the palace root resolves as
$MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
~/.mempalace/palace, and the stage is derived from it (<palace-root>/pi-stage).

The comment states that precedence, records why neither the image nor the
entrypoint exports it (pinning the palace without carrying the stage along
re-creates the split a shared root removed, v1.8.2), warns that a stage whose
persistence differs from the palace makes a scoped `mempalace sync` prune
conversation drawers whose dedup key is the staged path, and notes it is a
path INSIDE the container unlike the host-side WORKSPACE_PATH/SSH_KEY_PATH
above it.

Found while auditing a live host whose .env sets it redundantly to the
default value.
2026-08-23 23:27:42 +02:00
18 changed files with 2212 additions and 47 deletions
+13
View File
@@ -18,6 +18,19 @@ SSH_KEY_PATH=~/.ssh
# the staged files and the palace dedup keys pointing at them cannot be
# separated.
#
# That palace root is resolved with mempalace's own precedence
# ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
# ~/.mempalace/palace), and the feeders derive their stage FROM it
# (<palace-root>/pi-stage). Neither the image nor the entrypoint exports it, by
# design: pinning the palace without carrying the stage along re-creates the
# very split that a shared root removed. Override it only to move the palace off
# the default -- e.g. onto a different mount -- and only to a path with the SAME
# persistence as the palace itself. A stage that outlives its palace (or dies
# first) makes a scoped `mempalace sync` prune conversation drawers, because
# their dedup key is the staged path. Setting it to the default buys nothing.
# Unlike WORKSPACE_PATH/SSH_KEY_PATH above, this is a path INSIDE the container.
# MEMPALACE_PALACE_PATH=/home/developer/.mempalace/palace
#
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
+52
View File
@@ -142,6 +142,7 @@ jobs:
image: catthehacker/ubuntu:act-latest
outputs:
pi_version: ${{ steps.resolve.outputs.pi_version }}
mempalace_version: ${{ steps.resolve.outputs.mempalace_version }}
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
@@ -242,6 +243,54 @@ jobs:
fi
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
# ── mempalace core: same audit as pi, from Dockerfile.base ────
# Until now this pin had NO CI-side audit at all — a literal string
# in Dockerfile.base with zero references in this workflow, while
# PI_VERSION got a concreteness gate, a published-on-registry check
# and a drift warning. It is the same class of risk: the palace's MCP
# tool schema is the agent-facing contract, and a client/server skew
# against the shared central palace is a fleet-wide, not local,
# problem. Read from Dockerfile.base (not duplicated here) so a local
# `docker build` and CI install the same version by construction.
MEMPALACE_VERSION=$(sed -n 's/^ARG MEMPALACE_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.base | head -n1)
if ! printf '%s' "${MEMPALACE_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::ARG MEMPALACE_VERSION in Dockerfile.base is not a concrete version (got '${MEMPALACE_VERSION:-<empty>}'). CI refuses to build from a floating palace version — see the pin policy comment above that ARG."
exit 1
fi
# One fetch, two gates. `curl -sf` exits non-zero and prints nothing
# on 404 (PyPI's answer for an unpublished version), so an empty body
# lands in the "not published" branch with its own message.
MEMPALACE_PYPI=$(curl -sf "https://pypi.org/pypi/mempalace/${MEMPALACE_VERSION}/json" || true)
MEMPALACE_PUBLISHED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.version // empty' 2>/dev/null || true)
if [ "${MEMPALACE_PUBLISHED:-}" != "${MEMPALACE_VERSION}" ]; then
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is not published on PyPI (registry returned '${MEMPALACE_PUBLISHED:-<empty>}'). Fix ARG MEMPALACE_VERSION in Dockerfile.base."
exit 1
fi
# A yanked release still installs when pinned exactly (PEP 592), so
# `uv tool install mempalace==X` would succeed silently and ship a
# version upstream has withdrawn to the whole fleet. The escape hatch
# is the same one-line bump that got us here.
MEMPALACE_YANKED=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked // false' 2>/dev/null || true)
if [ "${MEMPALACE_YANKED:-false}" = "true" ]; then
# Reason hoisted into its own variable rather than inlined as a
# $(...) inside the message: a jq program nested in a substitution
# inside a double-quoted string needs escaping that silently breaks
# the FILTER (jq compile error) while the surrounding `exit 1` still
# fires, so the gate looks correct and reports garbage. Caught by
# the mutation test, not by review.
MEMPALACE_YANK_REASON=$(printf '%s' "$MEMPALACE_PYPI" | jq -r '.info.yanked_reason // "no reason given"' 2>/dev/null || true)
echo "::error::Pinned mempalace version ${MEMPALACE_VERSION} is YANKED on PyPI (${MEMPALACE_YANK_REASON:-no reason given}). An exact pin installs a yanked release without complaint — bump ARG MEMPALACE_VERSION in Dockerfile.base."
exit 1
fi
# Informational only, exactly like pi's npm drift warning: a newer
# palace must never be adopted implicitly. `|| true` so a transient
# PyPI failure cannot fail a release whose pin is already verified.
MEMPALACE_PYPI_LATEST=$(curl -sf "https://pypi.org/pypi/mempalace/json" | jq -r '.info.version // empty' 2>/dev/null || true)
if [ -n "${MEMPALACE_PYPI_LATEST:-}" ] && [ "${MEMPALACE_PYPI_LATEST}" != "${MEMPALACE_VERSION}" ]; then
echo "::warning::mempalace ${MEMPALACE_PYPI_LATEST} is published; this build ships the audited pin ${MEMPALACE_VERSION}. To adopt it: read the upstream CHANGELOG for MCP tool-schema changes (the agent-facing contract) and for sync/delete semantics, check the skew it introduces against the central palace host's server version, then bump ARG MEMPALACE_VERSION in Dockerfile.base and note the audit in CHANGELOG.md."
fi
echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT"
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
@@ -337,6 +386,7 @@ jobs:
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})"
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
@@ -471,6 +521,7 @@ jobs:
- name: Smoke test (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
@@ -533,6 +584,7 @@ jobs:
- name: Smoke test studio (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_MEMPALACE_VERSION: ${{ needs.resolve-versions.outputs.mempalace_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
# ── Phase 4: multi-arch publish ─────────────────────────────────────
+49
View File
@@ -52,6 +52,55 @@ jobs:
apt-get update
apt-get install -y --no-install-recommends shellcheck python3-yaml
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
# Gap being closed: everything else in this job shellchecks workflow
# `run:` steps ONLY, via actionlint. The repo's own shell scripts —
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
# rootfs/usr/local/bin/ — have never been shellchecked. That exact gap
# (a sibling repo with no shell-script lint at all) is how a defect
# shipped invisibly for two months: `echo "$json" | python3 <<'EOF'
# ... json.load(sys.stdin)` cannot work — with no script argument
# python reads its SCRIPT from stdin, so the heredoc IS stdin and the
# json.load call hits EOF. shellcheck flags exactly this at severity
# ERROR (SC2259, "This redirection overrides piped input"); nothing
# ever ran it. Measured before adding this gate: `-S error` is 0
# findings across every shell file in THIS repo today, so it is free
# to add. `-S warning` is NOT free here (19x SC2088 tilde-in-quotes in
# scripts/recreate-sanity-check.sh, plus assorted SC2016 — both
# intentional), so warning-level would train people to ignore the job;
# hence error-only, matching the SHELLCHECK_OPTS philosophy below.
#
# Discovery is *.sh UNION a shebang scan, because rootfs/usr/local/
# bin/{pi-devbox-version,devbox-skill-reconcile,dot-watch,studio-expose}
# are shell scripts with no extension. -print0/mapfile -d '' so a path
# with a space cannot silently split, and the file count is asserted
# non-zero — a green tick over an empty file set is not a check.
run: |
# Union of two signals, because either alone misses a real case:
# a shebang scan misses a sourced fragment with no shebang, and a
# *.sh glob misses the extensionless tools in rootfs/usr/local/bin/.
# Silent skipping is precisely the failure mode this gate exists to
# prevent, so err toward over-collecting.
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
sh_files=()
for f in "${all_files[@]}"; do
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
sh_files+=("$f")
fi
done
echo "Checking ${#sh_files[@]} shell file(s)"
if [ "${#sh_files[@]}" -eq 0 ]; then
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
exit 1
fi
shellcheck -S error -f gcc "${sh_files[@]}"
rc=0
for f in "${sh_files[@]}"; do
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
done
exit "$rc"
- name: Gitea shell guard (catches the actionlint blind spot)
# actionlint models GitHub Actions, where the default run shell is
# bash, so it does NOT flag bash syntax in a step that merely OMITS
+34 -6
View File
@@ -64,8 +64,36 @@ re-brand of opencode-devbox's `pi-only` variant.
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
Check release notes at https://github.com/earendil-works/pi/releases for
the upstream changelog to include in `CHANGELOG.md`.
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
3. Verify `docker compose up` works locally with the current `latest` image
2. **Refresh the vendored mempalace skill snapshot if the skillset moved:**
`scripts/vendor-mempalace-skill.sh --check` (reads a real skillset clone,
writes nothing). Three exit codes, not two — a stale-but-truthful record is
**not** a release blocker, so don't treat any non-zero exit as "must
refresh" without reading which one it was:
- **0** — the record is truthful. This includes stale-but-truthful
(upstream has moved past the recorded ref, or the local clone has
uncommitted changes) — a `NOTICE` is printed, but nothing is lying.
**Skipping the refresh in this case is the legitimate, sanctioned
outcome** — every enrolled host reads its own live skillset clone, so
the baked copy is only a no-mount fallback. What is not legitimate is
skipping it *silently*: the drift is visible here, in
`pi-devbox-version`, and in the manifest, so decide rather than forget.
- **1** — a confirmed problem: the vendored bytes provably do NOT match
the file at the recorded ref (a lying record), or the recorded ref
doesn't even resolve to that path in this clone. Refresh.
- **2** — cannot determine (the recorded ref itself isn't resolvable in
this clone — commonly a shallow checkout missing history). Fetch full
history and re-check before deciding; don't refresh blind.
Refresh with `scripts/vendor-mempalace-skill.sh`, which rewrites the file
**and** the ARG together so they cannot drift apart, and refuses (exit 1)
rather than silently rewinding provenance if the skillset clone's HEAD is
behind the already-recorded ref (detached HEAD, older checkout) — pass
`--force` only if that rewind is genuinely intended.
Two consequences to accept deliberately on an actual refresh: the snapshot
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.
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
persisted volumes survived and the pi runtime wiring re-deployed (not just
@@ -73,8 +101,8 @@ re-brand of opencode-devbox's `pi-only` variant.
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
5. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
6. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes. A tag push fires **only**
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
@@ -85,9 +113,9 @@ re-brand of opencode-devbox's `pi-only` variant.
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
below — because that guard costs nothing and a future workflow added on `v*`
would silently reintroduce the ambiguity.
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
7. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at
8. **Revoke any short-lived Gitea PAT** used during the release at
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
its lifecycle is managed host-side, nothing to revoke.
+784
View File
@@ -11,6 +11,790 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## Unreleased
The vendored `mempalace` skill snapshot stops being anonymous, and the
container starts saying which copy of each skill it is actually reading.
**Peer review (pi@emb-7kj4vr4g, logstream correlation
`skills-provenance-review`, full text in
`drawer_pi-devbox_reviews_e43e766641c9ec85217bc6ce`) found three blockers before
this was tagged. All three were the same species: a record asserting something
it had not verified. Every finding below was reproduced by execution here before
being fixed.**
- **The verification gate could print `OK` and exit 0 without verifying
anything.** `git show <ref>:<path> | sha256sum` hashes *empty stdin* when the
ref does not resolve, yielding a real-looking `sha256("")` rather than an
empty string — so the `UNKNOWN` branch in `--check` was dead code. Reproduced:
a bogus ref reported `MISMATCH` (accusing the snapshot of lying when the true
cause was an incomplete clone — and the operator's natural remedy for
MISMATCH is to re-run the refresh, which *rewrites provenance to silence the
complaint*); with a 0-byte snapshot against a 0-byte upstream file it printed
`OK: … exactly skillset@aaaaaaa` and exited 0 for a ref that does not exist.
The script already had the right idiom (`sha_empty`) and had applied it to
`blob_sha` but not to `at_ref`. Now existence is *proven* with `git cat-file
-e` before anything is hashed, at two levels (does the ref resolve; does the
path exist at it) because those are different failures. This was the same
defect class as the canary it replaces: a check that can succeed without
checking. A second, unflagged instance of the identical pipeline shape was
found in `blob_sha` and fixed too.
- **`--check`'s exit codes conflated "stale" with "lying",** so the release step
failed in the case `AGENTS.md` step 2 explicitly calls legitimate. Now: `0`
truthful (including stale-but-truthful, with a `NOTICE`), `1` a lying record
only, `2` cannot determine (ref absent from this clone). `AGENTS.md` step 2
rewritten to state all three, since its promise that "the message
distinguishes the two" was exactly what the branch was breaking.
- **`VENDORED.md` contradicted itself, in the release whose stated invariant is
non-contradiction.** Its hand-maintained "Snapshot provenance at last refresh"
line named skillset `670f7f1` — seven commits behind the ARG, and *the very
commit that told agents to hand-stamp `added_by`*, i.e. the withdrawn
instruction this line of work exists to stop shipping — while its `cp` recipe
still contradicted the "not `cp`" rule 20 lines above. The hand-maintained
line is gone (nothing forced it to move when the ARGs did); `670f7f1` is kept
only as a labelled cautionary example. The `pi-extensions` half was verified
redundant (CI resolves `PI_EXTENSIONS_REF` via `require_sha`) before removal,
rather than silently dropped.
**Should-fixes from the same review, all reproduced:** `--check` given the
documented positional spelling (`<root> --check`) silently ran a *refresh*,
because only `$1` was parsed — both tools now parse all arguments and reject
unknown ones; a refresh at a detached or older `HEAD` silently rewound ref and
bytes, now refused unless the recorded ref is an ancestor (`--force` to
override); `upstream_dirty` was computed and never used in check mode, now
reported; `pi-devbox-version --no-skills --json` printed human text and broke
`jq`; `--help` was a hardcoded `sed -n '2,22p'` range that this branch had
already made stale; the skill fingerprint hashed `SKILL.md` alone, so a live
skill dir differing only in a sibling file still reported "identical" — and
`pi-extensions` already ships two files — so it is now a per-skill **tree** hash
and the manifest field is renamed `skillset_snapshot_tree_sha256` to say what it
measures; and the `--no-skills` smoke assertion was negative-only, passing on a
crashed binary, now anchored positively. `mktemp`+`mv` left written files at
`0600` (a `mv` takes the temp file's mode) — CI was unaffected because the git
index records `100644`, but a local build from a dirty tree would have baked it;
now `chmod 0644` before the `mv`.
**The skill fix ships outside this release, because it had to.** The review also
found that skillset `82a8d3c` — the coordination protocol itself — told every
machine on this fleet to *skip* the mailbox it introduced: it gated the mailbox
on `mempalace_mesh_peers`, and a hub-and-spoke palace reports `peers: []`
precisely because every machine is a thin client of one replica. It also
asserted that a directed `open` event "stays in their mailbox until" acked —
false, because `event_ack` appends and `status` is written once, so an answered
ask matches forever. The headline measurement behind that claim ("exactly 1 —
the one that needed a reply") was of an event already acked half an hour
earlier. Fixed in skillset `5fd0d5c`, which derives owed-ness by joining on
`ack_of`/`correlation_id` with a **`seq` ordering test** — without which one
terminal reply suppresses every later ask on the same thread forever. Because
the skillset is mounted live on every enrolled host, that correction was already
deployed fleet-wide before this image was built; the vendored snapshot is
resynced to it (`c04cd15` → `5fd0d5c`) so the no-clone fallback does not ship
the withdrawn rule. Canary re-verified bidirectionally against the new bytes.
**Also carried, previously undocumented:** `dbb7879` resynced the vendored
`mempalace` snapshot to skillset `c04cd15` ("the withdrawal only holds where the
bridge is live"), landed after the v1.8.7 tag and so absent from that image.
⚠️ **A base rebuild is forced** (~67 min): both that resync and the
`pi-devbox-version` / `entrypoint-user.sh` changes below touch inputs to
`base_tag` (`rootfs/` and `entrypoint*.sh`). The provenance recording itself
adds nothing to that cost — it lives entirely in `Dockerfile.variant`.
Both come from one finding, made while verifying v1.8.7 from inside a freshly
recreated container: **the baked `mempalace` snapshot is read by no host on this
fleet.** `~/.agents/skills/mempalace` is a symlink to `/workspace/skillset/skills/mempalace`
— `entrypoint-user.sh` links the baked skill only `if [ ! -e ]`, and
`devbox-skill-reconcile` then repoints the skillset-owned ones at the live clone
(that is the v1.8.5 fix working as designed). All four compose stacks in
`docker-compose-repo` mount a workspace containing the skillset, so the vendored
copy is a CI/no-mount **fallback** and nothing else. Which means the
`mempalace skill snapshot is current` canary — the assertion that blocked
v1.8.7's first tag — polices a file that no agent on this fleet ever opens,
while the drift that *could* actually mislead an agent (a `git pull` nobody ran
in `/workspace/skillset`) was invisible from inside the container and is
invisible to CI by construction.
**The rejected fix is worth recording, because it was the obvious one.** The
old comment in `scripts/smoke-test.sh` said the real answer was "a CI job
diffing this file against the skillset repo". It isn't:
| Objection | Detail |
|---|---|
| needs a credential CI does not have | the skillset is **private** (`ssh://git@gitea.jordbo.se:2222/joakimp/skillset.git`); every build-time clone in this image uses anonymous HTTPS, and `resolve-versions`' `gitea_sha()` is explicitly documented as public-repo-only — its 401/403 path exists to survive a *stale token against a public repo*, so a private 403 would return empty and `require_sha` would hard-abort the release |
| makes another repo's branch able to fail this build | the same pi-devbox commit would go green today and red tomorrow, and a release could be blocked by an edit in an unrelated repo — precisely the shape of the run 589 failure, but automated and permanent |
| pure churn, and it is measurable | pi@emb-7kj4vr4g pushed **four** skillset commits in one evening (`d9dbbbd`, `b740d51`, `3324bd0`, `c04cd15`); a byte-parity gate would have demanded a pi-devbox resync commit **and a ~67-minute base rebuild for each one**, to keep current a copy almost nobody resolves |
| guards the wrong artefact | see above: on this fleet, nobody reads it |
**The invariant is not currency, it is non-contradiction** — the framing comes
from pi@emb-7kj4vr4g's review (logstream `project/pi-devbox`, correlation
`skillset-vendor-drift`, which also **retracted** its own earlier build-time
byte-compare recommendation). A stale-but-self-consistent fallback is harmless;
a stale fallback carrying a **withdrawn instruction** is a live footgun, and
this project has already paid for that one — through v1.8.4 the baked snapshot
*shadowed* the live clone, which is how superseded attribution guidance kept
reaching agents. That is precisely what the bidirectional canary asserts, and
why it stays.
So provenance is **recorded** rather than policed, and the check moves to where
the skillset actually is — a maintainer's clone, or any running container.
### Added
- **`build-manifest.json` now records the vendored snapshot's provenance:
`skillset_snapshot_ref` (which skillset commit the bytes are claimed to come
from) and `skillset_snapshot_sha256` (the bytes that actually shipped).** The
ref is a plain `ARG` **default in `Dockerfile.variant`**, deliberately not a
CI-resolved output, which buys three things at once: it needs no credential
for a private repo; it keeps a local `docker build` and CI identical by
construction (the same reasoning that put `MEMPALACE_VERSION` in
`Dockerfile.base` rather than duplicating it in the workflow); and it requires
**no change at any of the four `Dockerfile.variant` call sites** (`smoke`,
`smoke-studio`, `build-variant`, `build-variant-studio`), whose `--build-arg`
lists are hand-duplicated and therefore easy to under-apply to only two.
Also emitted as OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref`, so it
is readable off the registry without pulling the image.
Two design points, each arrived at from the file's own rules:
- **The ref is a claim; the hash is measured.** `Dockerfile.variant` writes
the manifest from ground truth (`rev()` on each `/opt` clone, the live
`pi --version`), so the snapshot hash is computed with `sha256sum` in that
same layer rather than passed in. A build where the two disagree is exactly
what the new smoke assertions catch.
- **They are siblings, not members of `components{}`.** That map means "HEAD
of a clone present in this image" and the skillset is not cloned here —
calling it a component would be a lie a future reader would act on. It is
also load-bearing mechanically: `pi-devbox-version` renders every
`components{}` value with `.value[0:12]`, which would truncate a 64-hex
digest into something that looks like a short commit. Same reasoning as
`mempalace_version`'s existing comment.
⚠️ **Costs no base rebuild.** `base_tag` hashes `Dockerfile.base` + `rootfs/`
+ `entrypoint*.sh` + the mempalace-toolkit SHA; `Dockerfile.variant` is in
none of it. `scripts/check-base-hash.sh` scans `Dockerfile.base` **only**
(`DF="Dockerfile.base"`, single hardcoded path), so a new `*_REF` ARG in the
variant is invisible to that guard — correctly, since it changes nothing
about the base's contents.
- **`pi-devbox-version` gained a `skills:` section** reporting, per vendored
skill, whether the live copy is `baked` or a `live <repo> @ <sha>` clone —
and for `mempalace`, whether that live copy matches the baked fingerprint:
`(identical to baked snapshot)`, `(baked snapshot <ref> + uncommitted edits)`
when the clone is at the recorded commit but the bytes differ, or
`(baked snapshot <ref> — live copy differs)`. Same live-vs-baked shape as the
existing `pi:`/`palace:` drift annotations. **This is the check CI cannot do
and a container can, for free**, since every host that matters already has the
skillset mounted. The list iterates the baked tree rather than a hardcoded
name list, so vendoring a fourth skill needs no edit here.
`entrypoint-user.sh` calls it with the new **`--no-skills`** flag: the banner
is printed FIRST, before the baked links exist and long before the skillset
deploy and reconcile run last, so anything it said about skill sources would
describe a state that is about to change. Wrong-but-plausible is worse than
absent. (This is the one part of the change that touches `rootfs/` and
`entrypoint-user.sh`, so it does cost a base rebuild — already sunk, since
`dbb7879` refreshed the vendored snapshot.)
- **`scripts/vendor-mempalace-skill.sh`** — refreshes the snapshot and rewrites
the recorded ref *together*, because a `cp` without a matching ARG bump
produces a manifest that confidently lies, which is worse than the anonymous
snapshot it replaced. Refuses to record a ref when the upstream file has
uncommitted modifications (no commit describes those bytes, so recording one
would be a fabrication) — checked on that one file, not the whole tree, so
unrelated work in progress in the skillset does not block a vendoring.
`--check` answers "is the committed snapshot really `skillset@<recorded
ref>`?" and separately reports staleness against the clone's HEAD.
Counterfactual-tested rather than reasoned about, against throwaway clones:
a tampered snapshot reports `MISMATCH` **and** `STALE` (rc 1); a ref rolled
back to the previous skillset commit reports `MISMATCH` with content
unchanged (rc 1) and a subsequent refresh fixes only the ref, leaving the
bytes alone; unstaged and staged-but-uncommitted upstream edits are refused
with distinct messages and the snapshot left byte-identical, i.e. the refusal
is atomic.
**Hardened after review** by pi@emb-7kj4vr4g, whose warning was that a resync
script must "write the ref it ACTUALLY copied from, or the provenance field
inherits the same class of bug the canary just had". The first draft copied
the working tree and guarded it with `git diff` — which says nothing about an
**untracked** file, and can be clean on a detached or behind checkout while
`HEAD` names something else. The snapshot is now *constructed* from
`git show HEAD:<path>`, so the recorded pair cannot be a lie by construction,
and the untracked case is refused explicitly (tested: it was the one input the
first draft would have silently recorded a false ref for). Both new scripts are
`bash -n` clean and `shellcheck -S error` clean — the gate v1.8.7 added.
### Fixed
- **Three stale in-repo markers, all the same failure class.** Two "Unreleased"
pointers — `scripts/smoke-test.sh` pointed the reader at "the Unreleased
changelog note", and the v1.8.6 correction at "the Unreleased entry above";
that section became the `## v1.8.7` heading at release time and neither
back-reference was updated. The third: `scripts/smoke-test.sh`'s own coverage
list still advertised "typst PDF engine for pandoc **(Unreleased)**", five
releases after typst shipped in v1.4.0. Same class as the canary they sit
next to: true when written, silently false at release, with nothing checking
them. The smoke comment now describes the mechanism that actually shipped
(and why the CI-diff idea it advertised was rejected); the changelog one names
v1.8.7; the typst line names v1.4.0.
### Not fixed, deliberately
- **CI still cannot tell you the vendored snapshot is behind `skillset` main.**
That needs a read-only deploy key for a private repo threaded into
`resolve-versions`, to warn about a file no host on this fleet reads. Revisit
when a no-skillset container becomes a real deployment (shipping the image
outside the fleet, or a CI-only agent) — at which point the honest gate is a
**warning**, matching the existing `PI_VERSION`/`MEMPALACE_VERSION` policy
(concreteness → error, newer-release-exists → warning), never a build
failure.
- **The phrase canary stays.** It is orthogonal and free: it pins *content*
where the new fields pin *provenance*, so it still catches a re-vendored
snapshot whose ref was bumped correctly but whose bytes came from the wrong
place — and, per the review above, asserting the **absence of withdrawn
guidance** is the half of it that earns its keep. Its comment now states the
limit instead of promising a fix.
- **v1.8.7's published image has no recorded ref**, and that is expected: the
field arrives here. Worth knowing when reading one, since the tag move
`ebd0de0` → `f645e66` means the published v1.8.7 carries a pre-`dbb7879`
snapshot, i.e. its baked mempalace skill lacks c04cd15's "confirm the bridge
actually stamps" caveat. Harmless — on v1.8.7 the bridge *is* live, so that
caveat self-retires, and every enrolled host reads the live clone anyway.
`pi-devbox-version` degrades quietly on such an image: no fingerprint, no
annotation, verified against the real v1.8.7 manifest.
### Documented
- **The fleet's cross-machine coordination, which was working and unwritten.**
The RFC 003 logstream has carried real work between hosts since 2026-08-18 —
patch handoff, design review, a v1→v2 supersede — and no document in this repo
or the toolkit said so. Written up in three places, split by what each is
authoritative for:
- `README.md` § *Cross-machine agent coordination* — what the **container**
needs: `MEMPALACE_REMOTE_URL` selects the shared palace, and
`MEMPALACE_PI_DEVICE` is what makes this machine *reachable* on the log,
because when every host is a thin client of one palace the stamped agent name
is the only thing distinguishing them. Set both or neither: a container
without the device var can read the log but is addressable by nobody.
- the skillset's `mempalace` skill (`82a8d3c`, live on every host that mounts
the skillset, no rebuild needed) — the **norms**: a mailbox query at wake-up,
and the sender-declared ack contract, where a *directed* event with
`status="open"` is owed a reply and a `*` broadcast owes nothing. Measured
while designing it: an unfiltered mailbox returned 5 events, 4 of them
finished broadcasts from eight days earlier, where `status="open"` returned
exactly the 1 that needed an answer — an unfiltered mailbox trains you to
ignore it, so the filter is the feature.
- mempalace-toolkit `extensions/pi/README.md` (`e70bef2`) — the **mechanism**,
including that the bridge is *write-only* today (it stamps events going out
and never reads the log, so nothing in this image polls on the agent's
behalf), and that live SSE push is a palace-deployment question: the server
implements `GET /logstream/stream`, but a reverse proxy exposing only `/mcp`
makes it unreachable — verified by 404s against the real endpoint.
⚠️ **This makes the vendored snapshot stale on purpose.** The skill edit is in
the skillset (`82a8d3c`), so `SKILLSET_SNAPSHOT_REF` still records `c04cd15`
and `scripts/vendor-mempalace-skill.sh --check` now exits 1 with
*"has moved to 82a8d3c; the snapshot describes the older c04cd15"*. Refreshing
it is a deliberate release-day decision, not an oversight — hence the new
step 2 in `AGENTS.md` § *Release-day checklist*, which states both that the
refresh costs a base rebuild and that skipping it is legitimate because every
enrolled host reads its live clone. What is not legitimate is skipping it
*silently*, which is precisely what the new manifest fields and
`pi-devbox-version` output make impossible.
---
## v1.8.7 — 2026-08-25
Patch release, and the fastest turnaround in the series (~9 h after v1.8.6) for
one reason: **v1.8.6 shipped a container that cannot tell you which machine it
is running on**, and that anonymity produced a real misattribution the same
evening — a session on tor-ms22 read *another host's* diary out of the shared
palace, reported its verification as its own, and built a causal inference on
top of the coincidence. The client-side half of the fix lives in
`mempalace-toolkit`, which the image clones **at build time**, so it can only
reach the fleet through a tag. The CI-hardening work that had accumulated since
v1.8.6 rides along.
Gitea-hosted refs re-resolved immediately before tagging (2026-08-25T22:35Z):
pi-toolkit `0e1369e6` and pi-extensions `20228878` **unchanged** since v1.8.6;
mempalace-toolkit `0fe64c48` → `553d8657` (the provenance change below). CI
re-resolves pi-fork / pi-observational-memory / pi-atelier / pi-studio at build
time as usual. **Base rebuild is forced twice over** — `Dockerfile.base` changed
(the `MEMPALACE_VERSION` audit) *and* `base_tag` deliberately folds in the
mempalace-toolkit SHA ("otherwise a toolkit-only fix never lands") — so expect
~67 min, and note that either cause alone would have sufficed.
⚠️ **The first tag of this version did not publish.** Run 589 built the base
fine, then **both** smoke jobs failed 81-passed/1-failed on a single assertion —
`mempalace skill snapshot is current`, a canary pinning a phrase from the
vendored skill. The phrase it pinned was the heading of the very instruction this
release *withdraws*, so refreshing the snapshot without re-pinning the canary
made it fire correctly on a healthy image. Every publish job was skipped, so
nothing reached the registry and the version was never consumed; the tag was
moved to include the fix below. Fixing the canary is what this release is
*for*, in miniature: the gate was right and the expectation was stale.
### Added
- **Palace writes now carry the device that made them, and diary entries say so
in text.** The container is host-anonymous by construction — `hostname` is a
Docker hash, `$DEVBOX_HOST_ALIAS` is generic, the virtiofs source tag is
generic, and two hosts in this fleet are both `aarch64` — so nothing inside it
distinguished tor-ms22 from EMB-7KJ4VR4G. In a *local* palace that costs
nothing (one origin, so origin is a property of the whole store). In the
**shared** palace it means every drawer and all 621 diary entries read as
though written here, which is exactly how a v1.8.6 verification performed on
EMB was reported as tor-ms22's own.
Two halves, arriving by different routes:
| Half | Where it lives | How it gets into this image |
|---|---|---|
| the writer — stamps `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>\|` | `mempalace-toolkit` `extensions/pi/mempalace.ts` (553d8657) | cloned in `Dockerfile.base` at `MEMPALACE_TOOLKIT_REF`, whose SHA is folded into `base_tag` |
| the consumer skill — stops telling the agent to do it by hand, adds the read-side warning | vendored `rootfs/…/skills/mempalace/SKILL.md`, refreshed from skillset `73c7c8e6` | `rootfs/*` is hashed into `base_tag` too |
Three design points worth recording, because each was arrived at the hard way:
- **The stamp goes in the *client*, not the agent.** RFC 001 §7.3.2 ranks
"agent stamps it via a skill instruction" as the ❌ *worst possible* place,
and the skill had carried exactly that instruction since 2026-08-23. It
failed as predicted: the agent that wrote the instruction then filed its own
provenance drawer without it. 199 rows reached the palace unresolvable.
One `execute()` wrapper cannot forget.
- **The diary marker is in the entry TEXT on purpose.** `diary_write` has no
metadata parameter, but the deeper reason is that mempalace's `search`
projects a fixed key set and `diary_read` returns content — **metadata is
invisible to the agent who will later read the entry**, so no metadata-only
fix, not even a server-authoritative one, would have prevented the
misattribution. The marker is an AAAK field, so it is machine-parseable
*and* the first thing a reader sees. The wake-up preamble now also names the
device and warns that `diary_read` interleaves every machine's diary.
- **A solitary devbox stamps nothing.** Gated on `MEMPALACE_PI_DEVICE` **and**
`MEMPALACE_REMOTE_URL` — both set only when the palace is actually shared
(RFC 001 R1). Unset either and behaviour is byte-identical to v1.8.6.
Never injected into `diary_write` or `kg_add`: mempalace 3.8.0 hard-rejects
undeclared arguments with JSON-RPC `-32602` rather than dropping them (the
behaviour changed since the RFC's 2026-08-09 note, now corrected), so a
blanket injection would **break** those two calls instead of being ignored.
The allowlist is per tool for that reason.
- **CI now shellchecks the repo's own shell scripts, not just workflow `run:` steps.** `.gitea/workflows/lint.yml`'s `actionlint` job already shellchecks every workflow step, but nothing had ever pointed shellcheck at `entrypoint.sh`, `scripts/*.sh`, or the extensionless tools under `rootfs/usr/local/bin/` (`pi-devbox-version`, `devbox-skill-reconcile`, `dot-watch`, `studio-expose`). The gap is not hypothetical: a sibling repo (skillset's `ci-release-watcher` templates) shipped `echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)` for two months without anyone noticing it silently returned nothing — with no script argument python reads its *script* from stdin, so the heredoc is stdin and the JSON load hits EOF. shellcheck flags exactly this at severity **error** (`SC2259`, "This redirection overrides piped input"); it had been available to catch it the whole time, just never run.
New step in the `actionlint` job, `Shellcheck + syntax-check repository scripts`, runs `shellcheck -S error` plus `bash -n` over every shell file in the repo, **discovered by `*.sh` union a shebang scan** (neither alone suffices) so the extensionless `rootfs/usr/local/bin/*` tools are covered too. Measured before adding it: `-S error` is 0 findings across all 11 shell files today, so the gate is green on arrival with no cleanup. `-S warning` is *not* free (19× `SC2088` tilde-in-quotes in `scripts/recreate-sanity-check.sh`, plus assorted `SC2016`, both intentional here) — a warning-level gate would train people to ignore it, so it stays error-only, same reasoning as the existing `SHELLCHECK_OPTS` exclusions on the actionlint step. File-count guard included: the step fails loudly if the shebang scan matches zero files, since a green check over an empty set is not a check.
- **`MEMPALACE_VERSION` now gets the same CI audit as `PI_VERSION`** — closing
the item v1.8.6 (and v1.8.5 before it) listed as "Still open". The pin was a
literal string in `Dockerfile.base` with **zero** references anywhere in
`.gitea/workflows/docker-publish.yml`, while `PI_VERSION` had ~20: a
concreteness gate, a published-on-registry check, and a never-silently-adopt
drift warning. `resolve-versions` now applies all of them to the palace pin,
read from `Dockerfile.base` (not duplicated in the workflow, so a local
`docker build` and CI install the same version by construction):
| Gate | Behaviour |
|---|---|
| not a concrete `X.Y.Z` | **error** — no floating palace version, same policy as pi |
| not published on PyPI | **error** at resolve time, instead of a `uv tool install` failure mid-build |
| **yanked** on PyPI | **error** — an exact pin installs a yanked release silently under PEP 592, so `mempalace==X` would have shipped a withdrawn client to the whole fleet |
| newer release exists | **warning** naming what to audit before adopting (MCP tool-schema = the agent-facing contract; client/server skew against the central palace) |
Plus one smoke assertion, `installed mempalace matches CI's audited pin`,
gated on a new `EXPECTED_MEMPALACE_VERSION` env threaded into both the `smoke`
and `smoke-studio` jobs. It is **not** redundant with the existing `manifest
mempalace_version matches the installed core`: that one compares two
properties of a single image and therefore cannot notice that *both* are the
wrong version. The failure mode this one covers is a variant built `FROM` a
cached base whose `MEMPALACE_VERSION` pin was older — internally consistent,
silently stale, invisible to every other assertion (the risk
`scripts/check-base-hash.sh` exists to reduce but cannot eliminate).
**Mutation-tested rather than reasoned about**, by extracting the shipped
block out of the YAML and running it with a stubbed `curl`: 9 cases —
`latest` / `3.8` / absent ARG refused; 404 and a registry echoing a different
version refused; a yanked release refused *with its reason*; a newer release
warning without failing; a transient PyPI outage not failing a build whose pin
is already verified; happy path silent and emitting the job output. Then once
more end-to-end against live PyPI with the real `Dockerfile.base`. **This
found a genuine defect in the first draft**: the yank message inlined a jq
program inside a `$(...)` inside a double-quoted string, where the escaping
broke the *filter* (jq compile error) while the surrounding `exit 1` still
fired — a gate that looked correct and reported garbage. The reason is now
hoisted into its own variable. The new smoke assertion was checked the same
way, through the real `run` helper's `sh -c` quoting path: passes on `3.8.0`,
fails on `3.7.1` *and* on `3.8.01` (exact equality, not the substring match
the pi assertion uses), and skips cleanly when the env is unset so a local
`smoke-test.sh` run is unaffected.
⚠️ **Costs a base rebuild on the next tag**: `Dockerfile.base` is hashed
wholesale into `base_tag`, and its now-false "Known gap, carried forward"
comment had to be corrected in place (leaving a comment that says the audit
does not exist would repeat the shipped-false-claim mistake corrected below).
Expect ~67 min, as for v1.8.5/v1.8.6.
- **The SSH sidecar now defaults to connection multiplexing, without overriding
anyone's explicit choice.** `~/.ssh-local/config` already forced `ControlPath`
into the writable sidecar dir, but nothing supplied `ControlMaster` for targets
coming from the user's own bind-mounted `~/.ssh/config`. An entry that never
mentioned it therefore opened a **fresh TCP connection per `ssh` call** — and
an agent doing a dozen calls in a few minutes is exactly the traffic shape that
trips fail2ban or a CGNAT flow-table cap. Observed 2026-08-25 on this fleet:
~12 connections to one host in 15 minutes, after which port 22 stopped
answering while HTTPS to the same estate stayed healthy in 0.44 s (that
asymmetry is the tell for rate-limiting rather than an outage).
**The fix is where the block sits, not what it says.** `ssh_config` is
first-value-wins, so position encodes intent, and the two settings need
opposite treatment:
| Setting | Position | Meaning | Why |
|---|---|---|---|
| `ControlPath` | **before** `Include ~/.ssh/config` | override | the user's value points at read-only `~/.ssh`; it cannot work here, so it must lose |
| `ControlMaster auto` + `ControlPersist 10m` | **after** the `Include` | default | an explicit per-host `ControlMaster no` must keep winning; we only supply an opinion where the user expressed none |
*Force what is broken, default what is merely absent.* The first draft of this
put both in the leading block, which would have silently overridden an explicit
`ControlMaster no` — the counterfactual is in the test below.
Verified with `ssh -G` (the resolved-config oracle) rather than by reading the
man page, against a fixture with one host set to `no`, one silent, one set to
`auto`: the explicit `no` resolves to `controlmaster false` **and** still gets
the writable `ControlPath`, the silent host resolves to `auto`, and the same
fixture under the rejected layout flips the `no` host to `auto` — so the test
discriminates the *position*, not merely the presence of the block. Then
end-to-end: the real script rendered in a sandbox `HOME`, block last, `bash -n`
clean, `shellcheck -S error` clean (the gate added in v1.8.7).
Measured effect on the author's own config (41 host aliases): **22 were silent
about `ControlMaster` and gain `auto` + 10 m persist; 0 are overridden**, since
the fleet contains no explicit `no`. Worth noting *how* the one deliberate
exception is written — `proxmox002-vpn` carries `# No ControlMaster — VPN means
direct route, no CGNAT flow cap`, i.e. the intent is expressed as **absence
plus a comment**, which `ssh` cannot distinguish from "no opinion". That host
does now get multiplexing; its comment says multiplexing is *unnecessary*
there, not harmful. Anything that must stay unmultiplexed needs a literal
`ControlMaster no`.
Why the ordering matters beyond this one config: `~/.ssh/config` is
**per-machine**, differs across the fleet, and future machines' versions do not
exist yet to be audited. A default-not-override design is correct without
needing to inspect any of them.
`ControlPersist` is deliberately short (10 m idle, and each new session resets
the idle timer — long enough to collapse an agent's burst, short enough that an
abandoned socket ages out). A per-host entry that sets its own value keeps it:
hosts already specifying `ControlPersist 4h` still resolve to 4 h. The known
cost of multiplexing is the **stale master** — socket present, daemon gone,
after a suspend or network change — which makes every later `ssh` to that host
hang; recovery is `ssh -F ~/.ssh-local/config -O exit <host>`, now documented
in the `pi-devbox-environment` skill along with `-O check`.
### Fixed
- **The vendored-snapshot canary was one-way, and pinned a phrase the same
release deleted.** `mempalace skill snapshot is current` grepped for
*"Attribute what you file yourself"* — the heading of the hand-stamping
instruction withdrawn above. It therefore did its job (snapshot changed,
expectation did not) and blocked an otherwise-green build. Two changes rather
than a string bump: the assertion is now **bidirectional** (the new phrase must
be present **and** the withdrawn one absent, so a re-vendored *stale* snapshot
fails as loudly as a forgotten bump — verified by running it against v1.8.6's
snapshot, which correctly fails), and the comment now states the structural
limit: a phrase canary can only detect *"older than what I remembered to pin"*,
never *"older than skillset main"*.
- **Four build-provenance smoke assertions verified the presence of a manifest
field name and never looked at its value.** The originals were literally:
```sh
run_expect "manifest records pi_version" "cat …build-manifest.json" '"pi_version"'
```
which passes on `{"pi_version": ""}` and on `{"pi_version": null}`. The tell
was sitting in the passing output all along — `✅ manifest records pi_version
(got "pi_version")` echoes the *key* back as the thing it claims to have
found — and it was spotted while reading run 579's smoke log to confirm the
new v1.8.6 assertions had actually executed.
Replaced with checks against the values, and against ground truth where
ground truth exists:
| Assertion | What it now enforces |
|---|---|
| `manifest declares every required component key` | all seven components present by name, failing with *which* key vanished |
| `manifest component values are resolved 40-hex commits` | each value is a full 40-hex SHA; `null` allowed for `pi-studio` alone (absent in the non-studio variant) |
| `manifest pi_version matches the installed pi` | manifest value equals `pi --version`, same ground-truth shape as the mempalace check |
| `manifest top-level fields are well-formed, not merely present` | `release_tag` non-empty; `source_revision` 40-hex *when populated*; `build_date` ISO-8601 *when populated* |
| `pi-devbox-version --json round-trips the manifest byte-for-byte` | actual string equality with the file, since `--json` is a verbatim `cat` |
**Why five value-checks replace four name-checks (total assertion count
unchanged at 61), and specifically why key presence and value shape are kept
apart:** an "every component value is a valid SHA" loop passes **vacuously** on `components:{}`, because jq's `all()`
over an empty list is true. A single combined check would therefore go green
on a manifest that had lost every component — which is the same shape of hole
as the three false greens already recorded in this file. They are separate on
purpose.
Mutation-tested rather than reasoned about, twice: nine fabricated manifests
through the raw jq filters, then twelve through the shipped assertions using
the real `run` helper's `sh -c` quoting path (the quoting is load-bearing here
— a jq filter that dies on a quoting error exits non-zero and *looks* like a
caught defect). Measured against the old assertions on the same twelve
defects: **old caught 3, missed 9; new catches 12.** The three the old set
caught were key *disappearance* (grepping for a key name does fail when the
key is gone) and the literal string `"unknown"`; every value-level defect —
empty string, `null`, a 12-hex truncation, a wrong-but-plausible version, a
malformed `source_revision` — was invisible. Three legitimate variations are
correctly *not* flagged: empty `source_revision` and empty `build_date` (both
default empty on a plain local `docker build`, so demanding them would fail
honest local smoke runs) and `pi-studio: null`.
- **Dropped the now-redundant `manifest has no unresolved ('unknown')
components` assertion.** The 40-hex value check strictly subsumes it:
`"unknown"` is not 40-hex, and only `rev()` in Dockerfile.variant ever emits
that string, feeding `components{}` exclusively. Removed rather than left in
place, because a redundant check that can never fail independently is one more
green tick that means nothing.
- **Corrected a factually wrong "Still open" bullet in the v1.8.6 entry below**
(see the strikethrough there). It claimed `pi-devbox-version`'s human output
does not display `mempalace_version` and that only `--json` surfaces it. Both
halves are false — v1.8.6 shipped a `palace:` line with the same live-vs-baked
drift annotation `pi` already had. Verified by running the shipped script
against fabricated manifests: matching versions print `palace: 3.7.1`, a skew
prints `palace: 3.7.1 (baked as 3.8.0 — drift detected)`, and a pre-v1.8.6
manifest with no baked field prints the live value un-annotated. The bullet
appears to describe an intermediate state of the working tree and was never
re-checked before tagging. Left visible as a struck-through correction rather
than deleted, since v1.8.6 is already published and someone may have read it.
---
### Still open
- **Make the vendored-snapshot check automatic instead of a remembered string.**
Tonight's failure is the third iteration of the same maintenance burden (v1.8.4:
phrase present in both copies; v1.8.7: phrase deleted by the release that
refreshed the snapshot). A phrase canary structurally cannot answer *"is this
snapshot older than skillset main?"* — only a diff can. Proposed: a lint job
that clones the skillset repo and compares
`rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md` against it,
failing with the diff when they drift. Open question first: the skillset repo is
**private**, so this needs a CI clone credential, which is a policy decision
rather than a code change.
- **Provenance stops at Chroma's metadata.** The hourly reconciler on the palace
host stamps `device`/`agent_kind` in `chroma.sqlite3`, but knowledge-graph
triples and coordination events live in *separate* SQLite files
(`knowledge_graph.sqlite3`, `logstream.sqlite3`) it cannot reach. 156 triples
carry no origin field at all; `logstream`'s `from_agent` is free-form and
already inconsistent (`pi@tor-ms22`, `pi@emb-7kj4vr4g`, and bare `pi` in the
same table). Tracked in RFC 001 §7.3.1.
- **The stamp is self-asserted, and cannot be otherwise yet.** mempalace 3.8.0
authenticates with a *single scalar* bearer token and has zero device concept,
so a verified stamp needs per-device credentials plus an origin field in six
write paths across three databases. Deferred to RFC 001 Phase 4, where it is
now motivated primarily by **revocation** (one shared token covers every
device, so cutting off one laptop means rotating the fleet) rather than by
provenance. Forward-compatible by design: every stamp records *how* it was
determined, so an authoritative pass overwrites with `device_source='token'`
and nothing has to be undone.
- **`tor-ms22` and `tor-ms22-native` are one machine with two device values**
(4,680 and 3,826 rows). That is the hostname-as-identity cost RFC 001 §7.3.4
warned about, now visible in data: a rename splits one device's history
silently. Repairing it means a device-identity mapping, not a relabel.
## v1.8.6 — 2026-08-25
Patch release. Adopts the drift that accumulated in the ~2 days since v1.8.5
(pi `0.84.3`, mempalace core `3.8.0`), then closes the documentation and
observability gaps that v1.8.5 itself listed as "Still open". No component
was adopted without an audit note recording *why* it is safe.
All moving refs re-resolved immediately before tagging (2026-08-25T13:28Z):
pi-toolkit `0e1369e6`, pi-extensions `20228878`, mempalace-toolkit `0fe64c48`
and pi-observational-memory `ce9fc982` all unchanged since v1.8.5;
pi-fork `f1ff8087` → `bf702b4c`; pi-atelier holds at `v0.8.2` (floor for
pi ≥0.84 satisfied); pi-studio's CI-resolved newest tag has moved again to
`v0.9.51`. Base rebuild is forced (Dockerfile.base changed), so the 16
floating base-tooling ARGs re-roll — expect ~67 min as for v1.8.5.
### Changed
- **`mempalace` core `3.7.1` → `3.8.0`.** Released 2026-08-23T21:19Z, hours
after this project's own v1.8.5 tag the same day. Additive/reliability only
— reviewed for MCP tool-schema changes before bumping, as always: none.
`sync --apply` (PR #2320/#2322) no longer deletes a drawer solely because
its `source_file` was unreachable *at that moment* — it asks for
corroboration first. **This does not relax the standing landmine** against
running `mempalace_sync` / `mempalace_delete_by_source` beyond dry-run on
the shared central palace: that failure mode is paths *permanently* absent
from whichever host runs the sync, not transient unavailability, and 3.8.0
doesn't touch it. Server-side perf fix PR #2307 (long-running Chroma servers
no longer invalidate their own HNSW cache on their own writes) likewise does
not make `mempalace_reconnect` unnecessary — that tool covers *external*
writes bypassing the in-process client, a different scenario. Full reasoning
lives in the `Dockerfile.base` comment above `ARG MEMPALACE_VERSION`.
**Deployment note:** synlig's central palace currently serves `3.7.1`
server-side via `docker-compose.mempalace.yml` (which reuses this image) —
this client bump introduces version skew until that stack is separately
redeployed; sequence accordingly.
- **`pi` `0.84.2` → `0.84.3`.** Published 2026-08-24T11:09Z. Release notes
carry one "Breaking Changes" line — `GoogleThinkingLevel` renamed to
`GoogleApiThinkingLevel` — checked against all four vendored packages
(`pi-fork`, `pi-observational-memory`, `pi-atelier`, `pi-studio`): zero
references, inert here. 0.84.3 also fixes two skill-discovery bugs that
land directly on this repo's own vendored-skill work: nested Markdown
skills inside `.agents/skills/` grouping directories not being discovered,
and root Markdown files (`README.md`/`AGENTS.md`) in skill directories being
wrongly reported as broken skills.
### Added
- **Browser automation is now documented to humans, not just to agents.**
`agent-browser` + Playwright + a headless Chromium (~625 MB — the single
largest addition in the image) previously had zero mentions in `README.md`,
`DOCKER_HUB.md` or `THIRD_PARTY.md`; it existed only in the agent-facing
`AGENTS.md` managed block. Added a `README.md` "Browser automation"
subsection, a `DOCKER_HUB.md` feature entry, and `THIRD_PARTY.md` license
rows for `agent-browser` (Apache-2.0), Playwright (Apache-2.0), and Chromium
(BSD-3-Clause for Chromium's own code plus a large set of bundled
third-party components under their own licenses; the binary here is not
compiled by this repo — it's Playwright's own "Chrome for Testing" download
via `playwright install --with-deps chromium`).
- **`THIRD_PARTY.md` gains rows for `pi-atelier` (MIT) and `mempalace` core
(MIT per the GitHub repo; noted that the PyPI package's own metadata omits
a license classifier, so verify against the repo's `LICENSE` rather than
sdist/wheel metadata if clearance is needed from the artifact alone).**
- **`typst` and `socat` added to `README.md`'s tooling inventory.** Both were
already used in prose (typst as pandoc's `--pdf-engine`, socat by
`studio-expose`) but missing from the "What's inside" lists, so the
inventory didn't match what the image actually ships.
- **`mempalace` core version recorded in `/etc/pi-devbox/build-manifest.json`.**
Previously absent — a published image couldn't answer "which palace version
shipped?", and a palace bug couldn't be correlated to an image version.
Derived from the live installed binary (matching the manifest's existing
ground-truth-not-build-args philosophy), degrading to `null` rather than
failing the build if the binary is missing or its output format changes.
Verified landed: new top-level `"mempalace_version"` key, sibling to
`pi_version` rather than a member of `components{}` (that map is rendered
truncated to 12 chars by `pi-devbox-version`, which would mangle a longer
version string).
- **New smoke assertions**, all landed in `scripts/smoke-test.sh`: (1) the
`pi-observational-memory` clone is checked for the actual `ce9fc98`
auth-fix markers pinned to their fix site, `src/runtime.ts`
(`availability_recheck`, `providerCredentialConfigured`,
`hasConfiguredAuth`) — not merely clone existence, and deliberately not a
repo-wide grep: all three identifiers also appear under `tests/`, so a
repo-wide search would stay green even with the fix reverted in
`src/runtime.ts` alone; (2) the manifest's new `mempalace_version` field is
asserted present, non-null, and equal to what `mempalace --version` reports
live, so the manifest can't silently drift from the installed package —
expected to fail against any pre-v1.8.6 image, by design; (3) a
behavioural check for the mempalace-toolkit feeder's `--agent` default
(see below — this one turned out to be possible after all).
### Fixed
- **A false claim was being published to Docker Hub on every release.**
`DOCKER_HUB.md` advertised "neovim (LazyVim defaults)". Nothing in this
repo installs LazyVim — the only nvim configuration is a 19-line
`sysinit.vim` that sets `termguicolors`. `update-description` pushes this
file verbatim (with `{{PI_VERSION}}` substituted) to the Hub description, so
the error was public, not internal. Corrected to describe what's actually
there.
### Component audit for this release
Checked against upstream 2026-08-25 (two days after v1.8.5's own audit):
`mempalace` core moved `3.7.1` → `3.8.0` (see Changed, above — timing is
notable: released *hours after* v1.8.5 tagged, so v1.8.5 could not have caught
it no matter how carefully it was audited). `pi` moved `0.84.2` → `0.84.3`
(see Changed). `pi-toolkit` `0e1369e6`, `pi-extensions` `20228878`,
`pi-observational-memory` `ce9fc982`, and `pi-atelier` `v0.8.2` are all
**unchanged** from v1.8.5 — in particular `pi-observational-memory` still sits
exactly at the auth-fix commit with nothing landed upstream since, and
`pi-atelier` is still the newest tag with the `≥0.7.1` floor for `pi ≥ 0.84`
trivially satisfied. `pi-fork` has one upstream commit not adopted this
release: `f1ff8087` → `bf702b4c`, a text-only rewording of the fork task
preamble (no code-path change) — **left un-pulled** for this release since it
is a moving ref CI resolves fresh at every build anyway; it will be adopted
automatically on the next build regardless of this entry. `pi-studio` (studio
variant) has drifted two tags upstream, `v0.9.48` (pinned at build time via
CI's newest-semver-tag resolution) → `v0.9.51` at tag time, purely additive
(watched PDF previews, opening PDFs directly in Studio, Studio header
hide) — nothing to bump in this repo since studio-tag resolution happens in
CI, not the Dockerfile, but note it **will** auto-adopt `v0.9.51` on the next
studio-variant build. `mempalace-toolkit` unchanged — this release's manifest
and pi-bump work in `Dockerfile.variant` stayed within that file's ownership
and did not require a toolkit-side change.
### Still open
- **`MEMPALACE_VERSION` has no CI-side audit equivalent to `PI_VERSION`'s.**
`PI_VERSION` is verified published-on-npm and warns (never silently adopts)
on drift; `MEMPALACE_VERSION` is a literal Dockerfile string with zero
references in `.gitea/workflows/docker-publish.yml`. Flagged in v1.8.5's
audit as a gap; still a gap.
- ~~**`pi-devbox-version`'s human-readable output does not display
`mempalace_version`.** Its render path is a fixed sequence
(`release_tag`, `build_date`, `source_revision`, `pi`, then `components{}`)
and the new top-level field isn't in it — only `--json` mode (which `cat`s
the manifest directly) surfaces it today. One line in
`rootfs/usr/local/bin/pi-devbox-version` would fix this; deferred since the
field's stated purpose (correlating a palace bug to an image) is already
served by `--json`, but worth doing in a follow-up if this becomes a
routine manual check.~~
**CORRECTION (2026-08-25, post-tag):** this bullet is wrong and was never
true of the tagged tree. `pi-devbox-version` *does* print a `palace:` line
in human mode, with live-vs-baked drift detection, degrading quietly on
pre-v1.8.6 manifests. Nothing is open here. See the v1.8.7 entry above.
**Resolved during this release, not left open:** the feeder `--agent`
default behavioural hook initially looked like it might need a
mempalace-toolkit change (a `--print-config` flag that doesn't exist). It
didn't — `mempalace-pi-session` assigns `AGENT` before argument parsing and
`--help` exits 0 with no side effects, so `bash -x mempalace-pi-session
--help` observes the real resolution (env interpolation and fallback)
without needing a source change. The new smoke assertion exploits exactly
that, checked both ways: with `MEMPALACE_PI_DEVICE` set it must resolve to
`pi@<device>`; with it unset it must NOT be `pi@*` (catches a regression to
the old unconditional `$USER`/`mempalace` default).
`mempalace-toolkit` commit `c64ffa1` changed the feeder's `--agent` default
from `$USER` to `pi@<device>`, but there is still no way for smoke to assert
this default is actually in effect from this repo alone, since
`mempalace-toolkit` is a separate repo this release does not modify. If the
concurrent smoke-test work could not find an honest assertion from the
existing `/opt/mempalace-toolkit` surface (help text, `--self-test`), this
remains open pending a toolkit-side `--print-config`-style hook — a
toolkit-repo change, not a pi-devbox one.
- **16 base-tooling `ARG *_VERSION=latest` pins remain unrecorded.** (Corrected
count — v1.8.5's entry said "~14"; the actual count from `Dockerfile.base`
is 16, plus 5 more that float with no ARG at all: `rustup-init`, AWS CLI v2,
Chromium-via-Playwright, Node's minor version via `setup_22.x`, and
`DEBIAN_VERSION=trixie-slim` itself.) None of these are recorded anywhere
once the build completes — not in the manifest, not in a label — so a
published image cannot answer "which nvim/uv/chromium shipped?" without
exec-ing in and asking the binary.
### Documentation
- **`.env.example` documents `MEMPALACE_PALACE_PATH`.** It was the only MemPalace
variable the template never mentioned, while being the one that silently moves
the feeders' stage: the palace root resolves as `$MEMPALACE_PALACE_PATH` →
`$MEMPAL_PALACE_PATH` → `~/.mempalace/config.json` → `~/.mempalace/palace`, and
the stage is derived from it (`<palace-root>/pi-stage`). The comment states the
precedence, says why neither the image nor the entrypoint exports it (pinning
the palace without carrying the stage re-creates the split a shared root
removed — see v1.8.2), warns that a stage whose persistence differs from the
palace makes a scoped `mempalace sync` prune conversation drawers whose dedup
key is the staged path, and notes it is a *container* path unlike the
host-side `WORKSPACE_PATH`/`SSH_KEY_PATH` above it. Found while auditing a live
host whose `.env` sets the variable redundantly to the default.
---
## v1.8.5 — 2026-08-23
Patch release with two fixes in the container's skill wiring — one behavioural,
+8 -1
View File
@@ -65,12 +65,19 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
### Document and image tooling
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
- **Typst** — markup-based typesetting, used as pandoc's `--pdf-engine`
- **graphviz** (`dot`) — diagram rendering pipelines
- **imagemagick** (`magick`) — image conversion / resizing
### Browser automation
- **agent-browser** — CLI for driving a real browser (open pages, click/fill/`eval`, snapshot the DOM, screenshots) so agents can verify front-end work instead of guessing
- **Playwright** + a headless **Chromium** are pre-installed and pinned together; `AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so `agent-browser open <url>` works out of the box with no setup
- **socat** — TCP bridge used to expose the pi-studio server outside the container's loopback
### Modern CLI tooling
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
- **Search/nav**: ripgrep, fd, fzf, zoxide
- **Display**: bat, eza, htop, tree
- **Data**: jq, yq
+42 -1
View File
@@ -396,7 +396,48 @@ ARG INSTALL_MEMPALACE=true
# (refuse the write) rather than fail open. Neither affects the container's
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
# same lock under 3.6.0.
ARG MEMPALACE_VERSION=3.7.1
#
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
# the same day) is additive/reliability only — reviewed for MCP tool-schema
# changes before bumping, as always: there are NONE. Two PRs matter:
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
# its source_file was unreachable AT THAT MOMENT — it now asks for
# corroboration first. This fixes losing a whole mined project to one
# `sync --apply` while its volume happened to be unmounted.
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
# unreachability, not the standing landmine (documented in the operator's
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
# beyond dry-run on the SHARED central palace. On that palace most
# source_file paths are PERMANENTLY absent from whichever host runs the
# sync — a different machine's paths simply do not exist here, ever, not
# merely "right now". That is a different failure shape than #2320/#2322
# fixes. The landmine still stands; this bump does not relax it.
# - PR #2307: long-running Chroma servers no longer invalidate their own
# HNSW cache on their own writes (server-side perf fix). This does NOT
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
# writes bypassing the in-process client (e.g. direct sqlite backfills,
# CLI commands against a running server), a different scenario #2307
# does not touch.
#
# CI-side audit (added after v1.8.6, closing that release's "Still open" item):
# resolve-versions now treats this pin exactly as it treats PI_VERSION — it
# reads the ARG from THIS file, refuses a non-concrete value, verifies the
# version is published on PyPI, refuses a YANKED release (an exact pin installs
# one silently under PEP 592), and WARNS — never silently adopts — when PyPI has
# a newer release. smoke-test.sh then asserts the installed core equals that
# audited pin, which catches a stale cached base layer that no manifest-internal
# check can see. So a bump here is now gated end to end; what remains manual is
# the JUDGEMENT above (MCP schema review, server/client sequencing), which is
# the part that should stay manual.
#
# Deployment sequencing note for whoever ships this bump: synlig (the shared
# central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via
# docker-compose.mempalace.yml, which reuses this same devbox image. Bumping
# this ARG changes only the CLIENT version baked into pi-devbox images: it
# introduces client/server skew until synlig's compose stack is separately
# rebuilt/redeployed with the new pin. Not something to code around here —
# just sequence the redeploy.
ARG MEMPALACE_VERSION=3.8.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
+103 -2
View File
@@ -56,7 +56,22 @@ ARG USER_NAME=developer
# current when it was first populated (shipped the same bytes for pi-devbox
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
# branch below is kept only for a deliberate local `docker build` override.
ARG PI_VERSION=0.84.2
#
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
# were grepped for that symbol and reference it ZERO times, so nothing here
# couples to the renamed type. Recorded because the heading will look alarming
# to the next reader doing step 1 above — the audit is done, don't redo it.
# Adopted for two fixes that land squarely on this repo's own vendored-skill
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
# files such as README.md / AGENTS.md inside a skill dir were reported as
# broken skills unless they declared valid skill frontmatter.
# pi-atelier needs no companion bump: v0.8.2 clears the >=0.7.1 floor that
# pi >= 0.84 requires (see PI_ATELIER_REF below).
ARG PI_VERSION=0.84.3
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
@@ -262,6 +277,36 @@ ARG SOURCE_REVISION=
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
# only so its intended ref lands in the label set alongside the others.
ARG MEMPALACE_TOOLKIT_REF=main
# ── Vendored skill provenance ─────────────────────────────────────────
# The vendored mempalace SKILL.md is the ONLY baked artefact with no /opt
# clone behind it: its upstream (the skillset repo) is PRIVATE, so the
# image cannot clone it and CI cannot resolve its HEAD (see VENDORED.md).
# Consequence through v1.8.7: the snapshot was ANONYMOUS — nothing in the
# image or the repo recorded which skillset commit it was taken from, so
# the only staleness check available was a hand-maintained phrase canary in
# scripts/smoke-test.sh, which by construction can only detect "older than
# what I remembered to pin", never "older than skillset main".
#
# Recording the ref costs nothing and makes the question answerable. It is
# deliberately a plain ARG DEFAULT rather than a CI-resolved output:
# * the value is a fact about the committed snapshot, so it belongs in
# the tree next to it — not in a workflow that a local `docker build`
# never runs (same reasoning as MEMPALACE_VERSION living in
# Dockerfile.base rather than being duplicated in docker-publish.yml);
# * CI therefore needs NO new build-arg at any of its four
# Dockerfile.variant call sites (smoke, smoke-studio, build-variant,
# build-variant-studio) — a plumbing change that is easy to
# under-apply to only two of them;
# * and it needs no credential for a private repo.
# Bump it with scripts/vendor-mempalace-skill.sh, which refreshes the file
# and rewrites this line together, so the pair cannot drift apart by hand.
# This ARG lives in Dockerfile.variant ON PURPOSE: Dockerfile.base and
# rootfs/ are both hashed into base_tag, so recording provenance here costs
# 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=5fd0d5c406df506fd0e70977b6bd87f5fbc3b803
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
# and every variant INHERITS it, so both published images used to advertise
# themselves on Docker Hub as the base image. A LABEL cannot branch on
@@ -286,7 +331,8 @@ LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}"
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}" \
se.jordbo.pi-devbox.skillset-snapshot-ref="${SKILLSET_SNAPSHOT_REF}"
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
# of each /opt clone and the live `pi --version` — not merely the intended
@@ -297,14 +343,69 @@ RUN set -e; \
mkdir -p /etc/pi-devbox; \
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
# a palace bug could not be correlated to an image, which is precisely the
# correlation this file exists to provide. Read from the INSTALLED BINARY,
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
# what catches an install which resolved to something other than the pin.
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
# leading-digit test then rejects usage/error text (a renamed flag prints a
# usage block) and degrades to JSON null, so this can never fail the build.
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
STUDIO_REV='null'; \
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
# The vendored skill snapshot's fingerprint is MEASURED here, not passed
# in as a build-arg, per the ground-truth rule above: SKILLSET_SNAPSHOT_REF
# is a CLAIM about which skillset commit the file came from, while this
# hash is what the image actually ships. Recorded together they let any
# reader with the skillset checked out — which on this fleet is every
# host, since all four compose stacks mount it — verify the claim at
# RUNTIME, without CI ever needing access to the private repo. Degrades
# to JSON null rather than failing the build if the directory is absent;
# the smoke assertion is what turns that into a loud failure.
#
# Hashes the whole DIRECTORY, not just SKILL.md: a single-file hash
# answers "did this one file change", not "is the live copy the same
# skill" — a live checkout that added or edited a SIBLING file (a
# reference/ doc, a helper script) would still report "identical to
# baked snapshot" against a file-only hash. pi-extensions already ships
# two files for exactly this reason (SKILL.md + evaluate-extension-usage.py),
# so this is not a hypothetical. Deterministic over `find | sort`, never
# readdir order: relative paths + per-file sha256, folded into one hash.
# pi-devbox-version mirrors this exact pipeline over the live directory so
# the two sides are comparable — if you change this, change that too.
tree_sha256() { \
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1; \
}; \
SKILL_SNAP='null'; \
_snap_dir=/usr/local/share/pi-devbox/skills/mempalace; \
if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \
SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \
fi; \
{ \
echo '{'; \
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
echo " \"build_date\": \"${BUILD_DATE}\","; \
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
echo " \"pi_version\": \"${PI_V}\","; \
# Sibling of pi_version, NOT a member of components{}: that map holds git
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
# silently truncate a longer version string.
echo " \"mempalace_version\": ${MP_CORE},"; \
# Siblings, NOT members of components{}, for two independent reasons:
# that map means "HEAD of a clone present in this image" and the
# skillset is not cloned here (calling it a component would be a
# lie a future reader would act on), and `pi-devbox-version` renders
# every components{} value with .value[0:12] — which would truncate
# a 64-hex sha256 into something that looks like a short commit.
# Named `_tree_sha256`, not `_sha256`: it measures every file under the
# vendored skill directory, not one file — see tree_sha256() above.
echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \
echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \
echo " \"components\": {"; \
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
+47 -2
View File
@@ -70,9 +70,27 @@ so `TERM=xterm-kitty` is understood. Override either in your own
### Document and image tooling
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
- `typst` — markup-based typesetting, wired up as pandoc's `--pdf-engine` (see
[Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst))
- `graphviz` — `dot` rendering for diagram pipelines
- `imagemagick` — image conversion / resizing (invoked as `magick`)
### Browser automation
- `agent-browser` — CLI for driving a real headless browser: open pages,
click/fill/`eval`, snapshot the DOM, take screenshots. Useful whenever a task
involves a web UI or verifying how a page actually renders (live DOM, WebGL,
layout, popup positioning) instead of guessing from source.
- `playwright` + a pre-installed headless **Chromium** back it.
`AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser via a stable
`/usr/local/bin/agent-chrome` symlink (insulated from Playwright's
per-version/arch install directory), so `agent-browser open <url>` works
out of the box with no setup. Run `agent-browser skills get core --full`
for the command set and workflow patterns.
- `socat` — TCP bridge used by `studio-expose` to reach pi-studio's
loopback-bound server from outside the container (see
[Using pi-studio](#using-pi-studio--studio-variant))
### Language toolchains
- `python3` + `python3-venv` + `python3-pip` (system Python)
@@ -537,6 +555,32 @@ session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
diary-write, etc.) are wired into pi automatically by the pi-extensions
mempalace bridge.
### Cross-machine agent coordination
When `MEMPALACE_REMOTE_URL` points at a *shared* palace, the container gets more
than shared search: it joins an append-only coordination log (RFC 003) that other
machines' agents can address it on — used here for design review, patch handoff
and retraction between hosts.
Two container-side settings make it work:
| Variable | Why it matters |
|---|---|
| `MEMPALACE_REMOTE_URL` | selects the shared palace; unset means a purely local palace, and the log then contains only this machine's own events |
| `MEMPALACE_PI_DEVICE` | the bridge stamps `pi@<device>` as the writer, which is the **only** way the log can tell two machines apart when both are thin clients of one palace |
So a container with no `MEMPALACE_PI_DEVICE` can read the log but is not
reachable *on* it: messages addressed to a bare `pi` match nobody. Set both, or
neither.
What the agent is expected to *do* with this lives in the mempalace skill
(`~/.agents/skills/mempalace/SKILL.md`) — the mailbox query at wake-up, and the
convention that a directed event with `status="open"` is a request owed a reply
while a `*` broadcast owes nothing. The mechanism side (what the bridge stamps,
and why live SSE push depends on the palace deployment's reverse proxy rather
than on this image) is documented in the toolkit's `extensions/pi/README.md`.
Nothing in this image polls the log on the agent's behalf.
## Agent skills
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
@@ -787,8 +831,9 @@ docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq
`org.opencontainers.image.{version,revision,created}` plus
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
truth** — the actual checked-out commit of each `/opt` clone and the live
`pi --version` — so a tag is reconstructable after CI logs rotate:
truth** — the actual checked-out commit of each `/opt` clone, the live
`pi --version`, and (from v1.8.6) the live `mempalace --version` of the
installed palace core — so a tag is reconstructable after CI logs rotate:
```bash
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
+15
View File
@@ -18,8 +18,23 @@ for OS packages, the per-package copyright files inside the image at
| pi-fork | github.com/elpapi42/pi-fork | MIT |
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
| pi-atelier | github.com/michaelmjhhhh/pi-atelier | MIT |
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
## MemPalace (AI memory)
| Component | Upstream | License |
| --- | --- | --- |
| mempalace (core, MCP server) | github.com/MemPalace/mempalace (PyPI: `mempalace`) | MIT — the GitHub repo declares MIT; the PyPI package's own metadata omits a license classifier, so if you need clearance from the package artifact alone, verify against the repo's `LICENSE` file rather than the sdist/wheel metadata |
## Browser automation
| Component | Upstream | License |
| --- | --- | --- |
| agent-browser | github.com/vercel-labs/agent-browser (npm: `agent-browser`) | Apache-2.0 |
| Playwright | github.com/microsoft/playwright (npm: `playwright`) | Apache-2.0 |
| Chromium | chromium.googlesource.com/chromium/src | BSD-3-Clause for Chromium's own code, plus a large set of bundled third-party components each under their own license (see Chromium's own `LICENSE`/`about:credits`). The binary in this image is **not compiled here** — it is the build Playwright downloads for its pinned version ("Chrome for Testing"), installed via `playwright install --with-deps chromium` at `/usr/local/share/ms-playwright/`. Treat Playwright's own distribution terms for that build as authoritative over any summary here. |
## Tooling baked into the base image
| Component | Upstream | License (best effort) |
+5 -1
View File
@@ -7,7 +7,11 @@ set -euo pipefail
# so this reaches the same stream as the interactive shell the user lands
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
# with a short stderr notice on images built before it existed.
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
# `--no-skills`: this runs FIRST, before the baked skill links are created
# below and long before the skillset deploy + devbox-skill-reconcile run at the
# end of this script, so the skill-source section would report a pre-reconcile
# state that is about to change. Wrong-but-plausible is worse than absent.
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version --no-skills || true
# ── SSH ControlMaster socket dir ────────────────────────────────
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
+153 -2
View File
@@ -14,6 +14,8 @@
# pi-devbox-version human-readable summary (default)
# pi-devbox-version --json raw manifest JSON (for scripting)
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
# pi-devbox-version --no-skills skip the skill-source section (used at
# container start, where it would be premature)
#
# EXIT STATUS
# 0 on success. 1 if the manifest is missing (e.g. an image built before
@@ -24,15 +26,35 @@ set -euo pipefail
MANIFEST=/etc/pi-devbox/build-manifest.json
MODE="human"
SHOW_SKILLS="yes"
case "${1:-}" in
# A `case "${1:-}"` here only ever looked at the FIRST argument, so
# `--no-skills --json` matched --no-skills, silently dropped --json, and
# printed human text to a caller expecting JSON (a real failure: a jq
# consumer piping that output gets a parse error, not a wrong-but-parseable
# answer). Loop over every argument instead, and reject anything unknown
# rather than silently ignoring it the same way.
for _arg in "$@"; do
case "$_arg" in
--json) MODE="json" ;;
--quiet|-q) MODE="quiet" ;;
--no-skills) SHOW_SKILLS="no" ;;
--help|-h)
sed -n '2,20p' "$0" | sed 's/^# \?//'
# Print the leading `#`-comment block verbatim, stopping at the first
# non-comment line, rather than a hardcoded line range: `sed -n
# '2,22p'` was silently truncating --help because this file has grown
# usage lines since that range was written, and a fixed range will
# drift again the next time a comment is added above it.
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
exit 0
;;
*)
echo "pi-devbox-version: unknown option: $_arg" >&2
echo " try --help" >&2
exit 2
;;
esac
done
if [ ! -f "$MANIFEST" ]; then
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
@@ -55,6 +77,10 @@ release_tag=$(jq -r '.release_tag' "$MANIFEST")
build_date=$(jq -r '.build_date' "$MANIFEST")
source_rev=$(jq -r '.source_revision' "$MANIFEST")
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
# `// empty` matters: images built before v1.8.6 have no such field, and
# `jq -r` renders a JSON null as the 4-char string "null" — which would
# print as a bogus version rather than being treated as absent.
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
if [ "$MODE" = "quiet" ]; then
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
@@ -71,6 +97,16 @@ if command -v pi >/dev/null 2>&1; then
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
fi
# Same check for the palace, which matters more than it looks: mempalace is
# the one component that is BOTH client (here) and server (synlig runs this
# same image), so a skew between the two is a real failure mode rather than
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
mp_version_live=""
if command -v mempalace >/dev/null 2>&1; then
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
fi
printf 'pi-devbox %s\n' "$release_tag"
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
@@ -79,5 +115,120 @@ else
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
fi
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
# instead of showing an empty or "null" palace line.
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
else
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
fi
fi
printf ' components:\n'
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
# ── Which copy of each vendored skill is actually being read? ─────────
# The image bakes fallback skills under /usr/local/share/pi-devbox/skills/,
# but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live
# clone takes over at container start via devbox-skill-reconcile. Nothing
# reported which copy won, so a stale baked snapshot and a current live clone
# looked identical from inside — and on this fleet the baked mempalace copy is
# read by NOBODY (all four compose stacks mount a workspace containing the
# skillset), which is exactly the sort of fact that should be visible rather
# than reasoned about. Same "drift detected" shape as the pi/palace lines
# above: what is live, annotated with what was baked, when they disagree.
#
# Skipped with --no-skills at container start (entrypoint-user.sh calls this
# FIRST, before the baked links exist and long before the skillset deploy and
# reconcile run last), because a section that is accurate only after boot
# finishes is worse than no section at all.
BAKED_SKILLS=/usr/local/share/pi-devbox/skills
SKILLS_DIR="${HOME:-/home/developer}/.agents/skills"
if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then
# Recorded provenance of the vendored mempalace snapshot (absent on images
# built before this existed — `// empty` so a JSON null never prints as the
# 4-char string "null", the same trap noted for mempalace_version above).
# `_tree_sha256`, not `_sha256`: it is a hash over every file in the
# vendored skill DIRECTORY (see tree_sha256() below), not one file, because
# a single-file hash reports "identical" against a live checkout that added
# or edited a sibling file — pi-extensions already ships two files, so this
# is not hypothetical.
snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST")
snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST")
# Same pipeline Dockerfile.variant uses to measure the baked directory at
# build time: relative paths in `find | sort` order, each hashed, the whole
# listing folded into one sha256. Keep the two definitions identical — they
# run in different processes (image build vs. this container) and are
# meaningless to compare unless they agree byte-for-byte on the algorithm.
tree_sha256() {
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1
}
# Iterate the baked tree rather than a hardcoded name list, so vendoring a
# fourth skill needs no edit here. The header prints only if the tree is
# non-empty, so this can never emit a dangling "skills:" label.
_printed_header="no"
for _dir in "$BAKED_SKILLS"/*/; do
[ -d "$_dir" ] || continue
if [ "$_printed_header" = "no" ]; then
printf ' skills:\n'
_printed_header="yes"
fi
_name=$(basename "$_dir")
_link="$SKILLS_DIR/$_name"
if [ ! -e "$_link" ]; then
printf ' %-22s not linked\n' "$_name"
continue
fi
_target=$(readlink -f "$_link" 2>/dev/null || echo "$_link")
case "$_target" in
"$BAKED_SKILLS"/*|"$BAKED_SKILLS")
printf ' %-22s baked\n' "$_name"
continue
;;
esac
# Outside the baked tree: a mounted skillset clone, or a user override.
# The link target is <repo>/skills/<name>, so the repo root is two up.
# Everything here is guarded: this script runs on the container-start path
# and must never fail, and `set -e` is in force.
_root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root=""
_head=""
if [ -n "$_root" ]; then
_head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "")
fi
_where="live ${_root:-$_target}"
[ -n "$_head" ] && _where="$_where @ ${_head:0:7}"
# For the one skill whose baked fingerprint we recorded, say plainly
# whether the live copy differs from what shipped. This is the check CI
# cannot perform (the skillset is private) and the container can, free.
# Hash the whole live DIRECTORY with the same tree_sha256() used to
# measure the baked one in Dockerfile.variant — a SKILL.md-only compare
# would silently ignore a changed or added sibling file.
_live_sha=""
if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -d "$_target" ]; then
_live_sha=$(tree_sha256 "$_target")
fi
if [ -z "$_live_sha" ]; then
printf ' %-22s %s\n' "$_name" "$_where"
elif [ "$_live_sha" = "$snap_sha" ]; then
printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where"
elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then
# Same commit, different bytes — i.e. uncommitted edits in the live
# checkout. Distinguished from plain drift because otherwise the line
# reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15
# — live copy differs") and a reader would suspect the tool, not the
# working tree.
printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \
"$_name" "$_where" "${snap_ref:0:7}"
else
printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \
"$_name" "$_where" "${snap_ref:0:7}"
fi
done
fi
@@ -197,6 +197,45 @@ EOF
)
fi
# ── Multiplexing default, deliberately LAST ───────────────────────────
# Why this block exists: ControlPath above is forced, but ControlMaster is not
# set anywhere for targets that come from the user's own ~/.ssh/config. A target
# whose entry omits ControlMaster therefore opens a NEW TCP connection per ssh
# call, and an agent doing a dozen calls in a few minutes can trip fail2ban or a
# CGNAT flow-table cap on the far end — observed 2026-08-25: ~12 connections in
# 15 min and port 22 stopped answering while HTTPS to the same estate stayed fine.
#
# WHY IT IS AT THE BOTTOM, and ControlPath is at the top. ssh_config is
# first-value-wins, so position encodes intent:
# * BEFORE the Include = an OVERRIDE. Correct for ControlPath, whose value in
# the user's config points at read-only ~/.ssh and simply cannot work here.
# * AFTER the Include = a DEFAULT. Correct for ControlMaster, because an
# explicit per-host 'ControlMaster no' (or 'auto', or any value) in the
# user's own config must keep winning. We are supplying an opinion only
# where the user expressed none.
# That asymmetry is the whole design: force what is broken, default what is
# merely absent. It also means this needs no audit of anyone's ~/.ssh/config —
# which matters because that file is per-machine, differs across the fleet, and
# future machines' versions do not exist yet to be audited.
#
# Caveat worth knowing (and documented in the pi-devbox-environment skill): a
# stale master socket — file present, daemon gone, e.g. after the host suspends
# or changes network — makes every later ssh to that host hang. Recovery is
# 'ssh -F ~/.ssh-local/config -O exit <host>'. ControlPersist is deliberately
# short (10m idle, and each new session resets the idle timer) so an abandoned
# socket ages out on its own rather than lingering for hours.
MULTIPLEX_DEFAULT_BLOCK=$(cat <<'EOF'
# Multiplexing DEFAULT — intentionally after the Include above, so any explicit
# per-host ControlMaster in your own ~/.ssh/config still wins (first-value-wins).
# Applies only to targets that never mentioned ControlMaster at all.
# Stale socket after a suspend/network change? ssh -O exit <host>.
Host *
ControlMaster auto
ControlPersist 10m
EOF
)
cat > "$CONFIG" <<EOF
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
@@ -216,6 +255,7 @@ ${JUMP_BLOCK}
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
${MULTIPLEX_DEFAULT_BLOCK}
EOF
chmod 600 "$CONFIG" 2>/dev/null || true
@@ -39,6 +39,33 @@ its skill file needed baking.
*different* skill, `opencode-mempalace-bridge`), so there is no public
package source to copy from. This snapshot is refreshed manually per release.
**Refresh it with `scripts/vendor-mempalace-skill.sh <skillset-root>`, not
`cp`.** Because the image cannot clone the private upstream, the snapshot used
to be *anonymous* — nothing recorded which skillset commit the bytes came
from, so the only staleness check possible was a hand-maintained phrase canary
in `scripts/smoke-test.sh`, which by construction detects "older than the
phrase I remembered to pin", never "older than skillset main". Two facts now
travel with the file:
| Fact | Where | Kind |
|---|---|---|
| `ARG SKILLSET_SNAPSHOT_REF` in `Dockerfile.variant` | manifest `skillset_snapshot_ref` + OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref` | a **claim** about which commit these bytes are |
| `sha256sum` of this file, measured in the manifest layer | manifest `skillset_snapshot_sha256` | the bytes that **actually shipped** |
The script writes both together, refuses when the upstream file has
uncommitted modifications (no commit describes those bytes), and
`--check` verifies the claim against a real clone. Deliberately an `ARG`
default rather than a CI-resolved value: no credential for a private repo, no
change at any of the four `Dockerfile.variant` build call sites, and a local
`docker build` records the same thing CI does.
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
not made one — see the Unreleased CHANGELOG entry for why (private repo;
another repo's branch must not be able to fail this build; and the artefact it
would guard is read by no host on this fleet). The check belongs where the
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
and `pi-devbox-version`'s `skills:` section for an agent inside a container.
## Runtime precedence (v1.8.5+)
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
@@ -59,6 +86,21 @@ repoints the links for skills the **skillset owns**, listed one per line in
3. **baked snapshot** — everything else, and every skill when no skillset is
mounted
**Which one won is now reportable from inside the container:**
`pi-devbox-version` prints a `skills:` section naming, per vendored skill,
`baked` or `live <repo> @ <sha>` — and for `mempalace` whether that live copy is
identical to the baked fingerprint, at the same commit but with uncommitted
edits, or genuinely divergent. Before that, a stale baked snapshot and a current
live clone were indistinguishable from inside, which is how the freshness of
this file went unexamined for three releases. The section is suppressed with
`--no-skills` on the container-start banner, because `entrypoint-user.sh` prints
the version *before* the links exist and long before the reconcile below runs.
On this fleet, precedence 2 wins for `mempalace` on **every** host — all four
compose stacks mount a workspace containing the skillset — so the baked copy is
exercised only by CI and by a hypothetical no-mount container. Worth
remembering before spending effort on its freshness.
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
package repo (copied over the snapshot at build), and `skillset` carries a
downstream copy that can lag, so handing it to the clone would *regress* the
@@ -72,15 +114,31 @@ mounted, plus a fabricated-skillset run of the reconciler).
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
cp <skillset>/skills/mempalace/SKILL.md mempalace/SKILL.md
Copy each snapshot **from its owner in the table above** — `pi-extensions` from
the package repo's `skill/` (since `a7f3044` co-located it there; `skillset`
also carries a copy, but it is a downstream duplicate and can lag), and
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
regress the snapshot to whatever that repo last mirrored.
Copy `pi-extensions` **from its owner in the table above** — the package
repo's `skill/` (since `a7f3044` co-located it there; `skillset` also carries a
copy, but it is a downstream duplicate and can lag). Copying `pi-extensions`
from `skillset` would regress the snapshot to whatever that repo last mirrored.
Snapshot provenance at last refresh: skillset `670f7f1`, pi-extensions pkg `e73cb9f`.
`mempalace` is **not** refreshed by `cp` — see the *Freshness model* section
above: `scripts/vendor-mempalace-skill.sh <skillset-root>` is the only thing
that should ever touch that snapshot, because a bare copy can update the bytes
without updating the ref that claims to describe them, which produces a
manifest that confidently lies.
Neither vendored skill has a hand-maintained "last refreshed at" line here on
purpose — one previously existed (skillset `670f7f1`, pi-extensions pkg
`e73cb9f`) and went stale within hours, because nothing forced it to move
when the ARGs did. `670f7f1` is now a cautionary example rather than a fact
worth recording: it is the commit that told agents to hand-stamp `added_by`,
which a later skillset commit (and the pi-devbox edge stamper) withdrew — so a
reader trusting that line would have been pointed at superseded guidance.
Both facts it tried to capture now live somewhere that cannot drift by hand:
| Fact | Where |
|---|---|
| which skillset commit `mempalace`'s bytes came from | `ARG SKILLSET_SNAPSHOT_REF` (Dockerfile.variant) + `skillset_snapshot_ref` in `build-manifest.json`, written *only* by `vendor-mempalace-skill.sh` |
| which pi-extensions package commit was vendored | `ARG PI_EXTENSIONS_REF` (Dockerfile.variant, CI-resolved to a 40-hex commit) → OCI label `se.jordbo.pi-devbox.pi-extensions-ref` and `build-manifest.json`'s `components.pi-extensions`, both read from the actual `/opt/pi-extensions` checkout, not from intent |
When you refresh the `mempalace` snapshot, also update the phrase asserted by
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
@@ -41,6 +41,21 @@ Run these immediately when a session begins, before responding to the user:
mempalace_kg_query(entity="<project_or_person>")
```
4. **Check your mailbox.** Just run it — an empty result is a fine answer and
costs one call. Do not try to decide first whether coordination "applies to
you"; that test is what used to be wrong here (see *Cross-Machine
Coordination* below):
```
mempalace_event_list(to_agent="<harness>@<device>", status="open")
```
This is a candidate list, not a to-do list — `status` never changes after an
event is written, so finished asks keep matching. Subtract the ones you have
already answered using the rule in *What you actually owe*, below.
Another machine may have asked you something, or corrected something you are
about to rely on. This costs one call and is the only way you will find out:
nothing pushes an event into your session unless your bridge delivers it for
you, and if it does you will already have seen it before reading this.
Do NOT announce this to the user. Just do it silently to orient yourself.
### Temporal grounding — compute time deltas, don't guess
@@ -79,6 +94,79 @@ mempalace_search(query="<keywords>", wing="<project>")
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
#### Search Before You *Probe*
The rule above covers **questions**. This one covers **actions** — and it is the one
that actually gets skipped, because mid-task the impulse is to go and *look* rather
than to remember. The palace is a **fleet** record: another machine's agent has
usually already paid the cost of discovering how this environment is wired, and its
notes include the corrections that came afterwards, which a fresh probe cannot show
you.
**Before you SSH somewhere to find out how it is set up, enumerate infrastructure,
or derive a deployment — search.** Concrete triggers, all meaning *search first*:
- about to run `ssh <host> …`, `docker ps`, `systemctl list-units`, `ip addr` to
discover how something is deployed or connected
- about to establish topology: which hosts/runners/services exist, where they live,
which of them can reach which
- about to conclude "this isn't documented anywhere" or "there's no way to know"
- about to assert an environment fact you learned **earlier in this same session**
**That last trigger is the sharp edge.** A compacted session summary is lossy by
design, and a belief you formed 40 turns ago may already be *retracted* in the
palace by another machine. Trusting your own context over the shared record is how a
withdrawn claim gets re-published as fact.
Search broadly before narrowing — fleet knowledge often sits in another machine's
wing, or inside a mined conversation, not where you would file it yourself:
```
mempalace_search(query="<topic> <host> <mechanism>") # no wing filter first
mempalace_search(query="…", wing="<likely-wing>") # then narrow
```
Two or three searches cost seconds. Re-deriving infrastructure costs minutes **and
can be wrong**: a probe shows one host's present state, while the palace records
intent, history, and what was already disproved.
> **Worked example (real, 2026-08-25).** An agent evaluating whether to add an ARM
> CI runner probed hosts directly instead of searching. It concluded "the runner
> lives on synlig" — there are **four** — and that "synlig is on the home LAN" —
> it is an OpenStack VM with a public floating IP that cannot reach the home LAN at
> all. Both facts were already in the palace, the second one as an **explicit
> retraction of the very same mistake** made weeks earlier. The palace also held
> the runner labels and the deliberate `capacity: 1` setting, which the probe never
> revealed. Cost: a wrong recommendation written into the palace twice, then
> corrected twice.
**A search that comes back empty is not an answer — least of all about recent work.**
Semantic search is weakest exactly where the fleet record is freshest: a drawer filed
minutes ago is unranked against a keyword-shaped query, and the drawer you most need
is *by construction* the newest one, because the other machine files its release,
handoff and correction drawers at the **end** of its session. So a single miss proves
nothing. **If the work is 0-2 days old and the first search looks stale or empty,
enumerate before concluding:**
```
mempalace_list_drawers(wing="<wing>", since="<today>") # or room=, or no filter
mempalace_diary_read(agent_name="<you>", wing="<wing>") # the other machine's handoff
```
Enumeration is exact where embeddings are probabilistic. Treat "I searched and found
nothing" as a hypothesis you have not yet tested, and never as licence to go probing.
> **Worked example (real, 2026-08-25, same fleet as above).** An agent asked to
> orient on an in-flight release *did* search first — `"v1.8.6 release run 579
> Docker Hub verification"` — and got back only v1.6.4 / v0.78.0 era hits, because
> the release drawer it needed was **58 seconds old**. It accepted the miss and went
> off to probe Docker Hub and the Gitea API. The user had to prompt "maybe there is a
> note in mempalace"; `list_drawers(wing="pi-devbox", since=<today>)` then returned
> the drawer immediately, along with the diary entry naming the exact open item. The
> rule above was present and correct in this very file at the time — the failure was
> not knowing to *retry differently* after a bad first hit.
#### Mine New Projects
When working on a new codebase for the first time:
@@ -267,6 +355,158 @@ mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<to
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
```
## Cross-Machine Coordination — the logstream
The palace stores what you *know*. The logstream (`mempalace_event_*`,
`mempalace_artifact_*`) carries what you want to *say to another agent* —
delegation, review, patch handoff, retraction. It is the only channel on which
another machine can reach you.
**Does this apply to you at all? Do not use `mempalace_mesh_peers` to decide.**
It answers a different question than it appears to. A shared palace can be
*hub-and-spoke* — many machines as thin clients of one central replica — and
then `mesh_peers` reports `peers: []` because there are no peer *replicas*,
even while four machines are actively writing to the same log. Measured on this
fleet: `peers: []`, one replica authoring every event from every machine. An
earlier version of this section told you to read `mesh_peers` and skip the
mailbox when it came back empty, which disabled the mailbox on precisely the
fleet it was written for.
The honest discriminators, cheapest first: **just run the mailbox query** (empty
is a fine answer); check whether `MEMPALACE_REMOTE_URL` is set, which is what
actually selects a shared palace; or look for any event whose `from_agent` is
not you. On a solitary palace the event tools still work — you are writing to
yourself and your mailbox stays empty. That is not a fault to debug.
**It is a durable log, not a bus — nobody is "listening".** Events are appended
and persist; there is no subscription, no delivery window, and nothing is lost
by being offline when one is written. A message waits indefinitely for you, and
your reply waits just as patiently for a sender who has since gone away. Machines
in a fleet are rarely awake at the same time, which is exactly why this is a log
and not a chat.
**Agent name is the only identity the log has.** Depending on deployment, every
client may share one `origin_replica` — on the fleet this skill was written for,
all machines are thin MCP clients of a single central replica, so `origin_replica`
is identical for every event and cannot tell two machines apart. `from_agent` /
`to_agent` carry the whole distinction, which is why the `<harness>@<device>`
stamping in *Provenance is stamped for you* is load-bearing here and not mere
tidiness.
### Reading your mailbox
```
mempalace_event_list(to_agent="<harness>@<device>", status="open")
```
- `to_agent=<you>` **also matches `*` broadcasts**, so one call covers both. No
second query needed.
- `status="open"` narrows the mailbox to what a sender *said was an ask at the
time of writing* — that is all it can do. It is a good first filter (on a real
stream it cut 5 events to 2), but it is **not** a list of what you owe, and it
never shrinks as you work. Treating it as owed-ness is the mistake this
section previously made: an earlier draft cited "5 unfiltered, exactly 1
filtered — the one that needed a reply" as proof the filter tracked
obligation. It did not. That single result was an event which had *already
been acked* half an hour earlier; the filter looked decisive only because the
stream happened to contain one directed `open` event. **Unfiltered mailboxes
train you to ignore them — and so does a filter that keeps showing you
finished work.**
- To resume where you left off, use `since_event_id`, **never**
`since_created_at`. A timestamp cursor permanently skips an event that synced
in late — it is a time window ("what happened today"), not a cursor.
- Read `metadata` before acting: senders put the load-bearing specifics there
(which host verified what, which run failed, what a change retracts).
### The ack contract — the sender declares whether a reply is owed
An obligation you never agreed to is noise, so the sender states it:
| Sender writes | Means | Recipient owes |
|---|---|---|
| `to_agent="<specific agent>"` + `status="open"` | an ask | an ack or a reply (the event itself keeps matching forever — see below) |
| `to_agent="*"` (any status) | broadcast FYI | nothing |
| any other status (`ready`, `applied`, `blocked`, …) | a statement of fact | nothing |
Ack with `mempalace_event_ack(event_id=…, from_agent="<you>", status=…)`. It
**appends a new event** and never mutates the original; the correlation id is
copied for you, and `metadata.ack_of` is set to the event you answered.
#### What you actually owe — derive it, do not read it off `status`
The log is append-only and `status` is written **once**, so it is an honest
statement about an item *at the moment it was written* and nothing more. It is
not mutable state, and asking it to carry mutable state is what breaks:
acking appends a new event and changes nothing about the old one, so **a
directed `open` event matches your mailbox query forever, answered or not.**
Nothing is ever "dismissed" — which also means a deferred ask cannot be
accidentally lost, only that you must compute what is outstanding:
```
candidates = mempalace_event_list(to_agent="<you>", status="open")
mine = mempalace_event_list(from_agent="<you>")
```
A candidate is **answered** when one of your own events
1. has a **higher `seq`** than the candidate, and
2. joins to it — `metadata.ack_of == candidate.id` (exact, written for you by
`event_ack`) or the same `correlation_id` (the fallback), and
3. carries a **terminal** status: `applied`, `superseded`, `failed`, `blocked`.
Everything else is still owed. Two calls, constant cost.
**Compare `seq`, never `created_at`** — the same reason you resume with
`since_event_id`. Without the ordering test, one terminal reply would suppress
every later ask on the same `correlation_id` for good; verified on a live thread
where a `ready` reply at `seq` 16 sits *before* the request at `seq` 17 that it
obviously cannot have answered.
This also supplies the "taken, not finished" state that looked missing:
`claimed` and `ready` are deliberately **not** terminal, so work you have picked
up keeps resurfacing until you close it out. No extra convention, no new field.
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.
- **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
so in `metadata.expires_at` — metadata is stored verbatim — and honour it as a
hint when reading. An old `open` that the derivation still counts as owed is a
signal, not garbage: it means somebody asked and nobody answered.
### Writing to another machine
- **Address the stamped name you actually saw** in a `from_agent` field, e.g.
`pi@tor-ms22`. A bare `pi` reaches nobody's mailbox once stamping is live, and
older events in the log still carry bare names — do not copy them.
- **Use `status="open"` only when you truly need an answer.** It places an
obligation on another machine.
- **Never broadcast an ask.** `to_agent="*"` + `status="open"` obliges everyone
and therefore no one.
- **Always set a `correlation_id` on a directed `open`,** and reply with the
same one. It is not just for reconstructing a conversation later: it is the
join the owed-set derivation depends on. An uncorrelated ask can only ever be
closed by an `event_ack` (which sets `ack_of` for you) — a plain reply cannot
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.
- **Put a retraction where the reader will look.** An event reaches a live agent;
a *drawer* is what a future semantic search finds. If you filed advice as a
drawer and later withdraw it, file the withdrawal as a drawer too — otherwise
the next agent finds your original confident advice and no trace of the
correction. (This is a real incident, not a hypothetical.)
- **Hand over exact content as an artifact**, not prose: `mempalace_artifact_put`
or `mempalace_patch_submit` store bytes with a sha256, and the event references
the id. Never paste a diff into a body and hope it survives.
- **Waiting on a specific reply?** `mempalace_event_wait` blocks with backoff —
do not poll `event_list` in a loop. A timeout there is a normal result, not an
error.
## Palace Structure
### Wings
@@ -290,13 +530,14 @@ Zechner's pi-coding-agent). Implications:
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd `Weekday`: `0`/`7`=Sunday, `1`=Monday, `2`=Tuesday — misreading this by one day is easy). Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
When the palace is **central** (shared across machines), five more things apply:
When the palace is **central** (shared across machines), these further things apply:
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
- **Attribute what you file yourself.** Drawers now carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). Mined content gets these for free — the inbox path gives the device, the filename shape gives the harness — and a timer on the palace host re-stamps hourly, because live re-mining replaces metadata rows and silently drops earlier stamps. But for anything **you** file by hand, the only signal is what you pass: set `added_by="<harness>@<device>"` (e.g. `pi@emb-7kj4vr4g`, from `$MEMPALACE_PI_DEVICE`) on `add_drawer`/`checkpoint`/`mine`. Skip it and your drawer joins the ~16k historic `/workspace` project mines that are permanently unattributable, because `/workspace` exists identically on every devbox. Note the palace preserves `source_file` in full (see `source_path`) but *displays* only the basename — so a device prefix there survives storage even though it looks stripped.
- **Provenance is stamped for you — leave it alone.** Drawers carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). You do **not** set these, and you no longer set `added_by` either: the pi bridge defaults the writer field to `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>|`, from host-supplied `$MEMPALACE_PI_DEVICE`. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the *worst possible* place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as `added_by=checkpoint`. **Confirm the bridge in your image actually stamps before trusting it:** the extension is baked at image build time, so a container on an image older than the stamping commit (pi-devbox < v1.8.7) stamps nothing while still satisfying both gates — the env vars are set and the code is simply absent. Check with `grep -c MEMPALACE_PI_DEVICE "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`; zero means keep passing `added_by="<harness>@<device>"` and a manual `HOST:<device>|` diary prefix until the container is recreated on a newer image. Two things remain yours: pass `source_drawer_id` on `kg_add` (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit `added_by` **only** when deliberately filing on behalf of another device. Never invent values for `device`/`agent_kind`/`origin_device` — a fabricated value is worse than a blank, because it silently corrupts a future merge.
- **Metadata is invisible to search — so check the text, not the fields.** `search` results are built from a fixed key list and `diary_read` returns content, so neither ever shows `device`/`added_by`. Only `mempalace_get_drawer` reveals them. This is why diary entries carry an in-text `HOST:<device>` marker: it is the only attribution a reader actually sees. **A diary entry with no `HOST:` marker predates the convention and may be from any machine — do not assume it is this one's history.**
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history.
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history — and note that a container cannot tell you which machine it is on (`hostname` is a docker hash, `$DEVBOX_HOST_ALIAS` is generic). `$MEMPALACE_PI_DEVICE` is the cheap answer; `ssh -F ~/.ssh-local/config host hostname` is the independent one.
- **One writer, no queue.** A concurrent mine returns a structured `already-running` error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call `mempalace_reconnect` to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.
### Rooms
@@ -330,10 +571,15 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
## Anti-Patterns
- **Don't guess when you can search.** If a question touches past work, search first.
- **Don't probe what the fleet already knows.** Before SSH-ing into a host, enumerating infrastructure, or deriving how something is deployed, search the palace. A probe reveals one host's present state; the palace holds intent, history and prior corrections — including the ones that contradict what you are about to conclude.
- **Don't trust this session's context over the palace.** A compacted summary is lossy, and another machine may have corrected the fact since. Verify load-bearing environment claims against the shared record before acting on them.
- **Don't take one empty search as proof the palace is silent.** Fresh drawers rank worst, and the drawer that matters is usually the newest one. For anything 0-2 days old, enumerate with `mempalace_list_drawers(since=…)` and read the other machine's diary before you go and probe.
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
- **Don't hand-craft provenance.** Leave `added_by` alone (and never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`). Recording *which device* wrote a record is client/server infrastructure, not your job: a hostname or container ID is not a stable identity, and an invented value is worse than none because it silently corrupts any future palace merge. If you find notes in the palace describing an `origin_device` scheme, that is a design for the client to implement — not an instruction for you to start stamping.
- **Don't broadcast an ask, and don't leave one unanswered.** On a shared palace, `to_agent="*"` + `status="open"` obliges every machine and therefore none of them. And don't expect acking to tidy your mailbox: `status` is immutable, so the event keeps matching either way — what a terminal reply buys you is that the *derived* owed set (see *What you actually owe*) stops counting it. Leave asks unanswered and that set only grows, until everyone learns to stop looking. "Seen, not doing it" is a complete answer — silence is not.
- **Don't assume you would have heard.** Nothing pushes another machine's message into your session. If you did not run the mailbox query at wake-up, a correction addressed to you by name can sit unread while you confidently rebuild the thing it warned you about.
- **Don't invent provenance metadata, and don't hand-stamp it either.** An earlier version of this list told you to set `added_by="<harness>@<device>"` by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see *Provenance is stamped for you* above) — but the withdrawal only holds where the bridge is live, so run the one-line check in that bullet first; on an older image hand-stamping is still the only signal a hand-filed drawer gets. DO NOT invent values for the palace's own metadata fields (`device`, `agent_kind`, `origin_device`): those are stamped by infrastructure that also records *how* each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass `source_drawer_id` on `kg_add`. And never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`.
@@ -185,6 +185,16 @@ entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
can't be created under it), plus `Include ~/.ssh/config`.
- A **trailing** `Host *` block supplying `ControlMaster auto` + `ControlPersist
10m` as a *default*. Position is the design: `ControlPath` sits **before** the
`Include` (an override — the value in your own config points at read-only
`~/.ssh` and cannot work here), while `ControlMaster` sits **after** it (a
default — an explicit per-host `ControlMaster no`/`auto` in your own config
still wins, because ssh_config is first-value-wins). **Force what is broken,
default what is merely absent.** Without this, a target whose entry never
mentioned `ControlMaster` opens a fresh TCP connection per `ssh` call, and an
agent making a dozen calls in a few minutes can trip fail2ban or a CGNAT
flow-table cap on the far end.
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
@@ -199,6 +209,25 @@ ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
```
**Always go through the sidecar, never `-F ~/.ssh/config`.** This is the single
easiest way to break SSH from inside the container, and the failure actively
misleads: the read-only path makes the master socket uncreatable, so
multiplexing appears *impossible* rather than misconfigured. What follows is a
burst of fresh connections and, on a rate-limiting peer, a block that looks like
an outage. The tell that it is rate-limiting and not an outage: HTTPS to the same
estate keeps working while port 22 stops answering. (Recorded 2026-08-25 — an
agent hit exactly this, concluded "ControlMaster is impossible here", disabled
multiplexing, and filed that as a lesson. The sidecar had solved it since v1.4.)
If every `ssh` to one host suddenly hangs, suspect a **stale master** — socket
file present, daemon gone, typically after the host suspended or changed
network. Check and clear it:
```sh
ssh -F "$HOME/.ssh-local/config" -O check <host> # "Master running (pid=…)" or no master
ssh -F "$HOME/.ssh-local/config" -O exit <host> # tear down a stale one
```
Two related mechanisms (don't reinvent them):
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
+248 -15
View File
@@ -5,8 +5,9 @@
#
# Verifies:
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
# - mempalace core matches the audited pin (if EXPECTED_MEMPALACE_VERSION set)
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
# - typst PDF engine for pandoc (Unreleased) — `pandoc --pdf-engine=typst`
# - typst PDF engine for pandoc (v1.4.0) — `pandoc --pdf-engine=typst`
# - non-modal editors nano + micro (alongside nvim)
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
@@ -151,6 +152,33 @@ run "pi stage follows MEMPALACE_PALACE_PATH" '
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
'
# The feeder's --agent default is WHO a drawer is attributed to. mempalace core
# records neither the machine nor the harness on a write, and one shared bearer
# token means the server cannot tell clients apart, so toolkit c64ffa1 changed
# this default from $USER to pi@$MEMPALACE_PI_DEVICE — the one string that makes
# a write attributable to both. Nothing ever PRINTED the resolved value (the
# banner shows mode= and stage= only), so an image built from a pre-c64ffa1
# toolkit ref would ship unattributed writes with every check still green.
#
# `--help` assigns AGENT (script top) before it parses args, then exits 0 with
# no side effects — so `bash -x` observes the REAL resolution, env interpolation
# and fallback included, rather than grepping the source for a literal line that
# any reformat would break. Two-sided on purpose: device set => pi@<device>;
# device UNSET => must not be pi@anything. The second half is what fails against
# the old unconditional $USER default, which ignored the device entirely.
#
# Probes the PATH entry (a symlink into the /opt clone) rather than that clone
# path directly: this is the invocation the systemd/launchd timers and
# entrypoint-user.sh actually use, so it is the default that reaches the palace.
run "feeder resolves --agent to pi@<device> (drawer attribution)" '
f=$(command -v mempalace-pi-session) || { echo "feeder not on PATH" >&2; exit 1; }
with=$(MEMPALACE_PI_DEVICE=smoke-device bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
without=$(env -u MEMPALACE_PI_DEVICE bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
echo "resolved with-device=[$with] without-device=[$without]" >&2
[ "$with" = "pi@smoke-device" ] || exit 1
case "$without" in pi@*) exit 1 ;; esac
echo ok
'
# Regression guard for the pi transcript exporter. If pi ever changes its
# session JSONL shape, the exporter stops recognising sessions and the palace
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
@@ -263,6 +291,26 @@ 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"
# ...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
# key; upstream fixed it in ce9fc98, adopted in v1.8.4. PI_OBSMEM_REF tracks
# master, so an upstream revert or force-push would ship a dead `recall` with
# the clone assertion above still green — the exact gap flagged as open in the
# v1.8.5 changelog.
#
# Pin the markers to src/runtime.ts, the fix SITE, rather than grepping the
# repo: two of these three strings also appear under tests/, so a repo-wide
# grep stays green with runtime.ts itself reverted. That is a false green of the
# same family as the old skill-snapshot canary.
run "pi-observational-memory carries the ce9fc98 auth fix (recall stays alive)" '
f=/opt/pi-observational-memory/src/runtime.ts
test -f "$f" || { echo "fix site missing: $f" >&2; exit 1; }
for m in availability_recheck providerCredentialConfigured hasConfiguredAuth; do
grep -q "$m" "$f" || { echo "marker absent from runtime.ts: $m" >&2; exit 1; }
done
echo ok
'
# pi-atelier: deliberately NO node_modules assertion, unlike its siblings —
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
@@ -301,26 +349,157 @@ echo ""
echo "── Build provenance ──"
run "/etc/pi-devbox/build-manifest.json present" \
"test -f /etc/pi-devbox/build-manifest.json"
run_expect "manifest records pi-extensions component" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
run_expect "manifest records pi-atelier" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
run_expect "manifest records pi_version" \
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
# These next checks replace three that grepped the manifest for the FIELD NAME
# and never looked at the value:
#
# run_expect "manifest records pi_version" "cat …manifest.json" '"pi_version"'
#
# which passes on {"pi_version": ""} and on {"pi_version": null}. The tell was
# visible in its own passing output — `✅ manifest records pi_version (got
# "pi_version")` echoes the key back as the thing it claims to have found.
# Two failure modes were therefore invisible: a key that survives with an empty
# or garbage value, and a key that vanishes from the manifest while every
# remaining value still looks fine.
#
# Those two need SEPARATE assertions, and the reason is a trap worth keeping in
# writing: an "every component value is a valid SHA" loop passes VACUOUSLY on
# components:{} — jq's all() over an empty list is true — so the value check
# alone would go green on a manifest that lost every component. Mutation-tested
# 2026-08-25 across nine fabricated manifests (empty map, deleted key, "",
# null, "unknown", 12-hex truncation, 40 non-hex chars, legit null pi-studio).
run "manifest declares every required component key" '
req="pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier mempalace-toolkit pi-studio"
for k in $req; do
jq -e --arg k "$k" "(.components|has(\$k))" /etc/pi-devbox/build-manifest.json >/dev/null \
|| { echo "manifest lost component key: $k" >&2; exit 1; }
done
'
# Subsumes the old `! grep -q \"unknown\"` check ("unknown" is not 40-hex), and
# also catches "", null and truncated SHAs, which that grep let through. null is
# legitimate for pi-studio alone: the non-studio variant has no such clone.
run "manifest component values are resolved 40-hex commits" '
jq -e "
.components
| to_entries
| all(if .key == \"pi-studio\" and .value == null then true
else (.value|type) == \"string\" and (.value|test(\"^[0-9a-f]{40}\$\")) end)
" /etc/pi-devbox/build-manifest.json >/dev/null
'
# pi_version against ground truth, same shape as the mempalace check below.
# Chains with the "pi version matches build arg" assertion earlier in this file:
# together they tie build arg -> installed binary -> recorded manifest, so a
# manifest written from a stale variable cannot pass by agreeing with itself.
run "manifest pi_version matches the installed pi" '
m=$(jq -r ".pi_version // empty" /etc/pi-devbox/build-manifest.json)
b=$(pi --version 2>/dev/null | head -n1 | tr -d "\r")
echo "manifest=[$m] installed=[$b]" >&2
[ -n "$m" ] && [ "$m" = "$b" ]
'
# Top-level provenance fields: assert the SHAPE of each value, and only when the
# field is populated. source_revision and build_date legitimately default to
# empty (Dockerfile.variant ARGs) on a plain local `docker build`, so demanding
# them would fail honest local smoke runs; a populated-but-malformed value is
# the actual defect. release_tag defaults to "dev", so empty means a broken write.
run "manifest top-level fields are well-formed, not merely present" '
j=/etc/pi-devbox/build-manifest.json
t=$(jq -r ".release_tag // empty" $j)
r=$(jq -r ".source_revision // empty" $j)
d=$(jq -r ".build_date // empty" $j)
echo "release_tag=[$t] source_revision=[$r] build_date=[$d]" >&2
[ -n "$t" ] || { echo "release_tag empty (ARG default is dev)" >&2; exit 1; }
if [ -n "$r" ]; then
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" || { echo "source_revision not a 40-hex commit" >&2; exit 1; }
fi
if [ -n "$d" ]; then
printf "%s" "$d" | grep -qE "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" || { echo "build_date not ISO-8601" >&2; exit 1; }
fi
'
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
# was recorded but the palace version behind the MCP tools was not, so a palace
# bug could not be correlated to an image version. Assert the field exists AND
# equals the installed binary — recording it from ARG MEMPALACE_VERSION instead
# would look identical here yet drift silently the first time an install
# resolved to something other than the pin, which is the whole reason this file
# is built from ground truth. `// empty` matters: jq -r prints the 4-char
# string "null" for a JSON null, which would satisfy a naive -n test.
run "manifest mempalace_version matches the installed core" '
m=$(jq -r ".mempalace_version // empty" /etc/pi-devbox/build-manifest.json)
b=$(mempalace --version 2>/dev/null | head -n1 | tr -d "\r"); b=${b##* }
echo "manifest=[$m] installed=[$b]" >&2
[ -n "$m" ] && [ "$m" = "$b" ]
'
# ... and, when CI supplies it, that the installed core is the version CI
# actually AUDITED (published + not yanked on PyPI, in resolve-versions). This
# does NOT duplicate the check above, which compares two properties of one
# image and so cannot notice that BOTH are the wrong version. The live failure
# mode it covers: the variant builds `FROM` a base tag chosen by base-decide's
# content hash, so a bug in that hashing (the reason scripts/check-base-hash.sh
# exists) could reuse a cached base built from an OLDER MEMPALACE_VERSION pin —
# internally consistent, silently stale, invisible to every other assertion.
if [ -n "${EXPECTED_MEMPALACE_VERSION:-}" ]; then
run "installed mempalace matches CI's audited pin (${EXPECTED_MEMPALACE_VERSION})" "
b=\$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r'); b=\${b##* }
echo \"installed=[\$b] audited_pin=[${EXPECTED_MEMPALACE_VERSION}]\" >&2
[ \"\$b\" = \"${EXPECTED_MEMPALACE_VERSION}\" ]
"
fi
# Every component must be a resolved commit (or null for pi-studio in the
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
run "manifest has no unresolved ('unknown') components" \
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
# pi-devbox-version wraps the manifest into a human-first command (this
# PR); verify the binary is present, executable, and both output modes work.
# non-studio variant) — now enforced by the 40-hex value check above, which
# strictly subsumes the old whole-file grep for '"unknown"'. Only rev() ever
# emits "unknown" and rev() feeds components only, so nothing is lost.
# pi-devbox-version wraps the manifest into a human-first command; verify the
# binary is present, executable, and that all three output modes work.
run "pi-devbox-version binary present + executable" \
"test -x /usr/local/bin/pi-devbox-version"
run_expect "pi-devbox-version human output shows release tag" \
"pi-devbox-version" "pi-devbox "
run_expect "pi-devbox-version --json round-trips the manifest" \
"pi-devbox-version --json" '"release_tag"'
# --json is a verbatim `cat` of the manifest, so "round-trips" is assertable
# literally. The old form grepped the output for the string "release_tag" — the
# key name again — which would pass on a truncated or re-serialised dump.
run "pi-devbox-version --json round-trips the manifest byte-for-byte" '
a=$(cat /etc/pi-devbox/build-manifest.json)
b=$(pi-devbox-version --json)
[ "$a" = "$b" ] || { echo "--json output differs from the manifest on disk" >&2; exit 1; }
'
run_expect "pi-devbox-version --quiet is a compact one-liner" \
"pi-devbox-version --quiet | wc -l" "1"
# ── Vendored skill snapshot provenance ─────────────────────────────────
# The vendored mempalace skill is the one baked artefact with no /opt clone
# behind it (private upstream — see VENDORED.md), so until now the manifest
# could not say which skillset commit it came from. Two fields now travel with
# it: the CLAIMED ref (ARG default in Dockerfile.variant) and the MEASURED
# sha256 of the shipped bytes. Assert both are well-formed, and — separately —
# that the measurement still describes the file in the image.
#
# Kept as two assertions for the same reason the component checks are: one
# proves the fields are not empty/garbage, the other proves they are not merely
# self-consistent. A single combined check could pass on a manifest whose hash
# was computed from a file that was later overwritten (the pi-extensions skill
# copy at Dockerfile.variant:165 does exactly that kind of overwrite, one stage
# earlier), which is the failure this second one exists to catch.
run "manifest records the vendored skill snapshot provenance" '
j=/etc/pi-devbox/build-manifest.json
r=$(jq -r ".skillset_snapshot_ref // empty" $j)
s=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
echo "ref=[$r] tree_sha256=[$s]" >&2
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" \
|| { echo "skillset_snapshot_ref is not a 40-hex commit" >&2; exit 1; }
printf "%s" "$s" | grep -qxE "[0-9a-f]{64}" \
|| { echo "skillset_snapshot_tree_sha256 is not a 64-hex digest" >&2; exit 1; }
'
# Recomputes over the whole DIRECTORY with the same tree_sha256() pipeline
# Dockerfile.variant used to measure it, not a plain `sha256sum SKILL.md` —
# a file-only compare here would pass even if the manifest recorded a
# fingerprint over a directory that has since grown a second file (this is
# not hypothetical: pi-extensions already ships two files for its skill).
run "manifest skill fingerprint matches the baked snapshot" '
j=/etc/pi-devbox/build-manifest.json
d=/usr/local/share/pi-devbox/skills/mempalace
m=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
a=$( (cd "$d" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum) | sha256sum | cut -d" " -f1)
echo "manifest=[$m] actual=[$a]" >&2
[ -n "$m" ] && [ "$m" = "$a" ]
'
# OCI labels live in the image config, not the container fs — inspect them
# from the host docker rather than via `docker run`.
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
@@ -385,7 +564,37 @@ exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
# A snapshot canary must pin the NEWEST section, so update this string whenever
# the snapshot is refreshed — that is the point of it.
exec_test "mempalace skill snapshot is current" 'grep -q "Attribute what you file yourself" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
#
# v1.8.7: this fired for real, and on the release that changed the snapshot. The
# pinned phrase was "Attribute what you file yourself", the heading of the
# instruction telling agents to hand-stamp added_by — which that same release
# WITHDREW (RFC 001 §7.3.2 ranks agent-side stamping worst-possible; the bridge
# now does it). So the canary correctly reported "snapshot changed, expectation
# did not", and blocked publication of an otherwise-green build (81 passed, 1
# failed, twice). Two lessons kept in the assertion itself:
# * it is now BIDIRECTIONAL — the new phrase must be present AND the withdrawn
# one absent, so a re-vendored stale snapshot fails just as loudly as a
# forgotten bump. A one-way canary only catches half the drift.
# * a phrase canary can only ever detect "older than what I remembered to pin",
# never "older than skillset main".
#
# That structural limit is now addressed, but NOT by the "CI job diffing this
# file against the skillset repo" this comment used to point at (that pointer
# also dangled: it referenced an Unreleased changelog note that had become the
# v1.8.7 heading). A CI diff cannot be done without granting CI a credential
# for the PRIVATE skillset repo, and it would guard a file that on this fleet
# NO host reads — all four compose stacks mount a workspace containing the
# skillset, so devbox-skill-reconcile repoints this link at the live clone and
# the baked copy is a CI/no-mount fallback only. Instead the snapshot now
# carries its provenance (skillset_snapshot_ref + a measured
# skillset_snapshot_sha256 in build-manifest.json, written by
# scripts/vendor-mempalace-skill.sh), which moves the check to where the
# skillset actually IS: `scripts/vendor-mempalace-skill.sh --check` for a
# maintainer, and `pi-devbox-version` for an agent inside any container.
# This assertion is kept because it is orthogonal and free: it pins content,
# not provenance, so it still catches a re-vendored snapshot whose ref was
# bumped correctly but whose bytes came from the wrong place.
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Provenance is stamped for you" "$f" && ! grep -q "Attribute what you file yourself" "$f" && echo ok'
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
# baked tree must be what resolves, for all three vendored skills.
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
@@ -395,6 +604,30 @@ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
esac
done; echo ok'
# ... and that the tool REPORTS that resolution, which is the half that was
# missing: a stale baked snapshot and a current live clone were
# indistinguishable from inside the container. CI mounts no skillset, so every
# vendored skill must report "baked" here — which also makes this a real test of
# the fallback path rather than of the environment it happens to run in.
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; do
echo "$out" | grep -qE "^ $s +baked$" \
|| { echo "$s not reported as baked" >&2; exit 1; }
done; echo ok'
# 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
# a pre-reconcile state that is about to change.
# A bare negative (`! grep -q "skills:"`) passes if the tool crashes or
# prints nothing at all — it cannot tell "correctly omitted the section"
# apart from "the binary is broken". Anchor it positively: the command must
# still succeed and still print its normal release-tag line.
exec_test "pi-devbox-version --no-skills omits the skills section" \
'out=$(pi-devbox-version --no-skills) && echo "$out" | grep -q "^pi-devbox " && ! echo "$out" | grep -q "skills:"'
exec_test "entrypoint prints the version banner with --no-skills" \
'grep -q "pi-devbox-version --no-skills" /usr/local/bin/entrypoint-user.sh'
# The handover path itself. CI never mounts a skillset, so without this the
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
# baked-style links, run the reconciler, and assert all three outcomes —
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env bash
# vendor-mempalace-skill.sh — refresh the vendored mempalace skill snapshot
# AND its recorded provenance, together, so the two cannot drift apart.
#
# WHY THIS EXISTS
# ---------------
# rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md is a snapshot of a
# file owned by the PRIVATE skillset repo (see VENDORED.md). Because the image
# cannot clone that repo, refreshing the snapshot was a manual `cp` — and the
# result was anonymous: nothing recorded WHICH skillset commit the bytes came
# from. The only staleness check available was a hand-maintained phrase canary
# in scripts/smoke-test.sh, which by construction detects "older than the phrase
# I remembered to pin", never "older than skillset main".
#
# Two facts now travel with the snapshot: the skillset commit it was taken from
# (ARG SKILLSET_SNAPSHOT_REF in Dockerfile.variant) and the sha256 of the bytes
# themselves (measured at build time into build-manifest.json). This script is
# the only thing that should ever write the first one, because a `cp` without a
# matching ARG bump produces a manifest that CONFIDENTLY LIES — worse than the
# anonymous snapshot it replaced.
#
# HARDENED after peer review (pi@emb-7kj4vr4g, logstream correlation
# skills-provenance-review, 2026-08-26) proved the original --check could print
# OK and exit 0 without actually verifying anything: `git show <ref>:<path>`
# emits NOTHING when the ref/path doesn't resolve, and `sha256sum` still hashes
# that empty stdin, so "ref not found" silently collided with "the file really
# is 0 bytes". Depending on which side of the comparison hit the collision this
# fell through as either a false MISMATCH (blaming provenance for what was
# really an incomplete clone) or, worse, a false OK. See EXIT STATUS below —
# "cannot determine" is now its own outcome, distinct from "confirmed wrong",
# which is the same distinction the phrase canary this script replaced lacked.
#
# USAGE
# scripts/vendor-mempalace-skill.sh [skillset-root] [--force]
# refresh: rewrite the snapshot and the ARG together.
# scripts/vendor-mempalace-skill.sh --check [skillset-root]
# verify only, writes nothing. The root path and any flag may appear in
# either order — a positional-only parser previously made `<root>
# --check` silently run a refresh instead of the verification asked for.
#
# skillset-root defaults to /workspace/skillset, then $HOME/skillset.
#
# --force (refresh mode only) proceed even when the recorded ref cannot be
# proven to be an ancestor of the skillset's current HEAD — i.e.
# skip the guard against silently REWINDING provenance, which a
# detached HEAD, an older checkout, or a shallow clone lacking the
# recorded commit can all trigger. Meant to be used deliberately,
# not habitually: each use is a human deciding a rewind is fine.
#
# --check answers "is the committed snapshot really skillset@<recorded ref>?"
# — the question CI cannot answer without a credential for the private repo,
# and which anyone with the skillset checked out can answer for free.
#
# EXIT STATUS (same three codes in both modes)
# 0 the operation succeeded, or (--check) the record is verified truthful.
# This INCLUDES a truthful record that is merely stale — upstream has
# moved on since the recorded ref, or the local working tree has since
# diverged. A NOTICE is printed to stderr, but the snapshot is not being
# accused of lying, so this is not a release-blocking failure. Skipping a
# refresh is a legitimate release-day choice (see AGENTS.md); this exit
# code is what makes that choice checkable rather than merely asserted.
# 1 refused: a CONFIRMED problem. Dirty upstream file; a refresh that would
# rewind past the recorded ref; or (--check) the vendored bytes provably
# do NOT match the file at the recorded ref — a lying record.
# 2 cannot determine: the recorded ref, or the path at that ref, is not
# resolvable in this clone. Commonly a shallow clone missing history, or
# a ref that was rewritten or never pushed. Deliberately NOT the same as
# 1 — "I can't tell" must never be reported as "it's wrong".
set -euo pipefail
cd "$(dirname "$0")/.."
DOCKERFILE="Dockerfile.variant"
VENDORED="rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
ARG_NAME="SKILLSET_SNAPSHOT_REF"
REL_PATH="skills/mempalace/SKILL.md"
die() { printf '%s: %s\n' "$(basename "$0")" "$1" >&2; exit 1; }
# Parse flags and the optional root path in either order, and reject anything
# unrecognised rather than silently absorbing it.
MODE="refresh"
FORCE=0
ROOT=""
for arg in "$@"; do
case "$arg" in
--check) MODE="check" ;;
--force) FORCE=1 ;;
--*) die "unknown option: $arg" ;;
*)
[ -z "$ROOT" ] || die "unexpected extra argument: $arg (root already set to $ROOT)"
ROOT="$arg"
;;
esac
done
if [ "$MODE" = "check" ] && [ "$FORCE" = 1 ]; then
die "--force has no effect with --check (nothing is written); remove it"
fi
if [ -z "$ROOT" ]; then
for candidate in /workspace/skillset "$HOME/skillset"; do
if [ -d "$candidate/.git" ]; then
ROOT="$candidate"
break
fi
done
fi
[ -n "$ROOT" ] || die "no skillset clone found (pass one: $(basename "$0") /path/to/skillset)"
[ -d "$ROOT/.git" ] || die "not a git clone: $ROOT"
[ -f "$ROOT/$REL_PATH" ] || die "no $REL_PATH in $ROOT"
[ -f "$VENDORED" ] || die "vendored snapshot missing: $VENDORED"
[ -s "$VENDORED" ] || die "vendored snapshot is empty: $VENDORED"
head_sha=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null) || die "cannot read HEAD of $ROOT"
recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
[ -n "$recorded" ] || die "no 'ARG ${ARG_NAME}=<40-hex>' line in $DOCKERFILE"
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
vendored_sha=$(sha_of "$VENDORED")
upstream_sha=$(sha_of "$ROOT/$REL_PATH")
# Does $REL_PATH exist at HEAD at all? Proven with `cat-file -e` BEFORE
# hashing anything. Piping a failed `git show` straight into sha256sum, as
# this script used to, hashes an EMPTY stream and produces sha256(""): a real,
# collidable value — not a representation of absence. That collapsed "doesn't
# exist" and "exists and happens to be empty" into the same signal, which is
# exactly the defect class the peer review found in --check's at_ref, below.
blob_sha=""
if git -C "$ROOT" cat-file -e "HEAD:$REL_PATH" 2>/dev/null; then
blob_sha=$(git -C "$ROOT" show "HEAD:$REL_PATH" | sha256sum | cut -d' ' -f1)
fi
upstream_dirty=""
if [ -z "$blob_sha" ]; then
upstream_dirty="not present at HEAD (untracked, or absent at this commit)"
elif [ "$blob_sha" != "$upstream_sha" ]; then
if ! git -C "$ROOT" diff --quiet -- "$REL_PATH" 2>/dev/null; then
upstream_dirty="modified but not committed"
elif ! git -C "$ROOT" diff --cached --quiet -- "$REL_PATH" 2>/dev/null; then
upstream_dirty="staged but not committed"
else
upstream_dirty="different at HEAD than in the working tree"
fi
fi
if [ "$MODE" = "check" ]; then
# Resolve the recorded ref the same careful way: existence is proven with
# `cat-file -e` before anything is hashed, and "the ref itself is missing"
# is reported distinctly from "the ref resolves but the path isn't there
# at it" — both used to be silently swallowed into a plausible sha256("").
ref_exists=0
path_at_ref_exists=0
at_ref=""
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
ref_exists=1
if git -C "$ROOT" cat-file -e "${recorded}:${REL_PATH}" 2>/dev/null; then
path_at_ref_exists=1
at_ref=$(git -C "$ROOT" show "${recorded}:${REL_PATH}" | sha256sum | cut -d' ' -f1)
fi
fi
printf 'recorded ref: %s\n' "$recorded"
printf 'vendored sha256: %s\n' "$vendored_sha"
if [ "$path_at_ref_exists" = 1 ]; then
printf 'sha256 at ref: %s\n' "$at_ref"
elif [ "$ref_exists" = 1 ]; then
printf 'sha256 at ref: <%s not present at %s>\n' "$REL_PATH" "${recorded:0:7}"
else
printf 'sha256 at ref: <%s not present in this clone>\n' "${recorded:0:7}"
fi
printf 'skillset HEAD: %s (%s)\n' "$head_sha" "$upstream_sha"
if [ -n "$upstream_dirty" ]; then
printf 'live working tree: %s\n' "$upstream_dirty"
fi
rc=0
if [ "$ref_exists" != 1 ]; then
printf 'CANNOT-DETERMINE: %s is not present in %s — fetch, or check against a complete clone\n' "$recorded" "$ROOT" >&2
rc=2
elif [ "$path_at_ref_exists" != 1 ]; then
printf 'MISMATCH: %s does not exist at %s in this clone — the recorded ref cannot be describing these bytes\n' "$REL_PATH" "$recorded" >&2
rc=1
elif [ "$at_ref" != "$vendored_sha" ]; then
printf 'MISMATCH: the vendored snapshot is NOT the file at the recorded ref\n' >&2
rc=1
else
printf 'OK: the vendored snapshot is exactly skillset@%s:%s\n' "${recorded:0:7}" "$REL_PATH"
fi
# Staleness is orthogonal to truthfulness: a record can correctly describe
# an old commit even after upstream has moved on, and a dirty local working
# tree in $ROOT doesn't rewrite git history either — it says nothing about
# whether the RECORDED, committed ref describes the RECORDED, committed
# bytes. Only worth reporting once we already know rc=0 (truthful) — a
# MISMATCH or CANNOT-DETERMINE is the dominant fact and a staleness note
# would only muddy it.
if [ "$rc" = 0 ] && [ "$vendored_sha" != "$upstream_sha" ]; then
# Name the ACTUAL cause. "working tree differs" is wrong when the tree is
# clean and the ref simply moved on — a message that names the wrong cause
# is the same defect class as a canary pinned to a deleted phrase.
if [ "$recorded" != "$head_sha" ] && [ "$blob_sha" = "$upstream_sha" ]; then
printf 'NOTICE: %s has moved to %s; the snapshot describes the older %s (stale, not untruthful)\n' \
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
else
printf 'NOTICE: the working tree of %s differs from the snapshot (HEAD %s)\n' \
"$ROOT" "${head_sha:0:7}" >&2
fi
fi
exit "$rc"
fi
[ -z "$upstream_dirty" ] || die "$ROOT/$REL_PATH is $upstream_dirty — commit it first, or the recorded ref would not describe these bytes"
if [ "$vendored_sha" = "$upstream_sha" ] && [ "$recorded" = "$head_sha" ]; then
printf 'already current: snapshot == skillset@%s\n' "${head_sha:0:7}"
exit 0
fi
# Refuse to silently REWIND provenance. `git checkout <tag>`, a detached HEAD,
# or an older checkout can all leave $ROOT's HEAD behind the already-recorded
# ref; without this guard a refresh there would happily rewrite both the ARG
# and the bytes backwards and report it as an ordinary update.
if [ "$recorded" != "$head_sha" ]; then
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
if ! git -C "$ROOT" merge-base --is-ancestor "$recorded" "$head_sha" 2>/dev/null; then
if [ "$FORCE" != 1 ]; then
die "refusing: $ROOT's HEAD ($head_sha) is not a descendant of the recorded ref ($recorded) — this looks like a rewind. Pass --force if this is intentional."
fi
printf 'WARNING: --force set; %s is not an ancestor of HEAD %s — proceeding anyway\n' "${recorded:0:7}" "${head_sha:0:7}" >&2
fi
else
if [ "$FORCE" != 1 ]; then
printf 'CANNOT-DETERMINE: %s is not present in %s (shallow clone?) — fetch full history to verify this refresh moves forward, or pass --force to proceed without that guarantee\n' "$recorded" "$ROOT" >&2
exit 2
fi
printf 'WARNING: --force set; %s could not be resolved in %s — proceeding without verifying forward motion\n' "${recorded:0:7}" "$ROOT" >&2
fi
fi
# Written FROM THE REF, not copied from the working tree, so the pair cannot
# be a lie by construction. Via a temp file so a failed write cannot leave a
# half-vendored snapshot behind.
snap_tmp=$(mktemp)
if ! git -C "$ROOT" show "HEAD:$REL_PATH" > "$snap_tmp" 2>/dev/null; then
rm -f -- "$snap_tmp"
die "cannot read HEAD:$REL_PATH from $ROOT"
fi
chmod 0644 -- "$snap_tmp"
mv -- "$snap_tmp" "$VENDORED"
[ "$(sha_of "$VENDORED")" = "$blob_sha" ] \
|| die "internal: written snapshot does not match HEAD:$REL_PATH"
# In-place, and only the exact pinned line: a broad sed on this Dockerfile
# could rewrite one of the other *_REF ARGs.
tmp=$(mktemp)
sed "s|^ARG ${ARG_NAME}=.*\$|ARG ${ARG_NAME}=${head_sha}|" "$DOCKERFILE" > "$tmp"
chmod 0644 -- "$tmp"
mv -- "$tmp" "$DOCKERFILE"
new_recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
[ "$new_recorded" = "$head_sha" ] || die "failed to rewrite ${ARG_NAME} in $DOCKERFILE"
printf 'snapshot: %s -> %s\n' "${vendored_sha:0:12}" "$(sha_of "$VENDORED" | cut -c1-12)"
printf 'ref: %s -> %s\n' "${recorded:0:7}" "${head_sha:0:7}"
printf '\nNOTE: %s is hashed into base_tag, so this costs a base rebuild\n' "$VENDORED"
printf 'on the next tag (~67 min). Also re-pin the phrase canary in\n'
printf 'scripts/smoke-test.sh if the section it names changed.\n'