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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -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_events_test.go +15 -0
  9. package/engine/hooks_workspace.go +8 -31
  10. package/engine/internal/gate/gate.go +6 -11
  11. package/engine/internal/gate/gate_test.go +18 -2
  12. package/engine/internal/lib/buildreadiness.go +10 -16
  13. package/engine/internal/lib/cursor_compat_test.go +120 -0
  14. package/engine/internal/lib/preamble.go +1 -1
  15. package/engine/internal/lib/preamble_questions_test.go +10 -0
  16. package/engine/internal/lib/progress.go +69 -29
  17. package/engine/internal/lib/resolve.go +21 -17
  18. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  19. package/engine/internal/lib/tickafk.go +11 -28
  20. package/engine/internal/migrate/migrate.go +13 -32
  21. package/engine/internal/migrate/migrate_test.go +11 -3
  22. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  23. package/engine/internal/state/cursor.go +119 -0
  24. package/engine/internal/state/cursor_test.go +58 -0
  25. package/engine/internal/state/feature.go +25 -48
  26. package/engine/internal/state/schema.go +151 -24
  27. package/engine/internal/state/snapshot.go +60 -3
  28. package/engine/internal/state/state_test.go +119 -3
  29. package/engine/internal/state/workflow_manifest.json +310 -0
  30. package/engine/internal/workflow/commands.go +10 -29
  31. package/engine/internal/workflow/commands_test.go +18 -0
  32. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  33. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  34. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  35. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  36. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  37. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  38. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  39. package/engine/tests/adr_0004_required_by_phase_test.go +1 -12
  40. package/engine/tests/gate_test.go +22 -0
  41. package/engine/tests/lib_parity_test.go +1 -1
  42. package/engine/tests/migrate_cli_test.go +3 -3
  43. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  44. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  45. package/pack/generated/codex/AGENTS.md +1 -1
  46. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  47. package/package.json +1 -1
  48. package/scripts/codex-generate.sh +1 -1
  49. package/scripts/grade-feature.sh +5 -3
  50. package/scripts/run-outcome-evals.sh +21 -0
  51. package/scripts/validate-workspace-schema.py +9 -73
  52. package/scripts/validate.sh +10 -0
  53. package/scripts/workflow_schema.py +69 -0
