返回 DeepSeek-Reasonix
plan.go
1 package plancontract
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7 )
8
9 // MaxSteps bounds a plan's total step count. A plan past it is malformed rather
10 // than merely long: rejecting it beats projecting a silently truncated list.
11 const MaxSteps = 50
12
13 // Plan is one revision of a proposed approach. RequiresApproval is the planner's
14 // request, never its decision — the host's route owns whether execution gates.
15 // ID and Revision carry json:"-" because they are host-assigned: a planner
16 // cannot claim an identity the host did not give it.
17 type Plan struct {
18 ID string `json:"-"`
19 Revision int `json:"-"`
20 Objective string `json:"objective"`
21 Assumptions []Assumption `json:"assumptions,omitempty"`
22 NonGoals []string `json:"non_goals,omitempty"`
23 Steps []Step `json:"steps"`
24 RequiresApproval bool `json:"requires_approval,omitempty"`
25 }
26
27 // Assumption is an unverified premise the plan rests on. Confirm names the
28 // cheapest check that would settle it; empty leaves that to the executor.
29 type Assumption struct {
30 Text string `json:"text"`
31 Confirm string `json:"confirm,omitempty"`
32 }
33
34 // Step is one unit of the plan. An empty ParentID makes it a phase, otherwise it
35 // is a sub-step of that phase. DependsOn orders siblings and is advisory: the
36 // todo projection is serial, so a dependency becomes order, not gating.
37 type Step struct {
38 ID string `json:"id,omitempty"`
39 ParentID string `json:"parent_id,omitempty"`
40 Title string `json:"title"`
41 DependsOn []string `json:"depends_on,omitempty"`
42 VerifiedFiles []string `json:"verified_files,omitempty"` // paths the planner read
43 CandidateFiles []string `json:"candidate_files,omitempty"` // paths the planner inferred
44 Acceptance []Criterion `json:"acceptance,omitempty"`
45 Verification []Verification `json:"verification,omitempty"`
46 Risks []string `json:"risks,omitempty"`
47 }
48
49 // Criterion is one acceptance criterion. Regression marks must-keep-passing
50 // behavior; Optional marks a nice-to-have that never blocks completion. ID is
51 // host-assigned so it can key an evidence requirement downstream.
52 type Criterion struct {
53 ID string `json:"-"`
54 Text string `json:"text"`
55 Regression bool `json:"regression,omitempty"`
56 Optional bool `json:"optional,omitempty"`
57 }
58
59 // Verification is a command-level check. An empty Command accepts any
60 // delivery-verification command; Expect describes what a pass looks like.
61 type Verification struct {
62 Command string `json:"command,omitempty"`
63 Expect string `json:"expect,omitempty"`
64 }
65
66 // Normalize returns a canonical copy: trimmed text, dropped empty entries,
67 // assigned IDs, and repaired parent and dependency references. It never fails —
68 // what it cannot repair is left for Validate to reject.
69 func (p Plan) Normalize() Plan {
70 out := Plan{
71 ID: strings.TrimSpace(p.ID),
72 Revision: max(p.Revision, 1),
73 Objective: strings.TrimSpace(p.Objective),
74 NonGoals: cleanStrings(p.NonGoals),
75 RequiresApproval: p.RequiresApproval,
76 }
77 for _, a := range p.Assumptions {
78 text := strings.TrimSpace(a.Text)
79 if text == "" {
80 continue
81 }
82 out.Assumptions = append(out.Assumptions, Assumption{Text: text, Confirm: strings.TrimSpace(a.Confirm)})
83 }
84 out.Steps = normalizeSteps(p.Steps)
85 return out
86 }
87
88 func normalizeSteps(steps []Step) []Step {
89 out := make([]Step, 0, len(steps))
90 for _, s := range steps {
91 title := strings.TrimSpace(s.Title)
92 if title == "" {
93 continue
94 }
95 out = append(out, Step{
96 ID: strings.TrimSpace(s.ID),
97 ParentID: strings.TrimSpace(s.ParentID),
98 Title: title,
99 DependsOn: cleanStrings(s.DependsOn),
100 VerifiedFiles: cleanStrings(s.VerifiedFiles),
101 CandidateFiles: cleanStrings(s.CandidateFiles),
102 Acceptance: cleanCriteria(s.Acceptance),
103 Verification: cleanVerifications(s.Verification),
104 Risks: cleanStrings(s.Risks),
105 })
106 }
107 assignStepIDs(out)
108 assignCriterionIDs(out)
109 repairParents(out)
110 repairDependencies(out)
111 return out
112 }
113
114 // assignStepIDs fills blank and duplicate IDs with "s<n>", leaving the first
115 // use of an ID with its submitted value so parent and dependency references the
116 // planner did write keep resolving.
117 func assignStepIDs(steps []Step) {
118 used := make(map[string]bool, len(steps))
119 for i := range steps {
120 id := steps[i].ID
121 if id == "" || used[id] {
122 steps[i].ID = ""
123 continue
124 }
125 used[id] = true
126 }
127 next := 1
128 for i := range steps {
129 if steps[i].ID != "" {
130 continue
131 }
132 for {
133 id := fmt.Sprintf("plan_step_%02d", next)
134 next++
135 if !used[id] {
136 used[id] = true
137 steps[i].ID = id
138 break
139 }
140 }
141 }
142 }
143
144 func assignCriterionIDs(steps []Step) {
145 used := make(map[string]bool)
146 for i := range steps {
147 for j := range steps[i].Acceptance {
148 id := steps[i].Acceptance[j].ID
149 if id == "" || used[id] {
150 steps[i].Acceptance[j].ID = ""
151 continue
152 }
153 used[id] = true
154 }
155 }
156 next := 1
157 for i := range steps {
158 for j := range steps[i].Acceptance {
159 if steps[i].Acceptance[j].ID != "" {
160 continue
161 }
162 for {
163 id := fmt.Sprintf("c%d", next)
164 next++
165 if !used[id] {
166 used[id] = true
167 steps[i].Acceptance[j].ID = id
168 break
169 }
170 }
171 }
172 }
173 }
174
175 // repairParents rewrites every parent reference to the top-level phase the step
176 // actually belongs to, so a normalized plan is literally two levels deep and
177 // Ordered reads the field instead of re-deriving it.
178 func repairParents(steps []Step) {
179 for i, phase := range phaseIDs(steps) {
180 steps[i].ParentID = phase
181 }
182 }
183
184 func repairDependencies(steps []Step) {
185 index := make(map[string]bool, len(steps))
186 for _, s := range steps {
187 index[s.ID] = true
188 }
189 for i := range steps {
190 kept := steps[i].DependsOn[:0]
191 seen := make(map[string]bool, len(steps[i].DependsOn))
192 for _, dep := range steps[i].DependsOn {
193 if dep == steps[i].ID || !index[dep] || seen[dep] {
194 continue
195 }
196 seen[dep] = true
197 kept = append(kept, dep)
198 }
199 if len(kept) == 0 {
200 kept = nil
201 }
202 steps[i].DependsOn = kept
203 }
204 }
205
206 // Validate reports every defect a normalized plan can still carry, joined so a
207 // planner can fix them in one revision instead of one per round.
208 func (p Plan) Validate() error {
209 var errs []error
210 if strings.TrimSpace(p.Objective) == "" {
211 errs = append(errs, errors.New("plan has no objective"))
212 }
213 if len(p.Steps) == 0 {
214 errs = append(errs, errors.New("plan has no steps"))
215 }
216 if len(p.Steps) > MaxSteps {
217 errs = append(errs, fmt.Errorf("plan has %d steps; the limit is %d", len(p.Steps), MaxSteps))
218 }
219 seen := make(map[string]bool, len(p.Steps))
220 for _, s := range p.Steps {
221 if strings.TrimSpace(s.Title) == "" {
222 errs = append(errs, fmt.Errorf("step %q has no title", s.ID))
223 }
224 id := strings.TrimSpace(s.ID)
225 if id == "" {
226 errs = append(errs, fmt.Errorf("step %q has no id", s.Title))
227 continue
228 }
229 if seen[id] {
230 errs = append(errs, fmt.Errorf("step id %q is used more than once", id))
231 }
232 seen[id] = true
233 }
234 for _, group := range siblingGroups(p.Steps) {
235 if _, cyclic := sortSiblings(group); cyclic {
236 errs = append(errs, fmt.Errorf("steps %s form a dependency cycle", strings.Join(stepIDs(group), ", ")))
237 }
238 }
239 return errors.Join(errs...)
240 }
241
242 func stepIDs(steps []Step) []string {
243 out := make([]string, 0, len(steps))
244 for _, s := range steps {
245 out = append(out, s.ID)
246 }
247 return out
248 }
249
250 func cleanStrings(in []string) []string {
251 out := make([]string, 0, len(in))
252 seen := make(map[string]bool, len(in))
253 for _, s := range in {
254 s = strings.TrimSpace(s)
255 if s == "" || seen[s] {
256 continue
257 }
258 seen[s] = true
259 out = append(out, s)
260 }
261 if len(out) == 0 {
262 return nil
263 }
264 return out
265 }
266
267 func cleanCriteria(in []Criterion) []Criterion {
268 out := make([]Criterion, 0, len(in))
269 for _, c := range in {
270 text := strings.TrimSpace(c.Text)
271 if text == "" {
272 continue
273 }
274 out = append(out, Criterion{
275 ID: strings.TrimSpace(c.ID),
276 Text: text,
277 Regression: c.Regression,
278 Optional: c.Optional,
279 })
280 }
281 if len(out) == 0 {
282 return nil
283 }
284 return out
285 }
286
287 func cleanVerifications(in []Verification) []Verification {
288 out := make([]Verification, 0, len(in))
289 for _, v := range in {
290 command := strings.TrimSpace(v.Command)
291 expect := strings.TrimSpace(v.Expect)
292 if command == "" && expect == "" {
293 continue
294 }
295 out = append(out, Verification{Command: command, Expect: expect})
296 }
297 if len(out) == 0 {
298 return nil
299 }
300 return out
301 }
302
302 lines GO