devrites 7.3.3 → 7.3.5

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 (72) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/README.md +21 -1
  3. package/docs/adr/0039-autocomplete-structural-admission.md +87 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/cli.md +182 -7
  6. package/docs/command-map.md +2 -2
  7. package/docs/engine/commands.md +53 -3
  8. package/engine/commands.go +1 -1
  9. package/engine/help.go +3 -1
  10. package/engine/internal/lib/claims.go +65 -8
  11. package/engine/internal/lib/continuation.go +38 -4
  12. package/engine/internal/lib/dispatch.go +124 -63
  13. package/engine/internal/lib/frontier.go +864 -0
  14. package/engine/internal/lib/frontier_controller.go +652 -0
  15. package/engine/internal/lib/frontier_roadmap.go +168 -0
  16. package/engine/internal/lib/handoff.go +2 -5
  17. package/engine/internal/lib/next.go +19 -0
  18. package/engine/internal/parallel/cli.go +16 -5
  19. package/engine/internal/parallel/ops.go +7 -5
  20. package/engine/internal/parallel/repository.go +23 -0
  21. package/engine/internal/release/release.go +96 -5
  22. package/engine/main.go +4 -0
  23. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  24. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  25. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  26. package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -38
  27. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +46 -52
  28. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  29. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +19 -17
  30. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  31. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  32. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  33. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -38
  34. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +46 -52
  35. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  36. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +19 -17
  37. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  38. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  39. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  40. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +33 -38
  41. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +46 -52
  42. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  43. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +19 -17
  44. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  45. package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  46. package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  47. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +33 -38
  48. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +46 -52
  49. package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  50. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +19 -17
  51. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  52. package/pack/generated/dsh/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  53. package/pack/generated/dsh/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  54. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +33 -38
  55. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +46 -52
  56. package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  57. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +19 -17
  58. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  59. package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  60. package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  61. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +33 -38
  62. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +46 -52
  63. package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  64. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +19 -17
  65. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  66. package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  67. package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  68. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +33 -38
  69. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +46 -52
  70. package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  71. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +19 -17
  72. package/package.json +1 -1
@@ -239,21 +239,39 @@ func validSession(s string) bool {
239
239
  return true
240
240
  }
241
241
 
