Files
orchestra/internal/operations/trajectory.go
T
kami 57c028f94f Seal the plan as a specification instead of four bullet lists
The plan artifact was Changes{Target,Intent} plus three string lists, every
entry capped at 500 single-line characters. That bound makes a specification
impossible: a phase cannot carry a code block, a paragraph of reasoning, or a
verification command with its own argument list. renderSealed then flattened
what little survived through collapse(), so an implement session received a
summary of a summary.

plan.md replaces it. Markdown, 128 KiB, no per-line cap, sealed through the
existing path under the existing PlanRef. The parser enforces the structure the
brief states: required sections, phases numbered from 1 with no gaps, Files,
Changes and Verification per phase, and at least one automated or manual check,
because a phase nobody can verify can never be established as done. Automated
entries are JSON argv arrays, so a pipe is a literal argument rather than an
operator. Headings inside fenced blocks are content, so a plan may show
markdown without parsing its own example.

Citations resolve at seal time against the accepted research, on the
coordinator, which is the only party holding ResearchRef. A plan resting on a
finding nobody recorded fails on the planner while its session is still alive
to be told.

The plan now renders byte for byte into the implement launch, and a rotated
successor receives the same complete document. That is the property the whole
change exists for. collapse() stays for research findings, which really are
short claims.

DecodeStoredPlan reads pre-markdown refs and renders them into the same type,
labelled, so nothing downstream branches on which era a plan came from. A
legacy plan carries no phases, which is honest: the old artifact never named an
executable unit.

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

206 lines
7.0 KiB
Go

