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 ¶
- func BuildPrompt(req TurnRequest) string
- func DefaultBdRunner(ctx context.Context, dir string, args ...string) ([]byte, error)
- func ParseGrillingResponse(output string) (*grillingVerdict, error)
- func ValidateEmission(env *EmissionEnvelope, knownAnvils map[string]bool) []string
- type AnswerPayload
- type AnvilContext
- type AnvilLookup
- type AnvilTarget
- type BdRunner
- type BeadProposal
- type ClaudeRunner
- type EmissionEnvelope
- type EmittedMessage
- type HistoryMessage
- type MaterializeResult
- type MaterializedBead
- type Mode
- type QuestionOption
- type QuestionPayload
- type Runner
- type Stage
- type TurnRequest
- type TurnResponse
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 ¶
DefaultBdRunner is the production BdRunner. Inherits the parent process environment so `bd` finds the same Dolt config as the daemon.
func ParseGrillingResponse ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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.