242
- // claimConflicts returns live claims by other sessions overlapping any path: a
243
- // claimed directory covers every path beneath it, and vice versa.
244
- func claimConflicts(live []*claimRecord, session string, paths []string) []*claimRecord {
242
+ // claimConflicts checks logical and physical overlap for other live sessions:
243
+ // a claimed directory covers every path beneath it, and vice versa.
244
+ func claimConflicts(root string, live []*claimRecord, session string, paths []string) ([]*claimRecord, error) {
245
245
  var out []*claimRecord
246
+ var physical []string
246
247
  for _, rec := range live {
247
248
  if rec.Session == session {
248
249
  continue
249
250
  }
250
- if slices.ContainsFunc(rec.Paths, func(held string) bool {
251
+ overlaps := slices.ContainsFunc(rec.Paths, func(held string) bool {
251
252
  return slices.ContainsFunc(paths, func(p string) bool { return claimPathsOverlap(held, p) })
252
- }) {
253
+ })
254
+ if !overlaps {
255
+ if physical == nil {
256
+ var err error
257
+ physical, err = physicalClaimPaths(root, paths)
258
+ if err != nil {
259
+ return nil, err
260
+ }
261
+ }
262
+ held, err := physicalClaimPaths(root, rec.Paths)
263
+ if err != nil {
264
+ return nil, fmt.Errorf("claim %s: %w", rec.ID, err)
265
+ }
266
+ overlaps = slices.ContainsFunc(held, func(h string) bool {
267
+ return slices.ContainsFunc(physical, func(p string) bool { return claimPathsOverlap(h, p) })
268
+ })
269
+ }
270
+ if overlaps {
253
271
  out = append(out, rec)
254
272
  }
255
273
  }
256
- return out
274
+ return out, nil
257
275
  }
258
276
 
259
277
  // claimPathsOverlap folds case always: on a case-sensitive filesystem a false
@@ -482,6 +500,37 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
482
500
  return id, code
483
501
  }
484
502
 
503
+ // Caller holds the repository claims lock through new-wave publication.
504
+ func checkWriterClaimsLocked(root, session string, paths []string) error {
505
+ if len(paths) == 0 {
506
+ return nil
507
+ }
508
+ latest, order, bad, err := readClaims(claimsPath(root))
509
+ if err != nil {
510
+ return err
511
+ }
512
+ if bad > 0 {
513
+ return fmt.Errorf("%d unparsed ledger line(s); cannot rule out a writer claim", bad)
514
+ }
515
+ probe := newHolderProbe()
516
+ retained, err := retainedWriterConflicts(root, latest, paths, probe)
517
+ if err != nil {
518
+ return err
519
+ }
520
+ if len(retained) > 0 {
521
+ return fmt.Errorf("abandoned writer still holds claim %s on %s; confirm host termination and use dispatch abandon --release before retrying", retained[0].ID, strings.Join(retained[0].Paths, ","))
522
+ }
523
+ conflicts, err := claimConflicts(root, probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
524
+ if err != nil {
525
+ return err
526
+ }
527
+ if len(conflicts) > 0 {
528
+ rec := conflicts[0]
529
+ return fmt.Errorf("live writer claim %s on %s held by session %q until %s", rec.ID, strings.Join(rec.Paths, ","), rec.Session, rec.ExpiresAt)
530
+ }
531
+ return nil
532
+ }
533
+
485
534
  func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths []string, stdout, stderr io.Writer) (string, int) {
486
535
  latest, order, bad, err := readClaims(path)
487
536
  if err != nil {
@@ -503,7 +552,11 @@ func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths
503
552
  printConflicts(stderr, retained)
504
553
  return "", 3
505
554
  }
506
- conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, now)), session, paths)
555
+ conflicts, err := claimConflicts(filepath.Dir(path), probe.withoutGone(liveClaims(latest, order, now)), session, admissionPaths)
556
+ if err != nil {
557
+ fmt.Fprintf(stderr, "claim: %v\n", err)
558
+ return "", 3
559
+ }
507
560
  if len(conflicts) > 0 {
508
561
  printConflicts(stderr, conflicts)
509
562
  return "", 3
@@ -698,7 +751,11 @@ func claimCheckLocked(root, session string, paths []string, stdout, stderr io.Wr
698
751
  printConflicts(stdout, retained)
699
752
  return 3
700
753
  }
701
- conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
754
+ conflicts, err := claimConflicts(root, probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
755
+ if err != nil {
756
+ fmt.Fprintf(stderr, "claim: %v\n", err)
757
+ return 2
758
+ }
702
759
  if len(conflicts) > 0 {
703
760
  printConflicts(stdout, conflicts)
704
761
  return 3
@@ -10,6 +10,7 @@ import (
10
10
 
11
11
  "github.com/devrites/devrites/internal/devritespaths"
12
12
  "github.com/devrites/devrites/internal/gate"
13
+ "github.com/devrites/devrites/internal/markdowntext"
13
14
  "github.com/devrites/devrites/internal/state"
14
15
  "github.com/devrites/devrites/internal/workflow"
15
16
  )
@@ -39,12 +40,31 @@ func RunCheckContinuation(root string, args []string, stdout, stderr io.Writer)
39
40
  }
40
41
  return code
41
42
  }
43
+ mode, err := autocompleteMode(root)
44
+ if err != nil {
45
+ fmt.Fprintf(stderr, "check continuation: %v\n", err)
46
+ return 2
47
+ }
42
48
  verdict, err := classifyContinuation(root, slug)
43
49
  if err != nil {
44
50
  fmt.Fprintf(stderr, "check continuation: %v\n", err)
45
51
  return 2
46
52
  }
53
+ var frontier []string
54
+ if afkPresent(root) {
55
+ frontier, err = FrontierLines(root, slug)
56
+ if err != nil {
57
+ fmt.Fprintf(stderr, "check continuation: frontier: %v\n", err)
58
+ return 2
59
+ }
60
+ if mode && frontierGlobalPriority(slug, frontier) || !mode && !verdict.Required && len(frontier) > 0 && frontier[0] == "frontier: actionable" {
61
+ verdict = continuationVerdict{Required: true, Invoke: "/rite-autocomplete", Source: slug, Reason: "global frontier requires native preparation, authority review, or selection; no Ship approval"}
62
+ }
63
+ }
47
64
  writeContinuation(stdout, verdict)
65
+ for _, line := range frontier {
66
+ fmt.Fprintln(stdout, line)
67
+ }
48
68
  return 0
49
69
  }
