investigate

package
v0.7.2 Latest Latest
Warning

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

Go to latest
Published: Jun 2, 2026 License: MIT Imports: 40 Imported by: 0

Documentation

Overview

Package investigate contains the env-var contract between `entire investigate` (which spawns the agent process) and the lifecycle hook (which adopts the session), plus the persisted run state for resuming an investigation. These names are stable API; renaming any constant is a breaking change.

Design rationale: each spawned agent inherits its own copy of the process environment, so multi-tenant correctness (multiple worktrees, multi-agent runs) holds by construction — one agent's env vars cannot bleed into another agent's session. The lifecycle UserPromptSubmit hook reads these env vars to tag the in-flight session as an investigate session (Kind = "agent_investigate") and records the run id + topic.

Index

Constants

Investigate env vars. Names live in cmd/entire/cli/provenance; aliased here for the package's call sites.

View Source
const InvestigationsDirName = "entire-investigations"

InvestigationsDirName is the directory name (under git common dir) where investigation runs persist their per-run artifacts (findings.md + state.json).

Variables

View Source
var ErrGhNotFound = errors.New("gh CLI not found on PATH")

ErrGhNotFound is returned when ResolveIssueLink cannot find the gh CLI on PATH. Callers (and tests) can match on this sentinel via errors.Is.

View Source
var ErrInvestigateNoAgentsSelected = errors.New("no agents selected for investigation")

ErrInvestigateNoAgentsSelected is returned when the user unchecks all agents.

View Source
var ErrInvestigatePickerCancelled = errors.New("investigate agent picker cancelled")

ErrInvestigatePickerCancelled is returned when the user aborts the multi-select.

Functions

func AppendInvestigateEnv

func AppendInvestigateEnv(base []string, opts AppendOptions) []string

AppendInvestigateEnv adds the ENTIRE_INVESTIGATE_* env vars to base, returning the new slice. Used by the loop driver when spawning each per-turn agent process to propagate the investigate-session contract.

Any pre-existing ENTIRE_INVESTIGATE_* AND ENTIRE_REVIEW_* entries in base are stripped before the new values are appended. Stripping investigate entries handles nested invocations and stale inheritance from a parent shell — duplicate keys would otherwise have implementation-defined precedence. Stripping review entries prevents an outer `entire review` session from mis-tagging a child investigate session if invoked nested.

func ComposeInvestigatePrompt

func ComposeInvestigatePrompt(in ComposeInput) string

ComposeInvestigatePrompt renders the full prompt sent to one agent for one turn of an investigate run.

The findings doc is a shared living document. Each agent appends findings, evidence, and analysis until the team reaches quorum. The agent records its stance by writing the `pending_turn` field of Files.State to a JSON object of the form {"stance":"approve|request-changes|reject","note":"<one-line>"}. The agent must not modify any other field of state.json.

func ConfirmFirstRunSetup

func ConfirmFirstRunSetup(ctx context.Context, out io.Writer) bool

ConfirmFirstRunSetup prints a banner framing the picker as first-run setup (rather than the investigation itself) and waits for the user to confirm.

func DeriveTopicFromSeed

func DeriveTopicFromSeed(body []byte, fallbackFilename string) string

DeriveTopicFromSeed extracts a human-readable topic from a seed-doc body. Order of precedence:

  1. The first `# Investigation: <topic>` line — the scaffold's own title format. Round-trips a finished findings doc cleanly.
  2. The first markdown H1 (`# anything`).
  3. fallbackFilename without its extension.

func IsInvestigateEnvEntry

func IsInvestigateEnvEntry(kv string) bool

IsInvestigateEnvEntry reports whether kv is a "KEY=VALUE" entry whose key is one of the ENTIRE_INVESTIGATE_* contract variables.

func IsValidRunID

func IsValidRunID(runID string) bool

IsValidRunID reports whether runID matches the 12-lowercase-hex format. Delegates to provenance.IsValidRunID — the canonical validator lives alongside the env-var contract it's most often paired with.

