devrites 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/docs/adr/0001-go-engine-as-control-plane.md +43 -0
  4. package/docs/adr/0002-dual-host-harness.md +34 -0
  5. package/docs/adr/0003-gate-model-hitl-pause.md +38 -0
  6. package/docs/adr/0004-state-schema-phases-sections.md +47 -0
  7. package/docs/adr/0005-hooks-as-engine-subcommands.md +39 -0
  8. package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +50 -0
  9. package/docs/adr/0007-canonical-live-workspace-filenames.md +36 -0
  10. package/docs/adr/0008-sanctioned-engine-network-boundary.md +33 -0
  11. package/docs/adr/README.md +58 -0
  12. package/docs/command-map.md +1 -1
  13. package/docs/engine/state-schema.md +18 -14
  14. package/docs/flow.md +1 -1
  15. package/docs/research/go-authoritative-workflow-schema-and-quality-2026-07-20.md +212 -0
  16. package/engine/hooks_workspace.go +7 -7
  17. package/engine/internal/gate/gate.go +4 -4
  18. package/engine/internal/gate/gate_test.go +2 -2
  19. package/engine/internal/lib/cursor_compat_test.go +29 -0
  20. package/engine/internal/lib/preamble.go +1 -1
  21. package/engine/internal/lib/preamble_questions_test.go +10 -0
  22. package/engine/internal/lib/progress.go +59 -20
  23. package/engine/internal/lib/resolve.go +15 -6
  24. package/engine/internal/lib/resolve_remediation_test.go +9 -0
  25. package/engine/internal/lib/tickafk.go +2 -2
  26. package/engine/internal/migrate/migrate.go +18 -44
  27. package/engine/internal/migrate/migrate_test.go +33 -5
  28. package/engine/internal/state/cmd/workflowmanifest/main.go +54 -0
  29. package/engine/internal/state/cursor.go +42 -7
  30. package/engine/internal/state/feature.go +29 -41
  31. package/engine/internal/state/schema.go +155 -28
  32. package/engine/internal/state/snapshot.go +49 -3
  33. package/engine/internal/state/state_test.go +77 -6
  34. package/engine/internal/state/workflow_manifest.json +310 -0
  35. package/engine/internal/workflow/commands.go +0 -36
  36. package/engine/internal/workflow/commands_test.go +0 -17
  37. package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
  38. package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
  39. package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
  40. package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
  41. package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
  42. package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
  43. package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
  44. package/engine/tests/adr_0004_required_by_phase_test.go +1 -18
  45. package/engine/tests/budget_test.go +2 -2
  46. package/engine/tests/lib_parity_test.go +1 -1
  47. package/engine/tests/meta_test.go +3 -3
  48. package/engine/tests/migrate_cli_test.go +24 -22
  49. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  50. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  51. package/pack/generated/codex/AGENTS.md +1 -1
  52. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +5 -3
  53. package/package.json +2 -1
  54. package/scripts/codex-generate.sh +1 -1
  55. package/scripts/grade-feature.sh +5 -3
  56. package/scripts/run-outcome-evals.sh +21 -0
  57. package/scripts/validate-workflow-security.py +3 -8
  58. package/scripts/validate-workspace-schema.py +9 -73
  59. package/scripts/validate.sh +10 -0
  60. package/scripts/workflow_schema.py +69 -0
@@ -58,25 +58,26 @@ func TestMigrateNormalizesCanonicalWorkSchema(t *testing.T) {
58
58
  }
59
59
 
60
60
  feat := filepath.Join(root, "work", slug)
