Skip to content

Ship the compaction-boundary reminder hook to Claude - #738

Open
clkao wants to merge 6 commits into
mainfrom
spacedock-ensign/force-boot-at-compaction-boundary
Open

Ship the compaction-boundary reminder hook to Claude#738
clkao wants to merge 6 commits into
mainfrom
spacedock-ensign/force-boot-at-compaction-boundary

Conversation

@clkao

@clkao clkao commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

A compacted First Officer keeps its narrative and loses its bindings. It does not know it is a new session, so it never re-reads durable state. Six failures in one session traced to that — a merge mod bypassed entirely, four gates reported as presented that were never prepared, and commands run against a deleted binary path.

The plugin already shipped a compaction hook to tell it. That hook could never fire on Claude.

What changed

  • Add the hooks key to .claude-plugin/plugin.json — the key it never had, which made hooks.json invisible to Claude Code.
  • Fix ${PLUGIN_ROOT} to ${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}; Claude sets the prefixed name, so the command resolved to /hooks/... and failed.
  • Rename the Codex-only script host-neutral and give it the boot instruction.
  • Correct a contractlint invariant that asserted Claude must not activate hooks.

The invariant under test

Host signature parity: both manifests activate the same hooks.json, which holds one SessionStart/^compact$ entry, whose command resolves under either host's plugin-root variable. One script, one entry, two manifests, same event.

That is what a future change breaks silently. The tests lead with it.

Evidence

  • Sentinel spike: broken hook returns NONE; fixed hook returns the exact token in a post-compaction turn with no tool calls. Model delivery proven, not assumed.
  • The command template executed under Claude-shaped and Codex-shaped environments. The pre-fix form fails under Claude with exactly the recorded error and still works under Codex, so the fix is additive.
  • Gate closed cases run with an explicit non-inherited environment — this repo's ambient shell exports a real SPACEDOCK_BIN that would make them pass for the wrong reason.
  • Codex packaging verified by running the real staging, not reading the includes list.

Two bugs this found

hooks.json was structurally unreachable from Claude. Once wired, it still failed on the wrong variable name. Both were live in pre1, pre8 and 0.27.0 — inert only because nothing had wired the manifest.

A July entity (claude-post-compaction-contract-reload) diagnosed the missing key and was never dispatched. A contractlint test actively asserted the key must be absent, so anyone attempting the fix would have gone red.

Limits

This wires delivery, not obedience. Whether a compacted First Officer acts on the instruction is a live-journey question and is not claimed here.

No lane exercises this — no journey compacts. The contractlint tests cover parity and the token in offline; delivery itself is covered only by the manual spike.

Three deferred risks with promote conditions: an unverified Codex source field, auto-compact inferred rather than observed, and first-run trust not ruled out.


4c

🤖 Generated with Claude Code

clkao and others added 3 commits August 19, 2026 09:11
The receipt+guard mechanism was rejected by the captain after passing
validation twice — not a defect, a wrong design choice: two of ideation's
three grounds for rejecting a host hook did not survive inspection. This
builds the shape the captain actually asked for.

hooks/compact_boot_reminder.sh registers as a second command under
hooks.json's existing SessionStart/^compact$ matcher, activated by
re-adding .claude-plugin/plugin.json's "hooks" field — a path this repo
already carried live once (042f926..6b2ae97) for the unrelated,
since-removed dispatch-ack hooks, not a new mechanism. The pre-existing
codex_session_start_compact.sh entry is untouched; Codex has no proven
compaction callback and issue #595 owns that boundary.

Proof is split into three honest tiers: the hook's own content and silence
on non-compact input plus its manifest/hooks.json wiring are unit-tested and
each falsified by hand; that SessionStart stdout reaches model context is
relied on as Claude Code's documented general hook contract rather than
re-spiked; that the FO obeys the reminder is declared untestable by any
fixture and left as a live-journey observation.

Net +230 across 6 files, far below both figures that belonged to the
abandoned mechanism (+245 approved, +968 actual).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hooks.json is shared by both .claude-plugin/plugin.json and
.codex-plugin/plugin.json, so re-activating it for Claude (previous commit)
meant the pre-existing Codex-only codex_session_start_compact.sh would now
ALSO fire on Claude, alongside the new Claude-specific script added that
commit — two reminder blocks per compaction, on both hosts. Caught before
merge.

Rename codex_session_start_compact.sh to session_start_compact_reminder.sh,
rewrite it with this entity's reminder text, and register it as the sole
command under hooks.json's existing SessionStart/^compact$ matcher. Delete
the now-redundant Claude-only script. Keep the SPACEDOCK_BIN gate (launcher-
marked session, not merely plugin-installed) and the JSON
hookSpecificOutput.additionalContext output shape unchanged from the script
being replaced, since that shape was live-verified for Codex command
resolution (e143969) and this change should not pair a text update with an
unverified format change on that host.

Tests rewritten to cover installation, not just firing: the wiring test now
asserts hooks.json registers exactly one command (catching the duplication
this commit fixes) and that both plugin manifests activate it; the gate test
uses an explicit, non-inherited environment so an ambient SPACEDOCK_BIN
(this entity's own incident) can't mask the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t PLUGIN_ROOT

Required live spike (captain-mandated: prove model-context delivery, not
documented platform behavior) caught a real bug before it reached a real
user. First spike run against the exact committed hooks.json, via
`claude --plugin-dir` pointed at a disposable copy of this worktree with a
sentinel token injected into the hook's additionalContext:

    SessionStart:compact hook error
    Failed with non-blocking status code: /bin/sh: /hooks/session_start_compact_reminder.sh: No such file or directory

Root cause, found with a diagnostic env-dump hook: Claude Code sets
CLAUDE_PLUGIN_ROOT for a --plugin-dir-loaded plugin's hook subprocess. It
never sets a bare PLUGIN_ROOT — that is Codex's token (e143969, verified
live on codex-cli 0.144.6). Under /bin/sh, the unset ${PLUGIN_ROOT} expanded
to empty, producing the broken relative-looking path in the error above.
This bug predates this entity (inherited from the pre-existing
codex_session_start_compact.sh entry) but was inert until this cycle
activated hooks.json for Claude for the first time.

