devrites 4.1.0 → 4.3.0

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 (133) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +69 -6
  4. package/docs/orchestration.md +6 -3
  5. package/engine/go.mod +1 -1
  6. package/engine/internal/gate/gate.go +154 -46
  7. package/engine/internal/gate/gate_test.go +290 -2
  8. package/engine/internal/gate/readiness_binding.go +66 -100
  9. package/engine/internal/gate/readiness_binding_test.go +126 -3
  10. package/engine/internal/lib/cli_observe.go +66 -0
  11. package/engine/internal/lib/observe_summary.go +79 -0
  12. package/engine/internal/lib/resolve.go +6 -6
  13. package/engine/internal/lib/skilltrust.go +196 -0
  14. package/engine/internal/lib/taskgraph.go +162 -0
  15. package/engine/internal/lib/taskgraph_test.go +150 -0
  16. package/engine/internal/lib/workspace_read.go +29 -0
  17. package/engine/internal/parallel/cli.go +467 -0
  18. package/engine/internal/parallel/git.go +181 -0
  19. package/engine/internal/parallel/lease.go +244 -0
  20. package/engine/internal/parallel/ops.go +416 -0
  21. package/engine/internal/parallel/parallel_test.go +242 -0
  22. package/engine/internal/parallel/pathdisjoint.go +129 -0
  23. package/engine/internal/parallel/pathdisjoint_test.go +97 -0
  24. package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
  25. package/engine/internal/state/feature.go +1 -100
  26. package/engine/internal/state/observation.go +584 -0
  27. package/engine/internal/state/observation_open_other.go +9 -0
  28. package/engine/internal/state/observation_open_unix.go +12 -0
  29. package/engine/internal/state/observation_test.go +960 -0
  30. package/engine/internal/state/observation_unix_test.go +68 -0
  31. package/engine/internal/state/schema.go +60 -160
  32. package/engine/internal/state/state_test.go +489 -101
  33. package/engine/internal/state/status.go +97 -53
  34. package/engine/main.go +72 -3
  35. package/engine/root_routing_test.go +1 -1
  36. package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
  37. package/engine/tests/adr_0011_define_plan_test.go +14 -12
  38. package/engine/tests/gate_test.go +403 -28
  39. package/engine/tests/workspace_observation_migration_test.go +1065 -0
  40. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  41. package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  42. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  43. package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  44. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  45. package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  46. package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
  47. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  48. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
  49. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  50. package/pack/.claude/skills/rite-build/SKILL.md +46 -81
  51. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
  52. package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  53. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  54. package/pack/.claude/skills/rite-build/reference/output.md +2 -0
  55. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
  56. package/pack/.claude/skills/rite-build/reference/phase-contract.md +9 -22
  57. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  58. package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  59. package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
  60. package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
  61. package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
  62. package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  63. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  64. package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  65. package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
  66. package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
  67. package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  68. package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
  69. package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
  70. package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
  71. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  72. package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  73. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  74. package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  75. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  76. package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  77. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
  78. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  79. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
  80. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  81. package/pack/generated/claude/skills/rite-build/SKILL.md +46 -81
  82. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
  83. package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  84. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  85. package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
  86. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
  87. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +9 -22
  88. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  89. package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  90. package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
  91. package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
  92. package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
  93. package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  94. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  95. package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  96. package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
  97. package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
  98. package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  99. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
  100. package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
  101. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
  102. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
  103. package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  104. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  105. package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  106. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  107. package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  108. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
  109. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  110. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
  111. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  112. package/pack/generated/codex/skills/rite-build/SKILL.md +46 -81
  113. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
  114. package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  115. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
  116. package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
  117. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
  118. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +9 -22
  119. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
  120. package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
  121. package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
  122. package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
  123. package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
  124. package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
  125. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
  126. package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
  127. package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
  128. package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
  129. package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
  130. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
  131. package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
  132. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
  133. package/package.json +1 -1