61
- // evidence.md proof.md, state.md status.md, plus a generated feature.md.
62
- for _, want := range []string{"feature.md", "spec.md", "plan.md", "decisions.md", "tasks.md", "proof.md", "status.md"} {
61
+ // Current canonical files stay canonical; migration adds only the missing map.
62
+ for _, want := range []string{"README.md", "state.md", "spec.md", "plan.md", "decisions.md", "tasks.md", "evidence.md"} {
63
63
  if _, err := os.Stat(filepath.Join(feat, want)); err != nil {
64
64
  t.Errorf("missing new-schema file %s: %v", want, err)
65
65
  }
66
66
  }
67
- fm, err := os.ReadFile(filepath.Join(feat, "feature.md"))
67
+ readme, err := os.ReadFile(filepath.Join(feat, "README.md"))
68
68
  if err != nil {
69
69
  t.Fatal(err)
70
70
  }
71
- if !strings.Contains(string(fm), "phase: temper") {
72
- t.Errorf("feature.md phase not derived from state.md\n%s", fm)
71
+ if !strings.Contains(string(readme), "phase: temper") {
72
+ t.Errorf("README.md phase not derived from state.md\n%s", readme)
73
73
  }
74
- if !strings.Contains(string(fm), "schemaVersion: 1") {
75
- t.Errorf("feature.md missing schemaVersion\n%s", fm)
74
+ if !strings.Contains(string(readme), "schemaVersion: 1") {
75
+ t.Errorf("README.md missing schemaVersion\n%s", readme)
76
76
  }
77
- proof, _ := os.ReadFile(filepath.Join(feat, "proof.md"))
78
- if !strings.Contains(string(proof), "Tests green") {
79
- t.Errorf("proof.md did not carry evidence.md content\n%s", proof)
77
+ for _, unwanted := range []string{"feature.md", "proof.md", "status.md"} {
78
+ if _, err := os.Stat(filepath.Join(feat, unwanted)); !os.IsNotExist(err) {
79
+ t.Errorf("migration created legacy alias %s: %v", unwanted, err)
80
+ }
80
81
  }
81
82
 
82
83
  // A backup of the pre-migration state must exist.
@@ -127,11 +128,12 @@ func TestMigrateNormalizesLiveFeatureInPlace(t *testing.T) {
127
128
  t.Fatal(err)
128
129
  }
129
130
  files := map[string]string{
130
- "state.md": "# State\n\n- Phase: prove\n- Status: running\n",
131
+ "feature.md": "# Legacy feature map\n",
132
+ "status.md": "# State\n\n- Phase: prove\n- Status: running\n",
131
133
  "spec.md": "# Spec\n\nRotate tokens.\n",
132
134
  "plan.md": "# Plan\n\nStep 1, step 2.\n",
133
135
  "tasks.md": "# Tasks\n\n- [x] one\n",
134
- "evidence.md": "# Evidence\n\nTests green.\n",
136
+ "proof.md": "# Evidence\n\nTests green.\n",
135
137
  "decisions.md": "# Decisions\n\nUse HMAC.\n",
136
138
  }
137
139
  for name, body := range files {
@@ -147,25 +149,25 @@ func TestMigrateNormalizesLiveFeatureInPlace(t *testing.T) {
147
149
  if !strings.Contains(out, "migrated 1 feature(s)") {
148
150
  t.Errorf("unexpected migrate output\n%s", out)
149
151
  }
150
- for _, want := range []string{"feature.md", "proof.md", "status.md", "state.md", "evidence.md"} {
152
+ for _, want := range []string{"README.md", "feature.md", "proof.md", "status.md", "state.md", "evidence.md"} {
151
153
  if _, err := os.Stat(filepath.Join(feat, want)); err != nil {
152
154
  t.Errorf("missing normalized file %s: %v", want, err)
153
155
  }
154
156
  }
155
- fm, err := os.ReadFile(filepath.Join(feat, "feature.md"))
157
+ readme, err := os.ReadFile(filepath.Join(feat, "README.md"))
156
158
  if err != nil {
157
159
  t.Fatal(err)
158
160
  }
159
- if !strings.Contains(string(fm), "phase: prove") {
160
- t.Errorf("feature.md phase not derived from ledger\n%s", fm)
161
+ if !strings.Contains(string(readme), "Legacy feature map") {
162
+ t.Errorf("README.md did not carry feature.md content\n%s", readme)
161
163
  }
162
- status, _ := os.ReadFile(filepath.Join(feat, "status.md"))
163
- if !strings.Contains(string(status), "running") {
164
- t.Errorf("status.md did not carry state.md content\n%s", status)
164
+ stateFile, _ := os.ReadFile(filepath.Join(feat, "state.md"))
165
+ if !strings.Contains(string(stateFile), "running") {
166
+ t.Errorf("state.md did not carry status.md content\n%s", stateFile)
165
167
  }
166
- proof, _ := os.ReadFile(filepath.Join(feat, "proof.md"))
167
- if !strings.Contains(string(proof), "Tests green") {
168
- t.Errorf("proof.md did not carry evidence.md content\n%s", proof)
168
+ evidence, _ := os.ReadFile(filepath.Join(feat, "evidence.md"))
169
+ if !strings.Contains(string(evidence), "Tests green") {
170
+ t.Errorf("evidence.md did not carry proof.md content\n%s", evidence)
169
171
  }
170
172
  if got := backupDirs(t, root); len(got) != 1 {
171
173
  t.Errorf("want exactly one backup dir, got %v", got)
@@ -9,9 +9,11 @@ workspace map; `proof.md` may stand in for `evidence.md`.
9
9
 
10
10
  | Phase | Required artifacts |
11
11
  | --- | --- |
12
+ | frame | `state.md` |
12
13
  | spec | `README.md`/`index.md`/`feature.md`, `brief.md`, `spec.md`, `state.md`, `decisions.md`, `assumptions.md`, `questions.md` |
13
- | define/plan/vet/build | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
14
- | prove/polish/review/seal/ship | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
14
+ | temper | spec artifacts |
15
+ | define/plan/vet/build/converge | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
16
+ | prove/polish/review/seal/ship/done | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
15
17
  | conditional | `flows.md` when diagrams clarify; `design-brief.md` and `browser-evidence.md` for UI; `drift.md` for drift; `handoff.md` only when requested; `references.md` + `references/` when references exist |
16
18
 
17
19
  ## What each file owns
@@ -33,7 +35,7 @@ workspace map; `proof.md` may stand in for `evidence.md`.
33
35
  | `evidence.md` / `proof.md` | `EVID-###` command/action, result, timestamp if available, related AC/slice IDs, limitation | 280 lines |
34
36
  | `browser-evidence.md` | UI route/viewports/screenshots/console/network/interactions and Visual Verdict | 220 lines |
35
37
  | `drift.md` | `DRIFT-###` spec/plan drift and resolution | 160 lines |
36
- | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 180 lines |
38
+ | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 160 lines |
37
39
  | `design-brief.md` | UI design direction, states, interaction model | 160 lines |
38
40
  | `handoff.md` | cold-resume guide: current objective, last completed slice, next action, blockers, read-next links | 120 lines |
39
41
 
@@ -9,9 +9,11 @@ workspace map; `proof.md` may stand in for `evidence.md`.
9
9
 
10
10
  | Phase | Required artifacts |
11
11
  | --- | --- |
12
+ | frame | `state.md` |
12
13
  | spec | `README.md`/`index.md`/`feature.md`, `brief.md`, `spec.md`, `state.md`, `decisions.md`, `assumptions.md`, `questions.md` |
13
- | define/plan/vet/build | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
14
- | prove/polish/review/seal/ship | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
14
+ | temper | spec artifacts |
15
+ | define/plan/vet/build/converge | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
16
+ | prove/polish/review/seal/ship/done | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
15
17
  | conditional | `flows.md` when diagrams clarify; `design-brief.md` and `browser-evidence.md` for UI; `drift.md` for drift; `handoff.md` only when requested; `references.md` + `references/` when references exist |
16
18
 
17
19
  ## What each file owns
@@ -33,7 +35,7 @@ workspace map; `proof.md` may stand in for `evidence.md`.
33
35
  | `evidence.md` / `proof.md` | `EVID-###` command/action, result, timestamp if available, related AC/slice IDs, limitation | 280 lines |
34
36
  | `browser-evidence.md` | UI route/viewports/screenshots/console/network/interactions and Visual Verdict | 220 lines |
35
37
  | `drift.md` | `DRIFT-###` spec/plan drift and resolution | 160 lines |
36
- | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 180 lines |
38
+ | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 160 lines |
37
39
  | `design-brief.md` | UI design direction, states, interaction model | 160 lines |
38
40
  | `handoff.md` | cold-resume guide: current objective, last completed slice, next action, blockers, read-next links | 120 lines |
39
41
 
@@ -18,6 +18,6 @@ This project has DevRites installed for both Claude Code and Codex.
18
18
  ## Workflow contract
19
19
 
20
20
  - Keep all feature state in `.devrites/work/<slug>/` and preserve `.devrites/ACTIVE`.
21
- - Follow the DevRites lifecycle: spec -> define -> build -> prove -> polish -> review -> seal -> ship.
21
+ - Follow the DevRites lifecycle: frame -> spec -> temper -> define -> plan -> vet -> build -> converge -> prove -> polish -> review -> seal -> ship -> done.
22
22
  - Claims of completion need recorded evidence in the feature workspace, not confidence alone.
23
23
  <!-- END DEVRITES CODEX -->
@@ -9,9 +9,11 @@ workspace map; `proof.md` may stand in for `evidence.md`.
9
9
 
10
10
  | Phase | Required artifacts |
11
11
  | --- | --- |
12
+ | frame | `state.md` |
12
13
  | spec | `README.md`/`index.md`/`feature.md`, `brief.md`, `spec.md`, `state.md`, `decisions.md`, `assumptions.md`, `questions.md` |
13
- | define/plan/vet/build | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
14
- | prove/polish/review/seal/ship | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
14
+ | temper | spec artifacts |
15
+ | define/plan/vet/build/converge | spec artifacts plus `architecture.md`, `plan.md`, `tasks.md`, `traceability.md` |
16
+ | prove/polish/review/seal/ship/done | plan artifacts plus `evidence.md`/`proof.md`, `touched-files.md` |
15
17
  | conditional | `flows.md` when diagrams clarify; `design-brief.md` and `browser-evidence.md` for UI; `drift.md` for drift; `handoff.md` only when requested; `references.md` + `references/` when references exist |
16
18
 
17
19
  ## What each file owns
@@ -33,7 +35,7 @@ workspace map; `proof.md` may stand in for `evidence.md`.
33
35
  | `evidence.md` / `proof.md` | `EVID-###` command/action, result, timestamp if available, related AC/slice IDs, limitation | 280 lines |
34
36
  | `browser-evidence.md` | UI route/viewports/screenshots/console/network/interactions and Visual Verdict | 220 lines |
35
37
  | `drift.md` | `DRIFT-###` spec/plan drift and resolution | 160 lines |
36
- | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 180 lines |
38
+ | `touched-files.md` | implementation files, slice ownership, reason, and a concern-ordered `## Review trail` of `path:line` stops for human review | 160 lines |
37
39
  | `design-brief.md` | UI design direction, states, interaction model | 160 lines |
38
40
  | `handoff.md` | cold-resume guide: current objective, last completed slice, next action, blockers, read-next links | 120 lines |
39
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "DevRites — disciplined senior-engineer workflow skills pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",
@@ -54,6 +54,7 @@
54
54
  "test:fast": "node scripts/run-tests.mjs --fast",
55
55
  "test:parallel": "node scripts/run-tests.mjs",
56
56
  "test:serial": "node scripts/run-tests.mjs --serial",
57
+ "audit": "npm audit --audit-level=moderate",
57
58
  "size:baseline": "node scripts/check-instruction-size-baseline.mjs --write",
58
59
  "release": "semantic-release",
59
60
  "release:dry": "semantic-release --dry-run --no-ci"
@@ -132,7 +132,7 @@ This project has DevRites installed for both Claude Code and Codex.
132
132
  ## Workflow contract
133
133
 
134
134
  - Keep all feature state in `.devrites/work/<slug>/` and preserve `.devrites/ACTIVE`.
135
- - Follow the DevRites lifecycle: spec -> define -> build -> prove -> polish -> review -> seal -> ship.
135
+ - Follow the DevRites lifecycle: frame -> spec -> temper -> define -> plan -> vet -> build -> converge -> prove -> polish -> review -> seal -> ship -> done.
136
136
  - Claims of completion need recorded evidence in the feature workspace, not confidence alone.
137
137
  <!-- END DEVRITES CODEX -->
138
138
  EOF
@@ -77,9 +77,11 @@ fi
77
77
 
78
78
  # 7 — state phase / status
79
79
  if [ -f "$st" ]; then
80
- sfield() { awk -v k="$1" 'match($0,"^[[:space:]]*-?[[:space:]]*" k ":[[:space:]]*"){r=substr($0,RLENGTH+1); sub(/[[:space:]]*(#|\|).*$/,"",r); sub(/[[:space:]]+$/,"",r); print r; exit}' "$st"; }
81
- ph="$(sfield Phase)"; stt="$(sfield Status)"
82
- case "$ph" in seal|ship|done) ;; *) problems+=("state.md Phase='${ph}' (expected seal/ship/done)") ;; esac
80
+ ph="$(python3 "$ROOT/scripts/workflow_schema.py" field "$st" phase 2>/dev/null || true)"
81
+ stt="$(python3 "$ROOT/scripts/workflow_schema.py" field "$st" status 2>/dev/null || true)"
82
+ if ! python3 "$ROOT/scripts/workflow_schema.py" phase-property "$ph" shippable; then
83
+ problems+=("state.md Phase='${ph}' (expected a shippable phase)")
84
+ fi
83
85
  case "$stt" in awaiting_human|blocked) problems+=("state.md Status='${stt}' (not shippable)") ;; esac
84
86
  else
85
87
  problems+=("state.md missing")
@@ -23,6 +23,27 @@ else
23
23
  echo " FAIL — a known-shippable workspace should grade GO"; fail=1
24
24
  fi
25
25
 
26
+ echo
27
+ echo "== grade synthetic/canonical-cursor (expect GO) =="
28
+ canonical="$tmp/canonical-cursor"
29
+ mkdir -p "$canonical"
30
+ cp -R "$good/." "$canonical/"
31
+ cat > "$canonical/state.md" <<'MD'
32
+ # State
33
+
34
+ ## Cursor
35
+ | Key | Value |
36
+ | --- | --- |
37
+ | phase | done |
38
+ | status | done |
39
+ | next_action | archived |
40
+ MD
41
+ if bash "$GRADER" "$canonical"; then
42
+ echo " PASS — canonical cursor graded GO"
43
+ else
44
+ echo " FAIL — canonical cursor should grade GO"; fail=1
45
+ fi
46
+
26
47
  echo
27
48
  echo "== grade golden/blocked-feature (expect NO-GO) =="
28
49
  if bash "$GRADER" "$bad"; then
@@ -4,9 +4,8 @@
4
4
  DevRites' publish (release.yml) and auto-merge paths are high-value targets, so this
5
5
  gate fails CI when a workflow:
6
6
 
7
- - uses a THIRD-PARTY action not pinned to a full 40-char commit SHA — a moving tag
8
- like `@v2` lets a compromised upstream inject code into the pipeline (GitHub-owned
9
- `actions/*` and `github/*` tags are tolerated);
7
+ - uses any non-local action not pinned to a full 40-char commit SHA — a moving
8
+ tag like `@v2` lets a compromised upstream inject code into the pipeline;
10
9
  - declares no `permissions:` scope anywhere — the default token is broad;
11
10
  - uses `permissions: write-all` (over-broad);
12
11
  - uses `pull_request_target`, except for a Dependabot-only workflow that never
@@ -19,7 +18,6 @@ import os
19
18
  import re
20
19
  import sys
21
20
 
22
- FIRST_PARTY = {"actions", "github"} # GitHub-owned; major-tag refs tolerated
23
21
  SHA_RE = re.compile(r"^[0-9a-f]{40}$")
24
22
  USES_RE = re.compile(r"^\s*-?\s*uses:\s*([^\s#]+)")
25
23
  DEPENDABOT_ONLY_RE = re.compile(
@@ -61,13 +59,10 @@ def scan_text(path, text):
61
59
  ref = m.group(1)
62
60
  if ref.startswith("./") or ref.startswith("."):
63
61
  continue # local action
64
- owner = ref.split("/", 1)[0]
65
- if owner in FIRST_PARTY:
66
- continue
67
62
  at = ref.rsplit("@", 1)
68
63
  pin = at[1] if len(at) == 2 else ""
69
64
  if not SHA_RE.match(pin):
70
- findings.append("%s:%d: third-party action '%s' not pinned to a full commit "
65
+ findings.append("%s:%d: action '%s' not pinned to a full commit "
71
66
  "SHA — pin it (a moving tag is a supply-chain risk)"
72
67
  % (path, i, ref))
73
68
  return findings
@@ -12,6 +12,8 @@ import re
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+ from workflow_schema import PHASES, cursor_field
16
+
15
17
 
16
18
  ID_PATTERNS = {
17
19
  "AC": re.compile(r"\bAC-\d{3}\b"),
@@ -112,68 +114,6 @@ SLICE_REQUIRED_FIELDS = (
112
114
  "Done condition",
113
115
  )
114
116
 
115
- PHASE_REQUIRED = {
116
- "frame": ["state.md"],
117
- "spec": ["brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"],
118
- "temper": ["brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"],
119
- "plan": [
120
- "brief.md",
121
- "spec.md",
122
- "architecture.md",
123
- "plan.md",
124
- "tasks.md",
125
- "traceability.md",
126
- "state.md",
127
- "decisions.md",
128
- "assumptions.md",
129
- "questions.md",
130
- ],
131
- "vet": [
132
- "brief.md",
133
- "spec.md",
134
- "architecture.md",
135
- "plan.md",
136
- "tasks.md",
137
- "traceability.md",
138
- "state.md",
139
- "decisions.md",
140
- "assumptions.md",
141
- "questions.md",
142
- ],
143
- "build": [
144
- "brief.md",
145
- "spec.md",
146
- "architecture.md",
147
- "plan.md",
148
- "tasks.md",
149
- "traceability.md",
150
- "state.md",
151
- "decisions.md",
152
- "assumptions.md",
153
- "questions.md",
154
- ],
155
- "prove": [
156
- "brief.md",
157
- "spec.md",
158
- "architecture.md",
159
- "plan.md",
160
- "tasks.md",
161
- "traceability.md",
162
- "state.md",
163
- "decisions.md",
164
- "assumptions.md",
165
- "questions.md",
166
- ],
167
- "polish": [],
168
- "review": [],
169
- "seal": [],
170
- "ship": [],
171
- "done": [],
172
- }
173
- for phase in ("polish", "review", "seal", "ship", "done"):
174
- PHASE_REQUIRED[phase] = PHASE_REQUIRED["prove"]
175
-
176
-
177
117
  def read(path: Path) -> str:
178
118
  return path.read_text(encoding="utf-8")
179
119
 
@@ -198,12 +138,9 @@ def has_budget_override(text: str) -> bool:
198
138
  def phase_for(workspace: Path) -> str:
199
139
  for name in ("state.md", "status.md", "README.md", "index.md", "feature.md"):
200
140
  p = workspace / name
201
- if not p.exists():
202
- continue
203
- text = read(p)
204
- m = re.search(r"(?im)^\s*-?\s*phase\s*:\s*([a-z-]+)", text)
205
- if m:
206
- return m.group(1).lower()
141
+ value = cursor_field(p, "phase")
142
+ if value:
143
+ return value.split()[0].lower()
207
144
  return "spec"
208
145
 
209
146
 
@@ -374,9 +311,8 @@ def validate_workspace(workspace: Path) -> list[str]:
374
311
  if not workspace_index_present(workspace):
375
312
  errors.append(f"{workspace}: missing README.md/index.md/feature.md workspace map")
376
313
 
377
- required = PHASE_REQUIRED.get(phase, PHASE_REQUIRED["spec"])
378
- if phase in {"prove", "polish", "review", "seal", "ship", "done"}:
379
- required = [*required, "evidence.md", "touched-files.md"]
314
+ metadata = PHASES.get(phase, PHASES["spec"])
315
+ required = list(metadata["workspaceRequired"])
380
316
  for name in required:
381
317
  if not existing_or_alias(workspace, name):
382
318
  errors.append(f"{workspace}: phase {phase} requires {name}")
@@ -456,7 +392,7 @@ def validate_workspace(workspace: Path) -> list[str]:
456
392
  for ac in sorted(ac_ids):
457
393
  if ac not in rows:
458
394
  errors.append(f"{workspace / 'traceability.md'}: {ac} absent from coverage matrix")
459
- if phase in {"prove", "polish", "review", "seal", "ship", "done"}:
395
+ if bool(metadata.get("proofRequired")):
460
396
  for eid in sorted(evid_ids):
461
397
  if eid not in trace:
462
398
  errors.append(f"{workspace / 'traceability.md'}: evidence ID {eid} from evidence/browser proof is not mapped")
@@ -467,7 +403,7 @@ def validate_workspace(workspace: Path) -> list[str]:
467
403
  elif slice_id not in evidence:
468
404
  errors.append(f"{evidence_path}: completed {slice_id} is not referenced by evidence")
469
405
 
470
- if phase in {"plan", "vet", "build", "prove", "polish", "review", "seal", "ship", "done"}:
406
+ if bool(metadata.get("blocksOpenQuestions")):
471
407
  qfile = workspace / "questions.md"
472
408
  if qfile.exists():
473
409
  for qid in blocking_questions(read(qfile)):
@@ -219,6 +219,16 @@ fi
219
219
 
220
220
  # ---- 11b. generated workspace schema fixtures ----------------------------
221
221
  section "workspace artifact schema"
222
+ if command -v go >/dev/null 2>&1; then
223
+ if (cd "$ROOT/engine" && go run ./internal/state/cmd/workflowmanifest -check -out internal/state/workflow_manifest.json) >/tmp/dr_workflow_manifest 2>&1; then
224
+ good "workflow manifest is fresh"
225
+ else
226
+ cat /tmp/dr_workflow_manifest
227
+ bad "workflow manifest drifted from the typed state registry"
228
+ fi
229
+ else
230
+ echo "skip: go not found; workflow manifest freshness not checked"
231
+ fi
222
232
  if command -v python3 >/dev/null 2>&1; then
223
233
  if python3 "$ROOT/scripts/validate-workspace-schema.py" "$ROOT/tests/fixtures/workspace-schema" >/tmp/dr_workspace_schema 2>&1; then
224
234
  cat /tmp/dr_workspace_schema
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """Read generated workflow metadata and canonical/legacy cursor fields."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ MANIFEST = Path(__file__).resolve().parents[1] / "engine" / "internal" / "state" / "workflow_manifest.json"
13
+
14
+
15
+ DOCUMENT = json.loads(MANIFEST.read_text(encoding="utf-8"))
16
+
17
+
18
+ PHASES = {str(phase["id"]): phase for phase in DOCUMENT["phases"]}
19
+ CURSOR_KEY_ALIASES = {
20
+ str(entry["alias"]): str(entry["canonical"])
21
+ for entry in DOCUMENT["cursorKeyAliases"]
22
+ }
23
+
24
+
25
+ def normalize_cursor_key(key: str) -> str:
26
+ normalized = "".join(char for char in key.lower() if char.isalnum())
27
+ canonical = CURSOR_KEY_ALIASES.get(normalized, normalized)
28
+ return "".join(char for char in canonical.lower() if char.isalnum())
29
+
30
+
31
+ def cursor_field_text(text: str, key: str) -> str | None:
32
+ wanted = normalize_cursor_key(key)
33
+ for line in text.splitlines():
34
+ stripped = line.strip()
35
+ if stripped.startswith("|") and stripped.endswith("|"):
36
+ cells = [cell.strip() for cell in stripped[1:-1].split("|")]
37
+ if len(cells) >= 2 and normalize_cursor_key(cells[0]) == wanted:
38
+ return cells[1]
39
+ legacy = re.match(r"^\s*[-*+]?\s*([^:]+):\s*(.*?)\s*$", line)
40
+ if legacy and normalize_cursor_key(legacy.group(1)) == wanted:
41
+ return re.sub(r"\s*(?:#|\|).*?$", "", legacy.group(2)).rstrip()
42
+ return None
43
+
44
+
45
+ def cursor_field(path: Path, key: str) -> str | None:
46
+ if not path.is_file():
47
+ return None
48
+ return cursor_field_text(path.read_text(encoding="utf-8"), key)
49
+
50
+
51
+ def phase_property(phase: str, name: str) -> bool:
52
+ return bool(PHASES.get(phase.lower(), {}).get(name, False))
53
+
54
+
55
+ def main(argv: list[str]) -> int:
56
+ if len(argv) == 4 and argv[1] == "field":
57
+ value = cursor_field(Path(argv[2]), argv[3])
58
+ if value is None:
59
+ return 1
60
+ print(value)
61
+ return 0
62
+ if len(argv) == 4 and argv[1] == "phase-property":
63
+ return 0 if phase_property(argv[2], argv[3]) else 1
64
+ print("usage: workflow_schema.py field <state.md> <key> | phase-property <phase> <property>", file=sys.stderr)
65
+ return 2
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main(sys.argv))