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
@@ -8,6 +8,7 @@ import (
8
8
  "testing"
9
9
 
10
10
  "github.com/devrites/devrites/internal/reason"
11
+ "github.com/devrites/devrites/internal/state"
11
12
  "github.com/devrites/devrites/internal/testutil"
12
13
  )
13
14
 
@@ -135,12 +136,12 @@ func TestReadinessBindingRejectsUnsafeInputsWithoutDisclosure(t *testing.T) {
135
136
  }
136
137
  }},
137
138
  {name: "per input limit", mutate: func(t *testing.T, workspace string) {
138
- if err := os.WriteFile(filepath.Join(workspace, "test-plan.md"), bytes.Repeat([]byte("a"), int(maxReadinessInputBytes)+1), 0o644); err != nil {
139
+ if err := os.WriteFile(filepath.Join(workspace, "test-plan.md"), bytes.Repeat([]byte("a"), (1<<20)+1), 0o644); err != nil {
139
140
  t.Fatal(err)
140
141
  }
141
142
  }},
142
143
  {name: "aggregate limit", mutate: func(t *testing.T, workspace string) {
143
- content := bytes.Repeat([]byte("a"), int(maxReadinessInputBytes))
144
+ content := bytes.Repeat([]byte("a"), 1<<20)
144
145
  for _, name := range []string{"spec.md", "decision-coverage.md", "architecture.md", "plan.md", "tasks.md", "traceability.md", "test-plan.md", "strategy.md", "design-brief.md"} {
145
146
  if err := os.WriteFile(filepath.Join(workspace, name), content, 0o644); err != nil {
146
147
  t.Fatal(err)
@@ -173,11 +174,18 @@ func TestVerifyReadinessBindingRequiresOneStructuralStandaloneLine(t *testing.T)
173
174
  wantErr bool
174
175
  }{
175
176
  {name: "exact", content: "# Engineering review\n\n" + expected + "\n"},
177
+ {name: "exact CRLF", content: "# Engineering review\r\n\r\n" + expected + "\r\n"},
176
178
  {name: "exact plus fenced example", content: expected + "\n```text\n" + expected + "\n```\n"},
177
179
  {name: "missing", content: "# Engineering review\n\nREADY\n", wantErr: true},
178
180
  {name: "malformed", content: readinessBindingLabel + strings.Repeat("a", 63) + "\n", wantErr: true},
179
181
  {name: "duplicate", content: expected + "\n" + expected + "\n", wantErr: true},
182
+ {name: "prefixed", content: "prefix " + expected + "\n", wantErr: true},
180
183
  {name: "fenced only", content: "```text\n" + expected + "\n```\n", wantErr: true},
184
+ {name: "unterminated fence", content: "```text\n" + expected + "\n", wantErr: true},
185
+ {name: "tilde fenced only", content: "~~~text\n" + expected + "\n~~~~~\n", wantErr: true},
186
+ {name: "tilde fenced plus visible", content: "~~~text\n" + expected + "\n~~~~~\n" + expected + "\n"},
187
+ {name: "HTML comment remains structural", content: "<!--\n" + expected + "\n-->\n"},
188
+ {name: "inline comment marker does not hide binding", content: "`<!--`\n" + expected + "\n"},
181
189
  {name: "mismatch", content: readinessBindingLabel + strings.Repeat("0", 64) + "\n", wantErr: true},
182
190
  {name: "not standalone", content: " " + expected + "\n", wantErr: true},
183
191
  }
@@ -185,7 +193,11 @@ func TestVerifyReadinessBindingRequiresOneStructuralStandaloneLine(t *testing.T)
185
193
  for _, test := range tests {
186
194
  t.Run(test.name, func(t *testing.T) {
187
195
  testutil.WriteFile(t, filepath.Join(workspace, "eng-review.md"), test.content)
188
- got, err := verifyReadinessBinding(root, "markers")
196
+ observation, observeErr := state.ObserveWorkspace(root, "markers")
197
+ if observeErr != nil {
198
+ t.Fatal(observeErr)
199
+ }
200
+ got, err := verifyReadinessBinding(observation)
189
201
  if (err != nil) != test.wantErr {
190
202
  t.Fatalf("verifyReadinessBinding() error=%v, wantErr=%v", err, test.wantErr)
191
203
  }
@@ -238,6 +250,117 @@ func TestCheckUsesReadinessBindingForBuildAndSeal(t *testing.T) {
238
250
  }
239
251
  }
240
252
 
253
+ func TestReadinessBindingObservationUsesRetainedBytes(t *testing.T) {
254
+ root := t.TempDir()
255
+ workspace := writeReadinessFixture(t, root, "retained-binding", "build")
256
+ observation, err := state.ObserveWorkspace(root, "retained-binding")
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ before, err := readinessBindingFromObservation(observation)
261
+ if err != nil {
262
+ t.Fatal(err)
263
+ }
264
+
265
+ testutil.WriteFile(t, filepath.Join(workspace, "spec.md"), "# Spec\n\nChanged after observation.\n")
266
+ testutil.WriteFile(t, filepath.Join(workspace, "strategy.md"), "# Strategy\n\nAdded after observation.\n")
267
+ after, err := readinessBindingFromObservation(observation)
268
+ if err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ if after != before {
272
+ t.Fatalf("retained observation binding changed: before=%q after=%q", before, after)
273
+ }
274
+ }
275
+
276
+ func TestReadinessBindingOptionalEmptyFilesRemainPresent(t *testing.T) {
277
+ forms := []struct {
278
+ name string
279
+ body string
280
+ }{
281
+ {name: "zero-byte"},
282
+ {name: "heading-only", body: "# Optional\n"},
283
+ {name: "frontmatter-only", body: "---\nkind: optional\n---\n"},
284
+ }
285
+ for _, logical := range []string{"strategy.md", "design-brief.md", "ai-spec.md", ".devrites/principles.md"} {
286
+ for _, form := range forms {
287
+ t.Run(logical+"/"+form.name, func(t *testing.T) {
288
+ root := t.TempDir()
289
+ workspace := writeReadinessFixture(t, root, "optional-empty", "build")
290
+ baseline := mustReadinessBinding(t, root, "optional-empty")
291
+ path := filepath.Join(workspace, logical)
292
+ if logical == ".devrites/principles.md" {
293
+ path = filepath.Join(root, "principles.md")
294
+ }
295
+ testutil.WriteFile(t, path, form.body)
296
+ present := mustReadinessBinding(t, root, "optional-empty")
297
+ if present == baseline {
298
+ t.Fatalf("adding %s %s did not encode present bytes", form.name, logical)
299
+ }
300
+ if err := os.Remove(path); err != nil {
301
+ t.Fatal(err)
302
+ }
303
+ if restored := mustReadinessBinding(t, root, "optional-empty"); restored != baseline {
304
+ t.Fatalf("removing %s did not restore absent binding: got %q want %q", logical, restored, baseline)
305
+ }
306
+ })
307
+ }
308
+ }
309
+ }
310
+
311
+ func TestCheckReadinessBindingDetectsEmptyOptionalAddAndRemove(t *testing.T) {
312
+ for _, logical := range []string{"strategy.md", "design-brief.md", "ai-spec.md", ".devrites/principles.md"} {
313
+ t.Run(logical, func(t *testing.T) {
314
+ root := t.TempDir()
315
+ workspace := writeReadinessFixture(t, root, "empty-stale", "build")
316
+ binding := mustReadinessBinding(t, root, "empty-stale")
317
+ testutil.AppendFile(t, filepath.Join(workspace, "eng-review.md"), "\n"+binding+"\n")
318
+ path := filepath.Join(workspace, logical)
319
+ if logical == ".devrites/principles.md" {
320
+ path = filepath.Join(root, "principles.md")
321
+ }
322
+ testutil.WriteFile(t, path, "")
323
+
324
+ result, err := Check(Readiness, root, "empty-stale")
325
+ if err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ if !result.Blocked || result.ReasonID != reason.GateReadinessStale {
329
+ t.Fatalf("empty optional add blocked=%v reason=%q", result.Blocked, result.ReasonID)
330
+ }
331
+ if err := os.Remove(path); err != nil {
332
+ t.Fatal(err)
333
+ }
334
+ result, err = Check(Readiness, root, "empty-stale")
335
+ if err != nil {
336
+ t.Fatal(err)
337
+ }
338
+ if result.Blocked {
339
+ t.Fatalf("empty optional removal remained blocked: %s", result.Render())
340
+ }
341
+ })
342
+ }
343
+ }
344
+
345
+ func TestReadinessDiagnosticPayloads(t *testing.T) {
346
+ for _, tc := range []struct {
347
+ diagnostic state.ArtifactDiagnostic
348
+ want string
349
+ }{
350
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactMalformed, Code: state.DiagnosticMalformedMarkdown}, "readiness input strategy.md is malformed (malformed_markdown); replace invalid Markdown with valid Markdown"},
351
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnsafe, Code: state.DiagnosticParentSymlink}, "readiness input strategy.md is unsafe (parent_symlink); replace the symlinked parent with a real directory"},
352
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnsafe, Code: state.DiagnosticFinalSymlink}, "readiness input strategy.md is unsafe (final_symlink); replace the symlink with a regular file"},
353
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnsafe, Code: state.DiagnosticNonRegular}, "readiness input strategy.md is unsafe (non_regular); replace the non-regular entry with a regular file"},
354
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnsafe, Code: state.DiagnosticFileTooLarge}, "readiness input strategy.md is unsafe (file_too_large); reduce the file to at most 1 MiB"},
355
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnreadable, Code: state.DiagnosticPermissionDenied}, "readiness input strategy.md is unreadable (permission_denied); grant read permission"},
356
+ {state.ArtifactDiagnostic{Path: "strategy.md", State: state.ArtifactUnreadable, Code: state.DiagnosticReadFailure}, "readiness input strategy.md is unreadable (read_failure); restore a readable regular file"},
357
+ } {
358
+ if got := readinessDiagnosticError(tc.diagnostic).Error(); got != tc.want {
359
+ t.Errorf("readinessDiagnosticError(%+v)=%q, want %q", tc.diagnostic, got, tc.want)
360
+ }
361
+ }
362
+ }
363
+
241
364
  func writeReadinessFixture(t *testing.T, root, slug, phase string) string {
242
365
  t.Helper()
243
366
  workspace := filepath.Join(root, "work", slug)
@@ -286,15 +286,15 @@ func clearAwaiting(sfile, qid string) error {
286
286
  return fmt.Errorf("read state %s: %w", sfile, err)
287
287
  }
288
288
  lines := splitLinesNoTrailing(data)
289
- resumePhase := state.PhaseBuild
289
+ resumePolicy, _ := state.PolicyFor(state.PhaseBuild)
290
290
  if rawPhase, ok := state.CursorField(lines, state.CursorPhase); ok {
291
291
  if fields := strings.Fields(strings.ToLower(rawPhase)); len(fields) > 0 {
292
- if phase, known := state.PhaseForName(fields[0]); known && state.ResumeVerb(phase) != "" {
293
- resumePhase = phase
292
+ if policy, known := state.PolicyFor(state.Phase(fields[0])); known && policy.ResumeVerb != "" {
293
+ resumePolicy = policy
294
294
  }
295
295
  }
296
296
  }
297
- resumeCommand := workflow.ForVerb(state.ResumeVerb(resumePhase))
297
+ resumeCommand := workflow.ForVerb(resumePolicy.ResumeVerb)
298
298
  // First check whether the awaiting block references this question at all.
299
299
  inAw := false
300
300
  var awaitingLines []string
@@ -337,7 +337,7 @@ func clearAwaiting(sfile, qid string) error {
337
337
  continue
338
338
  case inLog && hdrSpaceRe.MatchString(line):
339
339
  if !logAppended {
340
- out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
340
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
341
341
  logAppended = true
342
342
  }
343
343
  inLog = false
@@ -347,7 +347,7 @@ func clearAwaiting(sfile, qid string) error {
347
347
  out = append(out, line)
348
348
  }
349
349
  if inLog && !logAppended {
350
- out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePhase, qid))
350
+ out = append(out, fmt.Sprintf("- %s %s: resolved %s", ts, resumePolicy.Target, qid))
351
351
  }
352
352
  out, _ = state.SetCursorField(out, state.CursorStatus, "running")
353
353
  out, _ = state.SetCursorField(out, state.CursorNextAction, "(resume: `"+resumeCommand.Both()+"` to continue the workflow)")
@@ -1,5 +1,5 @@
1
1
  // Command workflowmanifest writes the deterministic cross-language derivative
2
- // of state.WorkflowPhases. The typed Go registry remains the authority.
2
+ // of state.PhasePolicies. The typed Go registry remains the authority.
3
3
  package main
4
4
 
5
5
  import (
@@ -12,6 +12,35 @@ import (
12
12
  "github.com/devrites/devrites/internal/state"
13
13
  )
14
14
 
15
+ type workflowPhase struct {
16
+ ID state.Phase `json:"id"`
17
+ ResumeVerb string `json:"resumeVerb,omitempty"`
18
+ TransitionRight string `json:"transitionRight"`
19
+ RequiredSections []state.Section `json:"requiredSections,omitempty"`
20
+ WorkspaceRequired []state.ArtifactPath `json:"workspaceRequired"`
21
+ ProofRequired bool `json:"proofRequired,omitempty"`
22
+ BlocksOpenQuestions bool `json:"blocksOpenQuestions,omitempty"`
23
+ Shippable bool `json:"shippable,omitempty"`
24
+ }
25
+
26
+ func workflowPhases() []workflowPhase {
27
+ policies := state.PhasePolicies()
28
+ phases := make([]workflowPhase, len(policies))
29
+ for i, policy := range policies {
30
+ phases[i] = workflowPhase{
31
+ ID: policy.Target,
32
+ ResumeVerb: policy.ResumeVerb,
33
+ TransitionRight: policy.TransitionRight,
34
+ RequiredSections: policy.RequiredSections,
35
+ WorkspaceRequired: policy.RequiredArtifacts,
36
+ ProofRequired: policy.ProofRequired,
37
+ BlocksOpenQuestions: policy.BlocksOpenQuestions,
38
+ Shippable: policy.Shippable,
39
+ }
40
+ }
41
+ return phases
42
+ }
43
+
15
44
  func main() {
16
45
  outPath := flag.String("out", "workflow_manifest.json", "output path")
17
46
  check := flag.Bool("check", false, "fail when the output is stale")
@@ -21,13 +50,13 @@ func main() {
21
50
  GeneratedBy string `json:"generatedBy"`
22
51
  SchemaVersion int `json:"schemaVersion"`
23
52
  AuthorityPolicy state.AuthorityPolicy `json:"authorityPolicy"`
24
- Phases []state.WorkflowPhase `json:"phases"`
53
+ Phases []workflowPhase `json:"phases"`
25
54
  CursorKeyAliases []state.CursorKeyAlias `json:"cursorKeyAliases"`
26
55
  }{
27
56
  GeneratedBy: "go generate ./internal/state; DO NOT EDIT",
28
57
  SchemaVersion: state.SchemaVersion,
29
58
  AuthorityPolicy: state.WorkflowAuthorityPolicy(),
30
- Phases: state.WorkflowPhases(),
59
+ Phases: workflowPhases(),
31
60
  CursorKeyAliases: state.CursorKeyAliases(),
32
61
  }
33
62
  data, err := json.MarshalIndent(document, "", " ")
@@ -9,7 +9,6 @@ import (
9
9
  "strings"
10
10
 
11
11
  "github.com/devrites/devrites/internal/devritespaths"
12
- "github.com/devrites/devrites/internal/markdowntext"
13
12
  "github.com/devrites/devrites/internal/rootfacts"
14
13
  )
15
14
 
@@ -24,15 +23,6 @@ func ResolveRoot(override string) (string, error) {
24
23
  return facts.PhysicalRoot, nil
25
24
  }
26
25
 
27
- // Feature is a loaded per-feature workspace: its declared phase plus which
28
- // completeness sections currently have real content.
29
- type Feature struct {
30
- Slug string
31
- Phase Phase
32
- Present map[Section]bool
33
- PresentFiles map[string]bool
34
- }
35
-
36
26
  // featureDir is the canonical per-feature state directory under root.
37
27
  func featureDir(root, slug string) string {
38
28
  return devritespaths.FeatureDir(root, slug)
@@ -71,74 +61,6 @@ func regularFileExists(path string) bool {
71
61
  return err == nil && info.Mode().IsRegular()
72
62
  }
73
63
 
74
- // LoadFeature reads a canonical feature workspace. state.md is the sole
75
- // lifecycle authority; optional README.md metadata is not runtime state.
76
- func LoadFeature(root, slug string) (*Feature, error) {
77
- dir := featureDir(root, slug)
78
- if !regularFileExists(filepath.Join(dir, LedgerFile)) {
79
- return nil, fmt.Errorf("feature %q not found", slug)
80
- }
81
-
82
- phase, ledgerDeclared, err := declaredPhaseFromLedger(filepath.Join(dir, LedgerFile))
83
- if err != nil {
84
- return nil, fmt.Errorf("feature %q: %w", slug, err)
85
- }
86
- if !ledgerDeclared {
87
- return nil, fmt.Errorf("feature %q: no phase in %s ledger", slug, LedgerFile)
88
- }
89
- if !KnownPhase(phase) {
90
- return nil, fmt.Errorf("feature %q: unknown phase %q", slug, phase)
91
- }
92
-
93
- present := make(map[Section]bool, len(Sections))
94
- for _, section := range Sections {
95
- present[section] = sectionPresentAny(dir, section)
96
- }
97
- presentFiles := make(map[string]bool)
98
- for _, name := range WorkspaceFiles() {
99
- presentFiles[name] = sectionPresent(filepath.Join(dir, name))
100
- }
101
- return &Feature{
102
- Slug: slug,
103
- Phase: phase,
104
- Present: present,
105
- PresentFiles: presentFiles,
106
- }, nil
107
- }
108
-
109
- func sectionPresentAny(dir string, s Section) bool {
110
- for _, name := range sectionFiles[s] {
111
- if sectionPresent(filepath.Join(dir, name)) {
112
- return true
113
- }
114
- }
115
- return false
116
- }
117
-
118
- func declaredPhaseFromLedger(path string) (Phase, bool, error) {
119
- raw, err := os.ReadFile(path)
120
- if err != nil {
121
- if errors.Is(err, os.ErrNotExist) {
122
- return "", false, nil
123
- }
124
- return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
125
- }
126
- structural, err := markdowntext.Structural(raw)
127
- if err != nil {
128
- return "", false, fmt.Errorf("read %s: %w", LedgerFile, err)
129
- }
130
- lines := strings.Split(string(structural), "\n")
131
- value, ok := CursorField(lines, CursorPhase)
132
- if !ok {
133
- return "", false, nil
134
- }
135
- word := firstPhaseWord(value)
136
- if phase, known := PhaseForName(word); known {
137
- return phase, true, nil
138
- }
139
- return Phase(word), true, nil
140
- }
141
-
142
64
  func firstPhaseWord(value string) string {
143
65
  word := strings.ToLower(strings.TrimSpace(value))
144
66
  if i := strings.IndexAny(word, " "); i > 0 {
@@ -153,27 +75,6 @@ func blankOrHash(trimmed string) bool {
153
75
  return trimmed == "" || strings.HasPrefix(trimmed, "#")
154
76
  }
155
77
 
156
- // sectionPresent reports whether a section file has real content: it exists
157
- // and, after removing a leading YAML frontmatter block, ATX (`#`) headings, and
158
- // whitespace, some content remains. A stub that is only a heading counts as
159
- // empty, so scaffolding a file never fakes completeness.
160
- func sectionPresent(path string) bool {
161
- raw, err := os.ReadFile(path)
162
- if err != nil {
163
- return false
164
- }
165
- if _, err := markdowntext.Structural(raw); err != nil {
166
- return false
167
- }
168
- body := stripFrontmatter(raw)
169
- for _, line := range strings.Split(string(body), "\n") {
170
- if !blankOrHash(strings.TrimSpace(line)) {
171
- return true
172
- }
173
- }
174
- return false
175
- }
176
-
177
78
  // stripFrontmatter removes a well-formed leading frontmatter block when
178
79
  // determining whether an artifact contains substantive body content. The
179
80
  // closing fence must be a line that is exactly `---`.
@@ -191,7 +92,7 @@ func stripFrontmatter(raw []byte) []byte {
191
92
  }
192
93
  }
193
94
  if end < 0 {
194
- return raw // no closing fence: treat as if there were no frontmatter
95
+ return raw
195
96
  }
196
97
  return []byte(strings.Join(lines[end+1:], "\n"))
197
98
  }