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,219 @@
1
+ package lib
2
+
3
+ import (
4
+ "flag"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+
11
+ "github.com/devrites/devrites/internal/devritespaths"
12
+ "github.com/devrites/devrites/internal/fsutil"
13
+ "github.com/devrites/devrites/internal/state"
14
+ )
15
+
16
+ // migrateStep is one deterministic action of a migration plan.
17
+ type migrateStep struct {
18
+ kind string // "phase", "cursor", "schema", or "stub"
19
+ path string // workspace-relative file a stub creates
20
+ detail string
21
+ }
22
+
23
+ type migrateQuestion struct {
24
+ id string
25
+ prompt string
26
+ }
27
+
28
+ type migratePlan struct {
29
+ phase string
30
+ steps []migrateStep
31
+ questions []migrateQuestion
32
+ }
33
+
34
+ // Migrate normalizes a pre-v5 feature workspace to the current schema: it
35
+ // converts legacy bullet cursor fields into canonical table rows, records the
36
+ // schema row, and creates missing required artifacts as empty stubs. Bound
37
+ // proof files are preserved byte-exact; content is never synthesized. The
38
+ // command is one-shot and fail-closed: on ambiguity it writes nothing, prints
39
+ // its questions, and exits non-zero; answers arrive on rerun via
40
+ // --answer id=choice. --dry-run prints the plan and always writes nothing.
41
+ func Migrate(root string, args []string, stdout, stderr io.Writer) int {
42
+ flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
43
+ flags.SetOutput(io.Discard)
44
+ dryRun := flags.Bool("dry-run", false, "print the plan without writing")
45
+ var answerPairs stringSlice
46
+ flags.Var(&answerPairs, "answer", "answer a migration question: id=choice")
47
+ // Parse with interleaving support: stdlib stops at the first positional,
48
+ // so re-parse the remainder and collect positionals along the way.
49
+ var positionals []string
50
+ remaining := args
51
+ for {
52
+ if err := flags.Parse(remaining); err != nil {
53
+ fmt.Fprintf(stderr, "devrites: %v\n", err)
54
+ return 2
55
+ }
56
+ rest := flags.Args()
57
+ if len(rest) == 0 {
58
+ break
59
+ }
60
+ positionals = append(positionals, rest[0])
61
+ remaining = rest[1:]
62
+ }
63
+ if len(positionals) != 1 {
64
+ fmt.Fprintln(stderr, "usage: devrites-engine migrate <slug> [--dry-run] [--answer id=choice]")
65
+ return 2
66
+ }
67
+ slug := positionals[0]
68
+
69
+ work, err := devritespaths.ExistingFeatureDirChecked(root, slug)
70
+ if err != nil {
71
+ fmt.Fprintf(stderr, "migrate: %v\n", err)
72
+ return 2
73
+ }
74
+ answered := map[string]string{}
75
+ for _, pair := range answerPairs {
76
+ id, value, ok := strings.Cut(pair, "=")
77
+ if !ok || strings.TrimSpace(id) == "" {
78
+ fmt.Fprintf(stderr, "devrites: invalid --answer %q (want id=choice)\n", pair)
79
+ return 2
80
+ }
81
+ answered[strings.TrimSpace(id)] = strings.TrimSpace(value)
82
+ }
83
+
84
+ // Read, plan, and apply under one feature lock so a concurrent
85
+ // `state resolve`/`close` can never be clobbered by a stale read.
86
+ ledger := filepath.Join(work, "state.md")
87
+ var applied int
88
+ var exit int
89
+ var result string
90
+ err = state.WithFeatureLock(root, slug, func() error {
91
+ raw, err := os.ReadFile(ledger)
92
+ if err != nil {
93
+ return err
94
+ }
95
+ lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
96
+
97
+ version, err := state.WorkspaceSchema(root, slug)
98
+ if err != nil {
99
+ return err
100
+ }
101
+ if version > state.SchemaVersion {
102
+ fmt.Fprintf(stderr, "migrate: workspace %s declares schema %d, newer than this engine's %d; upgrade devrites\n", slug, version, state.SchemaVersion)
103
+ exit = 3
104
+ return nil
105
+ }
106
+ if version == state.SchemaVersion {
107
+ result = fmt.Sprintf("migrate: %s already at schema %d; nothing to do\n", slug, version)
108
+ exit = 0
109
+ return nil
110
+ }
111
+
112
+ plan := buildMigratePlan(root, slug, lines, answered)
113
+ if len(plan.questions) > 0 {
114
+ for _, q := range plan.questions {
115
+ fmt.Fprintf(stderr, "migrate: question %s: %s\n", q.id, q.prompt)
116
+ }
117
+ fmt.Fprintln(stderr, "migrate: nothing written; re-run with --answer id=choice")
118
+ exit = 3
119
+ return nil
120
+ }
121
+
122
+ if *dryRun {
123
+ fmt.Fprintf(stdout, "migrate: plan for %s\n", slug)
124
+ for _, step := range plan.steps {
125
+ fmt.Fprintf(stdout, " [%s] %s\n", step.kind, step.detail)
126
+ }
127
+ fmt.Fprintln(stdout, "migrate: dry run; nothing written")
128
+ exit = 0
129
+ return nil
130
+ }
131
+
132
+ // New stub files go first: they are idempotent, never bound, and
133
+ // state.md is the commit point written last.
134
+ for _, step := range plan.steps {
135
+ if step.kind != "stub" {
136
+ continue
137
+ }
138
+ path := filepath.Join(work, filepath.FromSlash(step.path))
139
+ if _, statErr := os.Stat(path); statErr == nil {
140
+ return fmt.Errorf("stub target %s appeared during migration; re-run migrate", step.path)
141
+ }
142
+ if err := fsutil.WriteFileAtomic(path, nil, 0o644); err != nil {
143
+ return err
144
+ }
145
+ applied++
146
+ }
147
+ next := lines
148
+ for _, step := range plan.steps {
149
+ if step.kind == "phase" {
150
+ next = state.UpsertCursorField(next, state.CursorPhase, plan.phase)
151
+ }
152
+ }
153
+ if converted, changed := state.ConvertCursorToTable(next); changed {
154
+ next = converted
155
+ }
156
+ next = state.UpsertCursorField(next, state.CursorSchema, fmt.Sprint(state.SchemaVersion))
157
+ if err := fsutil.WriteFileAtomic(ledger, []byte(strings.Join(next, "\n")), 0o644); err != nil {
158
+ return err
159
+ }
160
+ result = fmt.Sprintf("migrate: %s normalized to schema %d (%d stub(s) created)\n", slug, state.SchemaVersion, applied)
161
+ exit = 0
162
+ return nil
163
+ })
164
+ if err != nil {
165
+ fmt.Fprintf(stderr, "migrate: %v\n", err)
166
+ return 1
167
+ }
168
+ fmt.Fprint(stdout, result)
169
+ return exit
170
+ }
171
+
172
+ func buildMigratePlan(root, slug string, lines []string, answered map[string]string) migratePlan {
173
+ plan := migratePlan{}
174
+ phase, hasPhase := state.CursorField(lines, state.CursorPhase)
175
+
176
+ if value, ok := answered["phase"]; ok {
177
+ plan.steps = append(plan.steps, migrateStep{kind: "phase", detail: "record phase " + value + " from answer"})
178
+ phase, hasPhase = value, true
179
+ }
180
+ if !hasPhase {
181
+ plan.questions = append(plan.questions, migrateQuestion{id: "phase",
182
+ prompt: "state.md records no phase; re-run with --answer phase=<phase>"})
183
+ return plan
184
+ }
185
+ if _, ok := state.PolicyFor(state.Phase(phase)); !ok {
186
+ plan.questions = append(plan.questions, migrateQuestion{id: "phase",
187
+ prompt: "state.md records unknown phase " + phase + "; re-run with --answer phase=<phase>"})
188
+ return plan
189
+ }
190
+ plan.phase = phase
191
+
192
+ if state.CursorForm(lines) == "legacy" {
193
+ plan.steps = append(plan.steps, migrateStep{kind: "cursor", detail: "convert legacy cursor bullets to canonical table rows"})
194
+ }
195
+ plan.steps = append(plan.steps, migrateStep{kind: "schema", detail: fmt.Sprintf("record schema %d in state.md", state.SchemaVersion)})
196
+
197
+ policy, _ := state.PolicyFor(state.Phase(phase))
198
+ for _, artifact := range policy.RequiredArtifacts {
199
+ name := string(artifact)
200
+ if name == state.LedgerFile {
201
+ continue
202
+ }
203
+ if _, err := os.Stat(filepath.Join(devritespaths.FeatureDir(root, slug), filepath.FromSlash(name))); err == nil {
204
+ continue
205
+ }
206
+ plan.steps = append(plan.steps, migrateStep{kind: "stub", path: name, detail: "create missing " + name + " as an empty stub"})
207
+ }
208
+ return plan
209
+ }
210
+
211
+ // stringSlice collects repeated string flag values.
212
+ type stringSlice []string
213
+
214
+ func (s *stringSlice) String() string { return strings.Join(*s, ",") }
215
+
216
+ func (s *stringSlice) Set(value string) error {
217
+ *s = append(*s, value)
218
+ return nil
219
+ }
@@ -0,0 +1,154 @@
1
+ package lib
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // seedPreV5Workspace builds a pre-v5 workspace with a bullet cursor, prose,
11
+ // and no required artifacts beyond state.md.
12
+ func seedPreV5Workspace(t *testing.T, phase string) (root, workspace string) {
13
+ t.Helper()
14
+ root = t.TempDir()
15
+ workspace = filepath.Join(root, "work", "feature")
16
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ body := "- Phase: " + phase + "\n- Status: running\n- Next step: continue slice S-2\n\n# Status\n\nMinting middleware landed.\n"
20
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte(body), 0o644); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ return root, workspace
24
+ }
25
+
26
+ func TestMigrateDryRunPlansWithoutWriting(t *testing.T) {
27
+ root, workspace := seedPreV5Workspace(t, "build")
28
+ var stdout, stderr strings.Builder
29
+ if code := Migrate(root, []string{"feature", "--dry-run"}, &stdout, &stderr); code != 0 {
30
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
31
+ }
32
+ out := stdout.String()
33
+ if !strings.Contains(out, "migrate: plan for feature") ||
34
+ !strings.Contains(out, "[cursor] convert legacy cursor bullets") ||
35
+ !strings.Contains(out, "[schema] record schema") ||
36
+ !strings.Contains(out, "[stub] create missing spec.md") {
37
+ t.Fatalf("plan incomplete:\n%s", out)
38
+ }
39
+ // Nothing written: state.md bytes unchanged and no stub exists.
40
+ body, err := os.ReadFile(filepath.Join(workspace, "state.md"))
41
+ if err != nil || !strings.HasPrefix(string(body), "- Phase: build\n") {
42
+ t.Fatalf("state.md changed during dry run: %v %q", err, body)
43
+ }
44
+ if _, err := os.Stat(filepath.Join(workspace, "spec.md")); !os.IsNotExist(err) {
45
+ t.Fatalf("stub created during dry run: %v", err)
46
+ }
47
+ }
48
+
49
+ func TestMigrateExecutesCursorConversionAndStubs(t *testing.T) {
50
+ root, workspace := seedPreV5Workspace(t, "build")
51
+ var stdout, stderr strings.Builder
52
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 0 {
53
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
54
+ }
55
+ body, err := os.ReadFile(filepath.Join(workspace, "state.md"))
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ text := string(body)
60
+ if !strings.Contains(text, "| phase | build |") || !strings.Contains(text, "| status | running |") {
61
+ t.Fatalf("cursor bullets not converted:\n%s", text)
62
+ }
63
+ if !strings.Contains(text, "| next_action | continue slice S-2 |") {
64
+ t.Fatalf("next step not converted to canonical spelling:\n%s", text)
65
+ }
66
+ if strings.Contains(text, "- Next step:") {
67
+ t.Fatalf("legacy next step bullet survived:\n%s", text)
68
+ }
69
+ if !strings.Contains(text, "| schema | 3 |") {
70
+ t.Fatalf("schema row missing:\n%s", text)
71
+ }
72
+ if !strings.Contains(text, "# Status\n\nMinting middleware landed.") {
73
+ t.Fatalf("prose not preserved:\n%s", text)
74
+ }
75
+ // Required artifacts for build exist as empty stubs.
76
+ for _, name := range []string{"spec.md", "brief.md", "eng-review.md", "test-plan.md"} {
77
+ info, err := os.Stat(filepath.Join(workspace, name))
78
+ if err != nil {
79
+ t.Fatalf("stub %s: %v", name, err)
80
+ }
81
+ if info.Size() != 0 {
82
+ t.Fatalf("stub %s synthesized content", name)
83
+ }
84
+ }
85
+ // Idempotent: a second run is a no-op.
86
+ stdout.Reset()
87
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 0 {
88
+ t.Fatalf("second run code=%d stderr=%q", code, stderr.String())
89
+ }
90
+ if !strings.Contains(stdout.String(), "nothing to do") {
91
+ t.Fatalf("second run should be a no-op, got %q", stdout.String())
92
+ }
93
+ }
94
+
95
+ func TestMigrateUnknownPhaseAsksAndWritesNothing(t *testing.T) {
96
+ root, workspace := seedPreV5Workspace(t, "research")
97
+ var stdout, stderr strings.Builder
98
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 3 {
99
+ t.Fatalf("code=%d want 3", code)
100
+ }
101
+ if !strings.Contains(stderr.String(), "question phase:") || !strings.Contains(stderr.String(), "nothing written") {
102
+ t.Fatalf("stderr=%q", stderr.String())
103
+ }
104
+ body, _ := os.ReadFile(filepath.Join(workspace, "state.md"))
105
+ if !strings.HasPrefix(string(body), "- Phase: research\n") {
106
+ t.Fatalf("state.md changed on refusal: %q", body)
107
+ }
108
+
109
+ // Answering the question on rerun proceeds.
110
+ if code := Migrate(root, []string{"feature", "--answer", "phase=build"}, &stdout, &stderr); code != 0 {
111
+ t.Fatalf("answer rerun code=%d stderr=%q", code, stderr.String())
112
+ }
113
+ body, _ = os.ReadFile(filepath.Join(workspace, "state.md"))
114
+ if !strings.Contains(string(body), "| phase | build |") || !strings.Contains(string(body), "| schema | 3 |") {
115
+ t.Fatalf("answer rerun did not migrate:\n%s", body)
116
+ }
117
+ }
118
+
119
+ // A done workspace migrates like any other: it is fully artifacted (zero
120
+ // stubs), and normalizing it unblocks `state close`, which refuses pre-v5
121
+ // workspaces. A special no-op would strand done workspaces between migrate
122
+ // and close.
123
+ func TestMigrateDoneWorkspaceNormalizesWithoutStubs(t *testing.T) {
124
+ root, workspace := seedPreV5Workspace(t, "done")
125
+ var stdout, stderr strings.Builder
126
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 0 {
127
+ t.Fatalf("code=%d stderr=%q", code, stderr.String())
128
+ }
129
+ body, _ := os.ReadFile(filepath.Join(workspace, "state.md"))
130
+ if !strings.Contains(string(body), "| schema | 3 |") {
131
+ t.Fatalf("done workspace not normalized:\n%s", body)
132
+ }
133
+ if !strings.Contains(stdout.String(), "normalized to schema 3") {
134
+ t.Fatalf("stdout=%q", stdout.String())
135
+ }
136
+ }
137
+
138
+ func TestMigrateCurrentAndNewerSchemaAreRefused(t *testing.T) {
139
+ root, workspace := seedPreV5Workspace(t, "build")
140
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | build |\n| schema | 3 |\n"), 0o644); err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ var stdout, stderr strings.Builder
144
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 0 || !strings.Contains(stdout.String(), "nothing to do") {
145
+ t.Fatalf("current schema: code=%d stdout=%q", code, stdout.String())
146
+ }
147
+
148
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | build |\n| schema | 9 |\n"), 0o644); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ if code := Migrate(root, []string{"feature"}, &stdout, &stderr); code != 3 || !strings.Contains(stderr.String(), "upgrade devrites") {
152
+ t.Fatalf("newer schema: code=%d stderr=%q", code, stderr.String())
153
+ }
154
+ }
@@ -35,6 +35,9 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
35
35
  if !isFile(sfile) {
36
36
  return fail(stderr, "state.md missing at "+sfile, 2)
37
37
  }
