Skip to content

fix: one SessionStart entry, and a successful install reads as success (iss-204, iss-208) - #227

Merged
REPPL merged 3 commits into
mainfrom
fix/iss-204-sessionstart-collapse
Aug 12, 2026
Merged

fix: one SessionStart entry, and a successful install reads as success (iss-204, iss-208)#227
REPPL merged 3 commits into
mainfrom
fix/iss-204-sessionstart-collapse

Conversation

@REPPL

@REPPL REPPL commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Item A2 of the install-experience run
(.abcd/development/plans/2026-08-11-install-experience.md). Resolves
iss-204 and iss-208, both in this PR.

The defect

hooks/hooks.json listed bootstrap.sh and the two binary-gated commands as
three sibling SessionStart entries and relied on list order. The harness runs
every 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's notice() 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 SessionStart exit as a hook error. A first-time user's
opening screen was three consecutive hook errors, one of which was the install
succeeding.

The change

The three entries collapse into one SessionStart 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 stderr is captured and emitted first, so the single
    line the transcript renders is the checksum-verified success rather than a
    missing-binary complaint.
  • The two missing-binary complaints become one; it is still said out loud when
    the binary is genuinely absent.
  • A refusal keeps its own first line and its own exit code; the wrapper adds no
    second complaint on top of one.
  • The binary calls' stdout passes through untouched — it is the model-context
    channel.
  • An unset CLAUDE_PLUGIN_ROOT exits 0 and says nothing, rather than reaching
    for /hooks/bootstrap.sh.
  • The $CLAUDE_PLUGIN_ROOT/… references stay in literal form, so
    internal/core/launch's hook-command payload gate still resolves
    hooks/bootstrap.sh and abcd out 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 $binary path, which is worthless if
the 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.md said session-start was
"a second SessionStart command"; 05-internals/03-configuration.md's tree
comment omitted the bootstrap).

Red → green witnessed

New test file internal/surface/cli/hooks_sessionstart_test.go, written first.

  1. Red, against the shipped 3-entry manifest — all six tests fail at
    the SessionStart entry group must hold exactly one command (siblings race each other), found 3.
  2. Red again, against a naive one-entry chain
    (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 0
    with two raw sh: …/abcd: No such file or directory lines; the bootstrap's refusal exit must propagate; code = 127; and an unset
    CLAUDE_PLUGIN_ROOT producing three raw shell errors.
  3. Green with the committed command: 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 run
against 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 SessionStart hooks demonstrably having run.

Review round 1 — two findings from the adversarial review, both fixed in ad50cd1

Collapsing 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.ReadAll over the whole of stdin (readHookInput,
cli.go:792), so the first call consumed the entire harness payload and the
second read 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. 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; the
bootstrap is additionally given </dev/null, so nothing upstream of that read
can consume the payload.

2. MAJOR — with stdin fixed, the wrong line led. hook prompt-router-reset
ends with an unconditional abcd rules: reset session diagnostic on stderr, so
running first it owned the only line the transcript renders and session-start's
actionable notice was never seen. Fix: hook session-start runs first. The
exit 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:

  • against 90d8339, the entire output was abcd rules: reset session ("SessionStart") at exit 0 — session-start silently disabled;
  • against ad50cd1, the iss-95 notice leads and the exit is 2, identical to
    invoking hook session-start standalone.

abcd launch --dry-run --json still resolves both requirements out of the
command string (hooks/bootstrap.sh → payload, abcd → installed), and
verifyHookManifest still finds its hook prompt-router-reset substring.

Why the six tests missed it: sessionStartRun never set cmd.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 to stderr. Red witnessed against the
committed 90d8339 command: hook session-start stdin=[] (payload gone) and
first 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 preflight green and gofmt -l . empty, both run in this worktree at
    branch fix/iss-204-sessionstart-collapse, asserted SHA
    90d8339d98e3664f44e5e415f13b5fad987a3ddc for the first round and
    ad50cd1 for the review round (branch and SHA checked immediately before and
    after each run — unchanged, so both results stand).
  • Ledger: abcd capture resolve iss-204 … --impact fix and abcd capture resolve iss-208 … --impact fix, both in the same commit. No relative link
    anywhere pointed at either issue's open/ path, so nothing needed
    repointing; record-lint's links_resolve is green under preflight.

All six PRs in this run touch CHANGELOG.md — the maintainer resolves the
conflicts on merge. The entry is appended at the top of ### Fixed under
[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 $binary path. That sentence is
untouched 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]

REPPL added 3 commits August 12, 2026 11:50
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
@REPPL
REPPL enabled auto-merge August 12, 2026 14:23
@REPPL
REPPL merged commit 3ee1bb9 into main Aug 12, 2026
13 checks passed
@REPPL
REPPL deleted the fix/iss-204-sessionstart-collapse branch August 12, 2026 14:26
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