Development record
start-the-transcript-clock
Summary
spc-4 delivers itd-89's trigger: the session-transcript store
(internal/core/history, adr-29) existed correct and unused, and this spec
wires the missing caller so the corpus starts accruing. It adds one hidden,
operator-internal verb — abcd hook session-end — plus the SessionEnd entry
in hooks/hooks.json that invokes it. When a session ends, the hook reads the
harness payload from stdin, resolves the repo's root-commit SHA, reads the
transcript defensively, and hands it to the already-shipped redacting
history.Capture. No user-facing surface, no flag, no command to remember.
Approach
Follows the shipped hook prompt-router pattern exactly (itd-3): a hidden Go
subcommand under the hook sub-tree, fail-closed, always exit 0, diagnostics
out of band on stderr, stdout kept empty. A new verb was required because
history capture reading stdin requires --session <id>, while the hook
delivers its session id inside the JSON payload.
- Verb:
internal/surface/cli/cli.go(hook session-endblock; RunE parses the payload, resolves cwd →ahoy.Detectroot SHA, callshistory.Capture(captureRoot(cwd), rootSHA, sessionID, raw, "native")). - Defensive read:
readTranscriptopensO_RDONLY|O_NONBLOCK(a FIFO or device node cannot hang the hook), accepts only regular files, and enforces the size cap before reading. - Wiring:
hooks/hooks.jsongains aSessionEndentry running"$CLAUDE_PLUGIN_ROOT/abcd" hook session-end. Without it the verb is dead code; with it the corpus accrues with zero user action. - Redaction is not touched: the two-stage, fail-closed pass lives in
history.Captureand this spec only calls it, per the intent's scope.
Deviation: SessionEnd, not Stop
The plan's literal wording said Stop. Stop fires once per assistant
turn, and the transcript file grows through the session, so a Stop-wired
capture would store a fresh, larger superset every turn — history.Capture's
sha256 dedup only collapses byte-identical re-captures, which never happens on
a live transcript. SessionEnd fires once at orderly termination, and by the
harness contract its exit code and stdout are ignored — the exact shape a
fail-closed, non-blocking side-effect hook needs. Documented trade-off: a hard
crash (SIGKILL) fires no SessionEnd, so an uncleanly killed session is not
captured; the intent records this as deliberate (per-turn crash-resilience
would need session-keyed dedup in shipped core).
Default: the 64 MiB transcript cap
maxTranscriptBytes = 64 << 20. Generous for a JSONL session log and bounded
so a pathological file cannot stall the redaction pass inside a session hook.
An over-cap transcript is refused whole — capturing a truncated prefix would
break the sha256 idempotency key — and the refusal is logged on stderr, so an
over-cap session is visible rather than silently absent.
Milestones as delivered
abcd hook session-endverb, fail-closed on every input path (internal/surface/cli/cli.go), withreadTranscriptdefensive open.hooks/hooks.jsonSessionEndwiring (verb reachable from the plugin surface — wired-or-it-isn't-done).- Test corpus in
internal/surface/cli/hook_session_end_test.gocovering every acceptance criterion end-to-end on the hook path (see mapping below), over the core coverage ininternal/core/history/history_test.go.
Acceptance-criteria satisfaction
AC as numbered in itd-89 → covering evidence (all in
internal/surface/cli/hook_session_end_test.go unless noted):
- Hook fires → redacted + stored,
history listshows it, no user action —TestHookSessionEndCapturesTranscript(drives the verb via stdin payload, asserts the record viahistory.List); theSessionEndentry inhooks/hooks.jsonis the no-user-action wiring. - Long session → exactly one record (SessionEnd, not Stop) —
TestHookSessionEndOneRecordPerSession;hooks/hooks.jsoncarries aSessionEndentry and noStop-wired capture. - Same transcript offered twice → one record —
TestHookSessionEndIsIdempotent; core:TestCaptureIdempotentOnSourceSHAandTestCaptureIdenticalSourceDistinctSessionsWritesBoth(bounds the dedup to session+kind+source, so a distinct session is never swallowed). - Malformed payload / absent path / not a regular file / hostile session
id / non-repo cwd → writes nothing, exit 0, reason on stderr —
TestHookSessionEndNeverBlocksTheHost(seven payload subcases, each asserting exit 0, zero records, and a non-empty stderr reason); core:TestCaptureRejectsBadInput. - FIFO or device node → the open does not block —
TestHookSessionEndDoesNotBlockOnIrregularFiles(a FIFO with no writer and/dev/null, bounded by a watchdog so a regression fails in seconds). - Nothing on stdout, diagnostics stderr-only —
TestHookSessionEndWritesNothingToStdout. - Secrets/home paths redacted; surviving hard-fail span → no file at
all —
TestHookSessionEndRedactsOnThisPath(redaction demonstrably runs on the hook path),TestHistoryCaptureFromSubdirHonoursRepoPiiConfig(per-repopii.jsonhonoured from a subdirectory),TestHookSessionEndRefusesResidualHardFail(a hard_fail span built to survive stage-one masking is refused whole: no file, reason on stderr, exit 0); cap default:TestHookSessionEndRefusesOverCapTranscript. Core:TestCaptureRedactsSecretsAndHomePaths,TestSurvivingCallerHomeBackstop,TestRedactionEngineStripsUsersHomePath.
Out of scope (unchanged from the intent)
Retrieval/search over the corpus (Pass B, itd-36), importing historical
sessions (nothing exists to import), changes to the redaction engine, any
user-facing surface, and SubagentStop wiring (deliberately undecided — it
shares Stop's per-turn growing-superset problem).
Frontmatter
| id | spc-4 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-4-start-the-transcript-clock.md
open on GitHub ↗ · commit history ↗
References1
- itd-89Capture the Session You Just Had, Because You Cannot Capture It Laterimplements
mentions12
- adr-29A native local redacted transcript corpus
- dcn-26071400000000072026-07-14 (M1, itd-89/spc-4) — Transcript capture is wired to `SessionEnd`, NOT the
- dcn-26082100000000012026-08-21 — Bug-hunt round 4, branch bughunt-b/round-4. Baseline (make preflight
- iss-347readTranscript opens the hook-supplied transcript path without O_NOFOLLOW or an over-cap probe, the one guarded read left off fsutil.ReadGuarded, so a symlinked transcript_path is followed and an over-cap file is stored silently truncated
- iss-2608211134281912Four untrusted-operand stdin readers (readIdeatePayload, readLessonsPayload, readSynthesisPayload, readSource) read exactly cap bytes via a bare io.LimitReader(cap), so an over-cap '-' payload is silently truncated into a severed prefix instead of refused, while each reader's file transport (readGuardedOperand) refuses the same bytes with fsutil.ErrTooBig. Worst on the history capture path: history.Capture performs no parse and no cap check, so an over-cap stdin transcript is stored as an 8 MiB prefix, reported 'stored', under a sha256 idempotency key computed over the prefix — breaking spc-4's refuse-whole invariant. Also lets an over-cap ideate verdict past both the MaxPayloadBytes cap gate and the dec.More() trailing-data gate at a crafted boundary. Unswept siblings of resolved iss-201/iss-347; every site's doc comment claims stdin parity with the file cap.
- iss-2608231029040602`abcd history capture` read its operand through `maxOperandJSONBytes` (8 MiB)
- iss-2609290448510918The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count.
- itd-3abcd Loads Its Own Rules On Demand
- itd-6RepoPrompt over MCP is one opt-in reviewer route, beside the host's own agents
- itd-36Knowledge That Compounds, Not Knowledge That Re-Derives
- itd-49Flow-State Drift Becomes Visible Before It Compounds
- spc-2609090624222051A session's sub-agents are captured through the completion event, with lineage the record carries in fields