devrites 7.2.6 → 7.2.8

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 (95) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -1
  3. package/SECURITY.md +11 -7
  4. package/docs/command-map.md +4 -0
  5. package/docs/engine/commands.md +25 -3
  6. package/docs/orchestration.md +9 -0
  7. package/engine/internal/acceptance/cli.go +9 -5
  8. package/engine/internal/acceptance/run.go +97 -38
  9. package/engine/internal/devritespaths/paths.go +7 -2
  10. package/engine/internal/install/apply.go +5 -0
  11. package/engine/internal/install/merge.go +48 -6
  12. package/engine/internal/install/preflight.go +21 -3
  13. package/engine/internal/install/uninstall.go +9 -10
  14. package/engine/internal/lib/apply_bundle.go +7 -1
  15. package/engine/internal/lib/closeout.go +7 -7
  16. package/engine/internal/lib/context.go +10 -6
  17. package/engine/internal/lib/dispatch.go +81 -21
  18. package/engine/internal/lib/resolve.go +10 -5
  19. package/engine/internal/lib/state_edit.go +9 -2
  20. package/engine/internal/lib/taskgraph.go +94 -21
  21. package/engine/internal/lib/triggers.go +2 -2
  22. package/engine/internal/state/cursor.go +5 -5
  23. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  24. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +9 -8
  25. package/pack/.claude/skills/devrites-lib/reference/standards/principles.md +5 -6
  26. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  27. package/pack/.claude/skills/rite-autocomplete/reference/lookahead.md +18 -35
  28. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  29. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +26 -16
  30. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +5 -5
  31. package/pack/.claude/skills/rite-spec/SKILL.md +2 -2
  32. package/pack/.claude/skills/rite-temper/SKILL.md +4 -5
  33. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  34. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +9 -8
  35. package/pack/generated/claude/skills/devrites-lib/reference/standards/principles.md +5 -6
  36. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  37. package/pack/generated/claude/skills/rite-autocomplete/reference/lookahead.md +18 -35
  38. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  39. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +26 -16
  40. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +5 -5
  41. package/pack/generated/claude/skills/rite-spec/SKILL.md +2 -2
  42. package/pack/generated/claude/skills/rite-temper/SKILL.md +4 -5
  43. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  44. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +9 -8
  45. package/pack/generated/codex/skills/devrites-lib/reference/standards/principles.md +5 -6
  46. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  47. package/pack/generated/codex/skills/rite-autocomplete/reference/lookahead.md +18 -35
  48. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  49. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +26 -16
  50. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +5 -5
  51. package/pack/generated/codex/skills/rite-spec/SKILL.md +2 -2
  52. package/pack/generated/codex/skills/rite-temper/SKILL.md +4 -5
  53. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  54. package/pack/generated/devin/skills/devrites-lib/reference/standards/agents.md +9 -8
  55. package/pack/generated/devin/skills/devrites-lib/reference/standards/principles.md +5 -6
  56. package/pack/generated/devin/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  57. package/pack/generated/devin/skills/rite-autocomplete/reference/lookahead.md +18 -35
  58. package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  59. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +26 -16
  60. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +5 -5
  61. package/pack/generated/devin/skills/rite-spec/SKILL.md +2 -2
  62. package/pack/generated/devin/skills/rite-temper/SKILL.md +4 -5
  63. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  64. package/pack/generated/dsh/skills/devrites-lib/reference/standards/agents.md +9 -8
  65. package/pack/generated/dsh/skills/devrites-lib/reference/standards/principles.md +5 -6
  66. package/pack/generated/dsh/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  67. package/pack/generated/dsh/skills/rite-autocomplete/reference/lookahead.md +18 -35
  68. package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  69. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +26 -16
  70. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +5 -5
  71. package/pack/generated/dsh/skills/rite-spec/SKILL.md +2 -2
  72. package/pack/generated/dsh/skills/rite-temper/SKILL.md +4 -5
  73. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  74. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +13 -8
  75. package/pack/generated/omp/skills/devrites-lib/reference/standards/principles.md +5 -6
  76. package/pack/generated/omp/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  77. package/pack/generated/omp/skills/rite-autocomplete/reference/lookahead.md +18 -35
  78. package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  79. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +26 -16
  80. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +5 -5
  81. package/pack/generated/omp/skills/rite-spec/SKILL.md +2 -2
  82. package/pack/generated/omp/skills/rite-temper/SKILL.md +4 -5
  83. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +3 -3
  84. package/pack/generated/pi/skills/devrites-lib/reference/standards/agents.md +9 -8
  85. package/pack/generated/pi/skills/devrites-lib/reference/standards/principles.md +5 -6
  86. package/pack/generated/pi/skills/rite-autocomplete/reference/decision-policy.md +3 -1
  87. package/pack/generated/pi/skills/rite-autocomplete/reference/lookahead.md +18 -35
  88. package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +7 -8
  89. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +26 -16
  90. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +5 -5
  91. package/pack/generated/pi/skills/rite-spec/SKILL.md +2 -2
  92. package/pack/generated/pi/skills/rite-temper/SKILL.md +4 -5
  93. package/package.json +3 -3
  94. package/scripts/build-host-artifacts.sh +5 -6
  95. package/scripts/omp-generate.sh +24 -14
