transcript

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package transcript reads a harness's record of a session into the one shape every rule is written against.

A harness records a great deal more than a rule ever asks about — routing, versions, request ids, its own bookkeeping. Carrying that through would make this shape a mirror of Claude Code's, which is the opposite of the point: every field kept here is a field each new harness must be normalised into.

The shape is taken from what Claude Code already writes, because a format that has carried real sessions is a better starting point than one reasoned out cold. It is ours from then on, and Claude Code is simply the harness needing no translation today.

Index

Constants

View Source
const (
	EntryUser       = harness.EntryUser
	EntryAssistant  = harness.EntryAssistant
	EntrySystem     = harness.EntrySystem
	EntryAttachment = harness.EntryAttachment
)
View Source
const EnvHookTranscript = "SR_TRANSCRIPT"

EnvHookTranscript is the variable the engine sets on a check it runs from a hook: the session's record as the hook resolved it (from the payload, or the harness's own locator when the payload names none).

View Source
const SubagentDir = "subagents"

SubagentDir is the directory a harness nests a session's sub-agent records under, beside the session's own transcript.

Observed, not promised: Claude Code writes <project>/<session>/subagents/agent-<agent id>.jsonl, with the session's own transcript at <project>/<session>.jsonl beside the directory. Named here so the one place that assembles such a path is the one place that has to change when the harness moves it.

View Source
const SubagentUserAdvice = "You are a sub-agent: " + subagentUserRule

SubagentUserAdvice is said to a sub-agent whose citation of the user's words does not resolve.

Variables

View Source
var (
	// ErrNoSessionRoot: the user's own words were asked of a sub-agent's record
	// whose session's root record is not where the layout puts it. A sub-agent's
	// "user" message is the parent agent's dispatch, so without the root there
	// is nothing of the end user's to search.
	ErrNoSessionRoot = errors.New("a sub-agent's record whose session root is not found holds none of the end user's words")

	// ErrNoTranscriptPath: nothing was named to read. A hook runs where the
	// harness's record exists, so this is the caller's own omission.
	ErrNoTranscriptPath = errors.New("no transcript path given")

	// ErrNoOriginRecord: every entry in a transcript has a parent, so the
	// conversation it belongs to has no beginning in it. Not a session we
	// understand, and guessing at an origin would key state on a guess.
	ErrNoOriginRecord = errors.New("transcript has no origin record")

	// ErrContinuationMissing: a record says it continues an earlier one, and no
	// other transcript holds what it names. The environment lost a file;
	// answering anyway would answer wrongly.
	ErrContinuationMissing = errors.New("the record this transcript continues is in no other transcript")

	// ErrNoProjectDir: crossing a restart needs the conversation's other
	// transcripts, and where those sit was not resolved.
	ErrNoProjectDir = errors.New("the harness's project directory is unknown")

	// ErrChainRunaway: the walk across restarts revisited a file or ran past
	// its bound. A real conversation resumes a handful of times.
	ErrChainRunaway = errors.New("the chain of continuations loops or is longer than a real conversation")

	// ErrExpressionNotBoolean: an expression evaluated to something that is not
	// a yes or a no. The expression is wrong about itself.
	ErrExpressionNotBoolean = errors.New("expression did not produce a boolean")

	// ErrExpressionNeverRan: an expression could not be evaluated against a
	// single entry it was offered. It has told us nothing about the session, and
	// reporting nothing matched would report no violations without having
	// looked.
	ErrExpressionNeverRan = errors.New("expression could not be evaluated against any entry")

	// ErrNotAnAgentID: what was offered as a sub-agent's id is not a name, so no
	// file path may be built from it. An id carrying a path separator would
	// traverse out of the directory it is joined into and read — then key state
	// against — another conversation entirely.
	ErrNotAnAgentID = errors.New("the reported agent id is not an agent id")
)

The failure kinds a caller may want to tell apart, named rather than left to be recognised by the words in a message. A caller matching on text is a caller that breaks when the text is improved, and these messages are written to be read by a person — which means they will be.

Every one of these is a refusal to answer, never a quiet default. What they distinguish is WHICH thing was wrong, so a hook can say so; none of them offers a way to carry on regardless.

View Source
var CommandEchoes func(command string) bool

CommandEchoes decides whether a shell command prints the agent's own text back (git log, sloprail's own tools, ...). It is a hook because the decision is made on the parsed command line, and the package that parses it (commandmod) imports this one; commandmod registers it at init, and every binary that resolves a citation links commandmod. FAILS CLOSED: unset, every Bash result is an echo and so not citable.

Known holes (an echo the parser does not see, so its output stays citable): echo or Read of a file the agent wrote itself; Read of a file under .git/ (only commands are parsed); git aliases that print messages; paths built by $(...) or variables; interpreters (python -c, node -e) that read the history; and `git branch -v`, which prints subjects.

View Source
var ErrNoEntryAtLine = errors.New("no entry at the given line")

ErrNoEntryAtLine: the given line names no entry in the transcript — it is past the end of the file, or it falls on a line that is not an entry (a preamble or bookkeeping line Read skips, or one that will not parse). A citation's line always names a real entry, so this is a caller holding a line that did not come from Cite, or a file that changed under it.

View Source
var ErrSubagentUnlinked = errors.New("on this harness a sub-agent's session cannot be tied to the user's conversation, so a citation cannot be resolved from a sub-agent: citations, and the checks that resolve them (sr-checks run), must be made from the main agent. Hand it the quote or the output to cite, and run sr-checks there")

ErrSubagentUnlinked: a citation was asked of a sub-agent on a harness that cannot tie a sub-agent's session to the conversation that dispatched it (harness.SubagentsUnlinkable). It is a refusal to judge here, not a verdict: the quote was never searched, so neither "resolved" nor "not there" is true. Callers render it as itself and never store it as a failure, so the main agent's own run judges the same citation fresh.

View Source
var ErrWrongSession = errors.New("the transcript at that path belongs to another session")

ErrWrongSession: a transcript was reached by GUESSING its filename from a session id, and the file that turned up belongs to a different conversation.

Only ever the answer for a guess. A path handed over by the harness is authoritative and is never checked this way.

View Source
var ErrWrongTree = errors.New("the transcript at that path was written in another tree")

ErrWrongTree: a transcript was reached by GUESSING its filename, and the file that turned up was written in a different working tree than the one asking.

Only ever the answer for a guess, for the same reason as ErrWrongSession.

Functions

func AssistantText

func AssistantText(e Entry) string

AssistantText pulls the prose out of an assistant entry's message — the text a tag may sit in — whatever shape the harness recorded it in.

Claude Code writes an assistant `message.content` two ways: as a bare string for a plain reply, and as a list of typed blocks (`{"type":"text",...}` alongside `{"type":"tool_use",...}`) for a turn that also calls tools. A tag lives in the prose, so only text is read, and a tool_use block's arguments are deliberately NOT scanned: a `#tag` inside a file the agent wrote is that file's business, not a tag the agent declared in its message.

This is the same extraction session_stop.go's assistantText performs to feed the tag module at a cycle's end. It lives here so `normalize` gathers an entry's text the same way the Stop dispatch does, rather than re-teaching the message shape a second time — the shape is the harness's, and one reader of it is one thing to keep in step with the format.

