devrites 4.1.0 → 4.2.0

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 (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +65 -6
  4. package/engine/go.mod +1 -1
  5. package/engine/internal/gate/gate.go +154 -46
  6. package/engine/internal/gate/gate_test.go +290 -2
  7. package/engine/internal/gate/readiness_binding.go +66 -100
  8. package/engine/internal/gate/readiness_binding_test.go +126 -3
  9. package/engine/internal/lib/resolve.go +6 -6
  10. package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
  11. package/engine/internal/state/feature.go +1 -100
  12. package/engine/internal/state/observation.go +584 -0
  13. package/engine/internal/state/observation_open_other.go +9 -0
  14. package/engine/internal/state/observation_open_unix.go +12 -0
  15. package/engine/internal/state/observation_test.go +960 -0
  16. package/engine/internal/state/observation_unix_test.go +68 -0
  17. package/engine/internal/state/schema.go +60 -160
  18. package/engine/internal/state/state_test.go +489 -101
  19. package/engine/internal/state/status.go +67 -48
  20. package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
  21. package/engine/tests/adr_0011_define_plan_test.go +14 -12
  22. package/engine/tests/gate_test.go +403 -28
  23. package/engine/tests/workspace_observation_migration_test.go +1065 -0
  24. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  25. package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  26. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  27. package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  28. package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  29. package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
  30. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  31. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
  32. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  33. package/pack/.claude/skills/rite-build/SKILL.md +51 -79
  34. package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -17
  35. package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
  36. package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  37. package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  38. package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
  39. package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
  40. package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  41. package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
  42. package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
  43. package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
  44. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  45. package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  47. package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  48. package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  49. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
  50. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  51. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
  52. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  53. package/pack/generated/claude/skills/rite-build/SKILL.md +51 -79
  54. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -17
  55. package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
  56. package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  57. package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  58. package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
  59. package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
  60. package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  61. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
  62. package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
  63. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
  64. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
  65. package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  67. package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  68. package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  69. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
  70. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  71. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
  72. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  73. package/pack/generated/codex/skills/rite-build/SKILL.md +51 -79
  74. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -17
  75. package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
  76. package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
  77. package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
  78. package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
  79. package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
  80. package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
  81. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
  82. package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
  83. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
  84. package/package.json +1 -1
@@ -5,71 +5,129 @@ package state
5
5
  // (observable results), not internal structure.
6
6
 
7
7
  import (
8
+ "errors"
9
+ "io/fs"
8
10
  "os"
9
11
  "os/exec"
12
+ "path"
10
13
  "path/filepath"
11
14
  "slices"
12
15
  "strings"
13
16
  "testing"
14
17
  )
15
18
 
16
- func TestRequiredSectionsIsPhaseRelativeAndOrdered(t *testing.T) {
17
- got := RequiredSections(PhaseBuild)
18
- want := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
19
- if len(got) != len(want) {
20
- t.Fatalf("RequiredSections(build) = %v, want %v", got, want)
21
- }
22
- for i := range want {
23
- if got[i] != want[i] {
24
- t.Errorf("index %d: got %q, want %q", i, got[i], want[i])
25
- }
19
+ func expectedPhasePolicies() []PhasePolicy {
20
+ sectionsSpec := []Section{SectionSpec}
21
+ sectionsPlan := []Section{SectionSpec, SectionPlan}
22
+ sectionsBuild := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks}
23
+ sectionsProof := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof}
24
+ sectionsComplete := []Section{SectionSpec, SectionPlan, SectionDecisions, SectionTasks, SectionProof, SectionStatus}
25
+
26
+ artifactsFrame := []ArtifactPath{"state.md"}
27
+ artifactsSpec := []ArtifactPath{"brief.md", "spec.md", "state.md", "decisions.md", "assumptions.md", "questions.md"}
28
+ artifactsClarify := append(append([]ArtifactPath(nil), artifactsSpec...), "decision-coverage.md")
29
+ artifactsPlan := append(append([]ArtifactPath(nil), artifactsClarify...), "architecture.md", "plan.md", "tasks.md", "traceability.md")
30
+ artifactsVetted := append(append([]ArtifactPath(nil), artifactsPlan...), "eng-review.md", "test-plan.md")
31
+ artifactsProof := append(append([]ArtifactPath(nil), artifactsVetted...), "evidence.md", "touched-files.md")
32
+ artifactsFinal := append(append([]ArtifactPath(nil), artifactsProof...), "review.md", "seal.md")
33
+
34
+ return []PhasePolicy{
35
+ {Target: PhaseFrame, ResumeVerb: "frame", TransitionRight: "Frame an unstructured request before lifecycle work.", RequiredArtifacts: artifactsFrame},
36
+ {Target: PhaseSpec, ResumeVerb: "spec", TransitionRight: "Author the product specification.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsSpec},
37
+ {Target: PhaseClarify, ResumeVerb: "clarify", TransitionRight: "Close decision coverage in the written specification.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsClarify, BlocksOpenQuestions: true},
38
+ {Target: PhaseTemper, ResumeVerb: "temper", TransitionRight: "Optionally challenge the clarified specification strategy.", RequiredSections: sectionsSpec, RequiredArtifacts: artifactsClarify, BlocksOpenQuestions: true},
39
+ {Target: PhaseDefine, ResumeVerb: "define", TransitionRight: "Author and approve the initial implementation plan.", RequiredSections: sectionsPlan, RequiredArtifacts: artifactsPlan, BlocksOpenQuestions: true},
40
+ {Target: PhasePlan, ResumeVerb: "vet", TransitionRight: "Hold the approved or repaired plan checkpoint for engineering review.", RequiredSections: sectionsPlan, RequiredArtifacts: artifactsPlan, BlocksOpenQuestions: true},
41
+ {Target: PhaseVet, ResumeVerb: "vet", TransitionRight: "Review implementation readiness before build.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
42
+ {Target: PhaseBuild, ResumeVerb: "build", TransitionRight: "Implement the next approved vertical slice.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
43
+ {Target: PhaseConverge, ResumeVerb: "converge", TransitionRight: "Recover unmet clarified intent into new slices.", RequiredSections: sectionsBuild, RequiredArtifacts: artifactsVetted, BlocksOpenQuestions: true},
44
+ {Target: PhaseProve, ResumeVerb: "prove", TransitionRight: "Produce acceptance evidence for the implementation.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
45
+ {Target: PhasePolish, ResumeVerb: "polish", TransitionRight: "Apply the bounded quality pass.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
46
+ {Target: PhaseReview, ResumeVerb: "review", TransitionRight: "Review the proven implementation.", RequiredSections: sectionsProof, RequiredArtifacts: artifactsProof, ProofRequired: true, BlocksOpenQuestions: true},
47
+ {Target: PhaseSeal, ResumeVerb: "seal", TransitionRight: "Decide the final GO or NO-GO.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
48
+ {Target: PhaseShip, ResumeVerb: "ship", TransitionRight: "Perform authorized release and close-out mutations.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
49
+ {Target: PhaseDone, TransitionRight: "Represent archived completion with no resume command.", RequiredSections: sectionsComplete, RequiredArtifacts: artifactsFinal, ProofRequired: true, BlocksOpenQuestions: true, Shippable: true},
26
50
  }
27
- if len(RequiredSections(PhaseFrame)) != 0 {
28
- t.Errorf("frame should require no sections, got %v", RequiredSections(PhaseFrame))
51
+ }
52
+
53
+ func assertPhasePolicyEqual(t *testing.T, label string, got, want PhasePolicy) {
54
+ t.Helper()
55
+ if got.Target != want.Target ||
56
+ got.ResumeVerb != want.ResumeVerb ||
57
+ got.TransitionRight != want.TransitionRight ||
58
+ !slices.Equal(got.RequiredSections, want.RequiredSections) ||
59
+ !slices.Equal(got.RequiredArtifacts, want.RequiredArtifacts) ||
60
+ got.ProofRequired != want.ProofRequired ||
61
+ got.BlocksOpenQuestions != want.BlocksOpenQuestions ||
62
+ got.Shippable != want.Shippable {
63
+ t.Errorf("%s = %+v, want %+v", label, got, want)
29
64
  }
30
65
  }
31
66
 
32
- func TestLifecycleRegistryOwnsOrderAndResumeCommands(t *testing.T) {
33
- phases := LifecyclePhases()
34
- if len(phases) == 0 || phases[0] != PhaseFrame || phases[len(phases)-1] != PhaseDone {
35
- t.Fatalf("LifecyclePhases()=%v, want frame...done", phases)
67
+ func policyForTest(t *testing.T, target Phase) PhasePolicy {
68
+ t.Helper()
69
+ policy, ok := PolicyFor(target)
70
+ if !ok {
71
+ t.Fatalf("PolicyFor(%q) returned unknown", target)
36
72
  }
37
- wantPrefix := []Phase{PhaseFrame, PhaseSpec, PhaseClarify, PhaseTemper, PhaseDefine}
38
- if len(phases) < len(wantPrefix) {
39
- t.Fatalf("LifecyclePhases()=%v, want prefix %v", phases, wantPrefix)
73
+ return policy
74
+ }
75
+
76
+ func TestPhasePoliciesExposeCanonicalLifecycle(t *testing.T) {
77
+ want := expectedPhasePolicies()
78
+ got := PhasePolicies()
79
+ if len(got) != len(want) {
80
+ t.Fatalf("len(PhasePolicies()) = %d, want %d", len(got), len(want))
40
81
  }
41
- for i, want := range wantPrefix {
42
- if phases[i] != want {
43
- t.Fatalf("LifecyclePhases()[%d]=%q, want %q (full=%v)", i, phases[i], want, phases)
82
+ for i := range want {
83
+ assertPhasePolicyEqual(t, "PhasePolicies", got[i], want[i])
84
+ lookedUp, ok := PolicyFor(want[i].Target)
85
+ if !ok {
86
+ t.Fatalf("PolicyFor(%q) returned unknown", want[i].Target)
44
87
  }
88
+ assertPhasePolicyEqual(t, "PolicyFor", lookedUp, want[i])
45
89
  }
46
- if got := ResumeVerb(PhasePlan); got != "vet" {
47
- t.Fatalf("ResumeVerb(plan)=%q, want vet", got)
48
- }
49
- if got := ResumeVerb(PhaseDone); got != "" {
50
- t.Fatalf("ResumeVerb(done)=%q, want empty", got)
90
+ for _, unknown := range []Phase{"", "planning"} {
91
+ policy, ok := PolicyFor(unknown)
92
+ if ok {
93
+ t.Errorf("PolicyFor(%q) = (%+v, true), want unknown lookup rejected", unknown, policy)
94
+ }
95
+ assertPhasePolicyEqual(t, "unknown PolicyFor result", policy, PhasePolicy{})
51
96
  }
97
+ }
52
98
 
53
- phases[0] = PhaseDone
54
- if got := LifecyclePhases()[0]; got != PhaseFrame {
55
- t.Fatalf("LifecyclePhases exposed mutable registry: first=%q", got)
56
- }
99
+ func TestPolicyForReturnsDefensiveNestedCopies(t *testing.T) {
100
+ want := expectedPhasePolicies()[7]
101
+ policy := policyForTest(t, PhaseBuild)
102
+ policy.RequiredSections[0] = SectionStatus
103
+ policy.RequiredArtifacts[0] = "mutated.md"
104
+
105
+ assertPhasePolicyEqual(t, "PolicyFor after mutation", policyForTest(t, PhaseBuild), want)
106
+ }
107
+
108
+ func TestPhasePoliciesReturnsDefensiveNestedCopies(t *testing.T) {
109
+ want := expectedPhasePolicies()
110
+ policies := PhasePolicies()
111
+ policies[7].Target = PhaseDone
112
+ policies[7].RequiredSections[0] = SectionStatus
113
+ policies[7].RequiredArtifacts[0] = "mutated.md"
114
+
115
+ assertPhasePolicyEqual(t, "sibling policy after mutation", policies[8], want[8])
116
+ fresh := PhasePolicies()
117
+ assertPhasePolicyEqual(t, "PhasePolicies after mutation", fresh[7], want[7])
57
118
  }
58
119
 
59
120
  func TestPrebuildWorkspaceRequirementsAreEnforcedByPhase(t *testing.T) {
60
121
  requireFiles := func(phase Phase, names ...string) {
61
122
  t.Helper()
62
- definition, ok := definitionFor(phase)
63
- if !ok {
64
- t.Fatalf("missing phase definition for %q", phase)
65
- }
66
- got := make(map[string]bool, len(definition.workspaceRequired))
67
- for _, name := range definition.workspaceRequired {
68
- got[name] = true
123
+ policy := policyForTest(t, phase)
124
+ got := make(map[string]bool, len(policy.RequiredArtifacts))
125
+ for _, artifact := range policy.RequiredArtifacts {
126
+ got[string(artifact)] = true
69
127
  }
70
128
  for _, name := range names {
71
129
  if !got[name] {
72
- t.Errorf("phase %q does not require %s: %v", phase, name, definition.workspaceRequired)
130
+ t.Errorf("phase %q does not require %s: %v", phase, name, policy.RequiredArtifacts)
73
131
  }
74
132
  }
75
133
  }
@@ -81,6 +139,32 @@ func TestPrebuildWorkspaceRequirementsAreEnforcedByPhase(t *testing.T) {
81
139
  requireFiles(PhaseBuild, "decision-coverage.md", "eng-review.md", "test-plan.md")
82
140
  }
83
141
 
142
+ func TestWorkspaceObservationIncludesEveryLifecycleArtifact(t *testing.T) {
143
+ root := filepath.Join(t.TempDir(), ".devrites")
144
+ writeWorkSection(t, root, "all-artifacts", "state.md", "- Phase: frame\n")
145
+ writeWorkSection(t, root, "all-artifacts", "seal.md", "# Seal\n\nGO\n")
146
+
147
+ observation, err := ObserveWorkspace(root, "all-artifacts")
148
+ if err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ expected := map[ArtifactPath]bool{}
152
+ for _, policy := range PhasePolicies() {
153
+ for _, artifact := range policy.RequiredArtifacts {
154
+ expected[artifact] = true
155
+ }
156
+ }
157
+ for artifact := range expected {
158
+ if _, observed := observation.Fact(artifact); !observed {
159
+ t.Errorf("observation does not include lifecycle artifact %q", artifact)
160
+ }
161
+ }
162
+ seal, ok := observation.Fact("seal.md")
163
+ if !ok || seal.State() != ArtifactPresent {
164
+ t.Errorf("later-phase seal fact = (%q, %v), want present", seal.State(), ok)
165
+ }
166
+ }
167
+
84
168
  func TestRuntimeCompletenessUsesWorkspaceRequiredFiles(t *testing.T) {
85
169
  root := filepath.Join(t.TempDir(), ".devrites")
86
170
  for name, body := range map[string]string{
@@ -110,7 +194,8 @@ func TestRuntimeCompletenessUsesWorkspaceRequiredFiles(t *testing.T) {
110
194
 
111
195
  func TestRuntimeCompletenessRejectsEmptyRequiredFile(t *testing.T) {
112
196
  root := filepath.Join(t.TempDir(), ".devrites")
113
- for _, name := range RequiredWorkspaceFiles(PhaseClarify) {
197
+ for _, artifact := range policyForTest(t, PhaseClarify).RequiredArtifacts {
198
+ name := string(artifact)
114
199
  body := "# " + name + "\n\nreal\n"
115
200
  if name == "decision-coverage.md" {
116
201
  body = ""
@@ -128,39 +213,78 @@ func TestRuntimeCompletenessRejectsEmptyRequiredFile(t *testing.T) {
128
213
  }
129
214
  }
130
215
 
131
- func TestLifecycleRegistryInvariants(t *testing.T) {
216
+ func TestPhasePolicyInvariants(t *testing.T) {
217
+ policies := PhasePolicies()
132
218
  phaseNames := map[Phase]bool{}
133
219
  transitionRights := map[string]Phase{}
134
- for i, definition := range phaseDefinitions {
135
- if definition.phase == "" || phaseNames[definition.phase] {
136
- t.Fatalf("phase definition %d has empty or duplicate ID %q", i, definition.phase)
137
- }
138
- phaseNames[definition.phase] = true
139
- if definition.transitionRight == "" || transitionRights[definition.transitionRight] != "" {
140
- t.Fatalf("phase %q has empty or duplicate transition right %q", definition.phase, definition.transitionRight)
141
- }
142
- transitionRights[definition.transitionRight] = definition.phase
143
- if len(definition.workspaceRequired) == 0 {
144
- t.Fatalf("phase %q has no workspace requirements", definition.phase)
145
- }
146
- for _, section := range definition.required {
147
- known := false
148
- for _, canonical := range Sections {
149
- known = known || section == canonical
220
+ sectionOrder := make(map[Section]int, len(Sections))
221
+ for i, section := range Sections {
222
+ sectionOrder[section] = i
223
+ }
224
+
225
+ for i, policy := range policies {
226
+ if policy.Target == "" || phaseNames[policy.Target] {
227
+ t.Fatalf("policy %d has empty or duplicate target %q", i, policy.Target)
228
+ }
229
+ phaseNames[policy.Target] = true
230
+ if policy.TransitionRight == "" || transitionRights[policy.TransitionRight] != "" {
231
+ t.Fatalf("phase %q has empty or duplicate transition right %q", policy.Target, policy.TransitionRight)
232
+ }
233
+ transitionRights[policy.TransitionRight] = policy.Target
234
+ if len(policy.RequiredArtifacts) == 0 {
235
+ t.Fatalf("phase %q has no required artifacts", policy.Target)
236
+ }
237
+
238
+ seenSections := map[Section]bool{}
239
+ previousSectionIndex := -1
240
+ for _, section := range policy.RequiredSections {
241
+ index, known := sectionOrder[section]
242
+ if !known || seenSections[section] || index <= previousSectionIndex {
243
+ t.Fatalf("phase %q has non-canonical required sections %v", policy.Target, policy.RequiredSections)
244
+ }
245
+ seenSections[section] = true
246
+ previousSectionIndex = index
247
+ }
248
+ seenArtifacts := map[ArtifactPath]bool{}
249
+ for _, artifact := range policy.RequiredArtifacts {
250
+ name := string(artifact)
251
+ if artifact == "" || seenArtifacts[artifact] {
252
+ t.Fatalf("phase %q has empty or duplicate required artifact %q", policy.Target, artifact)
253
+ }
254
+ if path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") || strings.Contains(name, `\`) {
255
+ t.Fatalf("phase %q has non-feature-relative required artifact %q", policy.Target, artifact)
150
256
  }
151
- if !known {
152
- t.Fatalf("phase %q requires unknown section %q", definition.phase, section)
257
+ if artifact == ".devrites/principles.md" {
258
+ t.Fatalf("phase %q includes root principles in feature-relative requirements", policy.Target)
153
259
  }
260
+ seenArtifacts[artifact] = true
261
+ }
262
+
263
+ if i > 0 {
264
+ for _, section := range policies[i-1].RequiredSections {
265
+ if !seenSections[section] {
266
+ t.Errorf("phase %q dropped section %q required by %q", policy.Target, section, policies[i-1].Target)
267
+ }
268
+ }
269
+ for _, artifact := range policies[i-1].RequiredArtifacts {
270
+ if !seenArtifacts[artifact] {
271
+ t.Errorf("phase %q dropped artifact %q required by %q", policy.Target, artifact, policies[i-1].Target)
272
+ }
273
+ }
274
+ }
275
+ if policy.Target == PhaseDone && policy.ResumeVerb != "" {
276
+ t.Errorf("done resume verb = %q, want empty", policy.ResumeVerb)
154
277
  }
155
- if definition.shippable && !definition.proofRequired {
156
- t.Fatalf("shippable phase %q does not require proof", definition.phase)
278
+ if policy.Shippable && !policy.ProofRequired {
279
+ t.Errorf("shippable phase %q does not require proof", policy.Target)
157
280
  }
158
281
  }
159
282
  }
160
283
 
161
284
  func TestStatusFixtureBuildIncomplete(t *testing.T) {
162
285
  root := filepath.Join(t.TempDir(), ".devrites")
163
- for _, name := range RequiredWorkspaceFiles(PhaseBuild) {
286
+ for _, artifact := range policyForTest(t, PhaseBuild).RequiredArtifacts {
287
+ name := string(artifact)
164
288
  body := "# " + name + "\n\nreal\n"
165
289
  if name == "state.md" {
166
290
  body = "- Phase: build\n"
@@ -180,7 +304,8 @@ func TestStatusFixtureBuildIncomplete(t *testing.T) {
180
304
 
181
305
  func TestStatusFixtureSpecComplete(t *testing.T) {
182
306
  root := filepath.Join(t.TempDir(), ".devrites")
183
- for _, name := range RequiredWorkspaceFiles(PhaseSpec) {
307
+ for _, artifact := range policyForTest(t, PhaseSpec).RequiredArtifacts {
308
+ name := string(artifact)
184
309
  body := "# " + name + "\n\nreal\n"
185
310
  if name == "state.md" {
186
311
  body = "- Phase: spec\n"
@@ -196,6 +321,259 @@ func TestStatusFixtureSpecComplete(t *testing.T) {
196
321
  }
197
322
  }
198
323
 
324
+ func TestStatusRenderRemainsExactForCompleteAndOrdinaryMissingArtifacts(t *testing.T) {
325
+ completeRoot := filepath.Join(t.TempDir(), ".devrites")
326
+ writeStatusRequiredArtifacts(t, completeRoot, "complete", PhaseSpec)
327
+ complete, err := Status(completeRoot, "complete")
328
+ if err != nil {
329
+ t.Fatal(err)
330
+ }
331
+ wantComplete := "feature: complete\n" +
332
+ "phase: spec\n" +
333
+ " spec present required\n" +
334
+ " plan empty\n" +
335
+ " decisions present\n" +
336
+ " tasks empty\n" +
337
+ " proof empty\n" +
338
+ " status present\n" +
339
+ "result: complete\n"
340
+ if got := complete.Render(); got != wantComplete {
341
+ t.Fatalf("complete Render() =\n%q\nwant\n%q", got, wantComplete)
342
+ }
343
+
344
+ for _, tc := range []struct {
345
+ name string
346
+ briefBody *string
347
+ }{
348
+ {name: "absent"},
349
+ {name: "empty", briefBody: new(string)},
350
+ } {
351
+ t.Run(tc.name, func(t *testing.T) {
352
+ root := filepath.Join(t.TempDir(), ".devrites")
353
+ writeStatusRequiredArtifacts(t, root, tc.name, PhaseSpec)
354
+ brief := filepath.Join(root, "work", tc.name, "brief.md")
355
+ if tc.briefBody == nil {
356
+ if err := os.Remove(brief); err != nil {
357
+ t.Fatal(err)
358
+ }
359
+ } else if err := os.WriteFile(brief, []byte(*tc.briefBody), 0o644); err != nil {
360
+ t.Fatal(err)
361
+ }
362
+ report, statusErr := Status(root, tc.name)
363
+ if statusErr != nil {
364
+ t.Fatal(statusErr)
365
+ }
366
+ want := "feature: " + tc.name + "\n" +
367
+ "phase: spec\n" +
368
+ " spec present required\n" +
369
+ " plan empty\n" +
370
+ " decisions present\n" +
371
+ " tasks empty\n" +
372
+ " proof empty\n" +
373
+ " status present\n" +
374
+ "result: incomplete (missing files: brief.md)\n"
375
+ if got := report.Render(); got != want {
376
+ t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
377
+ }
378
+ })
379
+ }
380
+ }
381
+
382
+ func TestStatusRendersSelectedDiagnosticsBeforeResultWithoutRecovery(t *testing.T) {
383
+ root := filepath.Join(t.TempDir(), ".devrites")
384
+ writeStatusRequiredArtifacts(t, root, "diagnostic", PhaseSpec)
385
+ writeWorkSection(t, root, "diagnostic", "spec.md", "hostile-secret\x00")
386
+
387
+ report, err := Status(root, "diagnostic")
388
+ if err != nil {
389
+ t.Fatal(err)
390
+ }
391
+ want := "feature: diagnostic\n" +
392
+ "phase: spec\n" +
393
+ " spec empty required\n" +
394
+ " plan empty\n" +
395
+ " decisions present\n" +
396
+ " tasks empty\n" +
397
+ " proof empty\n" +
398
+ " status present\n" +
399
+ "artifact: spec.md: malformed (malformed_markdown)\n" +
400
+ "result: incomplete (missing files: spec.md)\n"
401
+ if got := report.Render(); got != want {
402
+ t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
403
+ }
404
+ if strings.Contains(report.Render(), "next:") || strings.Contains(report.Render(), "hostile-secret") {
405
+ t.Fatalf("Status disclosed content or emitted recovery:\n%s", report.Render())
406
+ }
407
+ }
408
+
409
+ func TestStatusOmitsUnselectedEvidenceDiagnostic(t *testing.T) {
410
+ root := filepath.Join(t.TempDir(), ".devrites")
411
+ writeStatusRequiredArtifacts(t, root, "unselected-evidence", PhaseSpec)
412
+ writeWorkSection(t, root, "unselected-evidence", EvidenceFile, "hostile-secret\x00")
413
+
414
+ report, err := Status(root, "unselected-evidence")
415
+ if err != nil {
416
+ t.Fatal(err)
417
+ }
418
+ want := "feature: unselected-evidence\n" +
419
+ "phase: spec\n" +
420
+ " spec present required\n" +
421
+ " plan empty\n" +
422
+ " decisions present\n" +
423
+ " tasks empty\n" +
424
+ " proof empty\n" +
425
+ " status present\n" +
426
+ "result: complete\n"
427
+ if got := report.Render(); got != want {
428
+ t.Fatalf("Render() =\n%q\nwant\n%q", got, want)
429
+ }
430
+ if strings.Contains(report.Render(), "artifact:") || strings.Contains(report.Render(), "next:") {
431
+ t.Fatalf("unselected evidence emitted diagnostic or recovery:\n%s", report.Render())
432
+ }
433
+ }
434
+
435
+ func TestStatusReturnsExactLogicalErrorsForUnusableState(t *testing.T) {
436
+ for _, tc := range []struct {
437
+ name string
438
+ prepare func(t *testing.T, root, slug string)
439
+ callback observationCallback
440
+ want string
441
+ }{
442
+ {
443
+ name: "absent",
444
+ prepare: func(*testing.T, string, string) {},
445
+ want: `compute status: feature "broken": state.md is absent; add real content to state.md and retry`,
446
+ },
447
+ {
448
+ name: "empty",
449
+ prepare: func(t *testing.T, root, slug string) {
450
+ writeWorkSection(t, root, slug, LedgerFile, "")
451
+ },
452
+ want: `compute status: feature "broken": state.md is empty; add real content to state.md and retry`,
453
+ },
454
+ {
455
+ name: "malformed",
456
+ prepare: func(t *testing.T, root, slug string) {
457
+ writeWorkSection(t, root, slug, LedgerFile, "bad\x00state")
458
+ },
459
+ want: `compute status: feature "broken": state.md is malformed (malformed_markdown); repair state.md and retry`,
460
+ },
461
+ {
462
+ name: "final symlink",
463
+ prepare: func(t *testing.T, root, slug string) {
464
+ target := filepath.Join(t.TempDir(), "state.md")
465
+ if err := os.WriteFile(target, []byte("- Phase: build\n"), 0o644); err != nil {
466
+ t.Fatal(err)
467
+ }
468
+ if err := os.Symlink(target, filepath.Join(root, "work", slug, LedgerFile)); err != nil {
469
+ t.Fatal(err)
470
+ }
471
+ },
472
+ want: `compute status: feature "broken": state.md is unsafe (final_symlink); repair state.md and retry`,
473
+ },
474
+ {
475
+ name: "nonregular",
476
+ prepare: func(t *testing.T, root, slug string) {
477
+ if err := os.Mkdir(filepath.Join(root, "work", slug, LedgerFile), 0o755); err != nil {
478
+ t.Fatal(err)
479
+ }
480
+ },
481
+ want: `compute status: feature "broken": state.md is unsafe (non_regular); repair state.md and retry`,
482
+ },
483
+ {
484
+ name: "too large",
485
+ prepare: func(t *testing.T, root, slug string) {
486
+ writeFile(t, filepath.Join(root, "work", slug, LedgerFile), sizedMarkdown("- Phase: build\n", (1<<20)+1))
487
+ },
488
+ want: `compute status: feature "broken": state.md is unsafe (file_too_large); repair state.md and retry`,
489
+ },
490
+ {
491
+ name: "permission denied",
492
+ prepare: func(t *testing.T, root, slug string) {
493
+ writeWorkSection(t, root, slug, LedgerFile, "- Phase: build\n")
494
+ },
495
+ callback: func(stage observationStage, path ArtifactPath) error {
496
+ if stage == observationBeforeOpen && path == LedgerFile {
497
+ return fs.ErrPermission
498
+ }
499
+ return nil
500
+ },
501
+ want: `compute status: feature "broken": state.md is unreadable (permission_denied); repair state.md and retry`,
502
+ },
503
+ {
504
+ name: "read failure",
505
+ prepare: func(t *testing.T, root, slug string) {
506
+ writeWorkSection(t, root, slug, LedgerFile, "- Phase: build\n")
507
+ },
508
+ callback: func(stage observationStage, path ArtifactPath) error {
509
+ if stage == observationBeforeRead && path == LedgerFile {
510
+ return errors.New("hostile read failure")
511
+ }
512
+ return nil
513
+ },
514
+ want: `compute status: feature "broken": state.md is unreadable (read_failure); repair state.md and retry`,
515
+ },
516
+ } {
517
+ t.Run(tc.name, func(t *testing.T) {
518
+ root := filepath.Join(t.TempDir(), ".devrites")
519
+ slug := "broken"
520
+ if err := os.MkdirAll(filepath.Join(root, "work", slug), 0o755); err != nil {
521
+ t.Fatal(err)
522
+ }
523
+ tc.prepare(t, root, slug)
524
+ report, err := statusWithCallback(root, slug, tc.callback)
525
+ if report != nil || err == nil || err.Error() != tc.want {
526
+ t.Fatalf("statusWithCallback() = (%+v, %v), want nil and %q", report, err, tc.want)
527
+ }
528
+ if strings.Contains(err.Error(), root) || strings.Contains(err.Error(), "hostile") {
529
+ t.Fatalf("error disclosed physical path or content: %v", err)
530
+ }
531
+ })
532
+ }
533
+ }
534
+
535
+ func TestStatusUsesRetainedStateWithoutConsumerReopen(t *testing.T) {
536
+ root := filepath.Join(t.TempDir(), ".devrites")
537
+ writeWorkSection(t, root, "retained", LedgerFile, "- Phase: build\n")
538
+ path := filepath.Join(root, "work", "retained", LedgerFile)
539
+ opens := 0
540
+ reads := 0
541
+ report, err := statusWithCallback(root, "retained", func(stage observationStage, artifact ArtifactPath) error {
542
+ if artifact != LedgerFile {
543
+ return nil
544
+ }
545
+ switch stage {
546
+ case observationBeforeOpen:
547
+ opens++
548
+ case observationBeforeRead:
549
+ reads++
550
+ case observationAfterRead:
551
+ if writeErr := os.WriteFile(path, []byte("- Phase: prove\n"), 0o644); writeErr != nil {
552
+ return writeErr
553
+ }
554
+ }
555
+ return nil
556
+ })
557
+ if err != nil {
558
+ t.Fatal(err)
559
+ }
560
+ if report.Phase != PhaseBuild || opens != 1 || reads != 1 {
561
+ t.Fatalf("phase=%q opens=%d reads=%d, want retained build and 1/1", report.Phase, opens, reads)
562
+ }
563
+ }
564
+
565
+ func TestStatusPreservesMissingAndUnknownPhaseErrors(t *testing.T) {
566
+ root := filepath.Join(t.TempDir(), ".devrites")
567
+ writeWorkSection(t, root, "missing-phase", LedgerFile, "- Status: build\n")
568
+ if _, err := Status(root, "missing-phase"); err == nil || err.Error() != `compute status: feature "missing-phase": no phase in state.md ledger; record phase in state.md and retry` {
569
+ t.Fatalf("missing-phase error = %v", err)
570
+ }
571
+ writeWorkSection(t, root, "unknown-phase", LedgerFile, "- Phase: building\n")
572
+ if _, err := Status(root, "unknown-phase"); err == nil || err.Error() != `compute status: feature "unknown-phase": unknown phase "building"; record a known phase in state.md and retry` {
573
+ t.Fatalf("unknown-phase error = %v", err)
574
+ }
575
+ }
576
+
199
577
  func TestCanonicalWorkspaceCompletenessUsesConcretePhaseFiles(t *testing.T) {
200
578
  root := filepath.Join(t.TempDir(), ".devrites")
201
579
  writeWorkSection(t, root, "live", "state.md", "| Key | Value |\n| --- | --- |\n| phase | vet |\n")
@@ -220,10 +598,10 @@ func TestCanonicalWorkspaceCompletenessUsesConcretePhaseFiles(t *testing.T) {
220
598
 
221
599
  func TestFinalPhasesRequireReviewAndSealArtifacts(t *testing.T) {
222
600
  for _, phase := range []Phase{PhaseSeal, PhaseShip, PhaseDone} {
223
- required := RequiredWorkspaceFiles(phase)
224
- for _, name := range []string{"review.md", "seal.md"} {
601
+ required := policyForTest(t, phase).RequiredArtifacts
602
+ for _, name := range []ArtifactPath{"review.md", "seal.md"} {
225
603
  if !slices.Contains(required, name) {
226
- t.Errorf("RequiredWorkspaceFiles(%q) = %v, want %s", phase, required, name)
604
+ t.Errorf("PolicyFor(%q).RequiredArtifacts = %v, want %s", phase, required, name)
227
605
  }
228
606
  }
229
607
  }
@@ -235,6 +613,17 @@ func TestStatusUnknownSlugErrors(t *testing.T) {
235
613
  }
236
614
  }
237
615
 
616
+ func writeStatusRequiredArtifacts(t *testing.T, root, slug string, phase Phase) {
617
+ t.Helper()
618
+ for _, artifact := range policyForTest(t, phase).RequiredArtifacts {
619
+ body := "# Artifact\n\nreal content\n"
620
+ if artifact == LedgerFile {
621
+ body = "- Phase: " + string(phase) + "\n"
622
+ }
623
+ writeWorkSection(t, root, slug, string(artifact), body)
624
+ }
625
+ }
626
+
238
627
  func writeSection(t *testing.T, root, slug, name, body string) {
239
628
  t.Helper()
240
629
  dir := filepath.Join(root, "work", slug)
@@ -260,7 +649,7 @@ func writeWorkSection(t *testing.T, root, slug, name, body string) {
260
649
  // A live workspace map need not carry frontmatter: the phase lives in
261
650
  // the canonical state.md ledger and the proof/status concepts are satisfied by
262
651
  // evidence.md/state.md. The engine must load, list, and report it anyway.
263
- func TestLoadFeatureFromOfficialBulletLedger(t *testing.T) {
652
+ func TestStatusFromOfficialBulletLedger(t *testing.T) {
264
653
  root := filepath.Join(t.TempDir(), ".devrites")
265
654
  writeSection(t, root, "live", "state.md", "- Phase: prove\n- Status: running\n")
266
655
  writeSection(t, root, "live", "spec.md", "# Spec\n\nDo the thing.\n")
@@ -276,11 +665,12 @@ func TestLoadFeatureFromOfficialBulletLedger(t *testing.T) {
276
665
  if rep.Phase != PhaseProve {
277
666
  t.Errorf("phase = %q, want prove (from the state.md ledger)", rep.Phase)
278
667
  }
279
- if !rep.Present[SectionProof] {
280
- t.Error("proof section should be present via canonical evidence.md")
668
+ rendered := rep.Render()
669
+ if !strings.Contains(rendered, " proof present required\n") {
670
+ t.Errorf("proof section should be present via canonical evidence.md:\n%s", rendered)
281
671
  }
282
- if !rep.Present[SectionStatus] {
283
- t.Error("status section should be present via canonical state.md")
672
+ if !strings.Contains(rendered, " status present\n") {
673
+ t.Errorf("status section should be present via canonical state.md:\n%s", rendered)
284
674
  }
285
675
 
286
676
  slugs, err := ListFeatures(root)
@@ -307,7 +697,7 @@ func TestStateLedgerIgnoresOptionalREADMEFrontmatter(t *testing.T) {
307
697
  }
308
698
  }
309
699
 
310
- func TestLoadFeatureRejectsCorruptStateMarkdown(t *testing.T) {
700
+ func TestStatusRejectsCorruptStateMarkdownWithoutContentDisclosure(t *testing.T) {
311
701
  root := filepath.Join(t.TempDir(), ".devrites")
312
702
  dir := filepath.Join(root, "work", "corrupt")
313
703
  if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -316,8 +706,9 @@ func TestLoadFeatureRejectsCorruptStateMarkdown(t *testing.T) {
316
706
  if err := os.WriteFile(filepath.Join(dir, "state.md"), []byte("| phase | build |\x00\n"), 0o644); err != nil {
317
707
  t.Fatal(err)
318
708
  }
319
- if _, err := LoadFeature(root, "corrupt"); err == nil || !strings.Contains(err.Error(), "NUL") {
320
- t.Fatalf("LoadFeature() error = %v, want NUL rejection", err)
709
+ want := `compute status: feature "corrupt": state.md is malformed (malformed_markdown); repair state.md and retry`
710
+ if _, err := Status(root, "corrupt"); err == nil || err.Error() != want {
711
+ t.Fatalf("Status() error = %v, want %q", err, want)
321
712
  }
322
713
  }
323
714
 
@@ -360,7 +751,7 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
360
751
  t.Fatal(err)
361
752
  }
362
753
 
363
- if _, err := LoadFeature(root, "aliases"); err == nil {
754
+ if _, err := Status(root, "aliases"); err == nil {
364
755
  t.Fatal("feature.md/index.md/status.md aliases created a feature without state.md")
365
756
  }
366
757
  if slugs, err := ListFeatures(root); err != nil {
@@ -371,11 +762,16 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
371
762
 
372
763
  writeWorkSection(t, root, "aliases", "state.md", "- Phase: prove\n")
373
764
  writeWorkSection(t, root, "aliases", "proof.md", "# Proof\n\nOld alias content.\n")
374
- feature, err := LoadFeature(root, "aliases")
765
+ report, err := Status(root, "aliases")
766
+ if err != nil {
767
+ t.Fatal(err)
768
+ }
769
+ observation, err := ObserveWorkspace(root, "aliases")
375
770
  if err != nil {
376
771
  t.Fatal(err)
377
772
  }
378
- if feature.Present[SectionProof] || feature.PresentFiles[EvidenceFile] {
773
+ evidence, ok := observation.Fact(EvidenceFile)
774
+ if report.present[SectionProof] || !ok || evidence.State() != ArtifactAbsent {
379
775
  t.Fatal("proof.md alias satisfied canonical evidence.md presence")
380
776
  }
381
777
  }
@@ -383,7 +779,7 @@ func TestSpeculativeWorkspaceAliasesAreRejected(t *testing.T) {
383
779
  func TestStatusCursorCannotStandInForPhase(t *testing.T) {
384
780
  root := filepath.Join(t.TempDir(), ".devrites")
385
781
  writeWorkSection(t, root, "status-only", "state.md", "- Status: build\n")
386
- if _, err := LoadFeature(root, "status-only"); err == nil || !strings.Contains(err.Error(), "no phase in state.md") {
782
+ if _, err := Status(root, "status-only"); err == nil || !strings.Contains(err.Error(), "no phase in state.md") {
387
783
  t.Fatalf("status-as-phase error = %v, want explicit missing phase", err)
388
784
  }
389
785
  }
@@ -398,33 +794,25 @@ func TestLedgerPhaseRejectsUnknownWord(t *testing.T) {
398
794
  }
399
795
  }
400
796
 
401
- func TestSectionPresentDistinguishesContentFromStubs(t *testing.T) {
402
- dir := t.TempDir()
797
+ func TestArtifactClassificationDistinguishesContentFromStubs(t *testing.T) {
403
798
  cases := []struct {
404
799
  name string
405
800
  body string
406
- want bool
801
+ want ArtifactState
407
802
  }{
408
- {"missing", "", false}, // no file written for this case
409
- {"empty", "", false},
410
- {"heading-only stub", "# Tasks\n", false},
411
- {"frontmatter-only", "---\nphase: build\n---\n", false},
412
- {"real content", "# Spec\n\nDo the thing.\n", true},
413
- {"content after frontmatter", "---\nk: v\n---\n\nreal words\n", true},
414
- {"fenced content", "# Notes\n\n```\nexample\n```\n", true},
415
- {"NUL content", "# Notes\n\nbad\x00\n", false},
416
- {"malformed UTF-8", "# Notes\n\nbad\xff\n", false},
417
- }
418
- for i, c := range cases {
419
- path := filepath.Join(dir, "s.md")
420
- _ = os.Remove(path)
421
- if c.name != "missing" {
422
- if err := os.WriteFile(path, []byte(c.body), 0o644); err != nil {
423
- t.Fatal(err)
424
- }
425
- }
426
- if got := sectionPresent(path); got != c.want {
427
- t.Errorf("case %d (%s): sectionPresent = %v, want %v", i, c.name, got, c.want)
803
+ {"empty", "", ArtifactEmpty},
804
+ {"heading-only stub", "# Tasks\n", ArtifactEmpty},
805
+ {"frontmatter-only", "---\nphase: build\n---\n", ArtifactEmpty},
806
+ {"real content", "# Spec\n\nDo the thing.\n", ArtifactPresent},
807
+ {"content after frontmatter", "---\nk: v\n---\n\nreal words\n", ArtifactPresent},
808
+ {"fenced content", "# Notes\n\n```\nexample\n```\n", ArtifactPresent},
809
+ {"NUL content", "# Notes\n\nbad\x00\n", ArtifactMalformed},
810
+ {"malformed UTF-8", "# Notes\n\nbad\xff\n", ArtifactMalformed},
811
+ }
812
+ for _, tc := range cases {
813
+ state, _ := classifyArtifact([]byte(tc.body))
814
+ if state != tc.want {
815
+ t.Errorf("%s: classifyArtifact state=%q, want %q", tc.name, state, tc.want)
428
816
  }
429
817
  }
430
818
  }