Development record
A session's sub-agents are captured through the completion event, with lineage the record carries in fields
Summary
This spec delivers itd-2609090559376002: the transcript corpus stops keeping the smaller half of the work.
Two things land. Capture gains a SubagentStop hook that stages the
finished sub-agent's own transcript through the staging path
spc-4 built, so redaction and
its fail-closed refusal are reused rather than reimplemented. The record
schema gains explicit lineage fields, so a sub-agent record says which session
spawned it, which agent spawned it, what kind of agent it was and which rung
placed it, instead of smuggling that into a hand-made composite session_id.
Nothing here redesigns the store. The per-repo root-SHA keying of
adr-29 stands, the
two-stage redaction stands, and Capture remains the only path that writes a
record.
Two capabilities that were planned inside this spec are separately delivered
and separately specced, because the landing order delivered them as independent
steps: recovering transcripts already on disk and repairing the composite
records is
itd-2609091718566731,
and rendering a session as one artefact with telemetry is
itd-2609091718595846.
Both consume the record schema and the fail-closed Capture this spec settles,
so this spec is where that material stays.
Scope
In: internal/core/history — the record schema and CaptureMeta, Capture,
Stage, Drain, Read, ListForSession, the staging sidecar, the staged TTL
and quarantine, and the session/gap notes in locate.go. The hook sub-tree in
internal/surface/cli (hook_subagent.go) and hooks/hooks.json. The plugin
page commands/history.md, the brief chapter
04-surfaces/11-history.md, the
regenerated surface snapshot and the generated command reference.
Out: the scanner and its detectors; the store's keying and provisioning; the harness's retention policy; ingest and migrate; reconstruction and telemetry; structured extraction of findings from a transcript.
Approach
The lineage fields, and why the composite id goes
A sub-agent record has to answer three questions a session record never had to:
which session spawned it, which agent inside that session spawned it, and what
kind of agent it was. The workaround already in the store answers the first two
by concatenating them into session_id, in the shape
<truncated-parent-prefix>--agent-<agent-id>. That fails in both directions.
A reader holding the identifier the harness actually gives them (the full
session id) cannot find the record, because the stored value is a truncation of
it; and a reader holding the record cannot recover the full parent id from it,
because the truncation is lossy. Overloading one field with two identifiers is
the defect, so the fix is fields.
Record gains, all optional and all empty on a main-thread record:
agent_id: the sub-agent's own identifier, as the harness reports it.parent_agent_id: the identifier of the agent that spawned it, empty when the main thread spawned it.agent_type: the kind of agent, as the harness reports it.spawn_depth: 0 for the main thread, 1 for a sub-agent of it, and so on.spawn_tool_use_id: the identifier of the tool call in the spawning transcript that launched this agent, when it can be established.lineage_source:hook,ingestormigrated, so an emptyagent_typeon a sub-agent record is distinguishable from an agent type that was never recoverable.spawn_attribution: which rung of the attribution ladder placed the spawn point —sidecar,transcriptorunattributed. Required on a sub-agent record and empty on the main thread, because "spawned by the main thread" and "spawn point unknown" would otherwise be the same empty fields.
session_id keeps its existing meaning unchanged: the session the transcript
belongs to. On a sub-agent record it holds the full, untruncated id of the
spawning session, which is what the SubagentStop payload delivers.
source_kind is deliberately NOT extended with a sub-agent value. Kind says
where the bytes came from (native, specstory-import), not what produced
them; encoding agent-ness there would repeat the overloading this section
removes.
The schema version moves forward. A schema-1 record parses as a main-thread
record with empty lineage, so List and Read keep working over everything
already stored, and the composite records stay readable until the repair verb
runs.
Three consequential changes follow:
- The idempotency key becomes
(source_sha256, session_id, agent_id, kind). Withoutagent_id, two sub-agents of one session that happened to produce byte-identical transcripts would collapse into one record, which is the precise failure the existing key's session component was added to prevent. recordFilenamebecomes<stamp>-<session-id>.mdfor a main-thread record and<stamp>-<session-id>-agent-<agent-id>.mdfor a sub-agent's. The filename is readable convenience only.listRecordsparses frontmatter and never the filename, so nothing decodes this string back into fields.Capturetakes a metadata struct.Capture(repoRoot, rootSHA string, raw []byte, meta CaptureMeta)replaces the five-positional signature;CaptureMetacarries the session id, the lineage fields and the kind. Six more positional string arguments on a security-critical call is a transposition waiting to happen, and a transposed session and agent id would silently mis-attribute a transcript rather than fail.
Read resolves its key in three steps: an exact record filename, then an exact
agent_id, then a session_id (preferring the main-thread record, newest
first). ListForSession(rootSHA, sessionID) returns the whole set for one
session, main thread first, and is what history show uses to list a session's
sub-agents under the record it rendered.
The frontmatter is redacted with the body
Every new field is externally supplied: an agent type comes from the harness
payload, an adopted project name from a configuration file a contributor can
edit. marshalRecord writes frontmatter, and frontmatter has never been
scanned, so adding externally-supplied scalars to it would open a redaction
bypass beside a redaction gate.
The fix is not a second scan. Capture prepends the lineage scalars to the raw
text as ordinary lines before the existing two-stage pass runs, then splits them
back off afterwards. The scalars therefore go through exactly the same
sanitise-then-verify discipline as the body, including the caller-home backstop
and the fail-closed residual refusal, with no second code path to drift. A
scalar whose redaction changed it is stored changed; a blocking residual refuses
the whole write as it does today. A scalar carrying a line break would forge a
frontmatter field, so it is refused at the store and dropped at the hook —
losing one decorative field beats losing the transcript.
The deferred decision record
The move from a composite identifier to explicit lineage fields refines adr-29 and deserves an ADR. It is deliberately deferred and unminted here: the shape above is the first thing implementation can falsify, and a decision record minted before its own migration has run would record a decision that has never met the store. The ADR is minted after this work merges, and until then this spec is the decision of record.
One caveat for whoever mints it. The repository's root AGENTS.md says ADRs are
the one record family whose ids are hand-numbered and so need coordination
between checkouts; the ADR store's own charter records the ruling of 2026-09-01
that abcd decide mints an ADR through the same collision-proof timestamp-numeric
seam as every other family, with the pre-existing ordinals grandfathered. Those
two statements disagree, and the disagreement is not this spec's to settle.
Resolve it before minting, and correct whichever surface is stale.
hook subagent-stop, and its wiring
A new operator-internal verb, abcd hook subagent-stop, built to the same shape
as hook session-end: fail-closed, diagnostics on stderr, stdout empty. One
difference is not cosmetic. SessionEnd's exit code is ignored by contract;
SubagentStop's is not — exit 2 is that event's BLOCKING status and would stop
the sub-agent from finishing. So every path in the verb returns nil, and no
staging failure is worth risking an agent's completion over. That is a rule about
this binary's own exits and not about the launcher's: the hooks.json wrapper's
exit 1 when no binary resolves is deliberately non-zero and deliberately not 2,
because it is the one signal a user gets that their transcripts are not being
captured.
It reads the hook payload from stdin and uses five of its fields:
agent_transcript_path: the finished sub-agent's own transcript. This is the whole reason the gap can be closed without reading the harness's on-disk layout, and its absence is the fallback case below.agent_id,agent_type: the lineage the payload carries directly.session_id: the spawning session, stored untruncated.cwd: resolved to the repository's root-commit SHA.
It stages, it does not capture, for the reason staging exists: redaction runs
at roughly 0.7 s per megabyte, and SubagentStop fires inside a live session
where a stall is felt directly. A later drain runs it through the unchanged
Capture.
Resolving the store needed a second rung the plan did not have. A sub-agent
given its own worktree records that worktree as its cwd, and the harness
REMOVES the worktree when the agent stops — so the directory is frequently gone
by the time the hook runs, and the agents that affects are the isolated
implementation lanes, whose transcripts are worth the most. So: the cwd first,
exactly as hook session-end does, and failing that the spawning session. A hook
that CAN resolve a repository writes a zero-byte note naming the session under
~/.abcd/history/<sha>/sessions/, and a later hook holding only that session id
finds the store with one stat per repository and no transcript read. A session id
two stores claim is REFUSED rather than guessed, because a transcript filed
against the wrong repository is redacted by the wrong repository's scanner
configuration. Notes are pruned past thirty days.
A payload with no usable agent_id stages nothing: without an agent id the
stage has no key of its own and would collide with the spawning session's own
staged transcript, overwriting the spine to save a branch.
parent_agent_id, spawn_depth and spawn_tool_use_id are not in the payload.
They are resolved down a three-rung ladder, and the rung that answered is
recorded in spawn_attribution:
- The harness's own sidecar, derived from the payload's transcript path by substituting the extension, never by walking a directory. When it is present it carries the spawning agent, the spawn depth and the tool-call identifier that launched the agent. The read is guarded and best-effort: absence, unreadability, a non-regular file, a parse failure or a missing depth all leave the fields empty and are never an error.
- The spawning transcript's own tool result for asynchronously launched
agents, which names the agent id beside the tool-call id. Resolved at
reconstruction time, not in the hook — it is
itd-2609091718595846's to deliver, and it did. - Unattributed. The record stores the sub-agent with empty spawn fields and
spawn_attribution: unattributed, and a reader lists it separately rather than guessing a spawn point.
Wiring: hooks/hooks.json gains a SubagentStop entry running
"$CLAUDE_PLUGIN_ROOT/abcd" hook subagent-stop, using the same binary
resolution preamble as the existing SessionEnd entry, and
applyHookPlaneFailOpen's path list gains {"hook", "subagent-stop"} so a
usage error on this verb fails open like every other hook path. Without both,
the verb is dead scaffolding.
Staging carries a sidecar
Staged files were identified by parsing their filename, which is why the filename had to encode the session. Encoding lineage there would rebuild the composite-identifier defect one directory earlier.
So Stage gains a sidecar. It writes two files, both mode 0o600:
<stamp>-<key>.raw, the raw bytes, where<key>is the agent id when there is one and the session id otherwise.<stamp>-<key>.stage.json, holding a schema version, the session id, the lineage fields, the source path the bytes were read from, and the stage time.
listStaged reads the sidecar when it is present. A .raw file with no sidecar
is a staged file from an older binary: its session id is parsed from the
filename exactly as before and it drains as a main-thread transcript, so an
upgrade never strands a backlog. A sidecar that is PRESENT but unreadable is a
different fact and is not drained at all — falling back to the filename there
would read a sub-agent's key as a session id and file the transcript under a
session that does not exist.
Stage idempotency moves from the session id to the (session_id, agent_id) pair,
with the existing content compare and the existing last-writer-wins replacement,
all still inside the staging lock.
The drain's order and its budget
Order. listStaged is chronological, and a session's main thread is staged
last because it ends last, so a bounded pass would drain the branches and leave
the spine. The drain therefore takes overdue entries first, then main-thread
entries, then sub-agent entries oldest-first. If a pass is truncated, what it
stored is the part that makes the rest legible.
Budget. DrainBudget is a pair: a byte bound and a count bound, whichever is
reached first. The byte bound is the one that protects the first prompt, because
redaction cost tracks bytes rather than files, and the count bound keeps a
pathological many-tiny-files case bounded too. A pass with nothing yet attempted
never declares itself exhausted, so a single staged transcript larger than the
byte bound cannot be skipped by every pass forever.
Staged text does not live indefinitely
Staging is the one place abcd holds unredacted transcript text on purpose. The
code claimed it lived "only until the next session starts", and the drain ran
from a hook of the repository the file belongs to — so a repository nobody
opened again kept its raw transcripts for as long as the disk lasted
(iss-2609090722466403). Four such files, thirteen megabytes, the oldest a
fortnight old. An intent that admits one staged file per sub-agent completion
makes that ordinary rather than pathological, so four mechanisms bound it:
- A drain runs while a session is LIVE, not only at its start: the prompt-router hook drains one entry and at most half a megabyte per prompt, so a session that spawns sub-agents redacts its own branches as it goes. Everything it says goes to stderr, never to the hook's stdout.
StagedTTL(seven days) is the maximum staged age. Past it an entry is OVERDUE: it sorts to the front of every drain and is named in every notice. Age buys priority and volume and nothing else — an overdue transcript is never deleted, never redacted down, never degraded. Losing the only copy is worse than keeping it, which is the premise staging is built on.- Session start reports EVERY repository in the store, not just the one the
operator is standing in, and
abcd history staged --all-reposis the read-only verb behind the same survey. A per-repo listing is blind to exactly the pile that grows: the one nobody opens. The survey carries counts, sizes and repository names — never another repository's session ids. - A transcript the fail-closed scanner will never pass is QUARANTINED, not
retried forever. A residual refusal is a property of the transcript's own
bytes, so every future drain reaches the same refusal; such an entry moves to
quarantine/with a written reason and leaves the queue.DrainFailuretherefore distinguishes retryable from permanent, which reporting them alike did not (iss-2609090722466403).abcd history discard --yesis the only thing that removes one.
Failure modes
- The event fires before the transcript is flushed. The likeliest way this
design is shown wrong, and it is still UNVERIFIED — the documentation
suggests it may, the binary does not settle it, and the rate has not been
measured over a corpus. Two mitigations, both cheap and both self-counting. At
stage time,
TranscriptSettledtests whether the final non-blank line is complete JSON; a transcript that is not is re-read up to four times at 25 ms intervals, and a copy that never settles is staged anyway with a stderr line saying it may be short. The wait lives in the surface and deliberately NOT insideStage, whose critical section is under a lock tuned for a singleSessionEnd: a wait in there would turn a burst of simultaneous completions into a queue. At drain time, the source path recorded in the staging sidecar is re-read when it still exists, and its bytes replace the staged copy only when they are strictly longer AND the staged bytes are a byte-prefix of them, so a recycled path can never substitute a different transcript.DrainResult.Extendedcounts the transcripts that were caught short and completed, which is the measurement that will eventually settle the question. - A session with many sub-agents. Staging is one write per completion, so the hook's cost is independent of the count. The drain is where the count lands, which the byte-and-count budget and the drain ordering address. The failure this leaves is a backlog of unredacted staged text, which the four mechanisms above bound and report.
- The payload is absent on an older harness. No
agent_transcript_pathmeans the hook warns on stderr, stages nothing and exits 0. Silence is what this intent exists to end, so it also writes a marker beside the staging directory recording the first and last sighting and a count, andhistory stagedreports it: this harness version does not deliver sub-agent transcripts, so no sub-agent capture is happening here. Absence of sub-agent records then has an explanation on disk instead of looking like an absence of sub-agents. - An unreadable transcript. The existing guarded read already refuses a symlink, a FIFO, a device node, a non-regular file and an over-cap file, and refuses an over-cap file whole rather than truncating it. Reported on stderr, nothing staged, exit 0.
- A degraded scanner. Unchanged and untouched. Sub-agent capture runs
through the same
Capture, so the refusal on a degraded scanner or a surviving blocking span applies by construction. The blocking-span half is asserted on the sub-agent path; the DEGRADED-SCANNER half is not asserted anywhere, on any path, and holds by construction alone. The guard predates this work, so this delivery inherits it rather than establishing it, and an unarmed guard is exactly the shape this repository refuses to trust elsewhere.
Surfaces
One operator-internal hook verb, whose plugin surface is its hooks.json entry,
as it is for hook session-end; plus the history staged and history discard
changes above. Each needs, in the same change: sections in
commands/history.md; rows in the brief's history chapter sub-verb table, which
surface_coverage checks in both directions and refuses on; a regenerated
surface snapshot (cmd/abcd-gen-surface) and command reference
(cmd/abcd-gen-cli-ref).
Landing order
Each step left the tree green, and each had its own tests.
- The record schema. The lineage fields,
CaptureMeta, the widened idempotency key, the frontmatter-through-the-redaction-pass change,Read's three-step resolution andListForSession. Existing callers updated; no new verb yet. - Staging's sidecar and the hook.
.stage.json, the(session, agent)idempotency key, the legacy sidecar-less path, the drain's ordering and the byte-and-count budget; thenabcd hook subagent-stop, itshooks.jsonentry, its fail-open path registration, the session-note fallback, the harness-sidecar rung and the missing-payload marker. This is the point at which the corpus starts accruing. - The staged-lifetime bound. The live drain,
StagedTTLand the overdue ordering, the all-repositories survey, and quarantine. - The measurement, still outstanding: an instrumented run over real sessions
counting how many staged sub-agent transcripts arrive truncated.
DrainResult.Extendedis the counter; the number goes into the intent's grounds when it exists.
How the Acceptance Criteria are satisfied
- ac-1 (a sub-agent's transcript is stored, redacted, and listed). The
SubagentStophook stages it; the next drain captures it through the unchanged fail-closedCapture;Listreturns it. Tests:TestHookSubagentStopStagesLineage,TestHookSubagentStopStagesRatherThanCapturesandTestSubagentStopThenSessionStartStoresTheRecord, the last driving the whole path end to end. - ac-2 (nesting, each attributable to its spawner).
parent_agent_idandspawn_depthcome from the attribution ladder and are stored per record. Tests:TestHookSubagentStopReadsTheHarnessSidecar,TestLineageRoundTripsThroughTheRecord,TestUnknownSpawnIsDistinguishableFromNoParentandTestUnreadableSidecarIsReportedNotMisattributed. - ac-3 (found from the spawning session's identifier, with its kind).
session_idholds the untruncated spawning session, soListForSessionreturns the main thread and every sub-agent, each carryingagent_type. Tests:TestListForSessionReturnsMainThreadAndEverySubagent,TestReadResolvesFilenameThenAgentThenSession,TestSubagentRecordFilenameNamesTheAgent. - ac-4 (a degraded scanner refuses). Unchanged
Capture. The tests below cover redaction and a surviving blocking span on the sub-agent path; NONE of them degrades a scanner, so that refusal is unasserted:TestLineageFieldsAreRedactedWithTheBody,TestBlockingSpanInAgentTypeRefusesTheWrite,TestCaptureRejectsAMalformedLineageScalar,TestQuarantineHoldsUnredactedTextAtOwnerOnlyModes. - ac-5 (the same transcript twice is a no-op). The widened idempotency key.
Tests:
TestSubagentCaptureIdempotentOnSourceSHAandTestTwoSubagentsWithIdenticalBytesBothStore, the second bounding the first so the key cannot be over-tightened back into the collapsing bug;TestStageIdempotencyIsPerSessionAndAgenton the staging half. - ac-6 (a completion without a readable transcript is reported, not silent and
not fatal). The hook's warn-and-exit-0 paths plus the missing-payload marker.
Tests:
TestHookSubagentStopAlwaysExitsZero(a table over an absent path, an irregular file, an over-cap file, a malformed payload and a payload with no transcript path, each asserting exit 0, zero records and a non-empty stderr reason),TestHookSubagentStopMarksAMissingPayloadField,TestSubagentGapMarker,TestSubagentStopNeverBootstraps(pinning the launcher's exit as neither 2 nor 0 nor 127).
Tests
internal/core/history: the schema round-trip over both versions
(TestSchemaOneRecordReadsAsMainThread, TestLineageRoundTripsThroughTheRecord),
the frontmatter redaction (TestLineageFieldsAreRedactedWithTheBody,
TestBlockingSpanInAgentTypeRefusesTheWrite,
TestCaptureRejectsAMalformedLineageScalar), the idempotency pair above plus
TestDivergentTranscriptsForOneAgentBothStore,
TestASecondStopSupersedesTheFirstRecord and
TestAShorterRearrivalDoesNotWriteASecondRecord; Read's resolution order;
staging's sidecar and its legacy path (TestStageWritesLineageSidecar,
TestListStagedReadsSidecarLineage,
TestLegacyStagedFileWithoutSidecarStillDrains,
TestCorruptSidecarIsNotTreatedAsPermanent,
TestConcurrentSubAgentStagesAllLand,
TestStageConcurrentSameSessionYieldsOneCopy); the drain's ordering, budget and
re-read (TestDrainTakesMainThreadFirst, TestDrainTakesOverdueEntriesFirst,
TestDrainByteBudgetBoundsThePass, TestDrainByteBudgetAlwaysMakesProgress,
TestDrainBudgetLeavesRemainderLoudly, TestDrainRereadsALongerSource,
TestDrainIgnoresADivergentSource, TestDrainCarriesSidecarLineageIntoTheRecord,
TestDrainKeepsStagedOnCaptureFailure,
TestDrainLeavesAReStagedCopyForTheNextPass); the staged lifetime
(TestStagedEntryPastTheLimitReportsOverdue,
TestOverdueEntryIsNeverDeletedByAge,
TestRetryableFailureStaysStagedAndRetryable,
TestDeterministicRefusalIsQuarantinedNotRetriedForever,
TestSurveyBacklogSeesEveryRepositoryInTheStore,
TestSurveyBacklogSkipsRepositoriesHoldingNothing,
TestDiscardRemovesOneTranscriptAndItsMetadata,
TestDiscardRefusesAnythingButABareStagedFilename); the store resolution and the
gap marker (TestSessionRepoRoundTrip, TestSessionRepoRefusesAnAmbiguousSession,
TestSessionRepoUnknownSessionIsAnError,
TestSessionRepoRefusesADirectoryReference, TestSessionNotesArePruned,
TestSubagentGapMarker); and the flush-race predicate (TestTranscriptSettled).
internal/surface/cli: the hook table above, plus
TestHookSubagentStopResolvesTheRepoThroughTheSession,
TestHookSubagentStopWaitsForAnUnsettledTranscript,
TestHookSubagentStopConcurrentCompletionsAllStage,
TestHookPlaneFailsOpenOnEveryUsageError and TestHooksManifestNamesLiveSubverbs
(extended by the manifest to cover the new path),
TestPromptRouterDrainsWhileTheSessionIsLive,
TestPromptRouterDrainsEvenWhenTheRulesLoaderFails,
TestSessionStartReportsAnotherRepositorysBacklog,
TestHistoryStagedAllReposSurveysTheWholeStore,
TestHistoryDiscardRefusesWithoutConfirmation.
Gates: surface_coverage over the new sub-verbs, the regenerated snapshot and
command reference, and make record-lint over this spec and the intent.
Uncertainties
- Whether the completion event fires after the sub-agent's transcript is flushed is STILL NOT ESTABLISHED. It is the intent's own falsifier. The mitigations shipped and count their own effect, but the residual rate over a real corpus has not been measured, and the intent's scope condition says so rather than claiming it settled.
- How much lineage survives without the harness's sidecar is unmeasured. The payload carries the agent id and the agent type, so ac-3 holds regardless; what degrades without it is the spawn point.
- The ADR ordinal question flagged above. Two committed surfaces disagree about how an ADR id is allocated. That does not block this spec, but it blocks minting the ADR this spec defers.
Out of scope
Redesigning the store's keying or provisioning. The harness's retention policy.
Recovering transcripts already on disk and repairing the composite records
(itd-2609091718566731). Reconstruction and its telemetry
(itd-2609091718595846). Structured extraction of findings from a transcript.
Any change to the scanner's detectors or to the two-stage redaction discipline
itself. Any change to source_kind.
Frontmatter
| id | spc-2609090624222051 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-2609090624222051-sub-agent-transcript-capture.md
open on GitHub ↗ · commit history ↗
References1
- itd-2609090559376002Every Sub-Agent a Session Spawns Leaves the Same Redacted Record Its Main Thread Doesimplements
mentions5
- adr-29A native local redacted transcript corpus
- iss-2609090722466403A staged raw transcript can live indefinitely, and the store's own comment says it cannot. Staging is the one place abcd holds unredacted transcript text on purpose, and its contract is that a staged file survives only until the next session starts. That holds only for a repository someone opens again. The drain runs from the session-start hook of the repository the staged file belongs to, so a repository that is finished with, or merely quiet, keeps its raw transcripts forever. On this machine right now there are four staged files totalling about thirteen megabytes of unredacted transcript, the oldest fourteen days old, and the per-repo status verb reports nothing from any other repository, so standing in one checkout cannot reveal a pile in another. A drain failure compounds it: the staged file is deliberately left in place, correctly, because deleting the only copy would be worse, but nothing ever retires it, so the population of permanently raw files only grows. Three things would close it: a drain that can run while a session is live rather than only at its start, a maximum staged age after which a file is redacted or deleted with a notice, and a cross-repository notice at session start so the pile in the repository nobody is standing in is still visible.
- itd-2609091718566731Transcripts Already on Disk Are Recovered into the Right Repository's Corpus, Under That Repository's Own Redaction
- itd-2609091718595846Any Captured Session Can Be Handed to an Agent as One Self-Contained Artefact, With a Telemetry File Saying What It Cost
- spc-4start-the-transcript-clock