devrites 7.3.1 → 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 (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +35 -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 +50 -21
  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 +1 -1
  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/overhaul/SKILL.md +8 -4
  46. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  47. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  48. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  49. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  50. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  51. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  52. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  53. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  54. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  55. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  56. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  57. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  58. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  59. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  60. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  61. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  62. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  69. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  72. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  73. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  74. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  75. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  76. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  77. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  78. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  79. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  80. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  81. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  82. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  83. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  84. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  85. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  86. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  87. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  88. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  89. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  90. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  91. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  98. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  99. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  100. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  101. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  102. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  103. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  104. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  105. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  106. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  107. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  108. package/package.json +1 -1
  109. package/scripts/dsh-generate.py +1 -1
  110. package/scripts/install-lib.sh +29 -0
  111. package/uninstall.sh +1 -0
@@ -3,6 +3,7 @@ package lib
3
3
  import (
4
4
  "bytes"
5
5
  "crypto/sha256"
6
+ "errors"
6
7
  "fmt"
7
8
  "io"
8
9
  "os"
@@ -34,10 +35,8 @@ func (s *contextSources) read(name string) ([]byte, error) {
34
35
  if source, ok := s.files[name]; ok {
35
36
  return source.data, source.err
36
37
  }
37
- var source contextSource
38
- if source.err = contextParents(s.dir, name); source.err == nil {
39
- source.data, source.err = readBoundedRegularFile(filepath.Join(s.dir, name), workspaceArtifactLimit)
40
- }
38
+ source := contextSource{}
39
+ source.data, source.err = readContextArtifact(s.dir, name)
41
40
  if source.err != nil {
42
41
  source.err = fmt.Errorf("workspace artifact %s: %w", name, source.err)
43
42
  }
@@ -48,6 +47,64 @@ func (s *contextSources) read(name string) ([]byte, error) {
48
47
  return source.data, source.err
49
48
  }
50
49
 
50
+ // Acquire workspace leaves through a confined root, not a checked absolute path.
51
+ func readContextArtifact(dir, name string) ([]byte, error) {
52
+ if !filepath.IsLocal(name) {
53
+ return nil, fmt.Errorf("workspace artifact %q escapes workspace", name)
54
+ }
55
+ if err := contextParents(dir, name); err != nil {
56
+ return nil, err
57
+ }
58
+ ws, err := os.OpenRoot(dir)
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+ defer func() { _ = ws.Close() }()
63
+ before, err := ws.Lstat(name)
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ if !before.Mode().IsRegular() {
68
+ return nil, errors.New("artifact is not a regular file")
69
+ }
70
+ if before.Size() < 0 || before.Size() > workspaceArtifactLimit {
71
+ return nil, fmt.Errorf("artifact exceeds %s limit", byteLimitName(workspaceArtifactLimit))
72
+ }
73
+ file, err := ws.Open(name)
74
+ if err != nil {
75
+ return nil, err
76
+ }
77
+ defer func() { _ = file.Close() }()
78
+ opened, err := file.Stat()
79
+ if err != nil {
80
+ return nil, err
81
+ }
82
+ if !opened.Mode().IsRegular() || opened.Size() != before.Size() || !os.SameFile(before, opened) {
83
+ return nil, errors.New("artifact changed type while opening")
84
+ }
85
+ body, err := io.ReadAll(io.LimitReader(file, workspaceArtifactLimit+1))
86
+ if err != nil {
87
+ return nil, err
88
+ }
89
+ if int64(len(body)) > workspaceArtifactLimit {
90
+ return nil, fmt.Errorf("artifact exceeds %s limit", byteLimitName(workspaceArtifactLimit))
91
+ }
92
+ after, err := file.Stat()
93
+ if err != nil {
94
+ return nil, err
95
+ }
96
+ current, err := ws.Lstat(name)
97
+ if err != nil {
98
+ return nil, err
99
+ }
100
+ if !after.Mode().IsRegular() || after.Size() != opened.Size() || after.Size() != int64(len(body)) ||
101
+ !os.SameFile(opened, after) || !current.Mode().IsRegular() || !os.SameFile(opened, current) ||
102
+ current.Size() != after.Size() {
103
+ return nil, errors.New("artifact changed size or type while reading")
104
+ }
105
+ return body, nil
106
+ }
107
+
51
108
  // Reject even in-workspace parent links: managed paths name actual directories.
52
109
  func contextParents(dir, rel string) error {
53
110
  parent := filepath.Dir(rel)
@@ -169,3 +226,30 @@ func publishContext(featureDir string, opts contextOpts, body []byte) (out strin
169
226
  }
170
227
  return out, false, nil
171
228
  }
229
+
230
+ // Caller holds the feature lock; a wave/role read-set is never replaced.
231
+ func retainPacketContext(workspace, name string, body []byte) error {
232
+ rel := filepath.Join("packets", name)
233
+ if err := contextParents(workspace, rel); err != nil {
234
+ return err
235
+ }
236
+ prior, err := (&contextSources{dir: workspace}).read(rel)
237
+ if err == nil {
238
+ if !bytes.Equal(prior, body) {
239
+ return fmt.Errorf("%s already retains a different context; render the new read-set under a new wave", rel)
240
+ }
241
+ return nil
242
+ }
243
+ if !errors.Is(err, os.ErrNotExist) {
244
+ return err
245
+ }
246
+ ws, err := os.OpenRoot(workspace)
247
+ if err != nil {
248
+ return err
249
+ }
250
+ defer func() { _ = ws.Close() }()
251
+ if err := ws.MkdirAll("packets", 0o755); err != nil {
252
+ return err
253
+ }
254
+ return fsutil.WriteFileAtomicIn(ws, rel, body, 0o600)
255
+ }
@@ -5,39 +5,25 @@ import (
5
5
  "io"
6
6
  "os"
7
7
  "path/filepath"
8
+ "strconv"
8
9
  "strings"
9
10
 
10
11
  "github.com/devrites/devrites/internal/devritespaths"
11
12
  "github.com/devrites/devrites/internal/gate"
12
13
  "github.com/devrites/devrites/internal/state"
14
+ "github.com/devrites/devrites/internal/workflow"
13
15
  )
14
16
 
15
17
  // ContinuationUsage is the help text for `check continuation`.
16
18
  const ContinuationUsage = "usage: devrites-engine check continuation [slug]"
17
19
 
18
- // internalPhaseVerbs are autocomplete phase edges. /rite-ship and
19
- // /rite-autocomplete stay user-facing (fresh GO, parked reopen).
20
- var internalPhaseVerbs = map[string]bool{
21
- "/rite-spec": true,
22
- "/rite-clarify": true,
23
- "/rite-temper": true,
24
- "/rite-define": true,
25
- "/rite-plan": true,
26
- "/rite-vet": true,
27
- "/rite-build": true,
28
- "/rite-prove": true,
29
- "/rite-polish": true,
30
- "/rite-review": true,
31
- "/rite-converge": true,
32
- "/rite-seal": true,
33
- }
34
-
35
20
  // continuationVerdict is the cursor-shaped answer to "may autocomplete reply?".
36
21
  // Required is an internal phase edge, not a human pause (exit stays 0).
37
22
  // shortcut: a host that ends the turn without running this check still stops; no Stop hook (ADR-0015). Upgrade if a host can block stop without a DevRites hook payload.
38
23
  type continuationVerdict struct {
39
24
  Required bool
40
25
  Invoke string
26
+ Source string
41
27
  Reason string
42
28
  }
43
29
 
@@ -71,6 +57,10 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
71
57
  if err != nil {
72
58
  return continuationVerdict{}, err
73
59
  }
60
+ invoke, internal, err := internalPhaseEdge(report.NextAction, slug)
61
+ if err != nil {
62
+ return continuationVerdict{}, err
63
+ }
74
64
  if !afkPresent(root) {
75
65
  return continuationVerdict{Reason: "no AFK sentinel"}, nil
76
66
  }
@@ -100,11 +90,10 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
100
90
  }
101
91
  }
102
92
  }
