devrites 3.0.0 → 3.0.2

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 (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +1 -1
  4. package/engine/hooks_events_test.go +15 -0
  5. package/engine/hooks_workspace.go +8 -31
  6. package/engine/internal/gate/gate.go +4 -9
  7. package/engine/internal/gate/gate_test.go +16 -0
  8. package/engine/internal/lib/analyze.go +9 -10
  9. package/engine/internal/lib/buildreadiness.go +10 -16
  10. package/engine/internal/lib/checkacceptance.go +9 -9
  11. package/engine/internal/lib/coverage.go +13 -15
  12. package/engine/internal/lib/cursor_compat_test.go +91 -0
  13. package/engine/internal/lib/lanes.go +1 -1
  14. package/engine/internal/lib/progress.go +25 -24
  15. package/engine/internal/lib/resolve.go +9 -14
  16. package/engine/internal/lib/tickafk.go +11 -28
  17. package/engine/internal/migrate/migrate.go +28 -16
  18. package/engine/internal/migrate/migrate_test.go +5 -3
  19. package/engine/internal/state/cursor.go +84 -0
  20. package/engine/internal/state/cursor_test.go +58 -0
  21. package/engine/internal/state/feature.go +34 -31
  22. package/engine/internal/state/schema.go +30 -18
  23. package/engine/internal/state/snapshot.go +13 -2
  24. package/engine/internal/state/state_test.go +48 -3
  25. package/engine/internal/workflow/commands.go +25 -8
  26. package/engine/internal/workflow/commands_test.go +35 -0
  27. package/engine/testdata/golden/TestParityAnalyze/slug=noac.golden +1 -1
  28. package/engine/testdata/golden/TestParityCheckAcceptance/arg=flat-ok.golden +1 -1
  29. package/engine/tests/adr_0004_required_by_phase_test.go +7 -1
  30. package/engine/tests/canonical_ac_ids_test.go +44 -0
  31. package/engine/tests/gate_test.go +22 -0
  32. package/engine/tests/migrate_cli_test.go +3 -3
  33. package/pack/.claude/skills/rite-define/SKILL.md +6 -0
  34. package/pack/.claude/skills/rite-spec/SKILL.md +2 -0
  35. package/pack/.claude/skills/rite-vet/SKILL.md +6 -1
  36. package/pack/generated/claude/skills/rite-define/SKILL.md +6 -0
  37. package/pack/generated/claude/skills/rite-spec/SKILL.md +2 -0
  38. package/pack/generated/claude/skills/rite-vet/SKILL.md +6 -1
  39. package/pack/generated/codex/skills/rite-define/SKILL.md +6 -0
  40. package/pack/generated/codex/skills/rite-spec/SKILL.md +2 -0
  41. package/pack/generated/codex/skills/rite-vet/SKILL.md +6 -1
  42. package/package.json +1 -1
@@ -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
 
@@ -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,9 @@ 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
-
305
302
  // First check whether the awaiting block references this question at all.
306
- inAw, matched := false, false
303
+ inAw := false
304
+ var awaitingLines []string
307
305
  for _, line := range lines {
308
306
  switch {
309
307
  case awaitingRe.MatchString(line):
@@ -312,11 +310,12 @@ func clearAwaiting(sfile, qid string) error {
312
310
  case inAw && hdrSpaceRe.MatchString(line):
313
311
  inAw = false
314
312
  }
315
- if inAw && qidRef.MatchString(line) {
316
- matched = true
313
+ if inAw {
314
+ awaitingLines = append(awaitingLines, line)
317
315
  }
318
316
  }
319
- if !matched {
317
+ waitingOn, _ := state.CursorField(awaitingLines, "question_id")
318
+ if waitingOn != qid {
320
319
  return nil
321
320
  }
322
321
 
@@ -336,12 +335,6 @@ func clearAwaiting(sfile, qid string) error {
336
335
  continue
337
336
  }
338
337
  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
338
  case logHdrRe.MatchString(line):
346
339
  out = append(out, line)
347
340
  inLog = true
@@ -360,6 +353,8 @@ func clearAwaiting(sfile, qid string) error {
360
353
  if inLog && !logAppended {
361
354
  out = append(out, fmt.Sprintf("- %s build: resolved %s", ts, qid))
362
355
  }
356
+ out, _ = state.SetCursorField(out, "status", "running")
357
+ out, _ = state.SetCursorField(out, "next_action", "(resume — `"+workflow.ForVerb("build").Both()+"` to continue the workflow)")
363
358
  if err := fsutil.WriteFileAtomic(sfile, joinRecords(out), 0o644); err != nil {
364
359
  return fmt.Errorf("update state %s: %w", sfile, err)
365
360
  }
@@ -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, "afk_slices_remaining")
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, "afk_slices_remaining", strconv.Itoa(n))
81
+ return []byte(strings.Join(updated, "\n") + "\n")
99
82
  }
@@ -141,29 +141,29 @@ 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{"phase", "status"} {
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]
@@ -173,18 +173,30 @@ func mapLegacyPhase(word string) (state.Phase, bool) {
173
173
  return state.PhaseFrame, true
174
174
  case "spec", "specced", "specifying":
175
175
  return state.PhaseSpec, true
176
- case "plan", "planned", "define", "defined":
176
+ case "temper", "tempered", "tempering":
177
+ return state.PhaseTemper, true
178
+ case "define", "defined", "defining":
179
+ return state.PhaseDefine, true
180
+ case "plan", "planned", "planning":
177
181
  return state.PhasePlan, true
182
+ case "vet", "vetted", "vetting":
183
+ return state.PhaseVet, true
178
184
  case "build", "building", "wip", "in", "in-progress":
179
185
  return state.PhaseBuild, true
186
+ case "converge", "converged", "converging":
187
+ return state.PhaseConverge, true
180
188
  case "prove", "proving", "proven", "testing":
181
189
  return state.PhaseProve, true
182
- case "vet", "review", "reviewing", "vetting":
183
- return state.PhaseVet, true
190
+ case "polish", "polished", "polishing":
191
+ return state.PhasePolish, true
192
+ case "review", "reviewed", "reviewing":
193
+ return state.PhaseReview, true
184
194
  case "seal", "sealed", "sealing":
185
195
  return state.PhaseSeal, true
186
- case "ship", "shipped", "done", "closed", "complete", "completed":
196
+ case "ship", "shipped", "shipping":
187
197
  return state.PhaseShip, true
198
+ case "done", "closed", "complete", "completed":
199
+ return state.PhaseDone, true
188
200
  default:
189
201
  return "", false
190
202
  }
@@ -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
 
@@ -87,7 +87,9 @@ func TestMapLegacyPhase(t *testing.T) {
87
87
  }{
88
88
  {word: "specced", want: state.PhaseSpec, ok: true},
89
89
  {word: "in-progress", want: state.PhaseBuild, ok: true},
90
- {word: "done - shipped", want: state.PhaseShip, ok: true},
90
+ {word: "temper", want: state.PhaseTemper, ok: true},
91
+ {word: "reviewing", want: state.PhaseReview, ok: true},
92
+ {word: "done - shipped", want: state.PhaseDone, ok: true},
91
93
  {word: "mystery"},
92
94
  } {
93
95
  got, ok := mapLegacyPhase(tc.word)
@@ -0,0 +1,84 @@
1
+ package state
2
+
3
+ import (
4
+ "strings"
5
+ "unicode"
6
+ )
7
+
8
+ type cursorLineKind uint8
9
+
10
+ const (
11
+ cursorLineUnknown cursorLineKind = iota
12
+ cursorLineLegacy
13
+ cursorLineTable
14
+ )
15
+
16
+ // CursorField reads a state.md field from either the canonical | key | value |
17
+ // cursor table or the legacy "- Key: value" form.
18
+ func CursorField(lines []string, key string) (string, bool) {
19
+ want := normalizeCursorKey(key)
20
+ for _, line := range lines {
21
+ gotKey, value, _, ok := parseCursorLine(line)
22
+ if ok && normalizeCursorKey(gotKey) == want {
23
+ return value, true
24
+ }
25
+ }
26
+ return "", false
27
+ }
28
+
29
+ // SetCursorField replaces an existing state.md field without changing whether
30
+ // the file uses the canonical table or legacy bullet form.
31
+ func SetCursorField(lines []string, key, value string) ([]string, bool) {
32
+ want := normalizeCursorKey(key)
33
+ out := append([]string(nil), lines...)
34
+ for i, line := range out {
35
+ gotKey, _, kind, ok := parseCursorLine(line)
36
+ if !ok || normalizeCursorKey(gotKey) != want {
37
+ continue
38
+ }
39
+ switch kind {
40
+ case cursorLineTable:
41
+ indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
42
+ out[i] = indent + "| " + strings.TrimSpace(gotKey) + " | " + value + " |"
43
+ case cursorLineLegacy:
44
+ colon := strings.IndexByte(line, ':')
45
+ out[i] = line[:colon+1] + " " + value
46
+ }
47
+ return out, true
48
+ }
49
+ return out, false
50
+ }
51
+
52
+ func parseCursorLine(line string) (key, value string, kind cursorLineKind, ok bool) {
53
+ trimmed := strings.TrimSpace(line)
54
+ if len(trimmed) >= 2 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|' {
55
+ cells := strings.Split(trimmed[1:len(trimmed)-1], "|")
56
+ if len(cells) >= 2 {
57
+ return strings.TrimSpace(cells[0]), strings.TrimSpace(cells[1]), cursorLineTable, true
58
+ }
59
+ }
60
+
61
+ trimmed = strings.TrimLeft(trimmed, "-*+ \t")
62
+ key, value, ok = strings.Cut(trimmed, ":")
63
+ if !ok || strings.TrimSpace(key) == "" {
64
+ return "", "", cursorLineUnknown, false
65
+ }
66
+ return strings.TrimSpace(key), strings.TrimSpace(value), cursorLineLegacy, true
67
+ }
68
+
69
+ func normalizeCursorKey(key string) string {
70
+ var b strings.Builder
71
+ for _, r := range strings.ToLower(key) {
72
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
73
+ b.WriteRune(r)
74
+ }
75
+ }
76
+ switch b.String() {
77
+ case "nextstep":
78
+ return "nextaction"
79
+ case "qid":
80
+ return "questionid"
81
+ default:
82
+ return b.String()
83
+ }
84
+ }
@@ -0,0 +1,58 @@
1
+ package state
2
+
3
+ import "testing"
4
+
5
+ func TestCursorFieldReadsCanonicalTableAndLegacyLines(t *testing.T) {
6
+ table := []string{
7
+ "| Key | Value |",
8
+ "| --- | --- |",
9
+ "| phase | temper |",
10
+ "| status | awaiting_human |",
11
+ "| next_action | /rite-define |",
12
+ }
13
+ legacy := []string{
14
+ "- Phase: build",
15
+ "- Status: running",
16
+ "- Next step: /rite-prove",
17
+ }
18
+
19
+ for _, tc := range []struct {
20
+ name string
21
+ lines []string
22
+ key string
23
+ want string
24
+ }{
25
+ {"table phase", table, "phase", "temper"},
26
+ {"table status", table, "status", "awaiting_human"},
27
+ {"table next alias", table, "Next step", "/rite-define"},
28
+ {"legacy phase", legacy, "phase", "build"},
29
+ {"legacy next alias", legacy, "next_action", "/rite-prove"},
30
+ } {
31
+ t.Run(tc.name, func(t *testing.T) {
32
+ got, ok := CursorField(tc.lines, tc.key)
33
+ if !ok || got != tc.want {
34
+ t.Fatalf("CursorField(%q) = %q, %v; want %q, true", tc.key, got, ok, tc.want)
35
+ }
36
+ })
37
+ }
38
+ }
39
+
40
+ func TestSetCursorFieldPreservesCanonicalAndLegacyFormats(t *testing.T) {
41
+ for _, tc := range []struct {
42
+ name string
43
+ lines []string
44
+ key string
45
+ value string
46
+ want string
47
+ }{
48
+ {"table", []string{"| status | awaiting_human |"}, "status", "running", "| status | running |"},
49
+ {"legacy", []string{"- AFK slices remaining: 2"}, "afk_slices_remaining", "1", "- AFK slices remaining: 1"},
50
+ } {
51
+ t.Run(tc.name, func(t *testing.T) {
52
+ got, ok := SetCursorField(tc.lines, tc.key, tc.value)
53
+ if !ok || len(got) != 1 || got[0] != tc.want {
54
+ t.Fatalf("SetCursorField() = %v, %v; want [%q], true", got, ok, tc.want)
55
+ }
56
+ })
57
+ }
58
+ }
@@ -124,11 +124,11 @@ func CandidateFiles(root, slug string) []string {
124
124
  return files
125
125
  }
126
126
 
127
- // LoadFeature reads feature <slug> under root. The phase comes from the manifest
128
- // (feature.md) frontmatter when present, otherwise from the working-state ledger
129
- // (state.md, a "- Phase: <p>" line) the live pack writes. A feature with neither a
130
- // manifest nor a ledger does not exist. Section content is read from the canonical
131
- // section files or their transitional aliases (see sectionFiles).
127
+ // LoadFeature reads feature <slug> under root. The phase comes from the live
128
+ // working-state ledger when it declares one, otherwise from feature.md
129
+ // frontmatter. A feature with neither a manifest nor a ledger does not exist.
130
+ // Section content is read from the canonical section files or their transitional
131
+ // aliases (see sectionFiles).
132
132
  func LoadFeature(root, slug string) (*Feature, error) {
133
133
  dir := featureDir(root, slug)
134
134
 
@@ -142,7 +142,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
142
142
  return nil, fmt.Errorf("feature %q not found", slug)
143
143
  }
144
144
 
145
- var phase Phase
145
+ var manifestPhase Phase
146
146
  if hasManifest {
147
147
  fm, _ := splitFrontmatter(manifest)
148
148
  if v := strings.TrimSpace(fm["schemaVersion"]); v != "" {
@@ -154,12 +154,16 @@ func LoadFeature(root, slug string) (*Feature, error) {
154
154
  return nil, fmt.Errorf("feature %q: schemaVersion %d is newer than this engine supports (%d); upgrade devrites", slug, n, SchemaVersion)
155
155
  }
156
156
  }
157
- phase = Phase(strings.TrimSpace(fm["phase"]))
157
+ manifestPhase = Phase(strings.TrimSpace(fm["phase"]))
158
158
  }
159
- // Ledger fallback: only when the manifest did not declare a phase, so a manifest
160
- // with an explicit-but-unknown phase still surfaces as that error below.
161
- if phase == "" {
162
- phase = PhaseFromLedger(filepath.Join(dir, LedgerFile))
159
+
160
+ // state.md is the mutable runtime cursor, so it must win over the manifest,
161
+ // which migration and compatibility flows may leave stale. Preserve an
162
+ // explicitly unknown ledger value so the validation below reports it rather
163
+ // than silently falling back to feature.md.
164
+ phase, ledgerDeclared := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
165
+ if !ledgerDeclared {
166
+ phase = manifestPhase
163
167
  }
164
168
  if phase == "" {
165
169
  return nil, fmt.Errorf("feature %q: no phase in feature.md frontmatter or %s ledger", slug, LedgerFile)
@@ -186,31 +190,30 @@ func sectionPresentAny(dir string, s Section) bool {
186
190
  return false
187
191
  }
188
192
 
189
- // PhaseFromLedger reads a phase from the working-state ledger: the value of a
190
- // "- Phase: <p>" line (leading list markers tolerated), taking the first token
191
- // and accepting it only if it is a phase the engine understands. Returns "" when
192
- // the file is absent or declares no recognized phase. Unlike migrate's tolerant
193
- // legacy mapping, this accepts only canonical phase words — the live pack's form.
193
+ // PhaseFromLedger reads a phase from the canonical cursor table or the legacy
194
+ // "- Phase: <p>" form, accepting only phases the engine understands.
194
195
  func PhaseFromLedger(path string) Phase {
196
+ phase, declared := declaredPhaseFromLedger(path)
197
+ if declared && KnownPhase(phase) {
198
+ return phase
199
+ }
200
+ return ""
201
+ }
202
+
203
+ func declaredPhaseFromLedger(path string) (Phase, bool) {
195
204
  raw, err := os.ReadFile(path)
196
205
  if err != nil {
197
- return ""
206
+ return "", false
198
207
  }
199
- for _, line := range strings.Split(string(raw), "\n") {
200
- t := strings.TrimLeft(strings.TrimSpace(line), "-*+ \t")
201
- key, val, ok := strings.Cut(t, ":")
202
- if !ok || !strings.EqualFold(strings.TrimSpace(key), "phase") {
203
- continue
204
- }
205
- word := strings.ToLower(strings.TrimSpace(val))
206
- if i := strings.IndexAny(word, " \t"); i > 0 {
207
- word = word[:i]
208
- }
209
- if p := Phase(word); KnownPhase(p) {
210
- return p
211
- }
208
+ value, ok := CursorField(strings.Split(string(raw), "\n"), "phase")
209
+ if !ok {
210
+ return "", false
212
211
  }
213
- return ""
212
+ word := strings.ToLower(strings.TrimSpace(value))
213
+ if i := strings.IndexAny(word, " \t"); i > 0 {
214
+ word = word[:i]
215
+ }
216
+ return Phase(word), true
214
217
  }
215
218
 
216
219
  // ReadDeclaredSchemaVersion returns the schemaVersion declared in a feature's
@@ -46,36 +46,48 @@ var sectionFiles = map[Section][]string{
46
46
  }
47
47
 
48
48
  // LedgerFile is the working-state ledger the live pack writes. It carries the
49
- // phase (as a "- Phase: <p>" line) when no feature.md manifest declares one, and
50
- // it satisfies the status section.
49
+ // phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
50
+ // when no feature.md manifest declares one, and it satisfies the status section.
51
51
  const LedgerFile = "state.md"
52
52
 
53
53
  // Phase is a workflow state. The order mirrors the rite-* arc.
54
54
  type Phase string
55
55
 
56
56
  const (
57
- PhaseFrame Phase = "frame" // problem framing
58
- PhaseSpec Phase = "spec" // specification
59
- PhasePlan Phase = "plan" // planning
60
- PhaseBuild Phase = "build" // implementation
61
- PhaseProve Phase = "prove" // proof / testing
62
- PhaseVet Phase = "vet" // review
63
- PhaseSeal Phase = "seal" // completeness seal
64
- PhaseShip Phase = "ship" // shipping
57
+ PhaseFrame Phase = "frame" // problem framing
58
+ PhaseSpec Phase = "spec" // specification
59
+ PhaseTemper Phase = "temper" // strategic specification review
60
+ PhaseDefine Phase = "define" // plan definition
61
+ PhasePlan Phase = "plan" // approved plan
62
+ PhaseVet Phase = "vet" // pre-build engineering review
63
+ PhaseBuild Phase = "build" // implementation
64
+ PhaseConverge Phase = "converge" // post-build gap closure
65
+ PhaseProve Phase = "prove" // proof / testing
66
+ PhasePolish Phase = "polish" // quality pass
67
+ PhaseReview Phase = "review" // post-proof review
68
+ PhaseSeal Phase = "seal" // completeness seal
69
+ PhaseShip Phase = "ship" // shipping
70
+ PhaseDone Phase = "done" // archived completion
65
71
  )
66
72
 
67
73
  // requiredByPhase lists the sections that must have real content to complete a
68
74
  // phase. Completeness is phase-relative: a section not yet required (e.g. proof
69
75
  // during the spec phase) never blocks. The set is additive down the arc.
70
76
  var requiredByPhase = map[Phase][]Section{
71
- PhaseFrame: {},
72
- PhaseSpec: {SectionSpec},
73
- PhasePlan: {SectionSpec, SectionPlan},
74
- PhaseBuild: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks},
75
- PhaseProve: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
76
- PhaseVet: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
77
- PhaseSeal: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
78
- PhaseShip: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
77
+ PhaseFrame: {},
78
+ PhaseSpec: {SectionSpec},
79
+ PhaseTemper: {SectionSpec},
80
+ PhaseDefine: {SectionSpec, SectionPlan},
81
+ PhasePlan: {SectionSpec, SectionPlan},
82
+ PhaseVet: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks},
83
+ PhaseBuild: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks},
84
+ PhaseConverge: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks},
85
+ PhaseProve: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
86
+ PhasePolish: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
87
+ PhaseReview: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
88
+ PhaseSeal: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
89
+ PhaseShip: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
90
+ PhaseDone: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
79
91
  }
