forgechat

package
v0.21.0 Latest Latest
Warning

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

Go to latest
Published: Jun 15, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package forgechat backs the per-turn AI loop for the Hearth 2.0 "Beads-Forge" page.

Each Forge session moves through three stages — drafting, grilling, ready — and at each stage a "turn" is one round-trip with claude. The package exposes a Runner abstraction so the daemon can plug in a real smith.SpawnWithProvider implementation while tests can swap in a fake without standing up the claude CLI.

Output contract:

  • drafting: claude returns plain markdown text ("kind:text").
  • drafting + plan request: claude returns a markdown plan ("kind:plan"); callers persist the body in session.plan as well.
  • grilling: claude returns a JSON envelope of structured questions or a done signal ("kind:question" / status update).

The grilling-stage system prompt is the user's grill-me skill, baked in verbatim so behaviour matches the rest of the user's tooling.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildPrompt

func BuildPrompt(req TurnRequest) string

BuildPrompt is the public-but-low-level entry point that turns a TurnRequest into the prompt string sent to claude. Real Runners use it directly; tests sometimes inspect the prompt to assert behaviour.

func DefaultBdRunner

func DefaultBdRunner(ctx context.Context, dir string, args ...string) ([]byte, error)

DefaultBdRunner is the production BdRunner. Inherits the parent process environment so `bd` finds the same Dolt config as the daemon.

func ParseGrillingResponse

func ParseGrillingResponse(output string) (*grillingVerdict, error)

ParseGrillingResponse extracts the JSON verdict claude was asked to emit during the grilling stage. Returns the structured envelope on success.

func ValidateEmission

func ValidateEmission(env *EmissionEnvelope, knownAnvils map[string]bool) []string

ValidateEmission checks the envelope is internally consistent and can be materialised. Returns a list of human-readable problems; an empty list means the envelope passed every check. knownAnvils maps anvil name -> true for every anvil registered with the daemon; pass nil to skip the anvil existence check (useful in tests where the resolver is not available).

Checks:

  • at least one bead present
  • every bead has non-empty title and proposal id
  • proposal ids are unique within the envelope
  • type is one of the known bd types
  • priority is in [0, 4]
  • each anvil exists in knownAnvils (when supplied)
  • depends_on entries reference real sibling proposal ids
  • depends_on never crosses anvils (bd cannot link cross-anvil)
  • the dep graph is a DAG (no cycles)

Types

type AnswerPayload

type AnswerPayload struct {
	QuestionID int64  `json:"question_id"`
	OptionID   string `json:"option_id,omitempty"`
}

AnswerPayload is the JSON shape stored in metadata for kind=answer. The QuestionID points at the message ID of the question this answer addresses; OptionID is empty when the user wrote a free-form response instead of picking one of the options.

type AnvilContext

type AnvilContext map[string]string

AnvilContext is rendered into the emission prompt so claude knows which anvil names are valid. Keys are anvil names; values are short hints (typically the path or a short description).

type AnvilLookup

type AnvilLookup func(name string) (path string, ok bool)

AnvilLookup resolves an anvil name to its on-disk path. The materializer uses it to set cmd.Dir so bd connects to the right Dolt database. Returns ok=false when the name is not registered with the daemon — callers should treat that as a validation failure (we already check before materialising, but a stale resolver could miss one).

type AnvilTarget

type AnvilTarget struct {
	Name string
	Path string
}

AnvilTarget is the resolved name + on-disk path of the anvil that owns a drafting / grilling session. The path is the absolute filesystem path the daemon would use when launching tools against that anvil — we render it into the prompt so the agent never has to ask the user where the code lives.

type BdRunner

type BdRunner func(ctx context.Context, dir string, args ...string) ([]byte, error)

BdRunner runs a `bd <args...>` command in dir and returns its stdout (or an error containing stderr). Tests inject a fake to avoid spawning real subprocesses; production wires DefaultBdRunner.

type BeadProposal

type BeadProposal struct {
	ProposalID  string   `json:"id"`
	Anvil       string   `json:"anvil"`
	Title       string   `json:"title"`
	Description string   `json:"description"`
	Type        string   `json:"type"`
	Priority    int      `json:"priority"`
	Labels      []string `json:"labels,omitempty"`
	// DependsOn is a list of sibling ProposalIDs that must complete before
	// this bead. Cross-anvil deps are rejected up front because bd's
	// dep-store is anvil-local — a "depends on" link from anvil X to anvil Y
	// would never resolve, so we refuse rather than silently break.
	DependsOn []string `json:"depends_on,omitempty"`
}

