Development record
Capture the Session You Just Had, Because You Cannot Capture It Later
Press Release
abcd now stores your session transcripts as they happen. A
SessionEndhook fires when a session finishes, and abcd redacts the transcript and files it in the local per-repo store — no flag to remember, no command to run.abcd history listshows what it has. Everything stays on your machine, secrets and home paths stripped on write, and a session that ends twice is stored once."The reason I do this work is that the why lives in the conversation, not the commit," said Maya, autonomous-development practitioner. "For a year my rationale evaporated when the window closed. Now it doesn't. The part I did not expect: it captures nothing when it cannot capture safely, and it never once got in my way."
Why This Matters
This is the only irreversible thing on the board. internal/core/history is built. history.Capture redacts on write, is idempotent, and is fail-closed. It is called by nothing — hooks/hooks.json wires UserPromptSubmit, SessionStart and PreCompact, and no session-termination hook exists. ~/.abcd/ has never been created. Zero transcripts.
Every other gap in abcd can be closed later at the cost of the work. This one cannot: a session that ends without being captured is gone. No future feature, however good, can reconstruct a conversation nobody stored. The corpus can only start accruing from the moment the hook is wired — so the cost of not shipping it is not "a delay", it is a permanent hole in the record that grows every day it is not shipped.
The lifeboat's Pass B — mining chat for the rationale nobody wrote down — has no corpus and cannot get one retroactively (adr-35, itd-88). It ships as a declared exemption until this does.
The verb has to be new. history capture cannot be wired to a session hook: reading from stdin it requires --session <id>, and a SessionEnd hook delivers its session id inside a JSON payload, not as a flag.
The event is SessionEnd, not Stop. Stop fires once per assistant turn, and Claude Code's transcript file grows through the session, so a Stop-wired capture would store a fresh, larger superset of the same session every turn — history.Capture's sha256 dedup only collapses byte-identical re-captures, which never happens on a live transcript. SessionEnd fires once when the session terminates, and by the harness contract its exit code and stdout are ignored — a perfect fit for a fail-closed, non-blocking side-effect hook. This is the one place the milestone deviates from its plan's literal wording, on evidence from the harness docs.
What's In Scope
abcd hook session-end— a hidden, operator-internal verb following the shippedhook prompt-routerpattern. Reads theSessionEndpayload from stdin, takessession_idandtranscript_path, and callshistory.Capture(which already redacts).- A
SessionEndentry inhooks/hooks.json— without this the verb is dead code and the corpus still does not accrue. - Fail-closed, always exit 0, never blocks the host. A malformed payload, an absent transcript, a hostile session id, or a repo with no root commit each capture nothing, log out of band, and exit 0.
- Idempotent re-capture — a SessionEnd hook can fire more than once per session; the corpus must not grow a duplicate each time.
What's Out of Scope
- Retrieval, search, or synthesis over the corpus. This intent starts the clock; reading the corpus is later work (Pass B, itd-36).
- Importing historical sessions. There is nothing to import — that is exactly the loss this intent stops compounding.
- Changing the redaction engine.
history.Capturealready redacts two-stage and fail-closed; this intent calls it, it does not touch it. - Any user-facing surface.
hookis operator-internal wiring, not a/abcd:command.
Acceptance Criteria
BDD format, per
itd-1-acceptance-gates. These gates are checked byintent-fidelity-reviewerwhen this intent moves toshipped/.
- Given a repo where abcd is installed, when a session ends and the
SessionEndhook fires, then the transcript is redacted and stored, andabcd history listshows the record — with no flag passed and no command typed by the user. - Given a session that ran for many turns (so its transcript grew), when it ends, then the store holds exactly one record for that session — capture is wired to
SessionEnd(once per session), notStop(once per turn), so a long session does not leave a pile of growing supersets. - Given the same transcript is offered twice (a SessionEnd hook can fire more than once for one session), when the second capture runs, then the store holds one record, not two.
- Given a malformed payload, an absent
transcript_path, atranscript_paththat is not a regular file, a session id that is not[A-Za-z0-9._-]+, or a cwd that is not a git repo with commits, when the hook runs, then it writes nothing, exits 0, and reports the reason on stderr — a SessionEnd hook that errors or hangs wedges the user's session, which is strictly worse than a missed transcript. - Given a
transcript_pathnaming a FIFO or a device node, when the hook opens it, then the open does not block — the hook must never hang the session it is ending. - Given any capture, when the hook runs, then it writes nothing to stdout — a SessionEnd hook's stdout is not a channel to the model; diagnostics go to stderr, out of band, as the prompt-router already does.
- Given a transcript containing a secret or an absolute home path, when it is captured, then the stored record is redacted (the existing two-stage, fail-closed pass), and if a hard-fail span survives redaction no file is written at all.
Prior Art
- adr-29 decided the native local redacted transcript corpus.
internal/core/historyimplements it. This intent is the missing trigger: the store has existed, correct and unused, with no caller. - itd-3 (rules loader, shipped) established the pattern this follows exactly: a hidden Go subcommand invoked from
hooks/hooks.json, fail-closed, non-blocking, exiting 0 whatever happens, with diagnostics out of band on stderr.hook session-endis a third entrypoint in the samehooksub-tree — no new mechanism is invented here. - adr-35 / itd-88 name the cost this intent stops: Pass B has no corpus and cannot get one retroactively, so it ships as a declared exemption until this lands. This intent is sequenced first in that plan for exactly that reason.
- itd-59 (autonomous-worker transcript capture, draft) is the adjacent case — capturing transcripts of unattended workers. It is not this: this intent covers the ordinary interactive session, which is the corpus that is being lost today.
- The
--pages-jsonseam onmemory.Distillerandintent review ingest --verdict-jsonare the shipped precedents for host-supplied data crossing into the core; the SessionEnd payload is the same shape of trust boundary and is validated at the same boundary.
Open Questions
- The 64 MiB transcript cap: generous for a JSONL session log, chosen so a pathological file cannot stall the redaction pass inside a SessionEnd hook. If a real session ever exceeds it, the capture is refused — and the refusal is logged, so we would see it. Is refusing right, or should an over-cap transcript be captured in part? (Capturing in part breaks the sha256 idempotency, so refusal is the conservative default.)
- Should the hook also fire on
SubagentStop? Subagent transcripts are a different corpus with a different signal density; deliberately not decided here. (SubagentStop, likeStop, fires per subagent-turn, so it would carry the same growing-superset problemSessionEndsidesteps.) SessionEnddoes not fire on a hard crash orSIGKILL(only on orderly termination —/exit,/clear, logout, EOF). A session killed uncleanly is therefore not captured. The alternative — wiringStopfor per-turn crash-resilience — trades that for N growing records per session and would needCaptureto dedup onsession_idrather than raw sha256 (a change to shipped core with its own tests). Deferred as a deliberate trade-off; the common case (orderly exit) is covered, and the rarer crash case is recorded rather than silently assumed away.
Audit Notes
Fidelity review — receipt rcp-791c91982a80 (verifier intent-fidelity-reviewer claude-fable-5).
Provenance: intent-fidelity-reviewer@claude-fable-5 · rubric_hash sha256:95792472ae74ca0469f69a51c618946e0d33cb1380032460099ed4b469d67e86 · prompt_hash sha256:82804d99f5a1de14cee029c6d45847ab447907d09bdd9b1610791dfd28d15143 Input attestations: diff:c3aeab7b08d0e8b9154bf47d70411f281ac5c72e@-;
Acceptance rollup: MET 7 · MET_WITH_CONCERNS 0 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET: hooks.json wires SessionEnd to
abcd hook session-end, which calls history.Capture (redact-on-write) with no user flag;abcd history listrenders history.List; TestHookSessionEndCapturesTranscript proves one stored, listable record (suite passes:go test -run TestHookSessionEnd ./internal/surface/cli/ok) evidence: hooks/hooks.json:16 — "{"type": "command", "command": "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook session-end"}" evidence: internal/surface/cli/cli.go:890 — "res, err := history.Capture(captureRoot(cwd), det.RootSHA, in.SessionID, raw, "native")" evidence: internal/surface/cli/cli.go:2111 — "Use: "list"" evidence: internal/surface/cli/hook_session_end_test.go:93 — "if len(recs) != 1 {" - ac-2 — MET: hooks.json contains a SessionEnd entry and no Stop entry anywhere, and TestHookSessionEndOneRecordPerSession asserts a multi-turn grown transcript leaves exactly one record evidence: hooks/hooks.json:15 — ""SessionEnd": [" evidence: internal/surface/cli/hook_session_end_test.go:148 — "if len(recs) != 1 {" evidence: internal/surface/cli/cli.go:840 — "Wired to SessionEnd, NOT Stop."
- ac-3 — MET: Capture's sha256 dedup returns Wrote=false on an identical re-offer, and TestHookSessionEndIsIdempotent runs the same payload twice through the hook and asserts one record evidence: internal/core/history/history.go:121 — "if r.SourceSHA256 == sourceSHA && r.SessionID == sessionID && r.SourceKind == kind {" evidence: internal/surface/cli/hook_session_end_test.go:119 — "if len(recs) != 1 {"
- ac-4 — MET: every guard degrades via warn() which prints to stderr and returns nil (exit 0); TestHookSessionEndNeverBlocksTheHost table covers malformed json, empty payload, missing transcript_path, absent file, directory (non-regular), hostile session id ../../escape (rejected by history's ^[A-Za-z0-9._-]+$), and non-repo cwd (RootSHA=="" guard), asserting non-empty stderr and zero records with runHook failing on any non-zero exit
evidence: internal/surface/cli/cli.go:866 — "return nil // never non-zero: a Stop hook must not wedge the session"
evidence: internal/surface/cli/cli.go:883 — "if err != nil || det.RootSHA == "" {"
evidence: internal/core/history/store.go:25 — "var sessionIDRe = regexp.MustCompile(
^[A-Za-z0-9._-]+$)" evidence: internal/surface/cli/hook_session_end_test.go:195 — "if strings.TrimSpace(errlog) == "" {" evidence: internal/surface/cli/cli_test.go:107 — "if err := cmd.Execute(); err != nil {" - ac-5 — MET: readTranscript opens O_RDONLY|O_NONBLOCK then rejects non-regular files, and TestHookSessionEndDoesNotBlockOnIrregularFiles exercises a writer-less FIFO and /dev/null under a 10-second watchdog, asserting no hang, exit 0, stderr reason, zero records evidence: internal/surface/cli/cli.go:967 — "f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)" evidence: internal/surface/cli/cli.go:977 — "if !st.Mode().IsRegular() {" evidence: internal/surface/cli/hook_session_end_test.go:360 — "case <-time.After(10 * time.Second):"
- ac-6 — MET: every diagnostic in the session-end RunE goes to cmd.ErrOrStderr() and nothing writes to stdout; TestHookSessionEndWritesNothingToStdout asserts stdout == "" on a successful capture evidence: internal/surface/cli/cli.go:865 — "fmt.Fprintf(cmd.ErrOrStderr(), "abcd history: "+format+"\n", a...)" evidence: internal/surface/cli/hook_session_end_test.go:315 — "if stdout != "" {"
- ac-7 — MET: the hook path triggers the existing two-stage redaction (TestHookSessionEndRedactsOnThisPath asserts a ghp_ token and a home path are masked in the stored record), and a hard_fail span surviving stage-two refuses the write entirely (TestHookSessionEndRefusesResidualHardFail: "refusing to write" on stderr, zero records) evidence: internal/core/history/history.go:68 — "history: redaction left %d hard_fail span(s) unresolved [%s]; refusing to write" evidence: internal/surface/cli/hook_session_end_test.go:248 — "if strings.Contains(string(stored), token) {" evidence: internal/surface/cli/hook_session_end_test.go:433 — "if !strings.Contains(errlog, "refusing to write") {"
Gap audit:
- honoured:
- SessionEnd hook wired so the corpus accrues with no flag and no user command evidence: hooks/hooks.json:16 — "\"$CLAUDE_PLUGIN_ROOT/abcd\" hook session-end"
- fail-closed, always exit 0, never blocks the host — every failure path logs to stderr and returns nil evidence: internal/surface/cli/cli.go:866 — "return nil // never non-zero"
- a session that ends twice is stored once evidence: internal/core/history/history.go:122 — "return CaptureResult{Record: r, Wrote: false}, nil"
- secrets and home paths stripped on write, refusal on residual hard-fail evidence: internal/core/history/history.go:156 — "Stage two — verify. Re-scan the redacted text"
- 64 MiB cap refuses an over-cap transcript whole rather than truncating (open question resolved to refusal) evidence: internal/surface/cli/cli.go:958 — "const maxTranscriptBytes = 64 << 20 // 64 MiB" evidence: internal/surface/cli/hook_session_end_test.go:396 — "if !strings.Contains(errlog, "cap") {"
- diverged:
- "no flag to remember, no command to run" — capture still requires a one-time store bootstrap (
ahoy installcreates the transcripts dir); an uninstalled repo captures nothing, mitigated by a delivered SessionStart warning (iss-95) rather than by auto-creation evidence: internal/surface/cli/hook_session_end_test.go:53 — "Capture requires the transcripts dir to exist already (abcd install creates it)." evidence: internal/surface/cli/cli.go:944 — "Run/abcd:ahoy install(orabcd ahoy install) to start recording."
- "no flag to remember, no command to run" — capture still requires a one-time store bootstrap (
- missing:
- "stores your session transcripts as they happen" — a session ended by hard crash or SIGKILL fires no SessionEnd and is never captured; declared as a deliberate trade-off in the intent, not closed by the delivery
evidence: .abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md:71 — "
SessionEnddoes not fire on a hard crash orSIGKILL"
- "stores your session transcripts as they happen" — a session ended by hard crash or SIGKILL fires no SessionEnd and is never captured; declared as a deliberate trade-off in the intent, not closed by the delivery
evidence: .abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md:71 — "
Frontmatter
| id | itd-89 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | standalone |
| severity | critical |
.abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md
open on GitHub ↗ · commit history ↗
References2
Referenced by9
- iss-2610262032188853itd-89 files transcripts in the local per-repo store; invariant 15 makes the user-level `~/.abcd.noindex/transcripts/<root-sha>/` the store, with a per-repo store only for a declared checkoutrelated
- iss-2610262033217382itd-89 files SessionEnd transcripts in the local per-repo store; 01-ahoy keeps the per-repo transcripts folder for opted-in checkouts onlyrelated
- iss-2610262036461110itd-89 files session transcripts in a local per-repo store; the history chapter says the store is user-level and the per-repo location is only an opt-inrelated
- iss-2610262038074426itd-89 files SessionEnd transcripts in the local per-repo store; 31-lab says the transcript store is machine-scoped and keyed on the root commitrelated
- iss-2610262040062985The README's hook row says session-end only stages the transcript and session-start files it away; itd-89 says session-end calls history.Capture, which redacts and files itrelated
- iss-2610262040135811The configuration chapter keeps transcripts in a user-scope store under ~/.abcd.noindex/transcripts; itd-89 files them in a local per-repo storerelated
- iss-2610262042214474itd-89 files captured transcripts in a local per-repo store; the build sequence keeps the transcript store outside every checkout, at the user level.related
- iss-2610262045100283The prompt router is the Python file hooks/prompt_router_hook.py in itd-39 and the shipped hidden verb `hook prompt-router` in itd-89related
- spc-4start-the-transcript-clockimplemented by
mentions11
- adr-35The lifeboat is a coverage experiment — read-only, out-of-tree, and proven before it is packed (supersedes adr-4)
- dcn-26071400000000072026-07-14 (M1, itd-89/spc-4) — Transcript capture is wired to `SessionEnd`, NOT the
- dcn-26071400000000082026-07-14 (M1) — iss-95: wiring the hook does NOT by itself start the clock. `history.
- iss-95Wiring the Stop hook does NOT by itself start the transcript clock: history.Capture requires ~/.abcd/history/<root-sha>/transcripts/ to already exist and deliberately never creates it (ownedDirsReal validates the store's dirs are real, not symlinks). That dir is bootstrapped by 'abcd ahoy install'. On a machine where install has not run — including this one, where ~/.abcd/ does not exist at all — 'hook session-end' fails closed, logs to stderr, exits 0, and captures NOTHING. Silently. That is precisely the failure mode itd-89 exists to prevent: a hook that appears wired while the corpus never accrues. Decide: (a) the hook bootstraps the store itself (changes Capture's stated precondition, and a hook creating dirs is a trust-boundary act the ownedDirsReal discipline deliberately avoids), or (b) 'ahoy install' stays the sanctioned bootstrap and the not-installed case is made LOUD rather than a stderr line nobody reads (e.g. ahoy doctor already flags history.bootstrap_missing as a required gap). Until this is settled, itd-89's acceptance is met in code but not on any machine that has not installed.
- iss-217add a cross-agent session-capture source as an optional plugin
- itd-1Every Intent Has Acceptance Criteria; Every Audit Verifies Them
- itd-36Knowledge That Compounds, Not Knowledge That Re-Derives
- itd-59Every Autonomous Run Pass Leaves the Same Durable, Queryable Transcript an Interactive Session Does
- itd-88Know What a Repo Can and Cannot Tell You — Before You Trust the Lifeboat
- itd-109Acceptance Criteria Verify Themselves, And The Manual Rest Renders For A Human
- spc-3lifeboat-coverage-experiment