Text blocks are joined with newlines, so a `#tag` at the end of one block and a word at the start of the next do not fuse into a single false token — the same boundary the scanner's own pattern relies on. Empty for a non-assistant entry, an entry with no message, or an undecodable one.

func BackgroundAgentSignalsSince added in v0.5.0

func BackgroundAgentSignalsSince(path string, cur AgentSignalCursor) ([]AgentSignal, AgentSignalCursor, error)

BackgroundAgentSignalsSince returns the signals of the lines after the cursor, and the cursor past them. A cursor for another record, or past the end of this one (it was replaced), starts over. Like BackgroundAgentSignals an unparseable line is an error ("unknown") and the cursor stays where it was, so the caller reads the same lines again next time.

One cursor is kept per session, for the record it dispatches from: a Stop that alternates between two records starts over each time, which costs what reading it whole always did.

A last line with no newline yet is read when it is a whole record and is an error when it is not, the same answer BackgroundAgentSignals gives for a torn record.

func BelongsToSession

func BelongsToSession(path, sessionID string) (bool, error)

BelongsToSession reports whether the transcript at path was written under sessionID, according to the file's own records.

This is the check a GUESS needs and a given path does not. Reconstructing "<project dir>/<session id>.jsonl" is an assumption about where a harness puts things, and the reasoning that a wrong guess "fails loudly" only covers a guess landing on nothing. A guess landing on a file that EXISTS but belongs to another conversation resolves silently and hands back that conversation's identity — after which the engine keys its state on it.

Records that carry a sessionId say who wrote them, so the file can be asked rather than trusted to match its own name. Not every record carries one: across 8085 real transcripts, `file-history-snapshot` records carry only {isSnapshotUpdate, messageId, snapshot, type} and appear as early as line 2. So the scan SKIPS blank-sessionId records rather than concluding from the first record — otherwise a mismatch would hide behind a leading fieldless one.

And a DISAGREEING record is not enough either: the scan keeps going until it finds agreement, and only reports the disagreement if none is ever found. One real transcript in this corpus opens with 303 records carrying an OLDER session's id and only reaches its own on line 304 — a resumed conversation whose earlier records were copied forward. Stopping at the first id present refuses that file, which is a legitimate session denied its own record. Over the whole corpus the first-record rule falsely refuses 1 and this one refuses 0.

Depended on, and it is HARNESS BEHAVIOUR rather than anything guaranteed: a refusal here can only fire if NO record in a transcript names the session its filename does. Across 8109 real transcripts that holds in every one — though it is a near thing, since one of them carries 303 records of an older session's id before reaching its own. So on real forks this check never fires: the convergence tests pass because the harness keeps a file's own id somewhere in its records, not because anything forces it to. If that ever changes, this turns from inert into a refusal on every fork, which is why it is written down here rather than assumed.

A file carrying no sessionId at all answers true. The field is observed rather than promised, and a harness that stops writing it must not turn every session into a refusal — the check is here to catch a guess landing on someone ELSE's conversation, which is a positive disagreement, not an absence of evidence. Both the check and its limit are the point: what is caught is a file naming a different session, and what is not caught is a file naming no session.

An unreadable file answers true and leaves the failure to whoever reads it properly, which reports the open error with its own path in it. This covers one real gap: a record longer than maxRecordBytes stops the scan with an error, so a mismatch sitting BEHIND such a line is not seen and the file is allowed. Accepted rather than fixed, because the alternative — refusing every file with one oversized record — turns a formatting accident into a dead session. BelongsToTree has the same gap on the same line, so the two do not cover for each other here; this is a limit of both, not of one.

func BelongsToTree

func BelongsToTree(path, cwd string) (bool, error)

BelongsToTree reports whether the transcript at path was written in cwd, or anywhere beneath it — compared as literal paths, not as encoded directory names.

This exists because EncodeProjectDir is LOSSY: `[^a-zA-Z0-9] -> "-"` maps the separator and a literal hyphen onto the same character, so "/home/u/proj" + subdir "pkg" and the sibling checkout "/home/u/proj-pkg" both encode to "-home-u-proj-pkg". Two hyphenated sibling checkouts is an ordinary layout, not an attack. Verified collisions: /a/b-c ≡ /a/b/c, /a/b_c ≡ /a/b-c, /a/b.c ≡ /a/b/c, "/home/u/x y" ≡ /home/u/x-y.

BelongsToSession does NOT catch this. When the guess lands in the sibling's directory and that sibling genuinely has a session by that id, the file's own records carry the id being asked about — it agrees, because the id really is its own. The disagreement is about the TREE, which that check never looks at, so the answer comes back as another project's real transcript with no error.

The records' own cwd is what settles it. Across 8085 real transcripts 7941 carry a cwd, first appearing by line 3 in 7522 of them. It is NOT stable within a file — 57 carry several distinct values, because the harness records the directory each turn ran in and a session that cd's into a subdirectory writes each one. So this asks whether ANY record's cwd is the tree in question or sits beneath it, rather than testing the first record: on that survey the rule accepts 7941 and falsely refuses 0. Testing only the first cwd would refuse 7.

A file with no cwd on any record answers true, for the same reason BelongsToSession passes a file with no sessionId: 144 transcripts carry none, the field is observed rather than promised, and absence of evidence must not turn every session into a refusal. What is caught is a positive disagreement.

An unreadable file answers true and leaves that failure to whoever reads it properly.

What this does NOT catch, having been tried:

  • An empty cwd answers true, because there is no tree to disagree with. The caller's guess is then unguarded by this check — though ProjectDir("") names the bare projects directory, which is not where transcripts live.
  • A session in the colliding sibling that GENUINELY cd'd into this tree records this tree's cwd, matches, and is accepted. Not a false positive in the file's own terms — it really did run there — but the two cannot be told apart by what is written down.
  • A record longer than maxRecordBytes stops the scan, which answers true. Same gap as BelongsToSession, and stated there.

func CitationsUnavailable added in v0.5.0

func CitationsUnavailable(path string, subagent bool) error

CitationsUnavailable is the one place that decides a citation cannot be resolved for the trajectory at path: ErrSubagentUnlinked when the harness cannot link sub-agents and path is (or the caller says it is, subagent) a sub-agent's. Everything that resolves a citation asks it, directly or through citeInSession.

func ConfigDir

func ConfigDir() string

ConfigDir is the harness's own configuration directory. Empty when it cannot be resolved.

func CurrentSessionPath added in v0.3.0

func CurrentSessionPath(cwd string) string

