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.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0004-state-schema-phases-sections.md +44 -0
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/state-schema.md +15 -12
  6. package/docs/flow.md +1 -1
  7. package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
  8. package/engine/hooks_workspace.go +7 -7
  9. package/engine/internal/gate/gate.go +4 -4
  10. package/engine/internal/gate/gate_test.go +2 -2
  11. package/engine/internal/lib/cursor_compat_test.go +29 -0
  12. package/engine/internal/lib/preamble.go +1 -1
  13. package/engine/internal/lib/preamble_questions_test.go +10 -0
  14. package/engine/internal/lib/progress.go +59 -20
  15. package/engine/internal/lib/resolve.go +15 -6
  16. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  17. package/engine/internal/lib/tickafk.go +2 -2
  18. package/engine/internal/migrate/migrate.go +2 -33
  19. package/engine/internal/migrate/migrate_test.go +7 -1
  20. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  21. package/engine/internal/state/cursor.go +42 -7
  22. package/engine/internal/state/feature.go +1 -27
  23. package/engine/internal/state/schema.go +135 -20
  24. package/engine/internal/state/snapshot.go +49 -3
  25. package/engine/internal/state/state_test.go +71 -0
  26. package/engine/internal/state/workflow_manifest.json +310 -0
  27. package/engine/internal/workflow/commands.go +0 -36
  28. package/engine/internal/workflow/commands_test.go +0 -17
  29. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  30. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  31. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  32. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  33. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  34. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  35. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  36. package/engine/tests/adr_0004_required_by_phase_test.go +1 -18
  37. package/engine/tests/lib_parity_test.go +1 -1
  38. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  39. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  40. package/pack/generated/codex/AGENTS.md +1 -1
  41. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  42. package/package.json +1 -1
  43. package/scripts/codex-generate.sh +1 -1
  44. package/scripts/grade-feature.sh +5 -3
  45. package/scripts/run-outcome-evals.sh +21 -0
  46. package/scripts/validate-workspace-schema.py +9 -73
  47. package/scripts/validate.sh +10 -0
  48. 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, "afk_slices_remaining")
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, "afk_slices_remaining", strconv.Itoa(n))
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{"phase", "status"} {
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
- switch word {
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
- switch b.String() {
77
- case "nextstep":
78
- return "nextaction"
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"), "phase")
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
- // requiredByPhase lists the sections that must have real content to complete a
74
- // phase. Completeness is phase-relative: a section not yet required (e.g. proof
75
- // during the spec phase) never blocks. The set is additive down the arc.
76
- var requiredByPhase = map[Phase][]Section{
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},
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 := requiredByPhase[p]
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
- want := requiredByPhase[p]
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"), "next_action"); ok {
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.ForPhase(string(phase))
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, "- [ ]") || strings.Contains(l, "gate: blocking") {
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")