Skip to content

release: v0.6.0 - #936

Merged
ryaneggz merged 23 commits into
mainfrom
development
Sep 1, 2026
Merged

release: v0.6.0#936
ryaneggz merged 23 commits into
mainfrom
development

Conversation

@ryaneggz

@ryaneggz ryaneggz commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Promotes development to main to cut v0.6.0.

Merging this pushes main, which triggers .github/workflows/release.yml: it reserves the v0.6.0 tag from root package.json, publishes the GHCR image and the CLI, and publishes the GitHub Release from the ## [0.6.0] changelog section.

Release prep in this PR

  • Bump root package.json 0.5.10.6.0.
  • Promote the ## [Unreleased] body to ## [0.6.0] - 2026-08-31, consolidating duplicated category headings into one block per category. All 70 entries preserved verbatim; a fresh empty ## [Unreleased] heads the file.

Why a minor bump

0.x with breaking changes: this window retires the prime agent, the spec agent handoff, cron worktree isolation, OH_IMAGE_ONLY, the hermes-dashboard overlay, and the baked-in coding harnesses, and moves repository knowledge to a tracked .oh/knowledge/ surface.

Scope — 22 commits since v0.5.1

Full list in CHANGELOG.md.

Verification

  • CI green on development (CI: Harness, CI: Sandbox Boot Guard).
  • .oh/evals/probes/changelog-entry-length.sh — PASS.
  • No v0.6.0 tag exists; ## [0.6.0] section present and matches package.json.

main carries two prior merge commits, so this lands as a merge commit like #894 and #895, not a fast-forward.

The runner extracts `# tier:`, `# source:`, and `# desc:` with a fixed grep, so
a probe missing `# source:` leaves that column empty in RESULTS.md and loses the
only record of which lesson it closes.

Rebased across the one-door migration, which moved the target set: the header
written for `env-schema-parity.sh` now belongs to its successor
`config-schema-parity.sh`, `oh-init-headless-config.sh` picks up the retarget to
`oh.json`, and `oh-config-surfaces.sh` — added by #887 after this branch opened —
was the fifth probe still missing one.

Closes #889
/delegate steps 4 and 5b instructed TaskCreate/TaskUpdate from
.oh/skills/, the canonical pack symlinked into .claude, .codex and .pi.
Codex and Pi never had those tools, and Claude Code 2.1.233 stopped
providing them by default on current models, so the step was a silent
no-op: the wave graph degraded to prose held in a context window that
/spec execute later compacts.

Steps 4 and 5b now write delegate-graph.json and delegate-log.txt under
.oh/tasks/<slug>/, beside but never overwriting the Advisor-owned
prd.json and progress.txt, and a resumed run re-runs only unfinished
tasks. The fix is provider-neutral and needs no environment flag.

Add skills-task-tool-coupling.sh, a two-sided tier-A invariant: a
canonical skill may instruct the task tools only while the sandbox
enables them, and the sandbox may enable them only while a skill needs
them. Both failure directions were exercised before commit.

Also drops a dangling 'skip to Step 9' in the rewritten block; the
skill has eight steps.

Closes #886
* task: collapse sandbox storage to one $HOME mount

Replace the eleven per-tool named volumes with a single mount at
/home/sandbox, selectable as a Docker-managed volume (<name>_workspace)
or a host bind via storage.homePath in oh.json.

The image's home is moved to /opt/home-seed at build time so an empty
named volume never auto-copies; entrypoint's seed_home restores it into
either mount kind with cp -a -n, which also backfills new dotfiles on an
image upgrade without clobbering operator state.

Retire the projectRoot/OH_PROJECT_ROOT config knob: the checkout is
fixed at /home/sandbox/harness, nested inside the home mount.

Replace the UID-sync 'find -xdev' with an explicit $OH_PROJECT_ROOT
prune. -xdev only pruned the checkout because /home/sandbox was the
container overlay; once it is a mount they share a device.

* task: retarget the herdr image-docs persistence assertion at the single home mount

* task: address audit findings on the single-$HOME-mount change

seed_home no longer uses 'cp -a -n'. That form skips existing regular
files but still rewrites the mode, owner and mtime of directories the
destination already has, so every boot relaxed ~/.ssh from 0700 to the
image's 0755 and reset the other pre-existing home directories likewise.
It now walks the seed and copies only entries the mount lacks, creating a
missing directory with the seed's own metadata and never touching an
existing path. It also returns non-zero on a real copy failure instead of
swallowing it, and the caller warns.

oh destroy branches on storage.homePath rather than appending a
correction: with a host bind, nothing is deleted and sign-in does not
start over, so the volume list and that warning are wrong to print. The
volume list is now project-prefixed to match 'docker volume ls'.

Reject reserved host paths for storage.homePath. The ownership repair is
now an unbounded recursive chown over the mount, so '/', '/home' or
'/usr' would hand the sandbox uid a host tree.

Fix the migration recipe: 'docker cp <name>:/home/sandbox <dir>' lands
the copy at <dir>/sandbox when <dir> exists. Use the trailing '/.' form
and drop the checkout copy that docker cp reads through the bind mount.

Document that storage.homePath is invisible to VS Code 'Reopen in
Container', which would otherwise give a second, silently diverging home.

Harden oh-home-mount.sh with the directory-metadata and copy-failure
cases that let the original bug through, plus the missing rm -rf
assertion; scope the -xdev ban to lines mentioning /home/sandbox.

Refresh the stale project-root seam section in .oh/README.md, which still
described the removed knob and cited the deleted probe.

* task: drop local agent memory and cron runtime state committed by mistake

* task: seed the home mount per top-level entry, not per file

A live boot on a real image caught the previous form: walking the whole
seed tree and copying file by file spends its time in the uv cache under
~/.cache/uv, and it aborted partway through with EEXIST, leaving a home
with four of sixteen entries.

seed_home now copies each top-level entry the mount lacks with a single
'cp -a', and never touches one it already has. Verified on a live boot:
a clean volume gets all sixteen entries in 7s, ~/.ssh ends at 0700, and
an operator edit to ~/.zshrc plus a 0700 ~/.ssh both survive a restart.