CurrentSessionPath resolves the CURRENT session's own transcript from the environment, or "" when it cannot. The running harness answers for itself (harness.CurrentTranscriptLocator): each names its session in its own variables and files its record in its own place. When the harness has none to name, a tool a hook started has the engine's answer in SR_TRANSCRIPT: a hook payload can carry no transcript path yet (Cursor's first tool call of a run), and that hook's environment names no session either.

cwd is the caller's reported working directory when there is one; "" uses the process's own.

func DescendantSubagentPaths added in v0.3.0

func DescendantSubagentPaths(path string) ([]string, error)

DescendantSubagentPaths returns every sub-agent record beneath the trajectory at path — the ones it dispatched, the ones THOSE dispatched, and records a harness nests deeper still (subagents/workflows/wf_<id>/, see SessionDirOfSubagent) — in lexical order. SubagentPaths lists one level; this is the whole tree, which is what "the session's own work" spans.

Empty, not an error, when path dispatched nothing. A subtree that cannot be read is an error: a caller deciding that a quote lands on exactly one entry must not decide it over records it silently skipped.

func EncodeProjectDir

func EncodeProjectDir(dir string) string

EncodeProjectDir maps a working directory to the harness's project directory name; the rule itself is the harness's (see harness.Transcripts).

func EnvelopeAt

func EnvelopeAt(path string, line int) ([]string, error)

EnvelopeAt returns the whole AskUserQuestion answer envelope(s) sitting on the entry at the given 1-based physical line of the transcript at path — the full `The user answered: "<question>"="<answer>". ...` string, question text and trailing instruction included, not the extracted answer alone.

This is what the judge-prepare piece calls with a citation's path and line. cite hands back `<path>:<line>` and nothing more; when the change being judged was grounded in an AskUserQuestion answer, the prepare/judge-input assembly passes that same path and line here to recover the whole envelope, so the judge sees WHAT WAS ASKED beside what was answered. (The consuming wiring is NOT in this package and is not built here: it lives in the judge-prepare slice — unit 17's Wave-2 slice, in internal/dispatch — which this helper exists to serve. This package only provides the reusable fetch.)

The envelope is returned UNCUT, which is the whole point of fetching it rather than reusing what cite searched. cite's own search extracts only each <answer>, because a quote must never resolve to the agent's question — but a judge needs the question to make the answer mean anything, and the sibling answers to know which one "the second option" was. One AskUserQuestion call routinely asks several questions and the harness writes every pair into one envelope, and one user turn may carry several such tool_result blocks, so this returns a LIST — in the order the envelopes sit on the entry.

Only genuine answer envelopes are returned. A tool_result whose content does not open with the answer prefix is an ordinary tool's output — a command's result that merely sits on the same user turn — and is skipped, the same line cite's own answer/output distinction draws. So a line naming a plain typed message, or a user turn carrying only ordinary tool results, yields (nil, nil): there is no envelope to show, which is the normal case for a message-grounded citation and not an error.

A line that names no entry is ErrNoEntryAtLine, and a file that cannot be read propagates its read error — both distinct from the empty-but-fine result above, so the consumer can tell "nothing to add for this citation" from "this citation does not point where you said".

func HookRefusalReason added in v0.3.0

func HookRefusalReason(body string) (string, bool)

HookRefusalReason returns the reason of a PreToolUse refusal body — what follows "PreToolUse:<Tool> hook error: " — and whether body is one.

func IsHookRefusal added in v0.3.0

func IsHookRefusal(body string) bool

IsHookRefusal reports whether a tool_result body is a hook blocking the call rather than anything the tool produced. Only Claude Code's spelling is recognised here: it writes a PreToolUse refusal as the tool_result "PreToolUse:<Tool> hook error: <reason>" with is_error — the reason being "[<command>]: <stderr>" for an exit 2 and the permissionDecisionReason for a JSON deny.

func IsSubagentRecord added in v0.5.0

func IsSubagentRecord(path string) bool

IsSubagentRecord is isSubagentRecord for callers outside the package: whether the record at path is a sub-agent's, by what it says of itself or by where, or how, the harness filed it (harness.SubagentLocator).

func IsSubagentTranscript

func IsSubagentTranscript(path string) bool

IsSubagentTranscript reports whether the trajectory at path belongs to a sub-agent rather than the main line.

Two independent signals, either of which settles it, because they come from different harnesses and a rule must not depend on both being present:

  • A meta file beside the record. Claude Code writes agent-<id>.meta.json for every sub-agent it dispatches to its own file, so the companion's presence is a positive mark of a sub-agent transcript.
  • isSidechain on the record's own entries. This is the ground truth the spec names: every sub-agent record carries it, in every layout measured. It is what answers the question when a meta file is absent — and it is what a harness that inlines sub-agent work has instead of a separate file at all.

The meta file is checked first because it is a single stat rather than a read of the record, and is decisive when present. The entry scan is the fallback, and it stops at the first sidechain record rather than reading the whole file.

An unreadable record answers false rather than erroring: a file we cannot open is a broken environment for whoever reads it properly, and this predicate's job is only to classify what it can see. A path under a subagents/ directory is NOT used as a signal on its own — the directory says where a harness put the file, which is a weaker fact than what the file says about itself.

func Mark

func Mark(entries []Entry) string

Mark is the position a cycle read up to — the uuid of the last entry it saw, for the next cycle to resume after. Empty when there was nothing to read, which leaves the previous mark standing rather than resetting it.

func ParentPath

func ParentPath(path, searchDir string) (string, error)

ParentPath returns the path of the trajectory that dispatched the sub-agent at path — its IMMEDIATE parent, which may itself be a sub-agent — or "" when this is a root trajectory or its parent cannot be derived.

The link is not written down as a path anywhere; it is DERIVED. A sub-agent's meta file carries the toolUseId of the tool_use that dispatched it, and that tool_use lives in the parent's trajectory. So the parent is found by reading this sub-agent's toolUseId and then searching the session's trajectories for the one holding a tool_use with that id.

searchDir is where the session's other trajectories live — the directory holding the root transcript and the subagents/ tree. The immediate parent may be the root (at <searchDir>/<session>.jsonl) or another sub-agent (under <searchDir>/.../subagents/), so both are searched: every .jsonl at the top of searchDir and every agent-<id>.jsonl beneath any subagents/ directory in it.

Returns "" without error in the ordinary "no parent to name" cases — a root trajectory has no meta file, and a meta file may carry an empty toolUseId (a harness that does not record it, the mock among them) which cannot be correlated. A meta file present but naming a toolUseId no trajectory holds is different: the dispatching call is somewhere the search could not see, so this returns "" and leaves the caller to report absence rather than inventing a link. The one hard error is an unreadable search directory, which is a broken environment rather than a trajectory without a parent.

func ProjectDir

func ProjectDir(configDir, dir string) string

ProjectDir is where the harness keeps the transcripts of every session run in dir.

Empty when the harness's own configuration directory cannot be located, which the caller reports rather than papering over: a walk that silently searched the wrong directory would find nothing and call the conversation new.

func RanToolUseIDs added in v0.3.0

func RanToolUseIDs(entries []Entry) map[string]bool

RanToolUseIDs is the tool_use id of every call that RAN: one with a tool_result that is not the harness saying the call never ran.

A call that did not run is in the record as a tool_use all the same, so a reader re-deriving what the agent DID from its calls — "did one gh search carry every keyword" — counts a search that never happened unless it asks this. Not run, measured against real Claude Code records:

  • no tool_result at all (interrupted, or still pending);
  • a hook block: "PreToolUse:<Tool> hook error: …", or "Tool call blocked by a PreToolUse hook: …";
  • a permission the user did not give: "The user doesn't want to …", "Permission to use … has been denied", "Claude requested permissions to use …", "This command requires approval", "This Bash command contains multiple operations. The following part requires approval: …", "Tool permission request …";
  • "[Request interrupted by user for tool use]";
  • the tool refusing its input before running: "<tool_use_error>…" (including a parallel call cancelled because a sibling errored), or the shell's safety check ("Contains brace with quote character …").

Each of those counts only on a result the harness marked is_error: a tool's own output can say anything, and text that merely looks like a refusal must not make a call that ran read as not run.

func RelocateRecord added in v0.3.0

func RelocateRecord(configDir, path string) string

RelocateRecord returns where the record the harness reported at path actually is.

