devrites 3.0.1 → 3.0.3

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 (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0004-state-schema-phases-sections.md +44 -0
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/state-schema.md +15 -12
  6. package/docs/flow.md +1 -1
  7. package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
  8. package/engine/hooks_events_test.go +15 -0
  9. package/engine/hooks_workspace.go +8 -31
  10. package/engine/internal/gate/gate.go +6 -11
  11. package/engine/internal/gate/gate_test.go +18 -2
  12. package/engine/internal/lib/buildreadiness.go +10 -16
  13. package/engine/internal/lib/cursor_compat_test.go +120 -0
  14. package/engine/internal/lib/preamble.go +1 -1
  15. package/engine/internal/lib/preamble_questions_test.go +10 -0
  16. package/engine/internal/lib/progress.go +69 -29
  17. package/engine/internal/lib/resolve.go +21 -17
  18. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  19. package/engine/internal/lib/tickafk.go +11 -28
  20. package/engine/internal/migrate/migrate.go +13 -32
  21. package/engine/internal/migrate/migrate_test.go +11 -3
  22. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  23. package/engine/internal/state/cursor.go +119 -0
  24. package/engine/internal/state/cursor_test.go +58 -0
  25. package/engine/internal/state/feature.go +25 -48
  26. package/engine/internal/state/schema.go +151 -24
  27. package/engine/internal/state/snapshot.go +60 -3
  28. package/engine/internal/state/state_test.go +119 -3
  29. package/engine/internal/state/workflow_manifest.json +310 -0
  30. package/engine/internal/workflow/commands.go +10 -29
  31. package/engine/internal/workflow/commands_test.go +18 -0
  32. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  33. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  34. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  35. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  36. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  37. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  38. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  39. package/engine/tests/adr_0004_required_by_phase_test.go +1 -12
  40. package/engine/tests/gate_test.go +22 -0
  41. package/engine/tests/lib_parity_test.go +1 -1
  42. package/engine/tests/migrate_cli_test.go +3 -3
  43. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  44. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  45. package/pack/generated/codex/AGENTS.md +1 -1
  46. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  47. package/package.json +1 -1
  48. package/scripts/codex-generate.sh +1 -1
  49. package/scripts/grade-feature.sh +5 -3
  50. package/scripts/run-outcome-evals.sh +21 -0
  51. package/scripts/validate-workspace-schema.py +9 -73
  52. package/scripts/validate.sh +10 -0
  53. package/scripts/workflow_schema.py +69 -0
@@ -0,0 +1,120 @@
1
+ package lib
2
+
3
+ import (
4
+ "bytes"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ const canonicalCursor = `# State
12
+
13
+ ## Cursor
14
+ | Key | Value |
15
+ | --- | --- |
16
+ | phase | temper |
17
+ | status | running |
18
+ | next_action | /rite-define |
19
+ | plan_approved | 2026-07-20 |
20
+ | afk_slices_remaining | 2 |
21
+ `
22
+
23
+ func TestCanonicalCursorReadAndWriteConsumers(t *testing.T) {
24
+ root := t.TempDir()
25
+ dir := filepath.Join(root, "work", "demo")
26
+ if err := os.MkdirAll(dir, 0o755); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ statePath := filepath.Join(dir, "state.md")
30
+ if err := os.WriteFile(statePath, []byte(canonicalCursor), 0o644); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+
34
+ t.Run("build readiness", func(t *testing.T) {
35
+ var stdout, stderr bytes.Buffer
36
+ if code := BuildReadiness(root, []string{"demo"}, &stdout, &stderr); code != 0 {
37
+ t.Fatalf("code=%d, stderr=%s", code, stderr.String())
38
+ }
39
+ })
40
+
41
+ t.Run("progress", func(t *testing.T) {
42
+ var stdout bytes.Buffer
43
+ if code := Progress(root, []string{"demo"}, &stdout, &bytes.Buffer{}); code != 0 {
44
+ t.Fatalf("code=%d", code)
45
+ }
46
+ if !strings.Contains(stdout.String(), "rite-temper") || !strings.Contains(stdout.String(), "temper ◉") {
47
+ t.Fatalf("progress ignored table phase:\n%s", stdout.String())
48
+ }
49
+ })
50
+
51
+ t.Run("tick AFK", func(t *testing.T) {
52
+ var stdout, stderr bytes.Buffer
53
+ if code := TickAfk([]string{statePath}, &stdout, &stderr); code != 0 {
54
+ t.Fatalf("code=%d, stderr=%s", code, stderr.String())
55
+ }
56
+ raw, err := os.ReadFile(statePath)
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ if !strings.Contains(string(raw), "| afk_slices_remaining | 1 |") {
61
+ t.Fatalf("tick AFK did not preserve/update table:\n%s", raw)
62
+ }
63
+ })
64
+ }
65
+
66
+ func TestClearAwaitingSupportsCanonicalCursor(t *testing.T) {
67
+ path := filepath.Join(t.TempDir(), "state.md")
68
+ state := canonicalCursor + `
69
+ ## Awaiting human
70
+ | Key | Value |
71
+ | --- | --- |
72
+ | question_id | Q-001 |
73
+ | gate | blocking |
74
+ `
75
+ state = strings.Replace(state, "| status | running |", "| status | awaiting_human |", 1)
76
+ if err := os.WriteFile(path, []byte(state), 0o644); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+
80
+ if err := clearAwaiting(path, "Q-001"); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ raw, err := os.ReadFile(path)
84
+ if err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ text := string(raw)
88
+ if strings.Contains(text, "## Awaiting human") || !strings.Contains(text, "| status | running |") {
89
+ t.Fatalf("canonical awaiting state was not cleared:\n%s", text)
90
+ }
91
+ if !strings.Contains(text, "$rite-temper") || strings.Contains(text, "$rite-build") {
92
+ t.Fatalf("canonical awaiting state resumed the wrong phase:\n%s", text)
93
+ }
94
+ }
95
+
96
+ func TestProgressReadsCanonicalSlicesAndShowsPlanPhase(t *testing.T) {
97
+ root := t.TempDir()
98
+ dir := filepath.Join(root, "work", "demo")
99
+ if err := os.MkdirAll(dir, 0o755); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ stateText := strings.Replace(canonicalCursor, "| phase | temper |", "| phase | plan |", 1)
103
+ stateText = strings.Replace(stateText, "| afk_slices_remaining | 2 |", "| active_slice | SLICE-002 |", 1)
104
+ if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte(stateText), 0o644); err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ tasks := "## SLICE-001 First\nStatus: built\n\n## SLICE-002 Second\nStatus: pending\n"
108
+ if err := os.WriteFile(filepath.Join(dir, "tasks.md"), []byte(tasks), 0o644); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+
112
+ var stdout bytes.Buffer
113
+ if code := Progress(root, []string{"demo"}, &stdout, &bytes.Buffer{}); code != 0 {
114
+ t.Fatalf("code=%d", code)
115
+ }
116
+ text := stdout.String()
117
+ if !strings.Contains(text, "Slice 1/2") || !strings.Contains(text, "plan ◉") || strings.Contains(text, "build ◉") {
118
+ t.Fatalf("progress missed canonical slice/plan state:\n%s", text)
119
+ }
120
+ }
@@ -117,7 +117,7 @@ func tallyOpenQuestions(data []byte) (blocking, validating, advisory, escalating
117
117
  }
118
118
  for _, line := range splitLinesNoTrailing(data) {
119
119
  switch {
120
- case strings.HasPrefix(line, "## q-"):
120
+ case strings.HasPrefix(strings.ToLower(line), "## q-"):
121
121
  finalize()
122
122
  inQ, status, gate = true, "", ""
123
123
  case inQ && strings.HasPrefix(line, "status:"):
@@ -0,0 +1,10 @@
1
+ package lib
2
+
3
+ import "testing"
4
+
5
+ func TestTallyOpenQuestionsAcceptsCanonicalUppercaseIDs(t *testing.T) {
6
+ b, v, a, e := tallyOpenQuestions([]byte("## Q-001\nstatus: open\ngate: blocking\n\n## q-2026-07-20-001\nstatus: open\ngate: escalating\n"))
7
+ if b != 1 || v != 0 || a != 0 || e != 1 {
8
+ t.Fatalf("counts=(%d,%d,%d,%d), want (1,0,0,1)", b, v, a, e)
9
+ }
10
+ }
@@ -7,18 +7,20 @@ import (
7
7
  "path/filepath"
8
8
  "regexp"
9
9
  "strings"
10
+
11
+ "github.com/devrites/devrites/internal/state"
10
12
  )
11
13
 
12
14
  // State-parsing patterns ported verbatim from progress.sh's awk. Each name-strip
13
15
  // sub is anchored (^ or $), so it matches at most once per line — ReplaceAllString
14
16
  // is therefore equivalent to awk's single-match sub.
15
17
  var (
16
- phaseSplitRe = regexp.MustCompile(`: *`)
17
18
  sliceCheckRe = regexp.MustCompile(`^- \[[^\]]*\][[:space:]]*`) // drop "- [x] "
18
19
  sliceNameRe = regexp.MustCompile(`^Slice[[:space:]]*[0-9]+:[[:space:]]*`) // drop "Slice N: "
19
20
  sliceStateRe = regexp.MustCompile(`[[:space:]]+(built|pending)[[:space:]]*$`)
20
21
  sliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`) // drop " — "
21
22
  sliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
23
+ sliceHeadingRe = regexp.MustCompile(`^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+(.*))?$`)
22
24
  )
23
25
 
24
26
  // Progress renders the active feature's position — the deterministic footer every
@@ -49,6 +51,9 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
49
51
  phase = "spec"
50
52
  }
51
53
  total, built, lastbuilt := tallySlices(lines)
54
+ if total == 0 {
55
+ total, built, lastbuilt = tallyCanonicalSlices(workDir)
56
+ }
52
57
 
53
58
  // Header rule: pad "── rite-<phase> " with box glyphs to ≥44 BYTES. Under the
54
59
  // oracle's LC_ALL=C the bash `${#header}` counts bytes, which len() matches.
@@ -77,22 +82,30 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
77
82
  }
78
83
  }
79
84
 
80
- // Flow ribbon: the lifecycle spine; temper/vet appear only once their artifact
81
- // exists.
82
- order := []string{"spec"}
83
- if isFile(filepath.Join(workDir, "strategy.md")) {
84
- order = append(order, "temper")
85
- }
86
- order = append(order, "define")
87
- if isFile(filepath.Join(workDir, "eng-review.md")) {
88
- order = append(order, "vet")
85
+ // Flow ribbon: the lifecycle spine; optional phases appear once their artifact
86
+ // exists or while they are the active phase, so the cursor is never omitted.
87
+ order := make([]string, 0, len(state.LifecyclePhases()))
88
+ for _, lifecyclePhase := range state.LifecyclePhases() {
89
+ name := string(lifecyclePhase)
90
+ include := true
91
+ switch lifecyclePhase {
92
+ case state.PhaseFrame:
93
+ include = phase == name
94
+ case state.PhaseTemper:
95
+ include = phase == name || isFile(filepath.Join(workDir, "strategy.md"))
96
+ case state.PhaseDone:
97
+ include = false
98
+ case state.PhaseVet:
99
+ include = phase == name || isFile(filepath.Join(workDir, "eng-review.md"))
100
+ case state.PhaseConverge:
101
+ include = phase == name
102
+ }
103
+ if include {
104
+ order = append(order, name)
105
+ }
89
106
  }
90
- order = append(order, "build", "prove", "polish", "review", "seal", "ship")
91
107
 
92
108
  cur := phase
93
- if cur == "plan" { // replan sits at build
94
- cur = "build"
95
- }
96
109
  idx := -1
97
110
  if phase == "done" { // past ship
98
111
  idx = len(order)
@@ -118,25 +131,52 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
118
131
  return 0
119
132
  }
120
133
 
121
- // parsePhase returns the first whitespace token of the value on the first
122
- // "- Phase:" line, mirroring progress.sh's `awk -F': *' '/^- Phase:/{print $2}' |
123
- // awk '{print $1}'`. Empty when absent.
124
- func parsePhase(lines []string) string {
125
- for _, line := range lines {
126
- if !strings.HasPrefix(line, "- Phase:") {
127
- continue
134
+ func tallyCanonicalSlices(workDir string) (total, built int, lastbuilt string) {
135
+ raw, err := os.ReadFile(filepath.Join(workDir, "tasks.md"))
136
+ if err != nil {
137
+ return 0, 0, ""
138
+ }
139
+ name, sliceState := "", ""
140
+ finalize := func() {
141
+ if name == "" {
142
+ return
128
143
  }
129
- fields := phaseSplitRe.Split(line, -1)
130
- if len(fields) < 2 {
131
- return ""
144
+ total++
145
+ if sliceState == "built" {
146
+ built++
147
+ lastbuilt = name
132
148
  }
133
- toks := strings.Fields(fields[1])
134
- if len(toks) == 0 {
135
- return ""
149
+ }
150
+ for _, line := range strings.Split(string(raw), "\n") {
151
+ if match := sliceHeadingRe.FindStringSubmatch(strings.TrimSpace(line)); match != nil {
152
+ finalize()
153
+ name = match[1]
154
+ if strings.TrimSpace(match[2]) != "" {
155
+ name += " " + strings.TrimSpace(match[2])
156
+ }
157
+ sliceState = "pending"
158
+ continue
136
159
  }
137
- return toks[0]
160
+ if name != "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "status:") {
161
+ _, value, _ := strings.Cut(strings.TrimSpace(line), ":")
162
+ sliceState = strings.ToLower(strings.TrimSpace(value))
163
+ }
164
+ }
165
+ finalize()
166
+ return total, built, lastbuilt
167
+ }
168
+
169
+ // parsePhase returns the first token of the canonical or legacy phase field.
170
+ func parsePhase(lines []string) string {
171
+ value, ok := state.CursorField(lines, state.CursorPhase)
172
+ if !ok {
173
+ return ""
174
+ }
175
+ toks := strings.Fields(value)
176
+ if len(toks) == 0 {
177
+ return ""
138
178
  }
139
- return ""
179
+ return toks[0]
140
180
  }
141
181
 
142
182
  // tallySlices reads the "## Slice progress" block: total slice lines, how many are
@@ -10,6 +10,7 @@ import (
10
10
  "time"
11
11
 
12
12
  "github.com/devrites/devrites/internal/fsutil"
13
+ "github.com/devrites/devrites/internal/state"
13
14
  "github.com/devrites/devrites/internal/workflow"
14
15
  )
15
16
 
@@ -219,7 +220,7 @@ func resolveQuestion(qfile, qid, status, answer string, stderr io.Writer) int {
219
220
  }
220
221
 
221
222
  var (
222
- qHeaderRe = regexp.MustCompile(`^## q-`)
223
+ qHeaderRe = regexp.MustCompile(`(?i)^## q-`)
223
224
  statusLineRe = regexp.MustCompile(`^status:`)
224
225
  statusStrip = regexp.MustCompile(`^status:[[:space:]]*`)
225
226
  answeredAtRe = regexp.MustCompile(`^answered_at:`)
@@ -285,8 +286,6 @@ func rewriteQuestionFields(lines []string, target *regexp.Regexp, status, answer
285
286
  var (
286
287
  awaitingRe = regexp.MustCompile(`^## Awaiting human`)
287
288
  hdrSpaceRe = regexp.MustCompile(`^##[[:space:]]`)
288
- statusMdRe = regexp.MustCompile(`^- Status:`)
289
- nextStepRe = regexp.MustCompile(`^- Next step:`)
290
289
  logHdrRe = regexp.MustCompile(`^## Log`)
291
290
  )
292
291
 
@@ -300,10 +299,18 @@ func clearAwaiting(sfile, qid string) error {
300
299
  return fmt.Errorf("read state %s: %w", sfile, err)
301
300
  }
302
301
  lines := splitLinesNoTrailing(data)
303
- qidRef := regexp.MustCompile(`qid:[[:space:]]*` + regexp.QuoteMeta(qid) + `([[:space:]]|$)`)
304
-
302
+ resumePhase := state.PhaseBuild
303
+ if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
304
+ if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
305
+ if phase, known := state.PhaseForName(fields[0]); known && state.ResumeVerb(phase) != "" {
306
+ resumePhase = phase
307
+ }
308
+ }
309
+ }
310
+ resumeCommand := workflow.ForVerb(state.ResumeVerb(resumePhase))
305
311
  // First check whether the awaiting block references this question at all.
306
- inAw, matched := false, false
312
+ inAw := false
313
+ var awaitingLines []string
307
314
  for _, line := range lines {
308
315
  switch {
309
316
  case awaitingRe.MatchString(line):
@@ -312,11 +319,12 @@ func clearAwaiting(sfile, qid string) error {
312
319
  case inAw && hdrSpaceRe.MatchString(line):
313
320
  inAw = false
314
321
  }
315
- if inAw && qidRef.MatchString(line) {
316
- matched = true
322
+ if inAw {
323
+ awaitingLines = append(awaitingLines, line)
317
324
  }
318
325
  }
319
- if !matched {
326
+ waitingOn, _ := state.CursorField(awaitingLines, state.CursorQuestionID)
327
+ if waitingOn != qid {
320
328
  return nil
321
329
  }
322
330
 
@@ -336,19 +344,13 @@ func clearAwaiting(sfile, qid string) error {
336
344
  continue
337
345
  }
338
346
  switch {
339
- case statusMdRe.MatchString(line):
340
- out = append(out, "- Status: running")
341
- continue
342
- case nextStepRe.MatchString(line):
343
- out = append(out, "- Next step: (resume — `"+workflow.ForVerb("build").Both()+"` to continue the workflow)")
344
- continue
345
347
  case logHdrRe.MatchString(line):
346
348
  out = append(out, line)
347
349
  inLog = true
348
350
  continue
349
351
  case inLog && hdrSpaceRe.MatchString(line):
350
352
  if !logAppended {
351
- out = append(out, fmt.Sprintf("- %s build: resolved %s", ts, qid))
353
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
352
354
  logAppended = true
353
355
  }
354
356
  inLog = false
@@ -358,8 +360,10 @@ func clearAwaiting(sfile, qid string) error {
358
360
  out = append(out, line)
359
361
  }
360
362
  if inLog && !logAppended {
361
- out = append(out, fmt.Sprintf("- %s build: resolved %s", ts, qid))
363
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
362
364
  }
365
+ out, _ = state.SetCursorField(out, state.CursorStatus, "running")
366
+ out, _ = state.SetCursorField(out, state.CursorNextAction, "(resume — `"+resumeCommand.Both()+"` to continue the workflow)")
363
367
  if err := fsutil.WriteFileAtomic(sfile, joinRecords(out), 0o644); err != nil {
364
368
  return fmt.Errorf("update state %s: %w", sfile, err)
365
369
  }
@@ -39,6 +39,15 @@ func TestResolveCompletesMissingAnswerFieldsInSingleRewrite(t *testing.T) {
39
39
  }
40
40
  }
41
41
 
42
+ func TestResolveAcceptsCanonicalUppercaseQuestionID(t *testing.T) {
43
+ root := resolveWorkspace(t, "## Q-001 blocking\nstatus: open\n")
44
+ var stdout, stderr bytes.Buffer
45
+
46
+ if code := Resolve(root, []string{"Q-001", "canonical answer"}, &stdout, &stderr); code != 0 {
47
+ t.Fatalf("code = %d, want 0; stderr=%q", code, stderr.String())
48
+ }
49
+ }
50
+
42
51
  func resolveWorkspace(t *testing.T, questions string) string {
43
52
  t.Helper()
44
53
  root := t.TempDir()
@@ -4,17 +4,13 @@ import (
4
4
  "fmt"
5
5
  "io"
6
6
  "os"
7
- "regexp"
8
7
  "strconv"
9
8
  "strings"
10
9
 
11
10
  "github.com/devrites/devrites/internal/fsutil"
11
+ "github.com/devrites/devrites/internal/state"
12
12
  )
13
13
 
14
- // budgetField matches an "AFK slices remaining:" line and captures the value that
15
- // follows. The optional leading "- " tolerates state.md's markdown-bullet form.
16
- var budgetField = regexp.MustCompile(`^[[:space:]]*-?[[:space:]]*AFK slices remaining:[[:space:]]*`)
17
-
18
14
  // TickAfk spends one slice from a run's AFK budget: it reads the "AFK slices
19
15
  // remaining" count from the state.md at args[0], decrements it, and writes the
20
16
  // file back. /rite-build calls it after building a slice unattended.
@@ -69,31 +65,18 @@ func TickAfk(args []string, stdout, stderr io.Writer) int {
69
65
  // readBudget returns the current "AFK slices remaining" value — the first blank-
70
66
  // delimited token after the field — and whether the field is present at all.
71
67
  func readBudget(lines []string) (value string, found bool) {
72
- for _, line := range lines {
73
- loc := budgetField.FindStringIndex(line)
74
- if loc == nil {
75
- continue
76
- }
77
- value = line[loc[1]:]
78
- if i := strings.IndexAny(value, spaceChars); i >= 0 {
79
- value = value[:i]
80
- }
81
- return value, true
68
+ value, found = state.CursorField(lines, state.CursorAFKSlicesRemaining)
69
+ if !found {
70
+ return "", false
82
71
  }
83
- return "", false
72
+ if i := strings.IndexAny(value, spaceChars); i >= 0 {
73
+ value = value[:i]
74
+ }
75
+ return value, true
84
76
  }
85
77
 
86
- // setBudget rewrites every budget line to the canonical bullet form with the new
87
- // count, passing all other lines through. The result is newline-terminated.
78
+ // setBudget preserves the cursor's canonical-table or legacy-bullet format.
88
79
  func setBudget(lines []string, n int) []byte {
89
- var b strings.Builder
90
- for _, line := range lines {
91
- if budgetField.MatchString(line) {
92
- fmt.Fprintf(&b, "- AFK slices remaining: %d", n)
93
- } else {
94
- b.WriteString(line)
95
- }
96
- b.WriteByte('\n')
97
- }
98
- return []byte(b.String())
80
+ updated, _ := state.SetCursorField(lines, state.CursorAFKSlicesRemaining, strconv.Itoa(n))
81
+ return []byte(strings.Join(updated, "\n") + "\n")
99
82
  }
@@ -141,53 +141,34 @@ Normalized by DevRites migration.
141
141
  `, slug, slug, phase, state.SchemaVersion)
142
142
  }
143
143
 
144
- // derivePhase reads the legacy state.md and maps its recorded phase/status onto a
145
- // v1 phase. An unreadable or unrecognised state defaults to build — the safe
146
- // middle of the arc, from which the completeness gates re-derive what's missing.
144
+ // derivePhase reads state.md in either canonical cursor-table or legacy form and
145
+ // maps its recorded phase/status onto the current lifecycle. An unreadable or
146
+ // unrecognised state defaults to build — the safe middle of the arc, from which
147
+ // the completeness gates re-derive what's missing.
147
148
  func derivePhase(statePath string) state.Phase {
148
149
  raw, err := os.ReadFile(statePath)
149
150
  if err != nil {
150
151
  return state.PhaseBuild
151
152
  }
152
- for _, line := range strings.Split(string(raw), "\n") {
153
- t := strings.TrimLeft(strings.ToLower(strings.TrimSpace(line)), "-*+ \t")
154
- key, val, ok := strings.Cut(t, ":")
155
- if !ok || (strings.TrimSpace(key) != "phase" && strings.TrimSpace(key) != "status") {
156
- continue
157
- }
158
- if p, ok := mapLegacyPhase(strings.TrimSpace(val)); ok {
159
- return p
153
+ lines := strings.Split(string(raw), "\n")
154
+ for _, key := range []string{state.CursorPhase, state.CursorStatus} {
155
+ if value, found := state.CursorField(lines, key); found {
156
+ if p, ok := mapLegacyPhase(value); ok {
157
+ return p
158
+ }
160
159
  }
161
160
  }
162
161
  return state.PhaseBuild
163
162
  }
164
163
 
165
- // mapLegacyPhase maps a legacy phase/status word onto a v1 phase.
164
+ // mapLegacyPhase maps a current or legacy phase/status word onto the lifecycle.
166
165
  func mapLegacyPhase(word string) (state.Phase, bool) {
166
+ word = strings.ToLower(strings.TrimSpace(word))
167
167
  // Take the first token, so "done — shipped 2024" maps on "done".
168
168
  if i := strings.IndexAny(word, " \t—-"); i > 0 {
169
169
  word = word[:i]
170
170
  }
171
- switch word {
172
- case "frame":
173
- return state.PhaseFrame, true
174
- case "spec", "specced", "specifying":
175
- return state.PhaseSpec, true
176
- case "plan", "planned", "define", "defined":
177
- return state.PhasePlan, true
178
- case "build", "building", "wip", "in", "in-progress":
179
- return state.PhaseBuild, true
180
- case "prove", "proving", "proven", "testing":
181
- return state.PhaseProve, true
182
- case "vet", "review", "reviewing", "vetting":
183
- return state.PhaseVet, true
184
- case "seal", "sealed", "sealing":
185
- return state.PhaseSeal, true
186
- case "ship", "shipped", "done", "closed", "complete", "completed":
187
- return state.PhaseShip, true
188
- default:
189
- return "", false
190
- }
171
+ return state.PhaseForName(word)
191
172
  }
192
173
 
193
174
  // backupWorkspace snapshots mutable state (work/, features/, and the ACTIVE pointer) into
@@ -41,8 +41,8 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
41
41
  if err != nil {
42
42
  t.Fatal(err)
43
43
  }
44
- if f.Phase != state.PhaseShip {
45
- t.Fatalf("migrated phase=%q, want %q", f.Phase, state.PhaseShip)
44
+ if f.Phase != state.PhaseDone {
45
+ t.Fatalf("migrated phase=%q, want %q", f.Phase, state.PhaseDone)
46
46
  }
47
47
  assertFile(t, res.BackupDir, "ACTIVE", "alpha\n")
48
48
 
@@ -80,6 +80,13 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
80
80
  }
81
81
 
82
82
  func TestMapLegacyPhase(t *testing.T) {
83
+ for _, phase := range state.LifecyclePhases() {
84
+ got, ok := mapLegacyPhase(string(phase))
85
+ if !ok || got != phase {
86
+ t.Fatalf("mapLegacyPhase(%q)=(%q,%v), want canonical phase", phase, got, ok)
87
+ }
88
+ }
89
+
83
90
  for _, tc := range []struct {
84
91
  word string
85
92
  want state.Phase
@@ -87,7 +94,8 @@ func TestMapLegacyPhase(t *testing.T) {
87
94
  }{
88
95
  {word: "specced", want: state.PhaseSpec, ok: true},
89
96
  {word: "in-progress", want: state.PhaseBuild, ok: true},
90
- {word: "done - shipped", want: state.PhaseShip, ok: true},
97
+ {word: "reviewing", want: state.PhaseReview, ok: true},
98
+ {word: "done - shipped", want: state.PhaseDone, ok: true},
91
99
  {word: "mystery"},
92
100
  } {
93
101
  got, ok := mapLegacyPhase(tc.word)
@@ -0,0 +1,54 @@
1
+ // Command workflowmanifest writes the deterministic cross-language derivative
2
+ // of state.WorkflowPhases. The typed Go registry remains the authority.
3
+ package main
4
+
5
+ import (
6
+ "bytes"
7
+ "encoding/json"
8
+ "flag"
9
+ "fmt"
10
+ "os"
11
+
12
+ "github.com/devrites/devrites/internal/state"
13
+ )
14
+
15
+ func main() {
16
+ outPath := flag.String("out", "workflow_manifest.json", "output path")
17
+ check := flag.Bool("check", false, "fail when the output is stale")
18
+ flag.Parse()
19
+
20
+ document := struct {
21
+ GeneratedBy string `json:"generatedBy"`
22
+ SchemaVersion int `json:"schemaVersion"`
23
+ Phases []state.WorkflowPhase `json:"phases"`
24
+ CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
25
+ }{
26
+ GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
27
+ SchemaVersion: state.SchemaVersion,
28
+ Phases: state.WorkflowPhases(),
29
+ CursorKeyAliases: state.CursorKeyAliases(),
30
+ }
31
+ data, err := json.MarshalIndent(document, "", " ")
32
+ if err != nil {
33
+ fatal(err)
34
+ }
35
+ data = append(data, '\n')
36
+ if *check {
37
+ current, err := os.ReadFile(*outPath)
38
+ if err != nil {
39
+ fatal(err)
40
+ }
41
+ if !bytes.Equal(current, data) {
42
+ fatal(fmt.Errorf("%s is stale; run go generate ./internal/state", *outPath))
43
+ }
44
+ return
45
+ }
46
+ if err := os.WriteFile(*outPath, data, 0o644); err != nil {
47
+ fatal(err)
48
+ }
49
+ }
50
+
51
+ func fatal(err error) {
52
+ fmt.Fprintln(os.Stderr, err)
53
+ os.Exit(1)
54
+ }