@@ -0,0 +1,119 @@
1
+ package state
2
+
3
+ import (
4
+ "sort"
5
+ "strings"
6
+ "unicode"
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
+
46
+ type cursorLineKind uint8
47
+
48
+ const (
49
+ cursorLineUnknown cursorLineKind = iota
50
+ cursorLineLegacy
51
+ cursorLineTable
52
+ )
53
+
54
+ // CursorField reads a state.md field from either the canonical | key | value |
55
+ // cursor table or the legacy "- Key: value" form.
56
+ func CursorField(lines []string, key string) (string, bool) {
57
+ want := normalizeCursorKey(key)
58
+ for _, line := range lines {
59
+ gotKey, value, _, ok := parseCursorLine(line)
60
+ if ok && normalizeCursorKey(gotKey) == want {
61
+ return value, true
62
+ }
63
+ }
64
+ return "", false
65
+ }
66
+
67
+ // SetCursorField replaces an existing state.md field without changing whether
68
+ // the file uses the canonical table or legacy bullet form.
69
+ func SetCursorField(lines []string, key, value string) ([]string, bool) {
70
+ want := normalizeCursorKey(key)
71
+ out := append([]string(nil), lines...)
72
+ for i, line := range out {
73
+ gotKey, _, kind, ok := parseCursorLine(line)
74
+ if !ok || normalizeCursorKey(gotKey) != want {
75
+ continue
76
+ }
77
+ switch kind {
78
+ case cursorLineTable:
79
+ indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
80
+ out[i] = indent + "| " + strings.TrimSpace(gotKey) + " | " + value + " |"
81
+ case cursorLineLegacy:
82
+ colon := strings.IndexByte(line, ':')
83
+ out[i] = line[:colon+1] + " " + value
84
+ }
85
+ return out, true
86
+ }
87
+ return out, false
88
+ }
89
+
90
+ func parseCursorLine(line string) (key, value string, kind cursorLineKind, ok bool) {
91
+ trimmed := strings.TrimSpace(line)
92
+ if len(trimmed) >= 2 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|' {
93
+ cells := strings.Split(trimmed[1:len(trimmed)-1], "|")
94
+ if len(cells) >= 2 {
95
+ return strings.TrimSpace(cells[0]), strings.TrimSpace(cells[1]), cursorLineTable, true
96
+ }
97
+ }
98
+
99
+ trimmed = strings.TrimLeft(trimmed, "-*+ \t")
100
+ key, value, ok = strings.Cut(trimmed, ":")
101
+ if !ok || strings.TrimSpace(key) == "" {
102
+ return "", "", cursorLineUnknown, false
103
+ }
104
+ return strings.TrimSpace(key), strings.TrimSpace(value), cursorLineLegacy, true
105
+ }
106
+
107
+ func normalizeCursorKey(key string) string {
108
+ var b strings.Builder
109
+ for _, r := range strings.ToLower(key) {
110
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
111
+ b.WriteRune(r)
112
+ }
113
+ }
114
+ normalized := b.String()
115
+ if canonical, ok := cursorKeyAliases[normalized]; ok {
116
+ return normalizeCursorKey(canonical)
117
+ }
118
+ return normalized
119
+ }
@@ -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
+ }
@@ -108,27 +108,11 @@ 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
- // LoadFeature reads feature <slug> under root. The phase comes from the manifest
128
- // (feature.md) frontmatter when present, otherwise from the working-state ledger
129
- // (state.md, a "- Phase: <p>" line) the live pack writes. A feature with neither a
130
- // manifest nor a ledger does not exist. Section content is read from the canonical
131
- // section files or their transitional aliases (see sectionFiles).
111
+ // LoadFeature reads feature <slug> under root. The phase comes from the live
112
+ // working-state ledger when it declares one, otherwise from feature.md
113
+ // frontmatter. A feature with neither a manifest nor a ledger does not exist.
114
+ // Section content is read from the canonical section files or their transitional
115
+ // aliases (see sectionFiles).
132
116
  func LoadFeature(root, slug string) (*Feature, error) {
133
117
  dir := featureDir(root, slug)
134
118
 
@@ -142,7 +126,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
142
126
  return nil, fmt.Errorf("feature %q not found", slug)
143
127
  }
144
128
 
145
- var phase Phase
129
+ var manifestPhase Phase
146
130
  if hasManifest {
147
131
  fm, _ := splitFrontmatter(manifest)
148
132
  if v := strings.TrimSpace(fm["schemaVersion"]); v != "" {
@@ -154,12 +138,16 @@ func LoadFeature(root, slug string) (*Feature, error) {
154
138
  return nil, fmt.Errorf("feature %q: schemaVersion %d is newer than this engine supports (%d); upgrade devrites", slug, n, SchemaVersion)
155
139
  }
156
140
  }
157
- phase = Phase(strings.TrimSpace(fm["phase"]))
141
+ manifestPhase = Phase(strings.TrimSpace(fm["phase"]))
158
142
  }