Fixed with ${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT}}, verified correct for both
hosts by running the template through /bin/sh under each host's real env
shape, then re-ran the full spike: no hook error, and the sentinel was
reproduced verbatim by the resumed model with no tool calls used. This is
the empirical delivery proof the sibling claude-post-compaction-contract-
reload entity's AC-1 asked for and never ran.

New TestSessionStartCompactReminderPluginRootFallbackResolves executes the
live hooks.json command template through /bin/sh under Claude-shaped and
Codex-shaped environments and asserts the resolved path — a JSON string-
equality check would not have caught this, since the broken template was
syntactically valid JSON and a syntactically valid shell command; only
executing it reveals the wrong path. Confirmed the new test and the wiring
test both go red against the pre-fix command, then restored byte-identically.

Full spike transcript, including the broken run, in
docs/dev/.spacedock-state/force-boot-at-compaction-boundary/ideation-spike-evidence.md
section 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clkao added 2 commits August 19, 2026 13:06
…te-copy

Cycle-5 correction, three prose-only defects on an already-approved and
validated mechanism:

- docs/runtime-support.md answers "how do I add a new runtime host," not
  "how does this hook work on the two hosts we have." Cut the +65-line
  mechanism narrative (commit SHA, spike story, PLUGIN_ROOT bug) down to two
  sentences: hooks.json is shared, a new host that activates it inherits the
  reminder, and it must supply its own plugin-root variable.
- hooks/session_start_compact_reminder.sh carried 18 comment lines around 4
  lines of code. Cut to the house style (shebang + one ABOUTME line), keeping
  only the SPACEDOCK_BIN gate's non-obvious rationale in a clause. Everything
  else (PreCompact-vs-SessionStart reasoning, the entity pointer, the
  can't-force-the-next-tool-call caveat) already lives in the entity, where
  it won't rot.
- session_start_compact_reminder_hook_test.go's wantReminder const was a
  byte-for-byte copy of the reminder payload the same change authored on
  both sides — a tautology that would red on any rewording. Replaced with
  the properties that actually matter and aren't proven elsewhere: valid
  JSON, hookEventName == SessionStart, non-empty additionalContext, and it
  names the boot command. Verified by hand: a pure rewording (keeping
  "status --boot") now passes; dropping the boot-command mention still reds.

No change to hooks.json, plugin.json, the printf payload, or any other test.
go test ./... and go test ./... -race both green except the same
pre-existing, unrelated TestCodexResolveManifestAgainstInstalledHost.
Defect 4, cycle-5 correction: the captain's actual complaint was that the
file buried the one invariant this entity delivers — HOST SIGNATURE PARITY
(both plugin manifests activate the same hooks.json, exactly one
SessionStart/^compact$ entry, resolving to the same script under either
host's plugin-root variable) — under a pile of tests permuting shell
built-ins.

- Reordered: TestSessionStartCompactReminderHookIsWired (static) and
  TestSessionStartCompactReminderPluginRootFallbackResolves (dynamic) now
  lead the file, with a joint doc comment naming the parity invariant they
  prove together. Extracted the shared hooks.json-parsing logic into
  sessionStartCompactCommand() to cut duplication between them.
- TestSessionStartCompactReminderHookGate cut from 9 cases to 3: one open
  case (compact + launcher-marked) and the two closed cases with an
  external source (SPACEDOCK_BIN unset; source != compact). Dropped
  SPACEDOCK_BIN-set-but-empty, source=resume/clear, empty stdin, malformed
  non-JSON stdin, and non-string source — those exercised sed and test(1)'s
  own semantics, not this entity's invariant.

File: 288 -> 185 lines (-36%). Verified every remaining assertion is
load-bearing by breaking it by hand and watching the right subtest (and
only that subtest) red, then restoring byte-identically: a duplicate
hooks.json command entry, reverting the plugin-root fallback to the bare
${PLUGIN_ROOT} token (reproduces the original shipped bug exactly — reds
the Claude-shaped case, passes the Codex-shaped one, matching the historical
record), dropping the SPACEDOCK_BIN gate, and dropping the source=compact
check.

go test ./... and gofmt clean; internal/contractlint green. Same
pre-existing, unrelated TestCodexResolveManifestAgainstInstalledHost
failure as every prior cycle.
The reminder said "re-run the Startup procedure". Startup ends at the
interaction boundary, whose interactive branch presents a summary, STOPS
for input, and forbids auto-dispatch — so a compacted FO that obeyed the
text demoted an engaged session to a cold one and made the captain
re-issue engage. Reproduced live.

The payload now mirrors the shared core's "Compaction continuity" After
clause: re-satisfy each load precondition and state read at its existing
trigger before the next workflow effect, then resume the loop. The four
distrust items (gate readiness, live workers, SPACEDOCK_BIN, contract
version) are unchanged. One line, payload string only — the gate, the
matcher, hooks.json, both manifests, and the tests are untouched.
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