devrites 3.0.1 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/docs/adr/0004-state-schema-phases-sections.md +44 -0
- package/docs/command-map.md +1 -1
- package/docs/engine/state-schema.md +15 -12
- package/docs/flow.md +1 -1
- package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
- package/engine/hooks_events_test.go +15 -0
- package/engine/hooks_workspace.go +8 -31
- package/engine/internal/gate/gate.go +6 -11
- package/engine/internal/gate/gate_test.go +18 -2
- package/engine/internal/lib/buildreadiness.go +10 -16
- package/engine/internal/lib/cursor_compat_test.go +120 -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 +69 -29
- package/engine/internal/lib/resolve.go +21 -17
- package/engine/internal/lib/resolve_remediation_test.go +9 -0
- package/engine/internal/lib/tickafk.go +11 -28
- package/engine/internal/migrate/migrate.go +13 -32
- package/engine/internal/migrate/migrate_test.go +11 -3
- package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
- package/engine/internal/state/cursor.go +119 -0
- package/engine/internal/state/cursor_test.go +58 -0
- package/engine/internal/state/feature.go +25 -48
- package/engine/internal/state/schema.go +151 -24
- package/engine/internal/state/snapshot.go +60 -3
- package/engine/internal/state/state_test.go +119 -3
- package/engine/internal/state/workflow_manifest.json +310 -0
- package/engine/internal/workflow/commands.go +10 -29
- package/engine/internal/workflow/commands_test.go +18 -0
- 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 -12
- package/engine/tests/gate_test.go +22 -0
- package/engine/tests/lib_parity_test.go +1 -1
- package/engine/tests/migrate_cli_test.go +3 -3
- 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 +1 -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-workspace-schema.py +9 -73
- package/scripts/validate.sh +10 -0
- package/scripts/workflow_schema.py +69 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"sort"
|
|
5
|
+
"strings"
|
|
6
|
+
"unicode"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Canonical cursor keys. Callers use these constants so schema spelling changes
|
|
10
|
+
// remain local to the cursor package; normalizeCursorKey still accepts legacy
|
|
11
|
+
// presentation aliases at the text boundary.
|
|
12
|
+
const (
|
|
13
|
+
CursorPhase = "phase"
|
|
14
|
+
CursorStatus = "status"
|
|
15
|
+
CursorNextAction = "next_action"
|
|
16
|
+
CursorQuestionID = "question_id"
|
|
17
|
+
CursorActiveSlice = "active_slice"
|
|
18
|
+
CursorAFKSlicesRemaining = "afk_slices_remaining"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
var cursorKeyAliases = map[string]string{
|
|
22
|
+
"nextstep": CursorNextAction,
|
|
23
|
+
"qid": CursorQuestionID,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// CursorKeyAlias is the generated-manifest view of a compatibility key.
|
|
27
|
+
type CursorKeyAlias struct {
|
|
28
|
+
Alias string `json:"alias"`
|
|
29
|
+
Canonical string `json:"canonical"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CursorKeyAliases returns compatibility keys in deterministic order.
|
|
33
|
+
func CursorKeyAliases() []CursorKeyAlias {
|
|
34
|
+
aliases := make([]string, 0, len(cursorKeyAliases))
|
|
35
|
+
for alias := range cursorKeyAliases {
|
|
36
|
+
aliases = append(aliases, alias)
|
|
37
|
+
}
|
|
38
|
+
sort.Strings(aliases)
|
|
39
|
+
out := make([]CursorKeyAlias, 0, len(aliases))
|
|
40
|
+
for _, alias := range aliases {
|
|
41
|
+
out = append(out, CursorKeyAlias{Alias: alias, Canonical: cursorKeyAliases[alias]})
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type cursorLineKind uint8
|
|
47
|
+
|
|
48
|
+
const (
|
|
49
|
+
cursorLineUnknown cursorLineKind = iota
|
|
50
|
+
cursorLineLegacy
|
|
51
|
+
cursorLineTable
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// CursorField reads a state.md field from either the canonical | key | value |
|
|
55
|
+
// cursor table or the legacy "- Key: value" form.
|
|
56
|
+
func CursorField(lines []string, key string) (string, bool) {
|
|
57
|
+
want := normalizeCursorKey(key)
|
|
58
|
+
for _, line := range lines {
|
|
59
|
+
gotKey, value, _, ok := parseCursorLine(line)
|
|
60
|
+
if ok && normalizeCursorKey(gotKey) == want {
|
|
61
|
+
return value, true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return "", false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// SetCursorField replaces an existing state.md field without changing whether
|
|
68
|
+
// the file uses the canonical table or legacy bullet form.
|
|
69
|
+
func SetCursorField(lines []string, key, value string) ([]string, bool) {
|
|
70
|
+
want := normalizeCursorKey(key)
|
|
71
|
+
out := append([]string(nil), lines...)
|
|
72
|
+
for i, line := range out {
|
|
73
|
+
gotKey, _, kind, ok := parseCursorLine(line)
|
|
74
|
+
if !ok || normalizeCursorKey(gotKey) != want {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
switch kind {
|
|
78
|
+
case cursorLineTable:
|
|
79
|
+
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
|
|
80
|
+
out[i] = indent + "| " + strings.TrimSpace(gotKey) + " | " + value + " |"
|
|
81
|
+
case cursorLineLegacy:
|
|
82
|
+
colon := strings.IndexByte(line, ':')
|
|
83
|
+
out[i] = line[:colon+1] + " " + value
|
|
84
|
+
}
|
|
85
|
+
return out, true
|
|
86
|
+
}
|
|
87
|
+
return out, false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func parseCursorLine(line string) (key, value string, kind cursorLineKind, ok bool) {
|
|
91
|
+
trimmed := strings.TrimSpace(line)
|
|
92
|
+
if len(trimmed) >= 2 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|' {
|
|
93
|
+
cells := strings.Split(trimmed[1:len(trimmed)-1], "|")
|
|
94
|
+
if len(cells) >= 2 {
|
|
95
|
+
return strings.TrimSpace(cells[0]), strings.TrimSpace(cells[1]), cursorLineTable, true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
trimmed = strings.TrimLeft(trimmed, "-*+ \t")
|
|
100
|
+
key, value, ok = strings.Cut(trimmed, ":")
|
|
101
|
+
if !ok || strings.TrimSpace(key) == "" {
|
|
102
|
+
return "", "", cursorLineUnknown, false
|
|
103
|
+
}
|
|
104
|
+
return strings.TrimSpace(key), strings.TrimSpace(value), cursorLineLegacy, true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func normalizeCursorKey(key string) string {
|
|
108
|
+
var b strings.Builder
|
|
109
|
+
for _, r := range strings.ToLower(key) {
|
|
110
|
+
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
111
|
+
b.WriteRune(r)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
normalized := b.String()
|
|
115
|
+
if canonical, ok := cursorKeyAliases[normalized]; ok {
|
|
116
|
+
return normalizeCursorKey(canonical)
|
|
117
|
+
}
|
|
118
|
+
return normalized
|
|
119
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
func TestCursorFieldReadsCanonicalTableAndLegacyLines(t *testing.T) {
|
|
6
|
+
table := []string{
|
|
7
|
+
"| Key | Value |",
|
|
8
|
+
"| --- | --- |",
|
|
9
|
+
"| phase | temper |",
|
|
10
|
+
"| status | awaiting_human |",
|
|
11
|
+
"| next_action | /rite-define |",
|
|
12
|
+
}
|
|
13
|
+
legacy := []string{
|
|
14
|
+
"- Phase: build",
|
|
15
|
+
"- Status: running",
|
|
16
|
+
"- Next step: /rite-prove",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for _, tc := range []struct {
|
|
20
|
+
name string
|
|
21
|
+
lines []string
|
|
22
|
+
key string
|
|
23
|
+
want string
|
|
24
|
+
}{
|
|
25
|
+
{"table phase", table, "phase", "temper"},
|
|
26
|
+
{"table status", table, "status", "awaiting_human"},
|
|
27
|
+
{"table next alias", table, "Next step", "/rite-define"},
|
|
28
|
+
{"legacy phase", legacy, "phase", "build"},
|
|
29
|
+
{"legacy next alias", legacy, "next_action", "/rite-prove"},
|
|
30
|
+
} {
|
|
31
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
32
|
+
got, ok := CursorField(tc.lines, tc.key)
|
|
33
|
+
if !ok || got != tc.want {
|
|
34
|
+
t.Fatalf("CursorField(%q) = %q, %v; want %q, true", tc.key, got, ok, tc.want)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestSetCursorFieldPreservesCanonicalAndLegacyFormats(t *testing.T) {
|
|
41
|
+
for _, tc := range []struct {
|
|
42
|
+
name string
|
|
43
|
+
lines []string
|
|
44
|
+
key string
|
|
45
|
+
value string
|
|
46
|
+
want string
|
|
47
|
+
}{
|
|
48
|
+
{"table", []string{"| status | awaiting_human |"}, "status", "running", "| status | running |"},
|
|
49
|
+
{"legacy", []string{"- AFK slices remaining: 2"}, "afk_slices_remaining", "1", "- AFK slices remaining: 1"},
|
|
50
|
+
} {
|
|
51
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
52
|
+
got, ok := SetCursorField(tc.lines, tc.key, tc.value)
|
|
53
|
+
if !ok || len(got) != 1 || got[0] != tc.want {
|
|
54
|
+
t.Fatalf("SetCursorField() = %v, %v; want [%q], true", got, ok, tc.want)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -108,27 +108,11 @@ func regularFileExists(path string) bool {
|
|
|
108
108
|
return err == nil && info.Mode().IsRegular()
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
func CandidateFiles(root, slug string) []string {
|
|
117
|
-
dir := featureDir(root, slug)
|
|
118
|
-
files := []string{filepath.Join(dir, "feature.md")}
|
|
119
|
-
for _, s := range Sections {
|
|
120
|
-
for _, name := range sectionFiles[s] {
|
|
121
|
-
files = append(files, filepath.Join(dir, name))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return files
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// LoadFeature reads feature <slug> under root. The phase comes from the manifest
|
|
128
|
-
// (feature.md) frontmatter when present, otherwise from the working-state ledger
|
|
129
|
-
// (state.md, a "- Phase: <p>" line) the live pack writes. A feature with neither a
|
|
130
|
-
// manifest nor a ledger does not exist. Section content is read from the canonical
|
|
131
|
-
// section files or their transitional aliases (see sectionFiles).
|
|
111
|
+
// LoadFeature reads feature <slug> under root. The phase comes from the live
|
|
112
|
+
// working-state ledger when it declares one, otherwise from feature.md
|
|
113
|
+
// frontmatter. A feature with neither a manifest nor a ledger does not exist.
|
|
114
|
+
// Section content is read from the canonical section files or their transitional
|
|
115
|
+
// aliases (see sectionFiles).
|
|
132
116
|
func LoadFeature(root, slug string) (*Feature, error) {
|
|
133
117
|
dir := featureDir(root, slug)
|
|
134
118
|
|
|
@@ -142,7 +126,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
|
|
|
142
126
|
return nil, fmt.Errorf("feature %q not found", slug)
|
|
143
127
|
}
|
|
144
128
|
|
|
145
|
-
var
|
|
129
|
+
var manifestPhase Phase
|
|
146
130
|
if hasManifest {
|
|
147
131
|
fm, _ := splitFrontmatter(manifest)
|
|
148
132
|
if v := strings.TrimSpace(fm["schemaVersion"]); v != "" {
|
|
@@ -154,12 +138,16 @@ func LoadFeature(root, slug string) (*Feature, error) {
|
|
|
154
138
|
return nil, fmt.Errorf("feature %q: schemaVersion %d is newer than this engine supports (%d); upgrade devrites", slug, n, SchemaVersion)
|
|
155
139
|
}
|
|
156
140
|
}
|
|
157
|
-
|
|
141
|
+
manifestPhase = Phase(strings.TrimSpace(fm["phase"]))
|
|
158
142
|
}
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
143
|
+
|
|
144
|
+
// state.md is the mutable runtime cursor, so it must win over the manifest,
|
|
145
|
+
// which migration and compatibility flows may leave stale. Preserve an
|
|
146
|
+
// explicitly unknown ledger value so the validation below reports it rather
|
|
147
|
+
// than silently falling back to feature.md.
|
|
148
|
+
phase, ledgerDeclared := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
|
|
149
|
+
if !ledgerDeclared {
|
|
150
|
+
phase = manifestPhase
|
|
163
151
|
}
|
|
164
152
|
if phase == "" {
|
|
165
153
|
return nil, fmt.Errorf("feature %q: no phase in feature.md frontmatter or %s ledger", slug, LedgerFile)
|
|
@@ -186,31 +174,20 @@ func sectionPresentAny(dir string, s Section) bool {
|
|
|
186
174
|
return false
|
|
187
175
|
}
|
|
188
176
|
|
|
189
|
-
|
|
190
|
-
// "- Phase: <p>" line (leading list markers tolerated), taking the first token
|
|
191
|
-
// and accepting it only if it is a phase the engine understands. Returns "" when
|
|
192
|
-
// the file is absent or declares no recognized phase. Unlike migrate's tolerant
|
|
193
|
-
// legacy mapping, this accepts only canonical phase words — the live pack's form.
|
|
194
|
-
func PhaseFromLedger(path string) Phase {
|
|
177
|
+
func declaredPhaseFromLedger(path string) (Phase, bool) {
|
|
195
178
|
raw, err := os.ReadFile(path)
|
|
196
179
|
if err != nil {
|
|
197
|
-
return ""
|
|
180
|
+
return "", false
|
|
198
181
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
word
|
|
206
|
-
if i := strings.IndexAny(word, " \t"); i > 0 {
|
|
207
|
-
word = word[:i]
|
|
208
|
-
}
|
|
209
|
-
if p := Phase(word); KnownPhase(p) {
|
|
210
|
-
return p
|
|
211
|
-
}
|
|
182
|
+
value, ok := CursorField(strings.Split(string(raw), "\n"), CursorPhase)
|
|
183
|
+
if !ok {
|
|
184
|
+
return "", false
|
|
185
|
+
}
|
|
186
|
+
word := strings.ToLower(strings.TrimSpace(value))
|
|
187
|
+
if i := strings.IndexAny(word, " \t"); i > 0 {
|
|
188
|
+
word = word[:i]
|
|
212
189
|
}
|
|
213
|
-
return
|
|
190
|
+
return Phase(word), true
|
|
214
191
|
}
|
|
215
192
|
|
|
216
193
|
// ReadDeclaredSchemaVersion returns the schemaVersion declared in a feature's
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package state
|
|
2
2
|
|
|
3
|
+
//go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
|
|
4
|
+
|
|
3
5
|
// SchemaVersion is the .devrites state-schema version this engine understands.
|
|
4
6
|
// A feature.md may declare its own schemaVersion in frontmatter; the engine
|
|
5
7
|
// refuses a version newer than this (see LoadFeature) and otherwise reads the
|
|
@@ -46,48 +48,173 @@ var sectionFiles = map[Section][]string{
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
// LedgerFile is the working-state ledger the live pack writes. It carries the
|
|
49
|
-
// phase (
|
|
50
|
-
// it satisfies the status section.
|
|
51
|
+
// phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
|
|
52
|
+
// when no feature.md manifest declares one, and it satisfies the status section.
|
|
51
53
|
const LedgerFile = "state.md"
|
|
52
54
|
|
|
53
55
|
// Phase is a workflow state. The order mirrors the rite-* arc.
|
|
54
56
|
type Phase string
|
|
55
57
|
|
|
56
58
|
const (
|
|
57
|
-
PhaseFrame
|
|
58
|
-
PhaseSpec
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
PhaseVet
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
PhaseFrame Phase = "frame" // problem framing
|
|
60
|
+
PhaseSpec Phase = "spec" // specification
|
|
61
|
+
PhaseTemper Phase = "temper" // strategic specification review
|
|
62
|
+
PhaseDefine Phase = "define" // plan definition
|
|
63
|
+
PhasePlan Phase = "plan" // approved plan
|
|
64
|
+
PhaseVet Phase = "vet" // pre-build engineering review
|
|
65
|
+
PhaseBuild Phase = "build" // implementation
|
|
66
|
+
PhaseConverge Phase = "converge" // post-build gap closure
|
|
67
|
+
PhaseProve Phase = "prove" // proof / testing
|
|
68
|
+
PhasePolish Phase = "polish" // quality pass
|
|
69
|
+
PhaseReview Phase = "review" // post-proof review
|
|
70
|
+
PhaseSeal Phase = "seal" // completeness seal
|
|
71
|
+
PhaseShip Phase = "ship" // shipping
|
|
72
|
+
PhaseDone Phase = "done" // archived completion
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// phaseDefinition is the single source of truth for lifecycle order and
|
|
76
|
+
// phase-specific behavior. Workflow topology is versioned application logic,
|
|
77
|
+
// rather than deploy-time configuration, so keeping it typed and ordered makes
|
|
78
|
+
// additions compile-visible and lets every consumer derive the same arc.
|
|
79
|
+
type phaseDefinition struct {
|
|
80
|
+
phase Phase
|
|
81
|
+
resumeVerb string
|
|
82
|
+
required []Section
|
|
83
|
+
aliases []string
|
|
84
|
+
workspaceRequired []string
|
|
85
|
+
proofRequired bool
|
|
86
|
+
blocksOpenQuestions bool
|
|
87
|
+
shippable bool
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var (
|
|
91
|
+
sectionsSpec = []Section{SectionSpec}
|
|
92
|
+
sectionsPlan = []Section{SectionSpec, SectionPlan}
|
|
93
|
+
sectionsBuild = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
|
|
94
|
+
sectionsProof = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
|
|
95
|
+
sectionsComplete = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
|
|
96
|
+
|
|
97
|
+
workspaceFrame = []string{"state.md"}
|
|
98
|
+
workspaceSpec = []string{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
99
|
+
workspacePlan = []string{"brief.md", "spec.md", "architecture.md", "plan.md", "tasks.md", "traceability.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
100
|
+
workspaceProof = append(append([]string(nil), workspacePlan...), "evidence.md", "touched-files.md")
|
|
65
101
|
)
|
|
66
102
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
// Completeness is phase-relative: a section not yet required (e.g. proof during
|
|
104
|
+
// spec) never blocks. Requirements are additive down the arc. Plan resumes at
|
|
105
|
+
// define because plan is the artifact state produced by rite-define; done has no
|
|
106
|
+
// resume command.
|
|
107
|
+
var phaseDefinitions = []phaseDefinition{
|
|
108
|
+
{phase: PhaseFrame, resumeVerb: "frame", workspaceRequired: workspaceFrame},
|
|
109
|
+
{phase: PhaseSpec, resumeVerb: "spec", required: sectionsSpec, aliases: []string{"specced", "specifying"}, workspaceRequired: workspaceSpec},
|
|
110
|
+
{phase: PhaseTemper, resumeVerb: "temper", required: sectionsSpec, aliases: []string{"tempered", "tempering"}, workspaceRequired: workspaceSpec},
|
|
111
|
+
{phase: PhaseDefine, resumeVerb: "define", required: sectionsPlan, aliases: []string{"defined", "defining"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
112
|
+
{phase: PhasePlan, resumeVerb: "define", required: sectionsPlan, aliases: []string{"planned", "planning"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
113
|
+
{phase: PhaseVet, resumeVerb: "vet", required: sectionsBuild, aliases: []string{"vetted", "vetting"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
114
|
+
{phase: PhaseBuild, resumeVerb: "build", required: sectionsBuild, aliases: []string{"building", "wip", "in", "in-progress"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
115
|
+
{phase: PhaseConverge, resumeVerb: "converge", required: sectionsBuild, aliases: []string{"converged", "converging"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
116
|
+
{phase: PhaseProve, resumeVerb: "prove", required: sectionsProof, aliases: []string{"proving", "proven", "testing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
117
|
+
{phase: PhasePolish, resumeVerb: "polish", required: sectionsProof, aliases: []string{"polished", "polishing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
118
|
+
{phase: PhaseReview, resumeVerb: "review", required: sectionsProof, aliases: []string{"reviewed", "reviewing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
119
|
+
{phase: PhaseSeal, resumeVerb: "seal", required: sectionsComplete, aliases: []string{"sealed", "sealing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
120
|
+
{phase: PhaseShip, resumeVerb: "ship", required: sectionsComplete, aliases: []string{"shipped", "shipping"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
121
|
+
{phase: PhaseDone, required: sectionsComplete, aliases: []string{"closed", "complete", "completed"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// WorkflowPhase is the read-only cross-format view used to generate the compact
|
|
125
|
+
// manifest consumed by non-Go release tooling.
|
|
126
|
+
type WorkflowPhase struct {
|
|
127
|
+
ID Phase `json:"id"`
|
|
128
|
+
ResumeVerb string `json:"resumeVerb,omitempty"`
|
|
129
|
+
Aliases []string `json:"aliases,omitempty"`
|
|
130
|
+
WorkspaceRequired []string `json:"workspaceRequired"`
|
|
131
|
+
ProofRequired bool `json:"proofRequired,omitempty"`
|
|
132
|
+
BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
|
|
133
|
+
Shippable bool `json:"shippable,omitempty"`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// WorkflowPhases returns copied metadata suitable for deterministic generation.
|
|
137
|
+
func WorkflowPhases() []WorkflowPhase {
|
|
138
|
+
out := make([]WorkflowPhase, 0, len(phaseDefinitions))
|
|
139
|
+
for _, definition := range phaseDefinitions {
|
|
140
|
+
out = append(out, WorkflowPhase{
|
|
141
|
+
ID: definition.phase,
|
|
142
|
+
ResumeVerb: definition.resumeVerb,
|
|
143
|
+
Aliases: append([]string(nil), definition.aliases...),
|
|
144
|
+
WorkspaceRequired: append([]string(nil), definition.workspaceRequired...),
|
|
145
|
+
ProofRequired: definition.proofRequired,
|
|
146
|
+
BlocksOpenQuestions: definition.blocksOpenQuestions,
|
|
147
|
+
Shippable: definition.shippable,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// LifecyclePhases returns the ordered lifecycle. The returned slice is a copy,
|
|
154
|
+
// so callers cannot mutate the registry.
|
|
155
|
+
func LifecyclePhases() []Phase {
|
|
156
|
+
phases := make([]Phase, len(phaseDefinitions))
|
|
157
|
+
for i, definition := range phaseDefinitions {
|
|
158
|
+
phases[i] = definition.phase
|
|
159
|
+
}
|
|
160
|
+
return phases
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func definitionFor(p Phase) (phaseDefinition, bool) {
|
|
164
|
+
for _, definition := range phaseDefinitions {
|
|
165
|
+
if definition.phase == p {
|
|
166
|
+
return definition, true
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return phaseDefinition{}, false
|
|
79
170
|
}
|
|
80
171
|
|
|
81
172
|
// KnownPhase reports whether p is a phase the engine understands.
|
|
82
173
|
func KnownPhase(p Phase) bool {
|
|
83
|
-
_, ok :=
|
|
174
|
+
_, ok := definitionFor(p)
|
|
84
175
|
return ok
|
|
85
176
|
}
|
|
86
177
|
|
|
178
|
+
// ResumeVerb returns the public rite verb that resumes p. Terminal and unknown
|
|
179
|
+
// phases return an empty string.
|
|
180
|
+
func ResumeVerb(p Phase) string {
|
|
181
|
+
definition, ok := definitionFor(p)
|
|
182
|
+
if !ok {
|
|
183
|
+
return ""
|
|
184
|
+
}
|
|
185
|
+
return definition.resumeVerb
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ShippablePhase reports whether p is allowed to claim a sealed/shipped result.
|
|
189
|
+
func ShippablePhase(p Phase) bool {
|
|
190
|
+
definition, ok := definitionFor(p)
|
|
191
|
+
return ok && definition.shippable
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// PhaseForName resolves a canonical phase ID or compatibility alias. Callers
|
|
195
|
+
// should normalize surrounding syntax before querying it.
|
|
196
|
+
func PhaseForName(name string) (Phase, bool) {
|
|
197
|
+
for _, definition := range phaseDefinitions {
|
|
198
|
+
if string(definition.phase) == name {
|
|
199
|
+
return definition.phase, true
|
|
200
|
+
}
|
|
201
|
+
for _, alias := range definition.aliases {
|
|
202
|
+
if alias == name {
|
|
203
|
+
return definition.phase, true
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return "", false
|
|
208
|
+
}
|
|
209
|
+
|
|
87
210
|
// RequiredSections returns the sections required to complete the given phase,
|
|
88
211
|
// in canonical Sections order.
|
|
89
212
|
func RequiredSections(p Phase) []Section {
|
|
90
|
-
|
|
213
|
+
definition, ok := definitionFor(p)
|
|
214
|
+
if !ok {
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
want := definition.required
|
|
91
218
|
set := make(map[Section]bool, len(want))
|
|
92
219
|
for _, s := range want {
|
|
93
220
|
set[s] = true
|
|
@@ -23,6 +23,7 @@ var (
|
|
|
23
23
|
snapshotSliceStateRe = regexp.MustCompile(`[[:space:]]+(built|pending)[[:space:]]*$`)
|
|
24
24
|
snapshotSliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`)
|
|
25
25
|
snapshotSliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
|
|
26
|
+
snapshotSliceHeadingRe = regexp.MustCompile(`(?m)^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+.*)?$`)
|
|
26
27
|
snapshotTouchedPathRe = regexp.MustCompile("`([^`]+)`")
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -121,7 +122,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
workDir := featureDir(root, report.Slug)
|
|
124
|
-
next :=
|
|
125
|
+
next := nextCommand(workDir, report.Phase)
|
|
125
126
|
snap := &WorkspaceSnapshot{
|
|
126
127
|
SchemaVersion: WorkspaceSnapshotSchema,
|
|
127
128
|
Slug: report.Slug,
|
|
@@ -152,7 +153,7 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
|
|
|
152
153
|
if snap.RunMode == "AFK" && len(snap.MissingSections) > 0 {
|
|
153
154
|
snap.Warnings = append(snap.Warnings, "AFK workspace has missing required sections")
|
|
154
155
|
}
|
|
155
|
-
if snap.Evidence.Status
|
|
156
|
+
if report.Required[SectionProof] && snap.Evidence.Status != "fresh" {
|
|
156
157
|
snap.Warnings = append(snap.Warnings, "proof phase requires fresh evidence")
|
|
157
158
|
}
|
|
158
159
|
if snap.Drift.Open > 0 {
|
|
@@ -161,6 +162,17 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
|
|
|
161
162
|
return snap, nil
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
func nextCommand(workDir string, phase Phase) workflow.Command {
|
|
166
|
+
if raw, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
167
|
+
if action, ok := CursorField(strings.Split(string(raw), "\n"), CursorNextAction); ok {
|
|
168
|
+
if command := workflow.ForAction(action); command.Verb != "" {
|
|
169
|
+
return command
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return workflow.ForVerb(ResumeVerb(phase))
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
func readActiveSlug(root string) string {
|
|
165
177
|
if ws := devritespaths.WorkspaceOverride(root, ""); ws != "" {
|
|
166
178
|
return filepath.Base(ws)
|
|
@@ -180,6 +192,22 @@ func runMode(root string) string {
|
|
|
180
192
|
}
|
|
181
193
|
|
|
182
194
|
func currentSlice(workDir string) *SliceSnapshot {
|
|
195
|
+
if rawState, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
196
|
+
if active, ok := CursorField(strings.Split(string(rawState), "\n"), CursorActiveSlice); ok {
|
|
197
|
+
fields := strings.Fields(active)
|
|
198
|
+
if len(fields) > 0 {
|
|
199
|
+
active = fields[0]
|
|
200
|
+
if rawTasks, taskErr := os.ReadFile(filepath.Join(workDir, "tasks.md")); taskErr == nil {
|
|
201
|
+
matches := snapshotSliceHeadingRe.FindAllStringSubmatch(string(rawTasks), -1)
|
|
202
|
+
for i, match := range matches {
|
|
203
|
+
if match[1] == active {
|
|
204
|
+
return &SliceSnapshot{Index: i + 1, Total: len(matches), Name: active, State: "pending"}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
183
211
|
for _, name := range []string{"state.md", "tasks.md"} {
|
|
184
212
|
raw, err := os.ReadFile(filepath.Join(workDir, name))
|
|
185
213
|
if err != nil {
|
|
@@ -260,6 +288,9 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
260
288
|
continue
|
|
261
289
|
}
|
|
262
290
|
open := countOpenMarkers(string(raw))
|
|
291
|
+
if name == "questions.md" {
|
|
292
|
+
open = countOpenQuestions(string(raw))
|
|
293
|
+
}
|
|
263
294
|
if open > 0 {
|
|
264
295
|
return DriftSnapshot{Status: "open", Open: open}
|
|
265
296
|
}
|
|
@@ -267,11 +298,37 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
267
298
|
return DriftSnapshot{Status: "clear"}
|
|
268
299
|
}
|
|
269
300
|
|
|
301
|
+
func countOpenQuestions(text string) int {
|
|
302
|
+
open := 0
|
|
303
|
+
inQuestion := false
|
|
304
|
+
status := ""
|
|
305
|
+
finalize := func() {
|
|
306
|
+
if inQuestion && status == "open" {
|
|
307
|
+
open++
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for _, line := range strings.Split(text, "\n") {
|
|
311
|
+
lower := strings.ToLower(strings.TrimSpace(line))
|
|
312
|
+
switch {
|
|
313
|
+
case strings.HasPrefix(lower, "## q-"):
|
|
314
|
+
finalize()
|
|
315
|
+
inQuestion, status = true, ""
|
|
316
|
+
case inQuestion && strings.HasPrefix(lower, "status:"):
|
|
317
|
+
status = strings.TrimSpace(strings.TrimPrefix(lower, "status:"))
|
|
318
|
+
case inQuestion && strings.HasPrefix(lower, "## "):
|
|
319
|
+
finalize()
|
|
320
|
+
inQuestion = false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finalize()
|
|
324
|
+
return open
|
|
325
|
+
}
|
|
326
|
+
|
|
270
327
|
func countOpenMarkers(text string) int {
|
|
271
328
|
open := 0
|
|
272
329
|
for _, line := range strings.Split(text, "\n") {
|
|
273
330
|
l := strings.ToLower(strings.TrimSpace(line))
|
|
274
|
-
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]")
|
|
331
|
+
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]") {
|
|
275
332
|
open++
|
|
276
333
|
}
|
|
277
334
|
}
|