38
+ if err := state.RequireWorkspaceSchema(root, slug); err != nil {
39
+ return fail(stderr, err.Error(), 3)
40
+ }
38
41
 
39
42
  var mode, qid, payload string
40
43
  switch first := argAt(args, 0); first {
@@ -55,6 +55,6 @@ func resolveWorkspace(t *testing.T, questions string) string {
55
55
  root := t.TempDir()
56
56
  testutil.WriteFile(t, filepath.Join(root, "ACTIVE"), "feat\n")
57
57
  testutil.WriteFile(t, filepath.Join(root, "work", "feat", "questions.md"), questions)
58
- testutil.WriteFile(t, filepath.Join(root, "work", "feat", "state.md"), "- Status: running\n- Next step: continue\n")
58
+ testutil.WriteFile(t, filepath.Join(root, "work", "feat", "state.md"), "- Status: running\n- Next step: continue\n- Schema: 3\n")
59
59
  return root
60
60
  }
@@ -348,6 +348,7 @@ func TestSecretScanBlocksHighSeverityTouchedFile(t *testing.T) {
348
348
  root := filepath.Join(project, ".devrites")
349
349
  testutil.WriteFile(t, filepath.Join(root, "ACTIVE"), "leak\n")
350
350
  testutil.WriteFile(t, filepath.Join(root, "work", "leak", "touched-files.md"), candidateTestManifest(candidateTestRow{state: "present", path: "secrets.txt", slice: "S-1", reason: "Credential check."}))
351
+ testutil.WriteFile(t, filepath.Join(root, "work", "leak", "state.md"), "| schema | 3 |\n")
351
352
  secret := "ghp_" + strings.Repeat("f", 32)
352
353
  testutil.WriteFile(t, filepath.Join(project, "secrets.txt"), secret+"\n")
353
354
  var out, err bytes.Buffer
@@ -259,7 +259,7 @@ func TestObserveSummaryExposesTaskGraphProblems(t *testing.T) {
259
259
  if err := os.MkdirAll(workspace, 0o755); err != nil {
260
260
  t.Fatal(err)
261
261
  }
262
- if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n"), 0o644); err != nil {
262
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n| schema | 3 |\n"), 0o644); err != nil {
263
263
  t.Fatal(err)
264
264
  }
265
265
  body := `# Tasks
@@ -308,7 +308,7 @@ func TestObserveSummaryExposesProblemsWhenNoSliceHeaders(t *testing.T) {
308
308
  if err := os.MkdirAll(workspace, 0o755); err != nil {
309
309
  t.Fatal(err)
310
310
  }
311
- if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n"), 0o644); err != nil {
311
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("| phase | define |\n| schema | 3 |\n"), 0o644); err != nil {
312
312
  t.Fatal(err)
313
313
  }
314
314
  if err := os.WriteFile(filepath.Join(workspace, "tasks.md"), []byte("# Tasks\n\n- do the work\n"), 0o644); err != nil {
@@ -9,6 +9,16 @@ import (
9
9
  "strings"
10
10
  )
11
11
 
12
+ // Test seams: indirection over the real primitives so discriminating tests
13
+ // can inject transient failures without touching the filesystem.
14
+ var (
15
+ writeLease = WriteLease
16
+ removeWorktree = worktreeRemove
17
+ warnf = func(format string, args ...any) {
18
+ fmt.Fprintf(os.Stderr, "warning: parallel: "+format+"\n", args...)
19
+ }
20
+ )
21
+
12
22
  // CreateOpts configures parallel worktree creation.
13
23
  type CreateOpts struct {
14
24
  Root string
@@ -54,13 +64,16 @@ func Create(opts CreateOpts) (*Lease, error) {
54
64
  createdBranches := make([]string, 0, len(opts.Slices))
55
65
  cleanupPartial := func() {
56
66
  for _, p := range createdPaths {
57
- _ = worktreeRemove(opts.Root, p)
67
+ if err := removeWorktree(opts.Root, p); err != nil {
68
+ warnf("worktree cleanup %s: %v", p, err)
69
+ }
58
70
  }
59
71
  for _, b := range createdBranches {
60
- _ = deleteBranch(opts.Root, b)
72
+ if err := deleteBranch(opts.Root, b); err != nil {
73
+ warnf("branch cleanup %s: %v", b, err)
74
+ }
61
75
  }
62
76
  }
63
-
64
77
  slices := make([]LeaseSlice, 0, len(opts.Slices))
65
78
  for _, sp := range opts.Slices {
66
79
  if err := validateSliceID(sp.ID); err != nil {
@@ -252,12 +265,28 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
252
265
  }
253
266
 
254
267
  fail := func(reason error) (string, *Lease, error) {
255
- _ = worktreeRemove(opts.Root, stageWT)
256
- _ = deleteBranch(opts.Root, ibranch)
257
- _, _ = git(opts.Root, "worktree", "prune")
268
+ if err := removeWorktree(opts.Root, stageWT); err != nil {
269
+ warnf("staging worktree cleanup: %v", err)
270
+ }
271
+ if err := deleteBranch(opts.Root, ibranch); err != nil {
272
+ warnf("integrate branch cleanup: %v", err)
273
+ }
274
+ if _, err := git(opts.Root, "worktree", "prune"); err != nil {
275
+ warnf("worktree prune: %v", err)
276
+ }
258
277
  lease.Status = StatusIntegrateFailed
259
- _ = WriteLease(leasePath, lease)
260
- _ = ensureControlAtBase(opts.Root, base, true)
278
+ if err := writeLease(leasePath, lease); err != nil {
279
+ // The integrate-failed transition must survive a transient write
280
+ // failure: a stale running lease still blocks create and stays
281
+ // retryable, but the honest state must be recorded when possible.
282
+ if retryErr := writeLease(leasePath, lease); retryErr != nil {
283
+ warnf("lease %s still status=%s (write failed twice: %v)",
284
+ leasePath, StatusRunning, retryErr)
285
+ }
286
+ }
287
+ if err := ensureControlAtBase(opts.Root, base, true); err != nil {
288
+ warnf("control reset to base: %v", err)
289
+ }
261
290
  return "", lease, reason
262
291
  }
263
292
 
@@ -313,8 +342,18 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
313
342
  if err != nil {
314
343
  return fail(err)
315
344
  }
316
- _ = worktreeRemove(opts.Root, stageWT)
317
- _, _ = git(opts.Root, "worktree", "prune")
345
+ if err := removeWorktree(opts.Root, stageWT); err != nil {
346
+ if _, statErr := os.Stat(stageWT); statErr == nil {
347
+ // The directory is genuinely stuck; failing now keeps the lease in
348
+ // integrate-failed and avoids compounding into un-cleanable branch
349
+ // state. Retrying integrate re-attempts the same removals.
350
+ return fail(fmt.Errorf("staging worktree could not be removed; retry integrate after cleanup: %w", err))
351
+ }
352
+ warnf("staging worktree cleanup: %v", err)
353
+ }
354
+ if _, err := git(opts.Root, "worktree", "prune"); err != nil {
355
+ warnf("worktree prune: %v", err)
356
+ }
318
357
 
319
358
  if opts.ApplyToControl {
320
359
  if err := mergeFFOnly(opts.Root, tip); err != nil {
@@ -361,17 +400,29 @@ func Cleanup(repoRoot, slug string, force bool) error {
361
400
  }
362
401
  for _, sl := range lease.Slices {
363
402
  if sl.WorktreePath != "" {
364
- _ = worktreeRemove(repoRoot, sl.WorktreePath)
365
- _ = os.RemoveAll(sl.WorktreePath)
403
+ if err := removeWorktree(repoRoot, sl.WorktreePath); err != nil {
404
+ warnf("worktree cleanup %s: %v", sl.WorktreePath, err)
405
+ }
406
+ if err := os.RemoveAll(sl.WorktreePath); err != nil {
407
+ warnf("worktree dir cleanup %s: %v", sl.WorktreePath, err)
408
+ }
366
409
  }
367
410
  if sl.Branch != "" {
368
- _ = deleteBranch(repoRoot, sl.Branch)
411
+ if err := deleteBranch(repoRoot, sl.Branch); err != nil {
412
+ warnf("branch cleanup %s: %v", sl.Branch, err)
413
+ }
369
414
  }
370
415
  }
371
416
  ibranch := IntegrateBranchName(slug, lease.BatchID)
372
- _ = deleteBranch(repoRoot, ibranch)
373
- _ = os.RemoveAll(filepath.Join(ScratchRoot(repoRoot), lease.BatchID))
374
- _, _ = git(repoRoot, "worktree", "prune")
417
+ if err := deleteBranch(repoRoot, ibranch); err != nil {
418
+ warnf("integrate branch cleanup %s: %v", ibranch, err)
419
+ }
420
+ if err := os.RemoveAll(filepath.Join(ScratchRoot(repoRoot), lease.BatchID)); err != nil {
421
+ warnf("scratch cleanup: %v", err)
422
+ }
423
+ if _, err := git(repoRoot, "worktree", "prune"); err != nil {
424
+ warnf("worktree prune: %v", err)
425
+ }
375
426
  return ClearLease(leasePath)
376
427
  }
377
428