The trade is explicit and documented: a new file inside a directory the
mount already has is not merged in. That matches what the per-tool
volumes did, while a new top-level entry from an image upgrade still
arrives. oh-home-mount.sh asserts the contract in both directions and now
rejects the two wrong implementations - 'cp -a -n' and the whole-tree
walk - rather than only the first.

Bake ~/.ssh at 0700 in the image so the seeded mode is right before the
entrypoint's chmod rather than because of it.

* task: ignore per-session agent memory and cron runtime state

.oh/memory/ and .oh/crons/ hold local agent scratch — session logs,
prompt-miner dumps, a cron pid and log. Nothing in either is tracked on
any branch, but neither was ignored, so a `git add -A` sweeps 37 files of
operator session state into the commit. It happened twice while building
this branch.

Ignore both, following the wiki-corpus convention already in this file:
local by default, `git add -f` for anything worth keeping.
* task: collapse sandbox storage to one $HOME mount

Replace the eleven per-tool named volumes with a single mount at
/home/sandbox, selectable as a Docker-managed volume (<name>_workspace)
or a host bind via storage.homePath in oh.json.

The image's home is moved to /opt/home-seed at build time so an empty
named volume never auto-copies; entrypoint's seed_home restores it into
either mount kind with cp -a -n, which also backfills new dotfiles on an
image upgrade without clobbering operator state.

Retire the projectRoot/OH_PROJECT_ROOT config knob: the checkout is
fixed at /home/sandbox/harness, nested inside the home mount.

Replace the UID-sync 'find -xdev' with an explicit $OH_PROJECT_ROOT
prune. -xdev only pruned the checkout because /home/sandbox was the
container overlay; once it is a mount they share a device.

* task: retarget the herdr image-docs persistence assertion at the single home mount

* task: address audit findings on the single-$HOME-mount change

seed_home no longer uses 'cp -a -n'. That form skips existing regular
files but still rewrites the mode, owner and mtime of directories the
destination already has, so every boot relaxed ~/.ssh from 0700 to the
image's 0755 and reset the other pre-existing home directories likewise.
It now walks the seed and copies only entries the mount lacks, creating a
missing directory with the seed's own metadata and never touching an
existing path. It also returns non-zero on a real copy failure instead of
swallowing it, and the caller warns.

oh destroy branches on storage.homePath rather than appending a
correction: with a host bind, nothing is deleted and sign-in does not
start over, so the volume list and that warning are wrong to print. The
volume list is now project-prefixed to match 'docker volume ls'.

Reject reserved host paths for storage.homePath. The ownership repair is
now an unbounded recursive chown over the mount, so '/', '/home' or
'/usr' would hand the sandbox uid a host tree.

Fix the migration recipe: 'docker cp <name>:/home/sandbox <dir>' lands
the copy at <dir>/sandbox when <dir> exists. Use the trailing '/.' form
and drop the checkout copy that docker cp reads through the bind mount.

Document that storage.homePath is invisible to VS Code 'Reopen in
Container', which would otherwise give a second, silently diverging home.

Harden oh-home-mount.sh with the directory-metadata and copy-failure
cases that let the original bug through, plus the missing rm -rf
assertion; scope the -xdev ban to lines mentioning /home/sandbox.

Refresh the stale project-root seam section in .oh/README.md, which still
described the removed knob and cited the deleted probe.

* task: drop local agent memory and cron runtime state committed by mistake

* task: seed the home mount per top-level entry, not per file

A live boot on a real image caught the previous form: walking the whole
seed tree and copying file by file spends its time in the uv cache under
~/.cache/uv, and it aborted partway through with EEXIST, leaving a home
with four of sixteen entries.

seed_home now copies each top-level entry the mount lacks with a single
'cp -a', and never touches one it already has. Verified on a live boot:
a clean volume gets all sixteen entries in 7s, ~/.ssh ends at 0700, and
an operator edit to ~/.zshrc plus a 0700 ~/.ssh both survive a restart.

The trade is explicit and documented: a new file inside a directory the
mount already has is not merged in. That matches what the per-tool
volumes did, while a new top-level entry from an image upgrade still
arrives. oh-home-mount.sh asserts the contract in both directions and now
rejects the two wrong implementations - 'cp -a -n' and the whole-tree
walk - rather than only the first.

Bake ~/.ssh at 0700 in the image so the seeded mode is right before the
entrypoint's chmod rather than because of it.

* task: ignore per-session agent memory and cron runtime state

.oh/memory/ and .oh/crons/ hold local agent scratch — session logs,
prompt-miner dumps, a cron pid and log. Nothing in either is tracked on
any branch, but neither was ignored, so a `git add -A` sweeps 37 files of
operator session state into the commit. It happened twice while building
this branch.

Ignore both, following the wiki-corpus convention already in this file:
local by default, `git add -f` for anything worth keeping.

* task: slim the sandbox image by ~540 MB

Stop shipping build caches inside the baked home seed, and stop shipping the
seed twice.

The home seed measured 358 MB, of which 103 MB was pure build cache: ~/.npm
(31 MB, left by the pi npm install) and ~/.cache/uv (72 MB, left by
provision-python.sh). Neither is read at runtime -- the entrypoint's
repair_home_mount_ownership install -d's the uv cache on every boot, and npm
recreates its own. Both purges fold into the RUN that produces them.

RUN mv /home/sandbox /opt/home-seed moved data across a layer boundary, so the
image stored the whole seed twice. The Dockerfile is now three stages: base
(through the sandbox user and the UV_* env), home (everything that writes into
/home/sandbox), and final, which brings the seed in with
COPY --from=home --chown=sandbox:sandbox. The final stage also removes the
useradd skel files that the old wholesale mv used to take with it, preserving
the empty-home invariant that keeps an empty named volume from auto-copying.

Runtime NPM_USER_PREFIX and UV_* still point at /home/sandbox, so pi
self-updates and uv tool install keep persisting into the mount.

.dockerignore now excludes .pnpm-store and the untracked .pi build outputs,
which were pulling ~2 GB into every local build context.

