Documentation
¶
Overview ¶
Package handoff assembles a redacted, provenance-rich markdown bundle from an active Semantica capture session so a fresh agent session can pick up where the previous one left off without re-reading the original transcript.
The service is the source of truth for handoff content. Both the terminal-facing `semantica handoff --write` command and the hidden `semantica skills handoff` backing command call into this package so the bundle shape is identical across surfaces.
Index ¶
- Constants
- Variables
- func ContinuePromptFor(bundlePath string) string
- func LookPathForTest() func(string) (string, error)
- func ProviderFromBundle(body []byte) string
- func SetLookPathForTest(fn func(string) (string, error))
- type ActiveProvider
- type AmbiguousActiveSessionError
- type Input
- type LaunchSpec
- type Result
- type Service
Constants ¶
const HandoffFilename = "handoff.md"
HandoffFilename is the relative path inside the repo's `.semantica/` directory that the writer targets.
Variables ¶
var ErrAmbiguousSession = errors.New("multiple agent sessions active for this repo")
ErrAmbiguousSession is returned when more than one distinct provider has active capture state in the repo. Multiple sessions for the same provider auto-resolve through that provider's latest lineage row; distinct providers require a caller choice. Callers can use errors.As to unwrap an AmbiguousActiveSessionError.
var ErrAutoSelectFailed = errors.New("could not resolve auto-selected provider")
ErrAutoSelectFailed is the auto-collapse sibling of ErrNoFromMatch. When multiple capture states for one provider are active and the service silently routes through the from path for that provider, a downstream lineage miss surfaces as this error, not ErrNoFromMatch. Wraps the same specific reasons ("no recent X session", "lineage.db not found", etc.); only the surface shaping differs at the command layer.
var ErrBundleMissing = errors.New("handoff bundle not found")
ErrBundleMissing indicates `.semantica/handoff.md` does not exist for the current repo. The continue command surfaces this by pointing the user at `semantica handoff --write` first.
var ErrNoFromMatch = errors.New("could not resolve --from source")
ErrNoFromMatch is returned when an explicit --from source cannot be resolved. The wrapped error includes the specific reason, such as missing lineage data or no recent session for that provider.
Only used when the user typed --from. When the service auto-routes through the from path (same-provider collapse), failures wrap ErrAutoSelectFailed instead so the command layer does not advise the user to "drop --from" they never typed.
var ErrNoSession = errors.New("no agent session found for this repo")
ErrNoSession is returned when no usable Semantica capture session resolves for the current repo. Callers translate this into a non-zero exit with a clear user message.
var ErrUnknownProvider = errors.New("unknown agent provider")
ErrUnknownProvider indicates --agent named a provider we don't have any launch knowledge for. Distinct from "no spawn for this provider" (which is the LaunchSpec.Spawn=false path): unknown provider means we don't even have a print-the-command fallback because we don't know which binary to suggest.
Functions ¶
func ContinuePromptFor ¶
ContinuePromptFor returns the starter prompt the launcher passes to the spawned agent for a given bundle path. The path is embedded verbatim so the spawned agent's cwd doesn't matter and the print-path command stays copy-pasteable from any terminal regardless of the user's current working directory. Callers always pass an absolute path; relative paths break the "run this from a fresh terminal" promise.
func LookPathForTest ¶
LookPathForTest returns the package-level lookPath stub. Used by command-level tests in another package that need to swap in a deterministic binary detector. Not part of the public API; only intended for tests.
func ProviderFromBundle ¶
ProviderFromBundle extracts the provider name from the "Original session" line emitted by renderBundle. Returns an empty string when the line is absent or malformed; callers surface that as "couldn't determine which agent; pass --agent."
func SetLookPathForTest ¶
SetLookPathForTest replaces the package-level lookPath stub. Pair with LookPathForTest to capture the original and restore in t.Cleanup.
Types ¶
type ActiveProvider ¶
type ActiveProvider struct {
// Provider is the hook-form provider name (claude-code,
// gemini-cli, etc.) the command layer surfaces to the user and
// passes back as --from.
Provider string
// Count is the number of active capture states for this
// provider. Surfaced in the picker label so the user can spot
// stale-orphan clusters at a glance.
Count int
// LatestTimestamp is the most-recent capture-state timestamp
// among this provider's active states. Used to sort the picker
// (most-recent first) and to render the "latest Xm ago" hint.
LatestTimestamp time.Time
}
ActiveProvider describes one distinct provider with active capture states in the repo. Used by the ambiguity resolution flow to populate the picker (interactive) or the error message (non-interactive).
type AmbiguousActiveSessionError ¶
type AmbiguousActiveSessionError struct {
Providers []ActiveProvider
}
AmbiguousActiveSessionError carries the candidate provider list alongside the ErrAmbiguousSession sentinel so the command layer can either show a picker (TTY) or print the list in a clear non-interactive error. Implements Is(target) so existing errors.Is(err, ErrAmbiguousSession) checks keep working.
func (*AmbiguousActiveSessionError) Error ¶
func (e *AmbiguousActiveSessionError) Error() string
func (*AmbiguousActiveSessionError) Is ¶
func (e *AmbiguousActiveSessionError) Is(target error) bool
Is wires errors.Is(err, ErrAmbiguousSession) back to true so existing call sites that test the sentinel keep working without knowing about the typed wrapper.
type Input ¶
type Input struct {
// RepoPath is the working repository whose session is being
// handed off. Defaults to the current working directory at the
// command layer.
RepoPath string
// Now is the wall-clock used for "is this session recent enough"
// checks. Tests inject a fixed time. Empty value means time.Now().
Now time.Time
// From, when non-empty, sources the bundle from the named
// provider's most-recent session in this repo. The value is the
// hook-form provider name (claude-code, cursor, gemini-cli,
// copilot, kiro-cli, kiro-ide). Empty uses the default resolution
// chain: active capture state, then lineage fallback.
From string
}
Input narrows the surface the caller has to provide. RepoPath is the only required field; the service derives everything else from the repo's lineage.db and the global capture-state directory.
type LaunchSpec ¶
type LaunchSpec struct {
// Provider is the canonical name of the agent (matches the
// capture-state and SKILL.md naming, e.g. "claude-code").
Provider string
// Binary is the executable name on PATH (e.g. "claude" for
// claude-code). Empty when Spawn is false and the user must
// run the agent manually.
Binary string
// Args are the positional + flag arguments to pass to Binary.
// The starter prompt is included here in whatever shape the
// target agent's CLI accepts.
Args []string
// Spawn reports whether this launch can be exec'd directly.
// When false, callers print Message and exit; the user is
// expected to invoke the agent themselves.
Spawn bool
// Message is the human-readable text the command layer prints
// before either spawning (informational) or surrendering to
// the manual flow (the explanation of why we can't spawn).
Message string
}
LaunchSpec describes how the continue command should hand off to the next agent. The command layer turns this into either an actual exec (when Spawn is true) or a printed instruction the user runs themselves (when Spawn is false).
func BuildLaunchSpec ¶
func BuildLaunchSpec(provider, bundlePath string, printOnly bool) (*LaunchSpec, error)
BuildLaunchSpec builds launch configuration for the given provider. The returned spec carries enough information for the command layer to either exec the agent's binary (Spawn=true) or print a manual-launch hint (Spawn=false). printOnly forces the print-the-command branch even for providers we know how to spawn, which lets users copy the invocation rather than land in a new agent shell.
bundlePath must be absolute. The starter prompt and any manual- launch hint embed it directly so the resulting commands are usable from any directory the user might paste them into.
type Result ¶
type Result struct {
// Path is the absolute filesystem path of the written bundle.
Path string
// SessionID is the resolved session whose context the bundle
// captures. Surfaced for diagnostics and tests.
SessionID string
// Provider is the capture provider for the resolved session.
Provider string
// Bytes is the raw markdown body that was written. Returned for
// tests; do not echo this back into the originating session.
Bytes []byte
}
Result describes what was written so the caller can render its two-line user instruction.
type Service ¶
type Service struct{}
Service assembles handoff bundles. Construct via NewService.
func NewService ¶
func NewService() *Service
NewService returns a stateless service. All dependencies (lineage store, redactor, capture-state directory) are reached through existing internal packages.
func (*Service) Write ¶
Write resolves the active session for the repo, assembles a redacted markdown bundle, and writes it to `<repo>/.semantica/handoff.md`. Returns the resolved session and the bytes written.
Session resolution has three layers, in priority order:
- Explicit --from: when Input.From names a provider, source the bundle from that provider's most-recent session in the repo regardless of which agent currently holds the active capture state.
- Active capture state, written by the agent's prompt-submit hook and deleted by the stop hook at end of turn. Works in-turn (e.g., from the skill body's bash invocation while the agent is still mid-response).
- Lineage fallback: when no active capture state matches, look up the most-recent parent session for the repo in agent_sessions (within the same 24h recency window) that has at least one event. This is what makes `handoff --write` work between turns, when capture state has been cleaned up but durable lineage data still exists.