159
- // Ledger fallback: only when the manifest did not declare a phase, so a manifest
160
- // with an explicit-but-unknown phase still surfaces as that error below.
161
- if phase == "" {
162
- phase = PhaseFromLedger(filepath.Join(dir, LedgerFile))
143
+
144
+ // state.md is the mutable runtime cursor, so it must win over the manifest,
145
+ // which migration and compatibility flows may leave stale. Preserve an
146
+ // explicitly unknown ledger value so the validation below reports it rather
147
+ // than silently falling back to feature.md.
148
+ phase, ledgerDeclared := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
149
+ if !ledgerDeclared {
150
+ phase = manifestPhase
163
151
  }
164
152
  if phase == "" {
165
153
  return nil, fmt.Errorf("feature %q: no phase in feature.md frontmatter or %s ledger", slug, LedgerFile)
@@ -186,31 +174,20 @@ func sectionPresentAny(dir string, s Section) bool {
186
174
  return false
187
175
  }
188
176
 
189
- // PhaseFromLedger reads a phase from the working-state ledger: the value of a
190
- // "- Phase: <p>" line (leading list markers tolerated), taking the first token
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.
194
- func PhaseFromLedger(path string) Phase {
177
+ func declaredPhaseFromLedger(path string) (Phase, bool) {
195
178
  raw, err := os.ReadFile(path)
196
179
  if err != nil {
197
- return ""
180
+ return "", false
198
181
  }
199
- for _, line := range strings.Split(string(raw), "\n") {
200
- t := strings.TrimLeft(strings.TrimSpace(line), "-*+ \t")
201
- key, val, ok := strings.Cut(t, ":")
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
- }
182
+ value, ok := CursorField(strings.Split(string(raw), "\n"), CursorPhase)
183
+ if !ok {
184
+ return "", false
185
+ }
186
+ word := strings.ToLower(strings.TrimSpace(value))
187
+ if i := strings.IndexAny(word, " \t"); i > 0 {
188
+ word = word[:i]
212
189
  }
213
- return ""
190
+ return Phase(word), true
214
191
  }
215
192
 
216
193
  // ReadDeclaredSchemaVersion returns the schemaVersion declared in a feature's
@@ -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
@@ -46,48 +48,173 @@ var sectionFiles = map[Section][]string{
46
48
  }
47
49
 
48
50
  // LedgerFile is the working-state ledger the live pack writes. It carries the
49
- // phase (as a "- Phase: <p>" line) when no feature.md manifest declares one, and
50
- // it satisfies the status section.
51
+ // phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
52
+ // when no feature.md manifest declares one, and it satisfies the status section.
51
53
  const LedgerFile = "state.md"
52
54
 
53
55
  // Phase is a workflow state. The order mirrors the rite-* arc.
54
56
  type Phase string
55
57
 
56
58
  const (
57
- PhaseFrame Phase = "frame" // problem framing
58
- PhaseSpec Phase = "spec" // specification
59
- PhasePlan Phase = "plan" // planning
60
- PhaseBuild Phase = "build" // implementation
61
- PhaseProve Phase = "prove" // proof / testing
62
- PhaseVet Phase = "vet" // review
63
- PhaseSeal Phase = "seal" // completeness seal
64
- PhaseShip Phase = "ship" // shipping
59
+ PhaseFrame Phase = "frame" // problem framing
60
+ PhaseSpec Phase = "spec" // specification
61
+ PhaseTemper Phase = "temper" // strategic specification review
62
+ PhaseDefine Phase = "define" // plan definition
63
+ PhasePlan Phase = "plan" // approved plan
64
+ PhaseVet Phase = "vet" // pre-build engineering review
65
+ PhaseBuild Phase = "build" // implementation
66
+ PhaseConverge Phase = "converge" // post-build gap closure
67
+ PhaseProve Phase = "prove" // proof / testing
68
+ PhasePolish Phase = "polish" // quality pass
69
+ PhaseReview Phase = "review" // post-proof review
70
+ PhaseSeal Phase = "seal" // completeness seal
71
+ PhaseShip Phase = "ship" // shipping
72
+ PhaseDone Phase = "done" // archived completion
73
+ )
74
+
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")
65
101
  )
66
102
 
67
- // requiredByPhase lists the sections that must have real content to complete a
68
- // phase. Completeness is phase-relative: a section not yet required (e.g. proof
69
- // during the spec phase) never blocks. The set is additive down the arc.
70
- var requiredByPhase = map[Phase][]Section{
71
- PhaseFrame: {},
72
- PhaseSpec: {SectionSpec},
73
- PhasePlan: {SectionSpec, SectionPlan},
74
- PhaseBuild: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks},
75
- PhaseProve: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
76
- PhaseVet: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof},
77
- PhaseSeal: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
78
- PhaseShip: {SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus},
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
79
170
  }