Two oracles move with the mechanism: oh-home-mount.sh pinned the literal
RUN mv spelling and now asserts the invariant (seed staged at /opt/home-seed,
empty home, no harness/), and sandbox-base-image.test.ts end-anchored the FROM
line, which a stage alias necessarily breaks.

Measured with docker system df -v: 4.0 GB -> 3.46 GB.

* fix: close four oracle gaps an adversarial audit found in the seed slimming

The Dockerfile restructure held up under an entry-by-entry comparison of the
two built images -- 29,992 seed entries, zero differences in type, mode, or
owner, and a path delta that is exactly the intended cache purge. The defects
were all in the guards.

image-seed-hygiene.sh compared textual line numbers in a file where instruction
order is not execution order, so it passed a Dockerfile that still shipped both
caches: delete the purges from the home stage and add them as the first
instruction of the final stage, and the line-number check is satisfied while
the seed is copied from an unpurged stage. It now parses stages, anchors on the
staging instruction itself rather than any mention of /opt/home-seed, resolves
which stage the seed is copied from, and requires a purge inside that stage.
The single-stage RUN mv form still works, and the previously fragile anchor no
longer false-positives on an earlier ENV referencing the same path.

oh-home-mount.sh accepted COPY --from=base (whose home is bare useradd skel, so
every baked dotfile would be lost) and COPY --from=alpine:latest. The source
must now be a stage declared in the same Dockerfile, and not base.

sandbox-base-image.test.ts stopped pinning anything once there were three FROM
lines -- any one matching satisfied it, so FROM ubuntu:24.04 AS final passed.
A new case walks the FROM lines in order and asserts every image is either the
pinned base or a stage defined earlier.

COPY --from creates its destination at the default 0755 and --chown does not
restore a mode, so /opt/home-seed silently went 0700 -> 0755 versus the RUN mv
era. Restored, and now asserted by the probe, since nothing caught it.

Each fix is mutation-verified: the audit's mutant and all four original
negative controls exit 1, the false-positive control stays green, and both bad
COPY --from sources are rejected by both probes.

* task: refresh eval scoreboard after merge
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906

* task: remove optional-harness build args and make the CLI the install path

#905 and #907 moved the default harnesses and tools out of the image but left
the four optional harnesses behind. The boundary — inside the sandbox the CLI
provisions harnesses and tools — has no carve-out for optional ones.

They were not merely leftover. opencode, grok-build, and hermes are all
installUser:"root", and harness.ts:256 installs with stdio:"inherit", so
local-target.ts selects the INTERACTIVE branch: plain `sudo --`, no -n.
/etc/sudoers.d/sandbox has no NOPASSWD, so `oh harness install opencode` hangs
on a password prompt no agent can answer. The build arg was the only working
path, which is why the Dockerfile blocks could not simply be deleted.

All four relocate to the sandbox user, verified by reading the upstream
installers rather than guessing: opencode takes an npm --prefix like
claude-code; grok's installer honours GROK_BIN_DIR; hermes honours
HERMES_INSTALL_DIR and its get_command_link_dir() already picks ~/.local/bin
for a non-root install; deepagents was already sandbox-installed via uv. So no
sudoers change is needed and no security posture moves.

- Delete all four ARG/RUN pairs, the compose build.args block, and the dead
  /opt/grok-build and /usr/local/lib/hermes-agent chowns. INSTALL_HERMES keeps
  its RUNTIME life — link-providers.sh vendors the Hermes skill pack from it
  and entrypoint.sh wires auth.json — so only its build-arg role goes.
- Remove `buildArg` from HarnessEntry entirely. It was dead metadata: declared,
  set four times, read by nothing. tool-catalog-boundary.sh already banned the
  same field in the tool catalog.
- provision-defaults.sh now reads the full catalog and also installs any
  non-default entry whose install.<key> is true, so declared intent survives a
  fresh home mount. isInstallFlagEnabled already reads oh.json, so this needs
  no new env plumbing.
- verify-sandbox-image.sh widens to "no harness of any kind is baked", and
  gains the inverse for tools: every kind:"baked-in" tool must be present, or
  the check passes on an image missing everything.
- sandbox-compatibility.yml's optional-installer job loses its subject. It now
  boots the image and runs `oh harness install` for each optional harness,
  asserting the binary lands under /home/sandbox/.local — the path operators
  actually use, instead of one that no longer exists.

harness.test.ts had a case asserting `cmd === "sudo"`, codifying the very
defect this fixes. It now asserts no install shells out to sudo at all.

Closes #908

* fix: retry an optional-harness install once before failing the job

The new compatibility job reaches four third-party endpoints. Hermes' own
installer hard-fails the whole install when its internal `npm install` step
blips, which took the job down on a commit that was correct — the rerun passed
unchanged, on the same SHA.

A vendor's transient error must not block this repo's merges. One retry absorbs
it. The contract is unchanged: a genuine break — wrong user, wrong path, a sudo
prompt — fails both attempts and still fails the job.

The probe now asserts both halves, so neither the retry nor the hard failure
after it can be dropped silently.

* fix(test): drop a template-literal scan that fired on catalog order

The source-level regex I added in #906 cannot distinguish a JS backtick from a
backtick inside prose — notInstallableReason has several. It passed only because
every ${...} in the catalog happened to precede the first prose backtick.
Adding a tool below them flips it to a false failure, which is exactly what
happened on the #858 branch.

The per-token ban, with the bash -lc body exempted, covers what is actually
checkable.
* task: provision the default harnesses into the home mount

oh is meant to split by execution target -- on the host it provisions the host
or the sandbox, and inside the sandbox it provisions the sandbox with harnesses
and tools. The second half did not work for the two harnesses most people use.

claude-code and codex carried installUser: "root", which against the local
execution target becomes sudo -n -- npm install -g, and /etc/sudoers.d/sandbox
grants sandbox ALL=(ALL) ALL with no NOPASSWD. sudo -n true returns "a password
is required". Both now match the pi entry directly above them: installUser
"sandbox", npm --prefix /home/sandbox/.local install -g. That lands them inside
the home mount, so they also survive container recreate and can be upgraded in
place in a running remote sandbox rather than requiring an image rebuild.

