Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ddeaf89f | |||
| 49a6534093 | |||
| e070e0bcbf | |||
| dbb78798fb | |||
| f645e6654f | |||
| 657b1ad856 | |||
| ebd0de0be2 | |||
| 4f1aa0d0dd | |||
| 9e744d701f | |||
| 2b8c3a4db4 | |||
| cb7b8ad2ae | |||
| 93f986e90e | |||
| 26f223568d | |||
| 01abda3456 | |||
| b5810654f6 | |||
| 4f6f470518 | |||
| fbc1f86612 |
@@ -18,6 +18,19 @@ SSH_KEY_PATH=~/.ssh
|
|||||||
# the staged files and the palace dedup keys pointing at them cannot be
|
# the staged files and the palace dedup keys pointing at them cannot be
|
||||||
# separated.
|
# 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
|
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
||||||
# + native), set the URL below. When set, the extension connects over HTTP
|
# + 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
|
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ jobs:
|
|||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
outputs:
|
outputs:
|
||||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||||
|
mempalace_version: ${{ steps.resolve.outputs.mempalace_version }}
|
||||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||||
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
||||||
@@ -242,6 +243,54 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
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.
|
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||||
@@ -337,6 +386,7 @@ jobs:
|
|||||||
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
|
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 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_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
||||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||||
@@ -471,6 +521,7 @@ jobs:
|
|||||||
- name: Smoke test (amd64)
|
- name: Smoke test (amd64)
|
||||||
env:
|
env:
|
||||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
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
|
run: bash scripts/smoke-test.sh pi-devbox:smoke
|
||||||
|
|
||||||
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
||||||
@@ -533,6 +584,7 @@ jobs:
|
|||||||
- name: Smoke test studio (amd64)
|
- name: Smoke test studio (amd64)
|
||||||
env:
|
env:
|
||||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
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
|
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
|
||||||
|
|
||||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||||
|
|||||||
@@ -52,6 +52,55 @@ jobs:
|
|||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends shellcheck python3-yaml
|
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)
|
- name: Gitea shell guard (catches the actionlint blind spot)
|
||||||
# actionlint models GitHub Actions, where the default run shell is
|
# actionlint models GitHub Actions, where the default run shell is
|
||||||
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
||||||
|
|||||||
@@ -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`).
|
(`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
|
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||||
the upstream changelog to include in `CHANGELOG.md`.
|
the upstream changelog to include in `CHANGELOG.md`.
|
||||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
2. **Refresh the vendored mempalace skill snapshot if the skillset moved:**
|
||||||
3. Verify `docker compose up` works locally with the current `latest` image
|
`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
|
if you're upgrading users from a previous version. Then run the
|
||||||
**post-recreate sanity check** inside the running container to confirm
|
**post-recreate sanity check** inside the running container to confirm
|
||||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||||
@@ -73,20 +101,53 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
|
`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
|
(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.
|
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. 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 +
|
6. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||||
(amd64 + arm64) only after smoke passes. **A tag push produces two runs, not
|
(amd64 + arm64) only after smoke passes. A tag push fires **only**
|
||||||
one** — `lint.yml` fires on every push (including tag refs) and
|
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
|
||||||
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
|
excludes tag refs on purpose (the tagged tree was already linted when the
|
||||||
*Gitea API access* below for how to find it without picking lint by mistake.
|
commit hit `main`, and a fast lint run sorting above the slow publish run
|
||||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
made releases look finished before anything shipped). Verified on v1.8.4:
|
||||||
|
`refs/tags/v1.8.4` produced run 571 (publish) and nothing else. Still filter
|
||||||
|
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.
|
||||||
|
7. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||||
base-latest if the base was rebuilt this run).
|
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.jordbo.se/user/settings/applications`. N/A if you used the
|
||||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||||
its lifecycle is managed host-side, nothing to revoke.
|
its lifecycle is managed host-side, nothing to revoke.
|
||||||
|
|
||||||
|
## Verifying this repo's reality from inside a container
|
||||||
|
|
||||||
|
Most work on this repo happens **inside** a pi-devbox container, inspecting a
|
||||||
|
host or a peer over SSH. That setup manufactures convincing false negatives, so
|
||||||
|
when you are about to report that something is **absent, unreachable, or not
|
||||||
|
running**, suspect your own command first. Recurring instances:
|
||||||
|
|
||||||
|
- **`docker` is not on the host's non-interactive SSH `PATH`.** `ssh mac 'docker
|
||||||
|
ps'` says *command not found* on a host that plainly runs Docker; use
|
||||||
|
`/usr/local/bin/docker` (or `command -v docker` first). Every step in the
|
||||||
|
*Release-day checklist* that inspects a running container hits this.
|
||||||
|
- **Don't `| head -N` a search whose answer you don't already know.** The host's
|
||||||
|
`~/.ssh/config` is ~500 lines; a `head -20` "proved" a peer absent that was
|
||||||
|
defined at line 454.
|
||||||
|
- **The deployment compose file is not this repo's.** `docker-compose.yml` here
|
||||||
|
is a template pinning `:latest`; a real host runs its own per-machine file
|
||||||
|
(find it with `docker inspect <container> --format '{{ index .Config.Labels
|
||||||
|
"com.docker.compose.project.config_files" }}'`). Recreating from the repo copy
|
||||||
|
can silently move a host off `:latest-studio` onto `:latest`.
|
||||||
|
- **A live SSH ControlMaster hides remote auth changes** — after editing a
|
||||||
|
peer's `authorized_keys`, prove access with `-o ControlPath=none -o
|
||||||
|
ControlMaster=no`, or the breakage surfaces in a later session instead.
|
||||||
|
|
||||||
|
Depth and further mechanisms: the repo-authored `pi-devbox-environment` skill
|
||||||
|
(`rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md`) §2
|
||||||
|
and §3 — that file is the one an agent actually loads mid-session, whereas this
|
||||||
|
`AGENTS.md` is only auto-read when the cwd *is* this repo.
|
||||||
|
|
||||||
## Gitea API access (env token)
|
## Gitea API access (env token)
|
||||||
|
|
||||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||||
|
|||||||
+958
@@ -11,6 +11,964 @@ 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,
|
||||||
|
one a latent crash found while reviewing the first — plus the `mempalace-toolkit`
|
||||||
|
change that makes palace writes carry provenance. **No component pin moved.**
|
||||||
|
Every ref was re-resolved at tag time and is byte-identical to what v1.8.4
|
||||||
|
shipped: `pi` `0.84.2` (still npm latest), `pi-atelier` `v0.8.2` → `159f34cf`
|
||||||
|
(newest tag; the `≥0.7.1` floor for `pi ≥ 0.84` holds), `pi-studio` `v0.9.48` →
|
||||||
|
`c3b83680`, `pi-fork` `f1ff8087`, `pi-observational-memory` `ce9fc982`,
|
||||||
|
`pi-toolkit` `0e1369e6`, `pi-extensions` `20228878`, `MEMPALACE_VERSION` `3.7.1`
|
||||||
|
(still PyPI latest, and the version the central palace serves — no client/server
|
||||||
|
skew). The single moving part is `mempalace-toolkit` `fd8b15f5` → `0fe64c4`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Vendored skills no longer silently shadow their live skillset
|
||||||
|
counterparts.** `~/.agents/skills` was *asymmetric*: `mempalace`,
|
||||||
|
`pi-devbox-environment` and `pi-extensions` resolved to the baked
|
||||||
|
`/usr/local/share/pi-devbox/skills/…`, while every other skill resolved to the
|
||||||
|
live `/workspace/skillset/skills/…`. Root cause was precedence-by-ordering in
|
||||||
|
`entrypoint-user.sh`: the baked links are created **early** (line 65 in
|
||||||
|
v1.8.4; the loop moved down as this fix added comments) — deliberately so, to
|
||||||
|
close a smoke-test readiness race — with `[ ! -e … ]` so they are
|
||||||
|
"created only when absent", and the skillset deploy runs **last**, where it
|
||||||
|
classifies the existing links as foreign and leaves them alone. The comment at
|
||||||
|
line 61 claimed the goal was "a same-named skillset skill … is never
|
||||||
|
clobbered" — but with baked-first plus create-when-absent, the *skillset* skill
|
||||||
|
was precisely the one that lost. Comment now describes the actual behaviour.
|
||||||
|
|
||||||
|
Observed cost, on two hosts independently: an edit to
|
||||||
|
`skillset/skills/mempalace/SKILL.md` (adding a drawer-attribution rule) was
|
||||||
|
pushed and present in the live clone (`md5 129bcc4752`), yet both the
|
||||||
|
EMB-7KJ4VR4G and tor-ms22 containers kept loading the baked copy
|
||||||
|
(`md5 5236024fef`) with zero occurrences of the new rule. The tor-ms22 agent
|
||||||
|
had to fetch the rule from the Gitea API to read it at all. Editing a skillset
|
||||||
|
skill therefore *appeared* to work and silently did nothing until an image
|
||||||
|
rebuild — for exactly the three skills most likely to be iterated on.
|
||||||
|
|
||||||
|
**The fix is not "the skillset always wins"**, because ownership is per-skill
|
||||||
|
(`rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`): `pi-extensions`'
|
||||||
|
authoritative source is the *package* repo, copied over the snapshot at build
|
||||||
|
time, and `skillset` carries a downstream copy that can lag — handing that one
|
||||||
|
to the clone would regress the skill. So a new helper
|
||||||
|
`devbox-skill-reconcile` runs immediately after the skillset deploy and
|
||||||
|
repoints only the skills named in `skills/skillset-owned.txt` (today:
|
||||||
|
`mempalace`). Precedence is now user override → live skillset clone (owned
|
||||||
|
names only) → baked snapshot, with the early links untouched as the fallback,
|
||||||
|
so the readiness race stays closed. It only ever replaces a symlink that
|
||||||
|
points into the baked tree, so a real directory or a link pointing elsewhere
|
||||||
|
is never disturbed. Verify with `readlink -f ~/.agents/skills/mempalace`, not
|
||||||
|
by reading the entrypoint.
|
||||||
|
|
||||||
|
- **A latent boot-abort in the baked-link block, found while reviewing the fix
|
||||||
|
above and fixed with it.** `[ ! -e "$link" ]` is TRUE for a *dangling* symlink
|
||||||
|
(`-e` follows the link), so once a link may point into `/workspace/skillset`
|
||||||
|
— which the fix above makes possible — a vanished mount turns the guard into
|
||||||
|
"create over a broken link", and plain `ln -s` then fails with `File exists`.
|
||||||
|
Under the entrypoint's `set -euo pipefail` that **aborts container start**
|
||||||
|
before `exec "$@"`, with a cryptic `ln` error and no pi. Reachable on a
|
||||||
|
`docker restart` or a host reboot under `restart: unless-stopped` (the writable
|
||||||
|
layer survives and `~/.agents` is not a volume on any host), though not on a
|
||||||
|
`compose up -d` recreate. Now `ln -sfn`, which heals the broken link back to
|
||||||
|
the baked fallback; the reconciler re-points it in the same boot if the clone
|
||||||
|
is back. A comment at the call site records why the `-f` must stay.
|
||||||
|
|
||||||
|
- **README's skill-precedence documentation was wrong** in the same way the
|
||||||
|
entrypoint comment was: it claimed baked skills are "created only when absent
|
||||||
|
so a same-named skillset skill … is never clobbered" and that "a mounted
|
||||||
|
skillset always overrides them". Rewritten to state the real, per-skill
|
||||||
|
precedence and to name `skillset-owned.txt` and `devbox-skill-reconcile`.
|
||||||
|
|
||||||
|
- **The smoke canary for a stale `mempalace` snapshot could not detect
|
||||||
|
staleness.** It grepped `"Shared palace: multiple harnesses"` — a phrase
|
||||||
|
present in *both* the stale and the fresh copy, so it passed throughout the
|
||||||
|
shadowing bug above. It now pins the newest section
|
||||||
|
(`"Attribute what you file yourself"`), and `VENDORED.md` records that
|
||||||
|
updating this string is part of refreshing the snapshot. Three further
|
||||||
|
assertions close the gaps that let the bug ship: skill link **targets** are
|
||||||
|
asserted (not merely `test -L`), the `skillset-owned.txt` list is asserted to
|
||||||
|
contain `mempalace` and *not* `pi-extensions`, and the reconciler's replace
|
||||||
|
path — which CI never exercises, since no smoke container mounts a skillset —
|
||||||
|
is covered by fabricating a skillset and asserting all three outcomes (owned
|
||||||
|
skill repointed, unowned skill left baked, user override untouched) — plus a
|
||||||
|
second case that a mutation test proved necessary: with the reconciler's
|
||||||
|
"is this link ours?" guard deleted, all three of those assertions still
|
||||||
|
passed, so the discriminating case is an *owned* name whose link is a user
|
||||||
|
override pointing outside the baked tree.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Vendored `mempalace` skill snapshot refreshed** from `skillset` `936fed8` →
|
||||||
|
`670f7f1` (`md5 5236024fef` → `129bcc4752`), which adds the "Attribute what
|
||||||
|
you file yourself" rule: hand-filed drawers should carry
|
||||||
|
`added_by="<harness>@<device>"`. Without this refresh the symlink fix above
|
||||||
|
would only help hosts that mount `skillset`; a bare container would still ship
|
||||||
|
the pre-attribution-rule skill.
|
||||||
|
|
||||||
|
- **Component audit for this release — no pin edits needed.** Every component
|
||||||
|
except `pi`/`pi-atelier` is pinned to a moving ref that CI resolves at build
|
||||||
|
time, and each was checked against upstream on 2026-08-23: `pi` `0.84.2`
|
||||||
|
(still npm latest, published 2026-08-14), `pi-atelier` `v0.8.2` (newest tag;
|
||||||
|
the `≥0.7.1` floor for `pi ≥ 0.84` is satisfied), `pi-fork` `f1ff8087`,
|
||||||
|
`pi-observational-memory` `ce9fc982`, `pi-toolkit` `0e1369e6`,
|
||||||
|
`pi-extensions` `20228878`, `pi-studio` `v0.9.48` → `c3b83680` — all
|
||||||
|
**byte-identical to what v1.8.4 shipped**. `MEMPALACE_VERSION` stays `3.7.1`
|
||||||
|
(still PyPI latest, and the version the central palace serves, so no
|
||||||
|
client/server skew). The one component that moved is `mempalace-toolkit`
|
||||||
|
`fd8b15f5` → `0fe64c4`, which is this release's other payload: the feeder now
|
||||||
|
defaults `--agent` to `pi@$MEMPALACE_PI_DEVICE` so palace writes carry
|
||||||
|
provenance, with `$USER` still the fallback when the variable is unset
|
||||||
|
(`AGENT="${MEMPALACE_PI_DEVICE:+pi@${MEMPALACE_PI_DEVICE}}"`), so un-enrolled
|
||||||
|
hosts are unaffected. Nothing landed upstream after the
|
||||||
|
`pi-observational-memory` merge `ce9fc982`, so the eight-week-bug fix in
|
||||||
|
v1.8.4 is not destabilised. All of the above was re-resolved immediately
|
||||||
|
before the tag and was unchanged — worth repeating for any future release,
|
||||||
|
because six of nine components are moving refs that CI resolves at build
|
||||||
|
time, so the build, not the Dockerfile, decides what ships.
|
||||||
|
|
||||||
|
Two notes for whoever runs the build. This release changes
|
||||||
|
`entrypoint-user.sh`, `rootfs/**` and the resolved toolkit SHA — all three feed
|
||||||
|
the base-image hash — so expect a **full multi-arch base rebuild** (~95 min,
|
||||||
|
as on v1.8.3/CI 562), not a fast variant-only publish. And that rebuild
|
||||||
|
re-resolves the ~14 base-tooling `ARG *_VERSION=latest` pins; measured drift
|
||||||
|
on 2026-08-23 was one patch (`nvim v0.12.4 → v0.12.5`), so the window is
|
||||||
|
favourable, but it is not covered by version assertions.
|
||||||
|
|
||||||
|
- **`pi-devbox-environment` skill — new §2 subsection "A negative result is
|
||||||
|
usually your own filter", plus ControlMaster masking in §3.** This is baked
|
||||||
|
(`rootfs/usr/local/share/pi-devbox/skills/`, symlinked to
|
||||||
|
`~/.agents/skills/`), so it is an image-behaviour change even though no
|
||||||
|
package moved. Motivated by three false negatives an agent produced in a
|
||||||
|
single session, each from its own filter rather than from the world: a
|
||||||
|
`| head -20` "proved" an SSH peer absent that was defined at **line 454** of a
|
||||||
|
~500-line config; `ssh mac 'docker ps'` "proved" the host had no Docker, when
|
||||||
|
the non-interactive SSH `PATH` simply lacks `/usr/local/bin`; and a `grep 'ssh
|
||||||
|
'` "proved" no ControlMaster was running, when master processes **rename
|
||||||
|
themselves** to `ssh: <controlpath> [mux]`. The rule now stated: a positive
|
||||||
|
result carries its own evidence, absence has to be *earned*. §3 additionally
|
||||||
|
documents that a live master socket makes later commands authenticate **not at
|
||||||
|
all**, so "it still works" proves nothing after editing a peer's
|
||||||
|
`authorized_keys` — verify with `-o ControlPath=none -o ControlMaster=no`, or
|
||||||
|
the breakage surfaces in a future session with no memory of the edit.
|
||||||
|
- **`AGENTS.md`: a stale CI claim corrected.** It said "a tag push produces two
|
||||||
|
runs, not one — `lint.yml` fires on every push (including tag refs)". That
|
||||||
|
stopped being true when lint was scoped to `branches: ['**']`, which excludes
|
||||||
|
tag refs by design; `refs/tags/v1.8.4` produced run 571 (publish) and nothing
|
||||||
|
else. The `head_sha` + workflow-`path` filter advice stays, because it costs
|
||||||
|
nothing and any future `v*`-triggered workflow would reintroduce the
|
||||||
|
ambiguity. Also adds a short "Verifying this repo's reality from inside a
|
||||||
|
container" section, including the trap that **this repo's `docker-compose.yml`
|
||||||
|
is a template pinning `:latest`** while a real host runs its own per-machine
|
||||||
|
file — so recreating from the repo copy can silently move a host off
|
||||||
|
`:latest-studio`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Still open
|
||||||
|
|
||||||
|
- `build-manifest.json` records the `mempalace-toolkit` SHA but **not** the
|
||||||
|
mempalace **core** version, so a palace bug cannot be correlated with an
|
||||||
|
image. `mempalace --version` prints it; adding it is a one-line change to the
|
||||||
|
manifest `RUN` in `Dockerfile.variant` plus one smoke assertion, and is
|
||||||
|
variant-only (no base rebuild cost).
|
||||||
|
- Smoke asserts the `pi-observational-memory` clone **exists** but not that it
|
||||||
|
contains the ambient-auth fix. npm still ships pre-fix `3.0.4`, so an
|
||||||
|
accidental switch from the `/opt` clone to an npm install would be a silent
|
||||||
|
regression. Cheap guard: `grep -rl availability_recheck` must be ≥1.
|
||||||
|
- The feeder's new `pi@<device>` default has no behavioural test hook
|
||||||
|
(`--dry-run` never prints the agent; `--self-test` only covers the remote-mine
|
||||||
|
response classifier). Cheapest available check is a source-shape grep for
|
||||||
|
`MEMPALACE_PI_DEVICE:+pi@`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.8.4 — 2026-08-22
|
## v1.8.4 — 2026-08-22
|
||||||
|
|
||||||
Patch release, and the one that ends an eight-week bug: **the baked
|
Patch release, and the one that ends an eight-week bug: **the baked
|
||||||
|
|||||||
+8
-1
@@ -65,12 +65,19 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
- **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
|
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||||
- **imagemagick** (`magick`) — image conversion / resizing
|
- **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
|
### 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
|
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||||
- **Display**: bat, eza, htop, tree
|
- **Display**: bat, eza, htop, tree
|
||||||
- **Data**: jq, yq
|
- **Data**: jq, yq
|
||||||
|
|||||||
+44
-1
@@ -396,7 +396,48 @@ ARG INSTALL_MEMPALACE=true
|
|||||||
# (refuse the write) rather than fail open. Neither affects the container's
|
# (refuse the write) rather than fail open. Neither affects the container's
|
||||||
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
||||||
# same lock under 3.6.0.
|
# 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_DIR=/opt/uv-tools
|
||||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||||
@@ -686,12 +727,14 @@ COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
|||||||
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||||
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
||||||
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
||||||
|
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||||
/usr/local/bin/studio-expose \
|
/usr/local/bin/studio-expose \
|
||||||
/usr/local/bin/dot-watch \
|
/usr/local/bin/dot-watch \
|
||||||
/usr/local/bin/pi-devbox-version \
|
/usr/local/bin/pi-devbox-version \
|
||||||
|
/usr/local/bin/devbox-skill-reconcile \
|
||||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||||
|
|||||||
+103
-2
@@ -56,7 +56,22 @@ ARG USER_NAME=developer
|
|||||||
# current when it was first populated (shipped the same bytes for pi-devbox
|
# 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`
|
# 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.
|
# 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_TOOLKIT_REF=main
|
||||||
ARG PI_EXTENSIONS_REF=main
|
ARG PI_EXTENSIONS_REF=main
|
||||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
# 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
|
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||||
# only so its intended ref lands in the label set alongside the others.
|
# only so its intended ref lands in the label set alongside the others.
|
||||||
ARG MEMPALACE_TOOLKIT_REF=main
|
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)"
|
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||||
# and every variant INHERITS it, so both published images used to advertise
|
# 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
|
# 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.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
||||||
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
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-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
|
# 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
|
# 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; \
|
mkdir -p /etc/pi-devbox; \
|
||||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
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')"; \
|
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'; \
|
STUDIO_REV='null'; \
|
||||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
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 '{'; \
|
||||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||||
echo " \"pi_version\": \"${PI_V}\","; \
|
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 " \"components\": {"; \
|
||||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||||
|
|||||||
@@ -70,9 +70,27 @@ so `TERM=xterm-kitty` is understood. Override either in your own
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
- `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
|
- `graphviz` — `dot` rendering for diagram pipelines
|
||||||
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
- `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
|
### Language toolchains
|
||||||
|
|
||||||
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
- `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
|
diary-write, etc.) are wired into pi automatically by the pi-extensions
|
||||||
mempalace bridge.
|
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
|
## Agent skills
|
||||||
|
|
||||||
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
|
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
|
||||||
@@ -547,7 +591,8 @@ directory, and they compose:
|
|||||||
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
||||||
external mount, survive volume recreate (the source is an image path, not a
|
external mount, survive volume recreate (the source is an image path, not a
|
||||||
home dir a named volume would shadow), and are created only when absent so a
|
home dir a named volume would shadow), and are created only when absent so a
|
||||||
same-named skillset skill or user override is never clobbered. The bundled
|
user override is never clobbered. Precedence against a mounted `skillset` repo
|
||||||
|
is per-skill, not blanket — see *Skillset repo* below. The bundled
|
||||||
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
||||||
the container's persistence model, host/LAN SSH reachability, split-DNS
|
the container's persistence model, host/LAN SSH reachability, split-DNS
|
||||||
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
||||||
@@ -558,8 +603,11 @@ directory, and they compose:
|
|||||||
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
||||||
fork/recall under-utilisation). That pointer would dangle in a container
|
fork/recall under-utilisation). That pointer would dangle in a container
|
||||||
started *without* the private `skillset` repo, so the image also bakes
|
started *without* the private `skillset` repo, so the image also bakes
|
||||||
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
|
fallback copies of **`pi-extensions`** and **`mempalace`**. Whether a mounted
|
||||||
symlinked only when absent, so a mounted skillset always overrides them. The
|
skillset overrides them depends on who *owns* the skill (see *Skillset repo*):
|
||||||
|
`mempalace` is skillset-owned, so the live clone wins; `pi-extensions` is
|
||||||
|
owned by its package repo, so the baked copy keeps winning — the skillset's
|
||||||
|
copy of it is a downstream duplicate that can lag. The
|
||||||
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
||||||
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
||||||
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
||||||
@@ -573,7 +621,16 @@ directory, and they compose:
|
|||||||
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
||||||
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
||||||
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
||||||
classified as foreign-links by its `--prune-stale` pass and left untouched.
|
classified as foreign-links by its `--prune-stale` pass and left untouched —
|
||||||
|
which through v1.8.4 meant the baked copy *always* won, so an edit pushed to a
|
||||||
|
skillset-owned skill was invisible until the next image build. Since v1.8.5
|
||||||
|
`devbox-skill-reconcile` runs right after the deploy and repoints the links for
|
||||||
|
skills the skillset owns, listed in
|
||||||
|
`/usr/local/share/pi-devbox/skills/skillset-owned.txt` (today: `mempalace`).
|
||||||
|
Effective precedence, highest first: **user override** (a real directory, or a
|
||||||
|
symlink pointing outside the baked tree) → **live skillset clone** (owned names
|
||||||
|
only) → **baked snapshot** (everything else, and every skill when no skillset
|
||||||
|
is mounted). Check with `readlink -f ~/.agents/skills/<skill>`.
|
||||||
|
|
||||||
To make agents *proactively* load a baked skill at session start (rather than
|
To make agents *proactively* load a baked skill at session start (rather than
|
||||||
only on description match), the image appends a short, gated pointer to the
|
only on description match), the image appends a short, gated pointer to the
|
||||||
@@ -774,8 +831,9 @@ docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq
|
|||||||
`org.opencontainers.image.{version,revision,created}` plus
|
`org.opencontainers.image.{version,revision,created}` plus
|
||||||
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
||||||
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
|
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
|
truth** — the actual checked-out commit of each `/opt` clone, the live
|
||||||
`pi --version` — so a tag is reconstructable after CI logs rotate:
|
`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
|
```bash
|
||||||
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
||||||
|
|||||||
@@ -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-fork | github.com/elpapi42/pi-fork | MIT |
|
||||||
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
||||||
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | 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 |
|
| 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
|
## Tooling baked into the base image
|
||||||
|
|
||||||
| Component | Upstream | License (best effort) |
|
| Component | Upstream | License (best effort) |
|
||||||
|
|||||||
+33
-6
@@ -7,7 +7,11 @@ set -euo pipefail
|
|||||||
# so this reaches the same stream as the interactive shell the user lands
|
# 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
|
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
|
||||||
# with a short stderr notice on images built before it existed.
|
# 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 ────────────────────────────────
|
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||||
@@ -58,10 +62,17 @@ fi
|
|||||||
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
||||||
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
||||||
# anything baked under a home dir, which a named volume would shadow). Created
|
# anything baked under a home dir, which a named volume would shadow). Created
|
||||||
# only when absent, so a same-named skillset skill (deployed later, at the end
|
# only when absent, so a user override is never clobbered.
|
||||||
# of this script) or a user override is never clobbered; the skillset deploy
|
#
|
||||||
# classifies these as foreign-links and its --prune-stale pass leaves them
|
# NB: "created only when absent" does NOT hand a same-named skillset skill
|
||||||
# alone (only dangling symlinks are pruned).
|
# priority — the opposite. The skillset deploy runs at the end of this script
|
||||||
|
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
|
||||||
|
# always won and an edit pushed to a skillset-owned skill was invisible until
|
||||||
|
# the next image build. The links below are therefore the FALLBACK only;
|
||||||
|
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
|
||||||
|
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
|
||||||
|
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
|
||||||
|
# keep losing to the baked copy.
|
||||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||||
mkdir -p "$HOME/.agents/skills"
|
mkdir -p "$HOME/.agents/skills"
|
||||||
@@ -69,7 +80,16 @@ if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
|||||||
[ -d "$_sk" ] || continue
|
[ -d "$_sk" ] || continue
|
||||||
_skname=$(basename "$_sk")
|
_skname=$(basename "$_sk")
|
||||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||||
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
|
||||||
|
# link), and since v1.8.5 these links can point into /workspace/skillset
|
||||||
|
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
|
||||||
|
# mount vanishes while the writable layer survives — a `docker restart` or
|
||||||
|
# a host reboot under restart: unless-stopped, as opposed to a recreate —
|
||||||
|
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
|
||||||
|
# container start before `exec "$@"`. With -f the broken link heals back to
|
||||||
|
# the baked fallback, and the reconciler re-points it in the same boot if
|
||||||
|
# the clone is back.
|
||||||
|
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -385,6 +405,13 @@ elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
|||||||
fi
|
fi
|
||||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||||
|
# The deploy leaves the early baked links (above) in place as foreign links,
|
||||||
|
# which silently shadows the live clone for skills the skillset OWNS. Repoint
|
||||||
|
# just those; baked stays the fallback, user overrides still win. `|| true`:
|
||||||
|
# a skill-link refinement must never break container start.
|
||||||
|
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
|
||||||
|
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Execute command ──────────────────────────────────────────────────
|
# ── Execute command ──────────────────────────────────────────────────
|
||||||
|
|||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# devbox-skill-reconcile — hand skillset-OWNED skills back to the live clone.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# entrypoint-user.sh links the image-baked skills into ~/.agents/skills/ EARLY
|
||||||
|
# (before pi-deploy), because the smoke readiness probe gates on markers that
|
||||||
|
# only land later, and a link created after that gate produced a flaky
|
||||||
|
# assertion. Those links are created with a `[ ! -e ]` guard — "only when
|
||||||
|
# absent" — and the skillset deploy runs LAST, treating already-present links
|
||||||
|
# as foreign and leaving them alone. Net effect through v1.8.4: the baked copy
|
||||||
|
# always won, so an edit pushed to a skillset-owned skill was invisible in
|
||||||
|
# every container until the next image build (measured on two hosts: live
|
||||||
|
# skillset md5 129bcc4752 vs baked 5236024fef, the new section absent).
|
||||||
|
#
|
||||||
|
# The fix is NOT "the skillset always wins". Ownership is per-skill (see
|
||||||
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md):
|
||||||
|
#
|
||||||
|
# pi-devbox-environment authored in pi-devbox → baked IS canonical
|
||||||
|
# pi-extensions owned by the package repo, copied over the snapshot
|
||||||
|
# at build time; skillset carries a DOWNSTREAM copy
|
||||||
|
# that can lag → baked must keep winning
|
||||||
|
# mempalace owned by the skillset repo; baked is a snapshot
|
||||||
|
# fallback for containers with no skillset mounted
|
||||||
|
# → the live clone must win when it is present
|
||||||
|
#
|
||||||
|
# So only skills listed in skills/skillset-owned.txt are handed over. Baked
|
||||||
|
# links stay as the fallback (the early-link race fix is untouched), and a user
|
||||||
|
# override always beats both: a real directory is never replaced, and neither is
|
||||||
|
# a symlink that already points somewhere other than the baked tree.
|
||||||
|
#
|
||||||
|
# Usage: devbox-skill-reconcile <skillset-root> [skills-dir] [baked-src]
|
||||||
|
# skillset-root the mounted skillset repo (contains skills/<name>/)
|
||||||
|
# skills-dir default $HOME/.agents/skills
|
||||||
|
# baked-src default /usr/local/share/pi-devbox/skills
|
||||||
|
#
|
||||||
|
# Idempotent, and silent unless it changes something. Exits 0 when there is
|
||||||
|
# nothing to do (no skillset, no list) so the entrypoint never fails on it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SKILLSET_ROOT="${1:-}"
|
||||||
|
SKILLS_DIR="${2:-$HOME/.agents/skills}"
|
||||||
|
BAKED_SRC="${3:-/usr/local/share/pi-devbox/skills}"
|
||||||
|
BAKED_SRC="${BAKED_SRC%/}" # a trailing slash would make the prefix
|
||||||
|
# match below ("$BAKED_SRC"/*) match nothing
|
||||||
|
|
||||||
|
[ -n "$SKILLSET_ROOT" ] || exit 0
|
||||||
|
[ -d "$SKILLSET_ROOT/skills" ] || exit 0
|
||||||
|
[ -d "$SKILLS_DIR" ] || exit 0
|
||||||
|
|
||||||
|
# Absolutise BOTH roots before they are used, because each has its own way of
|
||||||
|
# failing silently when relative: a relative symlink TARGET is resolved against
|
||||||
|
# the link's directory (~/.agents/skills), not $PWD, so it would dangle on
|
||||||
|
# creation; and a relative BAKED_SRC would never prefix-match the absolute
|
||||||
|
# target that `readlink` reports, so every skill would be skipped and the fix
|
||||||
|
# would look like it had simply done nothing.
|
||||||
|
SKILLSET_ROOT=$(CDPATH= cd -- "$SKILLSET_ROOT" 2>/dev/null && pwd) || exit 0
|
||||||
|
BAKED_SRC=$(CDPATH= cd -- "$BAKED_SRC" 2>/dev/null && pwd) || exit 0
|
||||||
|
OWNED_LIST="$BAKED_SRC/skillset-owned.txt"
|
||||||
|
[ -f "$OWNED_LIST" ] || exit 0
|
||||||
|
|
||||||
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||||
|
# strip comments and surrounding whitespace; skip blanks
|
||||||
|
_name=$(printf '%s\n' "$_line" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
|
[ -n "$_name" ] || continue
|
||||||
|
# defensive: a list entry must be a plain skill name, never a path
|
||||||
|
case "$_name" in */*|.*) continue ;; esac
|
||||||
|
|
||||||
|
_live="$SKILLSET_ROOT/skills/$_name"
|
||||||
|
_link="$SKILLS_DIR/$_name"
|
||||||
|
|
||||||
|
# the skillset does not ship it → the baked fallback is all there is
|
||||||
|
[ -d "$_live" ] || continue
|
||||||
|
# a real directory is a user override → never touch
|
||||||
|
[ -L "$_link" ] || continue
|
||||||
|
|
||||||
|
# only ever replace OUR OWN link. readlink is deliberate: `readlink -f`
|
||||||
|
# would resolve a link that already points into the skillset clone and,
|
||||||
|
# since both trees hold a same-named skill, could not tell them apart.
|
||||||
|
_target=$(readlink "$_link" 2>/dev/null || true)
|
||||||
|
case "$_target" in
|
||||||
|
"$BAKED_SRC"/*|"$BAKED_SRC") ;; # baked link → ours to replace
|
||||||
|
*) continue ;; # user/foreign target → leave alone
|
||||||
|
esac
|
||||||
|
|
||||||
|
# -n so an existing symlink-to-directory is replaced rather than followed
|
||||||
|
# (without it, ln would create $_link/$_name inside the baked tree).
|
||||||
|
if ln -sfn "$_live" "$_link" 2>/dev/null; then
|
||||||
|
printf 'skill %s: baked snapshot -> live skillset (%s)\n' "$_name" "$_live"
|
||||||
|
fi
|
||||||
|
done < "$OWNED_LIST"
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
# pi-devbox-version human-readable summary (default)
|
# pi-devbox-version human-readable summary (default)
|
||||||
# pi-devbox-version --json raw manifest JSON (for scripting)
|
# pi-devbox-version --json raw manifest JSON (for scripting)
|
||||||
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
|
# 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
|
# EXIT STATUS
|
||||||
# 0 on success. 1 if the manifest is missing (e.g. an image built before
|
# 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
|
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||||
MODE="human"
|
MODE="human"
|
||||||
|
SHOW_SKILLS="yes"
|
||||||
|
|
||||||
case "${1:-}" in
|
# A `case "${1:-}"` here only ever looked at the FIRST argument, so
|
||||||
--json) MODE="json" ;;
|
# `--no-skills --json` matched --no-skills, silently dropped --json, and
|
||||||
--quiet|-q) MODE="quiet" ;;
|
# printed human text to a caller expecting JSON (a real failure: a jq
|
||||||
--help|-h)
|
# consumer piping that output gets a parse error, not a wrong-but-parseable
|
||||||
sed -n '2,20p' "$0" | sed 's/^# \?//'
|
# answer). Loop over every argument instead, and reject anything unknown
|
||||||
exit 0
|
# rather than silently ignoring it the same way.
|
||||||
;;
|
for _arg in "$@"; do
|
||||||
esac
|
case "$_arg" in
|
||||||
|
--json) MODE="json" ;;
|
||||||
|
--quiet|-q) MODE="quiet" ;;
|
||||||
|
--no-skills) SHOW_SKILLS="no" ;;
|
||||||
|
--help|-h)
|
||||||
|
# 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
|
if [ ! -f "$MANIFEST" ]; then
|
||||||
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
|
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")
|
build_date=$(jq -r '.build_date' "$MANIFEST")
|
||||||
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
||||||
pi_version_baked=$(jq -r '.pi_version' "$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
|
if [ "$MODE" = "quiet" ]; then
|
||||||
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
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')
|
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
||||||
fi
|
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 'pi-devbox %s\n' "$release_tag"
|
||||||
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
||||||
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
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}"
|
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
||||||
fi
|
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'
|
printf ' components:\n'
|
||||||
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
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
|
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
|
cat > "$CONFIG" <<EOF
|
||||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
# 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>
|
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||||
@@ -216,6 +255,7 @@ ${JUMP_BLOCK}
|
|||||||
${LAN_CONF_BLOCK}
|
${LAN_CONF_BLOCK}
|
||||||
${AUTOJUMP_BLOCK}
|
${AUTOJUMP_BLOCK}
|
||||||
${INCLUDE_BLOCK}
|
${INCLUDE_BLOCK}
|
||||||
|
${MULTIPLEX_DEFAULT_BLOCK}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Vendored fallback skills
|
# Vendored fallback skills
|
||||||
|
|
||||||
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
||||||
symlinks into `~/.agents/skills/` on container start (only when a skill of the
|
symlinks into `~/.agents/skills/` on container start. They are the **fallback**
|
||||||
same name is not already present, so a mounted `skillset` repo or a user
|
layer: see *Runtime precedence* below for which copy actually wins when a
|
||||||
override always wins).
|
`skillset` repo is mounted (through v1.8.4 the answer was "always the baked
|
||||||
|
one", which was a bug).
|
||||||
|
|
||||||
| skill | owner | how it gets here |
|
| skill | owner | how it gets here |
|
||||||
|-------|-------|------------------|
|
|-------|-------|------------------|
|
||||||
@@ -38,16 +39,108 @@ its skill file needed baking.
|
|||||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||||
package source to copy from. This snapshot is refreshed manually per release.
|
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,
|
||||||
|
to close a smoke readiness race) with a create-only-when-absent guard, and the
|
||||||
|
skillset deploy runs **last** and treats them as foreign links. Through v1.8.4
|
||||||
|
that combination meant the baked snapshot always won: an edit pushed to
|
||||||
|
`skillset/skills/mempalace/SKILL.md` was invisible in every container until the
|
||||||
|
next image build (measured on two hosts — live `md5 129bcc4752` vs baked
|
||||||
|
`5236024fef`, new section absent). Editing those skills *appeared* to work.
|
||||||
|
|
||||||
|
`devbox-skill-reconcile` now runs immediately after the skillset deploy and
|
||||||
|
repoints the links for skills the **skillset owns**, listed one per line in
|
||||||
|
`skillset-owned.txt`. Precedence, highest first:
|
||||||
|
|
||||||
|
1. **user override** — a real directory, or a symlink pointing outside the baked
|
||||||
|
tree; never touched by anything
|
||||||
|
2. **live skillset clone** — but only for names in `skillset-owned.txt`
|
||||||
|
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
|
||||||
|
skill. Only `mempalace` is skillset-owned today.
|
||||||
|
|
||||||
|
Verify with `readlink -f ~/.agents/skills/<skill>` — not by reading the
|
||||||
|
entrypoint. Smoke covers both directions (baked resolution with no skillset
|
||||||
|
mounted, plus a fabricated-skillset run of the reconciler).
|
||||||
|
|
||||||
## Refreshing the snapshots
|
## Refreshing the snapshots
|
||||||
|
|
||||||
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||||
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
|
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
|
Copy `pi-extensions` **from its owner in the table above** — the package
|
||||||
the package repo's `skill/` (since `a7f3044` co-located it there; `skillset`
|
repo's `skill/` (since `a7f3044` co-located it there; `skillset` also carries a
|
||||||
also carries a copy, but it is a downstream duplicate and can lag), and
|
copy, but it is a downstream duplicate and can lag). Copying `pi-extensions`
|
||||||
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
from `skillset` would regress the snapshot to whatever that repo last mirrored.
|
||||||
regress the snapshot to whatever that repo last mirrored.
|
|
||||||
|
|
||||||
Snapshot provenance at last refresh: skillset `936fed8`, 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
|
||||||
|
**newest** section, because the previous canary grepped a phrase that survived
|
||||||
|
the very edit that made the snapshot stale, and so passed on stale content.
|
||||||
|
|||||||
@@ -41,6 +41,21 @@ Run these immediately when a session begins, before responding to the user:
|
|||||||
mempalace_kg_query(entity="<project_or_person>")
|
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.
|
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||||
|
|
||||||
### Temporal grounding — compute time deltas, don't guess
|
### 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.
|
**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
|
#### Mine New Projects
|
||||||
|
|
||||||
When working on a new codebase for the first time:
|
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>")
|
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
|
## Palace Structure
|
||||||
|
|
||||||
### Wings
|
### Wings
|
||||||
@@ -290,12 +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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
### Rooms
|
||||||
@@ -329,10 +571,15 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
|||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
|
|
||||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
- **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 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 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 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 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 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 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`.
|
||||||
|
|||||||
@@ -130,6 +130,36 @@ are "command not found" there — you must spell out the underlying command.
|
|||||||
If a command "works in my terminal but not when the agent runs it," this alias
|
If a command "works in my terminal but not when the agent runs it," this alias
|
||||||
gap is the first thing to suspect.
|
gap is the first thing to suspect.
|
||||||
|
|
||||||
|
### A negative result is usually your own filter
|
||||||
|
|
||||||
|
**When you are about to report that something is absent, unreachable, or not
|
||||||
|
running, the filter you wrote is the prime suspect — not the thing.** This
|
||||||
|
environment produces false negatives cheaply, and they are convincing because
|
||||||
|
the command "succeeded". Three real instances from one session, all wrong, all
|
||||||
|
mine:
|
||||||
|
|
||||||
|
| Claim I made | Why it was false |
|
||||||
|
|---|---|
|
||||||
|
| "`tor-ms22` is not in the SSH config" | `grep … \| head -20` — the entry was at **line 454**. `~/.ssh/config` here is ~500 lines. |
|
||||||
|
| "the Docker host has no `docker`" | non-interactive SSH `PATH` lacks `/usr/local/bin` (§2, §3). It was at `/usr/local/bin/docker`. |
|
||||||
|
| "no ControlMaster is running" | pattern `ssh ` (trailing space) cannot match a master: those processes **rename themselves** to `ssh: <controlpath> [mux]`. |
|
||||||
|
|
||||||
|
Habits that would have caught all three:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# don't cap the output of a search whose answer you don't already know
|
||||||
|
grep -n -i -A6 'tor-ms22' ~/.ssh/config # not | head -20
|
||||||
|
|
||||||
|
# on the host, resolve the binary instead of trusting PATH
|
||||||
|
ssh -F "$HOME/.ssh-local/config" mac 'command -v docker || ls /usr/local/bin/docker'
|
||||||
|
|
||||||
|
# match a process's ACTUAL argv, not the name you imagine
|
||||||
|
ps -eo pid,etime,args | grep -Ei 'mux|mosh|ssh'
|
||||||
|
```
|
||||||
|
|
||||||
|
A positive result needs no such scepticism — it carries its own evidence. Only
|
||||||
|
absence has to be *earned*, so spend the extra command there.
|
||||||
|
|
||||||
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
|
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
|
||||||
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
|
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
|
||||||
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
|
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
|
||||||
@@ -155,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`
|
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
|
||||||
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
|
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
|
||||||
can't be created under it), plus `Include ~/.ssh/config`.
|
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
|
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
|
||||||
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
|
`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
|
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
|
||||||
@@ -169,12 +209,48 @@ 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)
|
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):
|
Two related mechanisms (don't reinvent them):
|
||||||
|
|
||||||
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
|
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
|
||||||
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
||||||
a `ControlPath` under the read-only `~/.ssh`, override with
|
a `ControlPath` under the read-only `~/.ssh`, override with
|
||||||
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
||||||
|
- **A live master socket MASKS auth and config changes on the far end.** Once
|
||||||
|
`~/.ssh-local/cm/<user>@<host>:22` exists, later commands ride it and
|
||||||
|
authenticate **not at all** — so after editing remote `authorized_keys`,
|
||||||
|
`sshd_config`, host keys, or firewall rules, "it still works" proves nothing.
|
||||||
|
A corrupted `authorized_keys` then bites on the next *cold* connect, likely in
|
||||||
|
a future session with no memory of the edit. Prove it immediately instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -O check <host> # 'Master running (pid=N)'
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -o ControlPath=none -o ControlMaster=no \
|
||||||
|
-o BatchMode=yes <host> 'echo COLD AUTH OK'
|
||||||
|
```
|
||||||
|
|
||||||
|
To attribute a socket rather than guess whose it is: `ps -p <pid> -o
|
||||||
|
pid,ppid,lstart,etime,args`. A `mosh` the *user* started on the host
|
||||||
|
bootstraps with the **host's** `~/.ssh/cm/` and is invisible from in here;
|
||||||
|
only a mosh started *inside* the container shares `~/.ssh-local/cm/`.
|
||||||
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
||||||
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
||||||
skill for that path.
|
skill for that path.
|
||||||
@@ -257,6 +333,10 @@ hardcode. Details are in the `mempalace` skill.
|
|||||||
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
||||||
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
||||||
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
||||||
|
- [ ] About to report something **absent / unreachable / not running**? → re-run
|
||||||
|
without your own `head`/pattern/`PATH` assumptions first (§2).
|
||||||
|
- [ ] Changed remote `authorized_keys` / `sshd_config`? → prove it with a **cold**
|
||||||
|
connect; a live master socket hides breakage (§3).
|
||||||
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
||||||
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
||||||
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Skills in this directory whose OWNER is the skillset repo.
|
||||||
|
#
|
||||||
|
# Read by devbox-skill-reconcile, which runs after the skillset deploy in
|
||||||
|
# entrypoint-user.sh: for each name below, if the mounted skillset ships a
|
||||||
|
# skill of that name, the baked link in ~/.agents/skills/ is repointed at the
|
||||||
|
# live clone. The baked copy remains the fallback for containers started
|
||||||
|
# WITHOUT a skillset mount, and a user override always wins over both.
|
||||||
|
#
|
||||||
|
# Add a name here ONLY if the skillset repo is the authoritative source (see
|
||||||
|
# the ownership table in VENDORED.md). Do NOT add:
|
||||||
|
# pi-devbox-environment — authored in this repo; baked IS canonical
|
||||||
|
# pi-extensions — owned by the pi-extensions package repo and copied
|
||||||
|
# over the snapshot at build time; the skillset copy
|
||||||
|
# is a downstream duplicate that can lag, so letting
|
||||||
|
# it win would regress the skill.
|
||||||
|
mempalace
|
||||||
+301
-18
@@ -5,8 +5,9 @@
|
|||||||
#
|
#
|
||||||
# Verifies:
|
# Verifies:
|
||||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
# - 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)
|
# - 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)
|
# - non-modal editors nano + micro (alongside nvim)
|
||||||
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
|
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
|
||||||
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
|
# 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
|
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||||||
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
|
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
|
# Regression guard for the pi transcript exporter. If pi ever changes its
|
||||||
# session JSONL shape, the exporter stops recognising sessions and the palace
|
# session JSONL shape, the exporter stops recognising sessions and the palace
|
||||||
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
|
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
|
||||||
@@ -235,6 +263,16 @@ run "image-baked mempalace fallback skill" \
|
|||||||
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
||||||
run "pi-extensions skill refreshed from package when present" \
|
run "pi-extensions skill refreshed from package when present" \
|
||||||
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
||||||
|
# Runtime ownership handover (v1.8.5): the baked links are a FALLBACK, and
|
||||||
|
# skillset-OWNED skills must be repointed at the live clone when one is mounted.
|
||||||
|
# The list is data, so assert its content, not just its presence: mempalace in,
|
||||||
|
# pi-extensions deliberately out (its skillset copy is a lagging duplicate).
|
||||||
|
run "devbox-skill-reconcile helper present + executable" \
|
||||||
|
"test -x /usr/local/bin/devbox-skill-reconcile"
|
||||||
|
run "skillset-owned list ships and names mempalace" \
|
||||||
|
"grep -qx 'mempalace' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
run "skillset-owned list excludes pi-extensions (ownership)" \
|
||||||
|
"! grep -qx 'pi-extensions' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
|
||||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -253,6 +291,26 @@ run "pi-fork clone + node_modules" \
|
|||||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||||
run "pi-observational-memory clone + 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"
|
"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 —
|
# pi-atelier: deliberately NO node_modules assertion, unlike its siblings —
|
||||||
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
|
# 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.
|
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
|
||||||
@@ -291,26 +349,157 @@ echo ""
|
|||||||
echo "── Build provenance ──"
|
echo "── Build provenance ──"
|
||||||
run "/etc/pi-devbox/build-manifest.json present" \
|
run "/etc/pi-devbox/build-manifest.json present" \
|
||||||
"test -f /etc/pi-devbox/build-manifest.json"
|
"test -f /etc/pi-devbox/build-manifest.json"
|
||||||
run_expect "manifest records pi-extensions component" \
|
# These next checks replace three that grepped the manifest for the FIELD NAME
|
||||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
# and never looked at the value:
|
||||||
run_expect "manifest records pi-atelier" \
|
#
|
||||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
|
# run_expect "manifest records pi_version" "cat …manifest.json" '"pi_version"'
|
||||||
run_expect "manifest records pi_version" \
|
#
|
||||||
"cat /etc/pi-devbox/build-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
|
# 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.
|
# non-studio variant) — now enforced by the 40-hex value check above, which
|
||||||
run "manifest has no unresolved ('unknown') components" \
|
# strictly subsumes the old whole-file grep for '"unknown"'. Only rev() ever
|
||||||
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
# emits "unknown" and rev() feeds components only, so nothing is lost.
|
||||||
# pi-devbox-version wraps the manifest into a human-first command (this
|
# pi-devbox-version wraps the manifest into a human-first command; verify the
|
||||||
# PR); verify the binary is present, executable, and both output modes work.
|
# binary is present, executable, and that all three output modes work.
|
||||||
run "pi-devbox-version binary present + executable" \
|
run "pi-devbox-version binary present + executable" \
|
||||||
"test -x /usr/local/bin/pi-devbox-version"
|
"test -x /usr/local/bin/pi-devbox-version"
|
||||||
run_expect "pi-devbox-version human output shows release tag" \
|
run_expect "pi-devbox-version human output shows release tag" \
|
||||||
"pi-devbox-version" "pi-devbox "
|
"pi-devbox-version" "pi-devbox "
|
||||||
run_expect "pi-devbox-version --json round-trips the manifest" \
|
# --json is a verbatim `cat` of the manifest, so "round-trips" is assertable
|
||||||
"pi-devbox-version --json" '"release_tag"'
|
# 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" \
|
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||||||
"pi-devbox-version --quiet | wc -l" "1"
|
"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
|
# OCI labels live in the image config, not the container fs — inspect them
|
||||||
# from the host docker rather than via `docker run`.
|
# 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)
|
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||||||
@@ -369,10 +558,104 @@ exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills
|
|||||||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||||||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
||||||
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). It silently shadows the
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
||||||
# skillset copy in a devbox container, so a stale snapshot is invisible: assert
|
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||||||
# the multi-machine shared-palace guidance is actually present, not just the file.
|
# canary that was supposed to catch it could not: it grepped "Shared palace:
|
||||||
exec_test "mempalace skill snapshot is current" 'grep -q "Shared palace: multiple harnesses" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
# 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.
|
||||||
|
#
|
||||||
|
# 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)" \
|
||||||
|
'for s in mempalace pi-extensions pi-devbox-environment; do
|
||||||
|
case "$(readlink -f $HOME/.agents/skills/$s)" in
|
||||||
|
/usr/local/share/pi-devbox/skills/$s) ;;
|
||||||
|
*) 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 —
|
||||||
|
# owned skill repointed, unowned skill left baked, user override untouched.
|
||||||
|
exec_test "reconciler: owned skill handed to live clone, others untouched" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/ss/skills/pi-extensions $t/skills
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo LIVE > $t/ss/skills/pi-extensions/SKILL.md
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/mempalace $t/skills/mempalace
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/pi-extensions $t/skills/pi-extensions
|
||||||
|
mkdir -p $t/skills/mine; echo MINE > $t/skills/mine/SKILL.md
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null # idempotent
|
||||||
|
[ "$(readlink $t/skills/mempalace)" = "$t/ss/skills/mempalace" ] || { echo "owned skill NOT repointed" >&2; exit 1; }
|
||||||
|
[ "$(readlink $t/skills/pi-extensions)" = /usr/local/share/pi-devbox/skills/pi-extensions ] || { echo "unowned skill was repointed" >&2; exit 1; }
|
||||||
|
[ "$(cat $t/skills/mine/SKILL.md)" = MINE ] || { echo "user override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
|
# The case above cannot fail if the reconciler stops checking WHERE a link
|
||||||
|
# points — a mutation test showed all three of its assertions still passing with
|
||||||
|
# that guard deleted, which is the same false-green shape as the old snapshot
|
||||||
|
# canary. This one discriminates: an OWNED name (so it is considered) whose link
|
||||||
|
# is a user override pointing outside the baked tree (so it must be left alone).
|
||||||
|
exec_test "reconciler: user override on an owned name is left alone" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/skills $t/mine-skill
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo USERLINK > $t/mine-skill/SKILL.md
|
||||||
|
ln -sfn $t/mine-skill $t/skills/mempalace
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
[ "$(cat $t/skills/mempalace/SKILL.md)" = USERLINK ] || { echo "user symlink override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
|
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
|
||||||
# had one since they were added, so this asserts the set stays complete.
|
# had one since they were added, so this asserts the set stays complete.
|
||||||
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
|
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
|
||||||
|
|||||||
Executable
+269
@@ -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'
|
||||||
Reference in New Issue
Block a user