BeadProposal is one bead claude wants to create. The ProposalID is a stable identifier within a single emission so DependsOn can reference siblings before the real bead IDs exist (we only learn those after bd create).

type ClaudeRunner

type ClaudeRunner struct {
	// Provider is the AI backend used for every turn. Callers typically
	// resolve this from settings.stage_providers["forgechat"] with a fallback
	// to the global provider chain.
	Provider provider.Provider
	// MaxTurns caps the AI session length. Defaults to 10. The grilling
	// prompt asks for a single JSON envelope, so a low budget is fine.
	MaxTurns int
	// Timeout caps the wall-clock duration of a single turn. Defaults to
	// defaultTurnTimeout (5m). Override at construction from
	// settings.forgechat.turn_timeout.
	Timeout time.Duration
	// ExtraFlags are passed through to claude (e.g. --model).
	ExtraFlags []string
}

ClaudeRunner is the production Runner implementation. It spawns a short-lived claude (or fallback) session in a temp directory for each turn and parses the response into messages and stage transitions. The session does not run in any anvil worktree — the AI must not touch the filesystem during a Forge design conversation.

func NewClaudeRunner

func NewClaudeRunner(pv provider.Provider, extraFlags []string) *ClaudeRunner

NewClaudeRunner constructs a ClaudeRunner with sensible defaults.

func (*ClaudeRunner) Turn

func (r *ClaudeRunner) Turn(ctx context.Context, req TurnRequest) (*TurnResponse, error)

Turn implements Runner.

type EmissionEnvelope

type EmissionEnvelope struct {
	Beads   []BeadProposal `json:"beads"`
	Summary string         `json:"summary,omitempty"`
}

EmissionEnvelope is the JSON shape claude returns for ModeEmit. Summary is optional and shown to the user verbatim above the proposal list.

func ParseEmissionResponse

func ParseEmissionResponse(output string) (*EmissionEnvelope, error)

ParseEmissionResponse extracts the JSON envelope from claude output. It tolerates fenced ```json blocks, plain ``` blocks, and bare JSON.

type EmittedMessage

type EmittedMessage struct {
	Kind     string
	Content  string
	Metadata string
}

EmittedMessage is one assistant message produced by a turn. The kind determines how the caller renders it in the chat view.

func VerdictToMessages

func VerdictToMessages(v *grillingVerdict) ([]EmittedMessage, bool)

VerdictToMessages converts a parsed grilling verdict into the assistant messages the caller should persist. When the verdict signals done, the returned slice contains a single status message and NewStage is set to ready by the caller. Otherwise each question becomes a kind=question message with the options in metadata.

Mixed verdicts (done=true AND questions present) are contradictory — the contract is "either ask more or call it done." We resolve the ambiguity in favour of the questions: an unanswered question is more important to surface than a possibly-premature stage transition, and a follow-up turn can still emit done with no questions if the AI is genuinely exhausted. We also emit a status note so the user sees the AI signalled completion at the same time.

type HistoryMessage

type HistoryMessage struct {
	Role     string
	Kind     string
	Content  string
	Metadata string
}

HistoryMessage is one prior turn fed into the next prompt. Kind matches the message kind constants in internal/state — kept as plain strings so this package does not need to import state.

type MaterializeResult

type MaterializeResult struct {
	Created       []MaterializedBead
	RolledBack    bool
	RollbackError error
	Err           error
}

MaterializeResult is the outcome of MaterializeEmission. Created lists every bead that was successfully created (in creation order). When RolledBack is true, the listed beads were closed via `bd close --reason` after the failure that aborted the run. Err is the failure that triggered the rollback, or nil when every bead was created successfully.

func MaterializeEmission

func MaterializeEmission(
	ctx context.Context,
	logger *slog.Logger,
	env *EmissionEnvelope,
	lookup AnvilLookup,
	runner BdRunner,
) MaterializeResult

MaterializeEmission creates each proposed bead via `bd create`. Beads are processed in topological order so a bead is never created before its dependencies — that lets us pass already-resolved bd IDs into `--deps` rather than going back with `bd dep add` afterwards (fewer subprocess calls, simpler rollback).