func NewCommand

func NewCommand(deps Deps) *cobra.Command

NewCommand returns the `entire investigate` cobra command wired with the provided deps.

func PrintInvestigateFindingsListForTest

func PrintInvestigateFindingsListForTest(w io.Writer, manifests []LocalManifest)

PrintInvestigateFindingsListForTest exposes printInvestigateFindingsList to tests in package investigate_test.

func RunClean

func RunClean(ctx context.Context, in CleanInput, deps CleanDeps) error

RunClean implements `entire investigate clean`.

func RunFix

func RunFix(ctx context.Context, in FixInput, deps FixDeps) error

RunFix resolves a saved investigation, composes the follow-up prompt, and launches a coding agent session via deps.Launch.

The prompt body says "use these findings as grounded context, do not re-investigate". The composed prompt embeds the findings doc verbatim so the agent has full access without needing to re-read disk.

func RunInvestigateConfigPicker

func RunInvestigateConfigPicker(
	ctx context.Context,
	out io.Writer,
	spawnerFor func(agentName string) spawn.Spawner,
	getAgentsWithHooksInstalled func(ctx context.Context) []types.AgentName,
) (*settings.InvestigateConfig, error)

RunInvestigateConfigPicker shows a multi-select of eligible agents and prompts for max-turns / quorum. Returns a populated InvestigateConfig the caller can persist via settings.Save.

Eligibility: agent has a non-nil Spawner AND has hooks installed. Non-spawnable agents (cursor, opencode, factoryai-droid, copilot-cli) are filtered out at the SpawnerFor check.

func RunShow

func RunShow(ctx context.Context, in ShowInput, deps ShowDeps) error

RunShow prints the saved investigation summary + findings for the requested run id. Resolution rules:

  • empty RunID + exactly one manifest → use that manifest
  • empty RunID + multiple manifests → list candidates, return error
  • non-empty RunID: exact match wins; otherwise unique-prefix match; otherwise return an "ambiguous" or "not found" error

Findings come from manifest.FindingsContent when present (terminal outcomes), or by reading manifest.FindingsDoc from disk (paused / cancelled runs whose per-run dir still exists). Both paths missing is a soft state — the header is printed with an explanatory line.

func SetPickerFormFnForTest

func SetPickerFormFnForTest(fn pickerFormFn) func()

SetPickerFormFnForTest swaps the picker form function. Returns a cleanup function the caller must defer to restore the previous value.

func SlugifyTopic

func SlugifyTopic(topic string) string

SlugifyTopic converts an arbitrary topic string into a filesystem-safe slug. Result is lowercase, ASCII-alphanumeric with single dashes, no leading or trailing dash, and no longer than 60 characters. Empty/non-mappable input returns "investigation".

Types

type AgentChoice

type AgentChoice struct {
	Name  string
	Label string
}

AgentChoice is one row in the investigate picker. Name is the agent registry key (used for spawning); Label is the picker-visible string.

type AppendOptions

type AppendOptions struct {
	AgentName   string
	RunID       string
	Topic       string
	FindingsDoc string
	StateDoc    string
	StartingSHA string
}

AppendOptions carries the data needed to populate the ENTIRE_INVESTIGATE_* env vars on a spawned agent process.

type BootstrapInput

type BootstrapInput struct {
	// SeedDoc is the absolute path to a user-provided seed file. Empty
	// when no seed was passed.
	SeedDoc string

	// Topic is the topic-only investigation prompt collected from the
	// spawn-time multipicker (set when neither SeedDoc nor IssueLinkSeed
	// is supplied). Empty otherwise.
	Topic string

	// IssueLinkSeed is the markdown bytes produced by ResolveIssueLink.
	// Empty when --issue-link was not used.
	IssueLinkSeed []byte

	// IssueLinkTopic is the topic derived from the resolved issue/PR
	// title. Used only when IssueLinkSeed is non-empty.
	IssueLinkTopic string

	// FindingsDoc is the absolute path the findings doc must be written
	// to.
	FindingsDoc string
}

BootstrapInput carries the data needed to produce the initial findings doc on disk.

Exactly one of SeedDoc / Topic / IssueLinkSeed must be set:

  • SeedDoc: the user passed a positional [seed-doc] path; render the scaffold and embed the seed bytes under the `## Question` section. Topic is derived from the body (or filename).
  • Topic only: the user supplied the investigation prompt via the spawn-time multipicker (no seed, no issue link); render the scaffold with the topic printed under `## Question`.
  • IssueLinkSeed: the user passed --issue-link; ResolveIssueLink already produced a markdown body — render the scaffold and embed those bytes under `## Question`, using IssueLinkTopic as the topic.

type BootstrapResult

type BootstrapResult struct {
	// Topic is the resolved topic — used downstream for slug derivation,
	// manifest entries, and prompt rendering.
	Topic string

	// FindingsDoc is the absolute path the findings doc was written to
	// (echoes BootstrapInput.FindingsDoc).
	FindingsDoc string
}

BootstrapResult reports what was produced.

func Bootstrap

func Bootstrap(ctx context.Context, in BootstrapInput) (BootstrapResult, error)

Bootstrap writes the initial findings doc to disk.

File-write semantics: creates parent directories as needed and writes the findings file unconditionally. Callers that want "skip if findings doc exists" semantics should stat the path themselves; Bootstrap is idempotent at the byte level (same input → same output) but does not protect existing files — protecting an existing investigation belongs to a layer above this one.

type CleanDeps

type CleanDeps struct {
	ManifestStore *LocalManifestStore
	// RunDir returns the per-run directory path for a given run id. In
	// production this is StateStore.RunDir; tests inject a fake.
	RunDir func(runID string) string
	// ManifestPath returns the on-disk path for a manifest. In
	// production this is LocalManifestStore.PathFor(m).
	ManifestPath func(m LocalManifest) string
	// Confirm prompts the user with the given message and returns the
	// y/N answer. Nil → real huh-backed prompt (use newAccessibleForm).
	Confirm func(ctx context.Context, message string) (bool, error)
}

CleanDeps is what RunClean needs that's test-injectable.

type CleanInput

type CleanInput struct {
	// RunID, when non-empty, targets one run via exact-match-then-
	// unique-prefix. Ignored when All is true.
	RunID string

	// All targets every investigation found by the manifest store.
	All bool

	// Force skips the confirmation prompt.
	Force bool

	// Out / ErrOut sink the operator-facing output.
	Out    io.Writer
	ErrOut io.Writer
}

CleanInput drives RunClean.

type ComposeInput

type ComposeInput struct {
	// Topic is the human-readable subject of the investigation. Used in
	// the body of the prompt as plain text — never as a section heading,
	// since the rendered findings doc owns that.
	Topic string

	// AgentName is the agent the prompt is being rendered for (e.g.
	// "claude-code").
	AgentName string

	// Round is the 1-indexed round number in the loop.
	Round int

	// MaxTurns is the per-agent turn budget (the "of N" half of
	// "Round X of N").
	MaxTurns int

	// Turn is the 1-indexed overall turn number across rounds.
	Turn int

	// AlwaysPrompt, if non-empty, is appended verbatim at the end of the
	// rendered prompt. Lets users inject project-specific guardrails into
	// every turn via settings.
	AlwaysPrompt string

	// Files holds the findings + state absolute paths the agent must
	// read and edit.
	Files Files
}

ComposeInput is the per-turn data needed to render an investigate prompt. Intentionally narrow: the loop driver passes only what the prompt template uses.

type Deps

type Deps struct {
	// GetAgentsWithHooksInstalled returns the registry names of all agents
	// whose lifecycle hooks are installed in the current repo.
	GetAgentsWithHooksInstalled func(ctx context.Context) []types.AgentName

	// NewSilentError wraps an error so the cobra root does not double-print
	// it.
	NewSilentError func(err error) error

	// SpawnerFor maps an agent name → Spawner (claude-code, codex,
	// gemini-cli). Returns nil for non-launchable agents.
	SpawnerFor func(agentName string) spawn.Spawner

	// LaunchFix delegates to agentlaunch.LaunchFixAgent in production.
	LaunchFix func(ctx context.Context, agentName string, prompt string) error

	// LoopRun, when non-nil, replaces RunInvestigateLoop.
	LoopRun func(ctx context.Context, in LoopInput, ldeps LoopDeps) (LoopResult, error)

	// PromptYN is the interactive y/N prompt used by the settings migration
	// and the HEAD-soft-warn. Nil means "use the real huh-backed prompt".
	PromptYN func(ctx context.Context, question string, def bool) (bool, error)

	// HeadHasInvestigateCheckpoint returns (true, info) when the
	// checkpoint at HEAD already has HasInvestigation set. Used to
	// soft-warn against running a redundant investigation. Nil means
	// "skip the check entirely".
	HeadHasInvestigateCheckpoint func(ctx context.Context) (bool, string)

	// InvestigateMultipicker overrides the spawn-time agent picker. Nil
	// means "use the real PickInvestigateAgents form".
	InvestigateMultipicker func(ctx context.Context, choices []AgentChoice, askPrompt bool) (PickedInvestigate, error)
}

Deps collects the runtime-injectable hooks NewCommand needs from the parent cli package. Tests stub fields to drive branches that would otherwise require a real TTY or enabled repo.

type Files

type Files struct {
	// Findings is the absolute path to the findings document the agent
	// reads, edits, and adds evidence to.
	Findings string
	// State is the absolute path to the run's state.json file. The agent
	// records its stance there via the `pending_turn` field.
	State string
}

Files holds the absolute paths to the documents shared across an investigation run.

type FixDeps

type FixDeps struct {
	// ManifestStore loads local manifests by run ID.
	ManifestStore *LocalManifestStore

	// FixAgent is the agent registry name to launch. When empty, RunFix
	// falls back to defaultFixAgent.
	FixAgent string

	// Launch runs the actual coding agent session. Production wires this
	// to agentlaunch.LaunchFixAgent.
	Launch func(ctx context.Context, agentName string, prompt string) error

	// ReadFile, when non-nil, replaces os.ReadFile.
	ReadFile func(name string) ([]byte, error)
}

FixDeps collects what RunFix needs that's injectable for tests.

type FixInput

type FixInput struct {
	// RunID resolves a specific run; empty means "pick the most recent".
	RunID string

	// Out is the user-facing stream for the launch banner.
	Out io.Writer

	// ErrOut is the user-facing stream for warnings (e.g. missing doc).
	ErrOut io.Writer
}

FixInput drives RunFix.

type IssueLinkResult

type IssueLinkResult struct {
	// SeedDoc is the rendered markdown body — ready to write to a
	// findings doc via Bootstrap.IssueLinkSeed.
	SeedDoc []byte
	// Topic is the human-readable topic. Prefers the issue/PR title; if
	// the title is empty, falls back to the URL.
	Topic string
}

IssueLinkResult is the output of ResolveIssueLink.

func ResolveIssueLink(ctx context.Context, rawURL string) (IssueLinkResult, error)

ResolveIssueLink resolves a GitHub issue or PR URL via the gh CLI and returns a markdown seed-doc body suitable for passing to Bootstrap.IssueLinkSeed.

Supported: GitHub issues and PRs only. Non-GitHub hosts (gitlab, bitbucket, self-hosted forges) and non-issue/PR GitHub paths return an actionable error pointing the user at [seed-doc] instead.

The function intentionally does not follow nested issue/PR cross-references or fetch related sub-issues: keep the seed scope to one resource so agents have a clear starting point.

type LocalManifest

