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
|
@@ -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")
|