claude-code deliberately does not get --ignore-scripts. Its postinstall copies
the native binary over a placeholder; with the flag the install succeeds and
claude --version then fails with "claude native binary not installed". Verified
both ways against a scratch prefix.

provision-harnesses.sh follows provision-python.sh: the same mode flag, the same
root to gosu sandbox re-exec, the same ownership diagnostics, the same die-with-
the-command-to-re-run style. --print-env is absent because this provisioner
exports nothing downstream. It reads the catalog through oh harness list --json
and installs through oh harness install, so the shell knows no ids, packages,
prefixes, or argv, and the TypeScript catalog stays the only description.

No default harness carries a version pin today, so an existing install is never
replaced and the script says so in its own output rather than implying it
refreshes.

The entrypoint hook runs after link-providers.sh, not before. link-providers'
only binary dependency is cc-safety-net, which stays baked, and it is the
boot-critical hard gate; a network-dependent best-effort step does not belong in
front of the step that decides whether the boot is viable. Provisioning warns
and continues, so an offline sandbox still comes up as a usable shell.

BAKE_HARNESSES defaults to true and gates only the $AGENTS loop, not the whole
RUN. INSTALL_OPENCODE and INSTALL_GROK_BUILD are separate opt-ins and turning
them off as a side effect would be a silent regression. Nothing leaves the image
in this change.

* fix: bound the boot path and close four more provisioning defects

An adversarial audit of #903 found five defects that six green checks missed.

The serious one is a boot hang. oh harness list --json probes every entry in
the catalog, not just the three defaults, and one of them is t3code, whose
verifyArgv is npx --no-install t3 --version. npx contacts the registry, and
probeInstalled passed no timeoutMs, so spawnSync waited without bound. Against
an unreachable registry the auditor's run was still going at 2m30 when their own
timeout killed it. On any boot where DNS resolves but the registry does not
answer, the entrypoint blocks before sleep infinity, exceeds the 300s
start_period, and never goes healthy -- and restart: unless-stopped does not
rescue an unhealthy-but-alive container. Warn-and-continue cannot help, because
a hang never reaches the if !. It hangs while listing, before any install, so
BAKE_HARNESSES=true did not avoid it either.

Bounded at three layers, because each fails differently: a 15s timeoutMs on the
probe spawn, reported as unknown rather than a crash; a --defaults filter on
oh harness list so the boot path probes three entries instead of nine and never
runs npx; and a timeout wrapper on the entrypoint call so the boot is bounded
whatever the CLI does. Measured against the auditor's exact command: 2m30 and
killed, to 17.0s full-catalog and 1.55s with --defaults.

The install loop read from a herestring while installs run with stdio inherit,
so an installer that reads stdin consumed the rest of the loop. Reproduced with
a stub: three missing harnesses, one installed, exit 0, success printed. Latent
with npm, live the moment a default uses the curl | bash shape two catalog
entries already use. Installs now read from /dev/null.

The script force-exported OH_EXECUTION_TARGET=local, which short-circuits the
in-container check, while the prefix is hardcoded to /home/sandbox/.local, and
every error told the operator to re-run with no mention of where. On the host
that provisioned the host. It now refuses unless inside the sandbox, reusing the
CLI's own runningInsideSandbox predicate rather than inventing a check, and the
entrypoint asserts the local target explicitly -- the documented raw docker run
recipe never passes SANDBOX_NAME, so the guard would otherwise have silently
skipped provisioning for the prebuilt-image flavor.

The probe asserted that ARG BAKE_HARNESSES was declared, not that anything used
it: deleting the gate left it green. It now checks the ARG is referenced by the
RUN that installs $AGENTS and by the one that bakes pi, and that both stages
declare it. Deleting either declaration also used to pass.

ARG is stage-scoped, so BAKE_HARNESSES=false unbaked claude-code and codex but
left pi baked in the home stage while the else-branch claimed otherwise. The
home stage now declares and honors the flag.

Also: OH_SANDBOX_USER was advertised but illusory, since the catalog hardcodes
the user and prefix; it is gone. The final log line no longer claims to have
provisioned anything in --verify mode.

* task: stop baking the default harnesses into the sandbox image

PR #903 wired provision-harnesses.sh into the boot path but shipped it behind
ARG BAKE_HARNESSES=true, so every default harness was already present when the
provisioner ran and the install path never executed. All four defects that PR's
audit found lived in code a green CI run and a normal boot both skip.

Delete the bake rather than flip its default: remove ARG BAKE_HARNESSES, ARG
AGENTS, the PKG map and the $AGENTS loop in `base`, and the gated pi install in
`home`. A build arg that can re-bake is a dormant path that would restore both
the shadowed /usr/lib/node_modules copy and the untested boot install.

Make the install path CI-visible, since it is now load-bearing on every boot:

- The boot smoke asserts the outcome — each default harness resolves under
  NPM_USER_PREFIX via `type -P`, is owned by the reconciled sandbox uid, and
  prints its own version — and refuses to pass when the catalog reports no
  defaults. It boots on a fresh home volume, so this runs real npm work.
- verify-sandbox-image.sh gains the negative: reading the catalog out of the
  image itself, no kind:"default" harness may be installed.
- start_period goes 300s -> 600s in both compose files to cover the install,
  and the boot-guard probe now derives the smoke deadline from the healthcheck
  window instead of pinning a literal that a start_period bump could invert.
- The probe and unit assertions invert from "the bake is gated" to "no default
  harness package appears in the Dockerfile", reading the package names out of
  installArgv so they cannot drift from the catalog.

cc-safety-net stays baked. Opt-in INSTALL_* harnesses are untouched.

Costs this accepts, documented in installation.md: a first boot on a fresh home
mount needs network and runs 60-180s longer; an offline first boot yields a
usable shell with no agent CLIs; ~/.npm now lives in the home mount.

Closes #904

* task: cite the measured first-boot provisioning time

* task: move herdr and cloudflared out of the image into the tool catalog

