devrites 7.3.1 → 7.3.3

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 (131) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/cli.md +15 -7
  5. package/docs/engine/commands.md +37 -5
  6. package/docs/engine/workspace-schema.md +4 -3
  7. package/docs/release.md +5 -2
  8. package/engine/Makefile +2 -2
  9. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  10. package/engine/internal/acceptance/preconditions.go +17 -11
  11. package/engine/internal/acceptance/transcript.go +23 -4
  12. package/engine/internal/gate/gate.go +8 -6
  13. package/engine/internal/gate/readiness_binding.go +50 -21
  14. package/engine/internal/hostpack/hostpack.go +1 -1
  15. package/engine/internal/install/apply.go +36 -21
  16. package/engine/internal/install/binary.go +67 -9
  17. package/engine/internal/install/install.go +63 -21
  18. package/engine/internal/install/manifest.go +4 -5
  19. package/engine/internal/install/merge.go +89 -33
  20. package/engine/internal/install/preflight.go +60 -19
  21. package/engine/internal/install/uninstall.go +19 -9
  22. package/engine/internal/install/update.go +0 -4
  23. package/engine/internal/lib/apply_bundle.go +25 -8
  24. package/engine/internal/lib/claims.go +170 -4
  25. package/engine/internal/lib/claims_holder.go +13 -16
  26. package/engine/internal/lib/closeout.go +52 -28
  27. package/engine/internal/lib/context_io.go +88 -4
  28. package/engine/internal/lib/continuation.go +111 -24
  29. package/engine/internal/lib/dispatch.go +157 -24
  30. package/engine/internal/lib/final_prerequisites.go +137 -0
  31. package/engine/internal/lib/manifest_union.go +62 -25
  32. package/engine/internal/lib/next.go +10 -10
  33. package/engine/internal/lib/packet.go +32 -8
  34. package/engine/internal/lib/packet_integrity.go +194 -2
  35. package/engine/internal/lib/regression.go +0 -5
  36. package/engine/internal/lib/resolve.go +148 -145
  37. package/engine/internal/lib/treebaseline.go +0 -1
  38. package/engine/internal/markdowntext/markdowntext.go +110 -31
  39. package/engine/internal/overhaul/records/check.go +45 -5
  40. package/engine/internal/overhaul/records/consistency.go +34 -8
  41. package/engine/internal/overhaul/records/run.go +55 -11
  42. package/engine/internal/parallel/landing.go +17 -0
  43. package/engine/internal/rootfacts/facts.go +11 -0
  44. package/engine/internal/state/acceptance.go +35 -5
  45. package/engine/internal/state/feature.go +3 -2
  46. package/engine/internal/state/lock_other.go +4 -0
  47. package/engine/internal/state/observation.go +1 -1
  48. package/engine/internal/state/write.go +56 -39
  49. package/engine/internal/workflow/commands.go +25 -3
  50. package/engine/main.go +21 -2
  51. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  52. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  53. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  54. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  55. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  56. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  57. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  58. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  59. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  60. package/pack/.claude/skills/rite-doctor/SKILL.md +70 -68
  61. package/pack/generated/README.md +1 -1
  62. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  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-doctor/SKILL.md +70 -68
  72. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  73. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  74. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  75. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  76. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  77. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  78. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  79. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  80. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  81. package/pack/generated/codex/skills/rite-doctor/SKILL.md +70 -68
  82. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  83. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  84. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  85. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  86. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  87. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  88. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  89. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  90. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  91. package/pack/generated/devin/skills/rite-doctor/SKILL.md +70 -68
  92. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  93. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  94. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  95. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  96. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  97. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  98. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  99. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  100. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  101. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  102. package/pack/generated/dsh/skills/rite-doctor/SKILL.md +70 -68
  103. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  104. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
  105. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  106. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  107. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  108. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  109. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  110. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  111. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +18 -18
  112. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  113. package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
  114. package/pack/generated/pi/AGENTS.md +1 -1
  115. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  116. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  117. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  118. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  119. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  120. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  121. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  122. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  123. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  124. package/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
  125. package/package.json +1 -1
  126. package/scripts/build-host-artifacts.sh +1 -1
  127. package/scripts/dsh-generate.py +6 -2
  128. package/scripts/install-lib.sh +29 -0
  129. package/scripts/omp-generate.sh +4 -4
  130. package/scripts/pi-generate.sh +3 -3
  131. 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"