50
70
 
@@ -59,12 +79,16 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
59
79
  }
60
80
  invoke, internal, err := internalPhaseEdge(report.NextAction, slug)
61
81
  if err != nil {
62
- return continuationVerdict{}, err
82
+ if !frontierBoundSpec(root, slug, report.NextAction) {
83
+ return continuationVerdict{}, err
84
+ }
85
+ // A successor Spec belongs to the global controller, never this source's phase invocation.
86
+ invoke, internal = "", false
63
87
  }
64
88
  if !afkPresent(root) {
65
89
  return continuationVerdict{Reason: "no AFK sentinel"}, nil
66
90
  }
67
- if report.Status != "running" {
91
+ if report.Status != "running" && !(report.Phase == state.PhaseVet && report.Status == "ready") {
68
92
  if report.Status == "" {
69
93
  return continuationVerdict{Reason: "status unset"}, nil
70
94
  }
@@ -75,7 +99,17 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
75
99
  if err != nil {
76
100
  return continuationVerdict{}, err
77
101
  }
78
- if qid, ok := state.CursorField(strings.Split(string(data), "\n"), state.CursorQuestionID); ok && strings.TrimSpace(qid) != "" {
102
+ structural, err := markdowntext.Structural(data)
103
+ if err != nil {
104
+ return continuationVerdict{Reason: "malformed source cursor; native normalization required"}, nil
105
+ }
106
+ cursorLines := strings.Split(string(structural), "\n")
107
+ for _, key := range []string{state.CursorPhase, state.CursorStatus, state.CursorNextAction, state.CursorQuestionID} {
108
+ if _, _, err := frontierField(cursorLines, key); err != nil {
109
+ return continuationVerdict{Reason: err.Error() + "; native normalization required"}, nil
110
+ }
111
+ }
112
+ if qid, ok := state.CursorField(cursorLines, state.CursorQuestionID); ok && strings.TrimSpace(qid) != "" {
79
113
  return continuationVerdict{Reason: "question cursor set"}, nil
80
114
  }
81
115
  // #nosec G304 -- questions.md inside the checked feature workspace
@@ -93,7 +127,7 @@ func classifyContinuation(root, slug string) (continuationVerdict, error) {
93
127
  if !internal {
94
128
  return continuationVerdict{Reason: "next_action is not an internal phase edge"}, nil
95
129
  }
96
- return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=running internal phase edge"}, nil
130
+ return continuationVerdict{Required: true, Invoke: invoke, Source: slug, Reason: "AFK status=" + report.Status + " internal phase edge"}, nil
97
131
  }
98
132
 
99
133
  func afkPresent(root string) bool {
@@ -52,6 +52,8 @@ An abandoned writer's unreleased claim blocks overlapping writers across every
52
52
  live or archived feature in this repository, even in the same session or after
53
53
  TTL expiry, until explicit release or proven host termination.
54
54
  return --handle, when supplied, must match the role's recorded host handle.
55
+ Completed return replay requires the exact recorded role and --handle and only
56
+ reconciles claim release; failed release exits nonzero without undoing the return.
55
57
  status --wave exits
56
58
  nonzero until that wave is complete; bare status lists every open, sealed,
57
59
  or abandoned wave, folds complete waves into one count line (--all lists
@@ -114,7 +116,9 @@ type dispatchWave struct {
114
116
  ResliceReviewed []string `json:"reslice_reviewed,omitempty"`
115
117
  Claim string `json:"claim,omitempty"`
116
118
  ClaimSession string `json:"claim_session,omitempty"`
117
- Tree string `json:"tree,omitempty"` // work tree at a build wright open, for check windows --since-dispatch
119
+ Tree string `json:"tree,omitempty"` // work tree at a build wright open, for check windows --since-dispatch
120
+ ControllerBatch string `json:"controller_batch,omitempty"` // cohort admission identity, not evidence of actor checkout
121
+ AdmittedPaths map[string][]string `json:"admitted_paths,omitempty"` // immutable normalized write sets, keyed by source-local slice
118
122
  }
119
123
 
120
124
  // dispatchClaim carries the wave-claim flags; ttl 0 means unset.
@@ -150,32 +154,36 @@ func loadDispatch(root, slug string) (*dispatchFile, string, error) {
150
154
  }
151
155
 
152
156
  func loadDispatchFile(path string) (*dispatchFile, string, error) {
153
- f := &dispatchFile{Waves: map[string]*dispatchWave{}}
154
157
  data, err := os.ReadFile(path) // #nosec G304 -- path is a validated feature workspace artifact
155
158
  if os.IsNotExist(err) {
156
- return f, path, nil
159
+ return &dispatchFile{Waves: map[string]*dispatchWave{}}, path, nil
157
160
  }
158
161
  if err != nil {
159
162
  return nil, path, err
160
163
  }
161
- f = &dispatchFile{}
164
+ f, err := decodeDispatch(data)
165
+ return f, path, err
166
+ }
167
+
168
+ func decodeDispatch(data []byte) (*dispatchFile, error) {
169
+ f := &dispatchFile{}
162
170
  if err := json.Unmarshal(data, f); err != nil {
163
- return nil, path, fmt.Errorf("dispatch.json is not valid JSON: %w", err)
171
+ return nil, fmt.Errorf("dispatch.json is not valid JSON: %w", err)
164
172
  }
165
173
  if f.Waves == nil {
166
- return nil, path, fmt.Errorf("dispatch.json is malformed (missing waves)")
174
+ return nil, fmt.Errorf("dispatch.json is malformed (missing waves)")
167
175
  }
168
176
  // Validate every account before any caller can use or mutate the ledger.
169
177
  for name, w := range f.Waves {
170
178
  if w == nil || strings.TrimSpace(name) == "" || strings.TrimSpace(w.Phase) == "" || len(w.Roles) == 0 ||
171
179
  !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)
180
+ return nil, fmt.Errorf("wave %q is malformed (invalid phase, state or roles)", name)
173
181
  }
174
182
  handles := map[string]bool{}
175
183
  allStarted, allReturned := true, true
176
184
  for role, r := range w.Roles {
177
185
  if r == nil || strings.TrimSpace(role) == "" {
178
- return nil, path, fmt.Errorf("wave %q role %q is malformed (missing account)", name, role)
186
+ return nil, fmt.Errorf("wave %q role %q is malformed (missing account)", name, role)
179
187
  }
180
188
  started := r.Handle != ""
181
189
  if started != (r.StartedAt != "") || r.Returned != (r.ReturnedAt != "") ||
@@ -183,19 +191,19 @@ func loadDispatchFile(path string) (*dispatchFile, string, error) {
183
191
  (w.State == "open" && r.Returned) ||
184
192
  ((w.State == "sealed" || w.State == "complete") && !started) ||
185
193
  (w.State == "complete" && !r.Returned) {
186
- return nil, path, fmt.Errorf("wave %q role %q is malformed (inconsistent start or return)", name, role)
194
+ return nil, fmt.Errorf("wave %q role %q is malformed (inconsistent start or return)", name, role)
187
195
  }
188
196
  allStarted = allStarted && started
189
197
  allReturned = allReturned && r.Returned
190
198
  if started {
191
199
  start, startErr := time.Parse(time.RFC3339Nano, r.StartedAt)
192
200
  if startErr != nil {
193
- return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid start timestamp)", name, role)
201
+ return nil, fmt.Errorf("wave %q role %q is malformed (invalid start timestamp)", name, role)
194
202
  }
195
203
  if r.Returned {
196
204
  end, endErr := time.Parse(time.RFC3339Nano, r.ReturnedAt)
197
205
  if endErr != nil || end.Before(start) {
198
- return nil, path, fmt.Errorf("wave %q role %q is malformed (invalid return timestamp)", name, role)
206
+ return nil, fmt.Errorf("wave %q role %q is malformed (invalid return timestamp)", name, role)
199
207
  }
200
208
  }
201
209
  }
@@ -204,32 +212,32 @@ func loadDispatchFile(path string) (*dispatchFile, string, error) {
204
212
  }
205
213
  }
206
214
  if (w.State == "open" && allStarted) || (w.State == "sealed" && allReturned) {
207
- return nil, path, fmt.Errorf("wave %q is malformed (inconsistent barrier state)", name)
215
+ return nil, fmt.Errorf("wave %q is malformed (inconsistent barrier state)", name)
208
216
  }
209
217
  if w.AbandonedAt != "" {
210
218
  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)
219
+ return nil, fmt.Errorf("wave %q is malformed (invalid abandonment timestamp)", name)
212
220
  }
213
221
  }
214
222
  if w.TerminalAt != "" {
215
223
  terminal, err := time.Parse(time.RFC3339Nano, w.TerminalAt)
216
224
  abandoned, abandonErr := time.Parse(time.RFC3339Nano, w.AbandonedAt)
217
225
  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)
226
+ return nil, fmt.Errorf("wave %q is malformed (invalid terminal timestamp)", name)
219
227
  }
220
228
  for _, account := range w.Roles {
221
229
  for _, stamp := range []string{account.StartedAt, account.ReturnedAt} {
222
230
  if stamp != "" {
223
231
  at, _ := time.Parse(time.RFC3339Nano, stamp)
224
232
  if terminal.Before(at) {
225
- return nil, path, fmt.Errorf("wave %q is malformed (terminal timestamp precedes account)", name)
233
+ return nil, fmt.Errorf("wave %q is malformed (terminal timestamp precedes account)", name)
226
234
  }
227
235
  }
228
236
  }
229
237
  }
230
238
  }
231
239
  }