type LocalManifest struct {
	// RunID is the 12-hex-char investigation run identifier.
	RunID string `json:"run_id"`

	// Topic is the human-readable subject of the investigation.
	Topic string `json:"topic"`

	// Slug is the filesystem-safe form of Topic, derived via SlugifyTopic.
	Slug string `json:"slug"`

	// StartingSHA is the git commit SHA that was HEAD when the
	// investigation started.
	StartingSHA string `json:"starting_sha"`

	// WorktreePath is the absolute path to the worktree the run executed
	// in. Empty when the run was not associated with a specific
	// worktree.
	WorktreePath string `json:"worktree_path,omitempty"`

	// FindingsDoc is the absolute path to the findings document the run
	// produced. Always absolute — callers (writeRunManifest in particular)
	// must resolve repo-relative paths before populating this field, since
	// `entire investigate show` / `fix` read it back via os.ReadFile and
	// do not perform their own resolution. The on-disk file is removed for
	// terminal outcomes (Quorum/Stalled) once FindingsContent has been
	// captured — the path remains here for resumable runs (Paused /
	// Cancelled) where the file still lives in the per-run directory.
	FindingsDoc string `json:"findings_doc,omitempty"`

	// FindingsContent embeds the final findings.md content as of run
	// end. Populated on terminal outcomes (Quorum/Stalled) so the
	// findings survive after the per-run directory is cleaned up. Empty
	// on Paused/Cancelled — those runs are resumable and the file lives
	// on disk in the per-run directory at FindingsDoc.
	FindingsContent string `json:"findings_content,omitempty"`

	// Agents is the ordered list of agent names that participated in
	// the run.
	Agents []string `json:"agents"`

	// Outcome is the terminal outcome of the run. One of: "quorum",
	// "stalled", "paused", "cancelled".
	Outcome string `json:"outcome"`

	// StancesByAgent records the LAST stance each agent expressed in
	// the run, keyed by agent name. Empty when the run terminated
	// without any stances being recorded.
	StancesByAgent map[string]string `json:"stances_by_agent,omitempty"`

	// StartedAt is when the run was initiated.
	StartedAt time.Time `json:"started_at"`

	// EndedAt is when the run terminated.
	EndedAt time.Time `json:"ended_at"`
}

LocalManifest is the persisted record of one `entire investigate` run for local findings browsing. Written to <git-common-dir>/entire-investigations/ manifests/<timestamp>-<run-id>.json after each run terminates.

The schema is intentionally narrower than RunState: this file is what `entire investigate --findings` reads to render the picker, so it carries only what a human (or `entire status`) needs to identify a past run, not the state needed to resume one.

func ResolveByRunID

func ResolveByRunID(manifests []LocalManifest, runID string) ([]LocalManifest, error)

ResolveByRunID matches a (possibly partial) run ID against the supplied manifest list. Exact match wins; otherwise unique-prefix wins. Returns a slice (always length 1 on success) so callers handle the not-found and ambiguous cases via the error.

The shape mirrors what show and clean both want: an exact 12-hex match resolves O(1), a unique prefix expands to exactly one manifest, and any other case produces a user-readable error listing the candidates.

type LocalManifestStore

type LocalManifestStore struct {
	// contains filtered or unexported fields
}

LocalManifestStore wraps the directory that holds persisted LocalManifest JSON files for one repository.

func NewLocalManifestStore

func NewLocalManifestStore(ctx context.Context) (*LocalManifestStore, error)

NewLocalManifestStore creates a LocalManifestStore rooted at <git-common-dir>/entire-investigations/manifests. Resolves the common dir via session.GetGitCommonDir, so this requires a git repository context.

func NewLocalManifestStoreWithDir

func NewLocalManifestStoreWithDir(dir string) *LocalManifestStore

NewLocalManifestStoreWithDir creates a LocalManifestStore rooted at dir. Useful for tests that do not want to depend on a real git repository.

func (*LocalManifestStore) FindByRunID

func (s *LocalManifestStore) FindByRunID(ctx context.Context, runID string) (LocalManifest, bool, error)

FindByRunID returns the manifest whose RunID equals runID. The bool reports whether a match was found; when false the returned manifest is the zero value. Returns an error only when the underlying directory read itself fails.

