Skip to content

fix(template): register the session-end transcript archive hook in the devcontainer - #832

Merged
evanharmon1 merged 1 commit into
mainfrom
fix/register-session-end-hook
Aug 13, 2026
Merged

fix(template): register the session-end transcript archive hook in the devcontainer#832
evanharmon1 merged 1 commit into
mainfrom
fix/register-session-end-hook

Conversation

@evanharmon1

Copy link
Copy Markdown
Owner

Closes #829.

What

Registers the session-end-archive.sh hook so Claude Code actually runs it in
devcontainers. This is the change that makes container transcript archiving
live
— the hook has shipped in the image and never executed, because Claude
Code runs only what a settings hooks block names, and nothing named it.

Why the staged path, not /etc/claude-code/

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 present 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 there is no coupling to break.

This reverses the path choice argued in #816, and that reversal is the
point rather than a detail. Neither original reason survives:

  • "consistency with the other seven registrations" — those are mandatory
    hooks, guaranteed in /etc by required_files. They cannot have this
    problem. An optional hook is genuinely a different case.
  • "protect-files.sh guards /etc/claude-code/" — not a real boundary.
    vscode has passwordless sudo, and enable-claude-bypass.sh already
    writes the managed settings there with it.

The documentation now states the staged path is chosen for rollback safety
and explicitly not as a security boundary
— the earlier claim that root
ownership made it agent-write-protected was simply false.

Regression coverage

Nothing could catch this bug class. Dogfood parity only compares the twins to
each other, and #816's assertion checks the /etc copy this change does not
use — so the hook could be unregistered, or pointed anywhere, with every gate
green.

The image test now reads the installed managed settings and requires every
hook command they name to resolve to an executable, and asserts SessionEnd is
registered at the staged path specifically. A non-null check is not enough: an
/etc path would still pass, 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 — so a settings-only PR, which is exactly how this regresses, would
have missed it. claude-settings.json is now in the PR-side paths filter beside
ghostty.terminfo, which is there for the identical reason. The push-side list
is untouched, so repository config still never triggers a publish on main.

Negative-controlled, each against the shipping code:

Simulated regression Caught by
SessionEnd deleted presence check
Bad path executable loop
Reverted to /etc/… staged-path assertion

Shipped docs

docs/guides/devcontainers.md 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 plainly. Both twins updated in lockstep.

Rigor

standard (default) → challenge ≤3, review ≤3, shepherd 4.

  • Challenge: converged at 3/3 — rounds 2 and 3 adjudicated clean.
  • Review: converged at 2/3 — rounds 1 and 2 adjudicated clean.

Six findings (one P1, five P2), all fixed in place, none deferred — every
one concerned this change's own correctness rather than the vendored script.

Verification

task verify, task ci, and task test:devcontainer:image green; 120 dogfood
twins identical; actionlint and yamllint clean.

Not in this PR

evanharmon1/harmon-devkit#439, #440, #441 — defects in the vendored
archive script itself (corrupt archives never repaired, unbounded growth on the
volume, PID-reuse lock window). They become live once this merges and the hook
starts running. None blocks registration: archiving imperfectly beats a 30-day
deletion.

🤖 Generated with Claude Code

…e devcontainer

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>
@evanharmon1
evanharmon1 marked this pull request as ready for review August 13, 2026 16:49
@evanharmon1
evanharmon1 merged commit 597f9ee into main Aug 13, 2026
19 checks passed
@evanharmon1
evanharmon1 deleted the fix/register-session-end-hook branch August 13, 2026 16:49
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.

devcontainer: session-end-archive.sh is installed but never registered, so container transcripts are still unarchived

1 participant