devrites 3.0.1 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.0.2](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.1...v3.0.2) (2026-07-20)
6
+
7
+ ### Fixed
8
+
9
+ * **rite:** read canonical workspace cursor state ([e2fd60c](https://github.com/ViktorsBaikers/DevRites/commit/e2fd60c13aa53637e1af02a792f36f3efe32e1c9))
10
+
5
11
  ## [3.0.1](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.0...v3.0.1) (2026-07-20)
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -115,7 +115,7 @@ Full diagram set (lifecycle, polish orchestrator, review fan-out, debug loop,
115
115
  rules carrier, workspace state, namespace map) →
116
116
  [`docs/flow.md`](docs/flow.md).
117
117
 
118
- **Status:** [`v3.0.1`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.1) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
118
+ **Status:** [`v3.0.2`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.2) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
119
119
 
120
120
  ## Contents
121
121
 
@@ -26,6 +26,21 @@ func makeHookWorkspace(t *testing.T) string {
26
26
  return root
27
27
  }
28
28
 
29
+ func TestHookStatuslineReadsCanonicalCursor(t *testing.T) {
30
+ root := makeHookWorkspace(t)
31
+ state := "| Key | Value |\n| --- | --- |\n| phase | temper |\n| status | running |\n"
32
+ if err := os.WriteFile(filepath.Join(root, "work", "demo", "state.md"), []byte(state), 0o644); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ var stdout bytes.Buffer
36
+ if code := hookStatusline(strings.NewReader(""), &stdout, &bytes.Buffer{}); code != 0 {
37
+ t.Fatalf("hookStatusline code=%d", code)
38
+ }
39
+ if !strings.Contains(stdout.String(), "demo · temper ·") {
40
+ t.Fatalf("statusline ignored table phase: %s", stdout.String())
41
+ }
42
+ }
43
+
29
44
  func TestHookEventWritesTimelineAndWorkspaceEvents(t *testing.T) {
30
45
  root := makeHookWorkspace(t)
31
46
  if code := hookEvent([]string{"subagent-stop"}, strings.NewReader(`{}`), &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
@@ -22,20 +22,8 @@ import (
22
22
  "github.com/devrites/devrites/internal/state"
23
23
  )
24
24
 
25
- // State-field patterns. Each hook reads a field the way the scripts do:
26
- // `grep -iE <find>` (case-INSENSITIVE) selects the first matching line, then
27
- // `sed -E s/<strip>//` (case-SENSITIVE, as sed is) removes the key prefix — a
28
- // no-op when the case differs, so the mismatched-case behavior is preserved.
29
25
  var (
30
- findNextStep = regexp.MustCompile(`(?i)^[-[:space:]]*Next step:`)
31
- stripNextStep = regexp.MustCompile(`^[-[:space:]]*Next step:[[:space:]]*`)
32
- findStatus = regexp.MustCompile(`(?i)^[-[:space:]]*Status:`)
33
- stripStatus = regexp.MustCompile(`^[-[:space:]]*Status:[[:space:]]*`)
34
- findPhase = regexp.MustCompile(`(?i)^[-[:space:]]*Phase:`)
35
- stripPhase = regexp.MustCompile(`^[-[:space:]]*Phase:[[:space:]]*`)
36
- findAFKRem = regexp.MustCompile(`(?i)AFK slices remaining:`)
37
- stripAFKRem = regexp.MustCompile(`.*remaining:[[:space:]]*`)
38
- openStatusRe = regexp.MustCompile(`(?i)^[[:space:]]*status:[[:space:]]*open`)
26
+ openStatusRe = regexp.MustCompile(`(?i)^[[:space:]]*status:[[:space:]]*open`)
39
27
  )
40
28
 
41
29
  // resolveWorkspaceDir resolves the active feature's directory. New writes default
@@ -199,9 +187,9 @@ func hookHandoffSnapshot(stdin io.Reader, stdout, stderr io.Writer) int {
199
187
  return exitOK
200
188
  }
201
189
  stateLines := wsReadLines(filepath.Join(dir, "state.md"))
202
- phase := wsField(stateLines, findPhase, stripPhase)
203
- status := wsField(stateLines, findStatus, stripStatus)
204
- next := wsField(stateLines, findNextStep, stripNextStep)
190
+ phase, _ := state.CursorField(stateLines, "phase")
191
+ status, _ := state.CursorField(stateLines, "status")
192
+ next, _ := state.CursorField(stateLines, "next_action")
205
193
  stamp := time.Now().UTC().Format(time.RFC3339)
206
194
  var b strings.Builder
207
195
  fmt.Fprintf(&b, "\n## Handoff snapshot — %s\n", stamp)
@@ -233,12 +221,12 @@ func hookCursor(stdin io.Reader, stdout, stderr io.Writer) int {
233
221
  }
234
222
  stateLines := wsReadLines(filepath.Join(dir, "state.md"))
235
223
 
236
- next := wsField(stateLines, findNextStep, stripNextStep)
237
- status := wsField(stateLines, findStatus, stripStatus)
224
+ next, _ := state.CursorField(stateLines, "next_action")
225
+ status, _ := state.CursorField(stateLines, "status")
238
226
  gates := wsGateCount(filepath.Join(dir, "questions.md"))
239
227
  afk := ""
240
228
  if wsIsFile(filepath.Join(root, "AFK")) {
241
- afk = wsField(stateLines, findAFKRem, stripAFKRem)
229
+ afk, _ = state.CursorField(stateLines, "afk_slices_remaining")
242
230
  }
243
231
 
244
232
  fmt.Fprintf(stdout, "DevRites cursor — active feature: %s\n", slug)
@@ -267,7 +255,7 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
267
255
  if !ok {
268
256
  return exitOK
269
257
  }
270
- phase := wsField(wsReadLines(filepath.Join(dir, "state.md")), findPhase, stripPhase)
258
+ phase, _ := state.CursorField(wsReadLines(filepath.Join(dir, "state.md")), "phase")
271
259
  if phase == "" {
272
260
  phase = "?"
273
261
  }
@@ -536,17 +524,6 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
536
524
  return exitOK
537
525
  }
538
526
 
539
- // wsField mirrors `grep -iE <find> | head -1 | sed -E s/<strip>//`: the first line
540
- // matching find (case-insensitive) with strip removed (case-sensitive).
541
- func wsField(lines []string, find, strip *regexp.Regexp) string {
542
- for _, line := range lines {
543
- if find.MatchString(line) {
544
- return strip.ReplaceAllString(line, "")
545
- }
546
- }
547
- return ""
548
- }
549
-
550
527
  // wsGateCount mirrors `grep -ciE '^\s*status:\s*open' questions.md || echo 0`,
551
528
  // INCLUDING grep's quirk: on a present file with zero matches grep prints "0" and
552
529
  // exits 1, so `|| echo 0` appends a second "0" (the captured value is "0\n0").
@@ -90,7 +90,7 @@ func (r *Result) Render() string {
90
90
  }
91
91
 
92
92
  // StopGate evaluates the stop-hook rest-point invariant for the active feature:
93
- // a feature that CLAIMS completion (it has advanced to the seal or ship phase)
93
+ // a feature that CLAIMS completion (it has advanced to seal, ship, or done)
94
94
  // must not have an empty proof section. This is a rest-point check, NOT
95
95
  // whole-feature completeness — normal in-progress incompleteness never trips it,
96
96
  // so a mid-build turn is never blocked.
@@ -129,7 +129,7 @@ func StopGate(root string) (StopResult, error) {
129
129
  slug, strings.Join(gates, "/")),
130
130
  }, nil
131
131
  }
132
- claimsDone := f.Phase == state.PhaseSeal || f.Phase == state.PhaseShip
132
+ claimsDone := f.Phase == state.PhaseSeal || f.Phase == state.PhaseShip || f.Phase == state.PhaseDone
133
133
  if claimsDone && !f.Present[state.SectionProof] {
134
134
  return StopResult{
135
135
  Slug: slug,
@@ -191,13 +191,8 @@ func openBlockingQuestionGates(data []byte) []string {
191
191
  }
192
192
 
193
193
  func stateAwaitingHuman(data []byte) bool {
194
- for _, line := range splitLinesNoTrailing(data) {
195
- if strings.HasPrefix(line, "- Status:") {
196
- status := strings.TrimLeft(strings.TrimPrefix(line, "- Status:"), gateSpaceChars)
197
- return status == "awaiting_human"
198
- }
199
- }
200
- return false
194
+ status, _ := state.CursorField(splitLinesNoTrailing(data), "status")
195
+ return status == "awaiting_human"
201
196
  }
202
197
 
203
198
  func isHeadingLine(line string) bool {
@@ -38,6 +38,13 @@ func TestCheckAndRenderReadiness(t *testing.T) {
38
38
  }
39
39
  }
40
40
 
41
+ func TestStateAwaitingHumanReadsCanonicalCursor(t *testing.T) {
42
+ data := []byte("| Key | Value |\n| --- | --- |\n| status | awaiting_human |\n")
43
+ if !stateAwaitingHuman(data) {
44
+ t.Fatal("stateAwaitingHuman ignored canonical cursor table")
45
+ }
46
+ }
47
+
41
48
  func TestStopGateRestPointInvariants(t *testing.T) {
42
49
  for _, tc := range []struct {
43
50
  name string
@@ -82,6 +89,15 @@ func TestStopGateRestPointInvariants(t *testing.T) {
82
89
  wantBlocked: true,
83
90
  wantReasonSub: "proof.md is empty",
84
91
  },
92
+ {
93
+ name: "done without proof blocks",
94
+ files: map[string]string{
95
+ "feature.md": "---\nphase: ship\nschemaVersion: 1\n---\n",
96
+ "state.md": "| Key | Value |\n| --- | --- |\n| phase | done |\n",
97
+ },
98
+ wantBlocked: true,
99
+ wantReasonSub: "proof.md is empty",
100
+ },
85
101
  } {
86
102
  t.Run(tc.name, func(t *testing.T) {
87
103
  root := t.TempDir()
@@ -7,6 +7,7 @@ import (
7
7
  "regexp"
8
8
  "strings"
9
9
 
10
+ "github.com/devrites/devrites/internal/state"
10
11
  "github.com/devrites/devrites/internal/workflow"
11
12
  )
12
13
 
@@ -67,22 +68,15 @@ func BuildReadiness(root string, args []string, stdout, stderr io.Writer) int {
67
68
  // state.md field value.
68
69
  var fieldAnnotation = regexp.MustCompile(`[[:space:]]*(#|\|).*$`)
69
70
 
70
- // readinessField reads a `Key: value` field from state.md, tolerating a leading
71
- // "- " bullet and trimming any trailing annotation and blanks. It returns the
72
- // first match, or "" when the key is absent. A key may contain spaces (e.g.
73
- // "Plan approved") — it is matched literally.
71
+ // readinessField reads canonical cursor tables and legacy bullet fields, then
72
+ // trims the legacy trailing annotations accepted by the original command.
74
73
  func readinessField(lines []string, key string) string {
75
- field := regexp.MustCompile(`^[[:space:]]*-?[[:space:]]*` + key + `:[[:space:]]*`)
76
- for _, line := range lines {
77
- loc := field.FindStringIndex(line)
78
- if loc == nil {
79
- continue
80
- }
81
- value := line[loc[1]:]
82
- if m := fieldAnnotation.FindStringIndex(value); m != nil {
83
- value = value[:m[0]]
84
- }
85
- return strings.TrimRight(value, spaceChars)
74
+ value, ok := state.CursorField(lines, key)
75
+ if !ok {
76
+ return ""
77
+ }
78
+ if m := fieldAnnotation.FindStringIndex(value); m != nil {
79
+ value = value[:m[0]]
86
80
  }
87
- return ""
81
+ return strings.TrimRight(value, spaceChars)
88
82
  }
@@ -0,0 +1,91 @@
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
+ }
@@ -7,13 +7,14 @@ 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:]]*$`)
@@ -77,17 +78,25 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
77
78
  }
78
79
  }
79
80
 
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")) {
81
+ // Flow ribbon: the lifecycle spine; optional phases appear once their artifact
82
+ // exists or while they are the active phase, so the cursor is never omitted.
83
+ var order []string
84
+ if phase == "frame" {
85
+ order = append(order, "frame")
86
+ }
87
+ order = append(order, "spec")
88
+ if phase == "temper" || isFile(filepath.Join(workDir, "strategy.md")) {
84
89
  order = append(order, "temper")
85
90
  }
86
91
  order = append(order, "define")
87
- if isFile(filepath.Join(workDir, "eng-review.md")) {
92
+ if phase == "vet" || isFile(filepath.Join(workDir, "eng-review.md")) {
88
93
  order = append(order, "vet")
89
94
  }
90
- order = append(order, "build", "prove", "polish", "review", "seal", "ship")
95
+ order = append(order, "build")
96
+ if phase == "converge" {
97
+ order = append(order, "converge")
98
+ }
99
+ order = append(order, "prove", "polish", "review", "seal", "ship")
91
100
 
92
101
  cur := phase
93
102
  if cur == "plan" { // replan sits at build
@@ -118,25 +127,17 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
118
127
  return 0
119
128
  }
120
129
 
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.
130
+ // parsePhase returns the first token of the canonical or legacy phase field.
124
131
  func parsePhase(lines []string) string {
125
- for _, line := range lines {
126
- if !strings.HasPrefix(line, "- Phase:") {
127
- continue
128
- }
129
- fields := phaseSplitRe.Split(line, -1)
130
- if len(fields) < 2 {
131
- return ""
132
- }
133
- toks := strings.Fields(fields[1])
134
- if len(toks) == 0 {
135
- return ""
136
- }
137
- return toks[0]
132
+ value, ok := state.CursorField(lines, "phase")
133
+ if !ok {
134
+ return ""
135
+ }
136
+ toks := strings.Fields(value)
137
+ if len(toks) == 0 {
138
+ return ""
138
139
  }
139
- return ""
140
+ return toks[0]
140
141
  }
141
142
 
142
143
  // 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
 
@@ -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")
@@ -13,14 +13,19 @@ type Command struct {
13
13
  }
14
14
 
15
15
  var phaseToVerb = map[string]string{
16
- "frame": "frame",
17
- "spec": "spec",
18
- "plan": "define",
19
- "build": "build",
20
- "prove": "prove",
21
- "vet": "vet",
22
- "seal": "seal",
23
- "ship": "ship",
16
+ "frame": "frame",
17
+ "spec": "spec",
18
+ "temper": "temper",
19
+ "define": "define",
20
+ "plan": "define", // plan is the artifact state produced by rite-define.
21
+ "vet": "vet",
22
+ "build": "build",
23
+ "converge": "converge",
24
+ "prove": "prove",
25
+ "polish": "polish",
26
+ "review": "review",
27
+ "seal": "seal",
28
+ "ship": "ship",
24
29
  }
25
30
 
26
31
  // ForVerb returns the Claude and Codex forms for a public rite verb. The empty
@@ -35,6 +40,18 @@ func ForVerb(verb string) Command {
35
40
  return Command{Verb: verb, Claude: "/" + name, Codex: "$" + name}
36
41
  }
37
42
 
43
+ // ForAction extracts the first explicit rite invocation from a cursor action.
44
+ // Cursor values may append a short reason after the command.
45
+ func ForAction(action string) Command {
46
+ for _, field := range strings.Fields(action) {
47
+ field = strings.Trim(field, "`'\"()[]{}<>,.;:")
48
+ if strings.HasPrefix(field, "/rite-") || strings.HasPrefix(field, "$rite-") {
49
+ return ForVerb(field)
50
+ }
51
+ }
52
+ return Command{}
53
+ }
54
+
38
55
  // ForPhase returns the next public command for a workflow phase.
39
56
  func ForPhase(phase string) Command {
40
57
  verb := phaseToVerb[strings.TrimSpace(strings.ToLower(phase))]
@@ -0,0 +1,35 @@
1
+ package workflow
2
+
3
+ import "testing"
4
+
5
+ func TestForActionExtractsCanonicalInvocation(t *testing.T) {
6
+ for _, tc := range []struct {
7
+ action string
8
+ verb string
9
+ }{
10
+ {action: "/rite-define after readiness passes", verb: "define"},
11
+ {action: "resume with $rite-build", verb: "build"},
12
+ {action: "none"},
13
+ } {
14
+ if got := ForAction(tc.action); got.Verb != tc.verb {
15
+ t.Fatalf("ForAction(%q).Verb=%q, want %q", tc.action, got.Verb, tc.verb)
16
+ }
17
+ }
18
+ }
19
+
20
+ func TestForPhaseCoversCurrentLifecycle(t *testing.T) {
21
+ for _, phase := range []string{
22
+ "frame", "spec", "temper", "define", "vet", "build", "converge",
23
+ "prove", "polish", "review", "seal", "ship",
24
+ } {
25
+ if got := ForPhase(phase).Verb; got != phase {
26
+ t.Fatalf("ForPhase(%q).Verb=%q, want %q", phase, got, phase)
27
+ }
28
+ }
29
+ if got := ForPhase("plan").Verb; got != "define" {
30
+ t.Fatalf("ForPhase(plan).Verb=%q, want define compatibility route", got)
31
+ }
32
+ if got := ForPhase("done").Verb; got != "" {
33
+ t.Fatalf("ForPhase(done).Verb=%q, want empty", got)
34
+ }
35
+ }
@@ -16,12 +16,18 @@ import (
16
16
  var phaseArc = []state.Phase{
17
17
  state.PhaseFrame,
18
18
  state.PhaseSpec,
19
+ state.PhaseTemper,
20
+ state.PhaseDefine,
19
21
  state.PhasePlan,
22
+ state.PhaseVet,
20
23
  state.PhaseBuild,
24
+ state.PhaseConverge,
21
25
  state.PhaseProve,
22
- state.PhaseVet,
26
+ state.PhasePolish,
27
+ state.PhaseReview,
23
28
  state.PhaseSeal,
24
29
  state.PhaseShip,
30
+ state.PhaseDone,
25
31
  }
26
32
 
27
33
  func TestADR0004RequiredSectionsAreAdditiveDownTheArc(t *testing.T) {
@@ -85,6 +85,28 @@ func TestReadinessPassesCompletePhase(t *testing.T) {
85
85
  }
86
86
  }
87
87
 
88
+ func TestReadinessAcceptsCanonicalTemperCursor(t *testing.T) {
89
+ root := filepath.Join(t.TempDir(), ".devrites")
90
+ testutil.WriteFile(t, filepath.Join(root, "work", "tempered", "state.md"), `# State
91
+
92
+ ## Cursor
93
+ | Key | Value |
94
+ | --- | --- |
95
+ | phase | temper |
96
+ | status | running |
97
+ | next_action | /rite-define |
98
+ `)
99
+ testutil.WriteFile(t, filepath.Join(root, "work", "tempered", "spec.md"), "# Spec\n\nReady.\n")
100
+
101
+ out, errOut, code := runDevrites(t, root, "readiness", "tempered")
102
+ if code != 0 {
103
+ t.Fatalf("exit = %d, want 0; stderr:\n%s", code, errOut)
104
+ }
105
+ if !strings.Contains(out, "phase: temper") || !strings.Contains(out, "result: pass") {
106
+ t.Fatalf("unexpected readiness output:\n%s", out)
107
+ }
108
+ }
109
+
88
110
  func TestSealBlocksWhenSealSectionsMissing(t *testing.T) {
89
111
  root := newWorkspace(t)
90
112
  out, _, code := runDevrites(t, root, "seal", "auth-tokens")
@@ -20,7 +20,7 @@ func workWorkspace(t *testing.T) (root, slug string) {
20
20
  t.Fatal(err)
21
21
  }
22
22
  files := map[string]string{
23
- "state.md": "# State\n\nphase: build\n\nWorking on it.\n",
23
+ "state.md": "# State\n\n| Key | Value |\n| --- | --- |\n| phase | temper |\n| status | running |\n",
24
24
  "spec.md": "# Spec\n\nRotate tokens.\n",
25
25
  "plan.md": "# Plan\n\nStep 1, step 2.\n",
26
26
  "tasks.md": "# Tasks\n\n- [x] one\n",
@@ -68,7 +68,7 @@ func TestMigrateNormalizesCanonicalWorkSchema(t *testing.T) {
68
68
  if err != nil {
69
69
  t.Fatal(err)
70
70
  }
71
- if !strings.Contains(string(fm), "phase: build") {
71
+ if !strings.Contains(string(fm), "phase: temper") {
72
72
  t.Errorf("feature.md phase not derived from state.md\n%s", fm)
73
73
  }
74
74
  if !strings.Contains(string(fm), "schemaVersion: 1") {
@@ -94,7 +94,7 @@ func TestMigratePostStatusWorks(t *testing.T) {
94
94
  if code != 0 {
95
95
  t.Fatalf("status after migrate exit = %d (stderr: %s)", code, errOut)
96
96
  }
97
- if !strings.Contains(out, "phase: build") {
97
+ if !strings.Contains(out, "phase: temper") {
98
98
  t.Errorf("migrated feature status is wrong\n%s", out)
99
99
  }
100
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "DevRites — disciplined senior-engineer workflow skills pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",