Files
orchestra/internal/workphase/workphase.go
T
kami 7f12c7fc37 v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two
units and the burn-in instrument. This commit is the burn-in build identity:
coordinator and worker must both report this revision before a task is created.

Workflow (earlier sessions, uncommitted until now): human decision events and
reduction, source cursors and reconcile-before-launch, turn-boundary
reconciliation, internal/agentctx as the single renderer, ace-fca phases with
sealed artifacts, the trajectory gate, bounded grilling, independent review,
task pr enforcement, and human review reflection.

Capability restrictions at the agent boundary: an authz.Agent surface at
GatedWrite may ask and may not act. It also fixes two bugs the unit exposed --
gated surfaces could not reach the two endpoints written for them, and
RequestHumanDecision would block an unowned task while rejecting a question
from the session that did own it.

Turn-boundary reconcile-failure escalation: a streak of consecutive failures
asks the session to hand off, fenced on the lease epoch, with reconcile_failure
as a real handoff reason. The worker was dropping the coordinator's verdict on
the floor; it now acts on it.

Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to
<worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md
is the runbook. deploy/build.sh stamps both binaries from one commit.

go build, go vet and go test ./... pass, 20 packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:31:20 +04:00

195 lines
5.6 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"
"sort"
"strings"
)
// Finding is one thing research established, with the evidence for it.
type Finding struct {
Claim string `json:"claim"`
Evidence string `json:"evidence"`
}
// 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
}
for i, f := range r.Findings {
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)
}
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()
}
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
}