devrites 3.0.2 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0004-state-schema-phases-sections.md +44 -0
  4. package/docs/command-map.md +1 -1
  5. package/docs/engine/state-schema.md +15 -12
  6. package/docs/flow.md +1 -1
  7. package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
  8. package/engine/hooks_workspace.go +7 -7
  9. package/engine/internal/gate/gate.go +4 -4
  10. package/engine/internal/gate/gate_test.go +2 -2
  11. package/engine/internal/lib/cursor_compat_test.go +29 -0
  12. package/engine/internal/lib/preamble.go +1 -1
  13. package/engine/internal/lib/preamble_questions_test.go +10 -0
  14. package/engine/internal/lib/progress.go +59 -20
  15. package/engine/internal/lib/resolve.go +15 -6
  16. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  17. package/engine/internal/lib/tickafk.go +2 -2
  18. package/engine/internal/migrate/migrate.go +2 -33
  19. package/engine/internal/migrate/migrate_test.go +7 -1
  20. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  21. package/engine/internal/state/cursor.go +42 -7
  22. package/engine/internal/state/feature.go +1 -27
  23. package/engine/internal/state/schema.go +135 -20
  24. package/engine/internal/state/snapshot.go +49 -3
  25. package/engine/internal/state/state_test.go +71 -0
  26. package/engine/internal/state/workflow_manifest.json +310 -0
  27. package/engine/internal/workflow/commands.go +0 -36
  28. package/engine/internal/workflow/commands_test.go +0 -17
  29. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  30. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  31. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  32. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  33. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  34. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  35. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  36. package/engine/tests/adr_0004_required_by_phase_test.go +1 -18
  37. package/engine/tests/lib_parity_test.go +1 -1
  38. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  39. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  40. package/pack/generated/codex/AGENTS.md +1 -1
  41. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  42. package/package.json +1 -1
  43. package/scripts/codex-generate.sh +1 -1
  44. package/scripts/grade-feature.sh +5 -3
  45. package/scripts/run-outcome-evals.sh +21 -0
  46. package/scripts/validate-workspace-schema.py +9 -73
  47. package/scripts/validate.sh +10 -0
  48. package/scripts/workflow_schema.py +69 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.0.3](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.2...v3.0.3) (2026-07-20)
