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
@@ -9,37 +9,99 @@ import (
9
9
  "testing"
10
10
  "time"
11
11
 
12
+ internalgate "github.com/devrites/devrites/internal/gate"
12
13
  "github.com/devrites/devrites/internal/lib"
14
+ "github.com/devrites/devrites/internal/state"
13
15
  "github.com/devrites/devrites/internal/testutil"
14
16
  )
15
17
 
16
18
  func TestReadinessBlocksIncompletePhase(t *testing.T) {
17
19
  root := newWorkspace(t)
18
- out, _, code := runDevrites(t, root, "check", "readiness", "auth-tokens") // build phase, tasks empty
19
- if code != 3 {
20
- t.Fatalf("exit = %d, want 3 (blocked)", code)
21
- }
22
- if !strings.Contains(out, `result: blocked (missing to leave "build": tasks.md)`) {
23
- t.Errorf("stdout missing the block line\n%s", out)
24
- }
25
- if !strings.Contains(out, "devrites-engine check readiness auth-tokens") {
26
- t.Errorf("stdout missing the actionable retry step\n%s", out)
20
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "auth-tokens") // build phase, tasks empty
21
+ wantOut := "gate: readiness\n" +
22
+ "feature: auth-tokens\n" +
23
+ "phase: build\n" +
24
+ "result: blocked (missing to leave \"build\": tasks.md)\n" +
25
+ "reason: DRV-GATE-READINESS-MISSING\n" +
26
+ "next: add real content to tasks.md\n" +
27
+ "retry: devrites-engine check readiness auth-tokens\n"
28
+ if code != 3 || out != wantOut || errOut != "" {
29
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout=%q stderr empty", code, out, errOut, wantOut)
27
30
  }
28
31
  }
29
32
 
30
33
  func TestReadinessPassesCompletePhase(t *testing.T) {
31
34
  root := newWorkspace(t)
32
- out, _, code := runDevrites(t, root, "check", "readiness", "search-ranking") // spec phase, spec present
33
- if code != 0 {
34
- t.Fatalf("exit = %d, want 0 (pass)", code)
35
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "search-ranking")
36
+ wantOut := "gate: readiness\n" +
37
+ "feature: search-ranking\n" +
38
+ "phase: spec\n" +
39
+ "result: pass\n" +
40
+ "reason: DRV-GATE-READINESS-PASSED\n"
41
+ if code != 0 || out != wantOut || errOut != "" {
42
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=0 stdout=%q stderr empty", code, out, errOut, wantOut)
35
43
  }
36
- if !strings.Contains(out, "result: pass") {
37
- t.Errorf("stdout not a pass\n%s", out)
44
+ }
45
+
46
+ func TestReadinessBlocksAbsentRequiredArtifactExactCLIContract(t *testing.T) {
47
+ root := filepath.Join(t.TempDir(), ".devrites")
48
+ const slug = "absent-required"
49
+ writeCompleteGateCLIWorkspace(t, root, slug, state.PhaseSpec, state.PhaseSpec, "none\n")
50
+ if err := os.Remove(filepath.Join(root, "work", slug, "spec.md")); err != nil {
51
+ t.Fatal(err)
38
52
  }
39
- // A not-yet-required section (proof, empty at the spec phase) must not appear
40
- // as a blocker.
41
- if strings.Contains(out, "blocked") {
42
- t.Errorf("an empty not-yet-required section blocked readiness\n%s", out)
53
+
54
+ out, errOut, code := runDevrites(t, root, "check", "readiness", slug)
55
+ wantOut := "gate: readiness\n" +
56
+ "feature: absent-required\n" +
57
+ "phase: spec\n" +
58
+ "result: blocked (missing to leave \"spec\": spec.md)\n" +
59
+ "reason: DRV-GATE-READINESS-MISSING\n" +
60
+ "next: add real content to spec.md\n" +
61
+ "retry: devrites-engine check readiness absent-required\n"
62
+ if code != 3 || out != wantOut || errOut != "" {
63
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout=%q stderr empty", code, out, errOut, wantOut)
64
+ }
65
+ }
66
+
67
+ func TestReadinessEmitBindingPassesExactCLIContract(t *testing.T) {
68
+ root := filepath.Join(t.TempDir(), ".devrites")
69
+ const slug = "binding-success"
70
+ writeCompleteGateCLIWorkspace(t, root, slug, state.PhaseBuild, state.PhaseBuild, "none\n")
71
+
72
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "--emit-binding", slug)
73
+ const wantOut = "Readiness inputs SHA-256: 71c2d192ea09bca1d2c8806cb197e7fa4d1d08e0b331cf25b2e1bcb7ecac34e4\n"
74
+ if code != 0 || out != wantOut || errOut != "" {
75
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=0 stdout=%q stderr empty", code, out, errOut, wantOut)
76
+ }
77
+ }
78
+
79
+ func TestReadinessBlocksStaleBindingExactCLIContract(t *testing.T) {
80
+ root := filepath.Join(t.TempDir(), ".devrites")
81
+ const slug = "stale-readiness"
82
+ writeCompleteGateCLIWorkspace(t, root, slug, state.PhaseBuild, state.PhaseBuild, "none\n")
83
+ workspace := filepath.Join(root, "work", slug)
84
+ binding, err := internalgate.ReadinessBinding(root, slug)
85
+ if err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
89
+ testutil.WriteFile(t, filepath.Join(workspace, "plan.md"), "# Plan\n\nChanged after Vet.\n")
90
+ expected, err := internalgate.ReadinessBinding(root, slug)
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+
95
+ out, errOut, code := runDevrites(t, root, "check", "readiness", slug)
96
+ wantOut := "gate: readiness\n" +
97
+ "feature: " + slug + "\n" +
98
+ "phase: build\n" +
99
+ "result: blocked (state invariant)\n" +
100
+ "reason: DRV-GATE-READINESS-STALE\n" +
101
+ "invariant: readiness inputs are stale or the binding is invalid; rerun /rite-vet and record exactly one standalone line: " + expected + "\n" +
102
+ "retry: devrites-engine check readiness " + slug + "\n"
103
+ if code != 3 || out != wantOut || errOut != "" {
104
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout=%q stderr empty", code, out, errOut, wantOut)
43
105
  }
44
106
  }
45
107
 
@@ -74,6 +136,47 @@ func TestReadinessUsesStructuralArtifactsOnly(t *testing.T) {
74
136
  }
75
137
  }