232
- return f, path, nil
240
+ return f, nil
233
241
  }
234
242
 
235
243
  func saveDispatch(path string, f *dispatchFile) error {
@@ -335,20 +343,61 @@ func RunDispatch(root string, args []string, stdout, stderr io.Writer) int {
335
343
  // Serialize load-modify-save so concurrent start/return calls never lose
336
344
  // an update and strand the wave short of complete.
337
345
  code := 0
338
- if err := state.WithFeatureLock(root, slug, func() error {
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
346
+ recordedReturn := false
347
+ mutate := func() error {
348
+ return state.WithFeatureLock(root, slug, func() error {
349
+ // Controller → feature → claims; never acquire another feature.
350
+ return state.WithLock(claimsPath(root)+".lock", func() error {
351
+ code = dispatchLocked(root, slug, sub, phase, wave, role, handle, reason, roles, sliceIDs, review, contract, packet, outcome, result, all, claim, stdout, stderr, &recordedReturn)
352
+ return nil
353
+ })
343
354
  })
344
- }); err != nil {
355
+ }
356
+ controllerOpen := false
357
+ if sub == "open" && strings.EqualFold(phase, "build") {
358
+ for _, candidate := range roles {
359
+ controllerOpen = controllerOpen || strings.Contains(candidate, "wright")
360
+ }
361
+ }
362
+ if controllerOpen {
363
+ mode, err := autocompleteMode(root)
364
+ if err != nil {
365
+ fmt.Fprintf(stderr, "dispatch: BLOCKED: %v\n", err)
366
+ return 3
367
+ }
368
+ controllerOpen = mode
369
+ }
370
+ var lockErr error
371
+ if controllerOpen {
372
+ lockErr = parallel.WithControlLock(root, mutate)
373
+ } else {
374
+ lockErr = mutate()
375
+ }
376
+ if err := lockErr; err != nil {
345
377
  fmt.Fprintf(stderr, "dispatch: %v\n", err)
346
378
  return 3
347
379
  }
380
+ if recordedReturn {
381
+ mode, err := autocompleteMode(root)
382
+ if err != nil {
383
+ fmt.Fprintf(stderr, "dispatch: return recorded; global scheduling: %v\n", err)
384
+ return 3
385
+ }
386
+ if mode {
387
+ lines, err := FrontierLines(root, slug)
388
+ if err != nil {
389
+ fmt.Fprintf(stderr, "dispatch: return recorded; global scheduling: %v\n", err)
390
+ return 3
391
+ }
392
+ for _, line := range lines {
393
+ fmt.Fprintln(stdout, line)
394
+ }
395
+ }
396
+ }
348
397
  return code
349
398
  }
350
399
 
351
- func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles, sliceIDs []string, review, contract, packet, outcome, result string, all bool, claim dispatchClaim, stdout, stderr io.Writer) int {
400
+ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, roles, sliceIDs []string, review, contract, packet, outcome, result string, all bool, claim dispatchClaim, stdout, stderr io.Writer, recordedReturn *bool) int {
352
401
  f, path, err := loadDispatch(root, slug)
353
402
  if err != nil {
354
403
  fmt.Fprintf(stderr, "dispatch: %v\n", err)
@@ -386,6 +435,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
386
435
  if err := lintKnownRoles(root, phase, roles); err != nil {
387
436
  return fail("BLOCKED: %v", err)
388
437
  }
438
+ if isBuildWrightWave(w) {
439
+ binding, notices, err := autocompleteWriterAdmission(root, slug, w, f)
440
+ if err != nil {
441
+ return fail("BLOCKED: %v", err)
442
+ }
443
+ w.ControllerBatch = binding
444
+ for _, notice := range notices {
445
+ fmt.Fprintln(stdout, notice)
446
+ }
447
+ }
389
448
  rounds, code := admitWrightRounds(root, slug, f, w, review, stderr)
390
449
  if code != 0 {
391
450
  return code
@@ -422,10 +481,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
422
481
  }
423
482
  admissionPaths := slices.Clone(claimPaths)
424
483
  if isWrightWave(w) {
425
- admissionPaths = append(admissionPaths, sliceWriteSet(root, slug, w.Slices)...)
484
+ if len(w.AdmittedPaths) > 0 {
485
+ for _, id := range w.Slices {
486
+ admissionPaths = append(admissionPaths, w.AdmittedPaths[id]...)
487
+ }
488
+ } else {
489
+ admissionPaths = append(admissionPaths, sliceWriteSet(root, slug, w.Slices)...)
490
+ }
426
491
  }
427
492
  if len(claimPaths) == 0 {
428
- if err := checkAbandonedWriterClaims(root, admissionPaths); err != nil {
493
+ if err := checkWriterClaimsLocked(root, claim.session, admissionPaths); err != nil {
429
494
  return fail("BLOCKED: %v", err)
430
495
  }
431
496
  }
@@ -543,7 +608,7 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
543
608
 
544
609
  case "return":
545
610
  w := getWave()
546
- if w == nil || w.State != "sealed" {
611
+ if w == nil || (w.State != "sealed" && w.State != "complete") {
547
612
  return fail("wave %q is not sealed (state %s)", wave, waveState(w))
548
613
  }
549
614
  r := w.Roles[role]
@@ -553,6 +618,16 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
553
618
  if handle != "" && handle != r.Handle {
554
619
  return fail("handle %q does not match role %q's recorded handle %q", handle, role, r.Handle)
555
620
  }
621
+ if w.State == "complete" {
622
+ if !r.Returned || handle == "" || r.Handle == "" || w.Claim == "" {
623
+ return fail("completed wave %q needs a returned role, its recorded --handle, and an owned claim to reconcile", wave)
624
+ }
625
+ if !releaseWaveClaim(root, wave, w, stdout, stderr) {
626
+ return 3
627
+ }
628
+ *recordedReturn = true
629
+ return 0
630
+ }
556
631
  if r.Returned {
557
632
  return fail("role %q already returned", role)
558
633
  }
@@ -589,13 +664,14 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
589
664
  }
590
665
  return fail("%v", err)
591
666
  }
667
+ *recordedReturn = true
592
668
  recordMetricNote(root, slug, w.Phase, "return", role, note)
593
669
  fmt.Fprintf(stdout, "dispatch: %s returned (wave %s %s)\n", role, wave, w.State)
594
670
  if all {
595
671
  absorbWave(root, slug, w, stderr)
596
672
  }
597
- if all && w.Claim != "" {
598
- releaseWaveClaim(root, wave, w, stdout, stderr)
673
+ if all && w.Claim != "" && !releaseWaveClaim(root, wave, w, stdout, stderr) {
674
+ return 3
599
675
  }
600
676
  if stored != "" {
601
677
  head, _, _ := strings.Cut(note, " slice=")
@@ -699,33 +775,11 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
699
775
  return 2
700
776
  }
701
777
 
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.
778
+ // releaseWaveClaim releases w's claim or reconciles a verified prior release.
779
+ // Callers surface failure without undoing the recorded return or abandonment.
726
780
  func releaseWaveClaim(root, wave string, w *dispatchWave, stdout, stderr io.Writer) bool {
727
781
  n, err := releaseClaim(root, w.ClaimSession, w.Claim)
728
- if w.State == "abandoned" && errors.Is(err, errClaimAlreadyReleased) {
782
+ if errors.Is(err, errClaimAlreadyReleased) {
729
783
  fmt.Fprintf(stdout, "dispatch: wave %s claim %s already released\n", wave, w.Claim)
730
784
  return true
731
785
  }
@@ -914,21 +968,21 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
914
968
  if kind == "wright" {
915
969
  lines = append(lines, "dispatch: ADVISORY: wright contract reminder: name the writer-safe checks the wright runs (syntax/type/lint, unit and self-check suites, a narrow local smoke) and never forbid them; only artifact-producing, consumptive, or full canonical gates stay root-owned")
916
970
  }
971
+ if kind == "wright" {
972
+ if refusal := wrightFanoutRefusal(w); refusal != "" {
973
+ fmt.Fprintln(stderr, refusal)
974
+ return nil, 3
975
+ }
976
+ if refusal := widerBatchRefusal(root, slug, w.Slices); refusal != "" {
977
+ fmt.Fprintln(stderr, refusal)
978
+ return nil, 3
979
+ }
980
+ }
917
981
  if len(overCap) == 0 {
918
982
  if review != "" {
919
983
  fmt.Fprintf(stderr, "dispatch: --reslice-review given but no slice has reached its %d-round cap\n", wrightRoundsPerReview)
920
984
  return nil, 2
921
985
  }
922
- if kind == "wright" {
923
- if refusal := wrightFanoutRefusal(w); refusal != "" {
924
- fmt.Fprintln(stderr, refusal)
925
- return nil, 3
926
- }
927
- if refusal := widerBatchRefusal(root, slug, w.Slices); refusal != "" {
928
- fmt.Fprintln(stderr, refusal)
929
- return nil, 3
930
- }
931
- }
932
986
  return lines, 0
933
987
  }
934
988
  if review == "" {
@@ -1024,6 +1078,13 @@ func wrightLane(role string) (string, bool) {
1024
1078
  // errors are never refused: the check widens the batch, it never stops a
1025
1079
  // wave that already starts every remaining pick.
1026
1080
  func widerBatchRefusal(root, slug string, wave []string) string {
1081
+ mode, modeErr := autocompleteMode(root)
1082
+ if modeErr != nil {
1083
+ return fmt.Sprintf("dispatch: BLOCKED: %v", modeErr)
1084
+ }
1085
+ if mode {
1086
+ return ""
1087
+ }
1027
1088
  capN := min(afkMaxParallel(root), parallel.MaxParallelSlices)
1028
1089
  if capN < 2 {
1029
1090
  return ""