@@ -35,18 +35,12 @@ func (r *runner) uninstall() error {
35
35
  return err
36
36
  }
37
37
 
38
- // Per-path would-be content for a dry run; a nil value means the file
39
- // would be removed.
40
- dryTree := map[string][]byte{}
41
38
  for _, rel := range entries {
42
39
  merge, ok := hostpack.ManagedMergeForMarker(rel)
43
40
  if !ok {
44
41
  continue
45
42
  }
46
43
  if r.opts.DryRun {
47
- if err := dryRunStripMarker(dryTree, r.target, merge); err != nil {
48
- return err
49
- }
50
44
  fmt.Fprintf(r.opts.Stdout, " [merge-remove] %s\n", merge.DryRun)
51
45
  continue
52
46
  }
@@ -142,11 +136,16 @@ func (r *runner) uninstall() error {
142
136
  // dryRunStripMarker runs the real marker strip against an in-memory view of
143
137
  // the target (tree), so a dry run follows the same sequence as the real
144
138
  // uninstall and reports the same errors without writing anything.
145
- func dryRunStripMarker(tree map[string][]byte, target string, merge hostpack.ManagedMerge) error {
146
- if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel || merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
147
- return nil
139
+ func (r *runner) dryRunStripMarker(tree map[string][]byte, merge hostpack.ManagedMerge) error {
140
+ path := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
141
+ write := func(data []byte) error { tree[path] = data; return nil }
142
+ remove := func() error { tree[path] = nil; return nil }
143
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
144
+ return stripHooksWith(path, write, remove)
145
+ }
146
+ if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
147
+ return r.stripClaudeSettingsWith(path, write, remove)
148
148
  }
149
- path := filepath.Join(target, filepath.FromSlash(merge.TargetRel))
150
149
  data, seen := tree[path]
151
150
  if !seen {
152
151
  var err error
@@ -210,7 +210,12 @@ func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarg
210
210
  }
211
211
  pins := map[string]string{}
212
212
  for path, hash := range bundle.BaseHashes {
213
- pins[strings.TrimPrefix(filepath.ToSlash(path), prefix)] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
213
+ rel := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(filepath.ToSlash(path), prefix)))
214
+ pin := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(hash), "sha256:"))
215
+ if previous, ok := pins[rel]; ok && previous != pin {
216
+ problems = append(problems, fmt.Sprintf("bundle %d %s: conflicting base hashes for the same file", bi+1, path))
217
+ }
218
+ pins[rel] = pin
214
219
  }
