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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
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
+
11
+ ## [3.0.1](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.0...v3.0.1) (2026-07-20)
12
+
13
+ ### Fixed
14
+
15
+ * **rite:** run analysis after task definition ([6373227](https://github.com/ViktorsBaikers/DevRites/commit/6373227521b5e1e6b22bfe48e98799e709f5dc7a))
16
+
5
17
  ## [3.0.0](https://github.com/ViktorsBaikers/DevRites/compare/v2.6.1...v3.0.0) (2026-07-17)
6
18
 
7
19
  ### ⚠ BREAKING CHANGES
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.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.0) — 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
 
@@ -159,7 +159,7 @@ Prohibitions tables — a blocking spec-gate check.
159
159
  any code is written, so a coverage gap surfaces as a one-line plan edit instead of a reslice
160
160
  mid-build. It emits a markdown report with four passes:
161
161
 
162
- - **Coverage** — a spec `[ACn]` that no slice `Satisfies:` (**CRITICAL**).
162
+ - **Coverage** — a spec `AC-###` that no slice `Satisfies:` (**CRITICAL**; legacy `[ACn]` remains supported).
163
163
  - **Consistency** — a slice that `Satisfies:` an AC the spec never defines (**CRITICAL**).
164
164
  - **Orphan slice** — a slice satisfying no acceptance criterion (warn).
165
165
  - **Ambiguity** — an unquantified vague adjective (`fast`, `robust`, `intuitive`, …) or an
@@ -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()
@@ -15,7 +15,7 @@ import (
15
15
  // written, so a coverage gap surfaces as a one-line plan edit rather than a reslice
16
16
  // mid-build. It emits a markdown report and flags:
17
17
  //
18
- // Coverage — a spec [ACn] that no slice Satisfies (CRITICAL)
18
+ // Coverage — a spec AC id that no slice Satisfies (CRITICAL)
19
19
  // Consistency — a slice that Satisfies an AC the spec never defines (CRITICAL)
20
20
  // Orphan slice — a slice that satisfies no acceptance criterion (warn)
21
21
  // Ambiguity — an unquantified vague adjective or unresolved
@@ -27,9 +27,8 @@ import (
27
27
  // Exit codes: 0 clear · 1 at least one CRITICAL finding · 2 no workspace (no active
28
28
  // slug, or spec.md/tasks.md missing).
29
29
  var (
30
- specACRe = regexp.MustCompile(`\[AC[0-9]+\]`) // a bracketed [ACn] in the spec
31
- taskACRe = regexp.MustCompile(`\bAC[0-9]+\b`) // a bare ACn in the tasks
32
- sliceHeadRe = regexp.MustCompile(`^##[[:space:]]*Slice`)
30
+ taskACRe = regexp.MustCompile(`\b(?:AC-[0-9]{3}|AC[0-9]+)\b`)
31
+ sliceHeadRe = regexp.MustCompile(`(?i)^##[[:space:]]*(?:SLICE-[0-9]{3}\b|Slice\b)`)
33
32
  sliceNamePrefixRe = regexp.MustCompile(`^##[[:space:]]*`) // stripped to leave the slice name
34
33
  analyzeSatisfiesRe = regexp.MustCompile(`^[[:space:]]*Satisfies:`)
35
34
  // Ambiguity scan: a vague quality adjective with no number on the line is an
@@ -57,7 +56,7 @@ func Analyze(root string, args []string, stdout, stderr io.Writer) int {
57
56
  specData, _ := os.ReadFile(spec)
58
57
  tasksData, _ := os.ReadFile(tasks)
59
58
 
60
- specACs := sortedACIDs(specACRe, specData, true) // brackets stripped: "[AC1]" -> "AC1"
59
+ specACs := sortedACIDs(acIDRe, specData, true) // legacy brackets stripped: "[AC1]" -> "AC1"
61
60
  taskACs := sortedACIDs(taskACRe, tasksData, false)
62
61
  specSet := stringSet(specACs)
63
62
  taskSet := stringSet(taskACs)
@@ -70,7 +69,7 @@ func Analyze(root string, args []string, stdout, stderr io.Writer) int {
70
69
  covered := 0
71
70
  fmt.Fprintln(stdout, "## Coverage")
72
71
  if len(specACs) == 0 {
73
- fmt.Fprintln(stdout, "- [warn] spec.md — no [ACn] acceptance ids found; tag criteria as '[AC1] ...' for a machine-checkable gate.")
72
+ fmt.Fprintln(stdout, "- [warn] spec.md — no AC-### acceptance ids found; tag criteria for a machine-checkable gate.")
74
73
  } else {
75
74
  for _, ac := range specACs {
76
75
  if taskSet[ac] {
@@ -155,9 +154,9 @@ func ambiguityFindings(specData []byte) []string {
155
154
  out = append(out, fmt.Sprintf("- [warn] spec.md:L%d — unresolved placeholder %q", ln, m))
156
155
  continue
157
156
  }
158
- // Strip the AC id before the digit gate — else the id's own digit
159
- // ("[AC1]") masks a vague adjective on the very criterion line it tags.
160
- bare := specACRe.ReplaceAllString(line, "")
157
+ // Strip the AC id before the digit gate — else the id's own digit masks
158
+ // a vague adjective on the very criterion line it tags.
159
+ bare := acIDRe.ReplaceAllString(line, "")
161
160
  bare = taskACRe.ReplaceAllString(bare, "")
162
161
  if !analyzeDigitRe.MatchString(bare) {
163
162
  if m := vagueAdjRe.FindString(bare); m != "" {
@@ -170,7 +169,7 @@ func ambiguityFindings(specData []byte) []string {
170
169
 
171
170
  // sortedACIDs collects every acceptance id matched by re, deduplicated and sorted
172
171
  // lexicographically. When strip is set the surrounding brackets are removed, so
173
- // "[AC1]" becomes "AC1". Returns nil when there are none.
172
+ // Legacy "[AC1]" becomes "AC1". Returns nil when there are none.
174
173
  func sortedACIDs(re *regexp.Regexp, data []byte, strip bool) []string {
175
174
  seen := map[string]bool{}
176
175
  var out []string
@@ -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
  }
@@ -10,17 +10,17 @@ import (
10
10
  )
11
11
 
12
12
  // Executable acceptance criteria — a port of check-acceptance.sh. Grades the
13
- // [ACn]-tagged acceptance criteria in spec.md against seal.md: each spec
13
+ // AC-### acceptance criteria in spec.md against seal.md: each spec
14
14
  // criterion must be checked ([x]) in seal.md. Without ids it falls back to a
15
15
  // count/unchecked heuristic. The spec-time mirror is SpecValidate (shape); this
16
- // grades proof.
16
+ // grades proof. Legacy [ACn] ids remain supported for existing workspaces.
17
17
  //
18
18
  // Exit codes: 0 every criterion proven · 1 gap (uncovered/unproven) · 2 usage ·
19
19
  // 5 missing spec.md or seal.md.
20
20
  var (
21
21
  acHeadingRe = regexp.MustCompile(`^##[[:space:]]+[Aa]cceptance [Cc]riteria`)
22
22
  h2Re = regexp.MustCompile(`^##[[:space:]]`)
23
- acIDRe = regexp.MustCompile(`\[AC[0-9]+\]`)
23
+ acIDRe = regexp.MustCompile(`\[AC[0-9]+\]|\bAC-[0-9]{3}\b`)
24
24
  checkedRe = regexp.MustCompile(`^[[:space:]]*-[[:space:]]*\[[xX]\]`)
25
25
  bulletRe = regexp.MustCompile(`^[[:space:]]*-[[:space:]]`)
26
26
  checkboxRe = regexp.MustCompile(`^[[:space:]]*-[[:space:]]*\[[ xX]\]`)
@@ -64,7 +64,7 @@ func CheckAcceptance(ws string, stdout, stderr io.Writer) int {
64
64
  specIDs := sortedUniqueIDs(specBody)
65
65
 
66
66
  if len(specIDs) > 0 {
67
- // ID mode — each spec [ACn] must be checked ([x]) in seal.md.
67
+ // ID mode — each spec AC id must be checked ([x]) in seal.md.
68
68
  checkedIDs := map[string]bool{}
69
69
  for _, line := range sealBody {
70
70
  if checkedRe.MatchString(line) {
@@ -96,15 +96,15 @@ func CheckAcceptance(ws string, stdout, stderr io.Writer) int {
96
96
  sealUnchecked := countMatching(sealBody, uncheckedRe)
97
97
 
98
98
  if sealTotal < specN {
99
- fmt.Fprintf(stderr, "check-acceptance: seal.md lists %d acceptance items but spec.md has %d criteria — %d dropped. (Tag criteria [AC1].. to grade by id.)\n",
99
+ fmt.Fprintf(stderr, "check-acceptance: seal.md lists %d acceptance items but spec.md has %d criteria — %d dropped. (Tag criteria AC-### to grade by id.)\n",
100
100
  sealTotal, specN, specN-sealTotal)
101
101
  return 1
102
102
  }
103
103
  if sealUnchecked > 0 {
104
- fmt.Fprintf(stderr, "check-acceptance: %d acceptance criterion(s) unchecked in seal.md (unproven). (Tag criteria [AC1].. to grade by id.)\n", sealUnchecked)
104
+ fmt.Fprintf(stderr, "check-acceptance: %d acceptance criterion(s) unchecked in seal.md (unproven). (Tag criteria AC-### to grade by id.)\n", sealUnchecked)
105
105
  return 1
106
106
  }
107
- fmt.Fprintf(stdout, "check-acceptance: OK — %d acceptance items all checked in seal.md (no ids; add [AC1].. for id-level grading)\n", sealTotal)
107
+ fmt.Fprintf(stdout, "check-acceptance: OK — %d acceptance items all checked in seal.md (no ids; add AC-### for id-level grading)\n", sealTotal)
108
108
  return 0
109
109
  }
110
110
 
@@ -145,8 +145,8 @@ func sectionEmpty(lines []string) bool {
145
145
  return strings.TrimRight(strings.Join(lines, "\n"), "\n") == ""
146
146
  }
147
147
 
148
- // sortedUniqueIDs extracts every [ACn] id from the lines and returns them sorted
149
- // and de-duplicated, matching `grep -oE '\[AC[0-9]+\]' | sort -u`.
148
+ // sortedUniqueIDs extracts canonical AC-### and legacy [ACn] ids from the lines,
149
+ // then returns them sorted and de-duplicated.
150
150
  func sortedUniqueIDs(lines []string) []string {
151
151
  set := map[string]bool{}
152
152
  for _, line := range lines {
@@ -18,9 +18,9 @@ import (
18
18
  // criterion is covered by a slice and proven in the seal. Read-only.
19
19
  //
20
20
  // The convention pairs with check-acceptance: a criterion carries a stable id,
21
- // spec.md declares `- [ ] [ACn] …`, a tasks.md slice claims it with a
22
- // `Satisfies: ACn` line, and seal.md proves it with `- [x] [ACn] …`. A criterion
23
- // is proven iff its `[ACn]` id is checked in seal.md.
21
+ // spec.md declares `- [ ] AC-###: …`, a tasks.md slice claims it with a
22
+ // `Satisfies: AC-###` line, and seal.md proves it with `- [x] AC-###: …`.
23
+ // Legacy [ACn] ids remain supported for existing workspaces.
24
24
  //
25
25
  // base is the project directory that contains the .devrites tree; slug defaults
26
26
  // to the active feature named in .devrites/ACTIVE. The matrix is the stdout
@@ -83,16 +83,14 @@ func Coverage(root string, args []string, stdout, stderr io.Writer) int {
83
83
  return 0
84
84
  }
85
85
 
86
- // acIDRe (`\[AC[0-9]+\]`) is shared with check-acceptance, which declares it.
86
+ // acIDRe is shared with check-acceptance, which declares it.
87
87
  var (
88
- sliceHeaderRe = regexp.MustCompile(`^##[[:space:]]*Slice`)
89
- sliceStripRe = regexp.MustCompile(`^##[[:space:]]*`)
90
- satisfiesRe = regexp.MustCompile(`^[[:space:]]*Satisfies:`)
88
+ sliceStripRe = regexp.MustCompile(`^##[[:space:]]*`)
89
+ satisfiesRe = regexp.MustCompile(`^[[:space:]]*Satisfies:`)
91
90
  )
92
91
 
93
- // acIDs returns the unique `[ACn]` ids declared in spec.md, bracket-stripped and
94
- // byte-order sorted — mirroring `grep -oE '\[AC[0-9]+\]' | tr -d '[]' | sort -u`
95
- // under LC_ALL=C.
92
+ // acIDs returns unique canonical AC-### and legacy [ACn] ids declared in
93
+ // spec.md, bracket-stripped and byte-order sorted.
96
94
  func acIDs(spec string) []string {
97
95
  seen := map[string]bool{}
98
96
  var ids []string
@@ -118,7 +116,7 @@ func slicesForAC(tasks, ac string) string {
118
116
  sc := newLineScanner(strings.NewReader(tasks))
119
117
  for sc.Scan() {
120
118
  line := sc.Text()
121
- if sliceHeaderRe.MatchString(line) {
119
+ if sliceHeadRe.MatchString(line) {
122
120
  cur = sliceStripRe.ReplaceAllString(line, "")
123
121
  }
124
122
  if satisfiesRe.MatchString(line) && strings.Contains(line, ac) {
@@ -128,11 +126,11 @@ func slicesForAC(tasks, ac string) string {
128
126
  return strings.Join(matched, ";")
129
127
  }
130
128
 
131
- // provenInSeal reports whether seal.md checks off ac's id — a line of the form
132
- // `- [x] [ACn]` (with optional surrounding whitespace), the same pattern the
133
- // script greps for.
129
+ // provenInSeal reports whether seal.md contains a checked canonical or legacy
130
+ // criterion line.
134
131
  func provenInSeal(seal, ac string) bool {
135
- re := regexp.MustCompile(`(?m)^[[:space:]]*-[[:space:]]*\[x\][[:space:]]*\[` + regexp.QuoteMeta(ac) + `\]`)
132
+ quoted := regexp.QuoteMeta(ac)
133
+ re := regexp.MustCompile(`(?m)^[[:space:]]*-[[:space:]]*\[x\][[:space:]]*(?:\[` + quoted + `\]|` + quoted + `\b)`)
136
134
  return re.MatchString(seal)
137
135
  }
138
136
 
@@ -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
+ }
@@ -83,7 +83,7 @@ func parseLaneSlices(md string) []laneSlice {
83
83
  }
84
84
  for _, line := range strings.Split(md, "\n") {
85
85
  trim := strings.TrimSpace(line)
86
- if strings.HasPrefix(trim, "## Slice") {
86
+ if sliceHeadRe.MatchString(trim) {
87
87
  flush()
88
88
  cur = &laneSlice{name: strings.TrimPrefix(trim, "## ")}
89
89
  continue
@@ -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