80
92
 
81
93
  // KnownPhase reports whether p is a phase the engine understands.
@@ -121,7 +121,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
121
121
  }
122
122
 
123
123
  workDir := featureDir(root, report.Slug)
124
- next := workflow.ForPhase(string(report.Phase))
124
+ next := nextCommand(workDir, report.Phase)
125
125
  snap := &WorkspaceSnapshot{
126
126
  SchemaVersion: WorkspaceSnapshotSchema,
127
127
  Slug: report.Slug,
@@ -152,7 +152,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
152
152
  if snap.RunMode == "AFK" && len(snap.MissingSections) > 0 {
153
153
  snap.Warnings = append(snap.Warnings, "AFK workspace has missing required sections")
154
154
  }
155
- if snap.Evidence.Status == "missing" && (report.Phase == PhaseProve || report.Phase == PhaseSeal || report.Phase == PhaseShip) {
155
+ if report.Required[SectionProof] && snap.Evidence.Status != "fresh" {
156
156
  snap.Warnings = append(snap.Warnings, "proof phase requires fresh evidence")
157
157
  }
158
158
  if snap.Drift.Open > 0 {
@@ -161,6 +161,17 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
161
161
  return snap, nil
162
162
  }
163
163
 
164
+ func nextCommand(workDir string, phase Phase) workflow.Command {
165
+ if raw, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
166
+ if action, ok := CursorField(strings.Split(string(raw), "\n"), "next_action"); ok {
167
+ if command := workflow.ForAction(action); command.Verb != "" {
168
+ return command
169
+ }
170
+ }
171
+ }
172
+ return workflow.ForPhase(string(phase))
173
+ }
174
+
164
175
  func readActiveSlug(root string) string {
165
176
  if ws := devritespaths.WorkspaceOverride(root, ""); ws != "" {
166
177
  return filepath.Base(ws)
@@ -126,6 +126,52 @@ func TestLoadFeatureFromLedgerAndAliases(t *testing.T) {
126
126
  }
127
127
  }
128
128
 
129
+ func TestLedgerPhaseOverridesStaleManifestPhase(t *testing.T) {
130
+ root := filepath.Join(t.TempDir(), ".devrites")
131
+ writeWorkSection(t, root, "live", "feature.md", "---\nphase: spec\nschemaVersion: 1\n---\n")
132
+ writeWorkSection(t, root, "live", "state.md", "| Key | Value |\n| --- | --- |\n| phase | temper |\n")
133
+ writeWorkSection(t, root, "live", "spec.md", "# Spec\n\nReady.\n")
134
+
135
+ rep, err := Status(root, "live")
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ if rep.Phase != PhaseTemper {
140
+ t.Fatalf("phase=%q, want current ledger phase %q", rep.Phase, PhaseTemper)
141
+ }
142
+ }
143
+
144
+ func TestSnapshotUsesCanonicalNextActionAndWarnsWhenRequiredProofMissing(t *testing.T) {
145
+ root := filepath.Join(t.TempDir(), ".devrites")
146
+ writeWorkSection(t, root, "live", "state.md", `# State
147
+
148
+ | Key | Value |
149
+ | --- | --- |
150
+ | phase | review |
151
+ | status | running |
152
+ | next_action | /rite-seal after review is clean |
153
+ `)
154
+ for name, body := range map[string]string{
155
+ "spec.md": "# Spec\n\nReady.\n",
156
+ "plan.md": "# Plan\n\nReady.\n",
157
+ "decisions.md": "# Decisions\n\nReady.\n",
158
+ "tasks.md": "# Tasks\n\nReady.\n",
159
+ } {
160
+ writeWorkSection(t, root, "live", name, body)
161
+ }
162
+
163
+ snap, err := Snapshot(root, "live")
164
+ if err != nil {
165
+ t.Fatal(err)
166
+ }
167
+ if snap.NextCommands.Verb != "seal" || snap.NextCommand != "/rite-seal" {
168
+ t.Fatalf("next commands=%+v legacy=%q, want canonical next_action seal", snap.NextCommands, snap.NextCommand)
169
+ }
170
+ if got := strings.Join(snap.Warnings, "\n"); !strings.Contains(got, "requires fresh evidence") {
171
+ t.Fatalf("warnings=%v, want missing required-proof warning", snap.Warnings)
172
+ }
173
+ }
174
+
129
175
  func TestWorkLayoutIsCanonicalAndFeaturesIsAlias(t *testing.T) {
130
176
  root := filepath.Join(t.TempDir(), ".devrites")
131
177
  writeWorkSection(t, root, "live", "state.md", "- Phase: build\n")
@@ -148,9 +194,8 @@ func TestWorkLayoutIsCanonicalAndFeaturesIsAlias(t *testing.T) {
148
194
  }
149
195
  }
150
196
 
151
- // An unknown phase word in the ledger is ignored (not accepted as a phase), so a
152
- // ledger-only feature with no recognizable phase is a clear error, not a silent
153
- // mis-load.
197
+ // An unknown phase word in the ledger is rejected, so a ledger-only feature is a
198
+ // clear error rather than silently falling back or mis-loading.
154
199
  func TestLedgerPhaseRejectsUnknownWord(t *testing.T) {
155
200
  root := filepath.Join(t.TempDir(), ".devrites")
156
201
  writeSection(t, root, "bogus", "state.md", "- Phase: banana\n")