devrites 3.0.2 → 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 +6 -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_workspace.go +7 -7
- package/engine/internal/gate/gate.go +4 -4
- package/engine/internal/gate/gate_test.go +2 -2
- package/engine/internal/lib/cursor_compat_test.go +29 -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 +59 -20
- package/engine/internal/lib/resolve.go +15 -6
- package/engine/internal/lib/resolve_remediation_test.go +9 -0
- package/engine/internal/lib/tickafk.go +2 -2
- package/engine/internal/migrate/migrate.go +2 -33
- package/engine/internal/migrate/migrate_test.go +7 -1
- package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
- package/engine/internal/state/cursor.go +42 -7
- package/engine/internal/state/feature.go +1 -27
- package/engine/internal/state/schema.go +135 -20
- package/engine/internal/state/snapshot.go +49 -3
- package/engine/internal/state/state_test.go +71 -0
- package/engine/internal/state/workflow_manifest.json +310 -0
- package/engine/internal/workflow/commands.go +0 -36
- package/engine/internal/workflow/commands_test.go +0 -17
- 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 -18
- package/engine/tests/lib_parity_test.go +1 -1
- 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
|
@@ -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()
|
|
@@ -65,7 +65,7 @@ func TickAfk(args []string, stdout, stderr io.Writer) int {
|
|
|
65
65
|
// readBudget returns the current "AFK slices remaining" value — the first blank-
|
|
66
66
|
// delimited token after the field — and whether the field is present at all.
|
|
67
67
|
func readBudget(lines []string) (value string, found bool) {
|
|
68
|
-
value, found = state.CursorField(lines,
|
|
68
|
+
value, found = state.CursorField(lines, state.CursorAFKSlicesRemaining)
|
|
69
69
|
if !found {
|
|
70
70
|
return "", false
|
|
71
71
|
}
|
|
@@ -77,6 +77,6 @@ func readBudget(lines []string) (value string, found bool) {
|
|
|
77
77
|
|
|
78
78
|
// setBudget preserves the cursor's canonical-table or legacy-bullet format.
|
|
79
79
|
func setBudget(lines []string, n int) []byte {
|
|
80
|
-
updated, _ := state.SetCursorField(lines,
|
|
80
|
+
updated, _ := state.SetCursorField(lines, state.CursorAFKSlicesRemaining, strconv.Itoa(n))
|
|
81
81
|
return []byte(strings.Join(updated, "\n") + "\n")
|
|
82
82
|
}
|
|
@@ -151,7 +151,7 @@ func derivePhase(statePath string) state.Phase {
|
|
|
151
151
|
return state.PhaseBuild
|
|
152
152
|
}
|
|
153
153
|
lines := strings.Split(string(raw), "\n")
|
|
154
|
-
for _, key := range []string{
|
|
154
|
+
for _, key := range []string{state.CursorPhase, state.CursorStatus} {
|
|
155
155
|
if value, found := state.CursorField(lines, key); found {
|
|
156
156
|
if p, ok := mapLegacyPhase(value); ok {
|
|
157
157
|
return p
|
|
@@ -168,38 +168,7 @@ func mapLegacyPhase(word string) (state.Phase, bool) {
|
|
|
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 "temper", "tempered", "tempering":
|
|
177
|
-
return state.PhaseTemper, true
|
|
178
|
-
case "define", "defined", "defining":
|
|
179
|
-
return state.PhaseDefine, true
|
|
180
|
-
case "plan", "planned", "planning":
|
|
181
|
-
return state.PhasePlan, true
|
|
182
|
-
case "vet", "vetted", "vetting":
|
|
183
|
-
return state.PhaseVet, true
|
|
184
|
-
case "build", "building", "wip", "in", "in-progress":
|
|
185
|
-
return state.PhaseBuild, true
|
|
186
|
-
case "converge", "converged", "converging":
|
|
187
|
-
return state.PhaseConverge, true
|
|
188
|
-
case "prove", "proving", "proven", "testing":
|
|
189
|
-
return state.PhaseProve, true
|
|
190
|
-
case "polish", "polished", "polishing":
|
|
191
|
-
return state.PhasePolish, true
|
|
192
|
-
case "review", "reviewed", "reviewing":
|
|
193
|
-
return state.PhaseReview, true
|
|
194
|
-
case "seal", "sealed", "sealing":
|
|
195
|
-
return state.PhaseSeal, true
|
|
196
|
-
case "ship", "shipped", "shipping":
|
|
197
|
-
return state.PhaseShip, true
|
|
198
|
-
case "done", "closed", "complete", "completed":
|
|
199
|
-
return state.PhaseDone, true
|
|
200
|
-
default:
|
|
201
|
-
return "", false
|
|
202
|
-
}
|
|
171
|
+
return state.PhaseForName(word)
|
|
203
172
|
}
|
|
204
173
|
|
|
205
174
|
// backupWorkspace snapshots mutable state (work/, features/, and the ACTIVE pointer) into
|
|
@@ -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,6 @@ 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: "temper", want: state.PhaseTemper, ok: true},
|
|
91
97
|
{word: "reviewing", want: state.PhaseReview, ok: true},
|
|
92
98
|
{word: "done - shipped", want: state.PhaseDone, ok: true},
|
|
93
99
|
{word: "mystery"},
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
package state
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"sort"
|
|
4
5
|
"strings"
|
|
5
6
|
"unicode"
|
|
6
7
|
)
|
|
7
8
|
|
|
9
|
+
// Canonical cursor keys. Callers use these constants so schema spelling changes
|
|
10
|
+
// remain local to the cursor package; normalizeCursorKey still accepts legacy
|
|
11
|
+
// presentation aliases at the text boundary.
|
|
12
|
+
const (
|
|
13
|
+
CursorPhase = "phase"
|
|
14
|
+
CursorStatus = "status"
|
|
15
|
+
CursorNextAction = "next_action"
|
|
16
|
+
CursorQuestionID = "question_id"
|
|
17
|
+
CursorActiveSlice = "active_slice"
|
|
18
|
+
CursorAFKSlicesRemaining = "afk_slices_remaining"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
var cursorKeyAliases = map[string]string{
|
|
22
|
+
"nextstep": CursorNextAction,
|
|
23
|
+
"qid": CursorQuestionID,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// CursorKeyAlias is the generated-manifest view of a compatibility key.
|
|
27
|
+
type CursorKeyAlias struct {
|
|
28
|
+
Alias string `json:"alias"`
|
|
29
|
+
Canonical string `json:"canonical"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CursorKeyAliases returns compatibility keys in deterministic order.
|
|
33
|
+
func CursorKeyAliases() []CursorKeyAlias {
|
|
34
|
+
aliases := make([]string, 0, len(cursorKeyAliases))
|
|
35
|
+
for alias := range cursorKeyAliases {
|
|
36
|
+
aliases = append(aliases, alias)
|
|
37
|
+
}
|
|
38
|
+
sort.Strings(aliases)
|
|
39
|
+
out := make([]CursorKeyAlias, 0, len(aliases))
|
|
40
|
+
for _, alias := range aliases {
|
|
41
|
+
out = append(out, CursorKeyAlias{Alias: alias, Canonical: cursorKeyAliases[alias]})
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
8
46
|
type cursorLineKind uint8
|
|
9
47
|
|
|
10
48
|
const (
|
|
@@ -73,12 +111,9 @@ func normalizeCursorKey(key string) string {
|
|
|
73
111
|
b.WriteRune(r)
|
|
74
112
|
}
|
|
75
113
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
79
|
-
case "qid":
|
|
80
|
-
return "questionid"
|
|
81
|
-
default:
|
|
82
|
-
return b.String()
|
|
114
|
+
normalized := b.String()
|
|
115
|
+
if canonical, ok := cursorKeyAliases[normalized]; ok {
|
|
116
|
+
return normalizeCursorKey(canonical)
|
|
83
117
|
}
|
|
118
|
+
return normalized
|
|
84
119
|
}
|
|
@@ -108,22 +108,6 @@ func regularFileExists(path string) bool {
|
|
|
108
108
|
return err == nil && info.Mode().IsRegular()
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
// CandidateFiles returns the paths that make up feature <slug>: its feature.md
|
|
112
|
-
// manifest plus every file that can satisfy a section (canonical names and the
|
|
113
|
-
// transitional aliases), in a stable order. Not all need exist; callers that
|
|
114
|
-
// fingerprint the feature stat and hash the ones that do, so including the alias
|
|
115
|
-
// names means a change to either the canonical or the alias file is detected.
|
|
116
|
-
func CandidateFiles(root, slug string) []string {
|
|
117
|
-
dir := featureDir(root, slug)
|
|
118
|
-
files := []string{filepath.Join(dir, "feature.md")}
|
|
119
|
-
for _, s := range Sections {
|
|
120
|
-
for _, name := range sectionFiles[s] {
|
|
121
|
-
files = append(files, filepath.Join(dir, name))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return files
|
|
125
|
-
}
|
|
126
|
-
|
|
127
111
|
// LoadFeature reads feature <slug> under root. The phase comes from the live
|
|
128
112
|
// working-state ledger when it declares one, otherwise from feature.md
|
|
129
113
|
// frontmatter. A feature with neither a manifest nor a ledger does not exist.
|
|
@@ -190,22 +174,12 @@ func sectionPresentAny(dir string, s Section) bool {
|
|
|
190
174
|
return false
|
|
191
175
|
}
|
|
192
176
|
|
|
193
|
-
// PhaseFromLedger reads a phase from the canonical cursor table or the legacy
|
|
194
|
-
// "- Phase: <p>" form, accepting only phases the engine understands.
|
|
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
177
|
func declaredPhaseFromLedger(path string) (Phase, bool) {
|
|
204
178
|
raw, err := os.ReadFile(path)
|
|
205
179
|
if err != nil {
|
|
206
180
|
return "", false
|
|
207
181
|
}
|
|
208
|
-
value, ok := CursorField(strings.Split(string(raw), "\n"),
|
|
182
|
+
value, ok := CursorField(strings.Split(string(raw), "\n"), CursorPhase)
|
|
209
183
|
if !ok {
|
|
210
184
|
return "", false
|
|
211
185
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package state
|
|
2
2
|
|
|
3
|
+
//go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
|
|
4
|
+
|
|
3
5
|
// SchemaVersion is the .devrites state-schema version this engine understands.
|
|
4
6
|
// A feature.md may declare its own schemaVersion in frontmatter; the engine
|
|
5
7
|
// refuses a version newer than this (see LoadFeature) and otherwise reads the
|
|
@@ -70,36 +72,149 @@ const (
|
|
|
70
72
|
PhaseDone Phase = "done" // archived completion
|
|
71
73
|
)
|
|
72
74
|
|
|
73
|
-
//
|
|
74
|
-
// phase
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
75
|
+
// phaseDefinition is the single source of truth for lifecycle order and
|
|
76
|
+
// phase-specific behavior. Workflow topology is versioned application logic,
|
|
77
|
+
// rather than deploy-time configuration, so keeping it typed and ordered makes
|
|
78
|
+
// additions compile-visible and lets every consumer derive the same arc.
|
|
79
|
+
type phaseDefinition struct {
|
|
80
|
+
phase Phase
|
|
81
|
+
resumeVerb string
|
|
82
|
+
required []Section
|
|
83
|
+
aliases []string
|
|
84
|
+
workspaceRequired []string
|
|
85
|
+
proofRequired bool
|
|
86
|
+
blocksOpenQuestions bool
|
|
87
|
+
shippable bool
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var (
|
|
91
|
+
sectionsSpec = []Section{SectionSpec}
|
|
92
|
+
sectionsPlan = []Section{SectionSpec, SectionPlan}
|
|
93
|
+
sectionsBuild = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
|
|
94
|
+
sectionsProof = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
|
|
95
|
+
sectionsComplete = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
|
|
96
|
+
|
|
97
|
+
workspaceFrame = []string{"state.md"}
|
|
98
|
+
workspaceSpec = []string{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
99
|
+
workspacePlan = []string{"brief.md", "spec.md", "architecture.md", "plan.md", "tasks.md", "traceability.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
100
|
+
workspaceProof = append(append([]string(nil), workspacePlan...), "evidence.md", "touched-files.md")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// Completeness is phase-relative: a section not yet required (e.g. proof during
|
|
104
|
+
// spec) never blocks. Requirements are additive down the arc. Plan resumes at
|
|
105
|
+
// define because plan is the artifact state produced by rite-define; done has no
|
|
106
|
+
// resume command.
|
|
107
|
+
var phaseDefinitions = []phaseDefinition{
|
|
108
|
+
{phase: PhaseFrame, resumeVerb: "frame", workspaceRequired: workspaceFrame},
|
|
109
|
+
{phase: PhaseSpec, resumeVerb: "spec", required: sectionsSpec, aliases: []string{"specced", "specifying"}, workspaceRequired: workspaceSpec},
|
|
110
|
+
{phase: PhaseTemper, resumeVerb: "temper", required: sectionsSpec, aliases: []string{"tempered", "tempering"}, workspaceRequired: workspaceSpec},
|
|
111
|
+
{phase: PhaseDefine, resumeVerb: "define", required: sectionsPlan, aliases: []string{"defined", "defining"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
112
|
+
{phase: PhasePlan, resumeVerb: "define", required: sectionsPlan, aliases: []string{"planned", "planning"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
113
|
+
{phase: PhaseVet, resumeVerb: "vet", required: sectionsBuild, aliases: []string{"vetted", "vetting"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
114
|
+
{phase: PhaseBuild, resumeVerb: "build", required: sectionsBuild, aliases: []string{"building", "wip", "in", "in-progress"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
115
|
+
{phase: PhaseConverge, resumeVerb: "converge", required: sectionsBuild, aliases: []string{"converged", "converging"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
116
|
+
{phase: PhaseProve, resumeVerb: "prove", required: sectionsProof, aliases: []string{"proving", "proven", "testing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
117
|
+
{phase: PhasePolish, resumeVerb: "polish", required: sectionsProof, aliases: []string{"polished", "polishing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
118
|
+
{phase: PhaseReview, resumeVerb: "review", required: sectionsProof, aliases: []string{"reviewed", "reviewing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
119
|
+
{phase: PhaseSeal, resumeVerb: "seal", required: sectionsComplete, aliases: []string{"sealed", "sealing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
120
|
+
{phase: PhaseShip, resumeVerb: "ship", required: sectionsComplete, aliases: []string{"shipped", "shipping"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
121
|
+
{phase: PhaseDone, required: sectionsComplete, aliases: []string{"closed", "complete", "completed"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// WorkflowPhase is the read-only cross-format view used to generate the compact
|
|
125
|
+
// manifest consumed by non-Go release tooling.
|
|
126
|
+
type WorkflowPhase struct {
|
|
127
|
+
ID Phase `json:"id"`
|
|
128
|
+
ResumeVerb string `json:"resumeVerb,omitempty"`
|
|
129
|
+
Aliases []string `json:"aliases,omitempty"`
|
|
130
|
+
WorkspaceRequired []string `json:"workspaceRequired"`
|
|
131
|
+
ProofRequired bool `json:"proofRequired,omitempty"`
|
|
132
|
+
BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
|
|
133
|
+
Shippable bool `json:"shippable,omitempty"`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// WorkflowPhases returns copied metadata suitable for deterministic generation.
|
|
137
|
+
func WorkflowPhases() []WorkflowPhase {
|
|
138
|
+
out := make([]WorkflowPhase, 0, len(phaseDefinitions))
|
|
139
|
+
for _, definition := range phaseDefinitions {
|
|
140
|
+
out = append(out, WorkflowPhase{
|
|
141
|
+
ID: definition.phase,
|
|
142
|
+
ResumeVerb: definition.resumeVerb,
|
|
143
|
+
Aliases: append([]string(nil), definition.aliases...),
|
|
144
|
+
WorkspaceRequired: append([]string(nil), definition.workspaceRequired...),
|
|
145
|
+
ProofRequired: definition.proofRequired,
|
|
146
|
+
BlocksOpenQuestions: definition.blocksOpenQuestions,
|
|
147
|
+
Shippable: definition.shippable,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// LifecyclePhases returns the ordered lifecycle. The returned slice is a copy,
|
|
154
|
+
// so callers cannot mutate the registry.
|
|
155
|
+
func LifecyclePhases() []Phase {
|
|
156
|
+
phases := make([]Phase, len(phaseDefinitions))
|
|
157
|
+
for i, definition := range phaseDefinitions {
|
|
158
|
+
phases[i] = definition.phase
|
|
159
|
+
}
|
|
160
|
+
return phases
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func definitionFor(p Phase) (phaseDefinition, bool) {
|
|
164
|
+
for _, definition := range phaseDefinitions {
|
|
165
|
+
if definition.phase == p {
|
|
166
|
+
return definition, true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return phaseDefinition{}, false
|
|
91
170
|
}
|
|
92
171
|
|
|
93
172
|
// KnownPhase reports whether p is a phase the engine understands.
|
|
94
173
|
func KnownPhase(p Phase) bool {
|
|
95
|
-
_, ok :=
|
|
174
|
+
_, ok := definitionFor(p)
|
|
96
175
|
return ok
|
|
97
176
|
}
|
|
98
177
|
|
|
178
|
+
// ResumeVerb returns the public rite verb that resumes p. Terminal and unknown
|
|
179
|
+
// phases return an empty string.
|
|
180
|
+
func ResumeVerb(p Phase) string {
|
|
181
|
+
definition, ok := definitionFor(p)
|
|
182
|
+
if !ok {
|
|
183
|
+
return ""
|
|
184
|
+
}
|
|
185
|
+
return definition.resumeVerb
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ShippablePhase reports whether p is allowed to claim a sealed/shipped result.
|
|
189
|
+
func ShippablePhase(p Phase) bool {
|
|
190
|
+
definition, ok := definitionFor(p)
|
|
191
|
+
return ok && definition.shippable
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// PhaseForName resolves a canonical phase ID or compatibility alias. Callers
|
|
195
|
+
// should normalize surrounding syntax before querying it.
|
|
196
|
+
func PhaseForName(name string) (Phase, bool) {
|
|
197
|
+
for _, definition := range phaseDefinitions {
|
|
198
|
+
if string(definition.phase) == name {
|
|
199
|
+
return definition.phase, true
|
|
200
|
+
}
|
|
201
|
+
for _, alias := range definition.aliases {
|
|
202
|
+
if alias == name {
|
|
203
|
+
return definition.phase, true
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return "", false
|
|
208
|
+
}
|
|
209
|
+
|
|
99
210
|
// RequiredSections returns the sections required to complete the given phase,
|
|
100
211
|
// in canonical Sections order.
|
|
101
212
|
func RequiredSections(p Phase) []Section {
|
|
102
|
-
|
|
213
|
+
definition, ok := definitionFor(p)
|
|
214
|
+
if !ok {
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
want := definition.required
|
|
103
218
|
set := make(map[Section]bool, len(want))
|
|
104
219
|
for _, s := range want {
|
|
105
220
|
set[s] = true
|
|
@@ -23,6 +23,7 @@ var (
|
|
|
23
23
|
snapshotSliceStateRe = regexp.MustCompile(`[[:space:]]+(built|pending)[[:space:]]*$`)
|
|
24
24
|
snapshotSliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`)
|
|
25
25
|
snapshotSliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
|
|
26
|
+
snapshotSliceHeadingRe = regexp.MustCompile(`(?m)^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+.*)?$`)
|
|
26
27
|
snapshotTouchedPathRe = regexp.MustCompile("`([^`]+)`")
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -163,13 +164,13 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
|
|
|
163
164
|
|
|
164
165
|
func nextCommand(workDir string, phase Phase) workflow.Command {
|
|
165
166
|
if raw, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
166
|
-
if action, ok := CursorField(strings.Split(string(raw), "\n"),
|
|
167
|
+
if action, ok := CursorField(strings.Split(string(raw), "\n"), CursorNextAction); ok {
|
|
167
168
|
if command := workflow.ForAction(action); command.Verb != "" {
|
|
168
169
|
return command
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
|
-
return workflow.
|
|
173
|
+
return workflow.ForVerb(ResumeVerb(phase))
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
func readActiveSlug(root string) string {
|
|
@@ -191,6 +192,22 @@ func runMode(root string) string {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
func currentSlice(workDir string) *SliceSnapshot {
|
|
195
|
+
if rawState, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
196
|
+
if active, ok := CursorField(strings.Split(string(rawState), "\n"), CursorActiveSlice); ok {
|
|
197
|
+
fields := strings.Fields(active)
|
|
198
|
+
if len(fields) > 0 {
|
|
199
|
+
active = fields[0]
|
|
200
|
+
if rawTasks, taskErr := os.ReadFile(filepath.Join(workDir, "tasks.md")); taskErr == nil {
|
|
201
|
+
matches := snapshotSliceHeadingRe.FindAllStringSubmatch(string(rawTasks), -1)
|
|
202
|
+
for i, match := range matches {
|
|
203
|
+
if match[1] == active {
|
|
204
|
+
return &SliceSnapshot{Index: i + 1, Total: len(matches), Name: active, State: "pending"}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
194
211
|
for _, name := range []string{"state.md", "tasks.md"} {
|
|
195
212
|
raw, err := os.ReadFile(filepath.Join(workDir, name))
|
|
196
213
|
if err != nil {
|
|
@@ -271,6 +288,9 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
271
288
|
continue
|
|
272
289
|
}
|
|
273
290
|
open := countOpenMarkers(string(raw))
|
|
291
|
+
if name == "questions.md" {
|
|
292
|
+
open = countOpenQuestions(string(raw))
|
|
293
|
+
}
|
|
274
294
|
if open > 0 {
|
|
275
295
|
return DriftSnapshot{Status: "open", Open: open}
|
|
276
296
|
}
|
|
@@ -278,11 +298,37 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
278
298
|
return DriftSnapshot{Status: "clear"}
|
|
279
299
|
}
|
|
280
300
|
|
|
301
|
+
func countOpenQuestions(text string) int {
|
|
302
|
+
open := 0
|
|
303
|
+
inQuestion := false
|
|
304
|
+
status := ""
|
|
305
|
+
finalize := func() {
|
|
306
|
+
if inQuestion && status == "open" {
|
|
307
|
+
open++
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for _, line := range strings.Split(text, "\n") {
|
|
311
|
+
lower := strings.ToLower(strings.TrimSpace(line))
|
|
312
|
+
switch {
|
|
313
|
+
case strings.HasPrefix(lower, "## q-"):
|
|
314
|
+
finalize()
|
|
315
|
+
inQuestion, status = true, ""
|
|
316
|
+
case inQuestion && strings.HasPrefix(lower, "status:"):
|
|
317
|
+
status = strings.TrimSpace(strings.TrimPrefix(lower, "status:"))
|
|
318
|
+
case inQuestion && strings.HasPrefix(lower, "## "):
|
|
319
|
+
finalize()
|
|
320
|
+
inQuestion = false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finalize()
|
|
324
|
+
return open
|
|
325
|
+
}
|
|
326
|
+
|
|
281
327
|
func countOpenMarkers(text string) int {
|
|
282
328
|
open := 0
|
|
283
329
|
for _, line := range strings.Split(text, "\n") {
|
|
284
330
|
l := strings.ToLower(strings.TrimSpace(line))
|
|
285
|
-
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]")
|
|
331
|
+
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]") {
|
|
286
332
|
open++
|
|
287
333
|
}
|
|
288
334
|
}
|
|
@@ -29,6 +29,56 @@ func TestRequiredSectionsIsPhaseRelativeAndOrdered(t *testing.T) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
func TestLifecycleRegistryOwnsOrderAndResumeCommands(t *testing.T) {
|
|
33
|
+
phases := LifecyclePhases()
|
|
34
|
+
if len(phases) == 0 || phases[0] != PhaseFrame || phases[len(phases)-1] != PhaseDone {
|
|
35
|
+
t.Fatalf("LifecyclePhases()=%v, want frame...done", phases)
|
|
36
|
+
}
|
|
37
|
+
if got := ResumeVerb(PhasePlan); got != "define" {
|
|
38
|
+
t.Fatalf("ResumeVerb(plan)=%q, want define", got)
|
|
39
|
+
}
|
|
40
|
+
if got := ResumeVerb(PhaseDone); got != "" {
|
|
41
|
+
t.Fatalf("ResumeVerb(done)=%q, want empty", got)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
phases[0] = PhaseDone
|
|
45
|
+
if got := LifecyclePhases()[0]; got != PhaseFrame {
|
|
46
|
+
t.Fatalf("LifecyclePhases exposed mutable registry: first=%q", got)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestLifecycleRegistryInvariants(t *testing.T) {
|
|
51
|
+
phaseNames := map[Phase]bool{}
|
|
52
|
+
aliases := map[string]Phase{}
|
|
53
|
+
for i, definition := range phaseDefinitions {
|
|
54
|
+
if definition.phase == "" || phaseNames[definition.phase] {
|
|
55
|
+
t.Fatalf("phase definition %d has empty or duplicate ID %q", i, definition.phase)
|
|
56
|
+
}
|
|
57
|
+
phaseNames[definition.phase] = true
|
|
58
|
+
if len(definition.workspaceRequired) == 0 {
|
|
59
|
+
t.Fatalf("phase %q has no workspace requirements", definition.phase)
|
|
60
|
+
}
|
|
61
|
+
for _, alias := range definition.aliases {
|
|
62
|
+
if alias == "" || aliases[alias] != "" || KnownPhase(Phase(alias)) {
|
|
63
|
+
t.Fatalf("phase %q has empty or duplicate alias %q", definition.phase, alias)
|
|
64
|
+
}
|
|
65
|
+
aliases[alias] = definition.phase
|
|
66
|
+
}
|
|
67
|
+
for _, section := range definition.required {
|
|
68
|
+
known := false
|
|
69
|
+
for _, canonical := range Sections {
|
|
70
|
+
known = known || section == canonical
|
|
71
|
+
}
|
|
72
|
+
if !known {
|
|
73
|
+
t.Fatalf("phase %q requires unknown section %q", definition.phase, section)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if definition.shippable && !definition.proofRequired {
|
|
77
|
+
t.Fatalf("shippable phase %q does not require proof", definition.phase)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
32
82
|
func TestStatusFixtureBuildIncomplete(t *testing.T) {
|
|
33
83
|
rep, err := Status(fixtureRoot, "auth-tokens")
|
|
34
84
|
if err != nil {
|
|
@@ -172,6 +222,27 @@ func TestSnapshotUsesCanonicalNextActionAndWarnsWhenRequiredProofMissing(t *test
|
|
|
172
222
|
}
|
|
173
223
|
}
|
|
174
224
|
|
|
225
|
+
func TestSnapshotReadsCanonicalActiveSliceAndCountsQuestionsByRecord(t *testing.T) {
|
|
226
|
+
workDir := t.TempDir()
|
|
227
|
+
if err := os.WriteFile(filepath.Join(workDir, "state.md"), []byte("| phase | build |\n| active_slice | SLICE-002 |\n"), 0o644); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if err := os.WriteFile(filepath.Join(workDir, "tasks.md"), []byte("## SLICE-001 First\n\n## SLICE-002 Second\n"), 0o644); err != nil {
|
|
231
|
+
t.Fatal(err)
|
|
232
|
+
}
|
|
233
|
+
if err := os.WriteFile(filepath.Join(workDir, "questions.md"), []byte("## Q-001\nstatus: open\ngate: blocking\n\n## Q-002\nstatus: answered\ngate: blocking\n"), 0o644); err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
slice := currentSlice(workDir)
|
|
238
|
+
if slice == nil || slice.Name != "SLICE-002" || slice.Index != 2 || slice.Total != 2 {
|
|
239
|
+
t.Fatalf("currentSlice=%+v, want canonical SLICE-002 at 2/2", slice)
|
|
240
|
+
}
|
|
241
|
+
if drift := driftSummary(workDir); drift.Status != "open" || drift.Open != 1 {
|
|
242
|
+
t.Fatalf("driftSummary=%+v, want one open question record", drift)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
175
246
|
func TestWorkLayoutIsCanonicalAndFeaturesIsAlias(t *testing.T) {
|
|
176
247
|
root := filepath.Join(t.TempDir(), ".devrites")
|
|
177
248
|
writeWorkSection(t, root, "live", "state.md", "- Phase: build\n")
|