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.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
- package/docs/adr/0002-dual-host-harness.md +34 -0
- package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
- package/docs/adr/0004-state-schema-phases-sections.md +47 -0
- package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
- package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
- package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
- package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
- package/docs/adr/README.md +58 -0
- package/docs/command-map.md +1 -1
- package/docs/engine/state-schema.md +18 -14
- package/docs/flow.md +1 -1
- package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
- package/engine/hooks_workspace.go +7 -7
- package/engine/internal/gate/gate.go +4 -4
- package/engine/internal/gate/gate_test.go +2 -2
- package/engine/internal/lib/cursor_compat_test.go +29 -0
- package/engine/internal/lib/preamble.go +1 -1
- package/engine/internal/lib/preamble_questions_test.go +10 -0
- package/engine/internal/lib/progress.go +59 -20
- package/engine/internal/lib/resolve.go +15 -6
- package/engine/internal/lib/resolve_remediation_test.go +9 -0
- package/engine/internal/lib/tickafk.go +2 -2
- package/engine/internal/migrate/migrate.go +18 -44
- package/engine/internal/migrate/migrate_test.go +33 -5
- package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
- package/engine/internal/state/cursor.go +42 -7
- package/engine/internal/state/feature.go +29 -41
- package/engine/internal/state/schema.go +155 -28
- package/engine/internal/state/snapshot.go +49 -3
- package/engine/internal/state/state_test.go +77 -6
- package/engine/internal/state/workflow_manifest.json +310 -0
- package/engine/internal/workflow/commands.go +0 -36
- package/engine/internal/workflow/commands_test.go +0 -17
- package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
- package/engine/tests/adr_0004_required_by_phase_test.go +1 -18
- package/engine/tests/budget_test.go +2 -2
- package/engine/tests/lib_parity_test.go +1 -1
- package/engine/tests/meta_test.go +3 -3
- package/engine/tests/migrate_cli_test.go +24 -22
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/pack/generated/codex/AGENTS.md +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
- package/package.json +2 -1
- package/scripts/codex-generate.sh +1 -1
- package/scripts/grade-feature.sh +5 -3
- package/scripts/run-outcome-evals.sh +21 -0
- package/scripts/validate-workflow-security.py +3 -8
- package/scripts/validate-workspace-schema.py +9 -73
- package/scripts/validate.sh +10 -0
- 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,
|
|
191
|
-
status, _ := state.CursorField(stateLines,
|
|
192
|
-
next, _ := state.CursorField(stateLines,
|
|
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,
|
|
225
|
-
status, _ := state.CursorField(stateLines,
|
|
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,
|
|
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")),
|
|
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 :=
|
|
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),
|
|
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("##
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
|
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
|
|
363
|
+
out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
|
|
355
364
|
}
|
|
356
|
-
out, _ = state.SetCursorField(out,
|
|
357
|
-
out, _ = state.SetCursorField(out,
|
|
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,
|
|
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,
|
|
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,
|
|
93
|
+
if !regularFileExists(filepath.Join(dir, state.WorkspaceMapFile)) {
|
|
94
94
|
return true
|
|
95
95
|
}
|
|
96
|
-
return regularFileExists(filepath.Join(dir, "
|
|
97
|
-
regularFileExists(filepath.Join(dir,
|
|
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
|
-
|
|
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,
|
|
109
|
-
return fmt.Errorf("write
|
|
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, "
|
|
113
|
-
return fmt.Errorf("copy
|
|
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,
|
|
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
|
-
//
|
|
132
|
-
func
|
|
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{
|
|
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
|
-
|
|
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
|