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
@@ -0,0 +1,212 @@
1
+ # Go Authoritative Workflow Schema and Quality Research
2
+
3
+ Date: 2026-07-20
4
+
5
+ ## Question
6
+
7
+ How should a Go CLI keep lifecycle and schema values authoritative, avoid
8
+ shotgun changes, decide between code and configuration, safely generate
9
+ derivatives, validate the model, and find dead or defective code?
10
+
11
+ This note uses primary or first-party technical sources. Recommendations marked
12
+ **Inference for DevRites** apply those sources to this repository.
13
+
14
+ ## Executive recommendation
15
+
16
+ Use one typed, ordered Go registry as the authority for machine-readable
17
+ workflow facts. A phase definition should own its stable identifier, order,
18
+ required sections, aliases, and other phase-level policy. Consumers should ask
19
+ the registry instead of maintaining their own phase slices, maps, or switches.
20
+
21
+ Keep the registry in code, not runtime configuration: the lifecycle is versioned
22
+ application behavior and should change with tests and a release. Generate files
23
+ only where a second representation is genuinely required. Validate registry
24
+ invariants in one test and make regeneration drift a CI failure.
25
+
26
+ ## 1. Model stable domain values as typed code
27
+
28
+ Go supports defined string/numeric types with methods, typed constants, and
29
+ related constant sets. The language specification explicitly shows defined
30
+ types combined with constants and methods; `iota` is available for sequential
31
+ integer sets ([Go specification: type definitions and constants](https://go.dev/ref/spec#Type_definitions),
32
+ [Go specification: `iota`](https://go.dev/ref/spec#Iota)). Protocol Buffers uses
33
+ the same broad shape when generating Go enums: a defined type, typed constants,
34
+ a `String` method, and lookup metadata derived from the schema
35
+ ([Go generated code guide: enumerations](https://protobuf.dev/reference/go/go-generated/#enumerations)).
36
+
37
+ **Inference for DevRites:** retain `type Phase string` because serialized state
38
+ needs stable readable values, but make one ordered `[]PhaseDefinition` the
39
+ machine authority. Keep named `Phase...` constants so callers remain type-safe;
40
+ declare each serialized string only once. Derive or query all of the following
41
+ from the registry:
42
+
43
+ - known-phase validation;
44
+ - lifecycle order and progress position;
45
+ - required sections and proof/status requirements;
46
+ - legacy aliases used by migration;
47
+ - phase groups such as pre-build, proof-required, and terminal;
48
+ - user-facing labels where labels are truly metadata.
49
+
50
+ Do not replace several duplicated maps with one giant untyped map. A small
51
+ struct gives each fact a name and lets the compiler check field types. Do not
52
+ put unrelated constants such as filenames, parser field aliases, UI glyphs, and
53
+ timeouts into a global `constants.go`; centralize a fact at the narrowest package
54
+ that owns it.
55
+
56
+ ## 2. Code versus configuration
57
+
58
+ The Twelve-Factor definition draws a useful boundary: configuration is what
59
+ varies between deploys, while internal application configuration that does not
60
+ vary between deploys is best kept in code
61
+ ([Twelve-Factor App: Config](https://www.12factor.net/config)).
62
+
63
+ **Inference for DevRites:**
64
+
65
+ | Value | Authority | Reason |
66
+ | --- | --- | --- |
67
+ | Phase IDs, order, required artifacts, legal aliases | Typed Go registry | Versioned product semantics; must change with tests and release |
68
+ | Workspace state such as current phase and next action | Validated workspace data | Per-workspace mutable state |
69
+ | Credentials, external service endpoints, machine-specific paths | Environment/CLI config | Deployment or machine variation |
70
+ | User policy that is intentionally supported as an override | Explicit config with defaults and validation | Product feature, not an accidental escape hatch |
71
+
72
+ Making the phase arc runtime-configurable would weaken compatibility guarantees
73
+ and move failures from compile/test time to user runtime. Add configurability
74
+ only when users have a real supported need to define a different lifecycle.
75
+
76
+ ## 3. Generate derivatives, never another authority
77
+
78
+ The Go tool documents that `go generate` runs explicit author-defined commands,
79
+ is **never** run automatically by `go build` or `go test`, and generated source
80
+ should carry the standard `// Code generated ... DO NOT EDIT.` marker
81
+ ([`go generate` command documentation](https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source)).
82
+ The Go team also states that generated, tested source needed by clients must be
83
+ checked into the repository
84
+ ([Go blog: Generating code](https://go.dev/blog/generate)).
85
+
86
+ **Inference for DevRites:** start without a generator when all engine consumers
87
+ can import/query the Go registry. Add a minimal stdlib generator only for
88
+ representations that cannot consume Go directly, such as a compact machine
89
+ manifest shipped in the skills pack or a lifecycle table in documentation.
90
+
91
+ If generation is added:
92
+
93
+ 1. The generator reads/imports the authoritative registry; it must not parse a
94
+ copied list.
95
+ 2. Output is deterministic, formatted, marked generated, and checked in when
96
+ release consumers need it.
97
+ 3. CI runs generation and fails when `git diff --exit-code` is non-zero. This is
98
+ necessary because Go deliberately does not run generators during tests or
99
+ builds.
100
+ 4. Humans edit only the registry. Generated files say how to regenerate.
101
+
102
+ Protobuf demonstrates when a separate schema language is justified: one schema
103
+ generates typed code and lookup maps for multiple representations/languages
104
+ ([Protocol Buffers Go generated-code guide](https://protobuf.dev/reference/go/go-generated/)).
105
+ DevRites does not need Protobuf, CUE, or JSON Schema merely to centralize a Go
106
+ enum. Consider one only if the lifecycle becomes a cross-language public data
107
+ contract. JSON Schema is specifically a language for annotating and validating
108
+ JSON structure and constraints
109
+ ([JSON Schema specification](https://json-schema.org/specification)).
110
+
111
+ ## 4. Validate the authority, not every consumer independently
112
+
113
+ The official Go testing guidance recommends table-driven tests when many cases
114
+ share the same logic, avoiding copy-pasted tests
115
+ ([Go Wiki: Table-driven tests](https://go.dev/wiki/TableDrivenTests)). Go fuzzing
116
+ is built into the toolchain and is intended to find edge cases humans miss
117
+ ([Go fuzzing documentation](https://go.dev/doc/security/fuzz/)).
118
+
119
+ **Inference for DevRites:** add one registry-invariant test that iterates every
120
+ definition and fails on:
121
+
122
+ - an empty or duplicate phase ID;
123
+ - an empty or duplicate alias;
124
+ - duplicate or non-monotonic order;
125
+ - an unknown required section;
126
+ - a transition/group reference to an unknown phase;
127
+ - a terminal phase with a successor;
128
+ - a phase that should require proof/status but whose metadata says otherwise.
129
+
130
+ Then keep small behavior tests at public boundaries: readiness, migration,
131
+ progress, stop gates, and snapshots. These should consume the registry rather
132
+ than restating the whole lifecycle as expected data. A single explicit expected
133
+ phase list is still useful as a contract test; duplication in a test oracle is
134
+ intentional because deriving both input and expected output from the same table
135
+ cannot detect an accidentally missing phase.
136
+
137
+ Fuzz the untrusted text boundary (cursor/state parsing), not the static registry.
138
+ Useful properties are: parsing never panics, unknown phases remain errors,
139
+ canonical writes round-trip, and legacy aliases normalize predictably.
140
+
141
+ ## 5. Prevent shotgun changes
142
+
143
+ Centralization works only if consumers cannot quietly recreate the same facts.
144
+ The minimum enforcement is architectural rather than magical:
145
+
146
+ 1. Put the registry and queries in the owning `state` package.
147
+ 2. Export query functions or read-only copies, not mutable global maps/slices.
148
+ 3. Replace lifecycle switches and local `[]Phase` values with registry queries.
149
+ 4. Allow consumer switches only for genuinely phase-specific behavior that
150
+ cannot be represented as metadata; require a default that rejects unknown
151
+ phases.
152
+ 5. Search CI or a focused test for duplicated full lifecycle literals in code
153
+ and machine templates. Do not ban phase words in prose; documentation must be
154
+ readable and is not executable policy.
155
+
156
+ This is deliberately smaller than building a generic workflow framework. One
157
+ data table plus a few queries solves the observed change-amplification problem.
158
+
159
+ ## 6. Static analysis and dead-code audit stack
160
+
161
+ No single tool proves correctness. The Go `vet` documentation says it finds
162
+ suspicious constructs missed by the compiler but uses heuristics and is not a
163
+ firm correctness indicator ([`go vet` documentation](https://pkg.go.dev/cmd/vet)).
164
+ The Go team's `deadcode` command builds a whole-program call graph and reports
165
+ unreachable functions. Its `-test` flag includes test executables, and its docs
166
+ warn that results are specific to one `GOOS`/`GOARCH`/build-tag configuration
167
+ ([`deadcode` documentation](https://pkg.go.dev/golang.org/x/tools/cmd/deadcode),
168
+ [Go blog: Finding unreachable functions with deadcode](https://go.dev/blog/deadcode)).
169
+ Staticcheck provides additional correctness and unused-code checks, including
170
+ the `U1000` family ([Staticcheck checks](https://staticcheck.dev/docs/checks/)).
171
+ `govulncheck` reports known vulnerabilities that are actually reachable through
172
+ the program's call graph ([Go vulnerability management](https://go.dev/doc/security/vuln/)).
173
+
174
+ **Recommended audit order:**
175
+
176
+ ```text
177
+ go test ./...
178
+ go vet ./...
179
+ staticcheck ./...
180
+ deadcode -test ./...
181
+ govulncheck ./...
182
+ ```
183
+
184
+ Run race-enabled tests for concurrency changes and a bounded fuzz job for state
185
+ parsers. Run `deadcode` for every supported build configuration—at minimum the
186
+ supported Unix and Windows targets—before deleting reported code. Review each
187
+ finding; generated files, reflection, build tags, assembly, and external entry
188
+ points can change reachability assumptions.
189
+
190
+ ## 7. Concrete DevRites adoption sequence
191
+
192
+ 1. Characterize current behavior with the existing test suite.
193
+ 2. Introduce `PhaseDefinition` and the single ordered registry in
194
+ `engine/internal/state`; keep serialized phase strings stable.
195
+ 3. Move `KnownPhase`, order, requirements, aliases, groups, and labels behind
196
+ registry queries.
197
+ 4. Migrate consumers subsystem by subsystem and delete their local lifecycle
198
+ tables in the same change.
199
+ 5. Add the invariant/contract tests and a generated-drift check only if a
200
+ generator is actually introduced.
201
+ 6. Run the audit stack, delete only confirmed dead code, and rerun all supported
202
+ platform tests.
203
+
204
+ ## Decision summary
205
+
206
+ - **Choose:** one typed Go metadata registry, narrow query API, validation test.
207
+ - **Do not choose yet:** runtime-configurable lifecycle, a general workflow DSL,
208
+ or a new schema/code-generation dependency.
209
+ - **Generate only:** unavoidable cross-format derivatives, with deterministic
210
+ output and CI drift detection.
211
+ - **Audit continuously:** compiler/tests, vet, Staticcheck, deadcode by supported
212
+ build configuration, govulncheck, and focused fuzzing.
@@ -187,9 +187,9 @@ func hookHandoffSnapshot(stdin io.Reader, stdout, stderr io.Writer) int {
187
187
  return exitOK
188
188
  }
189
189
  stateLines := wsReadLines(filepath.Join(dir, "state.md"))
190
- phase, _ := state.CursorField(stateLines, "phase")
191
- status, _ := state.CursorField(stateLines, "status")
192
- next, _ := state.CursorField(stateLines, "next_action")
190
+ phase, _ := state.CursorField(stateLines, state.CursorPhase)
191
+ status, _ := state.CursorField(stateLines, state.CursorStatus)
192
+ next, _ := state.CursorField(stateLines, state.CursorNextAction)
193
193
  stamp := time.Now().UTC().Format(time.RFC3339)
194
194
  var b strings.Builder
195
195
  fmt.Fprintf(&b, "\n## Handoff snapshot — %s\n", stamp)
@@ -221,12 +221,12 @@ func hookCursor(stdin io.Reader, stdout, stderr io.Writer) int {
221
221
  }
222
222
  stateLines := wsReadLines(filepath.Join(dir, "state.md"))
223
223
 
224
- next, _ := state.CursorField(stateLines, "next_action")
225
- status, _ := state.CursorField(stateLines, "status")
224
+ next, _ := state.CursorField(stateLines, state.CursorNextAction)
225
+ status, _ := state.CursorField(stateLines, state.CursorStatus)
226
226
  gates := wsGateCount(filepath.Join(dir, "questions.md"))
227
227
  afk := ""
228
228
  if wsIsFile(filepath.Join(root, "AFK")) {
229
- afk, _ = state.CursorField(stateLines, "afk_slices_remaining")
229
+ afk, _ = state.CursorField(stateLines, state.CursorAFKSlicesRemaining)
230
230
  }
231
231
 
232
232
  fmt.Fprintf(stdout, "DevRites cursor — active feature: %s\n", slug)
@@ -255,7 +255,7 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
255
255
  if !ok {
256
256
  return exitOK
257
257
  }
258
- phase, _ := state.CursorField(wsReadLines(filepath.Join(dir, "state.md")), "phase")
258
+ phase, _ := state.CursorField(wsReadLines(filepath.Join(dir, "state.md")), state.CursorPhase)
259
259
  if phase == "" {
260
260
  phase = "?"
261
261
  }
@@ -129,7 +129,7 @@ func StopGate(root string) (StopResult, error) {
129
129
  slug, strings.Join(gates, "/")),
130
130
  }, nil
131
131
  }
132
- claimsDone := f.Phase == state.PhaseSeal || f.Phase == state.PhaseShip || f.Phase == state.PhaseDone
132
+ claimsDone := state.ShippablePhase(f.Phase)
133
133
  if claimsDone && !f.Present[state.SectionProof] {
134
134
  return StopResult{
135
135
  Slug: slug,
@@ -167,14 +167,14 @@ func openBlockingQuestionGates(data []byte) []string {
167
167
  inQ := false
168
168
  status, gate := "", ""
169
169
  finalize := func() {
170
- if inQ && status == "open" && (gate == "blocking" || gate == "validating") && !seen[gate] {
170
+ if inQ && status == "open" && (gate == "blocking" || gate == "validating" || gate == "escalating") && !seen[gate] {
171
171
  seen[gate] = true
172
172
  gates = append(gates, gate)
173
173
  }
174
174
  }
175
175
  for _, line := range lines {
176
176
  switch {
177
- case strings.HasPrefix(line, "## q-"):
177
+ case strings.HasPrefix(strings.ToLower(line), "## q-"):
178
178
  finalize()
179
179
  inQ, status, gate = true, "", ""
180
180
  case inQ && strings.HasPrefix(line, "status:"):
@@ -191,7 +191,7 @@ func openBlockingQuestionGates(data []byte) []string {
191
191
  }
192
192
 
193
193
  func stateAwaitingHuman(data []byte) bool {
194
- status, _ := state.CursorField(splitLinesNoTrailing(data), "status")
194
+ status, _ := state.CursorField(splitLinesNoTrailing(data), state.CursorStatus)
195
195
  return status == "awaiting_human"
196
196
  }
197
197
 
@@ -146,8 +146,8 @@ func TestStopGateUsesWorkspaceOverride(t *testing.T) {
146
146
  }
147
147
 
148
148
  func TestOpenBlockingQuestionGates(t *testing.T) {
149
- got := openBlockingQuestionGates([]byte("## q-1\nstatus: open\ngate: blocking\n\n## Not a question\n\n## q-2\nstatus: open\ngate: validating\n\n## q-3\nstatus: resolved\ngate: blocking\n\n## q-4\nstatus: open\ngate: blocking\n"))
150
- want := []string{"blocking", "validating"}
149
+ got := openBlockingQuestionGates([]byte("## Q-1\nstatus: open\ngate: blocking\n\n## Not a question\n\n## q-2\nstatus: open\ngate: validating\n\n## q-3\nstatus: resolved\ngate: blocking\n\n## q-4\nstatus: open\ngate: blocking\n\n## Q-5\nstatus: open\ngate: escalating\n"))
150
+ want := []string{"blocking", "validating", "escalating"}
151
151
  if strings.Join(got, ",") != strings.Join(want, ",") {
152
152
  t.Fatalf("openBlockingQuestionGates=%v, want %v", got, want)
153
153
  }
@@ -88,4 +88,33 @@ func TestClearAwaitingSupportsCanonicalCursor(t *testing.T) {
88
88
  if strings.Contains(text, "## Awaiting human") || !strings.Contains(text, "| status | running |") {
89
89
  t.Fatalf("canonical awaiting state was not cleared:\n%s", text)
90
90
  }
91
+ if !strings.Contains(text, "$rite-temper") || strings.Contains(text, "$rite-build") {
92
+ t.Fatalf("canonical awaiting state resumed the wrong phase:\n%s", text)
93
+ }
94
+ }
95
+
96
+ func TestProgressReadsCanonicalSlicesAndShowsPlanPhase(t *testing.T) {
97
+ root := t.TempDir()
98
+ dir := filepath.Join(root, "work", "demo")
99
+ if err := os.MkdirAll(dir, 0o755); err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ stateText := strings.Replace(canonicalCursor, "| phase | temper |", "| phase | plan |", 1)
103
+ stateText = strings.Replace(stateText, "| afk_slices_remaining | 2 |", "| active_slice | SLICE-002 |", 1)
104
+ if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte(stateText), 0o644); err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ tasks := "## SLICE-001 First\nStatus: built\n\n## SLICE-002 Second\nStatus: pending\n"
108
+ if err := os.WriteFile(filepath.Join(dir, "tasks.md"), []byte(tasks), 0o644); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+
112
+ var stdout bytes.Buffer
113
+ if code := Progress(root, []string{"demo"}, &stdout, &bytes.Buffer{}); code != 0 {
114
+ t.Fatalf("code=%d", code)
115
+ }
116
+ text := stdout.String()
117
+ if !strings.Contains(text, "Slice 1/2") || !strings.Contains(text, "plan ◉") || strings.Contains(text, "build ◉") {
118
+ t.Fatalf("progress missed canonical slice/plan state:\n%s", text)
119
+ }
91
120
  }
@@ -117,7 +117,7 @@ func tallyOpenQuestions(data []byte) (blocking, validating, advisory, escalating
117
117
  }
118
118
  for _, line := range splitLinesNoTrailing(data) {
119
119
  switch {
120
- case strings.HasPrefix(line, "## q-"):
120
+ case strings.HasPrefix(strings.ToLower(line), "## q-"):
121
121
  finalize()
122
122
  inQ, status, gate = true, "", ""
123
123
  case inQ && strings.HasPrefix(line, "status:"):
@@ -0,0 +1,10 @@
1
+ package lib
2
+
3
+ import "testing"
4
+
5
+ func TestTallyOpenQuestionsAcceptsCanonicalUppercaseIDs(t *testing.T) {
6
+ b, v, a, e := tallyOpenQuestions([]byte("## Q-001\nstatus: open\ngate: blocking\n\n## q-2026-07-20-001\nstatus: open\ngate: escalating\n"))
7
+ if b != 1 || v != 0 || a != 0 || e != 1 {
8
+ t.Fatalf("counts=(%d,%d,%d,%d), want (1,0,0,1)", b, v, a, e)
9
+ }
10
+ }
@@ -20,6 +20,7 @@ var (
20
20
  sliceStateRe = regexp.MustCompile(`[[:space:]]+(built|pending)[[:space:]]*$`)
21
21
  sliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`) // drop " — "
22
22
  sliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
23
+ sliceHeadingRe = regexp.MustCompile(`^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+(.*))?$`)
23
24
  )
24
25
 
25
26
  // Progress renders the active feature's position — the deterministic footer every
@@ -50,6 +51,9 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
50
51
  phase = "spec"
51
52
  }
52
53
  total, built, lastbuilt := tallySlices(lines)
54
+ if total == 0 {
55
+ total, built, lastbuilt = tallyCanonicalSlices(workDir)
56
+ }
53
57
 
54
58
  // Header rule: pad "── rite-<phase> " with box glyphs to ≥44 BYTES. Under the
55
59
  // oracle's LC_ALL=C the bash `${#header}` counts bytes, which len() matches.
@@ -80,28 +84,28 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
80
84
 
81
85
  // Flow ribbon: the lifecycle spine; optional phases appear once their artifact
82
86
  // exists or while they are the active phase, so the cursor is never omitted.
83
- var order []string
84
- if phase == "frame" {
85
- order = append(order, "frame")
86
- }
87
- order = append(order, "spec")
88
- if phase == "temper" || isFile(filepath.Join(workDir, "strategy.md")) {
89
- order = append(order, "temper")
90
- }
91
- order = append(order, "define")
92
- if phase == "vet" || isFile(filepath.Join(workDir, "eng-review.md")) {
93
- order = append(order, "vet")
94
- }
95
- order = append(order, "build")
96
- if phase == "converge" {
97
- order = append(order, "converge")
87
+ order := make([]string, 0, len(state.LifecyclePhases()))
88
+ for _, lifecyclePhase := range state.LifecyclePhases() {
89
+ name := string(lifecyclePhase)
90
+ include := true
91
+ switch lifecyclePhase {
92
+ case state.PhaseFrame:
93
+ include = phase == name
94
+ case state.PhaseTemper:
95
+ include = phase == name || isFile(filepath.Join(workDir, "strategy.md"))
96
+ case state.PhaseDone:
97
+ include = false
98
+ case state.PhaseVet:
99
+ include = phase == name || isFile(filepath.Join(workDir, "eng-review.md"))
100
+ case state.PhaseConverge:
101
+ include = phase == name
102
+ }
103
+ if include {
104
+ order = append(order, name)
105
+ }
98
106
  }
99
- order = append(order, "prove", "polish", "review", "seal", "ship")
100
107
 
101
108
  cur := phase
102
- if cur == "plan" { // replan sits at build
103
- cur = "build"
104
- }
105
109
  idx := -1
106
110
  if phase == "done" { // past ship
107
111
  idx = len(order)
@@ -127,9 +131,44 @@ func Progress(root string, args []string, stdout, stderr io.Writer) int {
127
131
  return 0
128
132
  }
129
133
 
134
+ func tallyCanonicalSlices(workDir string) (total, built int, lastbuilt string) {
135
+ raw, err := os.ReadFile(filepath.Join(workDir, "tasks.md"))
136
+ if err != nil {
137
+ return 0, 0, ""
138
+ }
139
+ name, sliceState := "", ""
140
+ finalize := func() {
141
+ if name == "" {
142
+ return
143
+ }
144
+ total++
145
+ if sliceState == "built" {
146
+ built++
147
+ lastbuilt = name
148
+ }
149
+ }
150
+ for _, line := range strings.Split(string(raw), "\n") {
151
+ if match := sliceHeadingRe.FindStringSubmatch(strings.TrimSpace(line)); match != nil {
152
+ finalize()
153
+ name = match[1]
154
+ if strings.TrimSpace(match[2]) != "" {
155
+ name += " " + strings.TrimSpace(match[2])
156
+ }
157
+ sliceState = "pending"
158
+ continue
159
+ }
160
+ if name != "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "status:") {
161
+ _, value, _ := strings.Cut(strings.TrimSpace(line), ":")
162
+ sliceState = strings.ToLower(strings.TrimSpace(value))
163
+ }
164
+ }
165
+ finalize()
166
+ return total, built, lastbuilt
167
+ }
168
+
130
169
  // parsePhase returns the first token of the canonical or legacy phase field.
131
170
  func parsePhase(lines []string) string {
132
- value, ok := state.CursorField(lines, "phase")
171
+ value, ok := state.CursorField(lines, state.CursorPhase)
133
172
  if !ok {
134
173
  return ""
135
174
  }
@@ -220,7 +220,7 @@ func resolveQuestion(qfile, qid, status, answer string, stderr io.Writer) int {
220
220
  }
221
221
 
222
222
  var (
223
- qHeaderRe = regexp.MustCompile(`^## q-`)
223
+ qHeaderRe = regexp.MustCompile(`(?i)^## q-`)
224
224
  statusLineRe = regexp.MustCompile(`^status:`)
225
225
  statusStrip = regexp.MustCompile(`^status:[[:space:]]*`)
226
226
  answeredAtRe = regexp.MustCompile(`^answered_at:`)
@@ -299,6 +299,15 @@ func clearAwaiting(sfile, qid string) error {
299
299
  return fmt.Errorf("read state %s: %w", sfile, err)
300
300
  }
301
301
  lines := splitLinesNoTrailing(data)
302
+ resumePhase := state.PhaseBuild
303
+ if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
304
+ if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
305
+ if phase, known := state.PhaseForName(fields[0]); known && state.ResumeVerb(phase) != "" {
306
+ resumePhase = phase
307
+ }
308
+ }
309
+ }
310
+ resumeCommand := workflow.ForVerb(state.ResumeVerb(resumePhase))
302
311
  // First check whether the awaiting block references this question at all.
303
312
  inAw := false
304
313
  var awaitingLines []string
@@ -314,7 +323,7 @@ func clearAwaiting(sfile, qid string) error {
314
323
  awaitingLines = append(awaitingLines, line)
315
324
  }
316
325
  }
317
- waitingOn, _ := state.CursorField(awaitingLines, "question_id")
326
+ waitingOn, _ := state.CursorField(awaitingLines, state.CursorQuestionID)
318
327
  if waitingOn != qid {
319
328
  return nil
320
329
  }
@@ -341,7 +350,7 @@ func clearAwaiting(sfile, qid string) error {
341
350
  continue
342
351
  case inLog && hdrSpaceRe.MatchString(line):
343
352
  if !logAppended {
344
- out = append(out, fmt.Sprintf("- %s build: resolved %s", ts, qid))
353
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
345
354
  logAppended = true
346
355
  }
347
356
  inLog = false
@@ -351,10 +360,10 @@ func clearAwaiting(sfile, qid string) error {
351
360
  out = append(out, line)
352
361
  }
353
362
  if inLog && !logAppended {
354
- out = append(out, fmt.Sprintf("- %s build: resolved %s", ts, qid))
363
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
355
364
  }
356
- out, _ = state.SetCursorField(out, "status", "running")
357
- out, _ = state.SetCursorField(out, "next_action", "(resume — `"+workflow.ForVerb("build").Both()+"` to continue the workflow)")
365
+ out, _ = state.SetCursorField(out, state.CursorStatus, "running")
366
+ out, _ = state.SetCursorField(out, state.CursorNextAction, "(resume — `"+resumeCommand.Both()+"` to continue the workflow)")
358
367
  if err := fsutil.WriteFileAtomic(sfile, joinRecords(out), 0o644); err != nil {
359
368
  return fmt.Errorf("update state %s: %w", sfile, err)
360
369
  }
@@ -39,6 +39,15 @@ func TestResolveCompletesMissingAnswerFieldsInSingleRewrite(t *testing.T) {
39
39
  }
40
40
  }
41
41
 
42
+ func TestResolveAcceptsCanonicalUppercaseQuestionID(t *testing.T) {
43
+ root := resolveWorkspace(t, "## Q-001 blocking\nstatus: open\n")
44
+ var stdout, stderr bytes.Buffer
45
+
46
+ if code := Resolve(root, []string{"Q-001", "canonical answer"}, &stdout, &stderr); code != 0 {
47
+ t.Fatalf("code = %d, want 0; stderr=%q", code, stderr.String())
48
+ }
49
+ }
50
+
42
51
  func resolveWorkspace(t *testing.T, questions string) string {
43
52
  t.Helper()
44
53
  root := t.TempDir()
@@ -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
  }
@@ -90,11 +90,11 @@ func featureDirsNeedingNormalization(root string) ([]normalizationTarget, error)
90
90
  }
91
91
 
92
92
  func needsNormalizeFeature(dir string) bool {
93
- if !regularFileExists(filepath.Join(dir, "feature.md")) {
93
+ if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
94
94
  return true
95
95
  }
96
- return regularFileExists(filepath.Join(dir, "evidence.md")) && !regularFileExists(filepath.Join(dir, "proof.md")) ||
97
- regularFileExists(filepath.Join(dir, state.LedgerFile)) && !regularFileExists(filepath.Join(dir, "status.md"))
96
+ return regularFileExists(filepath.Join(dir, "proof.md")) && !regularFileExists(filepath.Join(dir, state.EvidenceFile)) ||
97
+ regularFileExists(filepath.Join(dir, "status.md")) && !regularFileExists(filepath.Join(dir, state.LedgerFile))
98
98
  }
99
99
 
100
100
  func regularFileExists(path string) bool {
@@ -103,16 +103,21 @@ func regularFileExists(path string) bool {
103
103
  }
104
104
 
105
105
  func normalizeFeatureDir(dir, slug string) error {
106
- if !regularFileExists(filepath.Join(dir, "feature.md")) {
106
+ for _, alias := range state.WorkspaceMapFiles()[1:] {
107
+ if err := copyAliasFile(dir, alias, state.WorkspaceMapFile); err != nil {
108
+ return fmt.Errorf("copy %s to %s: %w", alias, state.WorkspaceMapFile, err)
109
+ }
110
+ }
111
+ if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
107
112
  phase := derivePhase(filepath.Join(dir, state.LedgerFile))
108
- if err := state.AtomicWrite(filepath.Join(dir, "feature.md"), []byte(featureIndex(slug, phase)), 0o644); err != nil {
109
- return fmt.Errorf("write feature index: %w", err)
113
+ if err := state.AtomicWrite(filepath.Join(dir, state.WorkspaceMapFile), []byte(workspaceIndex(slug, phase)), 0o644); err != nil {
114
+ return fmt.Errorf("write workspace index: %w", err)
110
115
  }
111
116
  }
112
- if err := copyAliasFile(dir, "evidence.md", "proof.md"); err != nil {
113
- return fmt.Errorf("copy evidence.md to proof.md: %w", err)
117
+ if err := copyAliasFile(dir, "proof.md", state.EvidenceFile); err != nil {
118
+ return fmt.Errorf("copy proof.md to evidence.md: %w", err)
114
119
  }
115
- return copyAliasFile(dir, state.LedgerFile, "status.md")
120
+ return copyAliasFile(dir, "status.md", state.LedgerFile)
116
121
  }
117
122
 
118
123
  func copyAliasFile(dir, alias, canonical string) error {
@@ -128,8 +133,8 @@ func copyAliasFile(dir, alias, canonical string) error {
128
133
  return state.AtomicWrite(dst, data, 0o644)
129
134
  }
130
135
 
131
- // featureIndex renders the manifest added to a normalized workspace.
132
- func featureIndex(slug string, phase state.Phase) string {
136
+ // workspaceIndex renders the compact map added to a normalized workspace.
137
+ func workspaceIndex(slug string, phase state.Phase) string {
133
138
  return fmt.Sprintf(`---
134
139
  slug: %s
135
140
  title: %s
@@ -151,7 +156,7 @@ func derivePhase(statePath string) state.Phase {
151
156
  return state.PhaseBuild
152
157
  }
153
158
  lines := strings.Split(string(raw), "\n")
154
- for _, key := range []string{"phase", "status"} {
159
+ for _, key := range []string{state.CursorPhase, state.CursorStatus} {
155
160
  if value, found := state.CursorField(lines, key); found {
156
161
  if p, ok := mapLegacyPhase(value); ok {
157
162
  return p
@@ -168,38 +173,7 @@ func mapLegacyPhase(word string) (state.Phase, bool) {
168
173
  if i := strings.IndexAny(word, " \t—-"); i > 0 {
169
174
  word = word[:i]
170
175
  }
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
- }
176
+ return state.PhaseForName(word)
203
177
  }
204
178
 
205
179
  // backupWorkspace snapshots mutable state (work/, features/, and the ACTIVE pointer) into