76
138
 
139
+ func TestGateQuestionPolicyCLIContract(t *testing.T) {
140
+ const openQuestion = "## q-1\nstatus: open\ngate: blocking\n"
141
+ cases := []struct {
142
+ name string
143
+ kind string
144
+ current state.Phase
145
+ required state.Phase
146
+ wantCode int
147
+ wantResult string
148
+ wantReason string
149
+ }{
150
+ {name: "frame-open", kind: "readiness", current: state.PhaseFrame, required: state.PhaseFrame, wantCode: 0, wantResult: "pass", wantReason: "DRV-GATE-READINESS-PASSED"},
151
+ {name: "spec-open", kind: "readiness", current: state.PhaseSpec, required: state.PhaseSpec, wantCode: 0, wantResult: "pass", wantReason: "DRV-GATE-READINESS-PASSED"},
152
+ {name: "clarify-open", kind: "readiness", current: state.PhaseClarify, required: state.PhaseClarify, wantCode: 3, wantResult: "blocked (state invariant)", wantReason: "DRV-GATE-READINESS-MISSING"},
153
+ {name: "seal-from-spec", kind: "seal", current: state.PhaseSpec, required: state.PhaseSeal, wantCode: 3, wantResult: "blocked (state invariant)", wantReason: "DRV-GATE-SEAL-MISSING"},
154
+ }
155
+
156
+ for _, tc := range cases {
157
+ t.Run(tc.name, func(t *testing.T) {
158
+ root := filepath.Join(t.TempDir(), ".devrites")
159
+ writeCompleteGateCLIWorkspace(t, root, tc.name, tc.current, tc.required, openQuestion)
160
+ if tc.kind == "seal" {
161
+ binding, err := internalgate.ReadinessBinding(root, tc.name)
162
+ if err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ testutil.AppendFile(t, filepath.Join(root, "work", tc.name, "eng-review.md"), "\n"+binding+"\n")
166
+ }
167
+
168
+ out, errOut, code := runDevrites(t, root, "check", tc.kind, tc.name)
169
+ wantOut := "gate: " + tc.kind + "\nfeature: " + tc.name + "\nphase: " + string(tc.current) + "\nresult: " + tc.wantResult + "\nreason: " + tc.wantReason + "\n"
170
+ if tc.wantCode == 3 {
171
+ wantOut += "invariant: open blocking human question(s) remain in questions.md but state.md is not awaiting_human\nretry: devrites-engine check " + tc.kind + " " + tc.name + "\n"
172
+ }
173
+ if code != tc.wantCode || out != wantOut || errOut != "" {
174
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=%d stdout=%q stderr empty", code, out, errOut, tc.wantCode, wantOut)
175
+ }
176
+ })
177
+ }
178
+ }
179
+
77
180
  func TestSealBlocksWhenSealSectionsMissing(t *testing.T) {
78
181
  root := newWorkspace(t)
79
182
  out, _, code := runDevrites(t, root, "check", "seal", "auth-tokens")
@@ -91,19 +194,21 @@ func TestSealPassesWhenComplete(t *testing.T) {
91
194
  if err := os.Chtimes(source, time.Unix(2_000_000_000, 0), time.Unix(2_000_000_000, 0)); err != nil {
92
195
  t.Fatal(err)
93
196
  }
197
+ digest, _, err := lib.CandidateIdentity(root, "final")
198
+ if err != nil {
199
+ t.Fatal(err)
200
+ }
94
201
  chdirForSeal(t, project)
95
202
 
96
203
  out, errOut, code := runDevrites(t, root, "check", "seal", "final")
97
- if code != 0 {
98
- t.Fatalf("exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", code, out, errOut)
99
- }
100
- for _, want := range []string{
101
- "result: pass",
102
- "evidence-fresh: OK",
103
- } {
104
- if !strings.Contains(out, want) {
105
- t.Fatalf("stdout missing %q:\n%s", want, out)
106
- }
204
+ wantOut := "evidence-fresh: OK: candidate digest " + digest + " matches evidence, review, and seal.\n" +
205
+ "gate: seal\n" +
206
+ "feature: final\n" +
207
+ "phase: seal\n" +
208
+ "result: pass\n" +
209
+ "reason: DRV-GATE-SEAL-PASSED\n"
210
+ if code != 0 || out != wantOut || errOut != "" {
211
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=0 stdout=%q stderr empty", code, out, errOut, wantOut)
107
212
  }
108
213
  }
109
214
 
@@ -198,6 +303,276 @@ func TestSealTracksCanonicalQuestionTableStatus(t *testing.T) {
198
303
  }
199
304
  }
200
305
 
306
+ func TestWorkspaceObservationEarlyStateErrorsCLIContract(t *testing.T) {
307
+ for _, test := range []struct {
308
+ name string
309
+ slug string
310
+ mutate func(*testing.T, string)
311
+ wantErr string
312
+ notDisclose []string
313
+ }{
314
+ {
315
+ name: "absent state",
316
+ slug: "absent-state",
317
+ mutate: func(t *testing.T, workspace string) {
318
+ if err := os.Remove(filepath.Join(workspace, "state.md")); err != nil {
319
+ t.Fatal(err)
320
+ }
321
+ },
322
+ wantErr: `devrites: gate readiness: feature "absent-state": state.md is absent; add real content to state.md and retry
323
+ `,
324
+ },
325
+ {
326
+ name: "empty state",
327
+ slug: "empty-state",
328
+ mutate: func(t *testing.T, workspace string) {
329
+ testutil.WriteFile(t, filepath.Join(workspace, "state.md"), "")
330
+ },
331
+ wantErr: `devrites: gate readiness: feature "empty-state": state.md is empty; add real content to state.md and retry
332
+ `,
333
+ },
334
+ {
335
+ name: "malformed state",
336
+ slug: "malformed-state",
337
+ mutate: func(t *testing.T, workspace string) {
338
+ testutil.WriteFile(t, filepath.Join(workspace, "state.md"), "# State\n\nhostile-secret\x00\n")
339
+ },
340
+ wantErr: `devrites: gate readiness: feature "malformed-state": state.md is malformed (malformed_markdown); repair state.md and retry
341
+ `,
342
+ notDisclose: []string{"hostile-secret"},
343
+ },
344
+ {
345
+ name: "unsafe state final symlink",
346
+ slug: "unsafe-state",
347
+ mutate: func(t *testing.T, workspace string) {
348
+ const target = "hostile-state-target.md"
349
+ statePath := filepath.Join(workspace, "state.md")
350
+ if err := os.Remove(statePath); err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ testutil.WriteFile(t, filepath.Join(workspace, target), "- Phase: spec\n- Status: running\n")
354
+ if err := os.Symlink(target, statePath); err != nil {
355
+ t.Fatalf("create final symlink fixture: %v", err)
356
+ }
357
+ },
358
+ wantErr: `devrites: gate readiness: feature "unsafe-state": state.md is unsafe (final_symlink); repair state.md and retry
359
+ `,
360
+ notDisclose: []string{"hostile-state-target.md"},
361
+ },
362
+ {
363
+ name: "state without phase",
364
+ slug: "state-without-phase",
365
+ mutate: func(t *testing.T, workspace string) {
366
+ testutil.WriteFile(t, filepath.Join(workspace, "state.md"), "# State\n\n- Status: running\n")
367
+ },
368
+ wantErr: `devrites: gate readiness: feature "state-without-phase": no phase in state.md ledger; record phase in state.md and retry
369
+ `,
370
+ },
371
+ {
372
+ name: "state with unknown phase",
373
+ slug: "state-with-unknown-phase",
374
+ mutate: func(t *testing.T, workspace string) {
375
+ testutil.WriteFile(t, filepath.Join(workspace, "state.md"), "# State\n\n- Phase: mystery\n- Status: running\n")
376
+ },
377
+ wantErr: `devrites: gate readiness: feature "state-with-unknown-phase": unknown phase "mystery"; record a known phase in state.md and retry
378
+ `,
379
+ },
380
+ } {
381
+ t.Run(test.name, func(t *testing.T) {
382
+ root := filepath.Join(t.TempDir(), ".devrites")
383
+ writeCompleteGateCLIWorkspace(t, root, test.slug, state.PhaseSpec, state.PhaseSpec, "none\n")
384
+ workspace := filepath.Join(root, "work", test.slug)
385
+ test.mutate(t, workspace)
386
+
387
+ out, errOut, code := runDevrites(t, root, "check", "readiness", test.slug)
388
+ if code != 2 || out != "" || errOut != test.wantErr {
389
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=2 stdout empty stderr=%q", code, out, errOut, test.wantErr)
390
+ }
391
+ for _, secret := range test.notDisclose {
392
+ if strings.Contains(out, secret) || strings.Contains(errOut, secret) {
393
+ t.Errorf("CLI output disclosed hostile fixture content %q: stdout=%q stderr=%q", secret, out, errOut)
394
+ }
395
+ }
396
+ })
397
+ }
398
+ }
399
+
400
+ func TestWorkspaceObservationGateDiagnosticCLIContract(t *testing.T) {
401
+ root := filepath.Join(t.TempDir(), ".devrites")
402
+ writeCompleteGateCLIWorkspace(t, root, "diagnostic", state.PhaseSpec, state.PhaseSpec, "none\n")
403
+ testutil.WriteFile(t, filepath.Join(root, "work", "diagnostic", "spec.md"), "hostile-secret\x00")
404
+
405
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "diagnostic")
406
+ wantOut := "gate: readiness\n" +
407
+ "feature: diagnostic\n" +
408
+ "phase: spec\n" +
409
+ "result: blocked (missing to leave \"spec\": spec.md)\n" +
410
+ "reason: DRV-GATE-READINESS-MISSING\n" +
411
+ "artifact: spec.md: malformed (malformed_markdown)\n" +
412
+ "next: repair spec.md: replace invalid Markdown with valid Markdown; required artifacts need substantive content\n" +
413
+ "retry: devrites-engine check readiness diagnostic\n"
414
+ if code != 3 || out != wantOut || errOut != "" {
415
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout=%q stderr empty", code, out, errOut, wantOut)
416
+ }
417
+ }
418
+
419
+ func TestWorkspaceObservationStandaloneBindingDiagnosticCLIContract(t *testing.T) {
420
+ root := filepath.Join(t.TempDir(), ".devrites")
421
+ writeCompleteGateCLIWorkspace(t, root, "binding-diagnostic", state.PhaseBuild, state.PhaseBuild, "none\n")
422
+ testutil.WriteFile(t, filepath.Join(root, "work", "binding-diagnostic", "strategy.md"), "hostile-secret\x00")
423
+
424
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "--emit-binding", "binding-diagnostic")
425
+ wantErr := "readiness-binding: BLOCKED: readiness input strategy.md is malformed (malformed_markdown); replace invalid Markdown with valid Markdown\n"
426
+ if code != 3 || out != "" || errOut != wantErr {
427
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout empty stderr=%q", code, out, errOut, wantErr)
428
+ }
429
+ }
430
+
431
+ func TestWorkspaceObservationConstructibleDiagnosticsCLIContract(t *testing.T) {
432
+ for _, test := range []struct {
433
+ name string
434
+ slug string
435
+ mutate func(*testing.T, string)
436
+ diagnostic string
437
+ recovery string
438
+ }{
439
+ {
440
+ name: "final symlink gate",
441
+ slug: "final-symlink",
442
+ mutate: func(t *testing.T, workspace string) {
443
+ path := filepath.Join(workspace, "spec.md")
444
+ if err := os.Remove(path); err != nil {
445
+ t.Fatal(err)
446
+ }
447
+ if err := os.Symlink("brief.md", path); err != nil {
448
+ t.Fatalf("create final symlink fixture: %v", err)
449
+ }
450
+ },
451
+ diagnostic: "artifact: spec.md: unsafe (final_symlink)\n",
452
+ recovery: "next: repair spec.md: replace the symlink with a regular file\n",
453
+ },
454
+ {
455
+ name: "nonregular gate",
456
+ slug: "nonregular",
457
+ mutate: func(t *testing.T, workspace string) {
458
+ path := filepath.Join(workspace, "spec.md")
459
+ if err := os.Remove(path); err != nil {
460
+ t.Fatal(err)
461
+ }
462
+ if err := os.Mkdir(path, 0o755); err != nil {
463
+ t.Fatal(err)
464
+ }
465
+ },
466
+ diagnostic: "artifact: spec.md: unsafe (non_regular)\n",
467
+ recovery: "next: repair spec.md: replace the non-regular entry with a regular file\n",
468
+ },
469
+ } {
470
+ t.Run(test.name, func(t *testing.T) {
471
+ root := filepath.Join(t.TempDir(), ".devrites")
472
+ writeCompleteGateCLIWorkspace(t, root, test.slug, state.PhaseSpec, state.PhaseSpec, "none\n")
473
+ workspace := filepath.Join(root, "work", test.slug)
474
+ test.mutate(t, workspace)
475
+
476
+ out, errOut, code := runDevrites(t, root, "check", "readiness", test.slug)
477
+ wantOut := "gate: readiness\n" +
478
+ "feature: " + test.slug + "\n" +
479
+ "phase: spec\n" +
480
+ "result: blocked (missing to leave \"spec\": spec.md)\n" +
481
+ "reason: DRV-GATE-READINESS-MISSING\n" +
482
+ test.diagnostic +
483
+ test.recovery +
484
+ "retry: devrites-engine check readiness " + test.slug + "\n"
485
+ if code != 3 || out != wantOut || errOut != "" {
486
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout=%q stderr empty", code, out, errOut, wantOut)
487
+ }
488
+ })
489
+ }
490
+
491
+ t.Run("file too large standalone binding", func(t *testing.T) {
492
+ root := filepath.Join(t.TempDir(), ".devrites")
493
+ writeCompleteGateCLIWorkspace(t, root, "binding-too-large", state.PhaseBuild, state.PhaseBuild, "none\n")
494
+ testutil.WriteFile(t, filepath.Join(root, "work", "binding-too-large", "strategy.md"), strings.Repeat("x", (1<<20)+1))
495
+
496
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "--emit-binding", "binding-too-large")
497
+ wantErr := "readiness-binding: BLOCKED: readiness input strategy.md is unsafe (file_too_large); reduce the file to at most 1 MiB\n"
498
+ if code != 3 || out != "" || errOut != wantErr {
499
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout empty stderr=%q", code, out, errOut, wantErr)
500
+ }
501
+ })
502
+ }
503
+
504
+ func TestWorkspaceObservationWholeFailureCLIChannels(t *testing.T) {
505
+ t.Run("workspace invalid gate", func(t *testing.T) {
506
+ root := filepath.Join(t.TempDir(), ".devrites")
507
+ if err := os.MkdirAll(root, 0o755); err != nil {
508
+ t.Fatal(err)
509
+ }
510
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "missing")
511
+ wantErr := "devrites: gate readiness: workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry\n"
512
+ if code != 2 || out != "" || errOut != wantErr {
513
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=2 stdout empty stderr=%q", code, out, errOut, wantErr)
514
+ }
515
+ })
516
+
517
+ t.Run("workspace invalid binding", func(t *testing.T) {
518
+ root := filepath.Join(t.TempDir(), ".devrites")
519
+ if err := os.MkdirAll(root, 0o755); err != nil {
520
+ t.Fatal(err)
521
+ }
522
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "--emit-binding", "missing")
523
+ wantErr := "readiness-binding: BLOCKED: workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry\n"
524
+ if code != 3 || out != "" || errOut != wantErr {
525
+ t.Fatalf("code=%d stdout=%q stderr=%q, want code=3 stdout empty stderr=%q", code, out, errOut, wantErr)
526
+ }
527
+ })
528
+
529
+ t.Run("aggregate too large gate and binding", func(t *testing.T) {
530
+ root := filepath.Join(t.TempDir(), ".devrites")
531
+ writeCompleteGateCLIWorkspace(t, root, "aggregate", state.PhaseBuild, state.PhaseBuild, "none\n")
532
+ for i, name := range []string{"state.md", "brief.md", "spec.md", "decisions.md", "assumptions.md", "questions.md", "decision-coverage.md", "architecture.md", "plan.md"} {
533
+ prefix := "retained content\n"
534
+ if i == 0 {
535
+ prefix = "- Phase: build\n"
536
+ }
537
+ body := prefix + strings.Repeat("x", (1<<20)-len(prefix))
538
+ testutil.WriteFile(t, filepath.Join(root, "work", "aggregate", name), body)
539
+ }
540
+ wantFailure := "workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry"
541
+ out, errOut, code := runDevrites(t, root, "check", "readiness", "aggregate")
542
+ if code != 2 || out != "" || errOut != "devrites: gate readiness: "+wantFailure+"\n" {
543
+ t.Fatalf("gate code=%d stdout=%q stderr=%q", code, out, errOut)
544
+ }
545
+ out, errOut, code = runDevrites(t, root, "check", "readiness", "--emit-binding", "aggregate")
546
+ if code != 3 || out != "" || errOut != "readiness-binding: BLOCKED: "+wantFailure+"\n" {
547
+ t.Fatalf("binding code=%d stdout=%q stderr=%q", code, out, errOut)
548
+ }
549
+ })
550
+ }
551
+
552
+ func writeCompleteGateCLIWorkspace(t *testing.T, root, slug string, current, required state.Phase, questions string) {
553
+ t.Helper()
554
+ policy, ok := state.PolicyFor(required)
555
+ if !ok {
556
+ t.Fatalf("PolicyFor(%q) returned unknown", required)
557
+ }
558
+ questionsRequired := false
559
+ for _, artifact := range policy.RequiredArtifacts {
560
+ name := string(artifact)
561
+ content := "# " + name + "\n\nreal\n"
562
+ switch name {
563
+ case "state.md":
564
+ content = "- Phase: " + string(current) + "\n- Status: running\n"
565
+ case "questions.md":
566
+ questionsRequired = true
567
+ content = questions
568
+ }
569
+ testutil.WriteFile(t, filepath.Join(root, "work", slug, name), content)
570
+ }
571
+ if !questionsRequired {
572
+ testutil.WriteFile(t, filepath.Join(root, "work", slug, "questions.md"), questions)
573
+ }
574
+ }
575
+
201
576
  func newFinalSealRepo(t *testing.T) (string, string) {
202
577
  t.Helper()
203
578
  requireGit(t)