devrites 7.3.0 → 7.3.2

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 (153) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +41 -3
  5. package/docs/release.md +5 -2
  6. package/engine/Makefile +2 -2
  7. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  8. package/engine/internal/acceptance/preconditions.go +17 -11
  9. package/engine/internal/acceptance/transcript.go +23 -4
  10. package/engine/internal/gate/gate.go +8 -6
  11. package/engine/internal/gate/readiness_binding.go +54 -24
  12. package/engine/internal/install/apply.go +36 -21
  13. package/engine/internal/install/binary.go +67 -9
  14. package/engine/internal/install/install.go +63 -21
  15. package/engine/internal/install/manifest.go +4 -5
  16. package/engine/internal/install/merge.go +89 -33
  17. package/engine/internal/install/preflight.go +60 -19
  18. package/engine/internal/install/uninstall.go +19 -9
  19. package/engine/internal/install/update.go +0 -4
  20. package/engine/internal/lib/claims.go +170 -4
  21. package/engine/internal/lib/claims_holder.go +13 -16
  22. package/engine/internal/lib/closeout.go +52 -28
  23. package/engine/internal/lib/context_io.go +88 -4
  24. package/engine/internal/lib/continuation.go +111 -24
  25. package/engine/internal/lib/dispatch.go +157 -24
  26. package/engine/internal/lib/final_prerequisites.go +137 -0
  27. package/engine/internal/lib/manifest_union.go +62 -25
  28. package/engine/internal/lib/next.go +10 -10
  29. package/engine/internal/lib/packet.go +32 -8
  30. package/engine/internal/lib/packet_integrity.go +194 -2
  31. package/engine/internal/lib/resolve.go +128 -142
  32. package/engine/internal/lib/treebaseline.go +0 -1
  33. package/engine/internal/markdowntext/markdowntext.go +110 -31
  34. package/engine/internal/overhaul/records/check.go +45 -5
  35. package/engine/internal/overhaul/records/consistency.go +34 -8
  36. package/engine/internal/overhaul/records/run.go +55 -11
  37. package/engine/internal/parallel/landing.go +17 -0
  38. package/engine/internal/state/acceptance.go +35 -5
  39. package/engine/internal/state/feature.go +3 -2
  40. package/engine/internal/state/observation.go +50 -3
  41. package/engine/internal/state/write.go +20 -44
  42. package/engine/internal/workflow/commands.go +25 -3
  43. package/engine/main.go +19 -0
  44. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  45. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  46. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  47. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  48. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  49. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  50. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  51. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  52. package/pack/.claude/skills/rite-autocomplete/SKILL.md +9 -10
  53. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +8 -1
  54. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  55. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  56. package/pack/.claude/skills/rite-prove/SKILL.md +15 -7
  57. package/pack/.claude/skills/rite-review/SKILL.md +7 -0
  58. package/pack/.claude/skills/rite-vet/SKILL.md +10 -1
  59. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  60. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  61. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  62. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  63. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  64. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  65. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  66. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  67. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +9 -10
  68. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +8 -1
  69. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/claude/skills/rite-prove/SKILL.md +15 -7
  72. package/pack/generated/claude/skills/rite-review/SKILL.md +7 -0
  73. package/pack/generated/claude/skills/rite-vet/SKILL.md +10 -1
  74. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  75. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  76. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  77. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  78. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  79. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  80. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  81. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  82. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +9 -10
  83. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +8 -1
  84. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  85. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  86. package/pack/generated/codex/skills/rite-prove/SKILL.md +15 -7
  87. package/pack/generated/codex/skills/rite-review/SKILL.md +7 -0
  88. package/pack/generated/codex/skills/rite-vet/SKILL.md +10 -1
  89. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  90. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  91. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +9 -10
  98. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +8 -1
  99. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  100. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  101. package/pack/generated/devin/skills/rite-prove/SKILL.md +15 -7
  102. package/pack/generated/devin/skills/rite-review/SKILL.md +7 -0
  103. package/pack/generated/devin/skills/rite-vet/SKILL.md +10 -1
  104. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  105. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  106. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  107. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  108. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  109. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  110. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  111. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  112. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  113. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +9 -10
  114. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +8 -1
  115. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  116. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  117. package/pack/generated/dsh/skills/rite-prove/SKILL.md +15 -7
  118. package/pack/generated/dsh/skills/rite-review/SKILL.md +7 -0
  119. package/pack/generated/dsh/skills/rite-vet/SKILL.md +10 -1
  120. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  121. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  122. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  123. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  124. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  125. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  126. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  127. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  128. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +9 -10
  129. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +8 -1
  130. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  131. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  132. package/pack/generated/omp/skills/rite-prove/SKILL.md +15 -7
  133. package/pack/generated/omp/skills/rite-review/SKILL.md +7 -0
  134. package/pack/generated/omp/skills/rite-vet/SKILL.md +10 -1
  135. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  136. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  137. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  138. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  139. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  140. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  141. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  142. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  143. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +9 -10
  144. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +8 -1
  145. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  146. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  147. package/pack/generated/pi/skills/rite-prove/SKILL.md +15 -7
  148. package/pack/generated/pi/skills/rite-review/SKILL.md +7 -0
  149. package/pack/generated/pi/skills/rite-vet/SKILL.md +10 -1
  150. package/package.json +1 -1
  151. package/scripts/dsh-generate.py +1 -1
  152. package/scripts/install-lib.sh +29 -0
  153. package/uninstall.sh +1 -0
