devrites 3.0.1 → 3.0.3
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/adr/0004-state-schema-phases-sections.md +44 -0
- package/docs/command-map.md +1 -1
- package/docs/engine/state-schema.md +15 -12
- package/docs/flow.md +1 -1
- package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
- package/engine/hooks_events_test.go +15 -0
- package/engine/hooks_workspace.go +8 -31
- package/engine/internal/gate/gate.go +6 -11
- package/engine/internal/gate/gate_test.go +18 -2
- package/engine/internal/lib/buildreadiness.go +10 -16
- package/engine/internal/lib/cursor_compat_test.go +120 -0
- package/engine/internal/lib/preamble.go +1 -1
- package/engine/internal/lib/preamble_questions_test.go +10 -0
- package/engine/internal/lib/progress.go +69 -29
- package/engine/internal/lib/resolve.go +21 -17
- package/engine/internal/lib/resolve_remediation_test.go +9 -0
- package/engine/internal/lib/tickafk.go +11 -28
- package/engine/internal/migrate/migrate.go +13 -32
- package/engine/internal/migrate/migrate_test.go +11 -3
- package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
- package/engine/internal/state/cursor.go +119 -0
- package/engine/internal/state/cursor_test.go +58 -0
- package/engine/internal/state/feature.go +25 -48
- package/engine/internal/state/schema.go +151 -24
- package/engine/internal/state/snapshot.go +60 -3
- package/engine/internal/state/state_test.go +119 -3
- package/engine/internal/state/workflow_manifest.json +310 -0
- package/engine/internal/workflow/commands.go +10 -29
- package/engine/internal/workflow/commands_test.go +18 -0
- package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
- package/engine/tests/adr_0004_required_by_phase_test.go +1 -12
- package/engine/tests/gate_test.go +22 -0
- package/engine/tests/lib_parity_test.go +1 -1
- package/engine/tests/migrate_cli_test.go +3 -3
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/pack/generated/codex/AGENTS.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/package.json +1 -1
- package/scripts/codex-generate.sh +1 -1
- package/scripts/grade-feature.sh +5 -3
- package/scripts/run-outcome-evals.sh +21 -0
- package/scripts/validate-workspace-schema.py +9 -73
- package/scripts/validate.sh +10 -0
- package/scripts/workflow_schema.py +69 -0
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
if !strings.Contains(text, "$rite-temper") || strings.Contains(text, "$rite-build") {
|
|
92
|
+
t.Fatalf("canonical awaiting state resumed the wrong phase:\n%s", text)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func TestProgressReadsCanonicalSlicesAndShowsPlanPhase(t *testing.T) {
|
|
97
|
+
root := t.TempDir()
|
|
98
|
+
dir := filepath.Join(root, "work", "demo")
|
|
99
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
100
|
+
t.Fatal(err)
|
|
101
|
+
}
|
|
102
|
+
stateText := strings.Replace(canonicalCursor, "| phase | temper |", "| phase | plan |", 1)
|
|
103
|
+
stateText = strings.Replace(stateText, "| afk_slices_remaining | 2 |", "| active_slice | SLICE-002 |", 1)
|
|
104
|
+
if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte(stateText), 0o644); err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
tasks := "## SLICE-001 First\nStatus: built\n\n## SLICE-002 Second\nStatus: pending\n"
|
|
108
|
+
if err := os.WriteFile(filepath.Join(dir, "tasks.md"), []byte(tasks), 0o644); err != nil {
|
|
109
|
+
t.Fatal(err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var stdout bytes.Buffer
|
|
113
|
+
if code := Progress(root, []string{"demo"}, &stdout, &bytes.Buffer{}); code != 0 {
|
|
114
|
+
t.Fatalf("code=%d", code)
|
|
115
|
+
}
|
|
116
|
+
text := stdout.String()
|
|
117
|
+
if !strings.Contains(text, "Slice 1/2") || !strings.Contains(text, "plan ◉") || strings.Contains(text, "build ◉") {
|
|
118
|
+
t.Fatalf("progress missed canonical slice/plan state:\n%s", text)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -117,7 +117,7 @@ func tallyOpenQuestions(data []byte) (blocking, validating, advisory, escalating
|
|
|
117
117
|
}
|
|
118
118
|
for _, line := range splitLinesNoTrailing(data) {
|
|
119
119
|
switch {
|
|
120
|
-
case strings.HasPrefix(line, "## q-"):
|
|
120
|
+
case strings.HasPrefix(strings.ToLower(line), "## q-"):
|
|
121
121
|
finalize()
|
|
122
122
|
inQ, status, gate = true, "", ""
|
|
123
123
|
case inQ && strings.HasPrefix(line, "status:"):
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestTallyOpenQuestionsAcceptsCanonicalUppercaseIDs(t *testing.T) {
|
|
6
|
+
b, v, a, e := tallyOpenQuestions([]byte("## Q-001\nstatus: open\ngate: blocking\n\n## q-2026-07-20-001\nstatus: open\ngate: escalating\n"))
|
|
7
|
+
if b != 1 || v != 0 || a != 0 || e != 1 {
|
|
8
|
+
t.Fatalf("counts=(%d,%d,%d,%d), want (1,0,0,1)", b, v, a, e)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -7,18 +7,20 @@ 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:]]*$`)
|
|
20
21
|
sliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`) // drop " — "
|
|
21
22
|
sliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
|
|
23
|
+
sliceHeadingRe = regexp.MustCompile(`^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+(.*))?$`)
|
|
22
24
|
)
|
|
23
25
|
|
|
24
26
|
// Progress renders the active feature's position — the deterministic footer every
|
|
@@ -49,6 +51,9 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
49
51
|
phase = "spec"
|
|
50
52
|
}
|
|
51
53
|
total, built, lastbuilt := tallySlices(lines)
|
|
54
|
+
if total == 0 {
|
|
55
|
+
total, built, lastbuilt = tallyCanonicalSlices(workDir)
|
|
56
|
+
}
|
|
52
57
|
|
|
53
58
|
// Header rule: pad "── rite-<phase> " with box glyphs to ≥44 BYTES. Under the
|
|
54
59
|
// oracle's LC_ALL=C the bash `${#header}` counts bytes, which len() matches.
|
|
@@ -77,22 +82,30 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Flow ribbon: the lifecycle spine;
|
|
81
|
-
// exists.
|
|
82
|
-
order := []string
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
// Flow ribbon: the lifecycle spine; optional phases appear once their artifact
|
|
86
|
+
// exists or while they are the active phase, so the cursor is never omitted.
|
|
87
|
+
order := make([]string, 0, len(state.LifecyclePhases()))
|
|
88
|
+
for _, lifecyclePhase := range state.LifecyclePhases() {
|
|
89
|
+
name := string(lifecyclePhase)
|
|
90
|
+
include := true
|
|
91
|
+
switch lifecyclePhase {
|
|
92
|
+
case state.PhaseFrame:
|
|
93
|
+
include = phase == name
|
|
94
|
+
case state.PhaseTemper:
|
|
95
|
+
include = phase == name || isFile(filepath.Join(workDir, "strategy.md"))
|
|
96
|
+
case state.PhaseDone:
|
|
97
|
+
include = false
|
|
98
|
+
case state.PhaseVet:
|
|
99
|
+
include = phase == name || isFile(filepath.Join(workDir, "eng-review.md"))
|
|
100
|
+
case state.PhaseConverge:
|
|
101
|
+
include = phase == name
|
|
102
|
+
}
|
|
103
|
+
if include {
|
|
104
|
+
order = append(order, name)
|
|
105
|
+
}
|
|
89
106
|
}
|
|
90
|
-
order = append(order, "build", "prove", "polish", "review", "seal", "ship")
|
|
91
107
|
|
|
92
108
|
cur := phase
|
|
93
|
-
if cur == "plan" { // replan sits at build
|
|
94
|
-
cur = "build"
|
|
95
|
-
}
|
|
96
109
|
idx := -1
|
|
97
110
|
if phase == "done" { // past ship
|
|
98
111
|
idx = len(order)
|
|
@@ -118,25 +131,52 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
118
131
|
return 0
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
func tallyCanonicalSlices(workDir string) (total, built int, lastbuilt string) {
|
|
135
|
+
raw, err := os.ReadFile(filepath.Join(workDir, "tasks.md"))
|
|
136
|
+
if err != nil {
|
|
137
|
+
return 0, 0, ""
|
|
138
|
+
}
|
|
139
|
+
name, sliceState := "", ""
|
|
140
|
+
finalize := func() {
|
|
141
|
+
if name == "" {
|
|
142
|
+
return
|
|
128
143
|
}
|
|
129
|
-
|
|
130
|
-
if
|
|
131
|
-
|
|
144
|
+
total++
|
|
145
|
+
if sliceState == "built" {
|
|
146
|
+
built++
|
|
147
|
+
lastbuilt = name
|
|
132
148
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
}
|
|
150
|
+
for _, line := range strings.Split(string(raw), "\n") {
|
|
151
|
+
if match := sliceHeadingRe.FindStringSubmatch(strings.TrimSpace(line)); match != nil {
|
|
152
|
+
finalize()
|
|
153
|
+
name = match[1]
|
|
154
|
+
if strings.TrimSpace(match[2]) != "" {
|
|
155
|
+
name += " " + strings.TrimSpace(match[2])
|
|
156
|
+
}
|
|
157
|
+
sliceState = "pending"
|
|
158
|
+
continue
|
|
136
159
|
}
|
|
137
|
-
|
|
160
|
+
if name != "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "status:") {
|
|
161
|
+
_, value, _ := strings.Cut(strings.TrimSpace(line), ":")
|
|
162
|
+
sliceState = strings.ToLower(strings.TrimSpace(value))
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finalize()
|
|
166
|
+
return total, built, lastbuilt
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// parsePhase returns the first token of the canonical or legacy phase field.
|
|
170
|
+
func parsePhase(lines []string) string {
|
|
171
|
+
value, ok := state.CursorField(lines, state.CursorPhase)
|
|
172
|
+
if !ok {
|
|
173
|
+
return ""
|
|
174
|
+
}
|
|
175
|
+
toks := strings.Fields(value)
|
|
176
|
+
if len(toks) == 0 {
|
|
177
|
+
return ""
|
|
138
178
|
}
|
|
139
|
-
return
|
|
179
|
+
return toks[0]
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
// 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
|
|
|
@@ -219,7 +220,7 @@ func resolveQuestion(qfile, qid, status, answer string, stderr io.Writer) int {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
var (
|
|
222
|
-
qHeaderRe = regexp.MustCompile(
|
|
223
|
+
qHeaderRe = regexp.MustCompile(`(?i)^## q-`)
|
|
223
224
|
statusLineRe = regexp.MustCompile(`^status:`)
|
|
224
225
|
statusStrip = regexp.MustCompile(`^status:[[:space:]]*`)
|
|
225
226
|
answeredAtRe = regexp.MustCompile(`^answered_at:`)
|
|
@@ -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,18 @@ 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
|
-
|
|
304
|
-
|
|
302
|
+
resumePhase := state.PhaseBuild
|
|
303
|
+
if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
|
|
304
|
+
if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
|
|
305
|
+
if phase, known := state.PhaseForName(fields[0]); known && state.ResumeVerb(phase) != "" {
|
|
306
|
+
resumePhase = phase
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
resumeCommand := workflow.ForVerb(state.ResumeVerb(resumePhase))
|
|
305
311
|
// First check whether the awaiting block references this question at all.
|
|
306
|
-
inAw
|
|
312
|
+
inAw := false
|
|
313
|
+
var awaitingLines []string
|
|
307
314
|
for _, line := range lines {
|
|
308
315
|
switch {
|
|
309
316
|
case awaitingRe.MatchString(line):
|
|
@@ -312,11 +319,12 @@ func clearAwaiting(sfile, qid string) error {
|
|
|
312
319
|
case inAw && hdrSpaceRe.MatchString(line):
|
|
313
320
|
inAw = false
|
|
314
321
|
}
|
|
315
|
-
if inAw
|
|
316
|
-
|
|
322
|
+
if inAw {
|
|
323
|
+
awaitingLines = append(awaitingLines, line)
|
|
317
324
|
}
|
|
318
325
|
}
|
|
319
|
-
|
|
326
|
+
waitingOn, _ := state.CursorField(awaitingLines, state.CursorQuestionID)
|
|
327
|
+
if waitingOn != qid {
|
|
320
328
|
return nil
|
|
321
329
|
}
|
|
322
330
|
|
|
@@ -336,19 +344,13 @@ func clearAwaiting(sfile, qid string) error {
|
|
|
336
344
|
continue
|
|
337
345
|
}
|
|
338
346
|
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
347
|
case logHdrRe.MatchString(line):
|
|
346
348
|
out = append(out, line)
|
|
347
349
|
inLog = true
|
|
348
350
|
continue
|
|
349
351
|
case inLog && hdrSpaceRe.MatchString(line):
|
|
350
352
|
if !logAppended {
|
|
351
|
-
out = append(out, fmt.Sprintf("- %s
|
|
353
|
+
out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
|
|
352
354
|
logAppended = true
|
|
353
355
|
}
|
|
354
356
|
inLog = false
|
|
@@ -358,8 +360,10 @@ func clearAwaiting(sfile, qid string) error {
|
|
|
358
360
|
out = append(out, line)
|
|
359
361
|
}
|
|
360
362
|
if inLog && !logAppended {
|
|
361
|
-
out = append(out, fmt.Sprintf("- %s
|
|
363
|
+
out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
|
|
362
364
|
}
|
|
365
|
+
out, _ = state.SetCursorField(out, state.CursorStatus, "running")
|
|
366
|
+
out, _ = state.SetCursorField(out, state.CursorNextAction, "(resume — `"+resumeCommand.Both()+"` to continue the workflow)")
|
|
363
367
|
if err := fsutil.WriteFileAtomic(sfile, joinRecords(out), 0o644); err != nil {
|
|
364
368
|
return fmt.Errorf("update state %s: %w", sfile, err)
|
|
365
369
|
}
|
|
@@ -39,6 +39,15 @@ func TestResolveCompletesMissingAnswerFieldsInSingleRewrite(t *testing.T) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
func TestResolveAcceptsCanonicalUppercaseQuestionID(t *testing.T) {
|
|
43
|
+
root := resolveWorkspace(t, "## Q-001 blocking\nstatus: open\n")
|
|
44
|
+
var stdout, stderr bytes.Buffer
|
|
45
|
+
|
|
46
|
+
if code := Resolve(root, []string{"Q-001", "canonical answer"}, &stdout, &stderr); code != 0 {
|
|
47
|
+
t.Fatalf("code = %d, want 0; stderr=%q", code, stderr.String())
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
func resolveWorkspace(t *testing.T, questions string) string {
|
|
43
52
|
t.Helper()
|
|
44
53
|
root := t.TempDir()
|
|
@@ -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, state.CursorAFKSlicesRemaining)
|
|
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, state.CursorAFKSlicesRemaining, strconv.Itoa(n))
|
|
81
|
+
return []byte(strings.Join(updated, "\n") + "\n")
|
|
99
82
|
}
|
|
@@ -141,53 +141,34 @@ 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{state.CursorPhase, state.CursorStatus} {
|
|
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]
|
|
170
170
|
}
|
|
171
|
-
|
|
172
|
-
case "frame":
|
|
173
|
-
return state.PhaseFrame, true
|
|
174
|
-
case "spec", "specced", "specifying":
|
|
175
|
-
return state.PhaseSpec, true
|
|
176
|
-
case "plan", "planned", "define", "defined":
|
|
177
|
-
return state.PhasePlan, true
|
|
178
|
-
case "build", "building", "wip", "in", "in-progress":
|
|
179
|
-
return state.PhaseBuild, true
|
|
180
|
-
case "prove", "proving", "proven", "testing":
|
|
181
|
-
return state.PhaseProve, true
|
|
182
|
-
case "vet", "review", "reviewing", "vetting":
|
|
183
|
-
return state.PhaseVet, true
|
|
184
|
-
case "seal", "sealed", "sealing":
|
|
185
|
-
return state.PhaseSeal, true
|
|
186
|
-
case "ship", "shipped", "done", "closed", "complete", "completed":
|
|
187
|
-
return state.PhaseShip, true
|
|
188
|
-
default:
|
|
189
|
-
return "", false
|
|
190
|
-
}
|
|
171
|
+
return state.PhaseForName(word)
|
|
191
172
|
}
|
|
192
173
|
|
|
193
174
|
// backupWorkspace snapshots mutable state (work/, features/, and the ACTIVE pointer) into
|
|
@@ -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
|
|
|
@@ -80,6 +80,13 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
func TestMapLegacyPhase(t *testing.T) {
|
|
83
|
+
for _, phase := range state.LifecyclePhases() {
|
|
84
|
+
got, ok := mapLegacyPhase(string(phase))
|
|
85
|
+
if !ok || got != phase {
|
|
86
|
+
t.Fatalf("mapLegacyPhase(%q)=(%q,%v), want canonical phase", phase, got, ok)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
83
90
|
for _, tc := range []struct {
|
|
84
91
|
word string
|
|
85
92
|
want state.Phase
|
|
@@ -87,7 +94,8 @@ func TestMapLegacyPhase(t *testing.T) {
|
|
|
87
94
|
}{
|
|
88
95
|
{word: "specced", want: state.PhaseSpec, ok: true},
|
|
89
96
|
{word: "in-progress", want: state.PhaseBuild, ok: true},
|
|
90
|
-
{word: "
|
|
97
|
+
{word: "reviewing", want: state.PhaseReview, ok: true},
|
|
98
|
+
{word: "done - shipped", want: state.PhaseDone, ok: true},
|
|
91
99
|
{word: "mystery"},
|
|
92
100
|
} {
|
|
93
101
|
got, ok := mapLegacyPhase(tc.word)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Command workflowmanifest writes the deterministic cross-language derivative
|
|
2
|
+
// of state.WorkflowPhases. The typed Go registry remains the authority.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"flag"
|
|
9
|
+
"fmt"
|
|
10
|
+
"os"
|
|
11
|
+
|
|
12
|
+
"github.com/devrites/devrites/internal/state"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func main() {
|
|
16
|
+
outPath := flag.String("out", "workflow_manifest.json", "output path")
|
|
17
|
+
check := flag.Bool("check", false, "fail when the output is stale")
|
|
18
|
+
flag.Parse()
|
|
19
|
+
|
|
20
|
+
document := struct {
|
|
21
|
+
GeneratedBy string `json:"generatedBy"`
|
|
22
|
+
SchemaVersion int `json:"schemaVersion"`
|
|
23
|
+
Phases []state.WorkflowPhase `json:"phases"`
|
|
24
|
+
CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
|
|
25
|
+
}{
|
|
26
|
+
GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
|
|
27
|
+
SchemaVersion: state.SchemaVersion,
|
|
28
|
+
Phases: state.WorkflowPhases(),
|
|
29
|
+
CursorKeyAliases: state.CursorKeyAliases(),
|
|
30
|
+
}
|
|
31
|
+
data, err := json.MarshalIndent(document, "", " ")
|
|
32
|
+
if err != nil {
|
|
33
|
+
fatal(err)
|
|
34
|
+
}
|
|
35
|
+
data = append(data, '\n')
|
|
36
|
+
if *check {
|
|
37
|
+
current, err := os.ReadFile(*outPath)
|
|
38
|
+
if err != nil {
|
|
39
|
+
fatal(err)
|
|
40
|
+
}
|
|
41
|
+
if !bytes.Equal(current, data) {
|
|
42
|
+
fatal(fmt.Errorf("%s is stale; run go generate ./internal/state", *outPath))
|
|
43
|
+
}
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if err := os.WriteFile(*outPath, data, 0o644); err != nil {
|
|
47
|
+
fatal(err)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func fatal(err error) {
|
|
52
|
+
fmt.Fprintln(os.Stderr, err)
|
|
53
|
+
os.Exit(1)
|
|
54
|
+
}
|