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 +6 -0
- package/README.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/buildreadiness.go +10 -16
- package/engine/internal/lib/cursor_compat_test.go +91 -0
- 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/tests/adr_0004_required_by_phase_test.go +7 -1
- package/engine/tests/gate_test.go +22 -0
- package/engine/tests/migrate_cli_test.go +3 -3
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
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
|
|
@@ -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
|
|
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
|
|
316
|
-
|
|
313
|
+
if inAw {
|
|
314
|
+
awaitingLines = append(awaitingLines, line)
|
|
317
315
|
}
|
|
318
316
|
}
|
|
319
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
72
|
+
if i := strings.IndexAny(value, spaceChars); i >= 0 {
|
|
73
|
+
value = value[:i]
|
|
74
|
+
}
|
|
75
|
+
return value, true
|
|
84
76
|
}
|
|
85
77
|
|
|
86
|
-
// setBudget
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
145
|
-
//
|
|
146
|
-
// middle of the arc, from which
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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 "
|
|
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 "
|
|
183
|
-
return state.
|
|
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", "
|
|
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.
|
|
45
|
-
t.Fatalf("migrated phase=%q, want %q", f.Phase, state.
|
|
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: "
|
|
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
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
|
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
|
-
|
|
157
|
+
manifestPhase = Phase(strings.TrimSpace(fm["phase"]))
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
190
|
-
// "- Phase: <p>"
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
58
|
-
PhaseSpec
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
PhaseVet
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
PhaseVet:
|
|
77
|
-
|
|
78
|
-
|
|
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 :=
|
|
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
|
|
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
|
|
152
|
-
//
|
|
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":
|
|
17
|
-
"spec":
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"vet":
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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.
|
|
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\
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|