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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +1 -1
- package/engine/hooks_events_test.go +15 -0
- package/engine/hooks_workspace.go +8 -31
- package/engine/internal/gate/gate.go +4 -9
- package/engine/internal/gate/gate_test.go +16 -0
- package/engine/internal/lib/analyze.go +9 -10
- package/engine/internal/lib/buildreadiness.go +10 -16
- package/engine/internal/lib/checkacceptance.go +9 -9
- package/engine/internal/lib/coverage.go +13 -15
- package/engine/internal/lib/cursor_compat_test.go +91 -0
- package/engine/internal/lib/lanes.go +1 -1
- package/engine/internal/lib/progress.go +25 -24
- package/engine/internal/lib/resolve.go +9 -14
- package/engine/internal/lib/tickafk.go +11 -28
- package/engine/internal/migrate/migrate.go +28 -16
- package/engine/internal/migrate/migrate_test.go +5 -3
- package/engine/internal/state/cursor.go +84 -0
- package/engine/internal/state/cursor_test.go +58 -0
- package/engine/internal/state/feature.go +34 -31
- package/engine/internal/state/schema.go +30 -18
- package/engine/internal/state/snapshot.go +13 -2
- package/engine/internal/state/state_test.go +48 -3
- package/engine/internal/workflow/commands.go +25 -8
- package/engine/internal/workflow/commands_test.go +35 -0
- package/engine/testdata/golden/TestParityAnalyze/slug=noac.golden +1 -1
- package/engine/testdata/golden/TestParityCheckAcceptance/arg=flat-ok.golden +1 -1
- package/engine/tests/adr_0004_required_by_phase_test.go +7 -1
- package/engine/tests/canonical_ac_ids_test.go +44 -0
- package/engine/tests/gate_test.go +22 -0
- package/engine/tests/migrate_cli_test.go +3 -3
- package/pack/.claude/skills/rite-define/SKILL.md +6 -0
- package/pack/.claude/skills/rite-spec/SKILL.md +2 -0
- package/pack/.claude/skills/rite-vet/SKILL.md +6 -1
- package/pack/generated/claude/skills/rite-define/SKILL.md +6 -0
- package/pack/generated/claude/skills/rite-spec/SKILL.md +2 -0
- package/pack/generated/claude/skills/rite-vet/SKILL.md +6 -1
- package/pack/generated/codex/skills/rite-define/SKILL.md +6 -0
- package/pack/generated/codex/skills/rite-spec/SKILL.md +2 -0
- package/pack/generated/codex/skills/rite-vet/SKILL.md +6 -1
- 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.
|
|
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
|
|
package/docs/engine/commands.md
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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 :=
|
|
203
|
-
status :=
|
|
204
|
-
next :=
|
|
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 :=
|
|
237
|
-
status :=
|
|
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 =
|
|
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 :=
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
160
|
-
bare :=
|
|
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
|
|
71
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
-
//
|
|
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 `- [ ]
|
|
22
|
-
// `Satisfies:
|
|
23
|
-
//
|
|
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
|
|
86
|
+
// acIDRe is shared with check-acceptance, which declares it.
|
|
87
87
|
var (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
satisfiesRe = regexp.MustCompile(`^[[:space:]]*Satisfies:`)
|
|
88
|
+
sliceStripRe = regexp.MustCompile(`^##[[:space:]]*`)
|
|
89
|
+
satisfiesRe = regexp.MustCompile(`^[[:space:]]*Satisfies:`)
|
|
91
90
|
)
|
|
92
91
|
|
|
93
|
-
// acIDs returns
|
|
94
|
-
//
|
|
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
|
|
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
|
|
132
|
-
//
|
|
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
|
-
|
|
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
|
|
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;
|
|
81
|
-
// exists.
|
|
82
|
-
order
|
|
83
|
-
if
|
|
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"
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|