devrites 3.0.2 → 3.0.4

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 (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
  4. package/docs/adr/0002-dual-host-harness.md +34 -0
  5. package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
  6. package/docs/adr/0004-state-schema-phases-sections.md +47 -0
  7. package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
  8. package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
  9. package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
  10. package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
  11. package/docs/adr/README.md +58 -0
  12. package/docs/command-map.md +1 -1
  13. package/docs/engine/state-schema.md +18 -14
  14. package/docs/flow.md +1 -1
  15. package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
  16. package/engine/hooks_workspace.go +7 -7
  17. package/engine/internal/gate/gate.go +4 -4
  18. package/engine/internal/gate/gate_test.go +2 -2
  19. package/engine/internal/lib/cursor_compat_test.go +29 -0
  20. package/engine/internal/lib/preamble.go +1 -1
  21. package/engine/internal/lib/preamble_questions_test.go +10 -0
  22. package/engine/internal/lib/progress.go +59 -20
  23. package/engine/internal/lib/resolve.go +15 -6
  24. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  25. package/engine/internal/lib/tickafk.go +2 -2
  26. package/engine/internal/migrate/migrate.go +18 -44
  27. package/engine/internal/migrate/migrate_test.go +33 -5
  28. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  29. package/engine/internal/state/cursor.go +42 -7
  30. package/engine/internal/state/feature.go +29 -41
  31. package/engine/internal/state/schema.go +155 -28
  32. package/engine/internal/state/snapshot.go +49 -3
  33. package/engine/internal/state/state_test.go +77 -6
  34. package/engine/internal/state/workflow_manifest.json +310 -0
  35. package/engine/internal/workflow/commands.go +0 -36
  36. package/engine/internal/workflow/commands_test.go +0 -17
  37. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  38. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  39. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  40. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  41. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  42. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  43. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  44. package/engine/tests/adr_0004_required_by_phase_test.go +1 -18
  45. package/engine/tests/budget_test.go +2 -2
  46. package/engine/tests/lib_parity_test.go +1 -1
  47. package/engine/tests/meta_test.go +3 -3
  48. package/engine/tests/migrate_cli_test.go +24 -22
  49. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  50. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  51. package/pack/generated/codex/AGENTS.md +1 -1
  52. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  53. package/package.json +2 -1
  54. package/scripts/codex-generate.sh +1 -1
  55. package/scripts/grade-feature.sh +5 -3
  56. package/scripts/run-outcome-evals.sh +21 -0
  57. package/scripts/validate-workflow-security.py +3 -8
  58. package/scripts/validate-workspace-schema.py +9 -73
  59. package/scripts/validate.sh +10 -0
  60. package/scripts/workflow_schema.py +69 -0
@@ -1,6 +1,7 @@
1
1
  package migrate
2
2
 
3
3
  import (
4
+ "os"
4
5
  "path/filepath"
5
6
  "strings"
6
7
  "testing"
@@ -33,8 +34,11 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
33
34
  }
34
35
 
35
36
  assertFile(t, root, "work/alpha/spec.md", "spec\n")
36
- assertFile(t, root, "work/alpha/proof.md", "proof\n")
37
- assertFile(t, root, "work/alpha/status.md", "status: done - shipped\n")
37
+ assertFile(t, root, "work/alpha/evidence.md", "proof\n")
38
+ assertFile(t, root, "work/alpha/state.md", "status: done - shipped\n")
39
+ assertFileContains(t, root, "work/alpha/README.md", "phase: done")
40
+ assertMissing(t, root, "work/alpha/proof.md")
41
+ assertMissing(t, root, "work/alpha/status.md")
38
42
  assertFile(t, root, "work/alpha/review.md", "review\n")
39
43
 
40
44
  f, err := state.LoadFeature(root, "alpha")
@@ -57,8 +61,9 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
57
61
 
58
62
  func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
59
63
  root := t.TempDir()
60
- testutil.WriteFile(t, filepath.Join(root, "features/beta/state.md"), "- Phase: prove\n")
61
- testutil.WriteFile(t, filepath.Join(root, "features/beta/evidence.md"), "evidence\n")
64
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/feature.md"), "# Beta\n")
65
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/status.md"), "- Phase: prove\n")
66
+ testutil.WriteFile(t, filepath.Join(root, "features/beta/proof.md"), "evidence\n")
62
67
 
63
68
  res, err := Run(root)
64
69
  if err != nil {
@@ -67,6 +72,9 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
67
72
  if got := strings.Join(res.Migrated, ","); got != "beta" {
68
73
  t.Fatalf("Run normalized=%q, want beta", got)
69
74
  }
75
+ assertFile(t, root, "features/beta/README.md", "# Beta\n")
76
+ assertFile(t, root, "features/beta/state.md", "- Phase: prove\n")
77
+ assertFile(t, root, "features/beta/evidence.md", "evidence\n")
70
78
  assertFile(t, root, "features/beta/status.md", "- Phase: prove\n")
71
79
  assertFile(t, root, "features/beta/proof.md", "evidence\n")
72
80
 
@@ -80,6 +88,13 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
80
88
  }
81
89
 
82
90
  func TestMapLegacyPhase(t *testing.T) {
91
+ for _, phase := range state.LifecyclePhases() {
92
+ got, ok := mapLegacyPhase(string(phase))
93
+ if !ok || got != phase {
94
+ t.Fatalf("mapLegacyPhase(%q)=(%q,%v), want canonical phase", phase, got, ok)
95
+ }
96
+ }
97
+
83
98
  for _, tc := range []struct {
84
99
  word string
85
100
  want state.Phase
@@ -87,7 +102,6 @@ func TestMapLegacyPhase(t *testing.T) {
87
102
  }{
88
103
  {word: "specced", want: state.PhaseSpec, ok: true},
89
104
  {word: "in-progress", want: state.PhaseBuild, ok: true},
90
- {word: "temper", want: state.PhaseTemper, ok: true},
91
105
  {word: "reviewing", want: state.PhaseReview, ok: true},
92
106
  {word: "done - shipped", want: state.PhaseDone, ok: true},
93
107
  {word: "mystery"},
@@ -105,3 +119,17 @@ func assertFile(t *testing.T, root, rel, want string) {
105
119
  t.Fatalf("%s=%q, want %q", rel, got, want)
106
120
  }
107
121
  }
122
+
123
+ func assertFileContains(t *testing.T, root, rel, want string) {
124
+ t.Helper()
125
+ if got := testutil.ReadFile(t, filepath.Join(root, rel)); !strings.Contains(got, want) {
126
+ t.Fatalf("%s=%q, want it to contain %q", rel, got, want)
127
+ }
128
+ }
129
+
130
+ func assertMissing(t *testing.T, root, rel string) {
131
+ t.Helper()
132
+ if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
133
+ t.Fatalf("%s exists or stat failed with %v, want missing", rel, err)
134
+ }
135
+ }
@@ -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
  }
@@ -66,8 +66,8 @@ func featureDir(root, slug string) string {
66
66
 
67
67
  // ListFeatures returns the slugs of every feature under root — directories under
68
68
  // canonical work/ plus compatibility features/ recognized as a feature — sorted.
69
- // A directory is a feature if it has a manifest (feature.md) OR the working-state
70
- // ledger (state.md), so a live workspace the pack created without a manifest
69
+ // A directory is a feature if it has a workspace map OR the working-state
70
+ // ledger (state.md), so a live workspace the pack created without a map
71
71
  // still lists. Missing layout directories yield an empty list, not an error.
72
72
  func ListFeatures(root string) ([]string, error) {
73
73
  seen := map[string]bool{}
@@ -96,11 +96,18 @@ func ListFeatures(root string) ([]string, error) {
96
96
  return slugs, nil
97
97
  }
98
98
 
99
- // isFeatureDir reports whether dir is a recognized feature: it carries a manifest
100
- // or the working-state ledger. Either is a sufficient phase source for LoadFeature.
99
+ // isFeatureDir reports whether dir carries a workspace map or the working-state
100
+ // ledger. Either is a sufficient phase source for LoadFeature.
101
101
  func isFeatureDir(dir string) bool {
102
- return regularFileExists(filepath.Join(dir, "feature.md")) ||
103
- regularFileExists(filepath.Join(dir, LedgerFile))
102
+ if regularFileExists(filepath.Join(dir, LedgerFile)) {
103
+ return true
104
+ }
105
+ for _, name := range workspaceMapFiles {
106
+ if regularFileExists(filepath.Join(dir, name)) {
107
+ return true
108
+ }
109
+ }
110
+ return false
104
111
  }
105
112
 
106
113
  func regularFileExists(path string) bool {
@@ -108,35 +115,26 @@ func regularFileExists(path string) bool {
108
115
  return err == nil && info.Mode().IsRegular()
109
116
  }
110
117
 
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
118
  // LoadFeature reads feature <slug> under root. The phase comes from the live
128
- // working-state ledger when it declares one, otherwise from feature.md
129
- // frontmatter. A feature with neither a manifest nor a ledger does not exist.
130
- // Section content is read from the canonical section files or their transitional
119
+ // working-state ledger when it declares one, otherwise from workspace-map
120
+ // frontmatter. A feature with neither a map nor a ledger does not exist.
121
+ // Section content is read from the canonical section files or their
131
122
  // aliases (see sectionFiles).
132
123
  func LoadFeature(root, slug string) (*Feature, error) {
133
124
  dir := featureDir(root, slug)
134
125
 
135
- manifest, mErr := os.ReadFile(filepath.Join(dir, "feature.md"))
136
- if mErr != nil && !errors.Is(mErr, os.ErrNotExist) {
137
- return nil, fmt.Errorf("feature %q: %w", slug, mErr)
126
+ var manifest []byte
127
+ for _, name := range workspaceMapFiles {
128
+ data, err := os.ReadFile(filepath.Join(dir, name))
129
+ if err == nil {
130
+ manifest = data
131
+ break
132
+ }
133
+ if !errors.Is(err, os.ErrNotExist) {
134
+ return nil, fmt.Errorf("feature %q: read %s: %w", slug, name, err)
135
+ }
138
136
  }
139
- hasManifest := mErr == nil
137
+ hasManifest := manifest != nil
140
138
  hasLedger := regularFileExists(filepath.Join(dir, LedgerFile))
141
139
  if !hasManifest && !hasLedger {
142
140
  return nil, fmt.Errorf("feature %q not found", slug)
@@ -166,7 +164,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
166
164
  phase = manifestPhase
167
165
  }
168
166
  if phase == "" {
169
- return nil, fmt.Errorf("feature %q: no phase in feature.md frontmatter or %s ledger", slug, LedgerFile)
167
+ return nil, fmt.Errorf("feature %q: no phase in workspace-map frontmatter or %s ledger", slug, LedgerFile)
170
168
  }
171
169
  if !KnownPhase(phase) {
172
170
  return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
@@ -190,22 +188,12 @@ func sectionPresentAny(dir string, s Section) bool {
190
188
  return false
191
189
  }
192
190
 
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
191
  func declaredPhaseFromLedger(path string) (Phase, bool) {
204
192
  raw, err := os.ReadFile(path)
205
193
  if err != nil {
206
194
  return "", false
207
195
  }
208
- value, ok := CursorField(strings.Split(string(raw), "\n"), "phase")
196
+ value, ok := CursorField(strings.Split(string(raw), "\n"), CursorPhase)
209
197
  if !ok {
210
198
  return "", false
211
199
  }
@@ -1,11 +1,25 @@
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
- // A feature.md may declare its own schemaVersion in frontmatter; the engine
6
+ // A workspace map may declare its own schemaVersion in frontmatter; the engine
5
7
  // refuses a version newer than this (see LoadFeature) and otherwise reads the
6
8
  // files, which evolve additively.
7
9
  const SchemaVersion = 1
8
10
 
11
+ const (
12
+ WorkspaceMapFile = "README.md"
13
+ EvidenceFile = "evidence.md"
14
+ )
15
+
16
+ var workspaceMapFiles = []string{WorkspaceMapFile, "feature.md", "index.md"}
17
+
18
+ // WorkspaceMapFiles returns the canonical workspace map followed by readable aliases.
19
+ func WorkspaceMapFiles() []string {
20
+ return append([]string(nil), workspaceMapFiles...)
21
+ }
22
+
9
23
  // Section is one single-concern completeness file within a feature directory.
10
24
  // Splitting a feature into small files (rather than one long document) keeps
11
25
  // each file context-cheap and makes completeness self-evident.
@@ -31,23 +45,23 @@ var Sections = []Section{
31
45
  }
32
46
 
33
47
  // sectionFiles lists the filenames that can satisfy each section, canonical name
34
- // first, then the transitional aliases the live pack still writes — the same
35
- // mapping `devrites-engine migrate` normalizes (evidence→proof, state→status). A section
48
+ // first, then supported aliases the same mapping `devrites-engine migrate`
49
+ // normalizes (proof→evidence, status→state). A section
36
50
  // counts as present if ANY of its files has real content, so the engine reads a
37
- // live workspace before the pack sweep converges the filenames. The manifest
38
- // (feature.md) is not a section; it is handled separately in LoadFeature.
51
+ // live workspace before the pack sweep converges the filenames. The workspace
52
+ // map is not a section; it is handled separately in LoadFeature.
39
53
  var sectionFiles = map[Section][]string{
40
54
  SectionSpec: {"spec.md"},
41
55
  SectionPlan: {"plan.md"},
42
56
  SectionDecisions: {"decisions.md"},
43
57
  SectionTasks: {"tasks.md"},
44
- SectionProof: {"proof.md", "evidence.md"},
45
- SectionStatus: {"status.md", "state.md"},
58
+ SectionProof: {EvidenceFile, "proof.md"},
59
+ SectionStatus: {"state.md", "status.md"},
46
60
  }
47
61
 
48
62
  // LedgerFile is the working-state ledger the live pack writes. It carries the
49
63
  // phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
50
- // when no feature.md manifest declares one, and it satisfies the status section.
64
+ // when no workspace map declares one, and it satisfies the status section.
51
65
  const LedgerFile = "state.md"
52
66
 
53
67
  // Phase is a workflow state. The order mirrors the rite-* arc.
@@ -70,36 +84,149 @@ const (
70
84
  PhaseDone Phase = "done" // archived completion
71
85
  )
72
86
 
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},
87
+ // phaseDefinition is the single source of truth for lifecycle order and
88
+ // phase-specific behavior. Workflow topology is versioned application logic,
89
+ // rather than deploy-time configuration, so keeping it typed and ordered makes
90
+ // additions compile-visible and lets every consumer derive the same arc.
91
+ type phaseDefinition struct {
92
+ phase Phase
93
+ resumeVerb string
94
+ required []Section
95
+ aliases []string
96
+ workspaceRequired []string
97
+ proofRequired bool
98
+ blocksOpenQuestions bool
99
+ shippable bool
100
+ }
101
+
102
+ var (
103
+ sectionsSpec = []Section{SectionSpec}
104
+ sectionsPlan = []Section{SectionSpec, SectionPlan}
105
+ sectionsBuild = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
106
+ sectionsProof = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
107
+ sectionsComplete = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
108
+
109
+ workspaceFrame = []string{"state.md"}
110
+ workspaceSpec = []string{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
111
+ workspacePlan = []string{"brief.md", "spec.md", "architecture.md", "plan.md", "tasks.md", "traceability.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
112
+ workspaceProof = append(append([]string(nil), workspacePlan...), "evidence.md", "touched-files.md")
113
+ )
114
+
115
+ // Completeness is phase-relative: a section not yet required (e.g. proof during
116
+ // spec) never blocks. Requirements are additive down the arc. Plan resumes at
117
+ // define because plan is the artifact state produced by rite-define; done has no
118
+ // resume command.
119
+ var phaseDefinitions = []phaseDefinition{
120
+ {phase: PhaseFrame, resumeVerb: "frame", workspaceRequired: workspaceFrame},
121
+ {phase: PhaseSpec, resumeVerb: "spec", required: sectionsSpec, aliases: []string{"specced", "specifying"}, workspaceRequired: workspaceSpec},
122
+ {phase: PhaseTemper, resumeVerb: "temper", required: sectionsSpec, aliases: []string{"tempered", "tempering"}, workspaceRequired: workspaceSpec},
123
+ {phase: PhaseDefine, resumeVerb: "define", required: sectionsPlan, aliases: []string{"defined", "defining"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
124
+ {phase: PhasePlan, resumeVerb: "define", required: sectionsPlan, aliases: []string{"planned", "planning"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
125
+ {phase: PhaseVet, resumeVerb: "vet", required: sectionsBuild, aliases: []string{"vetted", "vetting"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
126
+ {phase: PhaseBuild, resumeVerb: "build", required: sectionsBuild, aliases: []string{"building", "wip", "in", "in-progress"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
127
+ {phase: PhaseConverge, resumeVerb: "converge", required: sectionsBuild, aliases: []string{"converged", "converging"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
128
+ {phase: PhaseProve, resumeVerb: "prove", required: sectionsProof, aliases: []string{"proving", "proven", "testing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
129
+ {phase: PhasePolish, resumeVerb: "polish", required: sectionsProof, aliases: []string{"polished", "polishing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
130
+ {phase: PhaseReview, resumeVerb: "review", required: sectionsProof, aliases: []string{"reviewed", "reviewing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
131
+ {phase: PhaseSeal, resumeVerb: "seal", required: sectionsComplete, aliases: []string{"sealed", "sealing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
132
+ {phase: PhaseShip, resumeVerb: "ship", required: sectionsComplete, aliases: []string{"shipped", "shipping"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
133
+ {phase: PhaseDone, required: sectionsComplete, aliases: []string{"closed", "complete", "completed"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
134
+ }
135
+
136
+ // WorkflowPhase is the read-only cross-format view used to generate the compact
137
+ // manifest consumed by non-Go release tooling.
138
+ type WorkflowPhase struct {
139
+ ID Phase `json:"id"`
140
+ ResumeVerb string `json:"resumeVerb,omitempty"`
141
+ Aliases []string `json:"aliases,omitempty"`
142
+ WorkspaceRequired []string `json:"workspaceRequired"`
143
+ ProofRequired bool `json:"proofRequired,omitempty"`
144
+ BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
145
+ Shippable bool `json:"shippable,omitempty"`
146
+ }
147
+
148
+ // WorkflowPhases returns copied metadata suitable for deterministic generation.
149
+ func WorkflowPhases() []WorkflowPhase {
150
+ out := make([]WorkflowPhase, 0, len(phaseDefinitions))
151
+ for _, definition := range phaseDefinitions {
152
+ out = append(out, WorkflowPhase{
153
+ ID: definition.phase,
154
+ ResumeVerb: definition.resumeVerb,
155
+ Aliases: append([]string(nil), definition.aliases...),
156
+ WorkspaceRequired: append([]string(nil), definition.workspaceRequired...),
157
+ ProofRequired: definition.proofRequired,
158
+ BlocksOpenQuestions: definition.blocksOpenQuestions,
159
+ Shippable: definition.shippable,
160
+ })
161
+ }
162
+ return out
163
+ }
164
+
165
+ // LifecyclePhases returns the ordered lifecycle. The returned slice is a copy,
166
+ // so callers cannot mutate the registry.
167
+ func LifecyclePhases() []Phase {
168
+ phases := make([]Phase, len(phaseDefinitions))
169
+ for i, definition := range phaseDefinitions {
170
+ phases[i] = definition.phase
171
+ }
172
+ return phases
173
+ }
174
+
175
+ func definitionFor(p Phase) (phaseDefinition, bool) {
176
+ for _, definition := range phaseDefinitions {
177
+ if definition.phase == p {
178
+ return definition, true
179
+ }
180
+ }
181
+ return phaseDefinition{}, false
91
182
  }
92
183
 
93
184
  // KnownPhase reports whether p is a phase the engine understands.
94
185
  func KnownPhase(p Phase) bool {
95
- _, ok := requiredByPhase[p]
186
+ _, ok := definitionFor(p)
96
187
  return ok
97
188
  }
98
189
 
190
+ // ResumeVerb returns the public rite verb that resumes p. Terminal and unknown
191
+ // phases return an empty string.
192
+ func ResumeVerb(p Phase) string {
193
+ definition, ok := definitionFor(p)
194
+ if !ok {
195
+ return ""
196
+ }
197
+ return definition.resumeVerb
198
+ }
199
+
200
+ // ShippablePhase reports whether p is allowed to claim a sealed/shipped result.
201
+ func ShippablePhase(p Phase) bool {
202
+ definition, ok := definitionFor(p)
203
+ return ok && definition.shippable
204
+ }
205
+
206
+ // PhaseForName resolves a canonical phase ID or compatibility alias. Callers
207
+ // should normalize surrounding syntax before querying it.
208
+ func PhaseForName(name string) (Phase, bool) {
209
+ for _, definition := range phaseDefinitions {
210
+ if string(definition.phase) == name {
211
+ return definition.phase, true
212
+ }
213
+ for _, alias := range definition.aliases {
214
+ if alias == name {
215
+ return definition.phase, true
216
+ }
217
+ }
218
+ }
219
+ return "", false
220
+ }
221
+
99
222
  // RequiredSections returns the sections required to complete the given phase,
100
223
  // in canonical Sections order.
101
224
  func RequiredSections(p Phase) []Section {
102
- want := requiredByPhase[p]
225
+ definition, ok := definitionFor(p)
226
+ if !ok {
227
+ return nil
228
+ }
229
+ want := definition.required
103
230
  set := make(map[Section]bool, len(want))
104
231
  for _, s := range want {
105
232
  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
  }