Per the ownership boundary — the in-sandbox CLI provisions harnesses and tools —
herdr and cloudflared are tools, so the image should not carry them. #905 did
this for the default harnesses; this does it for the default tools.

The obvious template does not work. #897's tailscale entry root-installs to
/usr/local/bin, and commands/tool.ts:309 passes stdio:"inherit", so
local-target.ts:113-116 selects the INTERACTIVE branch — plain `sudo --`, no
-n. /etc/sudoers.d/sandbox grants `sandbox ALL=(ALL) ALL` with no NOPASSWD, so
`oh tool install <root tool>` hangs on a password prompt no agent can answer.
Verified in a running sandbox: `sudo -n -- true` → "a password is required".
(#897's tailscale has the same defect; flagged there, not fixed here.)

So install to ~/.local/bin as the sandbox user instead, the same correction
#900 made for the harnesses. No sudo, survives container recreation in the home
mount, and upgradeable in place by a running sandbox.

- ToolKind gains "default". herdr 0.7.4 and cloudflared 2026.8.2 become
  kind:"default", installUser:"sandbox", with per-arch pinned URLs and
  sha256 verification into $NPM_USER_PREFIX/bin. Checksums measured by
  downloading both arches, not copied from anywhere.
- provision-harnesses.sh generalizes over both catalogs and becomes
  provision-defaults.sh (OH_PROVISION_DEFAULTS, timeout 180s → 240s). It
  dies rather than reporting success when neither catalog yields a default.
- The Dockerfile loses the herdr RUN, ARG HERDR_VERSION, and the whole
  cloudflared apt block — with it the bookworm-suite workaround that existed
  only because Cloudflare publishes no trixie suite. Docker's is now the only
  third-party apt source.
- Both oracles generalize: verify-sandbox-image.sh rejects a baked default
  harness OR tool, reading each catalog out of the image; the boot smoke
  asserts every default in both catalogs resolves under NPM_USER_PREFIX,
  is owned by the sandbox uid, and prints a version.
- The herdr version+checksum pin moves from the Dockerfile to the catalog,
  and herdr-default.test.ts follows it.

Costs, documented in installation.md: an offline first boot on a fresh home
mount now has no herdr, so `oh shell` lands in a plain shell with tmux as the
fallback multiplexer. The entrypoint says so explicitly on failure.

Closes #906

* task: remove optional-harness build args and make the CLI the install path

#905 and #907 moved the default harnesses and tools out of the image but left
the four optional harnesses behind. The boundary — inside the sandbox the CLI
provisions harnesses and tools — has no carve-out for optional ones.

They were not merely leftover. opencode, grok-build, and hermes are all
installUser:"root", and harness.ts:256 installs with stdio:"inherit", so
local-target.ts selects the INTERACTIVE branch: plain `sudo --`, no -n.
/etc/sudoers.d/sandbox has no NOPASSWD, so `oh harness install opencode` hangs
on a password prompt no agent can answer. The build arg was the only working
path, which is why the Dockerfile blocks could not simply be deleted.

All four relocate to the sandbox user, verified by reading the upstream
installers rather than guessing: opencode takes an npm --prefix like
claude-code; grok's installer honours GROK_BIN_DIR; hermes honours
HERMES_INSTALL_DIR and its get_command_link_dir() already picks ~/.local/bin
for a non-root install; deepagents was already sandbox-installed via uv. So no
sudoers change is needed and no security posture moves.

- Delete all four ARG/RUN pairs, the compose build.args block, and the dead
  /opt/grok-build and /usr/local/lib/hermes-agent chowns. INSTALL_HERMES keeps
  its RUNTIME life — link-providers.sh vendors the Hermes skill pack from it
  and entrypoint.sh wires auth.json — so only its build-arg role goes.
- Remove `buildArg` from HarnessEntry entirely. It was dead metadata: declared,
  set four times, read by nothing. tool-catalog-boundary.sh already banned the
  same field in the tool catalog.
- provision-defaults.sh now reads the full catalog and also installs any
  non-default entry whose install.<key> is true, so declared intent survives a
  fresh home mount. isInstallFlagEnabled already reads oh.json, so this needs
  no new env plumbing.
- verify-sandbox-image.sh widens to "no harness of any kind is baked", and
  gains the inverse for tools: every kind:"baked-in" tool must be present, or
  the check passes on an image missing everything.
- sandbox-compatibility.yml's optional-installer job loses its subject. It now
  boots the image and runs `oh harness install` for each optional harness,
  asserting the binary lands under /home/sandbox/.local — the path operators
  actually use, instead of one that no longer exists.

harness.test.ts had a case asserting `cmd === "sudo"`, codifying the very
defect this fixes. It now asserts no install shells out to sudo at all.

Closes #908

* fix: retry an optional-harness install once before failing the job

The new compatibility job reaches four third-party endpoints. Hermes' own
installer hard-fails the whole install when its internal `npm install` step
blips, which took the job down on a commit that was correct — the rerun passed
unchanged, on the same SHA.

A vendor's transient error must not block this repo's merges. One retry absorbs
it. The contract is unchanged: a genuine break — wrong user, wrong path, a sudo
prompt — fails both attempts and still fails the job.

The probe now asserts both halves, so neither the retry nor the hard failure
after it can be dropped silently.

* fix(test): drop a template-literal scan that fired on catalog order

The source-level regex I added in #906 cannot distinguish a JS backtick from a
backtick inside prose — notInstallableReason has several. It passed only because
every ${...} in the catalog happened to precede the first prose backtick.
Adding a tool below them flips it to a false failure, which is exactly what
happened on the #858 branch.

The per-token ban, with the bash -lc body exempted, covers what is actually
checkable.

* task: retire the DeepAgents harness

deepagents-cli is deprecated upstream. Its own --version output now leads with:

  DeprecationWarning: deepagents-cli is deprecated and will not receive further
  releases. Use the `managed-deepagents` package (uv tool install
  managed-deepagents, then mda) instead.

Following it is not a rename — managed-deepagents ships a different binary
(mda), so binary, verifyArgv, the banner tile, and the docs would all move.
Operator ruling: retire the entry rather than chase a product that is moving
away from a local CLI.

Removed: the catalog entry; install.deepagents from OhConfig, its default, its
validation loop and its OH_CONFIG_FIELDS row; the config-render put(); the
env-file INSTALL_FIELDS/CONFIG_FIELD_BY_ENV_KEY entries; the init wizard option
and its ENV_TO_CONFIG mapping; the banner tile and shortcut; install.sh and
migrate-harness-yaml.sh entries; docs/harnesses/deepagents.md and every
reference to it; the oh.json key.

Kept deliberately:
- INSTALL_DEEPAGENTS in config-render's RETIRED_KEYS, so anything that tries to
  render it again fails loudly instead of emitting a dead variable.
- INSTALL_DEEPAGENTS in the two probes that assert no harness build arg returns
  to the Dockerfile. Dropping it there would let the arg come back unnoticed.

`oh config set install.deepagents true` now exits 1 with `unknown field`,
because the OH_CONFIG_FIELDS row is gone. A stale key already present in an
oh.json is ignored rather than rejected — that is the pre-existing behaviour for
any unrecognised install.* key, not something this change introduces.

Closes #910
* feat(cli): support T3 Code mobile access over Tailscale (#858)

Add Tailscale as an opt-in `oh tool` and give T3 Code a headless,
tailnet-private mobile access path.

Tailscale runs inside the sandbox in userspace-networking mode as the
unprivileged sandbox user. T3 Code stays on container loopback and
`t3 serve --tailscale-serve` advertises it over tailnet HTTPS. This adds
no capabilities, no /dev/net/tun, and no published port; the compose
surface gains only an INSTALL_TAILSCALE passthrough and a named state
volume. The entrypoint installs a pinned, sha256-verified tarball and
never starts the daemon or handles an auth key.

The /t3 skill gains serve, pair, url, and doctor. Headless launches use
`t3 serve` under the existing tmux convention, `t3 pair` adds a device
without restarting the server, and doctor fails with actionable guidance
on Node range, tailscaled state, tailnet login, and port reachability.

Two probes guard the result: tailscale-tool-boundary asserts the
no-capability, no-port, no-Funnel, pinned-and-hash-matched boundary, and
t3-headless-launch asserts the serve-only launch surface.

Closes #858

* fix(cli): use the default tailscaled socket for T3 mobile (#858)

The documented daemon ran on $HOME/.tailscale/tailscaled.sock while every
consumer — /t3 doctor, the doc'd tailscale status/serve/up/logout, and T3
Code's own --tailscale-serve — called the CLI bare, which only ever reaches
/var/run/tailscale/tailscaled.sock. There is no TS_SOCKET override, so
`/t3 start --tailscale` could never pass preflight.

The entrypoint and the tool catalog now create /var/run/tailscale owned by
sandbox, and every documented daemon and CLI invocation drops --socket.
--statedir and the tailscale-state volume are unchanged.

The Node-floor assertion in t3-headless-launch.sh becomes behavioral: a
stub node printing v22.15.0 must make doctor fail and name the range.

/t3 start creates its tee log under umask 077 so the one-time pairing URL
is not world-readable.

* test(cli): guard the tailscale socket-directory step against drift (#858)

The /var/run/tailscale pre-creation is duplicated between the entrypoint
and the catalog installArgv but was the only install step with no parity
assertion. Version, both sha256 literals, the download base, and the
/usr/local/bin paths were already guarded.

* fix(cli): install Tailscale as the sandbox user, not root (#858)

The tailscale tool entry was installUser:"root", writing both binaries into
/usr/local/bin. #908 proved that shape is unusable from inside the sandbox:
commands/tool.ts execs with stdio:"inherit", so local-target.ts selects the
INTERACTIVE branch — plain `sudo --`, no -n — and /etc/sudoers.d/sandbox grants
`sandbox ALL=(ALL) ALL` with no NOPASSWD. `oh tool install tailscale` therefore
hung on a password prompt no agent can answer. t3-code.sh's doctor already
printed that exact command as its remedy.

Nothing here needed root. Verified by running it: with
--tun=userspace-networking, tailscaled starts as the sandbox user, creates its
socket, and answers `tailscale status`. The only "operation not permitted" lines
are UDP buffer sizing, which Tailscale itself labels throughput-only warnings.

Both install paths — the catalog entry and the entrypoint's boot-time install —
now install into $NPM_USER_PREFIX/bin as the sandbox user. That also fixes a
second defect: /usr/local/bin is an image-layer path, so the old location was
discarded on every container recreate and re-downloaded 33 MB + 42 MB each time,
while leaving a root-owned binary no running sandbox could upgrade in place.

/var/run/tailscale still needs root to create, since tailscaled defaults its
control socket there and t3-code.sh calls a bare `tailscale status`. The
entrypoint keeps creating it, but no longer behind the INSTALL_TAILSCALE guard:
`oh tool install tailscale` promises the tool is usable in the already-running
container, and gating the socket directory would make that wait for a reboot.

The probe now holds all of it: no root install, no /usr/local/bin in either
path, and the socket directory created outside the guard.

* fix(test): restore the newline escape lost resolving the tool-catalog merge

* eval: refresh the scoreboard after merging development
The four correctness gates prove a change works. None can fail a change that
works and is twice the size it needed to be. Gate 5 asks whether the diff can
be smaller and still satisfy every acceptance criterion, and fails the audit
while the answer is yes.

Signals come from two new implementation-gates.sh modes: slop-metrics reports
net lines, per-function CCN on changed TypeScript via uvx lizard, and a
disclosed branch-point proxy for shell; simplicity-round reads the caller's
round record. An unresolvable lizard reports unavailable rather than an empty
clean result.

The loop terminates by construction, not by agreement: a finding without a
concrete smaller alternative is not a finding, and the loop ends at a cap of
three rounds or on a round that fails to reduce netAdded, passing with
SIMPLICITY-RESIDUAL for the operator.
An unrecognized first token now routes to a new `ship` node instead of
printing usage, so `/spec <plan-path>` runs plan then execute through to a
ready-for-review PR. `ship` composes the existing nodes and owns no build
mechanics; `spec-family-contract` fails if it grows a build literal, if the
dispatcher stops routing the default, or if ship.md stops stating how it
treats the commitment gate.

The gate itself survives: handing in an approved plan file is the approval,
and a bare topic with no plan file still stops after `plan`.

Closes #914

Submitted-by: kre8mymedia@gmail.com
…l-impact ledger (#916)

* skill: ingest WikiSkill (arXiv 2608.27454) into the wiki corpus

* skill: add wiki lint related-slug check and generate the index from the tracked set

* skill: add kind: source|pattern to the wiki schema and a --patterns query mode

* skill: add /wiki compile and the skill-impact ledger

* skill: make /builder consult wiki patterns and record proposals in the ledger

* skill: record verdicts in the ledger and route retro lessons through /wiki compile

* skill: add wiki co-evolution probes, CB-005, and the changelog entries

* skill: compile the first wiki pattern from this session's lesson

* skill: record SI-0001 in the skill-impact ledger

* skill: scope the ledger probe to the append region and exercise its oracles

* skill: fix the ledger record parser so appends do not read as mutations

* skill: score CB-005 from the first real run of the lesson-to-skill chain

* skill: refresh the eval scoreboard

* skill: compile four patterns from the session retro via delegated maintainer

* skill: close the compile procedure ambiguities the delegated run surfaced

* skill: refresh the eval scoreboard

* skill: re-score CB-005 from the delegated compile run

* skill: mint the two retro-nominated probes and require fault injection

* skill: restore the probe contract sections and shorten two changelog entries

* skill: refresh the eval scoreboard

* skill: regenerate the eval scoreboard after the merge
A cron fire, a detached Advisor, or a background agent that is blocked on an
operator decision has nowhere to put it. The GitHub path is proven broken: on
#799 the automation's gh identity is the operator's own account, so seven
comments produced zero notifications and a one-line kill-switch took ten days.

/escalate is one deterministic script with no live agent and no attached
terminal in the loop. --needs is required, because an escalation that names no
decision is a log line. --key suppresses a repeat for 12h so a looping session
cannot train the operator to ignore the channel.

Channel health is checked before every send. An unavailable channel is a no-op,
not a raised error -- a dead channel must not take down the session reporting
through it. But a no-op is still a non-delivery, so the reason is printed, the
JSON carries ok:false, and every attempt appends to .oh/logs/escalations.jsonl.
Silence is the failure this closes.

.oh/logs/ is new: gitignored by default with a tracked README, matching the
.oh/tasks/ idiom, for records that must outlive the session that wrote them.
Root AGENTS.md draws the line already: a nested AGENTS.md belongs to directories
whose contents are produced apart from the root context, and everything else
uses a README. .oh/logs/ is that shape -- unattended sessions write to it
without the root context loaded, and the guide's reader is the next agent
deciding whether a record belongs there.

Renames the guide, adds the CLAUDE.md provider symlink, un-ignores both, and
names the fourth directory in the root sentence. The escalate-contract probe now
asserts the symlink target rather than only the guide's presence.

Adding the directory to that sentence pushed AGENTS.md to 9507 B against a 9500 B
budget and the context-tier ratchet caught it, so the sentence is compressed
rather than the budget raised.
* task: scaffold compose-env-boundary task

Four-file contract:
- prd.md: 11 user stories
- prd.json: schemaVersion 1, branchName task/920-compose-env-boundary
- prompt.md: the rendered single-owner task prompt
- progress.txt: empty header

Tracks #920. PRD generated by /prd; converted by /ralph.

Submitted-by: Claude

* task: make the CLI the only install/config surface

Move eleven settings out of the compose environment: block into oh.json, read
inside the container through the oh CLI. A value belongs in compose only if a
process outside the sandbox — or the entrypoint before the control plane is
readable — must act on it.

- entrypoint.sh gains oh_config/oh_config_truthy over `oh config show`, and
  loses the duplicate agent-browser and Tailscale installers; the tool catalog
  is now the sole owner of both pins and Tailscale's two checksums.
- The Hermes wiring keys off `command -v hermes` instead of INSTALL_HERMES, so
  it runs identically in both sandbox flavors. Flavor B previously carried none
  of the flavor-A-only keys, leaving all of it dead there.
- The sshd overlay keeps its ports: and loses its environment:; sshd's mode
  comes from access.ssh* in oh.json.
- Retire docker-compose.hermes-dashboard.yml and its published 127.0.0.1:9119.
- config-render.ts stops rendering ten keys and adds each to RETIRED_KEYS, so a
  re-added put() throws; tools/catalog.ts drops entrypointGuard.
- cron-runtime.ts resolves the agent binary from CRON_AGENT_BIN, then oh.json,
  then "claude".
- tool-catalog-boundary and tailscale-tool-boundary are inverted to assert the
  post-epic boundary; every zero-exposure assertion is kept.

Every oh.json field survives — only its .env projection is gone.

Tracks #920.

Submitted-by: Claude

* task: detect the sandbox flavor instead of declaring it

Replace the OH_IMAGE_ONLY compose flag with a runtime check. Both conditions are
load-bearing: mountpoint alone misreads an empty bind mounted straight at the
project root as a checkout, and the .oh/ test alone would send a seeded no-bind
volume through the host-UID sync on its second boot.

The detected mode is logged on both paths, so a wrong detection shows up in
`oh logs` rather than silently. `.oh/.image-seeded` is now gitignored, so a
misdetection can never leave an untracked marker in a real checkout.

With the flag gone the two compose files' environment: blocks are byte-identical;
a new tier-A probe, compose-env-boundary.sh, holds that across every compose file
and overlay: no INSTALL_*, no OH_IMAGE_ONLY, and every key either rendered by
config-render.ts or one of four documented literals.

oh-image-only-deploy.sh drops the three assertions that only required
documentation prose to contain or omit a string; every behavioral assertion is
kept and the gate check now targets detection.

Tracks #920.

Submitted-by: Claude

* task: record compose-env-boundary evidence and eval result

Story graph complete (11/11), /eval clean at this HEAD, and evidence.md answers
back to the approved plan: what improved, what was built, five divergences, and
what stays unverified without a Docker daemon.

Tracks #920.

Submitted-by: Claude

* task: refresh the eval scoreboard on the merged tree

Regenerated after merging development rather than resolving the generated
RESULTS.md by hand. 117 probes, runner exit 0, no REGRESSION/TIMEOUT/ERROR rows.

Tracks #920.

Submitted-by: Claude

* task: key skills-vendored on the hermes binary, not INSTALL_HERMES

The probe drove link-providers.sh with INSTALL_HERMES=true and asserted the
Hermes skill symlink appeared. #920 replaced that flag with a `command -v hermes`
check, so on a CI runner with no hermes the link is correctly never created and
the assertion failed.

It now stubs hermes on a bare PATH inside the clean clone and asserts both
directions: absent with no binary, present with one. Verified in an isolated
clone that each direction flips when the gate is mutated to `true` / `false` —
the probe clones committed state, so an in-place working-tree mutation cannot
reach it.

Tracks #920.

Submitted-by: Claude

* task: strip explanatory comments from .devcontainer

Comments are a second, unverified description of behavior. Remove every
explanatory comment from .devcontainer/ (Dockerfile, all compose files,
entrypoint.sh, the two client scripts) and the stale .dockerignore block
that still named the retired OH_IMAGE_ONLY flag.

Kept only machine-read comment-shaped data: the shebangs, the
'# >>> name >>>' fences that oh-home-mount.sh, oh-image-only-deploy.sh
and entrypoint.test.ts extract functions from, and the header written
into the generated sshd config.

Re-anchor four assertions in three tests that sliced entrypoint.sh on
comment prose so they slice on code instead; one of them
(entrypoint.test.ts reconBranch) had been anchored on the pre-#920
'elif [ -d "$HARNESS_DIR" ]' line and was passing vacuously on a
whole-block slice.
* task: retire the Prime Agent harness

* task: preserve retired Prime Agent state ignores

* task: refresh the eval scoreboard after merging development
* task: retire automated /spec agent handoff

/spec execute no longer launches a coding agent. The agent that runs it is
the single implementation owner from the isolated worktree through the final
PR gates; /delegate stays bounded, disjoint fan-out beneath that owner.

Retired: the detached tmux Advisor launch, agent-spec-* sessions, tmux
pipe-pane logging, Advisor-specific resume/monitoring, automated Herdr
workspace/tab/pane creation, and every runner fallback. Task identity and
RUNNING state now depend on .oh/tasks/<slug>/ alone.

Preserved: worktree isolation, implementation <-> audit, the eval/evidence/
retro/final-PR gates, human merge, Herdr as operator-managed interactive
tooling, and tmux for headless infrastructure.

Probes: advisor-monitored-loop renamed to spec-single-owner; adds
spec-no-agent-handoff, spec-no-advisor-session-coupling,
cleanup-no-agent-session-coupling, headless-tmux-preserved.

Tracks #928.

Submitted-by: Claude

* task: cut scope creep and redundant probe machinery from #928

Four simplifications, no coverage lost:
1. Revert the plan-vs-built-reconciliation line-citation renumbering and the
   updated: bump (pre-existing drift, no acceptance criterion, no probe), which
   also drops corpus/README.md from the diff entirely.
2. Drop spec-single-owner's agent-spec- marker — spec-no-advisor-session-coupling
   already scans both files for it.
3. Reword two prohibition sentences so they no longer use the handles the probe
   bans, replacing a 20-line negation-aware awk matcher with a literal ban.
4. Compress the CHANGELOG from six bullets to four.

Tracks #928.

Submitted-by: Claude
Adopt ADR #929: the active coding agent is the runtime, skills encode
durable roles and procedures, and subagents stay a bounded execution
primitive owned by /delegate.

Adds /architect as an inline architecture-decision skill and retires the
project-authored agent catalog, the /builder agent artifact type, and every
stale project-agent reference across skills, docs, provider wiring, and the
oh payload manifest.

Submitted-by: Claude Code (claude-opus-5)
* task: retire cron worktree isolation

Remove the worktree: frontmatter key, .worktrees/cron/ per-fire worktrees, the CRON_WORKTREE export, the fallback-worktree prune/inspect/reap machinery, and their log states. Every cron now fires in the shared root checkout under the id-scoped overlap lock.

Tracks #933.

Submitted-by: Claude

* task: drop the CRON_WORKTREE root seam from audit-run

Nothing sets CRON_WORKTREE now that cron worktree isolation is retired. audit-run resolves its root from the script path alone; the root-contract probe runs a fixture-local copy of the script instead of overriding the root through the environment.

Tracks #933.

Submitted-by: Claude
Closes #926.

Make /spec a closed repo-knowledge learning loop: planning recalls tracked
knowledge and re-grounds it against current sources before the PRD exists,
execution re-grounds against HEAD and derives knowledge invalidation from the
actual diff, freshness becomes source-change aware via verified_at, and durable
knowledge moves to a tracked .oh/knowledge/ surface with per-machine scratch
physically separated.

Retired atomically with every consumer: /spec ship, the generated prompt.md,
the STATUS: COMPLETE sentinel, mandatory /compact as a semantic stage, the
.oh/memory vocabulary, and /wiki lint's orphan and 90-day checks.

Twelve probes added (the ten #926 requires plus two minted from the run's
retro), each fault-injected. Reconciled with #930, #931, and #934, which merged
during the build.

Submitted-by: Claude
Promote the [Unreleased] body to [0.6.0], consolidating the duplicated
category headings into one block per category, and bump root package.json
to 0.6.0 so the release workflow reserves v0.6.0 on the merge to main.
version-parity.sh requires package.json, .oh/cli/package.json, and the
CHANGELOG to name one version; publish-cli.yml treats an already-published
CLI version as a no-op, so a stale 0.5.1 would ship the old bundle.
@ryaneggz
ryaneggz merged commit 006c233 into main Sep 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant