devrites 5.12.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -3
  3. package/docs/architecture.md +2 -2
  4. package/docs/command-map.md +3 -1
  5. package/docs/engine/commands.md +21 -0
  6. package/docs/flow.md +2 -1
  7. package/docs/skills.md +13 -4
  8. package/engine/help.go +4 -1
  9. package/engine/help_test.go +2 -0
  10. package/engine/internal/overhaul/admit/admit_test.go +170 -0
  11. package/engine/internal/overhaul/admit/run.go +416 -0
  12. package/engine/internal/overhaul/bench/run.go +248 -0
  13. package/engine/internal/overhaul/bench/run_test.go +139 -0
  14. package/engine/internal/overhaul/overhaul.go +64 -0
  15. package/engine/internal/overhaul/ovio/ovio.go +158 -0
  16. package/engine/internal/overhaul/records/check.go +834 -0
  17. package/engine/internal/overhaul/records/records_test.go +577 -0
  18. package/engine/internal/overhaul/records/run.go +392 -0
  19. package/engine/internal/overhaul/render/run.go +776 -0
  20. package/engine/internal/overhaul/render/run_test.go +293 -0
  21. package/engine/internal/overhaul/score/run.go +600 -0
  22. package/engine/internal/overhaul/score/run_test.go +405 -0
  23. package/engine/internal/overhaul/snapshot/run.go +563 -0
  24. package/engine/internal/overhaul/snapshot/run_test.go +360 -0
  25. package/engine/main.go +4 -0
  26. package/engine/root_routing_test.go +2 -1
  27. package/pack/.claude/skills/overhaul/SKILL.md +201 -0
  28. package/pack/.claude/skills/overhaul/references/audit-domains.md +263 -0
  29. package/pack/.claude/skills/overhaul/references/lifecycle.md +308 -0
  30. package/pack/.claude/skills/overhaul/references/orchestration.md +264 -0
  31. package/pack/.claude/skills/overhaul/references/records.md +137 -0
  32. package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  33. package/pack/.claude/skills/overhaul/references/review-and-report.md +137 -0
  34. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +191 -0
  35. package/pack/.claude/skills/overhaul/references/scoring.md +222 -0
  36. package/pack/.claude/skills/overhaul/references/stack-profiles.md +264 -0
  37. package/pack/.claude/skills/overhaul/roles/backend-engineer.md +61 -0
  38. package/pack/.claude/skills/overhaul/roles/boundary.md +56 -0
  39. package/pack/.claude/skills/overhaul/roles/craft.md +77 -0
  40. package/pack/.claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  41. package/pack/.claude/skills/overhaul/roles/implementer.md +59 -0
  42. package/pack/.claude/skills/overhaul/roles/recon.md +60 -0
  43. package/pack/.claude/skills/overhaul/roles/specialist.md +56 -0
  44. package/pack/.claude/skills/overhaul/roles/verifier.md +60 -0
  45. package/pack/generated/claude/skills/overhaul/SKILL.md +201 -0
  46. package/pack/generated/claude/skills/overhaul/references/audit-domains.md +263 -0
  47. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +308 -0
  48. package/pack/generated/claude/skills/overhaul/references/orchestration.md +264 -0
  49. package/pack/generated/claude/skills/overhaul/references/records.md +137 -0
  50. package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +207 -0
  51. package/pack/generated/claude/skills/overhaul/references/review-and-report.md +137 -0
  52. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +191 -0
  53. package/pack/generated/claude/skills/overhaul/references/scoring.md +222 -0
  54. package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +264 -0
  55. package/pack/generated/claude/skills/overhaul/roles/backend-engineer.md +61 -0
  56. package/pack/generated/claude/skills/overhaul/roles/boundary.md +56 -0
  57. package/pack/generated/claude/skills/overhaul/roles/craft.md +77 -0
  58. package/pack/generated/claude/skills/overhaul/roles/frontend-engineer.md +63 -0
  59. package/pack/generated/claude/skills/overhaul/roles/implementer.md +59 -0
  60. package/pack/generated/claude/skills/overhaul/roles/recon.md +60 -0
  61. package/pack/generated/claude/skills/overhaul/roles/specialist.md +56 -0
  62. package/pack/generated/claude/skills/overhaul/roles/verifier.md +60 -0
  63. package/pack/generated/codex/skills/overhaul/SKILL.md +201 -0
  64. package/pack/generated/codex/skills/overhaul/agents/openai.yaml +2 -0
  65. package/pack/generated/codex/skills/overhaul/references/audit-domains.md +263 -0
  66. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +308 -0
  67. package/pack/generated/codex/skills/overhaul/references/orchestration.md +264 -0
  68. package/pack/generated/codex/skills/overhaul/references/records.md +137 -0
  69. package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +207 -0
  70. package/pack/generated/codex/skills/overhaul/references/review-and-report.md +137 -0
  71. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +191 -0
  72. package/pack/generated/codex/skills/overhaul/references/scoring.md +222 -0
  73. package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +264 -0
  74. package/pack/generated/codex/skills/overhaul/roles/backend-engineer.md +61 -0
  75. package/pack/generated/codex/skills/overhaul/roles/boundary.md +56 -0
  76. package/pack/generated/codex/skills/overhaul/roles/craft.md +77 -0
  77. package/pack/generated/codex/skills/overhaul/roles/frontend-engineer.md +63 -0
  78. package/pack/generated/codex/skills/overhaul/roles/implementer.md +59 -0
  79. package/pack/generated/codex/skills/overhaul/roles/recon.md +60 -0
  80. package/pack/generated/codex/skills/overhaul/roles/specialist.md +56 -0
  81. package/pack/generated/codex/skills/overhaul/roles/verifier.md +60 -0
  82. package/pack/generated/devin/skills/overhaul/SKILL.md +201 -0
  83. package/pack/generated/devin/skills/overhaul/references/audit-domains.md +263 -0
  84. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +308 -0
  85. package/pack/generated/devin/skills/overhaul/references/orchestration.md +264 -0
  86. package/pack/generated/devin/skills/overhaul/references/records.md +137 -0
  87. package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +207 -0
  88. package/pack/generated/devin/skills/overhaul/references/review-and-report.md +137 -0
  89. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +191 -0
  90. package/pack/generated/devin/skills/overhaul/references/scoring.md +222 -0
  91. package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +264 -0
  92. package/pack/generated/devin/skills/overhaul/roles/backend-engineer.md +61 -0
  93. package/pack/generated/devin/skills/overhaul/roles/boundary.md +56 -0
  94. package/pack/generated/devin/skills/overhaul/roles/craft.md +77 -0
  95. package/pack/generated/devin/skills/overhaul/roles/frontend-engineer.md +63 -0
  96. package/pack/generated/devin/skills/overhaul/roles/implementer.md +59 -0
  97. package/pack/generated/devin/skills/overhaul/roles/recon.md +60 -0
  98. package/pack/generated/devin/skills/overhaul/roles/specialist.md +56 -0
  99. package/pack/generated/devin/skills/overhaul/roles/verifier.md +60 -0
  100. package/pack/generated/omp/skills/overhaul/SKILL.md +201 -0
  101. package/pack/generated/omp/skills/overhaul/references/audit-domains.md +263 -0
  102. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +308 -0
  103. package/pack/generated/omp/skills/overhaul/references/orchestration.md +264 -0
  104. package/pack/generated/omp/skills/overhaul/references/records.md +137 -0
  105. package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +207 -0
  106. package/pack/generated/omp/skills/overhaul/references/review-and-report.md +137 -0
  107. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +191 -0
  108. package/pack/generated/omp/skills/overhaul/references/scoring.md +222 -0
  109. package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +264 -0
  110. package/pack/generated/omp/skills/overhaul/roles/backend-engineer.md +61 -0
  111. package/pack/generated/omp/skills/overhaul/roles/boundary.md +56 -0
  112. package/pack/generated/omp/skills/overhaul/roles/craft.md +77 -0
  113. package/pack/generated/omp/skills/overhaul/roles/frontend-engineer.md +63 -0
  114. package/pack/generated/omp/skills/overhaul/roles/implementer.md +59 -0
  115. package/pack/generated/omp/skills/overhaul/roles/recon.md +60 -0
  116. package/pack/generated/omp/skills/overhaul/roles/specialist.md +56 -0
  117. package/pack/generated/omp/skills/overhaul/roles/verifier.md +60 -0
  118. package/pack/generated/pi/skills/overhaul/SKILL.md +201 -0
  119. package/pack/generated/pi/skills/overhaul/references/audit-domains.md +263 -0
  120. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +308 -0
  121. package/pack/generated/pi/skills/overhaul/references/orchestration.md +264 -0
  122. package/pack/generated/pi/skills/overhaul/references/records.md +137 -0
  123. package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +207 -0
  124. package/pack/generated/pi/skills/overhaul/references/review-and-report.md +137 -0
  125. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +191 -0
  126. package/pack/generated/pi/skills/overhaul/references/scoring.md +222 -0
  127. package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +264 -0
  128. package/pack/generated/pi/skills/overhaul/roles/backend-engineer.md +61 -0
  129. package/pack/generated/pi/skills/overhaul/roles/boundary.md +56 -0
  130. package/pack/generated/pi/skills/overhaul/roles/craft.md +77 -0
  131. package/pack/generated/pi/skills/overhaul/roles/frontend-engineer.md +63 -0
  132. package/pack/generated/pi/skills/overhaul/roles/implementer.md +59 -0
  133. package/pack/generated/pi/skills/overhaul/roles/recon.md +60 -0
  134. package/pack/generated/pi/skills/overhaul/roles/specialist.md +56 -0
  135. package/pack/generated/pi/skills/overhaul/roles/verifier.md +60 -0
  136. package/package.json +1 -1
