Files
orchestra/internal/workphase/workphase.go
kami 822f086451 Make research findings citable
The brief at agentctx.go:167 advertised findings[].id and findings[].confidence
to every research session. The struct carried neither, so encoding/json dropped
both on every seal, silently, for as long as the schema has existed. A plan
phase had nothing stable to cite and no way to tell an observation from an
assumption.

Finding gains ID and Confidence. Ids are unique within an artifact and shaped
so "research:<id>" is unambiguous in plan prose. Confidence is fact, inference,
or assumption, matching the labels the output style already uses.

DecodeStoredResearch reads what is already in the CAS and backfills both.
Refusing an artifact sealed before this change would block every task whose
research predates it, including at rotation, where the agent that could fix it
is already gone. A backfilled finding is labelled inference rather than fact:
the old schema required evidence and made no verification claim, so upgrading
it on the way in would be the same class of lie this commit removes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 11:21:29 +04:00

268 lines
8.4 KiB
Go

// Package workphase holds the sealed output of a cognitive phase.
//
// A phase artifact is what survives a phase boundary. The conversation that
// produced it does not: the next phase starts from the sealed artifact, which
// is the whole point of separating research from planning from implementation.
//
// Implementation state is deliberately absent here. It already has a format,
// continuity.Handoff, and a third one would be a third thing to keep in sync.
package workphase
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
)
// Confidence separates what research observed from what it concluded. A plan
// that cites an assumption as if it were a fact is the failure this exists to
// make visible.
type Confidence string
const (
// Fact is directly observed in the repository or from a live probe.
Fact Confidence = "fact"
// Inference is supported by evidence but was not verified directly.
Inference Confidence = "inference"
// Assumption is required because the evidence is missing.
Assumption Confidence = "assumption"
)
func (c Confidence) Valid() bool {
switch c {
case Fact, Inference, Assumption:
return true
}
return false
}
// Finding is one thing research established, with the evidence for it.
//
// ID is what makes a finding citable. A plan phase references "research:r12",
// and the seal resolves that against the accepted research, so a plan cannot
// rest on a finding nobody recorded. The advertised schema at agentctx.go
// promised id and confidence for months while the struct dropped both.
type Finding struct {
ID string `json:"id"`
Claim string `json:"claim"`
Evidence string `json:"evidence"`
Confidence Confidence `json:"confidence"`
}
// findingID is deliberately narrow. An id appears in plan prose as
// "research:<id>", so anything with spaces or punctuation would make the
// citation ambiguous to both a parser and a reader.
var findingID = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// CodePath is a location the next phase will need, and why.
type CodePath struct {
Path string `json:"path"`
Why string `json:"why"`
}
type DeadEnd struct {
Tried string `json:"tried"`
WhyFailed string `json:"why_failed"`
}
// Research is the sealed result of a research phase. It is bounded on
// purpose: an unbounded research artifact is a transcript with extra steps.
type Research struct {
Findings []Finding `json:"findings"`
Code []CodePath `json:"relevant_code,omitempty"`
Invariants []string `json:"invariants,omitempty"`
DeadEnds []DeadEnd `json:"dead_ends,omitempty"`
Unknowns []string `json:"unknowns,omitempty"`
}
// Change is one intended modification. Target names what changes, Intent says
// what it should do afterwards. Neither is a diff: a plan that carries the
// patch is an implementation, and reviewing it costs what reviewing code costs.
type Change struct {
Target string `json:"target"`
Intent string `json:"intent"`
}
// Plan is the sealed result of a planning phase.
type Plan struct {
Changes []Change `json:"changes"`
Verification []string `json:"verification,omitempty"`
Risks []string `json:"risks,omitempty"`
DecisionsNeeded []string `json:"human_decisions_needed,omitempty"`
}
const maxItems = 64
const maxLine = 500
func (r Research) Validate() error {
if len(r.Findings) == 0 {
return fmt.Errorf("research: at least one finding is required")
}
if err := bound("findings", len(r.Findings)); err != nil {
return err
}
seen := map[string]bool{}
for i, f := range r.Findings {
if !findingID.MatchString(f.ID) {
return fmt.Errorf("research: findings[%d].id %q must be lowercase letters, digits, dash or underscore, at most 64 characters", i, f.ID)
}
if seen[f.ID] {
return fmt.Errorf("research: duplicate finding id %q", f.ID)
}
seen[f.ID] = true
if !f.Confidence.Valid() {
return fmt.Errorf("research: findings[%d].confidence %q is not fact, inference, or assumption", i, f.Confidence)
}
if err := line(fmt.Sprintf("findings[%d].claim", i), f.Claim, true); err != nil {
return err
}
if err := line(fmt.Sprintf("findings[%d].evidence", i), f.Evidence, true); err != nil {
return err
}
}
for i, c := range r.Code {
if err := line(fmt.Sprintf("relevant_code[%d].path", i), c.Path, true); err != nil {
return err
}
if strings.HasPrefix(c.Path, "/") {
return fmt.Errorf("research: relevant_code[%d].path must be repository-relative", i)
}
if err := line(fmt.Sprintf("relevant_code[%d].why", i), c.Why, false); err != nil {
return err
}
}
for i, d := range r.DeadEnds {
if err := line(fmt.Sprintf("dead_ends[%d].tried", i), d.Tried, true); err != nil {
return err
}
if err := line(fmt.Sprintf("dead_ends[%d].why_failed", i), d.WhyFailed, true); err != nil {
return err
}
}
if err := bound("relevant_code", len(r.Code)); err != nil {
return err
}
if err := bound("dead_ends", len(r.DeadEnds)); err != nil {
return err
}
return lists(map[string][]string{"invariants": r.Invariants, "unknowns": r.Unknowns})
}
func (p Plan) Validate() error {
if len(p.Changes) == 0 {
return fmt.Errorf("plan: at least one change is required")
}
if err := bound("changes", len(p.Changes)); err != nil {
return err
}
for i, c := range p.Changes {
if err := line(fmt.Sprintf("changes[%d].target", i), c.Target, true); err != nil {
return err
}
if err := line(fmt.Sprintf("changes[%d].intent", i), c.Intent, true); err != nil {
return err
}
}
return lists(map[string][]string{"verification": p.Verification, "risks": p.Risks, "human_decisions_needed": p.DecisionsNeeded})
}
func Encode(v interface{ Validate() error }) ([]byte, error) {
if err := v.Validate(); err != nil {
return nil, err
}
return json.Marshal(v)
}
// DecodeResearch validates a newly sealed artifact. It is strict: a finding
// without an id or a confidence is refused, and the refusal reaches the agent
// that wrote it.
func DecodeResearch(b []byte) (Research, error) {
var r Research
if err := json.Unmarshal(b, &r); err != nil {
return Research{}, fmt.Errorf("research artifact: %w", err)
}
return r, r.Validate()
}
// DecodeStoredResearch reads an artifact already in the CAS. Artifacts sealed
// before ids existed carry neither field, and refusing them would block every
// task whose research predates this change, including at rotation, where the
// agent that could fix it is already gone.
//
// A backfilled finding is labelled inference: the old schema required
// evidence and made no verification claim, so inference is what it actually
// meant. Calling it fact would upgrade an unverified claim on the way in.
func DecodeStoredResearch(b []byte) (Research, error) {
var r Research
if err := json.Unmarshal(b, &r); err != nil {
return Research{}, fmt.Errorf("research artifact: %w", err)
}
for i := range r.Findings {
if r.Findings[i].ID == "" {
r.Findings[i].ID = fmt.Sprintf("legacy-%d", i+1)
}
if r.Findings[i].Confidence == "" {
r.Findings[i].Confidence = Inference
}
}
return r, r.Validate()
}
func DecodePlan(b []byte) (Plan, error) {
var p Plan
if err := json.Unmarshal(b, &p); err != nil {
return Plan{}, fmt.Errorf("plan artifact: %w", err)
}
return p, p.Validate()
}
func bound(field string, n int) error {
if n > maxItems {
return fmt.Errorf("%s: %d entries exceeds the %d item bound", field, n, maxItems)
}
return nil
}
// line rejects a value that is empty when required, over-long, or
// multi-line. A phase artifact is a set of short claims, not prose: the bound
// is what keeps a sealed artifact cheaper to read than the session that
// produced it.
func line(field, v string, required bool) error {
s := strings.TrimSpace(v)
if s == "" {
if required {
return fmt.Errorf("%s is required", field)
}
return nil
}
if len(s) > maxLine {
return fmt.Errorf("%s: %d characters exceeds the %d character bound", field, len(s), maxLine)
}
if strings.ContainsAny(s, "\n\r") {
return fmt.Errorf("%s must be a single line", field)
}
return nil
}
func lists(fields map[string][]string) error {
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
// Deterministic error for the same input.
sort.Strings(names)
for _, name := range names {
if err := bound(name, len(fields[name])); err != nil {
return err
}
for i, v := range fields[name] {
if err := line(fmt.Sprintf("%s[%d]", name, i), v, true); err != nil {
return err
}
}
}
return nil
}