Skip to content

fix(template): install an optional session-end transcript archive hook in the devcontainer - #816

Merged
evanharmon1 merged 3 commits into
mainfrom
fix/devcontainer-transcript-archive
Aug 13, 2026
Merged

fix(template): install an optional session-end transcript archive hook in the devcontainer#816
evanharmon1 merged 3 commits into
mainfrom
fix/devcontainer-transcript-archive

Conversation

@evanharmon1

@evanharmon1 evanharmon1 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What

Teach the shared devcontainer image's installer to install an optional
session-end-archive.sh Claude hook, and ship that script in both devcontainer
config 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-day
default). Nothing in the devcontainer preserved them:

  • .devcontainer/config/claude-settings.json registers PreToolUse,
    PostToolUse, and SessionStart — no SessionEnd
  • no archive hook existed in claude-hooks/, in either layer
  • the same config ships to every generated repo with a devcontainer

Container transcripts live on the claude-code-config-… named volume, so they
survive 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.sh gets a second, guarded hook loop:

for hook in \
    session-end-archive.sh; do
    [ -f "${config_dir}/claude-hooks/${hook}" ] || continue
    install -m 0755 "${config_dir}/claude-hooks/${hook}" "/etc/claude-code/hooks/${hook}"
done

The entry is deliberately absent from required_files. ${config_dir} is
the consuming repository's config, and repositories generated from older
templates have no such file. Listing it in required_files, 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.

Promoting it into required_files becomes correct only once the fleet has
taken 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.json at
/usr/local/share/devcontainer-config/claude-hooks/session-end-archive.sh — where
the COPY already lands it — would have avoided touching the image entirely and
shipped in one PR. Rejected: all seven existing registrations
(six hooks plus the statusline) use /etc/claude-code/…, and claude-settings.json
contains 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.

  1. This PR — installer support + the script, shipped inert
  2. Mergepublish runs on main → new image published → pin bump lands
    in the root and template Dockerfiles
  3. Follow-up PR — register SessionEnd in claude-settings.json, both
    layers, once the path is guaranteed to exist

Verification

  • task verify — green, 6 template profiles PASS, 0 failures
  • task test:devcontainer:image — shared image builds and smoke-tests with the
    repository overlay, exercising the new installer path
  • Dogfood parity — 114 verbatim twins identical (up from 113)
  • shellcheck --severity=error + shfmt -d clean on the installer and the hook
  • Guard negative-controlled both ways: skips cleanly when the file is absent,
    installs when present

Rigor

standard (default) → challenge ≤3, review ≤3, shepherd 4. Resolved from
.devflow.toml on main; this change does not edit that file and carries no
rigor:* label.

  • Challenge: converged at 2/3 — two consecutive rounds adjudicated to zero
    P0/P1. Round 2 confirmed the design directly: "the optional installer guard
    preserves compatibility with older consumers and the staged rollout is
    coherent."
  • Review: converged at 2/3 — two consecutive adjudicated-clean rounds.

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.

Not in this PR

814 — pre-existing duplication in install-repo-config.sh (required_files,

the codex install loop, and /etc/codex/hooks each listed twice). Sits directly
beside this edit; kept separate so this diff stays reviewable.

🤖 Generated with Claude Code

evanharmon1 and others added 2 commits August 12, 2026 11:48
…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>
@evanharmon1

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 80e2ffd204

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

@evanharmon1

Copy link
Copy Markdown
Owner Author

Blocker report — staying draft

The readiness gate fails on checks-failing, so this PR stays draft. Everything
else the gate needs is satisfied.

Condition State
Required checks concluded successfully 6 failing
Current-head Codex cycle terminal and clean ✅ clean on 80e2ffd
Review findings fixed / declined / filed
Inline review comments answered ✅ none outstanding
## Deferred findings all ticked ✅ 5/5, 0 unchecked
reviewDecision not CHANGES_REQUESTED
mergeStateStatus not DIRTY/BEHIND/UNKNOWN

The failures are an upstream outage, not this change

Every one dies fetching a dependency, none in repository code:

  • guard, template-test (minimal), template-test (iac)arduino/setup-task
    cannot download task v3.51.1: Failed to download version v3.51.1: Error: socket hang up
  • candidate — Dockerfile:282 release downloads (zellij, workmux, aoe,
    agent-deck, sesh, herdr) fail, exit code: 2
  • candidate-verify, verify — cascade from the above

Sustained rather than transient: the first wave hit at 16:49Z with
HTTP 503 on the same downloads, and it was still failing at 18:01Z with
socket hang up. One set of reruns in between did not clear it.

Evidence the change itself is sound

  • task verify, task ci — green locally (6 template profiles, 0 FAIL lines)
  • task test:devcontainer:image — candidate, repository overlay, and the new
    legacy overlay
    all pass
  • Both negative controls pass: removing the hook script fails the install
    assertion; removing the [ -f … ] guard fails the legacy overlay build with
    install: cannot stat '…/claude-hooks/session-end-archive.sh'
  • Dogfood parity — 114 verbatim twins identical
  • 10 checks on this same head did pass, including both devcontainer build jobs

Next step

Re-run the failed jobs once the outage clears, then re-run the gate. No code
change is expected; if a rerun surfaces a failure that is not a dependency
download, that is new information and gets adjudicated on its own.

@evanharmon1
evanharmon1 merged commit e46904b into main Aug 13, 2026
19 checks passed
@evanharmon1
evanharmon1 deleted the fix/devcontainer-transcript-archive branch August 13, 2026 03:14
@evanharmon1

Copy link
Copy Markdown
Owner Author

Filed #829 to track step 3 — registering SessionEnd in claude-settings.json once the pin bump lands.

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 — publish-harmon-devcontainer.yml surfaces it automatically as a fix(devcontainer): update shared image to <sha> PR (precedents #740, #711, #706, #688, #651).

No change to this PR; the finding was about tracking, not correctness.

evanharmon1 added a commit that referenced this pull request Aug 13, 2026
…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>
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