Atomicity: if any step fails, every bead created so far is closed via `bd close --reason="rollback: ..."`. The function returns the partial MaterializeResult with Err set to the original failure and RolledBack true. The original Err is preferred over the rollback error — operators usually care more about why creation failed than about a stuck rollback.

Cycles must be caught upstream by ValidateEmission. If a cycle slips through, the topo sort returns an error and no bd subprocess runs.

type MaterializedBead

type MaterializedBead struct {
	ProposalID string
	BeadID     string
	Anvil      string
	AnvilPath  string
	Title      string
}

MaterializedBead is the result of a successful bd create for one proposal. ProposalID matches the input BeadProposal.ProposalID; BeadID is the real bd-assigned ID returned by `bd create --json`. AnvilPath is captured on the way in so rollback can run `bd close` against the same database without re-resolving the lookup (which may be stale after a crash).

type Mode

type Mode string

Mode is the per-turn intent. Each mode produces a slightly different prompt even within the same stage.

const (
	// ModeChat is the default drafting-stage turn: respond conversationally.
	ModeChat Mode = "chat"
	// ModePlan is a drafting-stage turn that asks claude to emit a single
	// markdown plan as its full response.
	ModePlan Mode = "plan"
	// ModeGrill is a grilling-stage turn that asks claude to emit the next
	// batch of structured questions (or signal done).
	ModeGrill Mode = "grill"
)
const ModeEmit Mode = "emit"

ModeEmit is the per-turn intent that asks claude to translate the settled design into a structured list of beads to create. It runs in the "ready" stage (post-grilling) and is the only mode that produces side-effects in bd databases.

type QuestionOption

type QuestionOption struct {
	ID          string `json:"id"`
	Label       string `json:"label"`
	Description string `json:"description,omitempty"`
}

QuestionOption is one selectable option attached to a grilling question.

type QuestionPayload

type QuestionPayload struct {
	Options        []QuestionOption `json:"options"`
	Recommendation string           `json:"recommendation,omitempty"`
	Rationale      string           `json:"rationale,omitempty"`
}

QuestionPayload is the JSON shape stored in metadata for kind=question.

type Runner

type Runner interface {
	Turn(ctx context.Context, req TurnRequest) (*TurnResponse, error)
}

Runner abstracts the AI session driver. The daemon supplies a real implementation that spawns the claude CLI; tests use a mock.

type Stage

type Stage string

Stage identifies the current Beads-Forge stage. Mirrors the constants in internal/state to avoid an import cycle when the state layer references these names.

const (
	StageDrafting Stage = "drafting"
	StageGrilling Stage = "grilling"
	StageReady    Stage = "ready"
)

type TurnRequest

type TurnRequest struct {
	Stage    Stage
	Mode     Mode
	Title    string
	Plan     string
	History  []HistoryMessage
	UserText string
	// Anvils is the set of registered anvils the AI may target when emitting
	// beads (ModeEmit). Keys are anvil names; values are short hints. Unused
	// for chat / plan / grill modes.
	Anvils AnvilContext
	// Anvil is the session-scoped target for drafting / plan / grilling turns
	// — the bead being designed already has an anvil association, so the AI
	// is told the resolved name + absolute path up-front and uses it to read
	// the codebase via Read / Grep / Glob. Nil leaves the anvil context out
	// of the prompt (the AI then has to ask, which is what we're fixing).
	Anvil *AnvilTarget
	// SessionID identifies the originating Beads-Forge session and is logged
	// when a turn fails (e.g. on timeout). Zero is acceptable for callers
	// that don't have a persistent session (tests, one-off runs).
	SessionID int64
}

TurnRequest is the input to Runner.Turn. The Runner uses it to assemble a stage-appropriate prompt that includes the session title, current plan, and the prior conversation.

type TurnResponse

type TurnResponse struct {
	Messages []EmittedMessage
	NewStage Stage
	NewPlan  string
	// CostUSD is the cumulative cost of this turn (best-effort — real
	// runners read it from the claude stream, mocks may set 0).
	CostUSD float64
	// Emission is set when Mode == ModeEmit and parsing succeeded. Callers
	// inspect this to materialise beads via bd; the returned Messages slice
	// is empty in that case (the handler decides what to persist after
	// materialisation).
	Emission *EmissionEnvelope
}

TurnResponse is the output of Runner.Turn. NewStage is non-empty when the turn requested a stage transition (e.g. grilling reports "done"). NewPlan is non-empty when the turn produced a fresh plan that should replace session.plan.

Jump to

Keyboard shortcuts

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