@@ -0,0 +1,834 @@
1
+ package records
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path"
9
+ "path/filepath"
10
+ "reflect"
11
+ "sort"
12
+ "strings"
13
+
14
+ "github.com/devrites/devrites/internal/overhaul/ovio"
15
+ )
16
+
17
+ func set(vals ...string) map[string]bool {
18
+ m := map[string]bool{}
19
+ for _, v := range vals {
20
+ m[v] = true
21
+ }
22
+ return m
23
+ }
24
+
25
+ var enums = []struct {
26
+ field string
27
+ allowed map[string]bool
28
+ }{
29
+ {"mode", set("full", "pr", "branch")},
30
+ {"phase", set("PREFLIGHT", "AUDITING", "RECONCILING", "AWAITING_APPROVAL", "REMEDIATING", "VERIFYING",
31
+ "SCORING", "FINAL_CHALLENGE", "COMPLETE", "PAUSED", "STOPPED", "BLOCKED")},
32
+ {"readiness_verdict", set("NOT_ASSESSED", "NOT_ASSESSABLE", "NOT_READY", "MEETS_TARGETS",
33
+ "MEETS_TARGETS_WITH_APPROVED_DEGRADATION")},
34
+ {"execution_outcome", set("RUNNING", "AUDIT_COMPLETE", "AUDIT_INCOMPLETE", "AWAITING_APPROVAL",
35
+ "READY_FOR_USER_REVIEW", "COMPLETED_WITH_APPROVED_DEGRADATION", "STOPPED_BY_USER",
36
+ "BLOCKED_NEEDS_USER", "BLOCKED_ENVIRONMENT", "BLOCKED_PARALLEL_CAPABILITY",
37
+ "BUDGET_EXHAUSTED", "INCONCLUSIVE")},
38
+ }
39
+
40
+ var (
41
+ kinds = set("defect", "hardening", "maintainability", "opportunity")
42
+ origins = set("introduced", "exposed-existing", "pre-existing")
43
+ severities = set("critical", "high", "medium", "low", "informational")
44
+ moves = map[string]map[string]bool{ // allowed finding status transitions
45
+ "candidate": set("needs-validation", "confirmed", "rejected"),
46
+ "needs-validation": set("confirmed", "rejected"),
47
+ "confirmed": set("approved-for-fix", "deferred", "accepted-risk", "rejected"),
48
+ "approved-for-fix": set("in-progress", "deferred"),
49
+ "in-progress": set("fixed-unverified", "approved-for-fix"),
50
+ "fixed-unverified": set("verified", "in-progress"),
51
+ "verified": set("confirmed"),
52
+ "deferred": set("approved-for-fix", "confirmed"),
53
+ "accepted-risk": set("approved-for-fix", "confirmed"),
54
+ "rejected": set("candidate"),
55
+ }
56
+ severityOK = set("confirmed", "approved-for-fix", "in-progress", "fixed-unverified", "verified", "deferred", "accepted-risk")
57
+ attemptState = set("dispatched", "running", "returned", "admitted", "rejected", "cancelled", "timed-out", "unknown")
58
+ live = set("dispatched", "running", "unknown", "timed-out") // writer not proven stopped
59
+ writers = set("implementer")
60
+ taskOutcomes = set("completed", "blocked", "deferred", "failed", "not-started", "superseded")
61
+ required = []string{"run.json", "coverage.json", "findings.json", "evidence.json", "dispatch.json",
62
+ "approval.json", "cycles.json"}
63
+ channels = set("conversation", "host-structured-action")
64
+ )
65
+
66
+ // Repr renders a decoded JSON value the way Python's repr() does, so rule
67
+ // messages read the same as the original recipe's.
68
+ func Repr(v any) string {
69
+ switch x := v.(type) {
70
+ case nil:
71
+ return "None"
72
+ case bool:
73
+ if x {
74
+ return "True"
75
+ }
76
+ return "False"
77
+ case string:
78
+ return quote(x)
79
+ case json.Number:
80
+ return x.String()
81
+ case []string:
82
+ parts := make([]string, len(x))
83
+ for i, s := range x {
84
+ parts[i] = quote(s)
85
+ }
86
+ return "[" + strings.Join(parts, ", ") + "]"
87
+ case []any:
88
+ parts := make([]string, len(x))
89
+ for i, e := range x {
90
+ parts[i] = Repr(e)
91
+ }
92
+ return "[" + strings.Join(parts, ", ") + "]"
93
+ case map[string]any:
94
+ keys := make([]string, 0, len(x))
95
+ for k := range x {
96
+ keys = append(keys, k)
97
+ }
98
+ sort.Strings(keys)
99
+ parts := make([]string, len(keys))
100
+ for i, k := range keys {
101
+ parts[i] = quote(k) + ": " + Repr(x[k])
102
+ }
103
+ return "{" + strings.Join(parts, ", ") + "}"
104
+ }
105
+ return fmt.Sprint(v)
106
+ }
107
+
108
+ func quote(s string) string {
109
+ q := '\''
110
+ if strings.ContainsRune(s, '\'') && !strings.ContainsRune(s, '"') {
111
+ q = '"'
112
+ }
113
+ var b strings.Builder
114
+ b.WriteRune(q)
115
+ for _, r := range s {
116
+ switch {
117
+ case r == '\\' || r == q:
118
+ b.WriteRune('\\')
119
+ b.WriteRune(r)
120
+ case r == '\n':
121
+ b.WriteString(`\n`)
122
+ case r == '\r':
123
+ b.WriteString(`\r`)
124
+ case r == '\t':
125
+ b.WriteString(`\t`)
126
+ case r < 0x20 || r == 0x7f:
127
+ fmt.Fprintf(&b, `\x%02x`, r)
128
+ default:
129
+ b.WriteRune(r)
130
+ }
131
+ }
132
+ b.WriteRune(q)
133
+ return b.String()
134
+ }
135
+
136
+ // PyStr is Python's str(): strings as-is, anything else as Repr.
137
+ func PyStr(v any) string {
138
+ if s, ok := v.(string); ok {
139
+ return s
140
+ }
141
+ return Repr(v)
142
+ }
143
+
144
+ // join mirrors os.path.join: an absolute part replaces everything before it.
145
+ func join(base string, parts ...string) string {
146
+ for _, p := range parts {
147
+ switch {
148
+ case strings.HasPrefix(p, "/"):
149
+ base = p
150
+ case base == "" || strings.HasSuffix(base, "/"):
151
+ base += p
152
+ default:
153
+ base += "/" + p
154
+ }
155
+ }
156
+ return base
157
+ }
158
+
159
+ func realpath(p string) string {
160
+ abs, err := filepath.Abs(p)
161
+ if err != nil {
162
+ abs = p
163
+ }
164
+ return ovio.Resolve(abs)
165
+ }
166
+
167
+ func inside(base, rel string) bool {
168
+ root := realpath(base)
169
+ return strings.HasPrefix(realpath(join(root, rel)), root+string(os.PathSeparator))
170
+ }
171
+
172
+ func isFile(p string) bool {
173
+ fi, err := os.Stat(p) // #nosec G703 -- stat only; path is inside the operator-selected run area
174
+ return err == nil && fi.Mode().IsRegular()
175
+ }
176
+
177
+ func isDir(p string) bool {
178
+ fi, err := os.Stat(p) // #nosec G703 -- stat only; path is inside the operator-selected run area
179
+ return err == nil && fi.IsDir()
180
+ }
181
+
182
+ func sha(p string) (string, error) { return ovio.SHA256File(p) }
183
+
184
+ // keyError mimics Python's KeyError text for a missing required field.
185
+ func keyError(k string) error { return fmt.Errorf("'%s'", k) }
186
+
187
+ // BadPath returns why p is not an exact repository-relative file path, or ""
188
+ // when it is one.
189
+ func BadPath(p any, repo string) string {
190
+ s, ok := p.(string)
191
+ if !ok || s == "" || strings.HasSuffix(s, "/") || path.IsAbs(s) || filepath.IsAbs(s) || strings.ContainsAny(s, "*?") {
192
+ return "not an exact relative file path"
193
+ }
194
+ if path.Clean(s) != s || strings.Split(s, "/")[0] == ".." {
195
+ return "not normalized or escapes the repository"
196
+ }
197
+ for _, part := range strings.Split(s, "/") {
198
+ if part == ".git" {
199
+ return "names a directory or git metadata"
200
+ }
201
+ }
202
+ if isDir(join(repo, s)) {
203
+ return "names a directory or git metadata"
204
+ }
205
+ if !inside(repo, s) {
206
+ return "resolves outside the repository (symlink or traversal)"
207
+ }
208
+ return ""
209
+ }
210
+
211
+ // vset is a set of JSON values keyed by their Repr, keeping the value.
212
+ type vset map[string]any
213
+
214
+ func (s vset) add(v any) { s[Repr(v)] = v }
215
+
216
+ func (s vset) has(v any) bool {
217
+ _, ok := s[Repr(v)]
218
+ return ok
219
+ }
220
+
221
+ func setOf(vals []any) vset {
222
+ s := vset{}
223
+ for _, v := range vals {
224
+ s.add(v)
225
+ }
226
+ return s
227
+ }
228
+
229
+ // sorted mirrors Python's sorted(set) for the string members the records use.
230
+ func (s vset) sorted() []any {
231
+ out := make([]any, 0, len(s))
232
+ for _, v := range s {
233
+ out = append(out, v)
234
+ }
235
+ sort.Slice(out, func(i, j int) bool { return PyStr(out[i]) < PyStr(out[j]) })
236
+ return out
237
+ }
238
+
239
+ func uniq(items []any, key func(map[string]any) string, label string, add func(string, ...any)) map[string]bool {
240
+ seen := map[string]bool{}
241
+ for _, it := range items {
242
+ k := key(ovio.Obj(it))
243
+ if seen[k] {
244
+ add("%s: duplicate id %s", label, k)
245
+ }
246
+ seen[k] = true
247
+ }
248
+ return seen
249
+ }
250
+
251
+ func idKey(m map[string]any) string { return Repr(m["id"]) }
252
+
253
+ func str(m map[string]any, k string) string { return ovio.Str(m[k]) }
254
+
255
+ func eq(a, b any) bool { return reflect.DeepEqual(a, b) }
256
+
257
+ // prefixEq reports whether list starts with prefix (Python list[:len(p)] == p).
258
+ func prefixEq(list, prefix []any) bool {
259
+ if len(list) < len(prefix) {
260
+ return false
261
+ }
262
+ for i := range prefix {
263
+ if !eq(list[i], prefix[i]) {
264
+ return false
265
+ }
266
+ }
267
+ return true
268
+ }
269
+
270
+ func decode(b []byte) (any, error) {
271
+ dec := json.NewDecoder(bytes.NewReader(b))
272
+ dec.UseNumber()
273
+ var v any
274
+ err := dec.Decode(&v)
275
+ return v, err
276
+ }
277
+
278
+ func closure(tasks map[string]map[string]any, picked []any) vset {
279
+ need, stack := vset{}, append([]any(nil), picked...)
280
+ for len(stack) > 0 {
281
+ t := stack[len(stack)-1]
282
+ stack = stack[:len(stack)-1]
283
+ for _, d := range ovio.List(tasks[Repr(t)]["deps"]) {
284
+ if !need.has(d) {
285
+ need.add(d)
286
+ stack = append(stack, d)
287
+ }
288
+ }
289
+ }
290
+ for _, p := range picked {
291
+ delete(need, Repr(p))
292
+ }
293
+ return need
294
+ }
295
+
296
+ func cyclic(tasks map[string]map[string]any) bool {
297
+ state := map[string]int{}
298
+ var visit func(k string) bool
299
+ visit = func(k string) bool {
300
+ switch state[k] {
301
+ case 1:
302
+ return true
303
+ case 2:
304
+ return false
305
+ }
306
+ state[k] = 1
307
+ hit := false
308
+ for _, d := range ovio.List(tasks[k]["deps"]) {
309
+ if _, ok := tasks[Repr(d)]; ok && visit(Repr(d)) {
310
+ hit = true
311
+ break
312
+ }
313
+ }
314
+ state[k] = 2
315
+ return hit
316
+ }
317
+ for k := range tasks {
318
+ if visit(k) {
319
+ return true
320
+ }
321
+ }
322
+ return false
323
+ }
324
+
325
+ // Check validates one generation directory (optionally against the previous
326
+ // one) and returns rule violations. An error means an unreadable input.
327
+ func Check(run, genDir, prevDir string) ([]string, error) {
328
+ var errs []string
329
+ add := func(f string, a ...any) { errs = append(errs, fmt.Sprintf(f, a...)) }
330
+ R := map[string]map[string]any{}
331
+ for _, name := range required {
332
+ p := join(genDir, name)
333
+ if !isFile(p) {
334
+ add("missing %s", name)
335
+ continue
336
+ }
337
+ b, err := os.ReadFile(p) // #nosec G304 G703 -- fixed record name inside the generation directory
338
+ if err != nil {
339
+ return nil, err
340
+ }
341
+ v, err := decode(b)
342
+ if err == nil {
343
+ if m, ok := v.(map[string]any); ok {
344
+ R[name] = m
345
+ continue
346
+ }
347
+ err = fmt.Errorf("expected a JSON object")
348
+ }
349
+ add("%s: invalid JSON (%v)", name, err)
350
+ }
351
+ if len(errs) > 0 {
352
+ return errs, nil
353
+ }
354
+ for _, name := range required {
355
+ want := "overhaul." + strings.TrimSuffix(name, ".json") + "/1"
356
+ if !eq(R[name]["schema"], want) {
357
+ add("%s: schema must be %s", name, want)
358
+ }
359
+ }
360
+ rj := R["run.json"]
361
+ for _, e := range enums {
362
+ if !e.allowed[str(rj, e.field)] {
363
+ keys := make([]string, 0, len(e.allowed))
364
+ for k := range e.allowed {
365
+ keys = append(keys, k)
366
+ }
367
+ sort.Strings(keys)
368
+ add("run.json: %s=%s not in %s", e.field, Repr(rj[e.field]), Repr(keys))
369
+ }
370
+ }
371
+ repo := str(rj, "repo_root")
372
+ if !isDir(repo) {
373
+ add("run.json: repo_root is not a directory")
374
+ }
375
+ ev := ovio.List(R["evidence.json"]["items"])
376
+ evIDs := uniq(ev, idKey, "evidence", add)
377
+ for _, it := range ev {
378
+ e := ovio.Obj(it)
379
+ p := str(e, "path")
380
+ if p == "" || !inside(run, p) || !isFile(join(run, p)) {
381
+ add("evidence %s: path missing or outside run dir", PyStr(e["id"]))
382
+ continue
383
+ }
384
+ d, err := sha(join(run, p))
385
+ if err != nil {
386
+ return nil, err
387
+ }
388
+ if !eq(d, e["sha256"]) {
389
+ add("evidence %s: sha256 missing or mismatched", PyStr(e["id"]))
390
+ }
391
+ }
392
+ att := ovio.List(R["dispatch.json"]["attempts"])
393
+ vr := map[string]bool{}
394
+ for _, it := range att {
395
+ x := ovio.Obj(it)
396
+ if str(x, "role") == "verifier" && str(x, "status") == "admitted" {
397
+ if r, ok := x["receipt"].(string); ok {
398
+ vr[r] = true
399
+ }
400
+ vr[PyStr(x["task_id"])+"#"+PyStr(x["attempt_id"])] = true
401
+ }
402
+ }
403
+ fs := ovio.List(R["findings.json"]["findings"])
404
+ fIDs := uniq(fs, idKey, "finding", add)
405
+ var fps []any
406
+ for _, f := range fs {
407
+ if ovio.Truthy(ovio.Obj(f)["fingerprint"]) {
408
+ fps = append(fps, f)
409
+ }
410
+ }
411
+ uniq(fps, func(m map[string]any) string { return Repr(m["fingerprint"]) }, "finding fingerprint", add)
412
+ for _, it := range fs {
413
+ f := ovio.Obj(it)
414
+ fid, hist, status := PyStr(f["id"]), ovio.List(f["history"]), str(f, "status")
415
+ if !kinds[str(f, "kind")] || !origins[str(f, "scope_origin")] {
416
+ add("finding %s: bad kind or scope_origin", fid)
417
+ }
418
+ if len(hist) == 0 || str(ovio.Obj(hist[0]), "status") != "candidate" || !eq(ovio.Obj(hist[len(hist)-1])["status"], f["status"]) {
419
+ add("finding %s: history must start at candidate and end at the status", fid)
420
+ }
421
+ for i := 1; i < len(hist); i++ {
422
+ a, b := ovio.Obj(hist[i-1]), ovio.Obj(hist[i])
423
+ if eq(b["status"], a["status"]) && ovio.Truthy(b["reason"]) {
424
+ continue // re-assessment entry (severity or kind change)
425
+ }
426
+ if !moves[str(a, "status")][str(b, "status")] {
427
+ add("finding %s: illegal transition %s -> %s", fid, PyStr(a["status"]), PyStr(b["status"]))
428
+ }
429
+ }
430
+ if f["severity"] != nil && (!severities[str(f, "severity")] || !severityOK[status]) {
431
+ add("finding %s: severity only for established impact", fid)
432
+ }
433
+ if set("confirmed", "verified", "rejected")[status] && !ovio.Truthy(f["evidence"]) {
434
+ add("finding %s: %s needs evidence", fid, status)
435
+ }
436
+ if vb, ok := f["verified_by"].(string); status == "verified" && !(ok && vr[vb]) {
437
+ add("finding %s: verified_by must name an admitted verifier attempt", fid)
438
+ }
439
+ if status == "rejected" && (len(hist) == 0 || !ovio.Truthy(ovio.Obj(hist[len(hist)-1])["reason"])) {
440
+ add("finding %s: rejection needs a reason", fid)
441
+ }
442
+ if status == "needs-validation" && !ovio.Truthy(f["blockers"]) {
443
+ add("finding %s: needs-validation must name the missing fact (blockers)", fid)
444
+ }
445
+ for _, l := range ovio.List(f["locations"]) {
446
+ loc := ovio.Obj(l)
447
+ lp := PyStr(loc["path"])
448
+ var bad bool
449
+ if ovio.Truthy(loc["external"]) {
450
+ bad = path.IsAbs(lp) || filepath.IsAbs(lp) || strings.HasPrefix(lp, "~")
451
+ } else {
452
+ bad = BadPath(lp, repo) != ""
453
+ }
454
+ if bad {
455
+ add("finding %s: location %s is not a repository-relative path or label", fid, Repr(lp))
456
+ }
457
+ }
458
+ for _, ref := range ovio.List(f["evidence"]) {
459
+ if !evIDs[Repr(ref)] {
460
+ add("finding %s: unknown evidence %s", fid, PyStr(ref))
461
+ }
462
+ }
463
+ for _, ref := range ovio.List(f["related"]) {
464
+ if !fIDs[Repr(ref)] {
465
+ add("finding %s: unknown related finding %s", fid, PyStr(ref))
466
+ }
467
+ }
468
+ }
469
+ var plan map[string]any
470
+ tasks := map[string]map[string]any{}
471
+ revs := ovio.Obj(rj["revisions"])
472
+ pr := ovio.Obj(revs["plan"])
473
+ hasPR := len(pr) > 0
474
+ if hasPR && !eq(pr["file"], "revisions/plan-r"+PyStr(pr["rev"])+".json") {
475
+ add("run.json: revisions.plan.file must be revisions/plan-r<rev>.json")
476
+ } else if hasPR {
477
+ var err error
478
+ if plan, err = ovio.LoadObject(join(run, str(pr, "file"))); err != nil {
479
+ return nil, err
480
+ }
481
+ if !eq(plan["rev"], pr["rev"]) {
482
+ add("plan revision number mismatch")
483
+ }
484
+ var controls vset
485
+ rub := ovio.Obj(plan["rubric"])
486
+ if rf := str(rub, "file"); rf == "" {
487
+ add("plan: rubric digest mismatch")
488
+ } else if d, err := sha(join(run, rf)); err != nil {
489
+ return nil, err
490
+ } else if !eq(d, rub["sha256"]) {
491
+ add("plan: rubric digest mismatch")
492
+ } else {
493
+ doc, err := ovio.LoadObject(join(run, rf))
494
+ if err != nil {
495
+ return nil, err
496
+ }
497
+ controls = vset{}
498
+ for _, c := range ovio.List(doc["controls"]) {
499
+ controls.add(ovio.Obj(c)["id"])
500
+ }
501
+ }
502
+ for _, r := range ovio.List(plan["pinned"]) {
503
+ ref := ovio.Obj(r)
504
+ if _, ok := ref["file"]; !ok {
505
+ return nil, keyError("file")
506
+ }
507
+ d, err := sha(join(run, str(ref, "file")))
508
+ if err != nil {
509
+ return nil, err
510
+ }
511
+ if !eq(d, ref["sha256"]) {
512
+ add("plan: pinned %s digest mismatch", PyStr(ref["file"]))
513
+ }
514
+ }
515
+ tl := ovio.List(plan["tasks"])
516
+ uniq(tl, idKey, "task", add)
517
+ var order []any // first-insertion order, like a Python dict
518
+ for _, it := range tl {
519
+ t := ovio.Obj(it)
520
+ if _, ok := tasks[Repr(t["id"])]; !ok {
521
+ order = append(order, t["id"])
522
+ }
523
+ tasks[Repr(t["id"])] = t
524
+ }
525
+ for _, tid := range order {
526
+ t, ts := tasks[Repr(tid)], PyStr(tid)
527
+ for _, d := range ovio.List(t["deps"]) {
528
+ if _, ok := tasks[Repr(d)]; !ok {
529
+ add("task %s: unknown dependency %s", ts, PyStr(d))
530
+ }
531
+ }
532
+ for _, ref := range ovio.List(t["findings"]) {
533
+ if !fIDs[Repr(ref)] {
534
+ add("task %s: unknown finding %s", ts, PyStr(ref))
535
+ }
536
+ }
537
+ if controls != nil {
538
+ for _, c := range ovio.List(t["controls"]) {
539
+ if !controls.has(c) {
540
+ add("task %s: unknown control ids", ts)
541
+ break
542
+ }
543
+ }
544
+ }
545
+ for _, p := range ovio.List(t["allowed_paths"]) {
546
+ if why := BadPath(p, repo); why != "" {
547
+ add("task %s: allowed path %s %s", ts, Repr(p), why)
548
+ }
549
+ }
550
+ if !ovio.Truthy(t["oracle"]) || !ovio.Truthy(t["verifier_role"]) || eq(t["verifier_role"], t["writer_role"]) {
551
+ add("task %s: needs an oracle and a separate verifier", ts)
552
+ }
553
+ }
554
+ if cyclic(tasks) {
555
+ add("plan: task dependencies contain a cycle")
556
+ }
557
+ }
558
+ rr, scp := ovio.Obj(revs["rubric"]), join(genDir, "scorecard.json")
559
+ if isFile(scp) {
560
+ if rf := str(rr, "file"); !(rf != "" && inside(run, rf) && isFile(join(run, rf))) {
561
+ add("run.json: a scorecard needs revisions.rubric naming its rubric file")
562
+ } else if ovio.Truthy(rr["sha256"]) {
563
+ d, err := sha(join(run, rf))
564
+ if err != nil {
565
+ return nil, err
566
+ }
567
+ if !eq(d, rr["sha256"]) {
568
+ add("run.json: revisions.rubric digest mismatch")
569
+ }
570
+ }
571
+ sc, err := ovio.LoadObject(scp)
572
+ if err != nil {
573
+ return nil, err
574
+ }
575
+ if v := sc["readiness_verdict"]; v != nil && !eq(v, rj["readiness_verdict"]) {
576
+ add("run.json: readiness_verdict differs from scorecard.json")
577
+ }
578
+ }
579
+ ap := ovio.List(R["approval.json"]["events"])
580
+ uniq(ap, idKey, "approval", add)
581
+ stopAt := -1
582
+ for i, it := range ap {
583
+ if k := str(ovio.Obj(it), "kind"); k == "stop" || k == "revoke" {
584
+ stopAt = i
585
+ }
586
+ }
587
+ var active, approvals []map[string]any
588
+ granted := map[string]vset{}
589
+ for i, it := range ap {
590
+ a := ovio.Obj(it)
591
+ if str(a, "kind") != "approve" {
592
+ continue
593
+ }
594
+ approvals = append(approvals, a)
595
+ granted[Repr(a["id"])] = setOf(ovio.List(a["tasks"]))
596
+ if str(a, "status") == "active" {
597
+ if i < stopAt {
598
+ add("approval %s: still active after a later stop/revoke", PyStr(a["id"]))
599
+ } else if i > stopAt {
600
+ active = append(active, a)
601
+ }
602
+ }
603
+ }
604
+ anyWriter := false
605
+ for _, it := range att {
606
+ anyWriter = anyWriter || writers[str(ovio.Obj(it), "role")]
607
+ }
608
+ if len(active) > 1 {
609
+ add("approval: more than one active approval")
610
+ }
611
+ if !hasPR && (len(granted) > 0 || anyWriter) {
612
+ add("run.json: approvals and writers need revisions.plan")
613
+ }
614
+ for _, it := range ap {
615
+ a := ovio.Obj(it)
616
+ kind, aid := str(a, "kind"), PyStr(a["id"])
617
+ if (kind == "approve" || kind == "accept-degradation") && (!ovio.Truthy(a["quote"]) || !channels[str(a, "channel")]) {
618
+ add("approval %s: needs the user's verbatim decision and a trusted channel", aid)
619
+ }
620
+ if kind != "approve" {
621
+ continue
622
+ }
623
+ pf := join(run, "revisions", "plan-r"+PyStr(a["plan_rev"])+".json")
624
+ ok := isFile(pf)
625
+ if ok {
626
+ d, err := sha(pf)
627
+ if err != nil {
628
+ return nil, err
629
+ }
630
+ ok = eq(d, a["plan_digest"])
631
+ }
632
+ if !ok {
633
+ add("approval %s: plan digest does not match plan revision %s", aid, PyStr(a["plan_rev"]))
634
+ }
635
+ if str(a, "status") == "active" && plan != nil {
636
+ if !eq(a["plan_rev"], plan["rev"]) {
637
+ add("approval %s: approves a stale plan revision", aid)
638
+ }
639
+ picked := ovio.List(a["tasks"])
640
+ missing, unknown := closure(tasks, picked), vset{}
641
+ for _, t := range picked {
642
+ if _, ok := tasks[Repr(t)]; !ok {
643
+ unknown.add(t)
644
+ }
645
+ }
646
+ if len(unknown) > 0 {
647
+ add("approval %s: unknown tasks %s", aid, Repr(unknown.sorted()))
648
+ }
649
+ if len(missing) > 0 {
650
+ add("approval %s: selected tasks miss prerequisites %s", aid, Repr(missing.sorted()))
651
+ }
652
+ }
653
+ }
654
+ assessmentOnly := ovio.Truthy(rj["assessment_only"])
655
+ if assessmentOnly && len(active) > 0 {
656
+ add("assessment-only run carries an active approval")
657
+ }
658
+ uniq(att, func(x map[string]any) string {
659
+ return "(" + Repr(x["task_id"]) + ", " + Repr(x["attempt_id"]) + ")"
660
+ }, "attempt", add)
661
+ approved := vset{}
662
+ if len(active) > 0 {
663
+ approved = setOf(ovio.List(active[0]["tasks"]))
664
+ }
665
+ liveWrites := map[string]string{}
666
+ var unsettled []any
667
+ for _, it := range att {
668
+ x := ovio.Obj(it)
669
+ xid, status := PyStr(x["task_id"])+"#"+PyStr(x["attempt_id"]), str(x, "status")
670
+ if !attemptState[status] {
671
+ add("attempt %s: bad status", xid)
672
+ }
673
+ if set("returned", "admitted", "rejected")[status] && !(str(x, "receipt") != "" && isFile(join(run, str(x, "receipt")))) {
674
+ add("attempt %s: receipt file missing", xid)
675
+ }
676
+ if set("dispatched", "running", "returned")[status] {
677
+ unsettled = append(unsettled, xid)
678
+ }
679
+ if !writers[str(x, "role")] {
680
+ continue
681
+ }
682
+ if assessmentOnly {
683
+ add("attempt %s: writer dispatched in an assessment-only run", xid)
684
+ }
685
+ if (status == "dispatched" || status == "running") && !approved.has(x["task_id"]) {
686
+ add("attempt %s: writer task is not in the active approval", xid)
687
+ }
688
+ if !granted[Repr(x["authorized_by"])].has(x["task_id"]) {
689
+ add("attempt %s: authorized_by does not name an approval of this task", xid)
690
+ }
691
+ allowed := setOf(ovio.List(tasks[Repr(x["task_id"])]["allowed_paths"]))
692
+ extra := vset{}
693
+ for _, p := range ovio.List(x["changed_paths"]) {
694
+ if !allowed.has(p) {
695
+ extra.add(p)
696
+ }
697
+ }
698
+ if status == "admitted" && len(extra) > 0 {
699
+ add("attempt %s: admitted changes outside allowed paths %s", xid, Repr(extra.sorted()))
700
+ }
701
+ if live[status] {
702
+ for _, p := range ovio.List(x["write_paths"]) {
703
+ k := strings.ToLower(realpath(join(repo, ovio.Str(p))))
704
+ if prev, ok := liveWrites[k]; ok {
705
+ add("single writer: %s and %s both hold %s", prev, xid, PyStr(p))
706
+ }
707
+ liveWrites[k] = xid
708
+ }
709
+ }
710
+ }
711
+ outcome := str(rj, "execution_outcome")
712
+ if outcome != "RUNNING" && len(unsettled) > 0 {
713
+ add("attempts must be admitted, rejected or cancelled before the run leaves RUNNING: %s", Repr(unsettled[:min(5, len(unsettled))]))
714
+ }
715
+ if outcome != "RUNNING" && outcome != "AWAITING_APPROVAL" && len(approvals) > 0 && !assessmentOnly {
716
+ outs := ovio.Obj(rj["task_outcomes"])
717
+ var missing []any
718
+ for _, t := range ovio.List(approvals[len(approvals)-1]["tasks"]) {
719
+ var o any
720
+ if s, ok := t.(string); ok {
721
+ o = outs[s]
722
+ }
723
+ if !taskOutcomes[ovio.Str(o)] {
724
+ missing = append(missing, t)
725
+ }
726
+ }
727
+ if len(missing) > 0 {
728
+ add("approved tasks without exactly one recorded outcome: %s", Repr(missing))
729
+ }
730
+ }
731
+ cov := ovio.List(R["coverage.json"]["files"])
732
+ uniq(cov, func(c map[string]any) string { return Repr(c["path"]) }, "coverage", add)
733
+ for _, it := range cov {
734
+ c := ovio.Obj(it)
735
+ if !ovio.Truthy(c["eligible"]) && !ovio.Truthy(ovio.Get(c, "exclusion.reason")) {
736
+ add("coverage %s: ineligible without an exclusion reason", PyStr(c["path"]))
737
+ }
738
+ }
739
+ if prevDir != "" {
740
+ prev := map[string]map[string]any{}
741
+ for _, n := range []string{"cycles.json", "findings.json", "approval.json", "run.json", "coverage.json"} {
742
+ m, err := ovio.LoadObject(join(prevDir, n))
743
+ if err != nil {
744
+ return nil, err
745
+ }
746
+ prev[n] = m
747
+ }
748
+ for _, k := range []string{"run_id", "repo_root", "mode", "assessment_only"} {
749
+ if !eq(prev["run.json"][k], rj[k]) {
750
+ add("run.json: %s changed; start a new run instead", k)
751
+ }
752
+ }
753
+ if !prefixEq(ovio.List(R["cycles.json"]["cycles"]), ovio.List(prev["cycles.json"]["cycles"])) {
754
+ add("cycles.json is append-only: an earlier cycle changed or disappeared")
755
+ }
756
+ cur := map[string]map[string]any{}
757
+ for _, it := range fs {
758
+ f := ovio.Obj(it)
759
+ cur[Repr(f["id"])] = f
760
+ }
761
+ for _, it := range ovio.List(prev["findings.json"]["findings"]) {
762
+ f := ovio.Obj(it)
763
+ g, ok := cur[Repr(f["id"])]
764
+ fh, isList := f["history"].([]any)
765
+ gh := ovio.List(g["history"])
766
+ if !ok || !isList || !prefixEq(gh, fh) {
767
+ add("finding %s: removed or history rewritten", PyStr(f["id"]))
768
+ } else if (!eq(g["severity"], f["severity"]) || !eq(g["kind"], f["kind"])) && len(gh) == len(fh) {
769
+ add("finding %s: severity or kind changed without a history entry", PyStr(f["id"]))
770
+ }
771
+ }
772
+ kept := vset{}
773
+ for _, c := range cov {
774
+ kept.add(ovio.Obj(c)["path"])
775
+ }
776
+ var gone []any
777
+ for _, c := range ovio.List(prev["coverage.json"]["files"]) {
778
+ if p := ovio.Obj(c)["path"]; !kept.has(p) {
779
+ gone = append(gone, p)
780
+ }
781
+ }
782
+ if len(gone) > 0 {
783
+ add("coverage denominator shrank: %s (exclude with a reason instead)", Repr(gone[:min(5, len(gone))]))
784
+ }
785
+ oldap := ovio.List(prev["approval.json"]["events"])
786
+ same := len(ap) >= len(oldap)
787
+ for i := 0; same && i < len(oldap); i++ {
788
+ same = eq(noStatus(ap[i]), noStatus(oldap[i]))
789
+ }
790
+ if !same {
791
+ add("approval.json events are append-only")
792
+ }
793
+ }
794
+ gen := strings.ReplaceAll(filepath.Base(strings.TrimRight(genDir, "/")), ".tmp", "")
795
+ planStamp := "none"
796
+ if hasPR {
797
+ if _, ok := pr["file"]; !ok {
798
+ return nil, keyError("file")
799
+ }
800
+ d, err := sha(join(run, str(pr, "file")))
801
+ if err != nil {
802
+ return nil, err
803
+ }
804
+ planStamp = "r" + PyStr(pr["rev"]) + ":" + d
805
+ }
806
+ stamp := fmt.Sprintf("overhaul-stamp run=%s generation=%s plan=%s", PyStr(rj["run_id"]), gen, planStamp)
807
+ vdir := join(genDir, "views")
808
+ if isDir(vdir) {
809
+ entries, err := os.ReadDir(vdir)
810
+ if err != nil {
811
+ return nil, err
812
+ }
813
+ for _, v := range entries {
814
+ b, err := os.ReadFile(join(vdir, v.Name())) // #nosec G703 -- view file listed from the generation's own views directory
815
+ if err != nil {
816
+ return nil, err
817
+ }
818
+ if !bytes.Contains(b, []byte(stamp)) {
819
+ add("view %s: stamp does not match this generation (%s)", v.Name(), stamp)
820
+ }
821
+ }
822
+ }
823
+ return errs, nil
824
+ }
825
+
826
+ // noStatus is dict(a, status=None): the event with its mutable status blanked.
827
+ func noStatus(v any) map[string]any {
828
+ m := map[string]any{}
829
+ for k, x := range ovio.Obj(v) {
830
+ m[k] = x
831
+ }
832
+ m["status"] = nil
833
+ return m
834
+ }