Usually that is path itself, and then this costs one stat. It is somewhere else when a session is resumed from a different working directory: Claude Code keeps appending to the transcript where the session began, but reports transcript_path under the project directory of the directory it was resumed in, where no such file exists. Measured on one machine: a session begun in a worktree and resumed from the main checkout got a SessionStart:resume payload naming <projects>/<main checkout>/<id>.jsonl, a file that was never written, while every record of that very hook — and of the rest of the session — went to <projects>/<worktree>/<id>.jsonl. Taken literally, the resumed session had no record and so no identity.

So a reported path that does not exist is looked for at the same place relative to the harness's OTHER project directories — <id>.jsonl for a session's record, <id>/subagents/…/agent-<a>.jsonl for a sub-agent's, which nests under the session's record and so moves with it. A file found there is accepted only if at least one of its records carries sessionId <id>, read positively: a file with no sessionId anywhere, or one that cannot be read, is not taken, because a coincidental name would otherwise hand back another conversation's identity.

A caller must not ask this for a FRESH session's SessionStart (source "startup"): that record does not exist yet by design, and a fixed --session-id reused across directories would find another project's transcript under the same name. See HookPayload.sessionRecord.

Not a sweep on every hook: the directories are listed only when the reported path is missing, and a relocation found is remembered for the life of the process (a hook asks for it several times).

configDir empty, a path not under <configDir's own "projects" folder>, or no match returns path unchanged.

The "projects" root this searches is derived from PATH ITSELF — climbed to from path, not built by joining configDir — and configDir is used only to confirm the two name the same directory, resolved-symlink to resolved-symlink so a spelling difference between them can never defeat that confirmation (see sameTree). Building it as filepath.Join(configDir, "projects") and then filepath.Rel-ing path against that join breaks the moment the two spell one directory differently: a config folder itself reached through a symlink (a symlinked config directory (CLAUDE_CONFIG_DIR under Claude Code), or the macOS /var → /private/var most callers resolve for exactly this reason but a bare join does not), or a reported path under a different-looking but identical directory. filepath.Rel does not resolve symlinks, so it either errors or returns a path escaping the tree with "../…", and relocation silently turns itself off — the caller falls straight back to the original, unwritten path with no diagnostic at all. Climbing from path's own directory names sidesteps this: whatever path a caller was handed, its own ancestry is asked directly, never compared to a second, independently-built path. sr:invariant session/resume-from-another-directory

func ResolveWorkDir

func ResolveWorkDir(dir string) string

ResolveWorkDir returns dir with its symlinks resolved, so that EncodeProjectDir(ResolveWorkDir(dir)) matches what the harness itself encoded.

On macOS the temp and data roots are symlinked — /var to /private/var — so a logical path and the harness's own resolved working directory differ, and an encoding taken from the unresolved one names a directory nothing is in.

For a path whose tail does not exist yet, the longest existing ancestor is resolved and the missing tail rejoined. On any error the input is returned unchanged, because a path we cannot resolve is still likelier to be right than nothing.

func RunningBackgroundAgents added in v0.4.0

func RunningBackgroundAgents(path string) (map[string]bool, error)

RunningBackgroundAgents returns the ids of the background sub-agents the transcript at path shows as still running: launched, with no LATER terminal <task-notification> for that id. A fold of BackgroundAgentSignals over this one record; the session's registry is the lasting answer, this is what one file says.

An error means "unknown": a caller must read it as "nothing is running" and judge everything.

func SessionDirOfSubagent

func SessionDirOfSubagent(path string) string

SessionDirOfSubagent returns the session directory a sub-agent's record is nested under — the directory beside which the dispatching session's own transcript sits — or "" when the path is not under a subagents/ tree at all.

Needed because "climb two levels" is not the layout, only the commonest one. Two are observed on this machine, and the survey behind this file's other notes counted only the first:

  • <session>/subagents/agent-<id>.jsonl — 322 files
  • <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — 9 files

For the second, the record's own directory is wf_<id> and its parent is workflows, so a fixed two-level climb lands on <session>/subagents rather than on <session>. Everything keyed on the result then looks for the conversation's other transcripts inside the subagents directory, finds none, and a restart that should have been crossed silently is not.

So the subagents component is SEARCHED for rather than counted to. That is what makes the answer independent of how deeply a harness decides to nest a record — the invariant the layouts share is that the directory is named subagents and the session directory is its parent, not that the record sits any particular number of levels below it.

The search runs from the record OUTWARDS (deepest first), so a session directory that itself contained a path component named subagents could not capture the answer ahead of the real one.

func SessionRootOf added in v0.3.0

func SessionRootOf(path string) string

SessionRootOf returns the ROOT record of the session the trajectory at path belongs to — the end user's own conversation — climbing out of however many levels of delegation path sits under. A root answers itself.

A sub-agent's record is nested under the directory named for the record that dispatched it (SessionDirOfSubagent), so its dispatcher's record is that directory plus .jsonl; the climb repeats while the record reached is still a sub-agent's, so a nested delegation reaches the root too.

Returns "" when path is a sub-agent's record whose root is not on disk where the layout puts it: an orphaned record is not a session this can name the root of, and guessing one is how one conversation's words are cited as another's.

func StableSessionID

func StableSessionID(projectDir, path string) (string, error)

StableSessionID resolves the identity of the conversation the transcript at path belongs to. It is ResolveStableSessionID without the Degraded flag: the id a degraded walk falls back to is returned as an answer, not an error. A caller that surfaces the degradation uses ResolveStableSessionID.

func StartCwd added in v0.4.0

func StartCwd(path string) (string, error)

