| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // CompletionStatus is the model's assessment, distinct from execution facts. |
| 11 | type CompletionStatus string |
| 12 | |
| 13 | const ( |
| 14 | CompletionComplete CompletionStatus = "complete" |
| 15 | CompletionPartial CompletionStatus = "partial" |
| 16 | CompletionBlocked CompletionStatus = "blocked" |
| 17 | CompletionFailed CompletionStatus = "failed" |
| 18 | ) |
| 19 | |
| 20 | // CriterionStatus is the child's claim about one acceptance criterion. |
| 21 | type CriterionStatus string |
| 22 | |
| 23 | const ( |
| 24 | CriterionSatisfied CriterionStatus = "satisfied" |
| 25 | CriterionUnsatisfied CriterionStatus = "unsatisfied" |
| 26 | ) |
| 27 | |
| 28 | // Completion evidence kinds describe the model's supporting information. |
| 29 | const ( |
| 30 | CompletionEvidenceVerification = "verification" |
| 31 | CompletionEvidenceReview = "review" |
| 32 | CompletionEvidenceDiff = "diff" |
| 33 | CompletionEvidenceFiles = "files" |
| 34 | CompletionEvidenceManual = "manual" |
| 35 | ) |
| 36 | |
| 37 | // CompletionEvidence is one proof attached to an acceptance criterion. |
| 38 | type CompletionEvidence struct { |
| 39 | Kind string `json:"kind"` |
| 40 | Summary string `json:"summary"` |
| 41 | Command string `json:"command,omitempty"` |
| 42 | Paths []string `json:"paths,omitempty"` |
| 43 | } |
| 44 | |
| 45 | // AcceptanceCriterion is one checkable condition the sub-task had to meet. |
| 46 | type AcceptanceCriterion struct { |
| 47 | ID string `json:"id"` |
| 48 | Status CriterionStatus `json:"status"` |
| 49 | Evidence []CompletionEvidence `json:"evidence,omitempty"` |
| 50 | } |
| 51 | |
| 52 | // CompletionReport is the structured payload submitted via complete_subtask. |
| 53 | type CompletionReport struct { |
| 54 | Status CompletionStatus `json:"status"` |
| 55 | Summary string `json:"summary"` |
| 56 | Criteria []AcceptanceCriterion `json:"acceptance_criteria,omitempty"` |
| 57 | Unresolved []string `json:"unresolved,omitempty"` |
| 58 | } |
| 59 | |
| 60 | // ParseCompletionReport validates and normalizes a complete_subtask argument |
| 61 | // object. It checks shape only: whether the claims are true is the host's job. |
| 62 | func ParseCompletionReport(raw json.RawMessage) (CompletionReport, error) { |
| 63 | var r CompletionReport |
| 64 | if err := json.Unmarshal(raw, &r); err != nil { |
| 65 | return CompletionReport{}, fmt.Errorf("invalid complete_subtask JSON: %w", err) |
| 66 | } |
| 67 | r.Status = CompletionStatus(strings.ToLower(strings.TrimSpace(string(r.Status)))) |
| 68 | switch r.Status { |
| 69 | case CompletionComplete, CompletionPartial, CompletionBlocked, CompletionFailed: |
| 70 | default: |
| 71 | return CompletionReport{}, fmt.Errorf("complete_subtask.status must be complete, partial, blocked, or failed") |
| 72 | } |
| 73 | r.Summary = strings.TrimSpace(r.Summary) |
| 74 | if r.Summary == "" { |
| 75 | return CompletionReport{}, fmt.Errorf("complete_subtask.summary is required") |
| 76 | } |
| 77 | for i := range r.Criteria { |
| 78 | c := &r.Criteria[i] |
| 79 | c.ID = strings.TrimSpace(c.ID) |
| 80 | if c.ID == "" { |
| 81 | return CompletionReport{}, fmt.Errorf("acceptance_criteria[%d].id is required", i) |
| 82 | } |
| 83 | c.Status = CriterionStatus(strings.ToLower(strings.TrimSpace(string(c.Status)))) |
| 84 | switch c.Status { |
| 85 | case CriterionSatisfied, CriterionUnsatisfied: |
| 86 | default: |
| 87 | return CompletionReport{}, fmt.Errorf("acceptance_criteria[%d].status must be satisfied or unsatisfied", i) |
| 88 | } |
| 89 | for j := range c.Evidence { |
| 90 | e := &c.Evidence[j] |
| 91 | e.Kind = strings.ToLower(strings.TrimSpace(e.Kind)) |
| 92 | switch e.Kind { |
| 93 | case CompletionEvidenceVerification: |
| 94 | if strings.TrimSpace(e.Command) == "" { |
| 95 | return CompletionReport{}, fmt.Errorf("acceptance_criteria[%d].evidence[%d]: verification requires command", i, j) |
| 96 | } |
| 97 | case CompletionEvidenceDiff, CompletionEvidenceFiles: |
| 98 | if len(e.Paths) == 0 { |
| 99 | return CompletionReport{}, fmt.Errorf("acceptance_criteria[%d].evidence[%d]: %s requires paths", i, j, e.Kind) |
| 100 | } |
| 101 | case CompletionEvidenceReview, CompletionEvidenceManual: |
| 102 | default: |
| 103 | return CompletionReport{}, fmt.Errorf("acceptance_criteria[%d].evidence[%d].kind is not a known evidence kind", i, j) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | return r, nil |
| 108 | } |
| 109 | |
| 110 | // LatestCompletionReport returns the most recent successful complete_subtask |
| 111 | // payload recorded this run. |
| 112 | func (l *Ledger) LatestCompletionReport() (CompletionReport, bool) { |
| 113 | if l == nil { |
| 114 | return CompletionReport{}, false |
| 115 | } |
| 116 | l.mu.Lock() |
| 117 | defer l.mu.Unlock() |
| 118 | for _, r := range slices.Backward(l.receipts) { |
| 119 | if r.ToolName != "complete_subtask" || !r.Success { |
| 120 | continue |
| 121 | } |
| 122 | report, err := ParseCompletionReport(r.Args) |
| 123 | if err != nil { |
| 124 | continue |
| 125 | } |
| 126 | return report, true |
| 127 | } |
| 128 | return CompletionReport{}, false |
| 129 | } |
| 130 |