devrites 4.5.0 → 4.7.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 (191) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/NOTICE.md +0 -13
  3. package/README.md +4 -3
  4. package/docs/adr/0029-v5-workspace-schema-and-native-migration.md +62 -0
  5. package/docs/adr/README.md +3 -2
  6. package/docs/architecture.md +1 -1
  7. package/docs/cli.md +2 -2
  8. package/docs/command-map.md +2 -2
  9. package/docs/engine/commands.md +9 -0
  10. package/docs/engine/state-schema.md +2 -2
  11. package/docs/engine/workspace-schema.md +9 -3
  12. package/docs/usage.md +1 -1
  13. package/engine/commands.go +23 -0
  14. package/engine/internal/fsutil/copy_test.go +193 -0
  15. package/engine/internal/gate/gate_test.go +7 -7
  16. package/engine/internal/gate/readiness_binding_test.go +8 -2
  17. package/engine/internal/install/apply.go +314 -0
  18. package/engine/internal/install/binary.go +255 -0
  19. package/engine/internal/install/install.go +0 -1596
  20. package/engine/internal/install/manifest.go +109 -0
  21. package/engine/internal/install/merge.go +468 -0
  22. package/engine/internal/install/preflight.go +234 -0
  23. package/engine/internal/install/uninstall.go +119 -0
  24. package/engine/internal/install/update.go +184 -0
  25. package/engine/internal/lib/candidate_test.go +3 -0
  26. package/engine/internal/lib/closeout.go +4 -0
  27. package/engine/internal/lib/closeout_test.go +3 -3
  28. package/engine/internal/lib/migrate.go +219 -0
  29. package/engine/internal/lib/migrate_test.go +154 -0
  30. package/engine/internal/lib/resolve.go +3 -0
  31. package/engine/internal/lib/resolve_remediation_test.go +1 -1
  32. package/engine/internal/lib/secretscan_test.go +1 -0
  33. package/engine/internal/lib/taskgraph_test.go +2 -2
  34. package/engine/internal/parallel/ops.go +67 -16
  35. package/engine/internal/parallel/ops_test.go +211 -0
  36. package/engine/internal/state/cmd/workflowmanifest/main.go +24 -13
  37. package/engine/internal/state/cmd/workflowmanifest/main_test.go +96 -0
  38. package/engine/internal/state/cursor.go +74 -0
  39. package/engine/internal/state/schema.go +3 -2
  40. package/engine/internal/state/state_test.go +2 -2
  41. package/engine/internal/state/workflow_manifest.json +1 -1
  42. package/engine/internal/state/workspaceschema.go +58 -0
  43. package/engine/internal/state/workspaceschema_test.go +67 -0
  44. package/engine/main.go +7 -31
  45. package/engine/root_routing_test.go +10 -2
  46. package/engine/testdata/fixtures/basic/devrites-root/work/auth-tokens/state.md +1 -0
  47. package/engine/testdata/fixtures/basic/devrites-root/work/search-ranking/state.md +1 -0
  48. package/engine/testdata/golden/TestParityResolve/answer/state.md.golden +1 -0
  49. package/engine/testdata/golden/TestParityResolve/drop/state.md.golden +1 -0
  50. package/engine/tests/parity_closeout_test.go +3 -0
  51. package/engine/tests/parity_resolve_test.go +1 -0
  52. package/pack/.claude/agents/devrites-code-reviewer.md +8 -0
  53. package/pack/.claude/agents/devrites-devex-reviewer.md +7 -0
  54. package/pack/.claude/agents/devrites-doubt-reviewer.md +7 -0
  55. package/pack/.claude/agents/devrites-evidence-scout.md +4 -0
  56. package/pack/.claude/agents/devrites-frontend-reviewer.md +8 -0
  57. package/pack/.claude/agents/devrites-performance-reviewer.md +7 -0
  58. package/pack/.claude/agents/devrites-plan-reviewer.md +8 -0
  59. package/pack/.claude/agents/devrites-retrospector.md +2 -0
  60. package/pack/.claude/agents/devrites-security-auditor.md +7 -0
  61. package/pack/.claude/agents/devrites-simplifier-reviewer.md +7 -0
  62. package/pack/.claude/agents/devrites-spec-reviewer.md +7 -0
  63. package/pack/.claude/agents/devrites-strategy-reviewer.md +7 -0
  64. package/pack/.claude/agents/devrites-test-analyst.md +7 -0
  65. package/pack/.claude/skills/devrites-frontend-craft/SKILL.md +4 -7
  66. package/pack/.claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  67. package/pack/.claude/skills/devrites-lib/reference/intent-map.md +7 -0
  68. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  69. package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +9 -7
  70. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +1 -1
  71. package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
  72. package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  73. package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  74. package/pack/.claude/skills/devrites-lib/reference/standards/core.md +3 -6
  75. package/pack/.claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  76. package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  77. package/pack/.claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
  78. package/pack/.claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  79. package/pack/.claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  80. package/pack/.claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  81. package/pack/.claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  82. package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
  83. package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +4 -0
  84. package/pack/.claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  85. package/pack/.claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  86. package/pack/.claude/skills/devrites-lib/reference/standards/security.md +10 -2
  87. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  88. package/pack/.claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  89. package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +2 -2
  90. package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
  91. package/pack/.claude/skills/rite-define/reference/plan-template.md +5 -2
  92. package/pack/.claude/skills/rite-learn/SKILL.md +6 -0
  93. package/pack/.claude/skills/rite-polish/SKILL.md +4 -0
  94. package/pack/.claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  95. package/pack/.claude/skills/rite-pr-feedback/SKILL.md +7 -2
  96. package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -4
  97. package/pack/.claude/skills/rite-status/SKILL.md +2 -0
  98. package/pack/generated/claude/agents/devrites-code-reviewer.md +8 -0
  99. package/pack/generated/claude/agents/devrites-devex-reviewer.md +7 -0
  100. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +7 -0
  101. package/pack/generated/claude/agents/devrites-evidence-scout.md +4 -0
  102. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +8 -0
  103. package/pack/generated/claude/agents/devrites-performance-reviewer.md +7 -0
  104. package/pack/generated/claude/agents/devrites-plan-reviewer.md +8 -0
  105. package/pack/generated/claude/agents/devrites-retrospector.md +2 -0
  106. package/pack/generated/claude/agents/devrites-security-auditor.md +7 -0
  107. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +7 -0
  108. package/pack/generated/claude/agents/devrites-spec-reviewer.md +7 -0
  109. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +7 -0
  110. package/pack/generated/claude/agents/devrites-test-analyst.md +7 -0
  111. package/pack/generated/claude/skills/devrites-frontend-craft/SKILL.md +4 -7
  112. package/pack/generated/claude/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  113. package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +7 -0
  114. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  115. package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +9 -7
  116. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +1 -1
  117. package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +6 -1
  118. package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  119. package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  120. package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +3 -6
  121. package/pack/generated/claude/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  122. package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  123. package/pack/generated/claude/skills/devrites-lib/reference/standards/documentation.md +6 -0
  124. package/pack/generated/claude/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  125. package/pack/generated/claude/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  126. package/pack/generated/claude/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  127. package/pack/generated/claude/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  128. package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +16 -0
  129. package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +4 -0
  130. package/pack/generated/claude/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  131. package/pack/generated/claude/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  132. package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +10 -2
  133. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  134. package/pack/generated/claude/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  135. package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +2 -2
  136. package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +1 -1
  137. package/pack/generated/claude/skills/rite-define/reference/plan-template.md +5 -2
  138. package/pack/generated/claude/skills/rite-learn/SKILL.md +6 -0
  139. package/pack/generated/claude/skills/rite-polish/SKILL.md +4 -0
  140. package/pack/generated/claude/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  141. package/pack/generated/claude/skills/rite-pr-feedback/SKILL.md +7 -2
  142. package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -4
  143. package/pack/generated/claude/skills/rite-status/SKILL.md +2 -0
  144. package/pack/generated/codex/agents/devrites-code-reviewer.toml +8 -0
  145. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +7 -0
  146. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +7 -0
  147. package/pack/generated/codex/agents/devrites-evidence-scout.toml +4 -0
  148. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +8 -0
  149. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +7 -0
  150. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +8 -0
  151. package/pack/generated/codex/agents/devrites-retrospector.toml +2 -0
  152. package/pack/generated/codex/agents/devrites-security-auditor.toml +7 -0
  153. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +7 -0
  154. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +7 -0
  155. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +7 -0
  156. package/pack/generated/codex/agents/devrites-test-analyst.toml +7 -0
  157. package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +4 -7
  158. package/pack/generated/codex/skills/devrites-frontend-craft/reference/quality-standards.md +9 -4
  159. package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +7 -0
  160. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +1 -1
  161. package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +9 -7
  162. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +1 -1
  163. package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +6 -1
  164. package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +4 -3
  165. package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +4 -0
  166. package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +3 -6
  167. package/pack/generated/codex/skills/devrites-lib/reference/standards/data-integrity.md +4 -0
  168. package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +8 -0
  169. package/pack/generated/codex/skills/devrites-lib/reference/standards/documentation.md +6 -0
  170. package/pack/generated/codex/skills/devrites-lib/reference/standards/edge-case-trace.md +3 -1
  171. package/pack/generated/codex/skills/devrites-lib/reference/standards/elicitation.md +5 -0
  172. package/pack/generated/codex/skills/devrites-lib/reference/standards/error-handling.md +14 -0
  173. package/pack/generated/codex/skills/devrites-lib/reference/standards/integration-reliability.md +18 -6
  174. package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +16 -0
  175. package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +4 -0
  176. package/pack/generated/codex/skills/devrites-lib/reference/standards/repository-topology.md +6 -0
  177. package/pack/generated/codex/skills/devrites-lib/reference/standards/security-checklist.md +3 -0
  178. package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +10 -2
  179. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +8 -0
  180. package/pack/generated/codex/skills/devrites-lib/reference/standards/spec-grammar.md +5 -6
  181. package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +2 -2
  182. package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +1 -1
  183. package/pack/generated/codex/skills/rite-define/reference/plan-template.md +5 -2
  184. package/pack/generated/codex/skills/rite-learn/SKILL.md +6 -0
  185. package/pack/generated/codex/skills/rite-polish/SKILL.md +4 -0
  186. package/pack/generated/codex/skills/rite-polish/reference/anti-ai-slop.md +12 -53
  187. package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -2
  188. package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -4
  189. package/pack/generated/codex/skills/rite-status/SKILL.md +2 -0
  190. package/package.json +1 -1
  191. package/docs/markdown-instruction-upgrade-2026-08-27.md +0 -127