@@ -10,7 +11,7 @@ import (
10
11
  "strings"
11
12
  "time"
12
13
 
13
- "github.com/devrites/devrites/internal/fsutil"
14
+ "github.com/devrites/devrites/internal/devritespaths"
14
15
  "github.com/devrites/devrites/internal/markdowntext"
15
16
  "github.com/devrites/devrites/internal/state"
16
17
  "github.com/devrites/devrites/internal/workflow"
@@ -24,11 +25,24 @@ import (
24
25
  // 0 resolved 2 no active workspace 3 qid not found
25
26
  // 4 qid not open 5 bad arguments
26
27
  func Resolve(root string, args []string, stdout, stderr io.Writer) int {
28
+ return resolveWithLock(root, args, stdout, stderr, state.WithFeatureLockIn)
29
+ }
30
+
31
+ func resolveWithLock(root string, args []string, stdout, stderr io.Writer, withLock func(*os.Root, string, func() error) error) int {
27
32
  slug := activeSlug(root)
28
33
  if slug == "" {
29
34
  return fail(stderr, "No active workspace. Run "+workflow.ForVerb("spec").Both()+" <feature> first.", 2)
30
35
  }
31
- work := featureDir(root, slug)
36
+ work, err := devritespaths.ExistingFeatureDirChecked(root, slug)
37
+ if err != nil {
38
+ return fail(stderr, err.Error(), 2)
39
+ }
40
+ // Pin the selected root before waiting; workspace paths may change meanwhile.
41
+ selectedRoot, err := os.OpenRoot(root)
42
+ if err != nil {
43
+ return fail(stderr, err.Error(), 2)
44
+ }
45
+ defer func() { _ = selectedRoot.Close() }()
32
46
  qfile := filepath.Join(work, "questions.md")
33
47
  sfile := filepath.Join(work, "state.md")
34
48
  if !isFile(qfile) {
@@ -67,7 +81,7 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
67
81
  mode = "answer"
68
82
  qid = first
69
83
  payload = argAt(args, 1)
70
- if payload == "" {
84
+ if strings.TrimSpace(payload) == "" {
71
85
  return fail(stderr, "Answer text required for "+qid, 5)
72
86
  }
73
87
  }
@@ -78,8 +92,13 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
78
92
  }
79
93
 
80
94
  code := 0
81
- if err := state.WithFeatureLock(root, slug, func() error {
82
- code = resolveMutation(mode, qid, payload, qfile, sfile, human, stdout, stderr)
95
+ if err := withLock(selectedRoot, slug, func() error {
96
+ ws, err := selectedRoot.OpenRoot(filepath.Join("work", slug))
97
+ if err != nil {
98
+ return err
99
+ }
100
+ defer func() { _ = ws.Close() }()
101
+ code = resolveMutation(ws, slug, mode, qid, payload, human, stdout, stderr, writeBundleTargets)
83
102
  return nil
84
103
  }); err != nil {
85
104
  return fail(stderr, err.Error(), 1)
@@ -87,38 +106,20 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
87
106
  return code
88
107
  }
89
108
 
90
- func resolveMutation(mode, qid, payload, qfile, sfile string, human bool, stdout, stderr io.Writer) int {
109
+ func resolveMutation(ws *os.Root, slug, mode, qid, payload string, human bool, stdout, stderr io.Writer, publish func(*os.Root, []*bundleTarget) error) int {
110
+ type resolution struct{ qid, status, text string }
111
+ var entries []resolution
91
112
  switch mode {
92
113
  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")
114
+ entries = append(entries, resolution{qid, "answered", payload})
102
115
  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")
116
+ entries = append(entries, resolution{qid, "dropped", payload})
112
117
  case "batch":
113
118
  // #nosec G304 -- operator-passed --batch file argument
114
119
  data, err := os.ReadFile(payload)
115
120
  if err != nil {
116
121
  return fail(stderr, "Batch file not found: "+payload, 5)
117
122
  }
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
123
  for _, line := range batchLines(data) {
123
124
  line = strings.TrimSpace(line)
124
125
  if line == "" || strings.HasPrefix(line, "#") {
@@ -133,27 +134,55 @@ func resolveMutation(mode, qid, payload, qfile, sfile string, human bool, stdout
133
134
  if !ok || bid == "" || strings.HasPrefix(bid, "-") {
134
135
  return fail(stderr, "Malformed batch line (want `<qid>: <answer>` or `--drop <qid>: <reason>`): "+line, 5)
135
136
  }
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
137
+ if status == "answered" && strings.TrimSpace(text) == "" {
138
+ return fail(stderr, "Answer text required for "+bid, 5)
148
139
  }
140
+ entries = append(entries, resolution{bid, status, strings.TrimPrefix(text, " ")})
149
141
  }
150
- if err := fsutil.WriteFileAtomic(qfile, joinRecords(lines), 0o644); err != nil {
142
+ }
143
+ if len(entries) == 0 {
144
+ return 0
145
+ }
146
+ qdata, err := ws.ReadFile("questions.md")
147
+ if err != nil {
148
+ return fail(stderr, fmt.Sprintf("read questions.md: %v", err), 2)
149
+ }
150
+ sdata, err := ws.ReadFile("state.md")
151
+ if err != nil {
152
+ return fail(stderr, fmt.Sprintf("read state.md: %v", err), 1)
153
+ }
154
+ lines := splitLinesNoTrailing(qdata)
155
+ nextState := sdata
156
+ // Prepare both artifacts for every entry before publishing either file.
157
+ for _, entry := range entries {
158
+ var code int
159
+ if lines, code = applyResolution(lines, entry.qid, entry.status, entry.text, human, stderr); code != 0 {
160
+ return code
161
+ }
162
+ if nextState, err = clearAwaiting(nextState, entry.qid, slug); err != nil {
151
163
  return fail(stderr, err.Error(), 1)
152
164
  }
165
+ }
166
+ targets := []*bundleTarget{
167
+ {rel: "questions.md", before: qdata, existed: true, after: joinRecords(lines)},
168
+ }
169
+ if !bytes.Equal(nextState, sdata) {
170
+ targets = append(targets, &bundleTarget{rel: "state.md", before: sdata, existed: true, after: nextState})
171
+ }
172
+ if err := publish(ws, targets); err != nil {
173
+ return fail(stderr, err.Error(), 1)
174
+ }
175
+ switch mode {
176
+ case "answer":
177
+ fmt.Fprintf(stdout, "Resolved: %s\n", qid)
178
+ fmt.Fprintf(stdout, "Status: answered\n")
179
+ fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
180
+ case "drop":
181
+ fmt.Fprintf(stdout, "Dropped: %s\n", qid)
182
+ fmt.Fprintf(stdout, "Reason: %s\n", payload)
183
+ fmt.Fprintf(stdout, "Workspace: questions.md + state.md updated.\n")
184
+ case "batch":
153
185
  for _, entry := range entries {
154
- if err := clearAwaiting(sfile, entry.qid); err != nil {
155
- return fail(stderr, err.Error(), 1)
156
- }
157
186
  if entry.status == "dropped" {
158
187
  fmt.Fprintf(stdout, "Dropped: %s: %s\n", entry.qid, entry.text)
159
188
  } else {
@@ -214,29 +243,6 @@ func clockNow() time.Time {
214
243
  // nowUTC returns the current time as an ISO-8601 UTC timestamp.
215
244
  func nowUTC() string { return clockNow().UTC().Format("2006-01-02T15:04:05Z") }
216
245
 
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
246
  // applyResolution rewrites the <qid> block in the questions.md lines in memory.
241
247
  // Returns 0, or 3 (qid not found) / 4 (qid not open), writing the message on
242
248
  // failure.
@@ -350,100 +356,97 @@ var (
350
356
  logHdrRe = regexp.MustCompile(`^## Log`)
351
357
  )
352
358
 
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
- }
359
+ // clearAwaiting removes only matching wait representations. Another question
360
+ // keeps the workspace paused; otherwise the canonical phase resumes on SOURCE.
361
+ func clearAwaiting(data []byte, qid, slug string) ([]byte, error) {
363
362
  lines := splitLinesNoTrailing(data)
364
363
  visible, err := structuralLines(data)
365
364
  if err != nil {
366
- return fmt.Errorf("read state %s: %w", sfile, err)
365
+ return nil, fmt.Errorf("read state: %w", err)
367
366
  }
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
367
  var out []string
406
- inAw, inLog, logAppended := false, false, false
407
- for i, original := range lines {
368
+ matched, otherWait := false, false
369
+ inLog, logAt := false, -1
370
+ for i := 0; i < len(lines); i++ {
408
371
  line := visible[i]
409
- if awaitingRe.MatchString(line) {
410
- inAw = true
411
- continue
372
+ if inLog && hdrSpaceRe.MatchString(line) {
373
+ if logAt < 0 {
374
+ logAt = len(out)
375
+ }
376
+ inLog = false
412
377
  }
413
- if inAw && hdrSpaceRe.MatchString(line) {
414
- inAw = false
378
+ if logHdrRe.MatchString(line) {
379
+ inLog = true
415
380
  }
416
- if inAw {
381
+ if awaitingRe.MatchString(line) {
382
+ end := i + 1
383
+ for end < len(lines) && !hdrSpaceRe.MatchString(visible[end]) {
384
+ end++
385
+ }
386
+ blockMatches, blockConflicts := false, false
387
+ for j := i + 1; j < end; j++ {
388
+ if waitingOn, ok := state.CursorField(visible[j:j+1], state.CursorQuestionID); ok && waitingOn != "" {
389
+ if waitingOn == qid {
390
+ blockMatches = true
391
+ } else {
392
+ blockConflicts = true
393
+ }
394
+ }
395
+ }
396
+ if blockMatches && blockConflicts {
397
+ return nil, fmt.Errorf("state has conflicting question IDs in Awaiting human")
398
+ }
399
+ if blockMatches {
400
+ matched = true
401
+ } else {
402
+ otherWait = true
403
+ out = append(out, lines[i:end]...)
404
+ }
405
+ i = end - 1
417
406
  continue
418
407
  }
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
408
+ if waitingOn, ok := state.CursorField(visible[i:i+1], state.CursorQuestionID); ok && waitingOn != "" {
409
+ if waitingOn == qid {
410
+ matched = true
411
+ continue
428
412
  }
429
- inLog = false
430
- out = append(out, original)
431
- continue
413
+ otherWait = true
432
414
  }
433
- out = append(out, original)
415
+ out = append(out, lines[i])
416
+ }
417
+ if !matched {
418
+ return data, nil
419
+ }
420
+ rawPhase, _ := state.CursorField(lines, state.CursorPhase)
421
+ fields := strings.Fields(strings.ToLower(rawPhase))
422
+ var resumePolicy state.PhasePolicy
423
+ known := false
424
+ if len(fields) > 0 {
425
+ resumePolicy, known = state.PolicyFor(state.Phase(fields[0]))
434
426
  }
435
- if inLog && !logAppended {
436
- out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
427
+ if !known || resumePolicy.ResumeVerb == "" {
428
+ return nil, fmt.Errorf("cannot resume unknown or terminal phase %q", rawPhase)
437
429
  }
438
- if cursorWaits {
439
- out = state.DeleteCursorField(out, state.CursorQuestionID)
430
+ if inLog && logAt < 0 {
431
+ logAt = len(out)
440
432
  }
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)
433
+ if logAt >= 0 {
434
+ out = append(out, "")
435
+ copy(out[logAt+1:], out[logAt:])
436
+ out[logAt] = fmt.Sprintf("- %s %s: resolved %s", nowUTC(), resumePolicy.Target, qid)
437
+ }
438
+ if !otherWait {
439
+ action := workflow.ForVerb(resumePolicy.ResumeVerb).SourceInvocation(slug)
440
+ out = state.UpsertCursorField(out, state.CursorStatus, "running")
441
+ out = state.UpsertCursorField(out, state.CursorNextAction, action)
442
+ if got, _ := state.CursorField(out, state.CursorStatus); got != "running" {
443
+ return nil, fmt.Errorf("cannot update state status")
444
+ }
445
+ if got, _ := state.CursorField(out, state.CursorNextAction); got != action {
446
+ return nil, fmt.Errorf("cannot update state next_action")
447
+ }
445
448
  }
446
- return nil
449
+ return joinRecords(out), nil
447
450
  }
448
451
 
449
452
  // 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 {