Filenames are <timestamp>-<runID>.json, so the lookup is a single Glob + one file read rather than scanning every manifest in the directory.

func (*LocalManifestStore) Latest

Latest returns the most recent manifest in the store, identified by the lexicographically largest filename (filenames are <timestamp>-<runID>.json where the timestamp prefix sorts chronologically). The bool reports whether the store has any manifests; when false the returned manifest is the zero value. Avoids reading every manifest just to pick the newest one.

func (*LocalManifestStore) List

List returns every manifest in the store sorted newest first by StartedAt. A missing directory is treated as an empty list (nil, nil) — useful for callers that want to render `--findings` even when no investigation has ever been run in this repo.

func (*LocalManifestStore) PathFor

func (s *LocalManifestStore) PathFor(m LocalManifest) string

PathFor returns the on-disk path of the manifest file for m. The path is computed deterministically from m.StartedAt + m.RunID (the same inputs Write uses to choose its destination), so callers can use this to delete a manifest record without scanning the directory.

func (*LocalManifestStore) Write

Write persists m to the manifests directory using a deterministic filename derived from m.StartedAt and m.RunID. Existing files are overwritten — the timestamp+run-id combination is unique by construction (each run has a fresh run ID and a different start time).

type LoopDeps

type LoopDeps struct {
	// SpawnerFor maps an agent name → Spawner. Returns nil for an unknown
	// agent name, in which case the loop pauses with an error.
	SpawnerFor func(agentName string) spawn.Spawner

	// States persists/loads RunState across turns. In production this is
	// a *StateStore rooted at <git-common-dir>/entire-investigations.
	States *StateStore

	// Progress receives turn lifecycle events. Production wires either a
	// tuiProgressSink (TTY) or textProgressSink (non-TTY). nil is treated
	// as nullProgressSink (no-op).
	Progress ProgressSink

	// Now returns the current time. Defaults to time.Now if nil.
	Now func() time.Time
}

LoopDeps collects the runtime-injectable hooks RunInvestigateLoop needs.

type LoopInput

type LoopInput struct {
	RunID        string    // 12-hex
	Topic        string    // human-readable subject of the investigation
	Agents       []string  // ordered, length >= 1
	MaxTurns     int       // per-agent turn budget; 0 → defaultMaxTurns (2)
	Quorum       int       // approvals needed; 0 → len(Agents)
	AlwaysPrompt string    // optional, appended verbatim to every prompt
	FindingsDoc  string    // absolute path
	StartingSHA  string    // git HEAD when `entire investigate` was invoked
	Resume       *RunState // when non-nil, resume from this state
}

LoopInput carries everything RunInvestigateLoop needs that isn't a hook.

type LoopOutcome

type LoopOutcome string

LoopOutcome describes how the loop ended.

const (
	// OutcomeQuorum means the most recent completed round produced enough
	// approve stances to meet Quorum. The loop ends successfully.
	OutcomeQuorum LoopOutcome = "quorum"
	// OutcomeStalled means the per-agent turn budget was exhausted without
	// reaching quorum. The investigation produced findings but no
	// consensus.
	OutcomeStalled LoopOutcome = "stalled"
	// OutcomePaused means two consecutive agent invocations failed (process
	// error, non-zero exit). The loop stops so the user can investigate;
	// state is preserved for `--continue`.
	OutcomePaused LoopOutcome = "paused"
	// OutcomeCancelled means the context was cancelled (Ctrl+C, parent
	// command shutdown). State is preserved for resume.
	OutcomeCancelled LoopOutcome = "cancelled"
)

type LoopResult

type LoopResult struct {
	Outcome LoopOutcome
	State   *RunState
	// Err holds the most recent per-turn spawn error, if any. Informational:
	// when Outcome is Quorum/Stalled it is typically nil. When Outcome is
	// Paused this surfaces the underlying agent failure.
	Err error
}

LoopResult is the loop's final report.