103
- invoke, ok := internalPhaseEdge(report.NextAction)
104
- if !ok {
93
+ if !internal {
105
94
  return continuationVerdict{Reason: "next_action is not an internal phase edge"}, nil
106
95
  }
107
- return continuationVerdict{Required: true, Invoke: invoke, Reason: "AFK status=running internal phase edge"}, nil
96
+ return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=running internal phase edge"}, nil
108
97
  }
109
98
 
110
99
  func afkPresent(root string) bool {
@@ -112,18 +101,116 @@ func afkPresent(root string) bool {
112
101
  return err == nil && info.Mode().IsRegular()
113
102
  }
114
103
 
115
- func internalPhaseEdge(action string) (string, bool) {
104
+ func internalPhaseEdge(action, slug string) (string, bool, error) {
105
+ command := workflow.ForAction(action)
106
+ policy, known := state.PolicyFor(state.Phase(command.Verb))
107
+ if !known || policy.Target == state.PhaseFrame || policy.Target == state.PhaseShip || policy.ResumeVerb == "" {
108
+ return "", false, nil
109
+ }
116
110
  fields := strings.Fields(action)
117
- if len(fields) == 0 || !internalPhaseVerbs[strings.ToLower(fields[0])] {
118
- return "", false
111
+ if len(fields) == 0 || fields[0] != command.Claude && fields[0] != command.Codex && fields[0] != command.Skill {
112
+ return "", false, fmt.Errorf("next_action must start with one native phase invocation")
113
+ }
114
+ if workflow.ForAction(strings.Join(fields[1:], " ")).Verb != "" {
115
+ return "", false, fmt.Errorf("next_action must contain only one phase invocation")
116
+ }
117
+ for i, field := range fields[1:] {
118
+ if field == "—" {
119
+ fields = fields[:i+1]
120
+ break
121
+ }
122
+ }
123
+ args, err := continuationArguments(command, fields[1:], slug)
124
+ if err != nil {
125
+ return "", false, err
126
+ }
127
+ return strings.Join(append(fields[:1], args...), " "), true, nil
128
+ }
129
+
130
+ func continuationArguments(command workflow.Command, args []string, slug string) ([]string, error) {
131
+ badArgs := func() ([]string, error) {
132
+ return nil, fmt.Errorf("invalid next_action arguments for %s: %q", command.Claude, strings.Join(args, " "))
133
+ }
134
+ var positional []string
135
+ for i := 0; i < len(args); i++ {
136
+ arg := args[i]
137
+ switch {
138
+ case arg == "--full" && (command.Verb == "vet" || command.Verb == "review" || command.Verb == "seal"):
139
+ case arg == "--cross-model" && command.Verb == "vet":
140
+ case arg == "--lookahead" && command.Verb == "spec":
141
+ case arg == "--mode" && command.Verb == "temper":
142
+ i++
143
+ if i >= len(args) {
144
+ return badArgs()
145
+ }
146
+ switch args[i] {
147
+ case "expand", "selective", "hold-rigor", "reduce-to-MVP":
148
+ default:
149
+ return badArgs()
150
+ }
151
+ case arg == "--parallel" && command.Verb == "build":
152
+ i++
153
+ if i >= len(args) {
154
+ return badArgs()
155
+ }
156
+ n, err := strconv.Atoi(args[i])
157
+ if err != nil || n < 1 || n > 10 {
158
+ return badArgs()
159
+ }
160
+ case strings.HasPrefix(arg, "-"):
161
+ return badArgs()
162
+ default:
163
+ positional = append(positional, arg)
164
+ }
165
+ }
166
+ if command.SourceIsFirstArgument() {
167
+ if len(positional) > 1 {
168
+ return badArgs()
169
+ }
170
+ if len(positional) == 1 && positional[0] != slug {
171
+ return nil, fmt.Errorf("next_action targets workspace %q, not selected source %q", positional[0], slug)
172
+ }
173
+ if len(positional) == 0 {
174
+ args = append(args, slug)
175
+ }
176
+ return args, nil
177
+ }
178
+ switch command.Verb {
179
+ case "build", "polish":
180
+ // Build's positional argument is a slice number/name, not a workspace.
181
+ return args, nil
182
+ case "plan":
183
+ if len(positional) == 0 {
184
+ return args, nil
185
+ }
186
+ if len(positional) <= 2 {
187
+ switch positional[0] {
188
+ case "decompose", "reslice", "repair", "reorder", "split", "unblock", "course-correct", "revise":
189
+ if len(positional) == 2 && positional[1] != slug {
190
+ return nil, fmt.Errorf("next_action targets workspace %q, not selected source %q", positional[1], slug)
191
+ }
192
+ return args, nil
193
+ }
194
+ }
195
+ case "review":
196
+ if len(positional) == 0 || len(positional) == 1 && positional[0] == "feature" {
197
+ return args, nil
198
+ }
199
+ if len(positional) == 2 && positional[0] == "slice" {
200
+ n, err := strconv.Atoi(positional[1])
201
+ if err == nil && n > 0 || state.ValidSliceID(positional[1]) {
202
+ return args, nil
203
+ }
204
+ }
119
205
  }
120
- return strings.Join(fields, " "), true
206
+ return badArgs()
121
207
  }
122
208
 
123
209
  func writeContinuation(stdout io.Writer, verdict continuationVerdict) {
124
210
  if verdict.Required {
125
211
  fmt.Fprintln(stdout, "continuation: required")
126
212
  fmt.Fprintf(stdout, "invoke: %s\n", verdict.Invoke)
213
+ fmt.Fprintf(stdout, "source: %s\n", verdict.Source)
127
214
  } else {
128
215
  fmt.Fprintln(stdout, "continuation: clear")
129
216
  }
@@ -38,7 +38,20 @@ open --claim-session --claim records one file claim for the wave and fails,
38
38
  creating no wave, while a live foreign session holds a path; the return that
39
39
  completes the wave releases it, and abandon keeps it unless --release (any
40
40
  host-confirmed terminal path without a completing return, such as a stop or a
41
- refused launch). Re-opening an abandoned wave keeps or replaces its claim.
41
+ refused launch). Wave IDs are single-use, including abandoned waves; retries
42
+ need a fresh ID and never inherit or release an earlier wave's claim.
43
+ Native starts, returns and abandonment retain subsecond timestamps. abandon
44
+ --release also retains host-terminal confirmation, even without a claim or when
45
+ its owned claim was already released; a failed release does not certify termination.
46
+ Finalization requires every launched
47
+ role to settle, and abandoned writers need actual returns or this confirmation.
48
+ An abandoned reviewer or proof-runner still needs a later complete account in
49
+ the same phase, role and slice subject; rerun abandon on a legacy record to
50
+ record reconciliation time first.
51
+ An abandoned writer's unreleased claim blocks overlapping writers across every
52
+ live or archived feature in this repository, even in the same session or after
53
+ TTL expiry, until explicit release or proven host termination.
54
+ return --handle, when supplied, must match the role's recorded host handle.
42
55
  status --wave exits
43
56
  nonzero until that wave is complete; bare status lists every open, sealed,
44
57
  or abandoned wave, folds complete waves into one count line (--all lists
@@ -93,6 +106,8 @@ type dispatchWave struct {
93
106
  Phase string `json:"phase"`
94
107
  State string `json:"state"` // open, sealed, abandoned, complete
95
108
  Reason string `json:"reason,omitempty"`
109
+ AbandonedAt string `json:"abandoned_at,omitempty"`
110
+ TerminalAt string `json:"terminal_at,omitempty"`
96
111
  Roles map[string]*dispatchRole `json:"roles"`
97
112
  Slices []string `json:"slices,omitempty"`
98
113
  ResliceReview string `json:"reslice_review,omitempty"`
@@ -131,7 +146,10 @@ func dispatchPath(root, slug string) string {
131
146
  }
132
147
 
133
148
  func loadDispatch(root, slug string) (*dispatchFile, string, error) {
134
- path := dispatchPath(root, slug)
149
+ return loadDispatchFile(dispatchPath(root, slug))
150
+ }
151
+
152
+ func loadDispatchFile(path string) (*dispatchFile, string, error) {
135
153
  f := &dispatchFile{Waves: map[string]*dispatchWave{}}
136
154
  data, err := os.ReadFile(path) // #nosec G304 -- path is a validated feature workspace artifact
137
155
  if os.IsNotExist(err) {
@@ -140,16 +158,75 @@ func loadDispatch(root, slug string) (*dispatchFile, string, error) {
140
158
  if err != nil {
141
159
  return nil, path, err
142
160
  }
161
+ f = &dispatchFile{}
143
162
  if err := json.Unmarshal(data, f); err != nil {
144
163
  return nil, path, fmt.Errorf("dispatch.json is not valid JSON: %w", err)
145
164
  }
146
165
  if f.Waves == nil {
147
- f.Waves = map[string]*dispatchWave{}
166
+ return nil, path, fmt.Errorf("dispatch.json is malformed (missing waves)")
148
167
  }
149
- // Fail closed on hand-edited impossible state.
168
+ // Validate every account before any caller can use or mutate the ledger.
150
169
  for name, w := range f.Waves {
151
- if w.Roles == nil || w.State == "" {
152
- return nil, path, fmt.Errorf("wave %q is malformed (missing state or roles)", name)
170
+ if w == nil || strings.TrimSpace(name) == "" || strings.TrimSpace(w.Phase) == "" || len(w.Roles) == 0 ||
171
+ !slices.Contains([]string{"open", "sealed", "complete", "abandoned"}, w.State) {
172
+ return nil, path, fmt.Errorf("wave %q is malformed (invalid phase, state or roles)", name)
173
+ }
174
+ handles := map[string]bool{}
175
+ allStarted, allReturned := true, true
176
+ for role, r := range w.Roles {
177
+ if r == nil || strings.TrimSpace(role) == "" {
178
+ return nil, path, fmt.Errorf("wave %q role %q is malformed (missing account)", name, role)
179
+ }
180
+ started := r.Handle != ""
181
+ if started != (r.StartedAt != "") || r.Returned != (r.ReturnedAt != "") ||
182
+ (r.Returned && !started) || (started && handles[r.Handle]) ||
183
+ (w.State == "open" && r.Returned) ||
184
+ ((w.State == "sealed" || w.State == "complete") && !started) ||
185
+ (w.State == "complete" && !r.Returned) {
186
+ return nil, path, fmt.Errorf("wave %q role %q is malformed (inconsistent start or return)", name, role)
187
+ }
188
+ allStarted = allStarted && started
189
+ allReturned = allReturned && r.Returned
190
+ if started {
191
+ start, startErr := time.Parse(time.RFC3339Nano, r.StartedAt)
192
+ if startErr != nil {
193
+ return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid start timestamp)", name, role)
194
+ }
195
+ if r.Returned {
196
+ end, endErr := time.Parse(time.RFC3339Nano, r.ReturnedAt)
197
+ if endErr != nil || end.Before(start) {
198
+ return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid return timestamp)", name, role)
199
+ }
200
+ }
201
+ }
202
+ if started {
203
+ handles[r.Handle] = true
204
+ }
205
+ }
206
+ if (w.State == "open" && allStarted) || (w.State == "sealed" && allReturned) {
207
+ return nil, path, fmt.Errorf("wave %q is malformed (inconsistent barrier state)", name)
208
+ }
209
+ if w.AbandonedAt != "" {
210
+ if _, err := time.Parse(time.RFC3339Nano, w.AbandonedAt); err != nil || w.State != "abandoned" {
211
+ return nil, path, fmt.Errorf("wave %q is malformed (invalid abandonment timestamp)", name)
212
+ }
213
+ }
214
+ if w.TerminalAt != "" {
215
+ terminal, err := time.Parse(time.RFC3339Nano, w.TerminalAt)
216
+ abandoned, abandonErr := time.Parse(time.RFC3339Nano, w.AbandonedAt)
217
+ if err != nil || abandonErr != nil || w.State != "abandoned" || terminal.Before(abandoned) {
218
+ return nil, path, fmt.Errorf("wave %q is malformed (invalid terminal timestamp)", name)
219
+ }
220
+ for _, account := range w.Roles {
221
+ for _, stamp := range []string{account.StartedAt, account.ReturnedAt} {
222
+ if stamp != "" {
223
+ at, _ := time.Parse(time.RFC3339Nano, stamp)
224
+ if terminal.Before(at) {
225
+ return nil, path, fmt.Errorf("wave %q is malformed (terminal timestamp precedes account)", name)
226
+ }
227
+ }
228
+ }
229
+ }
153
230
  }
154
231
  }
155
232
  return f, path, nil
@@ -166,7 +243,7 @@ func saveDispatch(path string, f *dispatchFile) error {
166
243
  return state.AtomicWrite(path, data, 0o600)
167
244
  }
168
245
 
169
- func nowStamp() string { return time.Now().UTC().Format(time.RFC3339) }
246
+ func nowStamp() string { return time.Now().UTC().Format(time.RFC3339Nano) }
170
247
 
171
248
  // RunDispatch implements the dispatch wave state machine.
172
249
  func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
@@ -259,8 +336,11 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
259
336
  // an update and strand the wave short of complete.
260
337
  code := 0
261
338
  if err := state.WithFeatureLock(root, slug, func() error {
262
- code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, contract, packet, outcome, result, all, claim, stdout, stderr)
263
- return nil
339
+ // Feature → claims is the lock order; never acquire another feature.
340
+ return state.WithLock(claimsPath(root)+".lock", func() error {
341
+ code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, contract, packet, outcome, result, all, claim, stdout, stderr)
342
+ return nil
343
+ })
264
344
  }); err != nil {
265
345
  fmt.Fprintf(stderr, "dispatch: %v\n", err)
266
346
  return 3
@@ -293,8 +373,7 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
293
373
  fmt.Fprint(stderr, dispatchUsage)
294
374
  return 2
295
375
  }
296
- prev := getWave()
297
- if prev != nil && prev.State != "abandoned" {
376
+ if prev := getWave(); prev != nil {
298
377
  return fail("wave %q already exists (state %s)", wave, prev.State)
299
378
  }
300
379
  w := &dispatchWave{Phase: phase, State: "open", Roles: map[string]*dispatchRole{}, Slices: sliceIDs}
@@ -315,6 +394,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
315
394
  if err := lintWrightContractFor(root, slug, contract); err != nil {
316
395
  return fail("BLOCKED: %v", err)
317
396
  }
397
+ if err := lintPacketDiffs(root, slug, contract, false); err != nil {
398
+ return fail("BLOCKED: %v", err)
399
+ }
318
400
  }
319
401
  for _, r := range roles {
320
402
  if isReviewerRole(r) {
@@ -331,6 +413,22 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
331
413
  break
332
414
  }
333
415
  }
416
+ var claimPaths []string
417
+ if len(claim.paths) > 0 {
418
+ claimPaths, err = normalizeClaimPaths(claim.paths)
419
+ if err != nil {
420
+ return fail("%v", err)
421
+ }
422
+ }
423
+ admissionPaths := slices.Clone(claimPaths)
424
+ if isWrightWave(w) {
425
+ admissionPaths = append(admissionPaths, sliceWriteSet(root, slug, w.Slices)...)
426
+ }
427
+ if len(claimPaths) == 0 {
428
+ if err := checkAbandonedWriterClaims(root, admissionPaths); err != nil {
429
+ return fail("BLOCKED: %v", err)
430
+ }
431
+ }
334
432
  if err := openTripwire(root, slug, w, claim.paths); err != nil {
335
433
  return fail("BLOCKED: %v", err)
336
434
  }
@@ -339,15 +437,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
339
437
  fmt.Fprintln(stderr, "dispatch: --claim-session and --claim go together (--claim-ttl needs both)")
340
438
  return 2
341
439
  }
342
- // The claim goes last so a refused open never leaves one behind.
343
- id, code := addClaim(root, claim.session, "dispatch wave "+wave, cmp.Or(claim.ttl, claimDefaultTTL), claim.paths, stdout, stderr)
440
+ if !validSession(claim.session) {
441
+ fmt.Fprintln(stderr, "claim: --session is required (1-128 chars: letters, digits, - _ . : /)")
442
+ return 2
443
+ }
444
+ // The ledger lock spans admission, claim append, and publication.
445
+ id, code := claimAddLocked(claimsPath(root), claim.session, "dispatch wave "+wave, cmp.Or(claim.ttl, claimDefaultTTL), claimPaths, admissionPaths, stdout, stderr)
344
446
  if code != 0 {
345
447
  return code
346
448
  }
347
449
  w.Claim, w.ClaimSession = id, claim.session
348
- } else if prev != nil {
349
- // Inherit the abandoned wave's kept claim so the completing return releases it.
350
- w.Claim, w.ClaimSession = prev.Claim, prev.ClaimSession
351
450
  }
352
451
  if project, _, ok := treeProject(root); ok && isBuildWrightWave(w) {
353
452
  // Uncommitted rounds look like additions against HEAD; this snapshot
@@ -358,10 +457,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
358
457
  w.Tree = tree
359
458
  }
360
459
  }
361
- superseded := prev != nil && prev.Claim != "" && prev.Claim != w.Claim
362
460
  f.Waves[wave] = w
363
461
  if err := saveDispatch(path, f); err != nil {
364
- if w.Claim != "" && (prev == nil || w.Claim != prev.Claim) {
462
+ if w.Claim != "" {
365
463
  _, _ = releaseClaim(root, w.ClaimSession, w.Claim)
366
464
  }
367
465
  return fail("%v", err)
@@ -370,9 +468,6 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
370
468
  if err := ensureTreeBaseline(root, slug, stdout); err != nil {
371
469
  fmt.Fprintf(stderr, "dispatch: WARNING: %s not recorded: %v\n", treeBaselineFile, err)
372
470
  }
373
- if superseded {
374
- releaseWaveClaim(root, wave, prev, stdout, stderr)
375
- }
376
471
  for _, line := range rounds {
377
472
  fmt.Fprintln(stdout, line)
378
473
  }
@@ -455,6 +550,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
455
550
  if r == nil {
456
551
  return fail("role %q not declared in wave %q", role, wave)
457
552
  }
553
+ if handle != "" && handle != r.Handle {
554
+ return fail("handle %q does not match role %q's recorded handle %q", handle, role, r.Handle)
555
+ }
458
556
  if r.Returned {
459
557
  return fail("role %q already returned", role)
460
558
  }
@@ -569,14 +667,23 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
569
667
  already := w.State == "abandoned"
570
668
  w.State = "abandoned"
571
669
  w.Reason = reason
670
+ if w.AbandonedAt == "" {
671
+ w.AbandonedAt = nowStamp()
672
+ }
572
673
  // A timeout or unknown outcome keeps the claim; --release is for any
573
674
  // host-confirmed terminal path without a completing return (a stop or a
574
675
  // refused launch), including a re-run once a timed-out child is confirmed.
575
676
  if claim.release {
576
677
  if w.Claim == "" {
577
678
  fmt.Fprintf(stderr, "dispatch: --release: wave %s holds no claim\n", wave)
679
+ if w.TerminalAt == "" {
680
+ w.TerminalAt = nowStamp()
681
+ }
578
682
  } else if releaseWaveClaim(root, wave, w, stdout, stderr) {
579
683
  w.Claim, w.ClaimSession = "", ""
684
+ if w.TerminalAt == "" {
685
+ w.TerminalAt = nowStamp()
686
+ }
580
687
  }
581
688
  }
582
689
  if err := saveDispatch(path, f); err != nil {
@@ -592,10 +699,36 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
592
699
  return 2
593
700
  }
594
701
 
595
- // releaseWaveClaim releases w's claim and reports whether it did. A failure
596
- // only warns: the wave state change does not depend on it.
702
+ // Caller holds the repository claims lock through new-wave publication.
703
+ func checkAbandonedWriterClaims(root string, paths []string) error {
704
+ if len(paths) == 0 {
705
+ return nil
706
+ }
707
+ latest, _, bad, err := readClaims(claimsPath(root))
708
+ if err != nil {
709
+ return err
710
+ }
711
+ if bad > 0 {
712
+ return fmt.Errorf("%d unparsed ledger line(s); cannot rule out an abandoned writer claim", bad)
713
+ }
714
+ conflicts, err := retainedWriterConflicts(root, latest, paths, newHolderProbe())
715
+ if err != nil {
716
+ return err
717
+ }
718
+ if len(conflicts) > 0 {
719
+ return fmt.Errorf("abandoned writer still holds claim %s on %s; confirm host termination and use dispatch abandon --release before retrying", conflicts[0].ID, strings.Join(conflicts[0].Paths, ","))
720
+ }
721
+ return nil
722
+ }
723
+
724
+ // releaseWaveClaim releases w's claim; explicit terminal abandonment also
725
+ // reconciles a verified prior release. A failure only warns.
597
726
  func releaseWaveClaim(root, wave string, w *dispatchWave, stdout, stderr io.Writer) bool {
598
727
  n, err := releaseClaim(root, w.ClaimSession, w.Claim)
728
+ if w.State == "abandoned" && errors.Is(err, errClaimAlreadyReleased) {
729
+ fmt.Fprintf(stdout, "dispatch: wave %s claim %s already released\n", wave, w.Claim)
730
+ return true
731
+ }
599
732
  if err != nil {
600
733
  fmt.Fprintf(stderr, "dispatch: WARNING: wave %s claim not released: %v\n", wave, err)
601
734
  return false
@@ -799,7 +932,7 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
799
932
  return lines, 0
800
933
  }
801
934
  if review == "" {
802
- fmt.Fprintf(stderr, "dispatch: BLOCKED: %s reached %d %s rounds; run a reslice review (acceptance-preserving split or simpler proof seam, or a drift.md entry recording why neither applies) and reopen with --reslice-review <DRIFT-ID>\n",
935
+ fmt.Fprintf(stderr, "dispatch: BLOCKED: %s reached %d %s rounds; run a reslice review (acceptance-preserving split or simpler proof seam, or a drift.md entry recording why neither applies) and open a fresh wave with --reslice-review <DRIFT-ID>\n",
803
936
  strings.Join(overCap, ","), wrightRoundsPerReview, kind)
804
937
  return nil, 3
805
938
  }