package workphase import ( "encoding/json" "fmt" "regexp" "strconv" "strings" ) // PlanDoc is a sealed markdown specification. The document is retained // verbatim, because that is what an implement session receives: a plan // flattened to bullet lines is a summary, and a summary cannot be executed. // // The parsed fields exist so Orchestra can act on the plan without rereading // prose. Nothing outside this file interprets the markdown. type PlanDoc struct { // Markdown is the exact bytes that were sealed. Markdown string `json:"markdown"` // Title is the level-one heading. Title string `json:"title"` // Phases are ordered and contiguous from 1. Phases []PlanPhase `json:"phases"` // References are the research ids the plan cites, deduplicated. References []string `json:"references,omitempty"` } // PlanPhase is one executable unit of the plan. Files and Changes are prose // for the implementer; the verification entries are what Orchestra runs and // what the human signs off. type PlanPhase struct { // ID is the stable name used in a progress request, "phase-1". ID string `json:"id"` Number int `json:"number"` Name string `json:"name"` // Body is the phase's markdown, retained so a mismatch report can quote it. Body string `json:"body"` // Automated are argv arrays. Not shell strings: they execute through // exec.Command with no shell, so a pipe or a redirection is a literal // argument rather than an operator. Automated [][]string `json:"automated,omitempty"` // Manual are human-testable steps. A phase whose automated checks all pass // while manual steps remain is awaiting a human, not verified. Manual []string `json:"manual,omitempty"` } const ( // MaxPlanBytes bounds the whole document. A specification needs room that // the 500-character single-line rule never allowed, but an unbounded // artifact is a transcript that every later session pays to read. MaxPlanBytes = 128 << 10 maxPhases = 32 maxCommands = 16 maxArgv = 32 ) // requiredSections are the level-two headings every plan carries. They are the // questions a plan has to answer before anyone implements against it: what is // true now, what should be true after, and what is deliberately not being done. var requiredSections = []string{ "Overview", "Current state", "Desired end state", "Non-goals", "Approach", "Testing strategy", "Risks and edge cases", "Migration", "References", } var ( phaseHeading = regexp.MustCompile(`^##\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$`) runLine = regexp.MustCompile(`^-\s+run:\s*(.+?)\s*$`) bulletLine = regexp.MustCompile(`^[-*]\s+(.+?)\s*$`) researchCite = regexp.MustCompile(`research:([a-z0-9][a-z0-9_-]{0,63})`) ) // ParsePlan reads a markdown plan and reports the first structural problem it // finds. Errors name the section or phase, because "invalid plan" sends a // planner rereading its own document with nothing to go on. func ParsePlan(b []byte) (PlanDoc, error) { if len(b) == 0 { return PlanDoc{}, fmt.Errorf("plan: the document is empty") } if len(b) > MaxPlanBytes { return PlanDoc{}, fmt.Errorf("plan: %d bytes exceeds the %d byte bound", len(b), MaxPlanBytes) } doc := PlanDoc{Markdown: string(b)} lines := strings.Split(strings.ReplaceAll(doc.Markdown, "\r\n", "\n"), "\n") sections := map[string]bool{} // current tracks which phase's body the parser is inside, and which // verification subsection, so a "- run:" line is attributed to the phase // that actually contains it. var current *PlanPhase subsection := "" inFence := false flush := func() { if current != nil { current.Body = strings.TrimSpace(current.Body) doc.Phases = append(doc.Phases, *current) current = nil } } for n, raw := range lines { line := strings.TrimRight(raw, " \t") // A heading inside a fenced block is content, not structure. Without // this a plan that shows example markdown parses its own example. if strings.HasPrefix(strings.TrimSpace(line), "```") { inFence = !inFence } if !inFence { switch { case strings.HasPrefix(line, "# "): if doc.Title == "" { doc.Title = strings.TrimSpace(strings.TrimPrefix(line, "# ")) } continue case phaseHeading.MatchString(line): flush() m := phaseHeading.FindStringSubmatch(line) number, _ := strconv.Atoi(m[1]) current = &PlanPhase{ID: "phase-" + m[1], Number: number, Name: m[2]} subsection = "" continue case strings.HasPrefix(line, "## "): flush() sections[strings.TrimSpace(strings.TrimPrefix(line, "## "))] = true subsection = strings.TrimSpace(strings.TrimPrefix(line, "## ")) continue case strings.HasPrefix(line, "#### "): subsection = strings.TrimSpace(strings.TrimPrefix(line, "#### ")) case strings.HasPrefix(line, "### "): subsection = strings.TrimSpace(strings.TrimPrefix(line, "### ")) } } if current == nil { continue } current.Body += line + "\n" if inFence { continue } switch subsection { case "Automated": m := runLine.FindStringSubmatch(line) if m == nil { continue } var argv []string if err := json.Unmarshal([]byte(m[1]), &argv); err != nil { return PlanDoc{}, fmt.Errorf("plan: %s line %d: run: must be a JSON argv array like [\"go\", \"test\", \"./...\"], got %s", current.ID, n+1, m[1]) } if err := validArgv(current.ID, argv); err != nil { return PlanDoc{}, err } current.Automated = append(current.Automated, argv) case "Manual": if m := bulletLine.FindStringSubmatch(line); m != nil { current.Manual = append(current.Manual, m[1]) } } } flush() if doc.Title == "" { return PlanDoc{}, fmt.Errorf("plan: a level-one heading naming the plan is required") } for _, name := range requiredSections { if !sections[name] { return PlanDoc{}, fmt.Errorf("plan: the %q section is required", name) } } if err := doc.validatePhases(); err != nil { return PlanDoc{}, err } doc.References = citations(doc.Markdown) return doc, nil } func (d PlanDoc) validatePhases() error { if len(d.Phases) == 0 { return fmt.Errorf("plan: at least one \"## Phase 1: \" section is required") } if len(d.Phases) > maxPhases { return fmt.Errorf("plan: %d phases exceeds the %d phase bound", len(d.Phases), maxPhases) } for i, p := range d.Phases { // Contiguous numbering from 1. A plan that jumps from phase 2 to // phase 4 has either lost a phase or renumbered one that progress // already refers to, and both are worse discovered later. if p.Number != i+1 { return fmt.Errorf("plan: phases must be numbered from 1 with no gaps; found Phase %d where Phase %d was expected", p.Number, i+1) } if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("plan: %s has no name", p.ID) } for _, required := range []string{"### Files", "### Changes", "### Verification"} { if !strings.Contains(p.Body, required) { return fmt.Errorf("plan: %s is missing its %q subsection", p.ID, strings.TrimPrefix(required, "### ")) } } // A phase nobody can check is a phase that can never leave // in_progress, so it is refused at seal time rather than becoming a // stuck task later. if len(p.Automated) == 0 && len(p.Manual) == 0 { return fmt.Errorf("plan: %s has no automated and no manual verification, so nothing could ever establish it is done", p.ID) } if len(p.Automated) > maxCommands { return fmt.Errorf("plan: %s declares %d automated commands, at most %d", p.ID, len(p.Automated), maxCommands) } } return nil } func validArgv(phase string, argv []string) error { if len(argv) == 0 { return fmt.Errorf("plan: %s declares an empty run: command", phase) } if len(argv) > maxArgv { return fmt.Errorf("plan: %s declares a run: command of %d arguments, at most %d", phase, len(argv), maxArgv) } for i, a := range argv { if strings.TrimSpace(a) == "" { return fmt.Errorf("plan: %s run: argument %d is empty", phase, i) } if strings.ContainsAny(a, "\n\r\x00") { return fmt.Errorf("plan: %s run: argument %d contains a newline or a null byte", phase, i) } } return nil } // Phase finds a phase by its id. func (d PlanDoc) Phase(id string) (PlanPhase, bool) { for _, p := range d.Phases { if p.ID == id { return p, true } } return PlanPhase{}, false } // citations returns every research id the document cites, deduplicated, in // order of first appearance. func citations(markdown string) []string { var out []string seen := map[string]bool{} for _, m := range researchCite.FindAllStringSubmatch(markdown, -1) { if !seen[m[1]] { seen[m[1]] = true out = append(out, m[1]) } } return out } // ResolveReferences reports the cited research ids that the accepted research // does not contain. A plan resting on a finding nobody recorded is the failure // this prevents, and it lands on the planner rather than the implementer. func (d PlanDoc) ResolveReferences(r Research) []string { known := map[string]bool{} for _, f := range r.Findings { known[f.ID] = true } var missing []string for _, id := range d.References { if !known[id] { missing = append(missing, id) } } return missing } // DecodeStoredPlan reads a plan already in the CAS. New seals are markdown; // refs sealed before plan.md existed are JSON, and refusing them would block // every task whose plan predates this change, at rotation most of all. // // A legacy plan is rendered into the same type so nothing downstream branches // on which era a plan came from. It carries no phases, which is honest: the // old artifact never named an executable unit, so plan progress genuinely // cannot apply to it. func DecodeStoredPlan(b []byte) (PlanDoc, error) { if doc, err := ParsePlan(b); err == nil { return doc, nil } p, err := DecodePlan(b) if err != nil { // Report the markdown failure, not the JSON one: every new seal is // markdown, so that is the error a reader needs. _, mdErr := ParsePlan(b) return PlanDoc{}, mdErr } return p.legacyDoc(), nil } // legacyDoc renders a pre-markdown plan as the document type. The text is // labelled so nobody mistakes a flattened plan for a specification. func (p Plan) legacyDoc() PlanDoc { var b strings.Builder b.WriteString("# Accepted plan\n\nSealed before plan.md existed, so it names intent rather than phases.\n\n## Changes\n\n") for _, c := range p.Changes { fmt.Fprintf(&b, "- %s: %s\n", c.Target, c.Intent) } for _, section := range []struct { title string items []string }{ {"Verification", p.Verification}, {"Risks", p.Risks}, {"Human decisions needed", p.DecisionsNeeded}, } { if len(section.items) == 0 { continue } fmt.Fprintf(&b, "\n## %s\n\n", section.title) for _, v := range section.items { fmt.Fprintf(&b, "- %s\n", v) } } return PlanDoc{Markdown: b.String(), Title: "Accepted plan"} }