6
+
7
+ ### Fixed
8
+
9
+ * **rite:** centralize workflow schema ([58bd55e](https://github.com/ViktorsBaikers/DevRites/commit/58bd55e8472a98be1073bcfa5fe7593e7bf0c5d1))
10
+
5
11
  ## [3.0.2](https://github.com/ViktorsBaikers/DevRites/compare/v3.0.1...v3.0.2) (2026-07-20)
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -115,7 +115,7 @@ Full diagram set (lifecycle, polish orchestrator, review fan-out, debug loop,
115
115
  rules carrier, workspace state, namespace map) →
116
116
  [`docs/flow.md`](docs/flow.md).
117
117
 
118
- **Status:** [`v3.0.2`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.2) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
118
+ **Status:** [`v3.0.3`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.0.3) — see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
119
119
 
120
120
  ## Contents
121
121
 
@@ -0,0 +1,44 @@
1
+ # ADR-0004: Phase-relative section completeness
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-07-08 (backfilled)
5
+
6
+ ## Context
7
+
8
+ A feature's working state must be legible to both humans and the engine, and
9
+ "is it complete?" must be answerable deterministically at each step of the
10
+ lifecycle. A single long document makes completeness opaque and burns context;
11
+ a rigid "all fields required always" model blocks early phases on artifacts
12
+ that don't exist yet (there's no proof during framing).
13
+
14
+ ## Decision
15
+
16
+ Model feature state as **six single-concern section files** — `spec`, `plan`,
17
+ `decisions`, `tasks`, `proof`, `status` (with transitional aliases
18
+ `evidence→proof`, `state→status` that `migrate` normalizes). Drive the lifecycle
19
+ through the ordered rite-\* arc:
20
+ `frame → spec → temper → define → plan → vet → build → converge → prove → polish → review → seal → ship → done`.
21
+ Completeness is **phase-relative and additive**: the typed phase registry maps
22
+ each phase to the sections that must have real content to leave it; a section
23
+ not yet required never blocks. `SchemaVersion = 1`; a feature may declare its
24
+ own and the engine refuses anything newer than it understands. Files evolve
25
+ additively.
26
+
27
+ ## Alternatives considered
28
+
29
+ | Option | Why not |
30
+ |--------|---------|
31
+ | One `feature.md` mega-document | Completeness isn't self-evident; every read pays for the whole file. |
32
+ | All sections required in every phase | Blocks framing/spec phases on proof/tasks that legitimately don't exist yet. |
33
+ | Status inferred from git / external tracker | Couples state to a tool outside the workspace; breaks the git-diffable, self-contained record. |
34
+
35
+ ## Consequences
36
+
37
+ - Completeness is a table lookup, not a judgment call — cheap and reproducible.
38
+ - Small files stay context-cheap and make "what's missing" self-evident.
39
+ - `SchemaVersion = 1` is young; migration is single-version. Hardening the
40
+ writer into one pure transition function is a recorded follow-up
41
+ (`docs/research/gsd-core-adoption.md` §3.2).
42
+ - The typed registry in `engine/internal/state/schema.go` is the lifecycle
43
+ authority. Its invariant tests lock order, aliases, commands, requirements,
44
+ and cross-format manifest freshness.
@@ -167,7 +167,7 @@ when available, falling back to file reads otherwise:
167
167
  See [`flow.md`](flow.md) for the Mermaid diagrams. The text path:
168
168
 
169
169
  ```
170
- /rite-spec → /rite-define → /rite-build ×N → /rite-prove → /rite-polish → /rite-review → /rite-seal → /rite-ship
170
+ /rite-frame → /rite-spec → /rite-temper → /rite-define → /rite-vet → /rite-build ×N → /rite-converge → /rite-prove → /rite-polish → /rite-review → /rite-seal → /rite-ship
171
171
  │ │ │ ▲ │ │ (decide) (execute+close)
172
172
  │ │ │ └ Spec Drift Guard → /rite-plan repair ────┘
173
173
  │ │ └ devrites-frontend-craft / source-driven / doubt
@@ -65,8 +65,9 @@ The per-feature index. Its YAML frontmatter carries:
65
65
  | `phase` | current workflow phase (see below) |
66
66
  | `schemaVersion` | schema version the file was written against |
67
67
 
68
- A feature exists iff its `feature.md` exists. A `feature.md` with no `phase`, or
69
- an unknown `phase`, is an error.
68
+ A feature exists when it has either a live `state.md` ledger or the transitional
69
+ `feature.md` manifest. The mutable `state.md` cursor is authoritative when both
70
+ declare a phase; an unknown declared phase is an error.
70
71
 
71
72
  ## Sections
72
73
 
@@ -91,16 +92,18 @@ requires only the sections needed to leave it, and the set grows additively down
91
92
  the arc. A section that is not yet required (e.g. `proof` during the `spec`
92
93
  phase) never blocks.
93
94
 
94
- | phase | required sections |
95
- | ------- | ----------------------------------------------------- |
96
- | `frame` | *(none)* |
97
- | `spec` | `spec` |
98
- | `plan` | `spec`, `plan` |
99
- | `build` | `spec`, `plan`, `decisions`, `tasks` |
100
- | `prove` | `spec`, `plan`, `decisions`, `tasks`, `proof` |
101
- | `vet` | `spec`, `plan`, `decisions`, `tasks`, `proof` |
102
- | `seal` | `spec`, `plan`, `decisions`, `tasks`, `proof`, `status` |
103
- | `ship` | `spec`, `plan`, `decisions`, `tasks`, `proof`, `status` |
95
+ | phase | required sections |
96
+ | --- | --- |
97
+ | `frame` | *(none)* |
98
+ | `spec`, `temper` | `spec` |
99
+ | `define`, `plan` | `spec`, `plan` |
100
+ | `vet`, `build`, `converge` | `spec`, `plan`, `decisions`, `tasks` |
101
+ | `prove`, `polish`, `review` | `spec`, `plan`, `decisions`, `tasks`, `proof` |
102
+ | `seal`, `ship`, `done` | `spec`, `plan`, `decisions`, `tasks`, `proof`, `status` |
103
+
104
+ The authoritative typed definitions live in `engine/internal/state/schema.go`.
105
+ `workflow_manifest.json` is a generated derivative for non-Go release tools;
106
+ run `go generate ./internal/state` after editing the registry.
104
107
 
105
108
  ## `devrites-engine status <slug>`
106
109
 
package/docs/flow.md CHANGED
@@ -270,7 +270,7 @@ erDiagram
270
270
  string slug PK ".devrites/work/<slug>/"
271
271
  }
272
272
  state {
273
- string phase "spec | plan | build | prove | polish | review | seal | ship | done"
273
+ string phase "frame | spec | temper | define | plan | vet | build | converge | prove | polish | review | seal | ship | done"
274
274
  string status "running | awaiting_human | blocked | done"
275
275
  string active_slice "N — name"
276
276
  int afk_slices_remaining "from .devrites/AFK max_slices on first AFK build"
@@ -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
  }