| 1 | package plancontract |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // Render turns a plan into the markdown a user reads. Its only list items are |
| 9 | // the steps — phases numbered, sub-steps indented — in their stable order. |
| 10 | // RequiresApproval is absent on purpose: it is a routing request the host answers with its |
| 11 | // approval surface, not plan content. |
| 12 | func Render(p Plan) string { |
| 13 | p = p.Normalize() |
| 14 | var b strings.Builder |
| 15 | if p.Objective != "" { |
| 16 | fmt.Fprintf(&b, "**Objective** — %s\n", p.Objective) |
| 17 | } |
| 18 | renderAssumptions(&b, p.Assumptions) |
| 19 | if len(p.NonGoals) > 0 { |
| 20 | section(&b, "Non-goals") |
| 21 | for _, goal := range p.NonGoals { |
| 22 | fmt.Fprintf(&b, " %s\n", continuation(goal)) |
| 23 | } |
| 24 | } |
| 25 | renderSteps(&b, p.Ordered()) |
| 26 | return strings.TrimSpace(b.String()) |
| 27 | } |
| 28 | |
| 29 | func renderAssumptions(b *strings.Builder, assumptions []Assumption) { |
| 30 | if len(assumptions) == 0 { |
| 31 | return |
| 32 | } |
| 33 | section(b, "Assumptions") |
| 34 | for _, a := range assumptions { |
| 35 | if a.Confirm == "" { |
| 36 | fmt.Fprintf(b, " %s\n", continuation(a.Text)) |
| 37 | continue |
| 38 | } |
| 39 | fmt.Fprintf(b, " %s (confirm: %s)\n", continuation(a.Text), a.Confirm) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // continuation strips a leading list marker from text that renders without a |
| 44 | // label, so a planner that writes its assumptions as bullets cannot smuggle a |
| 45 | // line into the step list. |
| 46 | func continuation(s string) string { |
| 47 | for _, marker := range []string{"- ", "* ", "+ "} { |
| 48 | if rest, ok := strings.CutPrefix(s, marker); ok { |
| 49 | return strings.TrimSpace(rest) |
| 50 | } |
| 51 | } |
| 52 | digits := 0 |
| 53 | for digits < len(s) && s[digits] >= '0' && s[digits] <= '9' { |
| 54 | digits++ |
| 55 | } |
| 56 | if digits > 0 && digits+1 < len(s) && (s[digits] == '.' || s[digits] == ')') && s[digits+1] == ' ' { |
| 57 | return strings.TrimSpace(s[digits+2:]) |
| 58 | } |
| 59 | return s |
| 60 | } |
| 61 | |
| 62 | func renderSteps(b *strings.Builder, steps []Step) { |
| 63 | if len(steps) == 0 { |
| 64 | return |
| 65 | } |
| 66 | section(b, "Plan") |
| 67 | phase := 0 |
| 68 | for _, step := range steps { |
| 69 | if step.ParentID == "" { |
| 70 | phase++ |
| 71 | fmt.Fprintf(b, "%d. %s\n", phase, step.Title) |
| 72 | renderDetail(b, step, " ") |
| 73 | continue |
| 74 | } |
| 75 | fmt.Fprintf(b, " - %s\n", step.Title) |
| 76 | renderDetail(b, step, " ") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // renderDetail writes a step's evidence and checks as indented continuation |
| 81 | // lines. They carry no list marker on purpose: a reader parsing the plan for its |
| 82 | // task list must see steps and nothing else. |
| 83 | func renderDetail(b *strings.Builder, step Step, indent string) { |
| 84 | if len(step.VerifiedFiles) > 0 { |
| 85 | fmt.Fprintf(b, "%sverified: %s\n", indent, strings.Join(step.VerifiedFiles, ", ")) |
| 86 | } |
| 87 | if len(step.CandidateFiles) > 0 { |
| 88 | fmt.Fprintf(b, "%scandidate: %s\n", indent, strings.Join(step.CandidateFiles, ", ")) |
| 89 | } |
| 90 | for _, c := range step.Acceptance { |
| 91 | label := "accept" |
| 92 | if c.Regression { |
| 93 | label = "regression" |
| 94 | } |
| 95 | if c.Optional { |
| 96 | label += " (optional)" |
| 97 | } |
| 98 | // The id is rendered because a proof has to cite it: a criterion the |
| 99 | // executor cannot name is one it cannot satisfy. |
| 100 | fmt.Fprintf(b, "%s%s [%s]: %s\n", indent, label, c.ID, c.Text) |
| 101 | } |
| 102 | for _, v := range step.Verification { |
| 103 | command := v.Command |
| 104 | if command == "" { |
| 105 | command = "any verification command" |
| 106 | } |
| 107 | if v.Expect != "" { |
| 108 | command += " — " + v.Expect |
| 109 | } |
| 110 | fmt.Fprintf(b, "%sverify: %s\n", indent, command) |
| 111 | } |
| 112 | for _, risk := range step.Risks { |
| 113 | fmt.Fprintf(b, "%srisk: %s\n", indent, risk) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func section(b *strings.Builder, title string) { |
| 118 | if b.Len() > 0 { |
| 119 | b.WriteString("\n") |
| 120 | } |
| 121 | fmt.Fprintf(b, "**%s**\n", title) |
| 122 | } |
| 123 |