@@ -0,0 +1,211 @@
1
+ package parallel
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ // commitIn makes a one-file green commit inside a worker worktree and returns
13
+ // its transfer commit SHA.
14
+ func commitIn(t *testing.T, wt, file, marker string) string {
15
+ t.Helper()
16
+ body := "package main\n\nfunc " + marker + "() {}\n"
17
+ if err := os.WriteFile(filepath.Join(wt, file), []byte(body), 0o644); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ gitOk(t, wt, "add", file)
21
+ gitOk(t, wt, "commit", "-m", "slice "+marker)
22
+ return gitOk(t, wt, "rev-parse", "HEAD")
23
+ }
24
+
25
+ // greenBatch creates a running lease with two recorded-green slices.
26
+ func greenBatch(t *testing.T, repo, base, slug, batch string) *Lease {
27
+ t.Helper()
28
+ lease, err := Create(CreateOpts{
29
+ Root: repo,
30
+ Slug: slug,
31
+ BatchID: batch,
32
+ BaseSHA: base,
33
+ Slices: []SlicePaths{
34
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
35
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
36
+ },
37
+ })
38
+ if err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ tcA := commitIn(t, lease.Slices[0].WorktreePath, "src/a.go", "A")
42
+ tcB := commitIn(t, lease.Slices[1].WorktreePath, "src/b.go", "B")
43
+ if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ return lease
50
+ }
51
+
52
+ type seamRestore struct {
53
+ origLease func(string, *Lease) error
54
+ origRemove func(string, string) error
55
+ origWarn func(string, ...any)
56
+ }
57
+
58
+ func swapSeams(t *testing.T) {
59
+ t.Helper()
60
+ s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origWarn: warnf}
61
+ t.Cleanup(func() { writeLease, removeWorktree, warnf = s.origLease, s.origRemove, s.origWarn })
62
+ }
63
+
64
+ // TestIntegrateFailedTransitionSurvivesTransientWriteFailure proves the
65
+ // integrate-failed lease transition is retried once when the first write
66
+ // fails, so the on-disk lease honestly records integrate-failed instead of a
67
+ // stale running status.
68
+ func TestIntegrateFailedTransitionSurvivesTransientWriteFailure(t *testing.T) {
69
+ repo, base := setupRepo(t)
70
+ slug, batch := "demo-feature", "batch1"
71
+ greenBatch(t, repo, base, slug, batch)
72
+ leasePath, err := LeasePath(repo, slug)
73
+ if err != nil {
74
+ t.Fatal(err)
75
+ }
76
+
77
+ // Corrupt a transfer commit so Integrate fails mid-flight via fail().
78
+ corrupted, err := ReadLease(leasePath)
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ corrupted.Slices[0].TransferCommit = strings.Repeat("deadbeef", 5)
83
+ if err := WriteLease(leasePath, corrupted); err != nil {
84
+ t.Fatal(err)
85
+ }
86
+
87
+ swapSeams(t)
88
+ calls := 0
89
+ writeLease = func(path string, l *Lease) error {
90
+ calls++
91
+ if calls == 1 {
92
+ return errors.New("transient lease write failure")
93
+ }
94
+ // delegates to the real WriteLease captured by swapSeams
95
+ return origWriteLease()(path, l)
96
+ }
97
+
98
+ _, got, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
99
+ if err == nil {
100
+ t.Fatal("expected integrate failure from corrupted transfer commit")
101
+ }
102
+ if calls != 2 {
103
+ t.Fatalf("lease write calls=%d want 2 (initial + one retry)", calls)
104
+ }
105
+ if got == nil || got.Status != StatusIntegrateFailed {
106
+ t.Fatalf("returned lease status=%v want integrate-failed", got)
107
+ }
108
+ onDisk, err := ReadLease(leasePath)
109
+ if err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ if onDisk.Status != StatusIntegrateFailed {
113
+ t.Fatalf("on-disk lease status=%s want integrate-failed", onDisk.Status)
114
+ }
115
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
116
+ t.Fatalf("control head %s want base %s", head, base)
117
+ }
118
+ }
119
+
120
+ // origWriteLease returns the real WriteLease bypassing any test override.
121
+ func origWriteLease() func(string, *Lease) error {
122
+ return WriteLease
123
+ }
124
+
125
+ // TestIntegrateSurfacesStuckStagingWorktree proves a staging-worktree removal
126
+ // failure on the integrate success path is propagated (fail closed, status
127
+ // integrate-failed, control untouched) instead of silently compounding into
128
+ // un-cleanable branch state.
129
+ func TestIntegrateSurfacesStuckStagingWorktree(t *testing.T) {
130
+ repo, base := setupRepo(t)
131
+ slug, batch := "demo-feature", "batch1"
132
+ greenBatch(t, repo, base, slug, batch)
133
+ leasePath, err := LeasePath(repo, slug)
134
+ if err != nil {
135
+ t.Fatal(err)
136
+ }
137
+
138
+ swapSeams(t)
139
+ removeWorktree = func(string, string) error {
140
+ return errors.New("simulated locked staging worktree")
141
+ }
142
+
143
+ _, got, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
144
+ if err == nil {
145
+ t.Fatal("expected integrate to surface the stuck staging worktree")
146
+ }
147
+ if !strings.Contains(err.Error(), "staging worktree") {
148
+ t.Fatalf("error should name the staging worktree, got %v", err)
149
+ }
150
+ if got == nil || got.Status != StatusIntegrateFailed {
151
+ t.Fatalf("returned lease status=%v want integrate-failed", got)
152
+ }
153
+ onDisk, err := ReadLease(leasePath)
154
+ if err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if onDisk.Status != StatusIntegrateFailed {
158
+ t.Fatalf("on-disk lease status=%s want integrate-failed", onDisk.Status)
159
+ }
160
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
161
+ t.Fatalf("control head %s want base %s (merge must not apply when staging cleanup fails)", head, base)
162
+ }
163
+ }
164
+
165
+ // TestCleanupWarnsOnFailedBranchCleanup proves Cleanup reports branch cleanup
166
+ // failures instead of erasing the lease while orphans remain.
167
+ func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
168
+ repo, base := setupRepo(t)
169
+ slug, batch := "demo-feature", "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
+ branchA := lease.Slices[0].Branch
184
+
185
+ swapSeams(t)
186
+ // Removal "fails" without removing; Cleanup's own RemoveAll then deletes
187
+ // the worktree directory while the stale git registration still lists the
188
+ // branch as checked out, so deleteBranch refuses and must be reported.
189
+ removeWorktree = func(string, string) error {
190
+ return errors.New("simulated stuck worker worktree")
191
+ }
192
+ var warnings []string
193
+ warnf = func(format string, args ...any) {
194
+ warnings = append(warnings, fmt.Sprintf(format, args...))
195
+ }
196
+
197
+ if err := Cleanup(repo, slug, true); err != nil {
198
+ t.Fatalf("cleanup continues after reporting failures: %v", err)
199
+ }
200
+ joined := strings.Join(warnings, "\n")
201
+ if !strings.Contains(joined, branchA) {
202
+ t.Fatalf("warnings should name the stuck branch %s, got:\n%s", branchA, joined)
203
+ }
204
+ leasePath, err := LeasePath(repo, slug)
205
+ if err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
209
+ t.Fatalf("cleanup should still clear the lease, got %v", err)
210
+ }
211
+ }
@@ -7,6 +7,7 @@ import (
7
7
  "encoding/json"
8
8
  "flag"
9
9
  "fmt"
10
+ "io"
10
11
  "os"
11
12
 
12
13
  "github.com/devrites/devrites/internal/state"
@@ -42,9 +43,19 @@ func workflowPhases() []workflowPhase {
42
43
  }
43
44
 
44
45
  func main() {
45
- outPath := flag.String("out", "workflow_manifest.json", "output path")
46
- check := flag.Bool("check", false, "fail when the output is stale")
47
- flag.Parse()
46
+ os.Exit(run(os.Args[1:], os.Stderr))
47
+ }
48
+
49
+ // run is the testable entry point. It returns the process exit code.
50
+ func run(args []string, stderr io.Writer) int {
51
+ flags := flag.NewFlagSet("workflowmanifest", flag.ContinueOnError)
52
+ flags.SetOutput(io.Discard)
53
+ outPath := flags.String("out", "workflow_manifest.json", "output path")
54
+ check := flags.Bool("check", false, "fail when the output is stale")
55
+ if err := flags.Parse(args); err != nil {
56
+ fmt.Fprintln(stderr, err)
57
+ return 2
58
+ }
48
59
 
49
60
  document := struct {
50
61
  GeneratedBy string `json:"generatedBy"`
@@ -61,25 +72,25 @@ func main() {
61
72
  }
62
73
  data, err := json.MarshalIndent(document, "", " ")
63
74
  if err != nil {
64
- fatal(err)
75
+ fmt.Fprintln(stderr, err)
76
+ return 1
65
77
  }
66
78
  data = append(data, '\n')
67
79
  if *check {
68
80
  current, err := os.ReadFile(*outPath)
69
81
  if err != nil {
70
- fatal(err)
82
+ fmt.Fprintln(stderr, err)
83
+ return 1
71
84
  }
72
85
  if !bytes.Equal(current, data) {
73
- fatal(fmt.Errorf("%s is stale; run go generate ./internal/state", *outPath))
86
+ fmt.Fprintf(stderr, "%s is stale; run go generate ./internal/state\n", *outPath)
87
+ return 1
74
88
  }
75
- return
89
+ return 0
76
90
  }
77
91
  if err := os.WriteFile(*outPath, data, 0o644); err != nil {
78
- fatal(err)
92
+ fmt.Fprintln(stderr, err)
93
+ return 1
79
94
  }
80
- }
81
-
82
- func fatal(err error) {
83
- fmt.Fprintln(os.Stderr, err)
84
- os.Exit(1)
95
+ return 0
85
96
  }
@@ -0,0 +1,96 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/devrites/devrites/internal/state"
12
+ )
13
+
14
+ func TestRunWritesDeterministicManifest(t *testing.T) {
15
+ dir := t.TempDir()
16
+ out := filepath.Join(dir, "workflow_manifest.json")
17
+
18
+ if code := run([]string{"--out", out}, os.Stderr); code != 0 {
19
+ t.Fatalf("run exit=%d", code)
20
+ }
21
+ first, err := os.ReadFile(out)
22
+ if err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ var document struct {
26
+ GeneratedBy string `json:"generatedBy"`
27
+ SchemaVersion int `json:"schemaVersion"`
28
+ Phases []struct {
29
+ ID state.Phase `json:"id"`
30
+ TransitionRight string `json:"transitionRight"`
31
+ } `json:"phases"`
32
+ }
33
+ if err := json.Unmarshal(first, &document); err != nil {
34
+ t.Fatalf("manifest is not valid JSON: %v", err)
35
+ }
36
+ if document.SchemaVersion != state.SchemaVersion {
37
+ t.Fatalf("schemaVersion=%d want %d", document.SchemaVersion, state.SchemaVersion)
38
+ }
39
+ if !strings.HasPrefix(document.GeneratedBy, "go generate ./internal/state") {
40
+ t.Fatalf("generatedBy %q", document.GeneratedBy)
41
+ }
42
+ if len(document.Phases) == 0 {
43
+ t.Fatal("manifest must list phases")
44
+ }
45
+
46
+ // Deterministic: a second run produces identical bytes.
47
+ if code := run([]string{"--out", out}, os.Stderr); code != 0 {
48
+ t.Fatalf("second run exit=%d", code)
49
+ }
50
+ second, err := os.ReadFile(out)
51
+ if err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ if !bytes.Equal(first, second) {
55
+ t.Fatal("manifest output is not deterministic")
56
+ }
57
+ }
58
+
59
+ func TestRunCheckPassesOnCurrentAndFailsOnStale(t *testing.T) {
60
+ dir := t.TempDir()
61
+ out := filepath.Join(dir, "workflow_manifest.json")
62
+
63
+ if code := run([]string{"--out", out}, os.Stderr); code != 0 {
64
+ t.Fatalf("generate exit=%d", code)
65
+ }
66
+ if code := run([]string{"--out", out, "--check"}, os.Stderr); code != 0 {
67
+ t.Fatalf("check on current manifest exit=%d", code)
68
+ }
69
+
70
+ if err := os.WriteFile(out, []byte("{\"stale\": true}\n"), 0o644); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ stderr := &strings.Builder{}
74
+ if code := run([]string{"--out", out, "--check"}, stderr); code != 1 {
75
+ t.Fatalf("check on stale manifest exit=%d want 1", code)
76
+ }
77
+ if !strings.Contains(stderr.String(), "is stale") {
78
+ t.Fatalf("stderr should name the stale manifest, got %q", stderr.String())
79
+ }
80
+ }
81
+
82
+ func TestRunCheckFailsOnMissingManifest(t *testing.T) {
83
+ dir := t.TempDir()
84
+ missing := filepath.Join(dir, "absent.json")
85
+ stderr := &strings.Builder{}
86
+ if code := run([]string{"--out", missing, "--check"}, stderr); code != 1 {
87
+ t.Fatalf("check on missing manifest exit=%d want 1", code)
88
+ }
89
+ }
90
+
91
+ func TestRunRejectsUnknownFlags(t *testing.T) {
92
+ stderr := &strings.Builder{}
93
+ if code := run([]string{"--bogus"}, stderr); code != 2 {
94
+ t.Fatalf("unknown flag exit=%d want 2", code)
95
+ }
96
+ }
@@ -20,6 +20,7 @@ const (
20
20
  CursorAFKSlicesRemaining = "afk_slices_remaining"
21
21
  CursorReturnPhase = "return_phase"
22
22
  CursorReturnNextAction = "return_next_action"
23
+ CursorSchema = "schema"
23
24
  )
24
25
 
25
26
  var cursorKeyAliases = map[string]string{
@@ -168,6 +169,79 @@ func DeleteCursorField(lines []string, key string) []string {
168
169
  return out
169
170
  }
170
171
 
172
+ // CursorForm reports the presentation of the state.md cursor: "table" when a
173
+ // canonical table row exists, "legacy" when only bullet fields exist, and
174
+ // "none" when neither is present.
175
+ func CursorForm(lines []string) string {
176
+ form := "none"
177
+ for _, line := range lines {
178
+ _, _, kind, ok := parseCursorLine(line)
179
+ if !ok {
180
+ continue
181
+ }
182
+ if kind == cursorLineTable {
183
+ return "table"
184
+ }
185
+ if form == "none" {
186
+ form = "legacy"
187
+ }
188
+ }
189
+ return form
190
+ }
191
+
192
+ // ConvertCursorToTable rewrites legacy bullet cursor fields into canonical
193
+ // table rows in place. A bullet whose key resolves to a canonical cursor key
194
+ // is a cursor field wherever it appears — the same rule CursorField uses to
195
+ // read — so such a bullet is converted even inside prose sections. Prose
196
+ // without a canonical cursor key is preserved, and a value carrying a raw pipe
197
+ // keeps its bullet form because a table cell cannot represent it. It reports
198
+ // whether any line changed.
199
+ func ConvertCursorToTable(lines []string) ([]string, bool) {
200
+ changed := false
201
+ out := append([]string(nil), lines...)
202
+ for i, line := range out {
203
+ key, value, kind, ok := parseCursorLine(line)
204
+ if !ok || kind != cursorLineLegacy {
205
+ continue
206
+ }
207
+ if !isMigratableCursorKey(key) {
208
+ continue
209
+ }
210
+ if strings.Contains(value, "|") {
211
+ continue
212
+ }
213
+ spelling, ok := canonicalCursorSpelling(key)
214
+ if !ok {
215
+ continue
216
+ }
217
+ indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
218
+ out[i] = indent + "| " + spelling + " | " + value + " |"
219
+ changed = true
220
+ }
221
+ return out, changed
222
+ }
223
+
224
+ func isMigratableCursorKey(key string) bool {
225
+ _, ok := canonicalCursorSpelling(key)
226
+ return ok
227
+ }
228
+
229
+ // canonicalCursorSpelling maps a cursor key (including aliases) to the
230
+ // canonical table-row spelling. Matching is normalization-based, so
231
+ // "Next step" resolves to the "next_action" spelling.
232
+ func canonicalCursorSpelling(key string) (string, bool) {
233
+ normalized := normalizeCursorKey(key)
234
+ canonicals := []string{CursorPhase, CursorStatus, CursorNextAction, CursorQuestionID,
235
+ CursorActiveSlice, CursorAFKSlicesRemaining, CursorReturnPhase,
236
+ CursorReturnNextAction, CursorSchema}
237
+ for _, canonical := range canonicals {
238
+ if normalized == normalizeCursorKey(canonical) {
239
+ return canonical, true
240
+ }
241
+ }
242
+ return "", false
243
+ }
244
+
171
245
  func structuralCursorLines(lines []string) ([]string, bool) {
172
246
  if len(lines) == 0 {
173
247
  return nil, true
@@ -2,8 +2,9 @@ package state
2
2
 
3
3
  //go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
4
4
 
5
- // SchemaVersion versions the persisted workflow/state manifest.
6
- const SchemaVersion = 2
5
+ // SchemaVersion versions the persisted workflow/state manifest and the
6
+ // workspace contract the engine accepts.
7
+ const SchemaVersion = 3
7
8
 
8
9
  const EvidenceFile = "evidence.md"
9
10
 
@@ -703,7 +703,7 @@ func TestStatusRejectsCorruptStateMarkdownWithoutContentDisclosure(t *testing.T)
703
703
  if err := os.MkdirAll(dir, 0o755); err != nil {
704
704
  t.Fatal(err)
705
705
  }
706
- if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n"), 0o644); err != nil {
706
+ if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n| schema | 3 |\n"), 0o644); err != nil {
707
707
  t.Fatal(err)
708
708
  }
709
709
  want := `compute status: feature "corrupt": state.md is malformed (malformed_markdown); repair state.md and retry`
@@ -720,7 +720,7 @@ func TestOnlyCanonicalWorkLayoutIsDiscovered(t *testing.T) {
720
720
  if err := os.MkdirAll(legacy, 0o755); err != nil {
721
721
  t.Fatal(err)
722
722
  }
723
- if err := os.WriteFile(filepath.Join(legacy, "state.md"), []byte("- Phase: spec\n"), 0o644); err != nil {
723
+ if err := os.WriteFile(filepath.Join(legacy, "state.md"), []byte("- Phase: spec\n- Schema: 3\n"), 0o644); err != nil {
724
724
  t.Fatal(err)
725
725
  }
726
726
  if _, err := Status(root, "alias"); err == nil {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "generatedBy": "go generate ./internal/state; DO NOT EDIT",
3
- "schemaVersion": 2,
3
+ "schemaVersion": 3,
4
4
  "authorityPolicy": {
5
5
  "principlesTrust": "Project principles may become project policy only after explicit provenance and validation; arbitrary project-local Markdown is never inherently trusted executable instruction.",
6
6
  "trackedState": [
@@ -0,0 +1,58 @@
1
+ package state
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strconv"
9
+ "strings"
10
+ )
11
+
12
+ // ErrWorkspaceSchemaRefused identifies a workspace whose declared schema is not
13
+ // the current engine contract; callers map it to the blocked exit code.
14
+ var ErrWorkspaceSchemaRefused = errors.New("workspace schema version refused")
15
+
16
+ // workspaceSchemaKey is the state.md cursor row carrying the workspace schema
17
+ // version. Workspaces written before the v5 engine carry no row and resolve to
18
+ // schema 2, the last pre-v5 contract.
19
+ const workspaceSchemaKey = CursorSchema
20
+
21
+ // WorkspaceSchema reports the schema version declared by the feature's
22
+ // state.md cursor. A missing row means the pre-v5 (schema 2) contract.
23
+ func WorkspaceSchema(root, slug string) (int, error) {
24
+ raw, err := os.ReadFile(filepath.Join(featureDir(root, slug), LedgerFile))
25
+ if err != nil {
26
+ return 0, fmt.Errorf("read workspace schema for %s: %w", slug, err)
27
+ }
28
+ lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
29
+ return workspaceSchemaRow(lines)
30
+ }
31
+
32
+ // RequireWorkspaceSchema refuses feature workspaces whose schema is not the
33
+ // current one, naming the exact recovery path for each direction.
34
+ func RequireWorkspaceSchema(root, slug string) error {
35
+ version, err := WorkspaceSchema(root, slug)
36
+ if err != nil {
37
+ return err
38
+ }
39
+ if version < SchemaVersion {
40
+ return fmt.Errorf("%w: workspace %s predates the v5 workspace schema (schema %d, engine requires %d); run devrites-engine migrate %s", ErrWorkspaceSchemaRefused, slug, version, SchemaVersion, slug)
41
+ }
42
+ if version > SchemaVersion {
43
+ return fmt.Errorf("%w: workspace %s declares schema %d, newer than this engine's %d; upgrade devrites", ErrWorkspaceSchemaRefused, slug, version, SchemaVersion)
44
+ }
45
+ return nil
46
+ }
47
+
48
+ func workspaceSchemaRow(lines []string) (int, error) {
49
+ value, ok := CursorField(lines, workspaceSchemaKey)
50
+ if !ok {
51
+ return 2, nil
52
+ }
53
+ version, err := strconv.Atoi(strings.TrimSpace(value))
54
+ if err != nil || version <= 0 {
55
+ return 0, fmt.Errorf("state.md schema row is invalid (value %q); expected the workspace schema version", value)
56
+ }
57
+ return version, nil
58
+ }
@@ -0,0 +1,67 @@
1
+ package state
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func writeSchemaWorkspace(t *testing.T, stateBody string) (root, slug string) {
11
+ t.Helper()
12
+ root = t.TempDir()
13
+ slug = "demo"
14
+ if err := os.MkdirAll(featureDir(root, slug), 0o755); err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ if err := os.WriteFile(filepath.Join(featureDir(root, slug), LedgerFile), []byte(stateBody), 0o644); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ return root, slug
21
+ }
22
+
23
+ func TestWorkspaceSchemaDefaultsToPreV5WhenRowIsAbsent(t *testing.T) {
24
+ root, slug := writeSchemaWorkspace(t, "- Phase: build\n- Status: running\n")
25
+ version, err := WorkspaceSchema(root, slug)
26
+ if err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ if version != 2 {
30
+ t.Fatalf("version=%d want 2 (pre-v5 default)", version)
31
+ }
32
+ if err := RequireWorkspaceSchema(root, slug); err == nil {
33
+ t.Fatal("pre-v5 workspace must be refused")
34
+ } else if !strings.Contains(err.Error(), "devrites-engine migrate demo") {
35
+ t.Fatalf("refusal must name migrate, got %v", err)
36
+ }
37
+ }
38
+
39
+ func TestWorkspaceSchemaAcceptsCurrentAndRefusesNewer(t *testing.T) {
40
+ root, slug := writeSchemaWorkspace(t, "| phase | build |\n| schema | 3 |\n")
41
+ if err := RequireWorkspaceSchema(root, slug); err != nil {
42
+ t.Fatalf("current schema must pass, got %v", err)
43
+ }
44
+
45
+ root, slug = writeSchemaWorkspace(t, "| phase | build |\n| schema | 4 |\n")
46
+ err := RequireWorkspaceSchema(root, slug)
47
+ if err == nil {
48
+ t.Fatal("newer schema must be refused")
49
+ }
50
+ if !strings.Contains(err.Error(), "upgrade devrites") {
51
+ t.Fatalf("newer-schema refusal must name the upgrade path, got %v", err)
52
+ }
53
+ }
54
+
55
+ func TestWorkspaceSchemaRefusesMalformedRow(t *testing.T) {
56
+ root, slug := writeSchemaWorkspace(t, "| phase | build |\n| schema | banana |\n")
57
+ if err := RequireWorkspaceSchema(root, slug); err == nil {
58
+ t.Fatal("malformed schema row must be refused")
59
+ }
60
+ }
61
+
62
+ func TestWorkspaceSchemaMissingLedgerIsRefused(t *testing.T) {
63
+ root := t.TempDir()
64
+ if err := RequireWorkspaceSchema(root, "absent"); err == nil {
65
+ t.Fatal("missing workspace must be refused")
66
+ }
67
+ }