@@ -1,6 +1,7 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "fmt"
5
6
  "io"
6
7
  "os"
@@ -67,7 +68,7 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
67
68
  mode = "answer"
68
69
  qid = first
69
70
  payload = argAt(args, 1)
70
- if payload == "" {
71
+ if strings.TrimSpace(payload) == "" {
71
72
  return fail(stderr, "Answer text required for "+qid, 5)
72
73
  }
73
74
  }
@@ -79,7 +80,7 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
79
80
 
80
81
  code := 0
81
82
  if err := state.WithFeatureLock(root, slug, func() error {
82
- code = resolveMutation(mode, qid, payload, qfile, sfile, human, stdout, stderr)
83
+ code = resolveMutation(slug, mode, qid, payload, qfile, sfile, human, stdout, stderr)
83
84
  return nil
84
85
  }); err != nil {
85
86
  return fail(stderr, err.Error(), 1)
@@ -87,38 +88,20 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
87
88
  return code
88
89
  }
89
90
 
90
- func resolveMutation(mode, qid, payload, qfile, sfile string, human bool, stdout, stderr io.Writer) int {
91
+ func resolveMutation(slug, mode, qid, payload, qfile, sfile string, human bool, stdout, stderr io.Writer) int {
92
+ type resolution struct{ qid, status, text string }
93
+ var entries []resolution
91
94
  switch mode {
92
95
  case "answer":
93
- if code := resolveQuestion(qfile, qid, "answered", payload, human, stderr); code != 0 {
94
- return code
95
- }
96
- if err := clearAwaiting(sfile, qid); err != nil {
97
- return fail(stderr, err.Error(), 1)
98
- }
99
- fmt.Fprintf(stdout, "Resolved: %s\n", qid)
100
- fmt.Fprintf(stdout, "Status: answered\n")
101
- fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
96
+ entries = append(entries, resolution{qid, "answered", payload})
102
97
  case "drop":
103
- if code := resolveQuestion(qfile, qid, "dropped", payload, human, stderr); code != 0 {
104
- return code
105
- }
106
- if err := clearAwaiting(sfile, qid); err != nil {
107
- return fail(stderr, err.Error(), 1)
108
- }
109
- fmt.Fprintf(stdout, "Dropped: %s\n", qid)
110
- fmt.Fprintf(stdout, "Reason: %s\n", payload)
111
- fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
98
+ entries = append(entries, resolution{qid, "dropped", payload})
112
99
  case "batch":
113
100
  // #nosec G304 -- operator-passed --batch file argument
114
101
  data, err := os.ReadFile(payload)
115
102
  if err != nil {
116
103
  return fail(stderr, "Batch file not found: "+payload, 5)
117
104
  }
118
- // Every line is parsed and applied in memory first, so a bad line
119
- // leaves questions.md untouched instead of half-resolved.
120
- type batchEntry struct{ qid, status, text string }
121
- var entries []batchEntry
122
105
  for _, line := range batchLines(data) {
123
106
  line = strings.TrimSpace(line)
124
107
  if line == "" || strings.HasPrefix(line, "#") {
@@ -133,27 +116,56 @@ func resolveMutation(mode, qid, payload, qfile, sfile string, human bool, stdout
133
116
  if !ok || bid == "" || strings.HasPrefix(bid, "-") {
134
117
  return fail(stderr, "Malformed batch line (want `<qid>: <answer>` or `--drop <qid>: <reason>`): "+line, 5)
135
118
  }
136
- entries = append(entries, batchEntry{bid, status, strings.TrimPrefix(text, " ")})
137
- }
138
- // #nosec G304 -- questions.md path inside the feature workspace
139
- qdata, err := os.ReadFile(qfile)
140
- if err != nil {
141
- return fail(stderr, "questions.md missing at "+qfile, 2)
142
- }
143
- lines := splitLinesNoTrailing(qdata)
144
- for _, entry := range entries {
145
- var code int
146
- if lines, code = applyResolution(lines, entry.qid, entry.status, entry.text, human, stderr); code != 0 {
147
- return code
119
+ if status == "answered" && strings.TrimSpace(text) == "" {
120
+ return fail(stderr, "Answer text required for "+bid, 5)
148
121
  }
122
+ entries = append(entries, resolution{bid, status, strings.TrimPrefix(text, " ")})
123
+ }
124
+ }
125
+ if len(entries) == 0 {
126
+ return 0
127
+ }
128
+ // #nosec G304 -- questions.md path inside the feature workspace
129
+ qdata, err := os.ReadFile(qfile)
130
+ if err != nil {
131
+ return fail(stderr, "questions.md missing at "+qfile, 2)
132
+ }
133
+ // #nosec G304 -- state.md path inside the feature workspace
134
+ sdata, err := os.ReadFile(sfile)
135
+ if err != nil {
136
+ return fail(stderr, fmt.Sprintf("read state %s: %v", sfile, err), 1)
137
+ }
138
+ lines := splitLinesNoTrailing(qdata)
139
+ nextState := sdata
140
+ // Prepare both artifacts for every entry before publishing either file.
141
+ for _, entry := range entries {
142
+ var code int
143
+ if lines, code = applyResolution(lines, entry.qid, entry.status, entry.text, human, stderr); code != 0 {
144
+ return code
149
145
  }
150
- if err := fsutil.WriteFileAtomic(qfile, joinRecords(lines), 0o644); err != nil {
146
+ if nextState, err = clearAwaiting(nextState, entry.qid, slug); err != nil {
151
147
  return fail(stderr, err.Error(), 1)
152
148
  }
149
+ }
150
+ if err := fsutil.WriteFileAtomic(qfile, joinRecords(lines), 0o644); err != nil {
151
+ return fail(stderr, err.Error(), 1)
152
+ }
153
+ if !bytes.Equal(nextState, sdata) {
154
+ if err := fsutil.WriteFileAtomic(sfile, nextState, 0o644); err != nil {
155
+ return fail(stderr, fmt.Sprintf("update state %s: %v", sfile, err), 1)
156
+ }
157
+ }
158
+ switch mode {
159
+ case "answer":
160
+ fmt.Fprintf(stdout, "Resolved: %s\n", qid)
161
+ fmt.Fprintf(stdout, "Status: answered\n")
162
+ fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
163
+ case "drop":
164
+ fmt.Fprintf(stdout, "Dropped: %s\n", qid)
165
+ fmt.Fprintf(stdout, "Reason: %s\n", payload)
166
+ fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
167
+ case "batch":
153
168
  for _, entry := range entries {
154
- if err := clearAwaiting(sfile, entry.qid); err != nil {
155
- return fail(stderr, err.Error(), 1)
156
- }
157
169
  if entry.status == "dropped" {
158
170
  fmt.Fprintf(stdout, "Dropped: %s: %s\n", entry.qid, entry.text)
159
171
  } else {
@@ -214,29 +226,6 @@ func clockNow() time.Time {
214
226
  // nowUTC returns the current time as an ISO-8601 UTC timestamp.
215
227
  func nowUTC() string { return clockNow().UTC().Format("2006-01-02T15:04:05Z") }
216
228
 
217
- // resolveQuestion rewrites the <qid> block in questions.md so it records the new
218
- // status, answer, and answered-at time in one pass. With human set it also records
219
- // answered_by: human; without it any answered_by line in the block, whatever its
220
- // indentation or case, is removed, so
221
- // an identity written before the resolve cannot survive it. Returns 0, or 3 (qid
222
- // not found) / 4 (qid not open), writing the message on failure.
223
- func resolveQuestion(qfile, qid, status, answer string, human bool, stderr io.Writer) int {
224
- // #nosec G304 -- questions.md path inside the feature workspace
225
- data, err := os.ReadFile(qfile)
226
- if err != nil {
227
- return fail(stderr, "questions.md missing at "+qfile, 2)
228
- }
229
- updated, code := applyResolution(splitLinesNoTrailing(data), qid, status, answer, human, stderr)
230
- if code != 0 {
231
- return code
232
- }
233
- if err := fsutil.WriteFileAtomic(qfile, joinRecords(updated), 0o644); err != nil {
234
- return fail(stderr, err.Error(), 1)
235
- }
236
-
237
- return 0
238
- }
239
-
240
229
  // applyResolution rewrites the <qid> block in the questions.md lines in memory.
241
230
  // Returns 0, or 3 (qid not found) / 4 (qid not open), writing the message on
242
231
  // failure.
@@ -350,100 +339,97 @@ var (
350
339
  logHdrRe = regexp.MustCompile(`^## Log`)
351
340
  )
352
341
 
353
- // clearAwaiting resumes the workflow after a question is resolved: when state.md's
354
- // "Awaiting human" block or cursor question_id references qid, it drops that block, flips Status back to
355
- // running, clears the Next step, and appends a Log entry. It is a no-op when the
356
- // workspace is not waiting on this question.
357
- func clearAwaiting(sfile, qid string) error {
358
- // #nosec G304 -- state.md path inside the feature workspace
359
- data, err := os.ReadFile(sfile)
360
- if err != nil {
361
- return fmt.Errorf("read state %s: %w", sfile, err)
362
- }
342
+ // clearAwaiting removes only matching wait representations. Another question
343
+ // keeps the workspace paused; otherwise the canonical phase resumes on SOURCE.
344
+ func clearAwaiting(data []byte, qid, slug string) ([]byte, error) {
363
345
  lines := splitLinesNoTrailing(data)
364
346
  visible, err := structuralLines(data)
365
347
  if err != nil {
366
- return fmt.Errorf("read state %s: %w", sfile, err)
348
+ return nil, fmt.Errorf("read state: %w", err)
367
349
  }
368
- resumePolicy, _ := state.PolicyFor(state.PhaseBuild)
369
- if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
370
- if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
371
- if policy, known := state.PolicyFor(state.Phase(fields[0])); known && policy.ResumeVerb != "" {
372
- resumePolicy = policy
373
- }
374
- }
375
- }
376
- resumeCommand := workflow.ForVerb(resumePolicy.ResumeVerb)
377
- // First check whether the awaiting block references this question at all.
378
- inAw := false
379
- var awaitingLines []string
380
- for _, line := range visible {
381
- switch {
382
- case awaitingRe.MatchString(line):
383
- inAw = true
384
- continue
385
- case inAw && hdrSpaceRe.MatchString(line):
386
- inAw = false
387
- }
388
- if inAw {
389
- awaitingLines = append(awaitingLines, line)
390
- }
391
- }
392
- waitingOn, _ := state.CursorField(awaitingLines, state.CursorQuestionID)
393
- cursorWaits := false
394
- if waitingOn != qid {
395
- // `state set question_id=<qid>` records the wait in the cursor table
396
- // instead of an Awaiting human block.
397
- if cursorQID, _ := state.CursorField(lines, state.CursorQuestionID); cursorQID != qid {
398
- return nil
399
- }
400
- cursorWaits = true
401
- }
402
-
403
- // Rewrite: drop the awaiting block, reset Status/Next step, log the resolution.
404
- ts := nowUTC()
405
350
  var out []string
406
- inAw, inLog, logAppended := false, false, false
407
- for i, original := range lines {
351
+ matched, otherWait := false, false
352
+ inLog, logAt := false, -1
353
+ for i := 0; i < len(lines); i++ {
408
354
  line := visible[i]
409
- if awaitingRe.MatchString(line) {
410
- inAw = true
411
- continue
355
+ if inLog && hdrSpaceRe.MatchString(line) {
356
+ if logAt < 0 {
357
+ logAt = len(out)
358
+ }
359
+ inLog = false
412
360
  }
413
- if inAw && hdrSpaceRe.MatchString(line) {
414
- inAw = false
361
+ if logHdrRe.MatchString(line) {
362
+ inLog = true
415
363
  }
416
- if inAw {
364
+ if awaitingRe.MatchString(line) {
365
+ end := i + 1
366
+ for end < len(lines) && !hdrSpaceRe.MatchString(visible[end]) {
367
+ end++
368
+ }
369
+ blockMatches, blockConflicts := false, false
370
+ for j := i + 1; j < end; j++ {
371
+ if waitingOn, ok := state.CursorField(visible[j:j+1], state.CursorQuestionID); ok && waitingOn != "" {
372
+ if waitingOn == qid {
373
+ blockMatches = true
374
+ } else {
375
+ blockConflicts = true
376
+ }
377
+ }
378
+ }
379
+ if blockMatches && blockConflicts {
380
+ return nil, fmt.Errorf("state has conflicting question IDs in Awaiting human")
381
+ }
382
+ if blockMatches {
383
+ matched = true
384
+ } else {
385
+ otherWait = true
386
+ out = append(out, lines[i:end]...)
387
+ }
388
+ i = end - 1
417
389
  continue
418
390
  }
419
- switch {
420
- case logHdrRe.MatchString(line):
421
- out = append(out, original)
422
- inLog = true
423
- continue
424
- case inLog && hdrSpaceRe.MatchString(line):
425
- if !logAppended {
426
- out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
427
- logAppended = true
391
+ if waitingOn, ok := state.CursorField(visible[i:i+1], state.CursorQuestionID); ok && waitingOn != "" {
392
+ if waitingOn == qid {
393
+ matched = true
394
+ continue
428
395
  }
429
- inLog = false
430
- out = append(out, original)
431
- continue
396
+ otherWait = true
432
397
  }
433
- out = append(out, original)
398
+ out = append(out, lines[i])
399
+ }
400
+ if !matched {
401
+ return data, nil
402
+ }
403
+ rawPhase, _ := state.CursorField(lines, state.CursorPhase)
404
+ fields := strings.Fields(strings.ToLower(rawPhase))
405
+ var resumePolicy state.PhasePolicy
406
+ known := false
407
+ if len(fields) > 0 {
408
+ resumePolicy, known = state.PolicyFor(state.Phase(fields[0]))
434
409
  }
435
- if inLog && !logAppended {
436
- out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
410
+ if !known || resumePolicy.ResumeVerb == "" {
411
+ return nil, fmt.Errorf("cannot resume unknown or terminal phase %q", rawPhase)
437
412
  }
438
- if cursorWaits {
439
- out = state.DeleteCursorField(out, state.CursorQuestionID)
413
+ if inLog && logAt < 0 {
414
+ logAt = len(out)
440
415
  }
441
- out, _ = state.SetCursorField(out, state.CursorStatus, "running")
442
- out, _ = state.SetCursorField(out, state.CursorNextAction, "(resume: `"+resumeCommand.Both()+"` to continue the workflow)")
443
- if err := fsutil.WriteFileAtomic(sfile, joinRecords(out), 0o644); err != nil {
444
- return fmt.Errorf("update state %s: %w", sfile, err)
416
+ if logAt >= 0 {
417
+ out = append(out, "")
418
+ copy(out[logAt+1:], out[logAt:])
419
+ out[logAt] = fmt.Sprintf("- %s %s: resolved %s", nowUTC(), resumePolicy.Target, qid)
420
+ }
421
+ if !otherWait {
422
+ action := workflow.ForVerb(resumePolicy.ResumeVerb).SourceInvocation(slug)
423
+ out = state.UpsertCursorField(out, state.CursorStatus, "running")
424
+ out = state.UpsertCursorField(out, state.CursorNextAction, action)
425
+ if got, _ := state.CursorField(out, state.CursorStatus); got != "running" {
426
+ return nil, fmt.Errorf("cannot update state status")
427
+ }
428
+ if got, _ := state.CursorField(out, state.CursorNextAction); got != action {
429
+ return nil, fmt.Errorf("cannot update state next_action")
430
+ }
445
431
  }
446
- return nil
432
+ return joinRecords(out), nil
447
433
  }
448
434
 
449
435
  // joinRecords joins lines with newlines and terminates the last one, so a rewritten
@@ -249,7 +249,6 @@ func CheckTreeBaseline(root, slug string) error {
249
249
  return fmt.Errorf("tree baseline: %w", err)
250
250
  }
251
251
  if len(flagged) > 0 {
252
- recordUnattributed(root, slug, cursorPhase(root, slug), flagged)
253
252
  return errors.New(unattributedMessage(slug, flagged))
254
253
  }
255
254
  return nil
@@ -9,6 +9,16 @@ import (
9
9
  // Structural returns Markdown with fenced blocks replaced by spaces. Byte
10
10
  // offsets and line breaks stay aligned with the original input.
11
11
  func Structural(input []byte) ([]byte, error) {
12
+ return structural(input, false)
13
+ }
14
+
15
+ // Prose also masks HTML comment blocks, while Structural retains comments used
16
+ // by machine-readable markers. Both preserve byte offsets and line breaks.
17
+ func Prose(input []byte) ([]byte, error) {
18
+ return structural(input, true)
19
+ }
20
+
21
+ func structural(input []byte, comments bool) ([]byte, error) {
12
22
  if !utf8.Valid(input) {
13
23
  return nil, errors.New("markdown text contains malformed UTF-8")
14
24
  }
@@ -21,6 +31,8 @@ func Structural(input []byte) ([]byte, error) {
21
31
  fenceWidth := 0
22
32
  anchor, anchorStart := -1, 0
23
33
  afterBlank := true
34
+ inComment := false
35
+ lines:
24
36
  for start := 0; start < len(input); {
25
37
  lineEnd := bytes.IndexByte(input[start:], '\n')
26
38
  if lineEnd < 0 {
@@ -35,43 +47,110 @@ func Structural(input []byte) ([]byte, error) {
35
47
  line := input[start:contentEnd]
36
48
 
37
49
  if marker == 0 {
38
- if blank(line) {
39
- afterBlank = true
40
- } else if nextMarker, width, ok := openingFence(line, 3); ok {
41
- marker, fenceWidth = nextMarker, width
42
- mask(out[start:lineEnd])
43
- anchor, afterBlank = -1, false
44
- } else if nextMarker, width, ok := openingFence(line, anchor+3); ok && anchor >= 0 && indentColumns(line) >= anchor {
45
- anchored := anchor
46
- anchor, afterBlank = -1, false
47
- if closeEnd := listFenceEnd(input, lineEnd, nextMarker, width, indentColumns(line), anchored); closeEnd >= 0 {
48
- if !loneCR(input[anchorStart:closeEnd]) {
49
- mask(out[start:closeEnd])
50
+ commentsPending := comments
51
+ // Recognize fences before their literal info strings can open comments.
52
+ for pass := 0; ; pass++ {
53
+ if commentsPending && (inComment || pass > 0) {
54
+ commentsPending = false
55
+ for pos := 0; pos < len(line); {
56
+ commentStart := pos
57
+ if !inComment {
58
+ if line[pos] == '`' {
59
+ end := pos + 1
60
+ for end < len(line) && line[end] == '`' {
61
+ end++
62
+ }
63
+ width := end - pos
64
+ for next := end; next < len(line); {
65
+ tick := bytes.IndexByte(line[next:], '`')
66
+ if tick < 0 {
67
+ break
68
+ }
69
+ next += tick
70
+ close := next + 1
71
+ for close < len(line) && line[close] == '`' {
72
+ close++
73
+ }
74
+ if close-next == width {
75
+ end = close
76
+ break
77
+ }
78
+ next = close
79
+ }
80
+ pos = end
81
+ continue
82
+ }
83
+ if !bytes.HasPrefix(line[pos:], []byte("<!--")) {
84
+ pos++
85
+ continue
86
+ }
87
+ pos += len("<!--")
88
+ inComment = true
89
+ }
90
+ end := bytes.Index(line[pos:], []byte("-->"))
91
+ if end < 0 {
92
+ mask(out[start+commentStart : contentEnd])
93
+ break
94
+ }
95
+ pos += end + len("-->")
96
+ mask(out[start+commentStart : start+pos])
97
+ inComment = false
98
+ }
99
+ }
100
+ if comments && !bytes.Equal(line, out[start:contentEnd]) && blank(out[start:contentEnd]) {
101
+ anchor, afterBlank = -1, false
102
+ if lineEnd == len(input) {
103
+ break lines
104
+ }
105
+ start = lineEnd + 1
106
+ continue lines
107
+ }
108
+ line = out[start:contentEnd]
109
+ if blank(line) {
110
+ afterBlank = true
111
+ } else if nextMarker, width, ok := openingFence(line, 3); ok {
112
+ marker, fenceWidth = nextMarker, width
113
+ mask(out[start:lineEnd])
114
+ anchor, afterBlank = -1, false
115
+ } else if nextMarker, width, ok := openingFence(line, anchor+3); ok && anchor >= 0 && indentColumns(line) >= anchor {
116
+ anchored := anchor
117
+ anchor, afterBlank = -1, false
118
+ if closeEnd := listFenceEnd(input, lineEnd, nextMarker, width, indentColumns(line), anchored); closeEnd >= 0 {
119
+ if !loneCR(input[anchorStart:closeEnd]) {
120
+ mask(out[start:closeEnd])
121
+ }
122
+ if closeEnd == len(input) {
123
+ break lines
124
+ }
125
+ start = closeEnd + 1
126
+ continue lines
127
+ }
128
+ if commentsPending {
129
+ continue
130
+ }
131
+ } else if fence, closeEnd := markerLineFence(input, line, lineEnd, afterBlank || anchor >= 0); closeEnd >= 0 {
132
+ anchor, afterBlank = -1, false
133
+ if !loneCR(input[start:closeEnd]) {
134
+ mask(out[start+fence : closeEnd])
50
135
  }
51
136
  if closeEnd == len(input) {
52
- break
137
+ break lines
53
138
  }
54
139
  start = closeEnd + 1
55
- continue
56
- }
57
- } else if fence, closeEnd := markerLineFence(input, line, lineEnd, afterBlank || anchor >= 0); closeEnd >= 0 {
58
- anchor, afterBlank = -1, false
59
- if !loneCR(input[start:closeEnd]) {
60
- mask(out[start+fence : closeEnd])
61
- }
62
- if closeEnd == len(input) {
63
- break
64
- }
65
- start = closeEnd + 1
66
- continue
67
- } else {
68
- column, ok := topLevelItem(line)
69
- if ok && (afterBlank || anchor >= 0) {
70
- anchor, anchorStart = column, start
140
+ continue lines
71
141
  } else {
72
- anchor = -1
142
+ if commentsPending {
143
+ continue
144
+ }
145
+ column, ok := topLevelItem(line)
146
+ if ok && (afterBlank || anchor >= 0) {
147
+ anchor, anchorStart = column, start
148
+ } else {
149
+ anchor = -1
150
+ }
151
+ afterBlank = false
73
152
  }
74
- afterBlank = false
153
+ break
75
154
  }
76
155
  } else {
77
156
  mask(out[start:lineEnd])
@@ -575,6 +575,9 @@ func Check(run, genDir, prevDir string) ([]string, error) {
575
575
  }
576
576
  }
577
577
  }
578
+ if paths, ok := t["allowed_paths"].([]any); !ok || len(paths) == 0 {
579
+ add("task %s: allowed_paths must be a non-empty array of exact paths", ts)
580
+ }
578
581
  for _, p := range ovio.List(t["allowed_paths"]) {
579
582
  if why := BadPath(p, repo); why != "" {
580
583
  add("task %s: allowed path %s %s", ts, Repr(p), why)
@@ -724,11 +727,30 @@ func Check(run, genDir, prevDir string) ([]string, error) {
724
727
  for _, it := range att {
725
728
  x := ovio.Obj(it)
726
729
  xid, status := PyStr(x["task_id"])+"#"+PyStr(x["attempt_id"]), str(x, "status")
730
+ for _, k := range []string{"task_id", "attempt_id", "role"} {
731
+ if str(x, k) == "" {
732
+ add("attempt %s: %s must be a non-empty string", xid, k)
733
+ }
734
+ }
727
735
  if !attemptState[status] {
728
736
  add("attempt %s: bad status", xid)
729
737
  }
730
- if set("returned", "admitted", "rejected")[status] && !(str(x, "receipt") != "" && isFile(join(run, str(x, "receipt")))) {
731
- add("attempt %s: receipt file missing", xid)
738
+ if set("returned", "admitted", "rejected")[status] && !(str(x, "receipt") != "" && inside(run, str(x, "receipt")) && isFile(join(run, str(x, "receipt")))) {
739
+ add("attempt %s: receipt file missing or outside run dir", xid)
740
+ }
741
+ if status == "admitted" && inside(run, str(x, "receipt")) && isFile(join(run, str(x, "receipt"))) {
742
+ rc, err := ovio.LoadObject(join(run, str(x, "receipt")))
743
+ if err != nil {
744
+ return nil, err
745
+ }
746
+ if !eq(rc["schema"], "overhaul.receipt/1") || str(rj, "run_id") == "" || !eq(rc["run_id"], rj["run_id"]) {
747
+ add("attempt %s: admitted receipt has wrong schema or run", xid)
748
+ }
749
+ for _, k := range []string{"task_id", "attempt_id", "role", "lane", "snapshot"} {
750
+ if str(x, k) == "" || !eq(rc[k], x[k]) {
751
+ add("attempt %s: admitted receipt %s differs from dispatch or is missing", xid, k)
752
+ }
753
+ }
732
754
  }
733
755
  if !set("admitted", "rejected", "cancelled")[status] {
734
756
  unsettled = append(unsettled, xid)
@@ -739,7 +761,7 @@ func Check(run, genDir, prevDir string) ([]string, error) {
739
761
  if assessmentOnly {
740
762
  add("attempt %s: writer dispatched in an assessment-only run", xid)
741
763
  }
742
- if (status == "dispatched" || status == "running") && !approved.has(x["task_id"]) {
764
+ if live[status] && !approved.has(x["task_id"]) {
743
765
  add("attempt %s: writer task is not in the active approval", xid)
744
766
  }
745
767
  if !granted[Repr(x["authorized_by"])].has(x["task_id"]) {
@@ -756,13 +778,31 @@ func Check(run, genDir, prevDir string) ([]string, error) {
756
778
  add("attempt %s: admitted changes outside allowed paths %s", xid, Repr(extra.sorted()))
757
779
  }
758
780
  if live[status] {
759
- for _, p := range ovio.List(x["write_paths"]) {
781
+ paths, ok := tasks[Repr(x["task_id"])]["allowed_paths"].([]any)
782
+ if !ok || len(paths) == 0 {
783
+ add("attempt %s: live writer needs an authoritative task scope", xid)
784
+ }
785
+ // Reserve the approved task's whole scope, never a worker's narrower claim.
786
+ for _, p := range paths {
760
787
  k := strings.ToLower(realpath(join(repo, ovio.Str(p))))
761
- if prev, ok := liveWrites[k]; ok {
788
+ if prev, ok := liveWrites[k]; ok && prev != xid {
762
789
  add("single writer: %s and %s both hold %s", prev, xid, PyStr(p))
763
790
  }
764
791
  liveWrites[k] = xid
765
792
  }
793
+ if declared, exists := x["write_paths"]; exists {
794
+ declaredPaths, ok := declared.([]any)
795
+ if !ok {
796
+ add("attempt %s: write_paths must be an array of exact paths", xid)
797
+ }
798
+ for _, p := range declaredPaths {
799
+ if why := BadPath(p, repo); why != "" {
800
+ add("attempt %s: write path %s %s", xid, Repr(p), why)
801
+ } else if liveWrites[strings.ToLower(realpath(join(repo, ovio.Str(p))))] != xid {
802
+ add("attempt %s: write path %s is outside the authoritative task scope", xid, Repr(p))
803
+ }
804
+ }
805
+ }
766
806
  }
767
807
  }
768
808
  if outcome != "RUNNING" && len(unsettled) > 0 {