// 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:", 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 }