package operations
import (
"encoding/json"
"errors"
"fmt"
"strings"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
// ErrTrajectoryGate reports that a phase change stopped for human
// confirmation. It is not a fault: the work so far is sealed and valid, and
// the human now decides whether the direction is right.
var ErrTrajectoryGate = errors.New("trajectory gate: waiting for the human to confirm the direction")
// maxPacketBytes bounds the gate packet. It travels in the TaskBlocked
// blocker field, which is what notification surfaces already deliver, so the
// human reads the packet where they already read blockers.
const maxPacketBytes = 4000
// trajectoryGateOpen reports whether the human has answered the most recent
// gate for this task.
//
// The rule is positional rather than a flag: a decision recorded after the
// gate was raised is the answer to it. That needs no new task field and
// cannot drift out of sync with the log, and it accepts any wording, which
// matters because an imported comment carries no gate-specific subject.
func trajectoryGateOpen(s *store.Store, taskID string) bool {
return blockerAnswered(s, taskID, domain.BlockReasonTrajectoryGate)
}
// blockerAnswered reports whether the human has replied since the most recent
// block of this reason.
//
// The rule is positional on purpose. Deciding whether a reply semantically
// answers the question would mean parsing intent, and a wrong parse either
// strands a task the human already answered or resumes one they did not. The
// agent receives both the question and the reply and can see for itself.
func blockerAnswered(s *store.Store, taskID string, reason domain.BlockReason) bool {
var blockSeq, decisionSeq uint64
for _, e := range s.Events(0) {
if e.TaskID != taskID {
continue
}
switch e.Type {
case "TaskBlocked":
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
blockSeq = e.Seq
}
case domain.EventHumanDecisionRecorded:
decisionSeq = e.Seq
}
}
return blockSeq > 0 && decisionSeq > blockSeq
}
// raiseTrajectoryGate blocks the task and hands the human the packet.
func raiseTrajectoryGate(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) error {
packet, err := TrajectoryGatePacket(s, t, from, to, proposal)
if err != nil {
return err
}
b, err := json.Marshal(map[string]any{
"blocker": packet,
"block_reason": string(domain.BlockReasonTrajectoryGate),
"lifecycle_phase": "awaiting_human",
})
if err != nil {
return err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
return err
}
return fmt.Errorf("%w (task %s, %s to %s)", ErrTrajectoryGate, t.ID, from, to)
}
// TrajectoryGatePacket renders the human's decision packet from state that
// already exists. It is human-facing, unlike agentctx, and deliberately
// carries no transcript: the human is confirming a direction, not auditing a
// session.
// proposal, when set, is the artifact the finishing phase produced but has
// not sealed yet, which is exactly what the human is being asked about.
func TrajectoryGatePacket(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) (string, error) {
intent, err := s.EffectiveIntent(t.ID)
if err != nil {
return "", err
}
var b strings.Builder
fmt.Fprintf(&b, "Trajectory gate: %s to %s needs your confirmation.\n", from, to)
fmt.Fprintf(&b, "\nGoal: %s\n", oneLine(firstNonEmpty(t.Title, t.Description, "not stated")))
if len(t.Acceptance) > 0 {
b.WriteString("\nAcceptance:\n")
for _, a := range t.Acceptance {
fmt.Fprintf(&b, "- %s\n", oneLine(a))
}
}
if t.ResearchRef != "" {
if raw, err := s.Artifact(t.ResearchRef); err == nil {
if r, err := workphase.DecodeStoredResearch(raw); err == nil {
b.WriteString("\nWhat research established:\n")
for _, f := range r.Findings {
fmt.Fprintf(&b, "- %s (%s)\n", oneLine(f.Claim), oneLine(f.Evidence))
}
for _, u := range r.Unknowns {
fmt.Fprintf(&b, "- still unknown: %s\n", oneLine(u))
}
}
}
}
planned := proposal
if len(planned) == 0 && t.PlanRef != "" {
if raw, err := s.Artifact(t.PlanRef); err == nil {
planned = raw
}
}
if len(planned) > 0 {
// The plan reaches the human as the document that was sealed. A
// trajectory gate asks whether this direction is right, and a
// flattened summary is not the thing being approved. maxPacketBytes
// below bounds what a notification surface actually carries.
if p, err := workphase.DecodeStoredPlan(planned); err == nil {
b.WriteString("\nProposed plan:\n\n")
b.WriteString(p.Markdown)
if !strings.HasSuffix(p.Markdown, "\n") {
b.WriteString("\n")
}
}
}
if len(intent.Decisions) > 0 {
b.WriteString("\nYour decisions so far:\n")
for _, d := range intent.Decisions {
fmt.Fprintf(&b, "- %s (%s): %s\n", d.Kind, d.Subject, oneLine(d.Value))
}
}
b.WriteString("\nReply to confirm or correct the direction. Your reply becomes a recorded decision and outranks the plan above.\n")
out := b.String()
if len(out) > maxPacketBytes {
out = out[:maxPacketBytes] + "\n(truncated)\n"
}
return out, nil
}
func oneLine(s string) string {
return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// clearTrajectoryGate returns a gated task to the queue once the human has
// answered. The compensating TaskCorrected names the block it reverses, which
// is the §3.1 rule: a wrong or superseded event is never edited.
func clearTrajectoryGate(s *store.Store, t domain.Task) (domain.Task, error) {
return clearBlocker(s, t, domain.BlockReasonTrajectoryGate, "gate_cleared")
}
// clearBlocker returns an answered task to the queue. The compensating
// TaskCorrected names the block it reverses, per §3.1: a superseded event is
// never edited.
func clearBlocker(s *store.Store, t domain.Task, reason domain.BlockReason, phase string) (domain.Task, error) {
var gate domain.Event
for _, e := range s.Events(0) {
if e.TaskID != t.ID || e.Type != "TaskBlocked" {
continue
}
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
gate = e
}
}
if gate.ID == "" {
return t, fmt.Errorf("%w: no %s blocker to clear on task %s", domain.ErrInvalid, reason, t.ID)
}
b, err := json.Marshal(map[string]any{
"corrects": gate.ID, "state": string(domain.StateQueued),
"lifecycle_phase": phase,
})
if err != nil {
return t, err
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCorrected", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}); err != nil {
return t, err
}
updated, ok := s.Task(t.ID)
if !ok {
return t, domain.ErrNotFound
}
return updated, nil
}