@@ -0,0 +1,242 @@
1
+ package parallel
2
+
3
+ import (
4
+ "os"
5
+ "os/exec"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ func gitOk(t *testing.T, dir string, args ...string) string {
12
+ t.Helper()
13
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
14
+ cmd.Env = append(os.Environ(),
15
+ "GIT_CONFIG_NOSYSTEM=1",
16
+ "GIT_TERMINAL_PROMPT=0",
17
+ "GIT_AUTHOR_NAME=DevRites Test",
18
+ "GIT_AUTHOR_EMAIL=devrites@example.invalid",
19
+ "GIT_COMMITTER_NAME=DevRites Test",
20
+ "GIT_COMMITTER_EMAIL=devrites@example.invalid",
21
+ )
22
+ out, err := cmd.CombinedOutput()
23
+ if err != nil {
24
+ t.Fatalf("git %v: %v\n%s", args, err, out)
25
+ }
26
+ return strings.TrimSpace(string(out))
27
+ }
28
+
29
+ func setupRepo(t *testing.T) (repo, base string) {
30
+ t.Helper()
31
+ repo = t.TempDir()
32
+ gitOk(t, repo, "init")
33
+ gitOk(t, repo, "checkout", "-b", "main")
34
+ if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if err := os.WriteFile(filepath.Join(repo, "src", "a.go"), []byte("package main\n"), 0o644); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ if err := os.WriteFile(filepath.Join(repo, "src", "b.go"), []byte("package main\n"), 0o644); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("base\n"), 0o644); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ gitOk(t, repo, "add", ".")
47
+ gitOk(t, repo, "commit", "-m", "base")
48
+ base = gitOk(t, repo, "rev-parse", "HEAD")
49
+ return repo, base
50
+ }
51
+
52
+ func TestLeaseRoundTrip(t *testing.T) {
53
+ t.Parallel()
54
+ dir := t.TempDir()
55
+ path := filepath.Join(dir, "parallel-lease.md")
56
+ lease := &Lease{
57
+ BatchID: "batch1",
58
+ CreatedAt: "2026-08-23T00:00:00Z",
59
+ BaseSHA: "abcdef1",
60
+ N: 2,
61
+ Status: StatusRunning,
62
+ ControlPIDOrSession: "test",
63
+ Slices: []LeaseSlice{
64
+ {ID: "slice-a", Paths: []string{"src/a.go"}, WorktreePath: "/tmp/a", Branch: "devrites/parallel/x/batch1/slice-a", WrightStatus: WrightPending},
65
+ {ID: "slice-b", Paths: []string{"src/b.go"}, WorktreePath: "/tmp/b", Branch: "devrites/parallel/x/batch1/slice-b", WrightStatus: WrightPending},
66
+ },
67
+ }
68
+ if err := WriteLease(path, lease); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ got, err := ReadLease(path)
72
+ if err != nil {
73
+ t.Fatal(err)
74
+ }
75
+ if got.BatchID != "batch1" || got.Status != StatusRunning || len(got.Slices) != 2 {
76
+ t.Fatalf("unexpected lease: %#v", got)
77
+ }
78
+ }
79
+
80
+ func TestCreateAbortCleanup(t *testing.T) {
81
+ repo, base := setupRepo(t)
82
+ slug := "demo-feature"
83
+ batch := "batch1"
84
+ lease, err := Create(CreateOpts{
85
+ Root: repo,
86
+ Slug: slug,
87
+ BatchID: batch,
88
+ BaseSHA: base,
89
+ Slices: []SlicePaths{
90
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
91
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
92
+ },
93
+ })
94
+ if err != nil {
95
+ t.Fatal(err)
96
+ }
97
+ if lease.Status != StatusRunning || lease.N != 2 {
98
+ t.Fatalf("lease=%#v", lease)
99
+ }
100
+ wtA := lease.Slices[0].WorktreePath
101
+ wtB := lease.Slices[1].WorktreePath
102
+ for _, wt := range []string{wtA, wtB} {
103
+ if _, err := os.Stat(wt); err != nil {
104
+ t.Fatalf("missing worktree %s: %v", wt, err)
105
+ }
106
+ if head := gitOk(t, wt, "rev-parse", "HEAD"); head != base {
107
+ t.Fatalf("worktree head %s want %s", head, base)
108
+ }
109
+ }
110
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
111
+ t.Fatalf("control moved after create: %s", head)
112
+ }
113
+
114
+ // Drift control tip, then abort should restore base.
115
+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ gitOk(t, repo, "add", "README.md")
119
+ gitOk(t, repo, "commit", "-m", "drift")
120
+ if _, err := Abort(repo, slug, true); err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
124
+ t.Fatalf("abort left control at %s want %s", head, base)
125
+ }
126
+ leasePath, _ := LeasePath(repo, slug)
127
+ lease, err = ReadLease(leasePath)
128
+ if err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ if lease.Status != StatusAborted {
132
+ t.Fatalf("status=%s", lease.Status)
133
+ }
134
+ if _, err := os.Stat(wtA); err != nil {
135
+ t.Fatalf("abort should preserve worktree: %v", err)
136
+ }
137
+
138
+ if err := Cleanup(repo, slug, true); err != nil {
139
+ t.Fatal(err)
140
+ }
141
+ if _, err := os.Stat(wtA); !os.IsNotExist(err) {
142
+ t.Fatalf("cleanup should remove worktree A")
143
+ }
144
+ if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
145
+ t.Fatalf("cleanup should clear lease")
146
+ }
147
+ }
148
+
149
+ func TestCreateRefusesOverlap(t *testing.T) {
150
+ repo, base := setupRepo(t)
151
+ _, err := Create(CreateOpts{
152
+ Root: repo,
153
+ Slug: "demo",
154
+ BatchID: "batch1",
155
+ BaseSHA: base,
156
+ Slices: []SlicePaths{
157
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
158
+ {ID: "slice-b", Paths: []string{"src/a.go"}},
159
+ },
160
+ })
161
+ if err == nil || !strings.Contains(err.Error(), "overlap") {
162
+ t.Fatalf("expected overlap refusal, got %v", err)
163
+ }
164
+ }
165
+
166
+ func TestIntegrateDivergentSiblings(t *testing.T) {
167
+ repo, base := setupRepo(t)
168
+ slug := "demo-feature"
169
+ batch := "batch1"
170
+ lease, err := Create(CreateOpts{
171
+ Root: repo,
172
+ Slug: slug,
173
+ BatchID: batch,
174
+ BaseSHA: base,
175
+ Slices: []SlicePaths{
176
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
177
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
178
+ },
179
+ })
180
+ if err != nil {
181
+ t.Fatal(err)
182
+ }
183
+ wtA := lease.Slices[0].WorktreePath
184
+ wtB := lease.Slices[1].WorktreePath
185
+
186
+ if err := os.WriteFile(filepath.Join(wtA, "src", "a.go"), []byte("package main\n\nfunc A() {}\n"), 0o644); err != nil {
187
+ t.Fatal(err)
188
+ }
189
+ gitOk(t, wtA, "add", "src/a.go")
190
+ gitOk(t, wtA, "commit", "-m", "slice-a green")
191
+ tcA := gitOk(t, wtA, "rev-parse", "HEAD")
192
+
193
+ if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc B() {}\n"), 0o644); err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ gitOk(t, wtB, "add", "src/b.go")
197
+ gitOk(t, wtB, "commit", "-m", "slice-b green")
198
+ tcB := gitOk(t, wtB, "rev-parse", "HEAD")
199
+
200
+ if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+
207
+ tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
208
+ if err != nil {
209
+ t.Fatal(err)
210
+ }
211
+ if lease.Status != StatusComplete {
212
+ t.Fatalf("status=%s", lease.Status)
213
+ }
214
+ if tip == "" || tip == base {
215
+ t.Fatalf("expected advanced tip, got %s", tip)
216
+ }
217
+ head := gitOk(t, repo, "rev-parse", "HEAD")
218
+ if head != tip {
219
+ t.Fatalf("control head %s want tip %s", head, tip)
220
+ }
221
+ a := mustRead(t, filepath.Join(repo, "src", "a.go"))
222
+ b := mustRead(t, filepath.Join(repo, "src", "b.go"))
223
+ if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
224
+ t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
225
+ }
226
+
227
+ if err := Cleanup(repo, slug, false); err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ if _, err := os.Stat(wtA); !os.IsNotExist(err) {
231
+ t.Fatalf("cleanup should remove workers")
232
+ }
233
+ }
234
+
235
+ func mustRead(t *testing.T, path string) string {
236
+ t.Helper()
237
+ b, err := os.ReadFile(path)
238
+ if err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ return string(b)
242
+ }
@@ -0,0 +1,129 @@
1
+ // Package parallel implements deterministic parallel-worktree control ops:
2
+ // path-disjoint checks, advisory leases, and git worktree create/integrate/cleanup.
3
+ package parallel
4
+
5
+ import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "regexp"
11
+ "sort"
12
+ "strings"
13
+ )
14
+
15
+ var winAbsRE = regexp.MustCompile(`(?i)^[A-Za-z]:[/\\]`)
16
+
17
+ // SlicePaths is one path-disjoint eligibility unit.
18
+ type SlicePaths struct {
19
+ ID string `json:"id"`
20
+ Paths []string `json:"paths"`
21
+ }
22
+
23
+ // NormalizePath mirrors scripts/check-path-disjoint.py: slash-normalize, reject
24
+ // empty/absolute/.. paths, and drop empty/"." segments.
25
+ func NormalizePath(raw string) (string, error) {
26
+ path := strings.TrimSpace(strings.ReplaceAll(raw, `\`, "/"))
27
+ if path == "" {
28
+ return "", fmt.Errorf("empty path is not allowed")
29
+ }
30
+ if strings.HasPrefix(path, "/") || winAbsRE.MatchString(path) {
31
+ return "", fmt.Errorf("path must be project-relative, not absolute: %q", raw)
32
+ }
33
+ parts := make([]string, 0, strings.Count(path, "/")+1)
34
+ for part := range strings.SplitSeq(path, "/") {
35
+ if part == "" || part == "." {
36
+ continue
37
+ }
38
+ if part == ".." {
39
+ return "", fmt.Errorf("path must not contain '..': %q", raw)
40
+ }
41
+ parts = append(parts, part)
42
+ }
43
+ if len(parts) == 0 {
44
+ return "", fmt.Errorf("empty path is not allowed")
45
+ }
46
+ return strings.Join(parts, "/"), nil
47
+ }
48
+
49
+ func validateSlicePaths(paths []string, label, root string) ([]string, error) {
50
+ if paths == nil {
51
+ return nil, fmt.Errorf("%s: paths must be a list", label)
52
+ }
53
+ normalized := make([]string, 0, len(paths))
54
+ seen := make(map[string]struct{}, len(paths))
55
+ for _, raw := range paths {
56
+ path, err := NormalizePath(raw)
57
+ if err != nil {
58
+ return nil, fmt.Errorf("%s: %w", label, err)
59
+ }
60
+ // Parallel eligibility must reject workspace control metadata so slices
61
+ // cannot claim control-tree / SSOT files (docs: ".devrites/**").
62
+ if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
63
+ return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
64
+ }
65
+ if _, ok := seen[path]; ok {
66
+ return nil, fmt.Errorf("%s: duplicate path %q", label, path)
67
+ }
68
+ seen[path] = struct{}{}
69
+ normalized = append(normalized, path)
70
+ if root != "" {
71
+ full := filepath.Join(root, filepath.FromSlash(path))
72
+ if info, err := os.Lstat(full); err == nil && info.Mode()&os.ModeSymlink != 0 {
73
+ return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
74
+ }
75
+ }
76
+ }
77
+ return normalized, nil
78
+ }
79
+
80
+ // CheckPathDisjoint returns slice ids when every pair of path sets is disjoint.
81
+ func CheckPathDisjoint(slices []SlicePaths, root string) ([]string, error) {
82
+ if len(slices) < 2 {
83
+ return nil, fmt.Errorf("need at least two slices to check path-disjoint eligibility")
84
+ }
85
+ owners := make(map[string][]string)
86
+ ids := make([]string, 0, len(slices))
87
+ for index, item := range slices {
88
+ label := fmt.Sprintf("slice %d", index)
89
+ if item.ID != "" {
90
+ label = fmt.Sprintf("slice %q", item.ID)
91
+ ids = append(ids, item.ID)
92
+ } else {
93
+ ids = append(ids, fmt.Sprintf("%d", index))
94
+ }
95
+ paths, err := validateSlicePaths(item.Paths, label, root)
96
+ if err != nil {
97
+ return nil, err
98
+ }
99
+ for _, path := range paths {
100
+ owners[path] = append(owners[path], label)
101
+ }
102
+ }
103
+ var overlaps []string
104
+ for path, labels := range owners {
105
+ if len(labels) > 1 {
106
+ overlaps = append(overlaps, fmt.Sprintf("%q shared by %s", path, strings.Join(labels, ", ")))
107
+ }
108
+ }
109
+ if len(overlaps) > 0 {
110
+ sort.Strings(overlaps)
111
+ return nil, fmt.Errorf("path sets overlap: %s", strings.Join(overlaps, "; "))
112
+ }
113
+ return ids, nil
114
+ }
115
+
116
+ // ParseSlicesJSON accepts {"slices":[...]} or a top-level slices array.
117
+ func ParseSlicesJSON(data []byte) ([]SlicePaths, error) {
118
+ var asObject struct {
119
+ Slices []SlicePaths `json:"slices"`
120
+ }
121
+ if err := json.Unmarshal(data, &asObject); err == nil && asObject.Slices != nil {
122
+ return asObject.Slices, nil
123
+ }
124
+ var asList []SlicePaths
125
+ if err := json.Unmarshal(data, &asList); err == nil {
126
+ return asList, nil
127
+ }
128
+ return nil, fmt.Errorf(`input must be {"slices": [...]} or a top-level slices array`)
129
+ }
@@ -0,0 +1,97 @@
1
+ package parallel
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestNormalizePathRejectsDirty(t *testing.T) {
11
+ t.Parallel()
12
+ cases := []string{"", " ", "/", "/abs", `C:\windows`, `..`, "a/../b", "../x"}
13
+ for _, raw := range cases {
14
+ if _, err := NormalizePath(raw); err == nil {
15
+ t.Fatalf("NormalizePath(%q) should fail", raw)
16
+ }
17
+ }
18
+ }
19
+
20
+ func TestNormalizePathAcceptsRelative(t *testing.T) {
21
+ t.Parallel()
22
+ got, err := NormalizePath(`src\foo.go`)
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if got != "src/foo.go" {
27
+ t.Fatalf("got %q", got)
28
+ }
29
+ }
30
+
31
+ func TestCheckPathDisjointOverlap(t *testing.T) {
32
+ t.Parallel()
33
+ _, err := CheckPathDisjoint([]SlicePaths{
34
+ {ID: "a", Paths: []string{"src/a.go"}},
35
+ {ID: "b", Paths: []string{"src/a.go"}},
36
+ }, "")
37
+ if err == nil || !strings.Contains(err.Error(), "overlap") {
38
+ t.Fatalf("expected overlap error, got %v", err)
39
+ }
40
+ }
41
+
42
+ func TestCheckPathDisjointOK(t *testing.T) {
43
+ t.Parallel()
44
+ ids, err := CheckPathDisjoint([]SlicePaths{
45
+ {ID: "a", Paths: []string{"src/a.go"}},
46
+ {ID: "b", Paths: []string{"src/b.go"}},
47
+ }, "")
48
+ if err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ if len(ids) != 2 {
52
+ t.Fatalf("ids=%v", ids)
53
+ }
54
+ }
55
+
56
+ func TestCheckPathDisjointRejectsDevritesPaths(t *testing.T) {
57
+ t.Parallel()
58
+ _, err := CheckPathDisjoint([]SlicePaths{
59
+ {ID: "a", Paths: []string{".devrites/work/state.md"}},
60
+ {ID: "b", Paths: []string{"src/b.go"}},
61
+ }, "")
62
+ if err == nil || !strings.Contains(err.Error(), ".devrites") {
63
+ t.Fatalf("expected .devrites rejection error, got %v", err)
64
+ }
65
+ }
66
+
67
+ func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
68
+ t.Parallel()
69
+ dir := t.TempDir()
70
+ target := filepath.Join(dir, "real.go")
71
+ if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ link := filepath.Join(dir, "link.go")
75
+ if err := os.Symlink(target, link); err != nil {
76
+ t.Skip("symlinks unavailable")
77
+ }
78
+ _, err := CheckPathDisjoint([]SlicePaths{
79
+ {ID: "a", Paths: []string{"link.go"}},
80
+ {ID: "b", Paths: []string{"other.go"}},
81
+ }, dir)
82
+ if err == nil || !strings.Contains(err.Error(), "symlink") {
83
+ t.Fatalf("expected symlink error, got %v", err)
84
+ }
85
+ }
86
+
87
+ func TestParseSlicesJSONShapes(t *testing.T) {
88
+ t.Parallel()
89
+ a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
90
+ if err != nil || len(a) != 2 {
91
+ t.Fatalf("object shape: %v %#v", err, a)
92
+ }
93
+ b, err := ParseSlicesJSON([]byte(`[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]`))
94
+ if err != nil || len(b) != 2 {
95
+ t.Fatalf("array shape: %v %#v", err, b)
96
+ }
97
+ }
@@ -1,5 +1,5 @@
1
1
  // Command workflowmanifest writes the deterministic cross-language derivative
2
- // of state.WorkflowPhases. The typed Go registry remains the authority.
2
+ // of state.PhasePolicies. The typed Go registry remains the authority.
3
3
  package main
4
4
 
5
5
  import (
@@ -12,6 +12,35 @@ import (
12
12
  "github.com/devrites/devrites/internal/state"
13
13
  )
14
14
 
15
+ type workflowPhase struct {
16
+ ID state.Phase `json:"id"`
17
+ ResumeVerb string `json:"resumeVerb,omitempty"`
18
+ TransitionRight string `json:"transitionRight"`
19
+ RequiredSections []state.Section `json:"requiredSections,omitempty"`
20
+ WorkspaceRequired []state.ArtifactPath `json:"workspaceRequired"`
21
+ ProofRequired bool `json:"proofRequired,omitempty"`
22
+ BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
23
+ Shippable bool `json:"shippable,omitempty"`
24
+ }
25
+
26
+ func workflowPhases() []workflowPhase {
27
+ policies := state.PhasePolicies()
28
+ phases := make([]workflowPhase, len(policies))
29
+ for i, policy := range policies {
30
+ phases[i] = workflowPhase{
31
+ ID: policy.Target,
32
+ ResumeVerb: policy.ResumeVerb,
33
+ TransitionRight: policy.TransitionRight,
34
+ RequiredSections: policy.RequiredSections,
35
+ WorkspaceRequired: policy.RequiredArtifacts,
36
+ ProofRequired: policy.ProofRequired,
37
+ BlocksOpenQuestions: policy.BlocksOpenQuestions,
38
+ Shippable: policy.Shippable,
39
+ }
40
+ }
41
+ return phases
42
+ }
43
+
15
44
  func main() {
16
45
  outPath := flag.String("out", "workflow_manifest.json", "output path")
17
46
  check := flag.Bool("check", false, "fail when the output is stale")
@@ -21,13 +50,13 @@ func main() {
21
50
  GeneratedBy string `json:"generatedBy"`
22
51
  SchemaVersion int `json:"schemaVersion"`
23
52
  AuthorityPolicy state.AuthorityPolicy `json:"authorityPolicy"`
24
- Phases []state.WorkflowPhase `json:"phases"`
53
+ Phases []workflowPhase `json:"phases"`
25
54
  CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
26
55
  }{
27
56
  GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
28
57
  SchemaVersion: state.SchemaVersion,
29
58
  AuthorityPolicy: state.WorkflowAuthorityPolicy(),
30
- Phases: state.WorkflowPhases(),
59
+ Phases: workflowPhases(),
31
60
  CursorKeyAliases: state.CursorKeyAliases(),
32
61
  }
33
62
  data, err := json.MarshalIndent(document, "", " ")
@@ -9,7 +9,6 @@ import (
9
9
  "strings"
10
10
 
11
11
  "github.com/devrites/devrites/internal/devritespaths"
12
- "github.com/devrites/devrites/internal/markdowntext"
13
12
  "github.com/devrites/devrites/internal/rootfacts"
14
13
  )
15
14
 
@@ -24,15 +23,6 @@ func ResolveRoot(override string) (string, error) {
24
23
  return facts.PhysicalRoot, nil
25
24
  }
26
25
 
27
- // Feature is a loaded per-feature workspace: its declared phase plus which
28
- // completeness sections currently have real content.
29
- type Feature struct {
30
- Slug string
31
- Phase Phase
32
- Present map[Section]bool
33
- PresentFiles map[string]bool
34
- }
35
-
36
26
  // featureDir is the canonical per-feature state directory under root.
37
27
  func featureDir(root, slug string) string {
38
28
  return devritespaths.FeatureDir(root, slug)
@@ -71,74 +61,6 @@ func regularFileExists(path string) bool {
71
61
  return err == nil && info.Mode().IsRegular()
72
62
  }
73
63
 
74
- // LoadFeature reads a canonical feature workspace. state.md is the sole
75
- // lifecycle authority; optional README.md metadata is not runtime state.
76
- func LoadFeature(root, slug string) (*Feature, error) {
77
- dir := featureDir(root, slug)
78
- if !regularFileExists(filepath.Join(dir, LedgerFile)) {
79
- return nil, fmt.Errorf("feature %q not found", slug)
80
- }
81
-
82
- phase, ledgerDeclared, err := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
83
- if err != nil {
84
- return nil, fmt.Errorf("feature %q: %w", slug, err)
85
- }
86
- if !ledgerDeclared {
87
- return nil, fmt.Errorf("feature %q: no phase in %s ledger", slug, LedgerFile)
88
- }
89
- if !KnownPhase(phase) {
90
- return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
91
- }
92
-
93
- present := make(map[Section]bool, len(Sections))
94
- for _, section := range Sections {
95
- present[section] = sectionPresentAny(dir, section)
96
- }
97
- presentFiles := make(map[string]bool)
98
- for _, name := range WorkspaceFiles() {
99
- presentFiles[name] = sectionPresent(filepath.Join(dir, name))
100
- }
101
- return &Feature{
102
- Slug: slug,
103
- Phase: phase,
104
- Present: present,
105
- PresentFiles: presentFiles,
106
- }, nil
107
- }
108
-
109
- func sectionPresentAny(dir string, s Section) bool {
110
- for _, name := range sectionFiles[s] {
111
- if sectionPresent(filepath.Join(dir, name)) {
112
- return true
113
- }
114
- }
115
- return false
116
- }
117
-
118
- func declaredPhaseFromLedger(path string) (Phase, bool, error) {
119
- raw, err := os.ReadFile(path)
120
- if err != nil {
121
- if errors.Is(err, os.ErrNotExist) {
122
- return "", false, nil
123
- }
124
- return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
125
- }
126
- structural, err := markdowntext.Structural(raw)
127
- if err != nil {
128
- return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
129
- }
130
- lines := strings.Split(string(structural), "\n")
131
- value, ok := CursorField(lines, CursorPhase)
132
- if !ok {
133
- return "", false, nil
134
- }
135
- word := firstPhaseWord(value)
136
- if phase, known := PhaseForName(word); known {
137
- return phase, true, nil
138
- }
139
- return Phase(word), true, nil
140
- }
141
-
142
64
  func firstPhaseWord(value string) string {
143
65
  word := strings.ToLower(strings.TrimSpace(value))
144
66
  if i := strings.IndexAny(word, " "); i > 0 {
@@ -153,27 +75,6 @@ func blankOrHash(trimmed string) bool {
153
75
  return trimmed == "" || strings.HasPrefix(trimmed, "#")
154
76
  }
155
77
 
156
- // sectionPresent reports whether a section file has real content: it exists
157
- // and, after removing a leading YAML frontmatter block, ATX (`#`) headings, and
158
- // whitespace, some content remains. A stub that is only a heading counts as
159
- // empty, so scaffolding a file never fakes completeness.
160
- func sectionPresent(path string) bool {
161
- raw, err := os.ReadFile(path)
162
- if err != nil {
163
- return false
164
- }
165
- if _, err := markdowntext.Structural(raw); err != nil {
166
- return false
167
- }
168
- body := stripFrontmatter(raw)
169
- for _, line := range strings.Split(string(body), "\n") {
170
- if !blankOrHash(strings.TrimSpace(line)) {
171
- return true
172
- }
173
- }
174
- return false
175
- }
176
-
177
78
  // stripFrontmatter removes a well-formed leading frontmatter block when
178
79
  // determining whether an artifact contains substantive body content. The
179
80
  // closing fence must be a line that is exactly `---`.
@@ -191,7 +92,7 @@ func stripFrontmatter(raw []byte) []byte {
191
92
  }
192
93
  }
193
94
  if end < 0 {
194
- return raw // no closing fence: treat as if there were no frontmatter
95
+ return raw
195
96
  }
196
97
  return []byte(strings.Join(lines[end+1:], "\n"))
197
98
  }