fix: one SessionStart entry, and a successful install reads as success (iss-204, iss-208) - #227
Merged
Merged
Conversation
hooks/hooks.json listed the bootstrap and the two binary-backed commands as three sibling SessionStart entries and relied on list order. The harness runs every hook matching an event in parallel, so both gated entries raced the ~10.7 MB download, lost, printed "the plugin binary is not installed", and genuinely did not run — on every fresh install and every plugin update, since an update lands in a fresh cache directory with no binary. The three entries collapse into ONE command that runs the bootstrap and then both binary calls in a single shell, so the sequencing is owned by the manifest rather than assumed of the harness. The bootstrap's own message is emitted first, which is what the transcript renders: on a fresh install the visible line is the checksum-verified success rather than one of two missing-binary complaints (iss-208), and the two complaints collapse into one. A refusal keeps its message and its exit code, a genuinely absent binary is still said out loud, and the binary calls' stdout still reaches the model untouched. spc-21 carried the false warrant this rested on — "ordering within one event's hook list is preserved by the harness" — as a load-bearing claim. It is corrected in place with a note not to restore it, along with the brief's two descriptions of the manifest. Parallel hook execution and the plugin cache are not present in CI, so the end-to-end proof is the manual install gate. What CI holds is new: the manifest's shape (one entry, one command, bootstrap before the binary calls, valid POSIX sh) and the chained command's behaviour against fixtures — success leads the stderr, both calls run against the just-installed binary, the missing binary is reported exactly once, a refusal's first line and exit code survive, and an unset CLAUDE_PLUGIN_ROOT does nothing. Resolves iss-204, iss-208. Assisted-by: Claude:claude-opus-5[1m]
…rt lead
Two defects in the collapsed SessionStart command, both found by adversarial
review and both reproduced here with the built binary before being fixed.
Shared stdin (blocker). Every hook verb takes its input with io.ReadAll over the
whole of stdin (readHookInput, cli.go:792), so the two chained calls sharing one
stdin left the second reading EOF: json.Unmarshal failed and `hook session-start`
took its silent return-nil path (cli.go:979), permanently disabling both of its
notices — the iss-95 "transcripts will not be captured" warning and the itd-105
skew notice — in every session, fresh install and steady state alike. Collapsing
the entries is what created this; three sibling hooks each got their own stdin.
The wrapper now reads the payload once (`i=$(cat)`) and pipes a copy to each
call. The bootstrap is additionally given `</dev/null`, so nothing upstream of
the read can consume the payload.
Ordering (major, must land with the above). `hook prompt-router-reset` ends with
an unconditional "abcd rules: reset session" diagnostic on stderr, so with stdin
fixed it would own the single line the transcript renders and session-start's
actionable notice would never be seen. `hook session-start` now runs first. The
exit precedence — bootstrap, then session-start, then the reset — is computed
from saved status codes and is unaffected by the order.
Verified with the real binary, same payload on stdin, plugin root holding a
built abcd. Before: the whole output was `abcd rules: reset session
("SessionStart")` at exit 0. After: the iss-95 notice leads and the exit is 2,
identical to invoking `hook session-start` standalone. The launch payload gate
still resolves both requirements out of the command string (hooks/bootstrap.sh
as payload, abcd as installed), and verifyHookManifest still finds its
`hook prompt-router-reset` substring.
The tests missed both because the runner never set stdin and the stub binary
never read it. The stub now consumes stdin and records what it saw, and writes
the reset's success diagnostic; a new case asserts every call receives the whole
payload, and the steady-state case asserts session-start's notice is the first
stderr line.
Assisted-by: Claude:claude-opus-5[1m]
…art-collapse Assisted-by: Claude:claude-fable-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item A2 of the install-experience run
(
.abcd/development/plans/2026-08-11-install-experience.md). Resolvesiss-204 and iss-208, both in this PR.
The defect
hooks/hooks.jsonlistedbootstrap.shand the two binary-gated commands asthree sibling
SessionStartentries and relied on list order. The harness runsevery hook matching an event in parallel. Both gated entries raced the
~10.7 MB release download, lost, printed "the plugin binary is not installed",
and genuinely did not run — on every fresh install and every plugin update,
since an update lands in a fresh commit-stamped cache directory with no binary.
iss-208 compounded it:
bootstrap.sh'snotice()exits 2 for a sound reason(only a non-zero exit puts stderr in front of the human), but the harness
renders any non-zero
SessionStartexit as a hook error. A first-time user'sopening screen was three consecutive hook errors, one of which was the install
succeeding.
The change
The three entries collapse into one
SessionStartcommand that runs thebootstrap and then both binary calls in a single shell, so the sequencing is
owned by the manifest rather than assumed of the harness.
line the transcript renders is the checksum-verified success rather than a
missing-binary complaint.
the binary is genuinely absent.
second complaint on top of one.
channel.
CLAUDE_PLUGIN_ROOTexits 0 and says nothing, rather than reachingfor
/hooks/bootstrap.sh.$CLAUDE_PLUGIN_ROOT/…references stay in literal form, sointernal/core/launch's hook-command payload gate still resolveshooks/bootstrap.shandabcdout of the command string.The exit stays non-zero on success. There is no notice channel distinct
from error, and the success notice is the one place PATH setup is suggested —
A3 replaces that line with the absolute
$binarypath, which is worthless ifthe human never sees it. So the honest reading of the plan's §4 assertion 3 for
this PR is one notice line whose text is the success, not zero rendered
notices; a maintainer who wants literal zero is choosing to hide the install
receipt from the human and hand it to the model instead. Flagging rather than
deciding it.
spc-21's false warrant, corrected in the same change
.abcd/development/specs/closed/spc-21-a-fresh-plugin-install-just-works-the-hook-binary-survives.md§ Hook wiring asserted "Ordering within one event's hook list is preserved by
the harness, so the binary-backed session hooks run after the bootstrap in the
same event." That is false and it was load-bearing. It now records the
collapsed wiring, followed by a dated Correction block quoting the retired
warrant, naming parallel execution as the truth, and saying do not restore
it. The Scope bullet is corrected with it, as are the brief's two
descriptions of the manifest (
04-surfaces/01-ahoy.mdsaidsession-startwas"a second
SessionStartcommand";05-internals/03-configuration.md's treecomment omitted the bootstrap).
Red → green witnessed
New test file
internal/surface/cli/hooks_sessionstart_test.go, written first.the SessionStart entry group must hold exactly one command (siblings race each other), found 3.(
bootstrap.sh; abcd hook prompt-router-reset; abcd hook session-start,applied temporarily to prove the behavioural assertions bite beyond the
structural count) —
the missing binary must be reported exactly once, got 0with two raw
sh: …/abcd: No such file or directorylines;the bootstrap's refusal exit must propagate; code = 127; and an unsetCLAUDE_PLUGIN_ROOTproducing three raw shell errors.ok github.com/REPPL/abcd-cli/internal/surface/cli.What the tests hold: one entry / one command / bootstrap before the binary
calls / valid POSIX sh (
sh -n); success leads the stderr and both calls runagainst the just-installed binary; every call receives the whole hook payload;
the steady-state session runs both calls and puts session-start's notice first;
the missing binary is reported exactly once and no call runs; a refusal's first
line and exit code survive; an unset plugin root is a silent no-op.
What remains manual
Parallel hook execution, the plugin cache, and the harness's transcript
rendering are not present in CI — CI cannot observe any of them. This PR is
not proof that a fresh install is clean; it is proof of the manifest's shape and
of the chained command's behaviour against fixtures. The end-to-end assertions
are the plan's §4 gate, on a real install with no Go toolchain: zero hook lines
that read as faults, the bootstrap's success reading as success, and both
binary-backed
SessionStarthooks demonstrably having run.Review round 1 — two findings from the adversarial review, both fixed in
ad50cd1Collapsing three sibling hooks into one shell created two problems that did not
exist while they were siblings. Both were reproduced with the built binary
before being fixed.
1. BLOCKER — the two chained calls shared one stdin. Every hook verb takes
its input with
io.ReadAllover the whole of stdin (readHookInput,cli.go:792), so the first call consumed the entire harness payload and thesecond read EOF:
json.Unmarshalfailed andhook session-starttook its silentreturn nilpath (cli.go:979), permanently disabling both of its notices — theiss-95 "transcripts will not be captured" warning and the itd-105 skew notice —
in every session, fresh install and steady state alike. Three sibling hooks each
got their own stdin, so this was the collapse's own regression. Fix: the
wrapper reads the payload once (
i=$(cat)) and pipes a copy to each call; thebootstrap is additionally given
</dev/null, so nothing upstream of that readcan consume the payload.
2. MAJOR — with stdin fixed, the wrong line led.
hook prompt-router-resetends with an unconditional
abcd rules: reset sessiondiagnostic on stderr, sorunning first it owned the only line the transcript renders and session-start's
actionable notice was never seen. Fix:
hook session-startruns first. Theexit precedence (bootstrap, then session-start, then the reset) is computed from
saved status codes and is order-independent, so nothing else moved.
Verified with the real binary, identical payload on stdin, a plugin root
holding a built
abcd:90d8339, the entire output wasabcd rules: reset session ("SessionStart")at exit 0 — session-start silently disabled;ad50cd1, the iss-95 notice leads and the exit is 2, identical toinvoking
hook session-startstandalone.abcd launch --dry-run --jsonstill resolves both requirements out of thecommand string (
hooks/bootstrap.sh→ payload,abcd→ installed), andverifyHookManifeststill finds itshook prompt-router-resetsubstring.Why the six tests missed it:
sessionStartRunnever setcmd.Stdinand the stubbinary never read it. The stub now consumes stdin and records what it saw, and
writes the reset's success diagnostic to stderr. Red witnessed against the
committed
90d8339command:hook session-start stdin=[](payload gone) andfirst line
abcd rules: reset session ("SessionStart"). Green after the fix,all seven tests. The spec correction, the brief, and the CHANGELOG entry were
updated with the two properties, since both are easy to lose in a later edit.
The reviewer explicitly cleared the rest of the command — capture order,
$?mechanics, JSON quoting survival, spaces in the root, the unset/empty guard,
exit-precedence non-masking, no deadlock in the capture substitution, and the
spc-21/brief corrections — and none of it was reworked.
Gates
make preflightgreen andgofmt -l .empty, both run in this worktree atbranch
fix/iss-204-sessionstart-collapse, asserted SHA90d8339d98e3664f44e5e415f13b5fad987a3ddcfor the first round andad50cd1for the review round (branch and SHA checked immediately before andafter each run — unchanged, so both results stand).
abcd capture resolve iss-204 … --impact fixandabcd capture resolve iss-208 … --impact fix, both in the same commit. No relative linkanywhere pointed at either issue's
open/path, so nothing neededrepointing;
record-lint'slinks_resolveis green under preflight.All six PRs in this run touch
CHANGELOG.md— the maintainer resolves theconflicts on merge. The entry is appended at the top of
### Fixedunder[Unreleased]with no neighbour reflowed.Collision note
A3 branches off this one and replaces the success notice's first visible line
(~
hooks/bootstrap.sh:274) with the absolute$binarypath. That sentence isuntouched here — this PR changes only which stream that line leads, not its
words — so A3's delta stays a one-sentence rewrite.
Assisted-by: Claude:claude-opus-5[1m]