StartCwd is the working directory a session began in: the first directory its record names. A later record may name another (an agent that `cd`'d), and that is the point of asking for the first — where the session's own tree is decided once, not wherever the agent last stood.

"" with a nil error when the record names none; an error only when it cannot be read. Callers that need an answer treat "" as "cannot be determined".

func StartTime added in v0.4.0

func StartTime(path string) (time.Time, error)

StartTime is when the record's first timestamped entry was written: when the session began. The zero time with a nil error when the record carries none.

func SubagentPaths

func SubagentPaths(path string) ([]string, error)

SubagentPaths returns the paths of the sub-agent transcripts spawned off the trajectory at path — the separate-file harnesses' "and some of it lives over there".

The sub-agent records of a session sit in <session>/subagents/ beside the session's own transcript, so for a root trajectory at <dir>/<session>.jsonl the directory to enumerate is <dir>/<session>/subagents/. For a sub-agent trajectory the same construction names ITS own subagents directory, which is how a nested delegation (a sub-agent that dispatched another) is found — the derivation is uniform, the file's own location deciding whose children it enumerates.

Empty, not an error, when there is no subagents directory: a trajectory that spawned nothing has none, which is the common case and a plain "none" rather than a fault. Only agent-<id>.jsonl files are returned — the meta companions and anything else a harness leaves there are not transcripts. The result is sorted, so the enumeration is stable for a caller that prints or compares it.

A harness that INLINES sub-agent work writes no such directory and this is empty for it; that is correct, and the spec's note that such work is found via .isSidechain on the entries rather than here is the other half of the same fact.

func SubagentTranscriptPath

func SubagentTranscriptPath(parentPath, agentID string) (string, error)

SubagentTranscriptPath returns where the sub-agent identified by agentID wrote its record, given the transcript of the session that dispatched it.

Needed because a harness does not always hand the sub-agent's own path over. Claude Code's SubagentStop payload carries agent_transcript_path and the answer is simply that; but the payload also carries an agent_id in cases where it does not, and a sub-agent hook that fell back to the PARENT's path would resolve the parent's identity and write the sub-agent's baseline, read mark and verdicts into the parent's state. That is the confusion this whole file exists to prevent, so the fallback has to reconstruct rather than give up.

The layout is measured, and the first measurement had a hole worth stating. The survey behind the "305 sub-agent transcripts" figure this branch quoted matched <session>/subagents/agent-<id>.jsonl and nothing else, so it silently excluded the files nested a further two levels down under subagents/workflows/wf_<id>/ — it reported a clean uniform layout partly because it only looked where that layout holds. Re-counted there are 331: 322 in the flat shape and 9 in the workflows one. See SessionDirOfSubagent, which is what stops the difference from mattering to anything that has to climb back out.

What the survey did establish holds for the files it saw: every record carried isSidechain true, every record's agentId equalled its own filename's id, none carried a logicalParentUuid, and all origin uuids were distinct from each other and from every main-transcript origin.

This function assembles the FLAT shape, which is the only one it needs to: it is reached when a harness reports an agent id without a path, and the path it builds is checked against the file that is actually there.

An agentID that is not a plain name is refused rather than repaired. It is joined onto a directory path, and filepath.Join CLEANS after concatenating, so an id of "../../../../../-another-project/victim" resolves out of the subagents directory, out of the session's, out of the project's, out of projects/ itself, and lands the read — and then the identity, and then all the state keyed on it — in a SIBLING project's conversation, with no error raised anywhere. Five hops rather than three because "agent-.." is one ordinary component: the prefix absorbs the first climb. The exact depth is pinned in TestSubagentTranscriptPathRefusesTraversal rather than reasoned about here.

Taking filepath.Base instead would make the path safe and the anomaly invisible; an agent id with a separator in it is not an agent id, and saying so is the only honest answer. Both slashes are refused because Windows separates on both, and "." and ".." are refused by name because they contain no separator yet still traverse.

Empty parentPath or agentID yields an error for the same reason: a path assembled from a missing piece is a guess, and a guess about where state lives is how one session's work lands under another's name.

func SubagentsLinkable added in v0.5.0

func SubagentsLinkable() bool

SubagentsLinkable reports whether the running harness can tie a sub-agent's session to the conversation that dispatched it.

func ToolResultAt

func ToolResultAt(path string, line int) (text string, isToolResult bool, err error)

ToolResultAt reports whether the entry at physical line of the transcript at path is a tool_result the session produced, and returns that result's text.

This is the LINE-oriented counterpart to the SourceToolResult pool: a delivery OBSERVATION is a `<abs-jsonl>:<ranges>` citation into the transcript, so what the deterministic check asks is not "does a quote resolve" but "is the entry at THIS LINE a tool_result" — the exact gap the old guardrail named (its cite_check_user_message could refuse a user message, but had no "this line is a real tool-call result" check). It is built on the SAME toolResultText pool cite searches, so "is a tool_result" means precisely what the tool_result source type means — one authority, not a second hand-rolled entry-type test.

isToolResult is true iff the entry at that line is a user entry carrying at least one tool_result block (toolResultText non-empty); text is that block content joined by newlines, the bytes a reviewer sees as the result. A line that is a user message, an assistant turn, an answer envelope with no ordinary result, or no entry at all is NOT a tool_result — returning false, so the caller refuses an observation that points at the agent's prose rather than a produced result.

A line past the end, or one that is not an entry (a preamble line, an unparseable one), yields ("", false, nil): "not a tool_result" is the honest answer, not an error, so the caller can name the citation rather than crash on a bad range.

func UnresolvedHint added in v0.5.0

func UnresolvedHint(path, quote string, sources []SourceType) string

UnresolvedHint is what to say when quote resolved in none of sources in the session whose record is at path: why the user pool missed it (see UnresolvedUserHint) and, for the tool_result pool, which excluded kind of tool result the words sit in (excludedResultHint). "" when there is nothing specific to say — the words are simply not there.

func UnresolvedUserHint added in v0.3.0

func UnresolvedUserHint(path, quote string) string

UnresolvedUserHint is what to add when quote does not resolve in the user pool of the session whose record is at path, or "" when there is nothing specific to say.

A sub-agent never sees the user's messages — only the prompt its parent wrote — so its commonest miss is quoting that prompt as the user. When quote is in a sub-agent's dispatch prompt, the hint says so; when path is a sub-agent's own record (the caller is known to be one), it says how --cite:user works for a sub-agent. From the root's record the caller is not known, so a dispatch-prompt match is named without assuming who is asking.

Types

type AgentSignal added in v0.4.0

type AgentSignal struct {
	Kind    AgentSignalKind
	AgentID string
	// Status is the notification's terminal status (completed, failed, killed, stopped); "" for a launch.
	Status string
	// Key identifies the record that carried the signal, stable across reads and across files that
	// repeat the record: the entry's uuid, or a hash of the notification when it has none.
	Key string
}

AgentSignal is one fact the dispatching transcript records about a background sub-agent, in record order.

func BackgroundAgentSignals added in v0.4.0

func BackgroundAgentSignals(path string) ([]AgentSignal, error)

BackgroundAgentSignals returns what the transcript at path shows about background sub-agents, in order: each launch (an Agent tool_use with run_in_background whose result returned an agent id) and each <task-notification> with a terminal status. It decides nothing: the registry (internal/sessionstate) folds these with the hooks' signals, and RunningBackgroundAgents is the fold of this alone. A foreground sub-agent is never listed (its call returns only when it is done).

An error means "unknown": the caller learns nothing from this record and an agent it does not know otherwise is judged.

type AgentSignalCursor added in v0.5.0

type AgentSignalCursor struct {
	Path   string `json:"path"`
	Offset int64  `json:"offset"`
	// Check fingerprints the bytes the cursor stands behind (the record's start and what ends at
	// Offset): a record rewritten in place, as a compaction may, is read from its start again.
	Check      string   `json:"check,omitempty"`
	Background []string `json:"background,omitempty"`
}

AgentSignalCursor is how far a record's background-agent signals have been read: the record, the byte after the last line read, and the Agent calls run in the background whose result has not come yet (a later line carries it). A record only grows, so a reader that kept this reads only what was appended since — a Stop of a session whose record is hundreds of megabytes does not decode it again, twice, every turn.

type AgentSignalKind added in v0.4.0

type AgentSignalKind string

AgentSignalKind says what a record of the dispatching transcript shows about a sub-agent.

const (
	// AgentLaunched: a background Agent call whose receipt returned the agent's id.
	AgentLaunched AgentSignalKind = "launched"
	// AgentEnded: a <task-notification> for the agent carrying a terminal status.
	AgentEnded AgentSignalKind = "ended"
)

type Citation added in v0.3.0

type Citation struct {
	// Quote is the exact substring the caller asked to ground.
	Quote string `json:"quote"`

	// SourceTypes are the pools the quote actually resolved in — a subset of the
	// pools the request named. A request naming `user,tool_result` whose quote is
	// the user's words records `[user]`, so a requirement for tool_result alone
	// is not satisfied by it.
	SourceTypes []SourceType `json:"sourceTypes"`

	// Path is the absolute trajectory path the quote resolved in — a sub-agent's
	// own record for output a sub-agent's tool produced.
	Path string `json:"path"`

	// Line is the 1-based physical line of the resolved entry.
	Line int `json:"line"`

	// Message is the full text of the cited entry in the pool(s) the quote
	// resolved in: the whole user message (for an AskUserQuestion answer, the
	// question with the selected answers), or the whole tool output. A quote is a search key — often a fragment chosen to be
	// unique or to avoid awkward characters — so a judge weighs it against the
	// message it came from. Capped at maxCitedMessage bytes.
	Message string `json:"message"`

	// Call is, for a quote that resolved in the tool_result pool, the tool call
	// that produced the output — `Bash: <command>`, `Read: <file_path>`, or the
	// tool's name and input — one line per result on the entry. Output alone
	// does not say where it came from: `echo 'all tests passed'` prints exactly
	// what a test run does, so a judge weighs the output against the call that
	// printed it. Empty in the user pool.
	Call string `json:"call,omitempty"`
}

A Citation is a resolved grounding: a quote, the pool(s) it resolved in, and where it sits. Unlike CitationMatch — cite's own bare `{Path, Line}`, kept to the two fields its stdout contract needs — a Citation is the shape carried on an EVENT (`event.citations`), so a consumer that never ran cite itself (a judge template, a `require: citation` check) sees what was cited and how, not only where it resolved.

func ResolveCitation added in v0.3.0

func ResolveCitation(path string, req CitationRequest) (Citation, error)

ResolveCitation grounds one request in the session whose record is at path.

Each pool is searched in the records it may draw on (see CiteInSession): the user pool in the session's ROOT record alone — never a sub-agent's, whose "user" message is the parent agent's dispatch — and the tool_result pool in the root record AND every sub-agent record beneath it, since a sub-agent's tool output is recorded only in its own file.

Each named pool is searched on its own, so the Citation records which pools the quote actually landed in. The quote must land on exactly ONE entry across every pool and every record searched: none is "not said", several — in one record or in two — is ambiguous, the same outcomes cite's exit codes name, reported as errors here so a caller can surface them verbatim. The Citation's Path, Line, Message and Call point into the record the quote resolved in.

func ResolveCitationAcrossSessions added in v0.4.0

func ResolveCitationAcrossSessions(current, projectDir string, req CitationRequest) (Citation, error)

ResolveCitationAcrossSessions grounds one request in a project's sessions, starting from the current one.

A commit is judged at a Stop that may be in a later session than the one that made it, so a quote in a commit's trailer may sit in a transcript other than the current session's. Lookup is: the current session first, then the project's other sessions from newest to oldest (by modification time; ties by name, so the order is the same every run).

The first session that CONTAINS the quote is the one that answers, and there it must match exactly once, on the same rules as ResolveCitation: a quote in two entries of that session is ambiguous, and an ambiguity is not resolved by looking further back. A quote found in no session is a ResolutionError.

A transcript that cannot be read does not stop the search — one broken file elsewhere in the directory must not disable grounding — but it is not silence either: when nothing resolves, the error names what could not be read, so a quote that was missed for that reason says so.

current may be empty (no session known); projectDir may be empty (no other sessions to search).

func ResolveCitations added in v0.3.0

func ResolveCitations(path string, reqs []CitationRequest) ([]Citation, error)

ResolveCitations grounds every request, in order, and fails CLOSED on the first that does not resolve: a caller handed fewer citations than it asked for would ground its claim on only some of what it cited without noticing.

func ResolveSubagentCitation added in v0.3.0

func ResolveSubagentCitation(path string, req CitationRequest) (Citation, error)

ResolveSubagentCitation is ResolveCitation for a caller KNOWN to be a sub-agent — its hook payload says so — whose record is at path. Such a record is never a root, whatever it says about itself or wherever it was filed: when no root can be climbed to from it, the user pool is refused (ErrNoSessionRoot) rather than searched in the sub-agent's own record, where the first "user" message is the parent agent's dispatch.

type CitationMatch

type CitationMatch struct {
	// Path is the absolute path of the trajectory file the match sits in.
	Path string

	// Line is the 1-based physical line of the matched entry in that file.
	Line int
}

CitationMatch is one resolved citation: the user's own words the quote landed on, and where they sit. Rendered on stdout as `<path>:<line>`, carried as a value so the path and line are named rather than left for a reader to split.

It is `<path>:<line>` and nothing more, deliberately. cite's whole job is to turn a remembered quote into a resolvable location; what SITS at that location — the whole AskUserQuestion envelope a judge might want to read, question and answer both — is a separate concern with a separate home. A change grounded in an answer wants the question beside it before a judge can weigh it, but that fetch belongs in the judge-prepare piece, which is handed a citation's path and line and reads the envelope back with EnvelopeAt — not in the citation itself. Keeping the match to its two fields is what keeps cite's stdout a stable `<path>:<line>` contract and the envelope-fetch a reusable transcript helper any consumer can call, rather than a payload cite must always assemble.

func Cite

func Cite(path, quote string) ([]CitationMatch, error)

Cite finds where quote sits in the user's own words within the trajectory at path — the SourceUser pool alone. It is CiteWithSources pinned to today's default, kept so every existing caller (and every caller that only ever wants the person's words) reads the same one-argument shape it always did.

func CiteInSession added in v0.3.0

func CiteInSession(path, quote string, sources []SourceType) ([]CitationMatch, error)

CiteInSession finds where quote sits within the SESSION whose record is at path — CiteWithSources over every record each named pool may draw on — and returns one match per entry, the session's root record first and each record's matches in file order.

  • SourceUser the session's ROOT record alone: the end user's own conversation. path may name a sub-agent's record; the root it was dispatched from is searched instead, and ErrNoSessionRoot is returned when that root is not on disk. No sub-agent's record is ever searched for the user's words: its "user" message is the parent agent's dispatch.
  • SourceToolResult the record at path FIRST — the caller's own — and, only when that holds no match, the root record and every sub-agent record beneath it (DescendantSubagentPaths). A sub-agent's tool output is genuine tool output of this session, recorded only in the sub-agent's own file, so it is found from anywhere in the session. But when the root and a sub-agent (or two sub-agents) read the same file, the two entries are identical and no longer quote tells them apart: the caller's own output is the one it means, and before any sub-agent existed it was the only one searched. For an orphaned sub-agent record, the session is that record and its own sub-agents.

An empty sources is SourceUser only, as for CiteWithSources.

func CiteWithSources

func CiteWithSources(path, quote string, sources []SourceType) ([]CitationMatch, error)

CiteWithSources finds where quote sits within the trajectory at path, searching the pools named in sources and returning one match per ENTRY that contains the substring in any requested pool.

The search is over USER entries only — a tool_result is written on a user entry, and the user's own words are on a user entry, so no other entry type carries either pool. An assistant turn is never searched: grounding a claim in the agent's own prior output is precisely what this must not let happen, whichever pools are requested. Within each user entry, sources selects what is read:

  • SourceUser the message text and any AskUserQuestion answer envelope, with harness-injected user-role messages excluded. An ordinary tool_result's body contributes nothing here.
  • SourceToolResult the raw body of each tool_result block that is a GENUINE tool output — the tool's own result. An answer envelope is a tool_result block too, but its body is the user's selected words, so it belongs to SourceUser and is EXCLUDED here (genuineToolResultText drops it): the two pools are disjoint, and under this pool the caller is asking about tool output, not the person. Only a result whose call is in the record and produced tool output is read (citableResults): not a sub-agent's reply, not a result of unknown provenance.

A match is per ENTRY, not per occurrence or per pool: an entry whose text contains the substring — twice, or in both pools — is one candidate, because the citation resolves to the line, and the line is the same every time. The caller decides what one, many, or no matches mean; this only finds them, in file order, so the rendered candidates read top-to-bottom.

An empty quote is treated as matching nothing rather than everything: a citation of "" resolves nowhere useful, and returning every line for it would turn a mistake into an ambiguous flood. An empty sources is treated as SourceUser only, so a nil or unset selection is today's behaviour, not a search that matches nothing. The path having no readable record is the caller's error to surface; here a read failure propagates.

type CitationRequest added in v0.3.0

type CitationRequest struct {
	Quote       string
	SourceTypes []SourceType
}

CitationRequest is one quote to ground and the pools it may ground in — what one `--cite:<source-types> <quote>` flag, or one `sr-session trajectory cite` call, names before it is resolved.

func (CitationRequest) String added in v0.3.0

func (r CitationRequest) String() string

String renders the request the way a command line spells it, for a message.

type Entry

type Entry = harness.Entry

Entry, EntryType and the Entry* kinds are defined by the harness-neutral internal/harness package, which is where a harness implementation normalises its own record format into them; they are re-exported here so the readers of a session need not name two packages.

func Filter

func Filter(entries []Entry, q Query) ([]Entry, error)

Filter applies a query to entries.

Asking about something that never happened returns nothing rather than an error. Absence is a legitimate finding, and often the one a rule is looking for.

An expression that no entry could be evaluated against at all IS an error, and the distinction is the point. One entry of another shape is ordinary and silently not a match (see runWhere). But an expression that failed on EVERY entry it was offered has told us nothing about the session — and returning "nothing matched" for it would report no violations, and exit successfully, for a rule that never ran. That is the silently-vacuous rule this product exists to prevent, and it would be this product producing it.

Judged on whether any entry evaluated, not on what any of them decided: a working rule that legitimately matches nothing is the ordinary case, and must stay distinguishable from a broken one that could not look.

func Read

func Read(path string) ([]Entry, error)

Read reads a Claude Code session record into canonical entries.

Lines carrying no uuid are skipped. Claude Code writes preamble and bookkeeping records — `custom-title`, `ai-title`, `mode`, `queue-operation`, `last-prompt` — that describe the session rather than anything that happened in it, and which carry no uuid at all; they cannot take part in a chain and there is nothing in them for a rule to ask about.

A line that will not parse is skipped rather than fatal. The format is read by observation and promised by nobody, so one unrecognised line is the harness having changed something — a reason to keep reading the rest, not to refuse to answer at all. A file that cannot be opened or read IS fatal: see the fail-loudly reasoning on StableSessionID.

func Since

func Since(entries []Entry, mark string) []Entry

Since narrows entries to those after the entry with the given uuid.

The mark is the uuid of the last entry the previous cycle read. Entries up to and including it are behind the mark; everything after it is this cycle's work.

An empty mark means nothing has been read yet, so everything is this cycle's — a first cycle sees the whole session, which is correct rather than a special case. A mark naming an entry that is not here also yields everything: the record it pointed into is gone or is another conversation's, and the safe reading of a lost position is to look again rather than to skip. Re-reading a turn costs a second look; skipping one loses a violation for good.

type EntryType

type EntryType = harness.EntryType

Entry, EntryType and the Entry* kinds are defined by the harness-neutral internal/harness package, which is where a harness implementation normalises its own record format into them; they are re-exported here so the readers of a session need not name two packages.

type Identity added in v0.3.0

type Identity struct {
	// ID is the uuid the session's state is keyed on.
	ID string

	// Degraded is nil when ID is the conversation's origin. It is set when the
	// walk reached a continuation it could not cross, and ID is then that
	// continuation's own root, the furthest-back record the walk reached. The
	// error says why it stopped: ErrContinuationMissing when no transcript on
	// disk holds what the continuation names (the predecessor was deleted, or
	// every candidate that held it could not be read — see UnreadableSiblings),
	// or ErrChainRunaway when the continuations close on themselves or run past
	// maxRestartHops — shapes no real harness writes, so malformed files.
	//
	// Where the walk falls back depends on where it started only for a
	// malformed ring: in a ring every file is somebody's predecessor, so the
	// walk from each file stops at a different one (from link-a it stops at the
	// file that points back to link-a). For the real case, a deleted
	// predecessor, every fork of the continuation reaches the same dead end and
	// so the same fallback.
	//
	// The tradeoff, stated because it is a real one. What the conversation
	// stored BEFORE that continuation is keyed on an origin nobody can compute
	// any more, so it is not rejoined; the session starts a fresh store at the
	// continuation. What it buys is everything after: the fallback is the
	// continuation root, which the harness writes once and never moves, and
	// which every fork of that continuation carries verbatim. So all of this
	// session's own hooks agree, and so do its forks, which is the convergence
	// the whole identity exists for. The alternative this replaces was no
	// identity at all, and a session with no store has no baseline, no recorded
	// citations and no guardrail state for the rest of its life.
	Degraded error

	// UnreadableSiblings lists every transcript the walk found in a project
	// directory it searched but could not read to a decision (a record past
	// maxRecordBytes, a permission error) — present whether or not the walk
	// resolved. A file like that is not a predecessor that is GONE: unlike a
	// deleted file, it might hold the very record being searched for, so its
	// unreadability is reported rather than silently treated as absence, but it
	// must never by itself stop the walk from finding a DIFFERENT, readable
	// predecessor that does resolve. See predecessors.
	UnreadableSiblings []string
}

Identity is the conversation identity a transcript resolves to.

ID is always usable: every hook of a session gets the same one for as long as its transcript exists. Degraded says whether it is the conversation's TRUE origin or the best the walk could do.

func ResolveStableSessionID added in v0.3.0

func ResolveStableSessionID(projectDir, path string) (Identity, error)

ResolveStableSessionID resolves the identity of the conversation the transcript at path belongs to.

projectDir is where the conversation's OTHER transcripts live — needed only to cross a restart, since the record a restart continues from is by definition in an older file. Derive it with ProjectDir rather than by searching: see path.go on the 1067-directory sweep that replaces.

What fails is what leaves nothing trustworthy to key on at all: no path, the STARTING transcript itself cannot be read, it has no parentless record, or a restart has no project directory to cross it in. A hook runs where the harness's own transcript must be readable, so these are a broken environment, and returning the reported id instead would restore the exact silent orphaning this exists to prevent.

A candidate PREDECESSOR elsewhere in the project directory that is ON DISK but cannot be read (a record past maxRecordBytes, a permission error) does NOT fail the walk — it is not this session's own record, and one unrelated bad file must never take down every continuation in the directory. It is also not treated as "gone": unlike a deleted file it might hold the very record being searched for, so the walk keeps looking for a DIFFERENT, readable predecessor first, and only falls back to Degraded, with the path remembered in Identity.UnreadableSiblings, when nothing readable resolves. Silently calling it "gone" would key the session on a fallback today and on the origin tomorrow, if the file became readable, splitting its state in two — this is why the two are told apart.

A predecessor that is genuinely gone is likewise NOT a hard failure. The session has a record and a continuation root, so it resolves to that root with Degraded set — see Identity. Measured on one ~/.claude: of 19 transcripts opening on a continuation, 3 name a predecessor no file holds any more, because Claude Code deletes transcripts older than its cleanup period while a later continuation of the same conversation is still resumable. sr:invariant session/identity-survives-reissued-ids

type LinedEntry

type LinedEntry struct {
	Entry

	// Line is the 1-based physical line of this entry in the transcript file.
	Line int
}

LinedEntry is an entry together with the physical line it was read from.

The line is 1-based and counts EVERY physical line of the file, including the preamble and bookkeeping lines Read drops — because the number has to name the line a person or a tool would jump to in the file itself, not an index into the entries that survived filtering. An entry read from the 40th line of the jsonl reports 40, whatever was skipped before it.

func ReadLines

func ReadLines(path string) ([]LinedEntry, error)

ReadLines reads a transcript into entries, each carrying the physical line it came from.

It is Read with the line kept. The two are separate calls rather than one because almost every caller wants the entries and not the line — the line is what `cite` and `normalize` assemble into a resolvable `<path>:<line>`, and carrying it on the common path would put a field on every entry that nothing else reads. Same skipping rules as Read: a line with no uuid, or one that will not parse, is not an entry — but it is still counted, so the lines that follow it keep the numbers the file gives them.

type Query

type Query struct {
	// Where is an expression over an entry, in the same language a guardrail's
	// matcher uses, so a rule author learns one syntax rather than two. Empty
	// admits everything the rest of the query allows.
	Where string

	// IncludeSidechains keeps entries belonging to sub-agents. Off by default:
	// a rule asking what the agent did usually means the main line of work, and
	// delegated work would otherwise answer for it.
	IncludeSidechains bool
}

Query narrows which entries an answer contains.

type ResolutionError added in v0.3.0

type ResolutionError struct{ Msg string }

ResolutionError is a citation that was read and did not resolve: its quote is in no entry of the pools searched, in more than one, or the user's words were asked of a sub-agent whose session is not found. Anything else ResolveCitation returns is a request that could not be read or a record that could not be.

func (*ResolutionError) Error added in v0.3.0

func (e *ResolutionError) Error() string

type SourceType

type SourceType string

SourceType is one pool of text within a user entry that a quote may be resolved against. It is a string so an unknown value travels under its own name rather than being silently coerced, the same reasoning as EntryType.

const (
	// SourceUser is the user's own words: the typed message text, a message sent
	// mid-turn (a human-typed queued_command attachment), and the answer
	// selected to an AskUserQuestion. Harness-injected user-role messages
	// (<system-reminder>, <task-notification>, a slash-command envelope) are
	// excluded, and an ordinary tool_result's body is not searched — grounding a
	// claim on either would be a false citation of the person.
	SourceUser SourceType = "user"

	// SourceToolResult is the raw text a tool_result block carries — the output an
	// action produced. This is the pool SourceUser refuses: a delivery observation
	// grounds a "the work happened" claim here (a green test, a command's result),
	// where the user's own words could never prove a command ran. The reply to a
	// sub-agent dispatch (Agent/Task) is excluded: it is model-written text, and
	// the sub-agent's own tool output is in its own record.
	SourceToolResult SourceType = "tool_result"
)

func ParseSourceType

func ParseSourceType(name string) (SourceType, bool)

ParseSourceType maps a source-type name to its SourceType, reporting whether it is one this package knows. The `cite` command parses its --source-types flag through here so an unknown name is refused with the value the caller wrote, rather than silently searching nothing.

type SubagentMeta

type SubagentMeta struct {
	// AgentType is what the harness dispatched — the sub-agent's role
	// ("Explore", "general-purpose"). Empty when the harness did not record one.
	AgentType string `json:"agentType"`

	// Description is the one-line summary of the delegated task.
	Description string `json:"description"`

	// ToolUseID is the id of the tool_use block that dispatched this sub-agent,
	// carried in the PARENT trajectory. It is how the immediate parent is found:
	// the parent is whichever trajectory in the session holds a tool_use with
	// this id. Empty when the harness did not record it — in which case the
	// parent cannot be derived from the meta file and ParentPath says so rather
	// than guessing.
	ToolUseID string `json:"toolUseId"`

	// SpawnDepth is how many delegations deep this sub-agent sits — 1 for one
	// dispatched from the root, greater when a sub-agent dispatched it in turn.
	// Kept so a reader can tell a top-level sub-agent from a nested one without
	// walking the chain; not relied on for correlation, which the id settles.
	SpawnDepth int `json:"spawnDepth"`
}

SubagentMeta is the companion record a harness writes beside a sub-agent's transcript, describing the delegation that created it.

Observed, not promised: Claude Code writes agent-<id>.meta.json holding {agentType, description, toolUseId, spawnDepth}. Only ToolUseID is load-bearing here — it is the id of the tool_use in the PARENT trajectory that dispatched this sub-agent, and so the one thread back to the immediate parent. The rest is carried because a reader asking "what kind of sub-agent" or "how deep" is asking a fair question the file already answers, and dropping the fields would make this a parser of one field rather than of the record.

func ReadSubagentMeta

func ReadSubagentMeta(transcriptPath string) (SubagentMeta, bool, error)

ReadSubagentMeta reads the companion meta file for a sub-agent's transcript.

The boolean reports whether a meta file was there at all, kept apart from the error so the ordinary case — a ROOT trajectory, which has no meta file — is not an error to handle but a plain "no". A meta file that exists but will not parse IS an error: the harness wrote something and it was not what we read, a difference worth surfacing rather than swallowing into "no meta".

type ToolCall

type ToolCall struct {
	// Name is what the harness calls the tool — `Bash`, `Write`, `Edit`. Read
	// only to decide nothing here: the modules dispatch on the argument SHAPE,
	// not the name (see filemod.extractPending), so this is carried for a caller
	// that builds a module payload, which needs a Tool() to hand back.
	Name string

	// Input is the tool's own arguments, exactly as the harness wrote them.
	Input json.RawMessage

	// ID is the tool_use block's id — what the call's tool_result names as its
	// tool_use_id, and so how a reader tells whether the call ran (see
	// RanToolUseIDs).
	ID string
}

ToolCall is one tool_use block on an assistant entry: what the harness called the tool, and the arguments it was given, left undecoded.

The arguments are kept raw for the reason the modules keep them raw — what they mean is the tool's business, and each module reads only the keys it recognises (a shell tool's `command`, a write tool's `file_path`). Decoding them here would be this package modelling every tool's input, which is exactly what normalising into Entry avoids.

func ToolCalls

func ToolCalls(e Entry) []ToolCall

ToolCalls returns every tool_use block in an assistant entry's message, in the order they appear.

Empty for anything that is not an assistant turn carrying tool calls — a user message whose content is a string, an entry with no message, an assistant turn of plain text. That breadth is deliberate and matches ToolUseIDs: `normalize` walks every entry and asks each what it yields, and an entry that made no tool call contributes no events rather than being a case to skip around.

Only assistant entries are read. A tool_result on a USER entry carries a `content` that is not a tool CALL — the tool already ran — so reading a user entry here would invent calls out of results. The events `normalize` derives are the pre-action ones (what a call was ABOUT to do), which only an assistant entry's tool_use blocks describe.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL