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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package migrate
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"os"
|
|
4
5
|
"path/filepath"
|
|
5
6
|
"strings"
|
|
6
7
|
"testing"
|
|
@@ -33,8 +34,11 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
assertFile(t, root, "work/alpha/spec.md", "spec\n")
|
|
36
|
-
assertFile(t, root, "work/alpha/
|
|
37
|
-
assertFile(t, root, "work/alpha/
|
|
37
|
+
assertFile(t, root, "work/alpha/evidence.md", "proof\n")
|
|
38
|
+
assertFile(t, root, "work/alpha/state.md", "status: done - shipped\n")
|
|
39
|
+
assertFileContains(t, root, "work/alpha/README.md", "phase: done")
|
|
40
|
+
assertMissing(t, root, "work/alpha/proof.md")
|
|
41
|
+
assertMissing(t, root, "work/alpha/status.md")
|
|
38
42
|
assertFile(t, root, "work/alpha/review.md", "review\n")
|
|
39
43
|
|
|
40
44
|
f, err := state.LoadFeature(root, "alpha")
|
|
@@ -57,8 +61,9 @@ func TestRunNormalizesCanonicalWorkLayout(t *testing.T) {
|
|
|
57
61
|
|
|
58
62
|
func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
59
63
|
root := t.TempDir()
|
|
60
|
-
testutil.WriteFile(t, filepath.Join(root, "features/beta/
|
|
61
|
-
testutil.WriteFile(t, filepath.Join(root, "features/beta/
|
|
64
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/feature.md"), "# Beta\n")
|
|
65
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/status.md"), "- Phase: prove\n")
|
|
66
|
+
testutil.WriteFile(t, filepath.Join(root, "features/beta/proof.md"), "evidence\n")
|
|
62
67
|
|
|
63
68
|
res, err := Run(root)
|
|
64
69
|
if err != nil {
|
|
@@ -67,6 +72,9 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
|
67
72
|
if got := strings.Join(res.Migrated, ","); got != "beta" {
|
|
68
73
|
t.Fatalf("Run normalized=%q, want beta", got)
|
|
69
74
|
}
|
|
75
|
+
assertFile(t, root, "features/beta/README.md", "# Beta\n")
|
|
76
|
+
assertFile(t, root, "features/beta/state.md", "- Phase: prove\n")
|
|
77
|
+
assertFile(t, root, "features/beta/evidence.md", "evidence\n")
|
|
70
78
|
assertFile(t, root, "features/beta/status.md", "- Phase: prove\n")
|
|
71
79
|
assertFile(t, root, "features/beta/proof.md", "evidence\n")
|
|
72
80
|
|
|
@@ -80,6 +88,13 @@ func TestRunNormalizesLiveFeatureAliases(t *testing.T) {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
func TestMapLegacyPhase(t *testing.T) {
|
|
91
|
+
for _, phase := range state.LifecyclePhases() {
|
|
92
|
+
got, ok := mapLegacyPhase(string(phase))
|
|
93
|
+
if !ok || got != phase {
|
|
94
|
+
t.Fatalf("mapLegacyPhase(%q)=(%q,%v), want canonical phase", phase, got, ok)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
for _, tc := range []struct {
|
|
84
99
|
word string
|
|
85
100
|
want state.Phase
|
|
@@ -87,7 +102,6 @@ func TestMapLegacyPhase(t *testing.T) {
|
|
|
87
102
|
}{
|
|
88
103
|
{word: "specced", want: state.PhaseSpec, ok: true},
|
|
89
104
|
{word: "in-progress", want: state.PhaseBuild, ok: true},
|
|
90
|
-
{word: "temper", want: state.PhaseTemper, ok: true},
|
|
91
105
|
{word: "reviewing", want: state.PhaseReview, ok: true},
|
|
92
106
|
{word: "done - shipped", want: state.PhaseDone, ok: true},
|
|
93
107
|
{word: "mystery"},
|
|
@@ -105,3 +119,17 @@ func assertFile(t *testing.T, root, rel, want string) {
|
|
|
105
119
|
t.Fatalf("%s=%q, want %q", rel, got, want)
|
|
106
120
|
}
|
|
107
121
|
}
|
|
122
|
+
|
|
123
|
+
func assertFileContains(t *testing.T, root, rel, want string) {
|
|
124
|
+
t.Helper()
|
|
125
|
+
if got := testutil.ReadFile(t, filepath.Join(root, rel)); !strings.Contains(got, want) {
|
|
126
|
+
t.Fatalf("%s=%q, want it to contain %q", rel, got, want)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func assertMissing(t *testing.T, root, rel string) {
|
|
131
|
+
t.Helper()
|
|
132
|
+
if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
|
|
133
|
+
t.Fatalf("%s exists or stat failed with %v, want missing", rel, err)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Command workflowmanifest writes the deterministic cross-language derivative
|
|
2
|
+
// of state.WorkflowPhases. The typed Go registry remains the authority.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"flag"
|
|
9
|
+
"fmt"
|
|
10
|
+
"os"
|
|
11
|
+
|
|
12
|
+
"github.com/devrites/devrites/internal/state"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func main() {
|
|
16
|
+
outPath := flag.String("out", "workflow_manifest.json", "output path")
|
|
17
|
+
check := flag.Bool("check", false, "fail when the output is stale")
|
|
18
|
+
flag.Parse()
|
|
19
|
+
|
|
20
|
+
document := struct {
|
|
21
|
+
GeneratedBy string `json:"generatedBy"`
|
|
22
|
+
SchemaVersion int `json:"schemaVersion"`
|
|
23
|
+
Phases []state.WorkflowPhase `json:"phases"`
|
|
24
|
+
CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
|
|
25
|
+
}{
|
|
26
|
+
GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
|
|
27
|
+
SchemaVersion: state.SchemaVersion,
|
|
28
|
+
Phases: state.WorkflowPhases(),
|
|
29
|
+
CursorKeyAliases: state.CursorKeyAliases(),
|
|
30
|
+
}
|
|
31
|
+
data, err := json.MarshalIndent(document, "", " ")
|
|
32
|
+
if err != nil {
|
|
33
|
+
fatal(err)
|
|
34
|
+
}
|
|
35
|
+
data = append(data, '\n')
|
|
36
|
+
if *check {
|
|
37
|
+
current, err := os.ReadFile(*outPath)
|
|
38
|
+
if err != nil {
|
|
39
|
+
fatal(err)
|
|
40
|
+
}
|
|
41
|
+
if !bytes.Equal(current, data) {
|
|
42
|
+
fatal(fmt.Errorf("%s is stale; run go generate ./internal/state", *outPath))
|
|
43
|
+
}
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if err := os.WriteFile(*outPath, data, 0o644); err != nil {
|
|
47
|
+
fatal(err)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func fatal(err error) {
|
|
52
|
+
fmt.Fprintln(os.Stderr, err)
|
|
53
|
+
os.Exit(1)
|
|
54
|
+
}
|
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
package state
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"sort"
|
|
4
5
|
"strings"
|
|
5
6
|
"unicode"
|
|
6
7
|
)
|
|
7
8
|
|
|
9
|
+
// Canonical cursor keys. Callers use these constants so schema spelling changes
|
|
10
|
+
// remain local to the cursor package; normalizeCursorKey still accepts legacy
|
|
11
|
+
// presentation aliases at the text boundary.
|
|
12
|
+
const (
|
|
13
|
+
CursorPhase = "phase"
|
|
14
|
+
CursorStatus = "status"
|
|
15
|
+
CursorNextAction = "next_action"
|
|
16
|
+
CursorQuestionID = "question_id"
|
|
17
|
+
CursorActiveSlice = "active_slice"
|
|
18
|
+
CursorAFKSlicesRemaining = "afk_slices_remaining"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
var cursorKeyAliases = map[string]string{
|
|
22
|
+
"nextstep": CursorNextAction,
|
|
23
|
+
"qid": CursorQuestionID,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// CursorKeyAlias is the generated-manifest view of a compatibility key.
|
|
27
|
+
type CursorKeyAlias struct {
|
|
28
|
+
Alias string `json:"alias"`
|
|
29
|
+
Canonical string `json:"canonical"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CursorKeyAliases returns compatibility keys in deterministic order.
|
|
33
|
+
func CursorKeyAliases() []CursorKeyAlias {
|
|
34
|
+
aliases := make([]string, 0, len(cursorKeyAliases))
|
|
35
|
+
for alias := range cursorKeyAliases {
|
|
36
|
+
aliases = append(aliases, alias)
|
|
37
|
+
}
|
|
38
|
+
sort.Strings(aliases)
|
|
39
|
+
out := make([]CursorKeyAlias, 0, len(aliases))
|
|
40
|
+
for _, alias := range aliases {
|
|
41
|
+
out = append(out, CursorKeyAlias{Alias: alias, Canonical: cursorKeyAliases[alias]})
|
|
42
|
+
}
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
8
46
|
type cursorLineKind uint8
|
|
9
47
|
|
|
10
48
|
const (
|
|
@@ -73,12 +111,9 @@ func normalizeCursorKey(key string) string {
|
|
|
73
111
|
b.WriteRune(r)
|
|
74
112
|
}
|
|
75
113
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return
|
|
79
|
-
case "qid":
|
|
80
|
-
return "questionid"
|
|
81
|
-
default:
|
|
82
|
-
return b.String()
|
|
114
|
+
normalized := b.String()
|
|
115
|
+
if canonical, ok := cursorKeyAliases[normalized]; ok {
|
|
116
|
+
return normalizeCursorKey(canonical)
|
|
83
117
|
}
|
|
118
|
+
return normalized
|
|
84
119
|
}
|
|
@@ -66,8 +66,8 @@ func featureDir(root, slug string) string {
|
|
|
66
66
|
|
|
67
67
|
// ListFeatures returns the slugs of every feature under root — directories under
|
|
68
68
|
// canonical work/ plus compatibility features/ recognized as a feature — sorted.
|
|
69
|
-
// A directory is a feature if it has a
|
|
70
|
-
// ledger (state.md), so a live workspace the pack created without a
|
|
69
|
+
// A directory is a feature if it has a workspace map OR the working-state
|
|
70
|
+
// ledger (state.md), so a live workspace the pack created without a map
|
|
71
71
|
// still lists. Missing layout directories yield an empty list, not an error.
|
|
72
72
|
func ListFeatures(root string) ([]string, error) {
|
|
73
73
|
seen := map[string]bool{}
|
|
@@ -96,11 +96,18 @@ func ListFeatures(root string) ([]string, error) {
|
|
|
96
96
|
return slugs, nil
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// isFeatureDir reports whether dir
|
|
100
|
-
//
|
|
99
|
+
// isFeatureDir reports whether dir carries a workspace map or the working-state
|
|
100
|
+
// ledger. Either is a sufficient phase source for LoadFeature.
|
|
101
101
|
func isFeatureDir(dir string) bool {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
if regularFileExists(filepath.Join(dir, LedgerFile)) {
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
for _, name := range workspaceMapFiles {
|
|
106
|
+
if regularFileExists(filepath.Join(dir, name)) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
func regularFileExists(path string) bool {
|
|
@@ -108,35 +115,26 @@ func regularFileExists(path string) bool {
|
|
|
108
115
|
return err == nil && info.Mode().IsRegular()
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
// CandidateFiles returns the paths that make up feature <slug>: its feature.md
|
|
112
|
-
// manifest plus every file that can satisfy a section (canonical names and the
|
|
113
|
-
// transitional aliases), in a stable order. Not all need exist; callers that
|
|
114
|
-
// fingerprint the feature stat and hash the ones that do, so including the alias
|
|
115
|
-
// names means a change to either the canonical or the alias file is detected.
|
|
116
|
-
func CandidateFiles(root, slug string) []string {
|
|
117
|
-
dir := featureDir(root, slug)
|
|
118
|
-
files := []string{filepath.Join(dir, "feature.md")}
|
|
119
|
-
for _, s := range Sections {
|
|
120
|
-
for _, name := range sectionFiles[s] {
|
|
121
|
-
files = append(files, filepath.Join(dir, name))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return files
|
|
125
|
-
}
|
|
126
|
-
|
|
127
118
|
// LoadFeature reads feature <slug> under root. The phase comes from the live
|
|
128
|
-
// working-state ledger when it declares one, otherwise from
|
|
129
|
-
// frontmatter. A feature with neither a
|
|
130
|
-
// Section content is read from the canonical section files or their
|
|
119
|
+
// working-state ledger when it declares one, otherwise from workspace-map
|
|
120
|
+
// frontmatter. A feature with neither a map nor a ledger does not exist.
|
|
121
|
+
// Section content is read from the canonical section files or their
|
|
131
122
|
// aliases (see sectionFiles).
|
|
132
123
|
func LoadFeature(root, slug string) (*Feature, error) {
|
|
133
124
|
dir := featureDir(root, slug)
|
|
134
125
|
|
|
135
|
-
manifest
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
var manifest []byte
|
|
127
|
+
for _, name := range workspaceMapFiles {
|
|
128
|
+
data, err := os.ReadFile(filepath.Join(dir, name))
|
|
129
|
+
if err == nil {
|
|
130
|
+
manifest = data
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
134
|
+
return nil, fmt.Errorf("feature %q: read %s: %w", slug, name, err)
|
|
135
|
+
}
|
|
138
136
|
}
|
|
139
|
-
hasManifest :=
|
|
137
|
+
hasManifest := manifest != nil
|
|
140
138
|
hasLedger := regularFileExists(filepath.Join(dir, LedgerFile))
|
|
141
139
|
if !hasManifest && !hasLedger {
|
|
142
140
|
return nil, fmt.Errorf("feature %q not found", slug)
|
|
@@ -166,7 +164,7 @@ func LoadFeature(root, slug string) (*Feature, error) {
|
|
|
166
164
|
phase = manifestPhase
|
|
167
165
|
}
|
|
168
166
|
if phase == "" {
|
|
169
|
-
return nil, fmt.Errorf("feature %q: no phase in
|
|
167
|
+
return nil, fmt.Errorf("feature %q: no phase in workspace-map frontmatter or %s ledger", slug, LedgerFile)
|
|
170
168
|
}
|
|
171
169
|
if !KnownPhase(phase) {
|
|
172
170
|
return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
|
|
@@ -190,22 +188,12 @@ func sectionPresentAny(dir string, s Section) bool {
|
|
|
190
188
|
return false
|
|
191
189
|
}
|
|
192
190
|
|
|
193
|
-
// PhaseFromLedger reads a phase from the canonical cursor table or the legacy
|
|
194
|
-
// "- Phase: <p>" form, accepting only phases the engine understands.
|
|
195
|
-
func PhaseFromLedger(path string) Phase {
|
|
196
|
-
phase, declared := declaredPhaseFromLedger(path)
|
|
197
|
-
if declared && KnownPhase(phase) {
|
|
198
|
-
return phase
|
|
199
|
-
}
|
|
200
|
-
return ""
|
|
201
|
-
}
|
|
202
|
-
|
|
203
191
|
func declaredPhaseFromLedger(path string) (Phase, bool) {
|
|
204
192
|
raw, err := os.ReadFile(path)
|
|
205
193
|
if err != nil {
|
|
206
194
|
return "", false
|
|
207
195
|
}
|
|
208
|
-
value, ok := CursorField(strings.Split(string(raw), "\n"),
|
|
196
|
+
value, ok := CursorField(strings.Split(string(raw), "\n"), CursorPhase)
|
|
209
197
|
if !ok {
|
|
210
198
|
return "", false
|
|
211
199
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
package state
|
|
2
2
|
|
|
3
|
+
//go:generate go run ./cmd/workflowmanifest -out workflow_manifest.json
|
|
4
|
+
|
|
3
5
|
// SchemaVersion is the .devrites state-schema version this engine understands.
|
|
4
|
-
// A
|
|
6
|
+
// A workspace map may declare its own schemaVersion in frontmatter; the engine
|
|
5
7
|
// refuses a version newer than this (see LoadFeature) and otherwise reads the
|
|
6
8
|
// files, which evolve additively.
|
|
7
9
|
const SchemaVersion = 1
|
|
8
10
|
|
|
11
|
+
const (
|
|
12
|
+
WorkspaceMapFile = "README.md"
|
|
13
|
+
EvidenceFile = "evidence.md"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
var workspaceMapFiles = []string{WorkspaceMapFile, "feature.md", "index.md"}
|
|
17
|
+
|
|
18
|
+
// WorkspaceMapFiles returns the canonical workspace map followed by readable aliases.
|
|
19
|
+
func WorkspaceMapFiles() []string {
|
|
20
|
+
return append([]string(nil), workspaceMapFiles...)
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
// Section is one single-concern completeness file within a feature directory.
|
|
10
24
|
// Splitting a feature into small files (rather than one long document) keeps
|
|
11
25
|
// each file context-cheap and makes completeness self-evident.
|
|
@@ -31,23 +45,23 @@ var Sections = []Section{
|
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
// sectionFiles lists the filenames that can satisfy each section, canonical name
|
|
34
|
-
// first, then
|
|
35
|
-
//
|
|
48
|
+
// first, then supported aliases — the same mapping `devrites-engine migrate`
|
|
49
|
+
// normalizes (proof→evidence, status→state). A section
|
|
36
50
|
// counts as present if ANY of its files has real content, so the engine reads a
|
|
37
|
-
// live workspace before the pack sweep converges the filenames. The
|
|
38
|
-
//
|
|
51
|
+
// live workspace before the pack sweep converges the filenames. The workspace
|
|
52
|
+
// map is not a section; it is handled separately in LoadFeature.
|
|
39
53
|
var sectionFiles = map[Section][]string{
|
|
40
54
|
SectionSpec: {"spec.md"},
|
|
41
55
|
SectionPlan: {"plan.md"},
|
|
42
56
|
SectionDecisions: {"decisions.md"},
|
|
43
57
|
SectionTasks: {"tasks.md"},
|
|
44
|
-
SectionProof: {
|
|
45
|
-
SectionStatus: {"
|
|
58
|
+
SectionProof: {EvidenceFile, "proof.md"},
|
|
59
|
+
SectionStatus: {"state.md", "status.md"},
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
// LedgerFile is the working-state ledger the live pack writes. It carries the
|
|
49
63
|
// phase in its canonical cursor table (legacy "- Phase: <p>" remains readable)
|
|
50
|
-
// when no
|
|
64
|
+
// when no workspace map declares one, and it satisfies the status section.
|
|
51
65
|
const LedgerFile = "state.md"
|
|
52
66
|
|
|
53
67
|
// Phase is a workflow state. The order mirrors the rite-* arc.
|
|
@@ -70,36 +84,149 @@ const (
|
|
|
70
84
|
PhaseDone Phase = "done" // archived completion
|
|
71
85
|
)
|
|
72
86
|
|
|
73
|
-
//
|
|
74
|
-
// phase
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
// phaseDefinition is the single source of truth for lifecycle order and
|
|
88
|
+
// phase-specific behavior. Workflow topology is versioned application logic,
|
|
89
|
+
// rather than deploy-time configuration, so keeping it typed and ordered makes
|
|
90
|
+
// additions compile-visible and lets every consumer derive the same arc.
|
|
91
|
+
type phaseDefinition struct {
|
|
92
|
+
phase Phase
|
|
93
|
+
resumeVerb string
|
|
94
|
+
required []Section
|
|
95
|
+
aliases []string
|
|
96
|
+
workspaceRequired []string
|
|
97
|
+
proofRequired bool
|
|
98
|
+
blocksOpenQuestions bool
|
|
99
|
+
shippable bool
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var (
|
|
103
|
+
sectionsSpec = []Section{SectionSpec}
|
|
104
|
+
sectionsPlan = []Section{SectionSpec, SectionPlan}
|
|
105
|
+
sectionsBuild = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
|
|
106
|
+
sectionsProof = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
|
|
107
|
+
sectionsComplete = []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
|
|
108
|
+
|
|
109
|
+
workspaceFrame = []string{"state.md"}
|
|
110
|
+
workspaceSpec = []string{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
111
|
+
workspacePlan = []string{"brief.md", "spec.md", "architecture.md", "plan.md", "tasks.md", "traceability.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
|
|
112
|
+
workspaceProof = append(append([]string(nil), workspacePlan...), "evidence.md", "touched-files.md")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Completeness is phase-relative: a section not yet required (e.g. proof during
|
|
116
|
+
// spec) never blocks. Requirements are additive down the arc. Plan resumes at
|
|
117
|
+
// define because plan is the artifact state produced by rite-define; done has no
|
|
118
|
+
// resume command.
|
|
119
|
+
var phaseDefinitions = []phaseDefinition{
|
|
120
|
+
{phase: PhaseFrame, resumeVerb: "frame", workspaceRequired: workspaceFrame},
|
|
121
|
+
{phase: PhaseSpec, resumeVerb: "spec", required: sectionsSpec, aliases: []string{"specced", "specifying"}, workspaceRequired: workspaceSpec},
|
|
122
|
+
{phase: PhaseTemper, resumeVerb: "temper", required: sectionsSpec, aliases: []string{"tempered", "tempering"}, workspaceRequired: workspaceSpec},
|
|
123
|
+
{phase: PhaseDefine, resumeVerb: "define", required: sectionsPlan, aliases: []string{"defined", "defining"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
124
|
+
{phase: PhasePlan, resumeVerb: "define", required: sectionsPlan, aliases: []string{"planned", "planning"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
125
|
+
{phase: PhaseVet, resumeVerb: "vet", required: sectionsBuild, aliases: []string{"vetted", "vetting"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
126
|
+
{phase: PhaseBuild, resumeVerb: "build", required: sectionsBuild, aliases: []string{"building", "wip", "in", "in-progress"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
127
|
+
{phase: PhaseConverge, resumeVerb: "converge", required: sectionsBuild, aliases: []string{"converged", "converging"}, workspaceRequired: workspacePlan, blocksOpenQuestions: true},
|
|
128
|
+
{phase: PhaseProve, resumeVerb: "prove", required: sectionsProof, aliases: []string{"proving", "proven", "testing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
129
|
+
{phase: PhasePolish, resumeVerb: "polish", required: sectionsProof, aliases: []string{"polished", "polishing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
130
|
+
{phase: PhaseReview, resumeVerb: "review", required: sectionsProof, aliases: []string{"reviewed", "reviewing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true},
|
|
131
|
+
{phase: PhaseSeal, resumeVerb: "seal", required: sectionsComplete, aliases: []string{"sealed", "sealing"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
132
|
+
{phase: PhaseShip, resumeVerb: "ship", required: sectionsComplete, aliases: []string{"shipped", "shipping"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
133
|
+
{phase: PhaseDone, required: sectionsComplete, aliases: []string{"closed", "complete", "completed"}, workspaceRequired: workspaceProof, proofRequired: true, blocksOpenQuestions: true, shippable: true},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// WorkflowPhase is the read-only cross-format view used to generate the compact
|
|
137
|
+
// manifest consumed by non-Go release tooling.
|
|
138
|
+
type WorkflowPhase struct {
|
|
139
|
+
ID Phase `json:"id"`
|
|
140
|
+
ResumeVerb string `json:"resumeVerb,omitempty"`
|
|
141
|
+
Aliases []string `json:"aliases,omitempty"`
|
|
142
|
+
WorkspaceRequired []string `json:"workspaceRequired"`
|
|
143
|
+
ProofRequired bool `json:"proofRequired,omitempty"`
|
|
144
|
+
BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
|
|
145
|
+
Shippable bool `json:"shippable,omitempty"`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// WorkflowPhases returns copied metadata suitable for deterministic generation.
|
|
149
|
+
func WorkflowPhases() []WorkflowPhase {
|
|
150
|
+
out := make([]WorkflowPhase, 0, len(phaseDefinitions))
|
|
151
|
+
for _, definition := range phaseDefinitions {
|
|
152
|
+
out = append(out, WorkflowPhase{
|
|
153
|
+
ID: definition.phase,
|
|
154
|
+
ResumeVerb: definition.resumeVerb,
|
|
155
|
+
Aliases: append([]string(nil), definition.aliases...),
|
|
156
|
+
WorkspaceRequired: append([]string(nil), definition.workspaceRequired...),
|
|
157
|
+
ProofRequired: definition.proofRequired,
|
|
158
|
+
BlocksOpenQuestions: definition.blocksOpenQuestions,
|
|
159
|
+
Shippable: definition.shippable,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// LifecyclePhases returns the ordered lifecycle. The returned slice is a copy,
|
|
166
|
+
// so callers cannot mutate the registry.
|
|
167
|
+
func LifecyclePhases() []Phase {
|
|
168
|
+
phases := make([]Phase, len(phaseDefinitions))
|
|
169
|
+
for i, definition := range phaseDefinitions {
|
|
170
|
+
phases[i] = definition.phase
|
|
171
|
+
}
|
|
172
|
+
return phases
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func definitionFor(p Phase) (phaseDefinition, bool) {
|
|
176
|
+
for _, definition := range phaseDefinitions {
|
|
177
|
+
if definition.phase == p {
|
|
178
|
+
return definition, true
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return phaseDefinition{}, false
|
|
91
182
|
}
|
|
92
183
|
|
|
93
184
|
// KnownPhase reports whether p is a phase the engine understands.
|
|
94
185
|
func KnownPhase(p Phase) bool {
|
|
95
|
-
_, ok :=
|
|
186
|
+
_, ok := definitionFor(p)
|
|
96
187
|
return ok
|
|
97
188
|
}
|
|
98
189
|
|
|
190
|
+
// ResumeVerb returns the public rite verb that resumes p. Terminal and unknown
|
|
191
|
+
// phases return an empty string.
|
|
192
|
+
func ResumeVerb(p Phase) string {
|
|
193
|
+
definition, ok := definitionFor(p)
|
|
194
|
+
if !ok {
|
|
195
|
+
return ""
|
|
196
|
+
}
|
|
197
|
+
return definition.resumeVerb
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ShippablePhase reports whether p is allowed to claim a sealed/shipped result.
|
|
201
|
+
func ShippablePhase(p Phase) bool {
|
|
202
|
+
definition, ok := definitionFor(p)
|
|
203
|
+
return ok && definition.shippable
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// PhaseForName resolves a canonical phase ID or compatibility alias. Callers
|
|
207
|
+
// should normalize surrounding syntax before querying it.
|
|
208
|
+
func PhaseForName(name string) (Phase, bool) {
|
|
209
|
+
for _, definition := range phaseDefinitions {
|
|
210
|
+
if string(definition.phase) == name {
|
|
211
|
+
return definition.phase, true
|
|
212
|
+
}
|
|
213
|
+
for _, alias := range definition.aliases {
|
|
214
|
+
if alias == name {
|
|
215
|
+
return definition.phase, true
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return "", false
|
|
220
|
+
}
|
|
221
|
+
|
|
99
222
|
// RequiredSections returns the sections required to complete the given phase,
|
|
100
223
|
// in canonical Sections order.
|
|
101
224
|
func RequiredSections(p Phase) []Section {
|
|
102
|
-
|
|
225
|
+
definition, ok := definitionFor(p)
|
|
226
|
+
if !ok {
|
|
227
|
+
return nil
|
|
228
|
+
}
|
|
229
|
+
want := definition.required
|
|
103
230
|
set := make(map[Section]bool, len(want))
|
|
104
231
|
for _, s := range want {
|
|
105
232
|
set[s] = true
|
|
@@ -23,6 +23,7 @@ var (
|
|
|
23
23
|
snapshotSliceStateRe = regexp.MustCompile(`[[:space:]]+(built|pending)[[:space:]]*$`)
|
|
24
24
|
snapshotSliceSepRe = regexp.MustCompile(`[[:space:]]+[^[:alnum:][:space:]]+[[:space:]]*$`)
|
|
25
25
|
snapshotSliceCheckedRe = regexp.MustCompile(`\[[xX]\]`)
|
|
26
|
+
snapshotSliceHeadingRe = regexp.MustCompile(`(?m)^##[[:space:]]+(SLICE-[0-9]{3})(?:[[:space:]]+.*)?$`)
|
|
26
27
|
snapshotTouchedPathRe = regexp.MustCompile("`([^`]+)`")
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -163,13 +164,13 @@ func Snapshot(root, slug string) (*WorkspaceSnapshot, error) {
|
|
|
163
164
|
|
|
164
165
|
func nextCommand(workDir string, phase Phase) workflow.Command {
|
|
165
166
|
if raw, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
166
|
-
if action, ok := CursorField(strings.Split(string(raw), "\n"),
|
|
167
|
+
if action, ok := CursorField(strings.Split(string(raw), "\n"), CursorNextAction); ok {
|
|
167
168
|
if command := workflow.ForAction(action); command.Verb != "" {
|
|
168
169
|
return command
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
|
-
return workflow.
|
|
173
|
+
return workflow.ForVerb(ResumeVerb(phase))
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
func readActiveSlug(root string) string {
|
|
@@ -191,6 +192,22 @@ func runMode(root string) string {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
func currentSlice(workDir string) *SliceSnapshot {
|
|
195
|
+
if rawState, err := os.ReadFile(filepath.Join(workDir, LedgerFile)); err == nil {
|
|
196
|
+
if active, ok := CursorField(strings.Split(string(rawState), "\n"), CursorActiveSlice); ok {
|
|
197
|
+
fields := strings.Fields(active)
|
|
198
|
+
if len(fields) > 0 {
|
|
199
|
+
active = fields[0]
|
|
200
|
+
if rawTasks, taskErr := os.ReadFile(filepath.Join(workDir, "tasks.md")); taskErr == nil {
|
|
201
|
+
matches := snapshotSliceHeadingRe.FindAllStringSubmatch(string(rawTasks), -1)
|
|
202
|
+
for i, match := range matches {
|
|
203
|
+
if match[1] == active {
|
|
204
|
+
return &SliceSnapshot{Index: i + 1, Total: len(matches), Name: active, State: "pending"}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
194
211
|
for _, name := range []string{"state.md", "tasks.md"} {
|
|
195
212
|
raw, err := os.ReadFile(filepath.Join(workDir, name))
|
|
196
213
|
if err != nil {
|
|
@@ -271,6 +288,9 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
271
288
|
continue
|
|
272
289
|
}
|
|
273
290
|
open := countOpenMarkers(string(raw))
|
|
291
|
+
if name == "questions.md" {
|
|
292
|
+
open = countOpenQuestions(string(raw))
|
|
293
|
+
}
|
|
274
294
|
if open > 0 {
|
|
275
295
|
return DriftSnapshot{Status: "open", Open: open}
|
|
276
296
|
}
|
|
@@ -278,11 +298,37 @@ func driftSummary(workDir string) DriftSnapshot {
|
|
|
278
298
|
return DriftSnapshot{Status: "clear"}
|
|
279
299
|
}
|
|
280
300
|
|
|
301
|
+
func countOpenQuestions(text string) int {
|
|
302
|
+
open := 0
|
|
303
|
+
inQuestion := false
|
|
304
|
+
status := ""
|
|
305
|
+
finalize := func() {
|
|
306
|
+
if inQuestion && status == "open" {
|
|
307
|
+
open++
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for _, line := range strings.Split(text, "\n") {
|
|
311
|
+
lower := strings.ToLower(strings.TrimSpace(line))
|
|
312
|
+
switch {
|
|
313
|
+
case strings.HasPrefix(lower, "## q-"):
|
|
314
|
+
finalize()
|
|
315
|
+
inQuestion, status = true, ""
|
|
316
|
+
case inQuestion && strings.HasPrefix(lower, "status:"):
|
|
317
|
+
status = strings.TrimSpace(strings.TrimPrefix(lower, "status:"))
|
|
318
|
+
case inQuestion && strings.HasPrefix(lower, "## "):
|
|
319
|
+
finalize()
|
|
320
|
+
inQuestion = false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
finalize()
|
|
324
|
+
return open
|
|
325
|
+
}
|
|
326
|
+
|
|
281
327
|
func countOpenMarkers(text string) int {
|
|
282
328
|
open := 0
|
|
283
329
|
for _, line := range strings.Split(text, "\n") {
|
|
284
330
|
l := strings.ToLower(strings.TrimSpace(line))
|
|
285
|
-
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]")
|
|
331
|
+
if strings.Contains(l, "status: open") || strings.Contains(l, "- [ ]") {
|
|
286
332
|
open++
|
|
287
333
|
}
|
|
288
334
|
}
|