fix(template): install an optional session-end transcript archive hook in the devcontainer - #816
Conversation
…vcontainer
Claude Code deletes transcripts after cleanupPeriodDays (unset, so 30 days).
The devcontainer preserved none: claude-settings.json registers PreToolUse,
PostToolUse and SessionStart but no SessionEnd, and no archive hook existed in
either layer's claude-hooks/. The same config ships to every generated repo
with a devcontainer.
The host-side equivalent was fixed in harmon-dotfiles#68, and that fix cannot
reach here: a container has no personal-settings layer, and this repo may not
depend on a dotfiles checkout. Either harmon-init wires it or nothing does.
install-repo-config.sh gets a second hook loop, guarded on the file existing.
The entry is deliberately absent from required_files: ${config_dir} is the
CONSUMING repository's config, and repos generated from older templates have
no such file, so listing it there — or installing it unguarded under set -e —
would fail the devcontainer build of every repo that has not adopted it yet.
A new image must never break an old consumer.
Rejected the simpler path of pointing the hook at the staged config directory,
which would have avoided touching the image entirely: all seven existing
registrations use /etc/claude-code/…, so one hook elsewhere would be the sole
exception to a uniform convention, and it would sit outside protect-files.sh's
guard as the only agent-writable hook.
The overlay test asserted nothing about this, and the guard uses `continue`, so
a wrong path would skip silently and still report green — the first build did
exactly that. Added an assertion and negative-controlled it: with the script
hidden the guard skips and the assertion fails, restoring it passes.
The hook is NOT registered in claude-settings.json here. The Dockerfiles pin an
image digest, so registering before that pin moves would point at a path the
pinned installer never creates. Registration follows the publish and pin bump.
Verified: task verify green (6 profiles); task test:devcontainer:image green
with the new assertion, and failing without it; dogfood parity 114 twins;
shellcheck and shfmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 1 finding (P2, fixed in place). The overlay test builds from this repository, where every optional hook is present, so the added assertion passes whether the `[ -f … ]` guard works or not. Removing that guard — or promoting the entry into required_files — would keep CI green while the published image broke the devcontainer build of every repository that had not adopted the file yet. That is precisely the failure this change exists to prevent, and nothing was watching for it. I had negative-controlled the guard by hand before pushing, which proved the mechanism once and left nothing behind. A one-off check is not a test. Build the overlay a second time against a config directory with the optional hooks stripped out. The build succeeding IS the assertion: the installer runs under `set -e`, so an unguarded install of a missing file fails here rather than in the fleet. Also assert the mandatory hooks still install and the optional one is absent rather than half-installed. Negative-controlled: deleting the guard makes the legacy build fail with `install: cannot stat '…/claude-hooks/session-end-archive.sh'`, and restoring it passes. Both overlays green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Blocker report — staying draftThe readiness gate fails on
The failures are an upstream outage, not this changeEvery one dies fetching a dependency, none in repository code:
Sustained rather than transient: the first wave hit at 16:49Z with Evidence the change itself is sound
Next stepRe-run the failed jobs once the outage clears, then re-run the gate. No code |
|
Filed #829 to track step 3 — registering Raised by a reviewer: the hook this PR installs is never registered, so it is inert on merge. That is deliberate and documented above under "Sequencing", but it was recorded only in this PR body — which stops being visible once this merges. A reader scanning merged work would reasonably conclude container archiving is handled; it will not be until #829 lands. Step 2 (publish + pin bump) needs no issue — No change to this PR; the finding was about tracking, not correctness. |
…e devcontainer (#832) Closes #829. The hook has shipped in the devcontainer image and has never run. Claude Code executes only what a settings `hooks` block names, and nothing named it — so it was installed, tested, documented, and inert. ## The path is the staged copy, not /etc Registering `/etc/claude-code/hooks/session-end-archive.sh` would break the one-line image rollback that docs/architecture/devcontainer-image.md guarantees: revert the `FROM` pin to an image whose installer predates the hook and the setting names a file that image never creates — silently, for a hook that is deliberately async and quiet. The staged path has no such coupling. `COPY .devcontainer/config/` lives in the CONSUMER's Dockerfile, so the hook is there whatever image is pinned: the settings entry and the script it names ship as one artifact and roll back together. Mandatory hooks keep `/etc/…` — required_files guarantees them in every image, so no coupling exists to break. This reverses the path choice argued in #816. The reasons given there do not survive: "consistency" compared an optional hook against mandatory ones that cannot have this problem, and "protect-files.sh guards /etc" is not a real boundary — vscode has passwordless sudo and enable-claude-bypass.sh already writes managed settings with it. The documentation now says the staged path is chosen for rollback safety and explicitly NOT as a security boundary, because the earlier claim that it was agent-write-protected was false. ## Regression coverage Nothing could catch this class of bug: dogfood parity only compares the twins to each other, and #816's assertion checks the /etc copy this change does not use. The image test now reads the INSTALLED managed settings and requires every hook command they name to resolve to an executable, plus asserts SessionEnd is registered at the staged path specifically — a non-null check would still pass on an /etc path, because this candidate image does install that copy. That assertion also has to run. It lives in the image test, which fires on image inputs; a settings-only PR — exactly how this regresses — would have missed it. Added claude-settings.json to the PR-side paths filter beside ghostty.terminfo, which is there for the identical reason, leaving the push-side list untouched so repository config still never triggers a publish. Negative-controlled, each against the shipping code: deleting SessionEnd fails the presence check; a bad path fails the executable loop; reverting to /etc fails the staged-path assertion. The shipped guide said hook scripts live at /etc/claude-code/hooks/, which would send someone troubleshooting to a real file that is not the one running. It now distinguishes mandatory from optional hooks and says so. Verified: task verify, task ci, task test:devcontainer:image all green; 120 dogfood twins identical; actionlint and yamllint clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Teach the shared devcontainer image's installer to install an optional
session-end-archive.shClaude hook, and ship that script in both devcontainerconfig layers. This is step 1 of 3 — the hook is not registered yet, by
design (see Sequencing).
Why
Claude Code deletes transcripts after
cleanupPeriodDays(unset, so the 30-daydefault). Nothing in the devcontainer preserved them:
.devcontainer/config/claude-settings.jsonregistersPreToolUse,PostToolUse, andSessionStart— noSessionEndclaude-hooks/, in either layerContainer transcripts live on the
claude-code-config-…named volume, so theysurvive rebuilds — but they are still swept at 30 days, and lost entirely if the
volume is removed.
The equivalent host-side gap was fixed in harmon-dotfiles#68. That fix cannot
cover this case: there is no personal-settings layer inside a container, and
this repo may not depend on a dotfiles checkout. Either harmon-init wires it or
nothing does.
The guard is the interesting part
install-repo-config.shgets a second, guarded hook loop:The entry is deliberately absent from
required_files.${config_dir}isthe consuming repository's config, and repositories generated from older
templates have no such file. Listing it in
required_files, or installing itunguarded under
set -e, would fail the devcontainer build of every repo thathas not adopted it yet. A new image must never break an old consumer.
Promoting it into
required_filesbecomes correct only once the fleet hastaken the update, at which point a missing file is a real error rather than an
expected state.
Why not the simpler path
Pointing
claude-settings.jsonat/usr/local/share/devcontainer-config/claude-hooks/session-end-archive.sh— wherethe
COPYalready lands it — would have avoided touching the image entirely andshipped in one PR. Rejected: all seven existing registrations
(six hooks plus the statusline) use
/etc/claude-code/…, andclaude-settings.jsoncontains zero references to the staging directory. One hook at a different path
would be the sole exception to a uniform convention, in a repo whose premise is
that generated repos inherit one coherent setup. It would also sit outside
protect-files.sh's/etc/claude-code/guard — the only agent-writable hook.Sequencing — this PR does not enable archiving
The Dockerfiles pin an image digest, so registering the hook before that pin
moves would point at a path the pinned installer never creates.
publishruns onmain→ new image published → pin bump landsin the root and template Dockerfiles
SessionEndinclaude-settings.json, bothlayers, once the path is guaranteed to exist
Verification
task verify— green, 6 template profiles PASS, 0 failurestask test:devcontainer:image— shared image builds and smoke-tests with therepository overlay, exercising the new installer path
shellcheck --severity=error+shfmt -dclean on the installer and the hookinstalls when present
Rigor
standard (default) → challenge ≤3, review ≤3, shepherd 4. Resolved from
.devflow.tomlonmain; this change does not edit that file and carries norigor:*label.P0/P1. Round 2 confirmed the design directly: "the optional installer guard
preserves compatibility with older consumers and the staged rollout is
coherent."
Both stages were run late, after the draft was opened, and only because they
were queried. The draft state is what made that recoverable.
Deferred findings
Five P2s across four rounds. One fixed here; four concern the script vendored
verbatim from harmon-devkit, where fixing them locally would fork a file whose
canonical copy and test suite live upstream.
scripts/test-devcontainer-image.sh— fixed in 80e2ffd. The overlaybuilds from this repo, where the optional hook is present, so the install
assertion passed whether the
[ -f … ]guard worked or not — removing theguard or promoting the entry into
required_fileswould have kept CIgreen while breaking every unmigrated consumer. Now builds a second
overlay with the hook stripped out; negative-controlled both ways.
session-end-archive.sh(corrupt archive never repaired) — filed as session-end-archive: a corrupt archive is never repaired — gzip -l failure exits 0 through the ERR trap harmon-devkit#439.gzip -lon a corrupt archive fails, theERRtrap exits 0, and the file is never rewritten.session-end-archive.sh(no retention) — filed as session-end-archive: archives accumulate without bound, and a full disk silently stops archiving harmon-devkit#440. Archives accumulate until the volumefills, after which archiving silently stops. Sharper here than on a host:
the container's
~/.claudeis a fixed-size volume.session-end-archive.sh(PID-reuse lock window) — filed as session-end-archive: a recycled PID keeps a dead lock alive for up to an hour, silently skipping archives harmon-devkit#441. A recycled PID keeps a dead lock alive forup to an hour, and each SessionEnd in that window exits without archiving.
harmon-devkit/scripts/test-archive-hook.shalready covers exactly the cases raised (happy path, unchanged no-op,
growth with identical mtime, live and dead lock contention, invalid JSON,
unwritable dir). The script is vendored verbatim, so a behavioural
regression cannot originate here; this repo's responsibility is that it
gets installed, which the new assertions cover.
Not in this PR
814 — pre-existing duplication in
install-repo-config.sh(required_files,the codex install loop, and
/etc/codex/hookseach listed twice). Sits directlybeside this edit; kept separate so this diff stays reviewable.
🤖 Generated with Claude Code