func RunInvestigateLoop

func RunInvestigateLoop(ctx context.Context, in LoopInput, deps LoopDeps) (LoopResult, error)

RunInvestigateLoop runs the round-robin investigation loop until it reaches quorum, stalls, gets paused, or the context is cancelled.

On every turn the function persists state via deps.States so a crash mid- turn leaves a recoverable RunState on disk. The returned LoopResult is always populated, even on context cancellation.

The function returns (result, error) where error is non-nil only for programmer errors (invalid input, missing dependencies). Per-turn agent failures are reflected in result.Outcome and result.Err, not the return error.

type PendingTurn

type PendingTurn struct {
	Stance string `json:"stance"`         // "approve" | "request-changes" | "reject"
	Note   string `json:"note,omitempty"` // short explanation; optional
}

PendingTurn is the agent-written stance for the most recent turn. The agent populates this before exiting; the loop reads it, appends to Stances[], and clears the field. The `agent` and `turn` fields are unambiguous from context (the loop knows which turn it just ran), so the agent does not include them.

type PickedInvestigate

type PickedInvestigate struct {
	Names  []string
	Prompt string
}

PickedInvestigate is the result of PickInvestigateAgents: the agents the user selected for this run, and (when no seed/issue input was supplied) the free-form investigation prompt that becomes the topic for this run. Prompt is always empty when askPrompt was false.

func PickInvestigateAgents

func PickInvestigateAgents(ctx context.Context, eligible []AgentChoice, askPrompt bool) (PickedInvestigate, error)

PickInvestigateAgents shows a multi-select form populated from eligible (the agents that are both configured AND have a launchable Spawner), pre-checks all of them, and returns the user's selection.

When askPrompt is true, a second form collects the investigation prompt that will become the topic for this run. When false (e.g. a seed doc or --issue-link was supplied), the prompt form is skipped and Prompt is returned empty.

Requires len(eligible) >= 2.

type ProgressSink

type ProgressSink interface {
	// TurnStarted is called immediately before the agent process starts for
	// the given turn. perAgentTurn is the 1-indexed count of turns this
	// agent has taken (this one included); maxPerAgent is the configured
	// per-agent budget.
	TurnStarted(agent string, turn, perAgentTurn, maxPerAgent int)

	// TurnFinished is called once after the agent process exits AND the
	// timeline doc has been parsed for the freshly-added turn block. stance
	// is one of "approve", "request-changes", "reject", "unknown". duration
	// is the wall-clock duration of the agent process. failed is true when
	// the turn was treated as a failure by the loop (spawn error, missing
	// heading, etc.); err is the underlying error or nil.
	TurnFinished(agent string, turn int, stance string, duration time.Duration, failed bool, err error, preview string)

	// RunFinished is called once when the loop terminates (any outcome).
	// The TUI uses this to flip rows to a terminal status and freeze the
	// dashboard; the text sink may print a final outcome line.
	RunFinished(outcome LoopOutcome)
}

ProgressSink consumes turn lifecycle events from RunInvestigateLoop. The loop invokes the methods from a single goroutine — implementations need not synchronize against themselves.

Implementations MUST NOT block. The loop calls these synchronously around the per-turn agent spawn; a slow sink stalls the entire investigation.

type RunState

type RunState struct {
	RunID           string       `json:"run_id"`
	Topic           string       `json:"topic"`
	Agents          []string     `json:"agents"`
	MaxTurns        int          `json:"max_turns"`
	Quorum          int          `json:"quorum"`
	CompletedRounds int          `json:"completed_rounds"`
	Turn            int          `json:"turn"`           // overall turn index across rounds
	NextAgentIdx    int          `json:"next_agent_idx"` // index into Agents for the NEXT turn
	Stances         []TurnStance `json:"stances,omitempty"`
	FindingsDoc     string       `json:"findings_doc"` // absolute path
	StartingSHA     string       `json:"starting_sha"`
	StartedAt       time.Time    `json:"started_at"`
	UpdatedAt       time.Time    `json:"updated_at"`

	// PendingTurn is the agent-writable section. After each agent turn the
	// agent sets this to its stance + a short note. The loop reads it
	// after the agent process exits, validates it, appends a TurnStance to
	// Stances[], clears PendingTurn, advances cursors, persists.
	PendingTurn *PendingTurn `json:"pending_turn,omitempty"`
}