215
220
  for _, file := range bundle.CandidateFiles {
216
221
  rel := strings.TrimPrefix(filepath.ToSlash(file.Path), prefix)
@@ -219,6 +224,7 @@ func collectBundleTargets(slug string, bundles []candidateBundle) ([]*bundleTarg
219
224
  problems = append(problems, where+": path must be a Markdown file inside the "+slug+" workspace")
220
225
  continue
221
226
  }
227
+ rel = filepath.ToSlash(filepath.Clean(rel))
222
228
  op := strings.ToLower(strings.TrimSpace(file.Operation))
223
229
  switch op {
224
230
  case "create", "replace":
@@ -62,11 +62,6 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
62
62
  archDisplay := filepath.ToSlash(filepath.Join("archive", slug))
63
63
  active := filepath.Join(dv, "ACTIVE")
64
64
 
65
- activeSlug, err := devritespaths.ActiveSlug(dv)
66
- if err != nil {
67
- fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
68
- return 1
69
- }
70
65
  // A broken symlink at the destination still counts as "already there", so use
71
66
  // Lstat rather than Stat to avoid silently overwriting it.
72
67
  if _, err := os.Lstat(arch); err == nil {
@@ -84,7 +79,7 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
84
79
  // open file, so there the lock is released just before the rename.
85
80
  code := 1
86
81
  if err := state.WithFeatureLockRelease(dv, slug, func(release func()) error {
87
- code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, activeSlug, slug, stdout, stderr)
82
+ code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
88
83
  return nil
89
84
  }); err != nil {
90
85
  fmt.Fprintf(stderr, "state close: cannot lock feature %s: %v\n", slug, err)
@@ -96,7 +91,12 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
96
91
  // closeOutLocked performs the rename and the conditional ACTIVE clear. It runs
97
92
  // under the feature lock held by CloseOut and reports an exit code rather than
98
93
  // an error, preserving CloseOut's documented exit codes.
99
- func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active, activeSlug, slug string, stdout, stderr io.Writer) int {
94
+ func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
95
+ activeSlug, err := devritespaths.PhysicalActiveSlug(filepath.Dir(active))
96
+ if err != nil {
97
+ fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
98
+ return 1
99
+ }
100
100
  // Fail closed: if the move does not happen, leave the cursor alone and do not
101
101
  // claim success: a lost audit trail must never look like a clean close-out.
102
102
  if err := os.MkdirAll(archiveDir, 0o755); err != nil {
@@ -57,7 +57,9 @@ every ID the block cites).
57
57
  Repeat --slice for a recheck that spans several slices. In phase build a
58
58
  --role bundle other than slice-wright requires --slice (exit 3). A root
59
59
  build or autocomplete bundle (no --role, no --slice) defaults to --slice
60
- <active_slice> when state.md names one present in tasks.md; --whole opts out.
60
+ <active_slice> when state.md names one present in tasks.md, unless
61
+ .devrites/AFK max_parallel is 2 or more or a parallel lease is running
62
+ (the bundle stays whole so the other slices stay visible); --whole opts out.
61
63
  --recheck <inventory> (phase build, with --role and --slice) builds a
62
64
  bounded recheck bundle: the inventory file (relative to the workspace)
63
65
  and core.md are inlined; every other pack file becomes a by-reference
@@ -78,7 +80,7 @@ func RunContext(root string, args []string, stdout, stderr io.Writer) int {
78
80
  }
79
81
  // A Build reviewer judges one slice; an unscoped bundle inlines whole
80
82
  // plan/tasks/architecture into every dispatch.
81
- if opts.phase == "build" && opts.role != "" && opts.role != "slice-wright" && opts.slice == "" {
83
+ if opts.phase == "build" && opts.role != "" && roleBase(opts.role) != "slice-wright" && opts.slice == "" {
82
84
  fmt.Fprintf(stderr, "context: BLOCKED: unscoped build reviewer bundle: --role %s in phase build needs --slice <SLICE-ID>\n", opts.role)
83
85
  return 3
84
86
  }
@@ -91,10 +93,11 @@ func RunContext(root string, args []string, stdout, stderr io.Writer) int {
91
93
  return 2
92
94
  }
93
95
  }
94
- // The root's own build/autocomplete bundle holds one slice at a time;
95
- // whole tasks/plan/decisions ride every later turn otherwise.
96
+ // One active slice per bundle, except a parallel batch: the root has to
97
+ // see the other slices to start them. Whole tasks/plan/decisions ride
98
+ // that batch's turns; serial runs stay scoped.
96
99
  autoScope := false
97
- if featureDir != "" && opts.role == "" && opts.slice == "" && !opts.whole && (opts.phase == "build" || opts.phase == "autocomplete") {
100
+ if featureDir != "" && opts.role == "" && opts.slice == "" && !opts.whole && (opts.phase == "build" || opts.phase == "autocomplete") && !parallelPlanVisible(root, opts.slug) {
98
101
  if id := activeSliceScope(featureDir); id != "" {
99
102
  opts.slices, opts.slice, autoScope = []string{id}, id, true
100
103
  }
@@ -452,7 +455,7 @@ func workspaceReadSet(manifest loadsManifest, role string) ([]string, bool) {
452
455
  if role == "" {
453
456
  return manifest.Workspace, false
454
457
  }
455
- if list, ok := manifest.WorkspaceByRole[role]; ok {
458
+ if list, ok := manifest.WorkspaceByRole[roleBase(role)]; ok {
456
459
  return list, true
457
460
  }
458
461
  return manifest.Workspace, false
@@ -912,6 +915,7 @@ func parseLoadsManifest(text string) (loadsManifest, bool, error) {
912
915
  }
913
916
 
914
917
  func roleAgentPath(manifest loadsManifest, role string) string {
918
+ role = roleBase(role)
915
919
  if manifest.Agents != nil {
916
920
  if rel, ok := manifest.Agents[role]; ok {
917
921
  return rel
@@ -50,6 +50,7 @@ For a reviewer role, return --result <file> stores the return verbatim as
50
50
  packets/<wave>-<role>.return.md (never overwritten), derives the outcome
51
51
  from its Outcome/Counts head (malformed records gap), refuses an --outcome
52
52
  that disagrees, and records its Finding: keys for metrics summary.
53
+ Reviewer returns require --result or --outcome; omitting both leaves the role pending.
53
54
 
54
55
  A build wave with a wright role must name its slice(s) with --slice; a vet
55
56
  wave may (slice-bound recheck), and so may a reviewer-only wave (uncapped),
@@ -57,9 +58,11 @@ which keys its --result findings on that slice. A --result file may already
57
58
  be the packets/ path; it is then kept as the stored return. Every 5 rounds of one kind on one slice, the
58
59
  next open is blocked until --reslice-review names a drift.md
59
60
  "## <DRIFT-ID>" heading for that slice. Under .devrites/AFK max_parallel >= 2,
60
- a first-round build wright wave narrower than the parallel select --from-plan
61
- batch is blocked until evidence.md records "serial: <skipped ids> — <reason>";
62
- repair rounds and lease siblings are never blocked.
61
+ a build wright wave that leaves parallel select --from-plan picks unstarted is
62
+ blocked until evidence.md records "serial: <skipped ids> — <reason>". A repair
63
+ round or a running lease slice does not waive those picks. A wave naming two
64
+ or more slices needs one slice-wright.<SLICE-ID> role per slice; one shared
65
+ role has one handle, so the other slices never start. HITL is never width-blocked.
63
66
 
64
67
  Root-edit tripwire (ADR-0038): the first open records tree-baseline.json
65
68
  (dirty product paths outside .devrites/ and .scratch/, accepted as user work);
@@ -453,6 +456,9 @@ func dispatchLocked(root, slug, sub, phase, wave, role, handle, reason string, r
453
456
  if r.Returned {
454
457
  return fail("role %q already returned", role)
455
458
  }
459
+ if isReviewerRole(role) && result == "" && outcome == "" {
460
+ return fail("reviewer %q needs --result <file> or --outcome <findings|no-findings|gap>", role)
461
+ }
456
462
  if outcome != "" && !slices.Contains([]string{"findings", "no-findings", "gap"}, outcome) {
457
463
  return fail("--outcome %q is not findings, no-findings, or gap", outcome)
458
464
  }
@@ -742,7 +748,6 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
742
748
  }
743
749
  }
744
750
  var lines, overCap []string
745
- firstRound := true
746
751
  for _, id := range w.Slices {
747
752
  prior, reviews := 0, 0
748
753
  var unreturned []string
@@ -762,7 +767,6 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
762
767
  }
763
768
  }
764
769
  round := prior + 1
765
- firstRound = firstRound && prior == 0
766
770
  if round > wrightRoundsPerReview*(1+reviews) {
767
771
  overCap = append(overCap, id)
768
772
  }
@@ -780,7 +784,11 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
780
784
  fmt.Fprintf(stderr, "dispatch: --reslice-review given but no slice has reached its %d-round cap\n", wrightRoundsPerReview)
781
785
  return nil, 2
782
786
  }
783
- if kind == "wright" && firstRound {
787
+ if kind == "wright" {
788
+ if refusal := wrightFanoutRefusal(w); refusal != "" {
789
+ fmt.Fprintln(stderr, refusal)
790
+ return nil, 3
791
+ }
784
792
  if refusal := widerBatchRefusal(root, slug, w.Slices); refusal != "" {
785
793
  fmt.Fprintln(stderr, refusal)
786
794
  return nil, 3
@@ -816,36 +824,88 @@ func admitWrightRounds(root, slug string, f *dispatchFile, w *dispatchWave, revi
816
824
  }
817
825
 
818
826
  // serialRecordRe matches an evidence.md `serial: <ids> — <reason>` record
819
- // (the reason may open with a code span, never be empty); group 1 holds the ids.
820
- var serialRecordRe = regexp.MustCompile("(?mi)^[ \\t]*(?:[-*][ \\t]+)?`?serial:([^\\n]*?)(?:[ \\t]*[—–]|[ \\t]+--?[ \\t])[ \\t]*(?:[^\\s`]|`[^\\s`])")
827
+ // (the reason may open with a code span, never be empty). Group 1 holds the
828
+ // ids; group 2 holds the reason.
829
+ var serialRecordRe = regexp.MustCompile("(?mi)^[ \\t]*(?:[-*][ \\t]+)?`?serial:([^\\n]*?)(?:[ \\t]*[—–]|[ \\t]+--?[ \\t])[ \\t]*([^\\s`][^\\n]*|`[^\\s`][^\\n]*)")
830
+
831
+ // A repair, an open wave, or "already started" is not a shared surface.
832
+ // Those reasons do not waive a pick unless they also name a path.
833
+ var transientSerialReasonRe = regexp.MustCompile(`(?i)\b(?:repairs?|repairing|repaired|already started|in flight|open wave|sealed wave|running lease|in the lease)\b`)
834
+ var serialSurfaceRe = regexp.MustCompile("`[^`\\n]*\\.[^`\\n]+`|(?i)\\b[\\w./-]+\\.[a-z0-9]+\\b")
835
+
836
+ func serialReasonWaives(reason string) bool {
837
+ if transientSerialReasonRe.MatchString(reason) && !serialSurfaceRe.MatchString(reason) {
838
+ return false
839
+ }
840
+ return true
841
+ }
821
842
 
822
843
  var sliceTokenRe = regexp.MustCompile(`(?i)\bSLICE-\d+\b`)
823
844
 
824
- // widerBatchRefusal refuses a first-round AFK build wright wave when
825
- // `parallel select --from-plan` picks more slices than the wave holds and
826
- // evidence.md has no `serial:` record naming each skipped pick. HITL runs,
827
- // lease siblings, slices the plan does not list as eligible, and selection
828
- // errors are never refused: the check widens batches, it never stops a build.
845
+ // wrightFanoutRefusal refuses a build wright wave that names several slices
846
+ // but not one slice-wright role per slice. One role has one host handle, so
847
+ // the other slices would never start.
848
+ func wrightFanoutRefusal(w *dispatchWave) string {
849
+ if len(w.Slices) < 2 {
850
+ return ""
851
+ }
852
+ lanes := map[string]bool{}
853
+ bare := 0
854
+ for role := range w.Roles {
855
+ if roleBase(role) != "slice-wright" {
856
+ continue
857
+ }
858
+ lane, ok := wrightLane(role)
859
+ if !ok {
860
+ bare++
861
+ continue
862
+ }
863
+ lanes[sliceNumber(lane)] = true
864
+ }
865
+ if bare > 0 || len(lanes) != len(w.Slices) {
866
+ return fmt.Sprintf("dispatch: BLOCKED: %d slices need one `--role slice-wright.<SLICE-ID>` each so every writer gets a handle; one shared slice-wright role starts only one agent", len(w.Slices))
867
+ }
868
+ for _, id := range w.Slices {
869
+ if !lanes[sliceNumber(id)] {
870
+ return fmt.Sprintf("dispatch: BLOCKED: %s has no `--role slice-wright.%s`", id, id)
871
+ }
872
+ }
873
+ return ""
874
+ }
875
+
876
+ // wrightLane is the SLICE-### suffix of a slice-wright.<SLICE-ID> role.
877
+ func wrightLane(role string) (string, bool) {
878
+ base, lane, ok := strings.Cut(strings.TrimPrefix(role, "devrites-"), ".")
879
+ if !ok || base != "slice-wright" || !state.ValidSliceID(lane) {
880
+ return "", false
881
+ }
882
+ return lane, true
883
+ }
884
+
885
+ // widerBatchRefusal refuses an AFK build wright wave when
886
+ // `parallel select --from-plan` still picks slices this wave does not start
887
+ // and evidence.md has no `serial:` record for each. A repair round or a
888
+ // running lease slice does not waive those picks. HITL runs and selection
889
+ // errors are never refused: the check widens the batch, it never stops a
890
+ // wave that already starts every remaining pick.
829
891
  func widerBatchRefusal(root, slug string, wave []string) string {
830
892
  capN := min(afkMaxParallel(root), parallel.MaxParallelSlices)
831
893
  if capN < 2 {
832
894
  return ""
833
895
  }
834
- eligible, batch, err := planBatch(root, slug, capN)
835
- if err != nil || len(batch) <= len(wave) {
896
+ _, batch, err := planBatch(root, slug, capN)
897
+ if err != nil || len(batch) == 0 {
836
898
  return ""
837
899
  }
838
- for _, id := range wave {
839
- if !containsSlice(eligible, id) {
840
- return ""
841
- }
842
- }
843
900
  var recorded []string
844
901
  if raw, err := readWorkspaceArtifact(root, slug, "evidence.md"); err == nil {
845
902
  if visible, err := markdowntext.Structural(raw); err == nil {
846
903
  raw = visible
847
904
  }
848
905
  for _, m := range serialRecordRe.FindAllStringSubmatch(string(raw), -1) {
906
+ if len(m) < 3 || !serialReasonWaives(m[2]) {
907
+ continue
908
+ }
849
909
  recorded = append(recorded, sliceTokenRe.FindAllString(m[1], -1)...)
850
910
  }
851
911
  }
@@ -858,7 +918,7 @@ func widerBatchRefusal(root, slug string, wave []string) string {
858
918
  if len(missing) == 0 {
859
919
  return ""
860
920
  }
861
- return fmt.Sprintf("dispatch: BLOCKED: AFK max_parallel %d: `%s` picks %s, wider than this wave (%s); run that batch per rite-build/reference/parallel-batch.md (`parallel create`, then one wave with a --slice per sibling), or record `serial: %s — <shared surface or reason>` in evidence.md",
921
+ return fmt.Sprintf("dispatch: BLOCKED: AFK max_parallel %d: `%s` picks %s, wider than this wave (%s); start those picks (`parallel create`, or `parallel add` while a lease runs) and dispatch them, or record `serial: %s — <shared surface or reason>` in evidence.md",
862
922
  capN, planSelectCommand(filepath.Dir(root), slug, capN), strings.Join(batch, ","), strings.Join(wave, ","), strings.Join(missing, ","))
863
923
  }
864
924
 
@@ -361,6 +361,10 @@ func clearAwaiting(sfile, qid string) error {
361
361
  return fmt.Errorf("read state %s: %w", sfile, err)
362
362
  }
363
363
  lines := splitLinesNoTrailing(data)
364
+ visible, err := structuralLines(data)
365
+ if err != nil {
366
+ return fmt.Errorf("read state %s: %w", sfile, err)
367
+ }
364
368
  resumePolicy, _ := state.PolicyFor(state.PhaseBuild)
365
369
  if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
366
370
  if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
@@ -373,7 +377,7 @@ func clearAwaiting(sfile, qid string) error {
373
377
  // First check whether the awaiting block references this question at all.
374
378
  inAw := false
375
379
  var awaitingLines []string
376
- for _, line := range lines {
380
+ for _, line := range visible {
377
381
  switch {
378
382
  case awaitingRe.MatchString(line):
379
383
  inAw = true
@@ -400,7 +404,8 @@ func clearAwaiting(sfile, qid string) error {
400
404
  ts := nowUTC()
401
405
  var out []string
402
406
  inAw, inLog, logAppended := false, false, false
403
- for _, line := range lines {
407
+ for i, original := range lines {
408
+ line := visible[i]
404
409
  if awaitingRe.MatchString(line) {
405
410
  inAw = true
406
411
  continue
@@ -413,7 +418,7 @@ func clearAwaiting(sfile, qid string) error {
413
418
  }
414
419
  switch {
415
420
  case logHdrRe.MatchString(line):
416
- out = append(out, line)
421
+ out = append(out, original)
417
422
  inLog = true
418
423
  continue
419
424
  case inLog && hdrSpaceRe.MatchString(line):
@@ -422,10 +427,10 @@ func clearAwaiting(sfile, qid string) error {
422
427
  logAppended = true
423
428
  }
424
429
  inLog = false
425
- out = append(out, line)
430
+ out = append(out, original)
426
431
  continue
427
432
  }
428
- out = append(out, line)
433
+ out = append(out, original)
429
434
  }
430
435
  if inLog && !logAppended {
431
436
  out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
@@ -58,10 +58,17 @@ func RunStateSet(root string, args []string, stdout, stderr io.Writer) int {
58
58
  return fail(stderr, stateSetUsage, 2)
59
59
  }
60
60
  }
61
- if !cursorKeyPattern.MatchString(key) || seen[key] {
61
+ if !cursorKeyPattern.MatchString(key) {
62
62
  return fail(stderr, fmt.Sprintf("state set: key %q is not a snake_case cursor key or repeats", key), 2)
63
63
  }
64
- seen[key] = true
64
+ if canonical, ok := state.CanonicalCursorKey(key); ok {
65
+ key = canonical
66
+ }
67
+ identity := strings.ReplaceAll(key, "_", "")
68
+ if seen[identity] {
69
+ return fail(stderr, fmt.Sprintf("state set: key %q is not a snake_case cursor key or repeats", key), 2)
70
+ }
71
+ seen[identity] = true
65
72
  changes = append(changes, change{key, value})
66
73
  }
67
74
  sfile, code := stateEditWorkspace(root, slug, "state.md", stderr)
@@ -1,6 +1,7 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "errors"
4
5
  "fmt"
5
6
  "os"
6
7
  "path"
@@ -195,10 +196,10 @@ func PrecheckSkips(root, slug, slice string, gateIDs []string) (map[string]strin
195
196
  return skips, nil
196
197
  }
197
198
 
198
- // planReady lists, in plan order, the tasks.md slices state.md does not check
199
- // built whose dependencies it all checks built, each with its sliceWritePaths
200
- // plus its dependency manifest and lockfile when it declares external
201
- // dependencies. root is the .devrites directory.
199
+ // planReady lists, in plan order, the tasks.md slices that are not built whose
200
+ // dependencies are all built, each with its sliceWritePaths plus its dependency
201
+ // manifest and lockfile when it declares external dependencies. root is the
202
+ // .devrites directory.
202
203
  func planReady(root, slug string) ([]parallel.SlicePaths, error) {
203
204
  raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
204
205
  if err != nil {
@@ -233,8 +234,11 @@ func planReady(root, slug string) ([]parallel.SlicePaths, error) {
233
234
  // builtWordRe marks a checked state.md item as a built checkpoint.
234
235
  var builtWordRe = regexp.MustCompile(`(?i)\bbuilt\b`)
235
236
 
236
- // builtSlices returns the slice numbers state.md checks built (`- [x] SLICE-004
237
- // built — <sha>`, or `- [x] Slice 4: <name> — built`), outside fenced examples.
237
+ // builtSlices returns the slice numbers that are built, outside fenced examples.
238
+ // A state.md checkbox (`- [x] SLICE-004 built — <sha>`, or `- [x] Slice 4:
239
+ // <name> — built`) counts. So does an evidence.md heading that says that slice
240
+ // was checkpointed and whose section has a `Checkpoint: <sha>` line — a missed
241
+ // checkbox must not keep dependents out of the ready set.
238
242
  func builtSlices(root, slug string) (map[string]bool, error) {
239
243
  raw, err := readWorkspaceArtifact(root, slug, "state.md")
240
244
  if err != nil {
@@ -253,35 +257,86 @@ func builtSlices(root, slug string) (map[string]bool, error) {
253
257
  built[strings.TrimLeft(m[1], "0")] = true
254
258
  }
255
259
  }
260
+ extra, err := evidenceBuilt(root, slug)
261
+ if err != nil {
262
+ return nil, err
263
+ }
264
+ for id := range extra {
265
+ built[id] = true
266
+ }
256
267
  return built, nil
257
268
  }
258
269
 
259
- // manifestDenialRe is prose that says the slice adds no dependency.
260
- var manifestDenialRe = regexp.MustCompile(`(?i)\b(?:no new|no product|existing|already-admitted|already admitted|stdlib)\b`)
270
+ var (
271
+ checkpointedHeadingRe = regexp.MustCompile(`(?i)\bcheckpointed\b`)
272
+ notCheckpointedRe = regexp.MustCompile(`(?i)\bnot checkpointed\b|\bcheckpoint (?:refused|reverted|failed)\b`)
273
+ checkpointSHARe = regexp.MustCompile(`(?m)^[ \t]*Checkpoint: [0-9a-fA-F]{7,40}\b`)
274
+ )
275
+
276
+ // evidenceBuilt reads evidence.md checkpoint sections. A missing file is none.
277
+ func evidenceBuilt(root, slug string) (map[string]bool, error) {
278
+ raw, err := readWorkspaceArtifact(root, slug, "evidence.md")
279
+ if err != nil {
280
+ if errors.Is(err, os.ErrNotExist) {
281
+ return map[string]bool{}, nil
282
+ }
283
+ return nil, err
284
+ }
285
+ masked, err := markdowntext.Structural(raw)
286
+ if err != nil {
287
+ return nil, fmt.Errorf("evidence.md: %w", err)
288
+ }
289
+ text := string(masked)
290
+ if strings.HasPrefix(text, "## ") {
291
+ text = "\n" + text
292
+ }
293
+ built := map[string]bool{}
294
+ for part := range strings.SplitSeq(text, "\n## ") {
295
+ heading, body, _ := strings.Cut(part, "\n")
296
+ var ids []string
297
+ for _, id := range sliceTokenRe.FindAllString(heading, -1) {
298
+ ids = append(ids, sliceNumber(id))
299
+ }
300
+ if len(ids) == 0 {
301
+ continue
302
+ }
303
+ if notCheckpointedRe.MatchString(heading) {
304
+ for _, id := range ids {
305
+ delete(built, id)
306
+ }
307
+ continue
308
+ }
309
+ if checkpointedHeadingRe.MatchString(heading) && checkpointSHARe.MatchString(body) {
310
+ for _, id := range ids {
311
+ built[id] = true
312
+ }
313
+ }
314
+ }
315
+ return built, nil
316
+ }
261
317
 
262
318
  // A backtick name, module path, npm scope, or x.y.z pin. go.mod/go.sum is not a module path.
319
+ // A toolchain version (Go 1.22.0) is not a package add.
263
320
  var (
264
- backtickPkgRe = regexp.MustCompile("`([^`]+)`")
265
- modulePathRe = regexp.MustCompile(`(?i)(?:[a-z0-9-]+\.)+[a-z]{2,}/[a-z0-9._@~+-]+`)
266
- npmScopeRe = regexp.MustCompile(`(?i)@[a-z0-9._-]+/[a-z0-9._-]+`)
267
- versionPinRe = regexp.MustCompile(`(?i)\bv?\d+\.\d+\.\d+\b`)
268
- manifestHostRe = regexp.MustCompile(`(?i)\.(?:mod|sum|json|toml|yaml|yml|lock|lockb)$`)
321
+ backtickPkgRe = regexp.MustCompile("`([^`]+)`")
322
+ modulePathRe = regexp.MustCompile(`(?i)(?:[a-z0-9-]+\.)+[a-z]{2,}/[a-z0-9._@~+-]+`)
323
+ npmScopeRe = regexp.MustCompile(`(?i)@[a-z0-9._-]+/[a-z0-9._-]+`)
324
+ versionPinRe = regexp.MustCompile(`(?i)\bv?\d+\.\d+\.\d+\b`)
325
+ toolchainVersionRe = regexp.MustCompile(`(?i)\b(?:go|golang|node|bun|npm|pnpm|yarn|rustc|python|ruby|java)\s+v?\d+\.\d+\.\d+\b`)
326
+ manifestHostRe = regexp.MustCompile(`(?i)\.(?:mod|sum|json|toml|yaml|yml|lock|lockb)$`)
269
327
  )
270
328
 
271
329
  // externalClaimsManifest reports whether External dependencies names a package
272
330
  // add, so parallel select reserves the nearest manifest. A none prefix does
273
- // not. shortcut: existing/no-new prose with no package coordinate does not
274
- // either; name the module, version, scope, or `package` when the slice edits one.
331
+ // not. Prose with no module path, npm scope, `package`, or non-toolchain
332
+ // version pin does not either. shortcut: name the package when the slice
333
+ // edits a manifest; "Go 1.22.0" or "standard library" is not a package add.
275
334
  func externalClaimsManifest(external string) bool {
276
335
  ext := strings.ToLower(strings.Trim(strings.TrimSpace(external), "`"))
277
336
  if ext == "" || ext == "-" || ext == "n/a" || strings.HasPrefix(ext, "none") {
278
337
  return false
279
338
  }
280
- raw := strings.TrimSpace(external)
281
- if manifestDenialRe.MatchString(raw) && !namesPackage(raw) {
282
- return false
283
- }
284
- return true
339
+ return namesPackage(strings.TrimSpace(external))
285
340
  }
286
341
 
287
342
  // namesPackage reports a module path, version pin, npm scope, or `package`
@@ -293,7 +348,7 @@ func namesPackage(raw string) bool {
293
348
  return true
294
349
  }
295
350
  }
296
- if npmScopeRe.MatchString(raw) || versionPinRe.MatchString(raw) {
351
+ if npmScopeRe.MatchString(raw) || versionPinRe.MatchString(toolchainVersionRe.ReplaceAllString(raw, " ")) {
297
352
  return true
298
353
  }
299
354
  for _, loc := range modulePathRe.FindAllStringIndex(raw, -1) {
@@ -396,6 +451,24 @@ func afkMaxParallel(root string) int {
396
451
  return n
397
452
  }
398
453
 
454
+ // parallelPlanVisible reports that the root must see every slice. An
455
+ // unattended cap above 1, or a running parallel lease, is a batch: scoping
456
+ // the bundle to active_slice hides the siblings that batch is supposed to start.
457
+ func parallelPlanVisible(devritesRoot, slug string) bool {
458
+ if afkMaxParallel(devritesRoot) >= 2 {
459
+ return true
460
+ }
461
+ if slug == "" {
462
+ return false
463
+ }
464
+ path, err := parallel.LeasePath(filepath.Dir(devritesRoot), slug)
465
+ if err != nil {
466
+ return false
467
+ }
468
+ lease, err := parallel.ReadLease(path)
469
+ return err == nil && lease.Status == parallel.StatusRunning
470
+ }
471
+
399
472
  func allDone(ids []string, done map[string]bool) bool {
400
473
  for _, id := range ids {
401
474
  if !done[id] {
@@ -52,7 +52,7 @@ var (
52
52
  // corpus (minus the template's AI-SPEC annex) names a model, agent, RAG, or
53
53
  // tool surface.
54
54
  func keepModelSurface(role string, facts triggerFacts) bool {
55
- return role == "security-auditor" || facts.aiSpec || strings.TrimSpace(facts.corpus) == "" ||
55
+ return roleBase(role) == "security-auditor" || facts.aiSpec || strings.TrimSpace(facts.corpus) == "" ||
56
56
  aiSignalRe.MatchString(aiAnnexRe.ReplaceAllString(facts.corpus, "$1"))
57
57
  }
58
58
 
@@ -102,7 +102,7 @@ func suggestTriggers(manifest loadsManifest, root, featureDir string, opts conte
102
102
  }
103
103
 
104
104
  func gatherTriggerFacts(root, featureDir, role string) triggerFacts {
105
- facts := triggerFacts{role: role}
105
+ facts := triggerFacts{role: roleBase(role)}
106
106
  if root != "" {
107
107
  afkPath := filepath.Join(root, "AFK")
108
108
  if info, err := os.Stat(afkPath); err == nil && info.Mode().IsRegular() {
@@ -265,7 +265,7 @@ func ConvertCursorToTable(lines []string) ([]string, bool) {
265
265
  if strings.Contains(value, "|") {
266
266
  continue
267
267
  }
268
- spelling, ok := canonicalCursorSpelling(key)
268
+ spelling, ok := CanonicalCursorKey(key)
269
269
  if !ok {
270
270
  continue
271
271
  }
@@ -278,14 +278,14 @@ func ConvertCursorToTable(lines []string) ([]string, bool) {
278
278
  }
279
279
 
280
280
  func isMigratableCursorKey(key string) bool {
281
- _, ok := canonicalCursorSpelling(key)
281
+ _, ok := CanonicalCursorKey(key)
282
282
  return ok
283
283
  }
284
284
 
285
- // canonicalCursorSpelling maps a cursor key (including aliases) to the
285
+ // CanonicalCursorKey maps a cursor key (including aliases) to the
286
286
  // canonical table-row spelling. Matching is normalization-based, so
287
- // "Next step" resolves to the "next_action" spelling.
288
- func canonicalCursorSpelling(key string) (string, bool) {
287
+ // "Next step" resolves to the "next_action" spelling. Unknown extensions return false.
288
+ func CanonicalCursorKey(key string) (string, bool) {
289
289
  normalized := normalizeCursorKey(key)
290
290
  canonicals := []string{CursorPhase, CursorStatus, CursorNextAction, CursorQuestionID,
291
291
  CursorActiveSlice, CursorAFKSlicesRemaining, CursorSequenceParent,
@@ -169,9 +169,9 @@ named in the re-dispatch contract; changes outside task paths stop for the human
169
169
 
170
170
  ## Scale limits
171
171
 
172
- The role table bounds rosters. Use roughly four concurrent read-only reviewers,
173
- counting children any leaf spawned; eight only with disjoint paths/resources.
174
- Refuse unbounded swarms and role substitution: sharpen scope or use a bounded second pass when coverage is missing.
172
+ The role table bounds rosters. Use up to ten concurrent read-only reviewers,
173
+ the same cap as a writer batch, counting children any leaf spawned.
174
+ Refuse a larger swarm and role substitution: sharpen scope or use a bounded second pass when coverage is missing.
175
175
  Overlapping reviewers increase duplicate findings and reconciliation work.
176
176
 
177
177
  ## Children are tools, not peers