80
171
 
81
172
  // KnownPhase reports whether p is a phase the engine understands.
82
173
  func KnownPhase(p Phase) bool {
83
- _, ok := requiredByPhase[p]
174
+ _, ok := definitionFor(p)
84
175
  return ok
85
176
  }
86
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
+
87
210
  // RequiredSections returns the sections required to complete the given phase,
88
211
  // in canonical Sections order.
89
212
  func RequiredSections(p Phase) []Section {
90
- want := requiredByPhase[p]
213
+ definition, ok := definitionFor(p)
214
+ if !ok {
215
+ return nil
216
+ }
217
+ want := definition.required
91
218
  set := make(map[Section]bool, len(want))
92
219
  for _, s := range want {
93
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
 
@@ -121,7 +122,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
121
122
  }
122
123
 
123
124
  workDir := featureDir(root, report.Slug)
124
- next := workflow.ForPhase(string(report.Phase))
125
+ next := nextCommand(workDir, report.Phase)
125
126
  snap := &WorkspaceSnapshot{
126
127
  SchemaVersion: WorkspaceSnapshotSchema,
127
128
  Slug: report.Slug,
@@ -152,7 +153,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
152
153
  if snap.RunMode == "AFK" && len(snap.MissingSections) > 0 {
153
154
  snap.Warnings = append(snap.Warnings, "AFK workspace has missing required sections")
154
155
  }
155
- if snap.Evidence.Status == "missing" && (report.Phase == PhaseProve || report.Phase == PhaseSeal || report.Phase == PhaseShip) {
156
+ if report.Required[SectionProof] && snap.Evidence.Status != "fresh" {
156
157
  snap.Warnings = append(snap.Warnings, "proof phase requires fresh evidence")
157
158
  }
158
159
  if snap.Drift.Open > 0 {
@@ -161,6 +162,17 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
161
162
  return snap, nil
162
163
  }
163
164
 
165
+ func nextCommand(workDir string, phase Phase) workflow.Command {
166
+ if raw, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
167
+ if action, ok := CursorField(strings.Split(string(raw), "\n"), CursorNextAction); ok {
168
+ if command := workflow.ForAction(action); command.Verb != "" {
169
+ return command
170
+ }
171
+ }
172
+ }
173
+ return workflow.ForVerb(ResumeVerb(phase))
174
+ }
175
+
164
176
  func readActiveSlug(root string) string {
165
177
  if ws := devritespaths.WorkspaceOverride(root, ""); ws != "" {
166
178
  return filepath.Base(ws)
@@ -180,6 +192,22 @@ func runMode(root string) string {
180
192
  }
181
193
 
182
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
+ }
183
211
  for _, name := range []string{"state.md", "tasks.md"} {
184
212
  raw, err := os.ReadFile(filepath.Join(workDir, name))
185
213
  if err != nil {
@@ -260,6 +288,9 @@ func driftSummary(workDir string) DriftSnapshot {
260
288
  continue
261
289
  }
262
290
  open := countOpenMarkers(string(raw))
291
+ if name == "questions.md" {
292
+ open = countOpenQuestions(string(raw))
293
+ }
263
294
  if open > 0 {
264
295
  return DriftSnapshot{Status: "open", Open: open}
265
296
  }
@@ -267,11 +298,37 @@ func driftSummary(workDir string) DriftSnapshot {
267
298
  return DriftSnapshot{Status: "clear"}
268
299
  }
269
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
+
270
327
  func countOpenMarkers(text string) int {
271
328
  open := 0
272
329
  for _, line := range strings.Split(text, "\n") {
273
330
  l := strings.ToLower(strings.TrimSpace(line))
274
- if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]") || strings.Contains(l, "gate: blocking") {
331
+ if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]") {
275
332
  open++
276
333
  }
277
334
  }