RunState is the persisted state of an investigation run, sufficient to resume after a crash, Ctrl+C, or `--continue`.

Round semantics: CompletedRounds counts how many full passes through every agent have finished — it is 0 mid-round-1, increments to 1 once every agent has had its first turn, and so on. By contrast, TurnStance.Round records the 1-indexed round each individual turn belongs to. The two fields look similar but represent different things; readers must pick the one that matches the question they're asking.

type ShowDeps

type ShowDeps struct {
	ManifestStore *LocalManifestStore
}

ShowDeps collects what RunShow needs that's test-injectable.

type ShowInput

type ShowInput struct {
	// RunID is the run id (or run-id prefix) to display. Empty means
	// "show the only manifest, or list options if more than one exists".
	RunID string
	// Out is the destination writer for the rendered summary + findings.
	Out io.Writer
	// ErrOut is the destination writer for user-facing error/help messages.
	ErrOut io.Writer
}

ShowInput drives RunShow.

type StateStore

type StateStore struct {
	// contains filtered or unexported fields
}

StateStore is the runs-state directory wrapper. The root contains one sub-directory per run (named after the run ID), holding findings.md and state.json.

func NewStateStore

func NewStateStore(ctx context.Context) (*StateStore, error)

NewStateStore creates a StateStore rooted at <git-common-dir>/entire-investigations. Resolves the common dir via session.GetGitCommonDir, so this requires a git repository context.

func NewStateStoreWithDir

func NewStateStoreWithDir(dir string) *StateStore

NewStateStoreWithDir creates a StateStore rooted at dir. Useful for tests that don't want to depend on a real git repository.

func (*StateStore) Clear

func (s *StateStore) Clear(ctx context.Context, runID string) error

Clear removes the persisted state for runID. Missing files are treated as a successful clear (no-op).

func (*StateStore) List

func (s *StateStore) List(ctx context.Context) ([]*RunState, error)

List returns all persisted run states. Returns nil (and no error) when the state directory does not exist.

func (*StateStore) Load

func (s *StateStore) Load(ctx context.Context, runID string) (*RunState, error)

Load reads the run state for runID. Returns (nil, nil) when the file does not exist (treat as "no such run").

func (*StateStore) RunDir

func (s *StateStore) RunDir(runID string) string

RunDir returns the absolute path of the per-run directory for runID, where findings.md and state.json both live. The directory may or may not exist on disk; callers that need it materialised should MkdirAll before writing.

Precondition: runID MUST be a validated 12-hex id. RunDir joins it into a path that callers feed to os.RemoveAll (via clean), so an unvalidated id would be a path-traversal sink. Every path that reaches here enforces this: Save/Load validate before calling; manifest List/ResolveByRunID drop manifests whose RunID fails validateRunID before any RunID reaches clean.

func (*StateStore) Save

func (s *StateStore) Save(ctx context.Context, st *RunState) error

Save writes the run state atomically (temp file + rename).

type TurnStance

type TurnStance struct {
	Round       int    `json:"round"`
	Turn        int    `json:"turn"` // overall turn number
	Agent       string `json:"agent"`
	Stance      string `json:"stance"` // "approve" | "request-changes" | "reject" | "unknown"
	PlanChanged bool   `json:"plan_changed"`
	Note        string `json:"note,omitempty"`
}

TurnStance is one agent's recorded stance for a turn.

Round here is the 1-indexed round the turn belongs to (turn 1 of round 1, turn N+1 starts round 2, etc.) — distinct from RunState.CompletedRounds, which counts finished rounds.

Jump to

Keyboard shortcuts

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