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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [4.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.1.0...v4.2.0) (2026-08-23)
6
+
7
+ ### Added
8
+
9
+ * **rite:** ship workflow artifact, reslice, and observation ([#36](https://github.com/ViktorsBaikers/DevRites/issues/36)) ([456a00e](https://github.com/ViktorsBaikers/DevRites/commit/456a00e8280acdab9525a762a267be7084b26a9f))
10
+
5
11
  ## [4.1.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.0.12...v4.1.0) (2026-08-12)
6
12
 
7
13
  ### Added
package/README.md CHANGED
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
28
28
  Unattended runs may create local WIP checkpoint commits along the way, but they
29
29
  remain local unless Ship's disclosed plan includes an approved remote action.
30
30
 
31
- **Status:** [`v4.1.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.1.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
31
+ **Status:** [`v4.2.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.2.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
32
32
 
33
33
  This is the latest published release; `main` may contain unreleased work.
34
34
 
@@ -12,9 +12,9 @@ workflow strategy.
12
12
  | `update [flags]` | Refresh an existing managed installation. |
13
13
  | `uninstall [flags]` | Remove managed artifacts while preserving runtime workspace state. |
14
14
  | `check candidate <slug>` | Validate the strict manifest and compute the content-bound project-candidate identity. |
15
- | `check readiness <slug>` | Check phase files, human gates, and the current stable Build-input binding when applicable. |
15
+ | `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
16
16
  | `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
17
- | `check seal <slug>` | Check final files, human gates, the stable Build-input binding, and exact candidate bindings. |
17
+ | `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
18
18
  | `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
19
19
  | `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
20
20
  | `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
@@ -29,10 +29,12 @@ not to the engine command namespace.
29
29
  ## Check boundary
30
30
 
31
31
  The candidate gate validates and hashes path/state/type/mode/content identity;
32
- it does not infer scope from Git. The readiness gate checks structure plus the
33
- exact stable Build-input binding after Vet. The seal gate repeats that binding
34
- and checks exact candidate bindings in evidence, optional browser evidence,
35
- review, and seal. None judges the meaning of `CLEAR`/`READY` prose,
32
+ it does not infer scope from Git. The readiness gate checks target-Phase
33
+ structure and applies open-question blocking only when that target is Clarify
34
+ or later, plus the exact stable Build-input binding after Vet. The seal gate
35
+ always targets Phase `seal`, repeats that binding, and checks exact candidate
36
+ bindings in evidence, optional browser evidence, review, and seal. None judges
37
+ the meaning of `CLEAR`/`READY` prose,
36
38
  parses reviewer narratives, infers acceptance coverage, counts assertions,
37
39
  interprets capability deltas, or decides whether a technical plan is sound.
38
40
 
@@ -104,6 +106,63 @@ ambient Git state. Ordinary readiness and Seal require that exact standalone
104
106
  line in `eng-review.md`; stale input returns
105
107
  `reason: DRV-GATE-READINESS-STALE` and routes through `/rite-vet`.
106
108
 
109
+ ### Workspace observation diagnostics
110
+
111
+ Lifecycle checks acquire the fixed workspace Markdown inventory once. Each
112
+ artifact is classified as `absent`, `empty`, `malformed`, `unsafe`,
113
+ `unreadable`, or `present`. Retained content is limited to 1 MiB per file and
114
+ 8 MiB aggregate. Diagnostic lines use this exact shape:
115
+ `artifact: <logical-path>: <state> (<code>)`.
116
+
117
+ The closed diagnostic codes and recoveries are:
118
+
119
+ | Code | Exact Gate recovery | Exact standalone readiness-binding payload |
120
+ |---|---|---|
121
+ | `malformed_markdown` | `next: repair <logical-path>: replace invalid Markdown with valid Markdown; required artifacts need substantive content` | `readiness input <logical-path> is malformed (malformed_markdown); replace invalid Markdown with valid Markdown` |
122
+ | `parent_symlink` | `next: repair <logical-path>: replace the symlinked parent with a real directory` | `readiness input <logical-path> is unsafe (parent_symlink); replace the symlinked parent with a real directory` |
123
+ | `final_symlink` | `next: repair <logical-path>: replace the symlink with a regular file` | `readiness input <logical-path> is unsafe (final_symlink); replace the symlink with a regular file` |
124
+ | `non_regular` | `next: repair <logical-path>: replace the non-regular entry with a regular file` | `readiness input <logical-path> is unsafe (non_regular); replace the non-regular entry with a regular file` |
125
+ | `file_too_large` | `next: repair <logical-path>: reduce the file to at most 1 MiB` | `readiness input <logical-path> is unsafe (file_too_large); reduce the file to at most 1 MiB` |
126
+ | `permission_denied` | `next: repair <logical-path>: grant read permission` | `readiness input <logical-path> is unreadable (permission_denied); grant read permission` |
127
+ | `read_failure` | `next: repair <logical-path>: restore a readable regular file` | `readiness input <logical-path> is unreadable (read_failure); restore a readable regular file` |
128
+
129
+ The Gate recovery column remains exact for target-policy-required artifacts. For
130
+ a selected optional readiness input, the same code-specific repair appends
131
+ `; optional readiness input may instead be removed` and does not call the input
132
+ required.
133
+
134
+ These seven codes are the closed Workspace Observation classification and
135
+ recovery mapping outcomes. A selected public consumer emits only a code
136
+ reachable for its consumed fixed logical path. Invalid workspace ancestry is
137
+ `workspace_invalid`, not an artifact `parent_symlink` diagnostic.
138
+
139
+ Status emits diagnostics without recovery or `next:` lines, after section rows
140
+ and before `result`. Gate emits diagnostics after `reason` and before recovery,
141
+ `invariant`, and `retry` lines. Generic add-content recovery applies only to
142
+ absent or empty target-required artifacts. Standalone readiness-binding
143
+ failures use the existing `readiness-binding: BLOCKED:` prefix and the logical
144
+ readiness-input state/code plus recovery; they never disclose physical paths or
145
+ content.
146
+
147
+ Whole observation failures are `workspace_invalid`, `aggregate_too_large`, and
148
+ `concurrent_change`. Their disclosure-safe payloads are exact:
149
+
150
+ - `workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry`
151
+ - `workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry`
152
+ - `workspace observation: concurrent_change: workspace changed during acquisition; retry`
153
+
154
+ An absent or empty `state.md` appends `add real content to state.md and retry` to
155
+ the existing logical error. A malformed, unsafe, or unreadable `state.md`
156
+ appends `repair state.md and retry`. A ledger without a phase appends `record
157
+ phase in state.md and retry`; an unknown phase appends `record a known phase in
158
+ state.md and retry`.
159
+
160
+ Whole observation failures use stderr, exit `2`, and no lifecycle result or
161
+ reason on stdout. Standalone readiness-binding failures use one stderr line,
162
+ exit `3`, and empty stdout. Per-artifact lifecycle blocks keep existing reason
163
+ IDs and stdout exit `3`; successful checks keep stdout exit `0`. Seal evidence
164
+ freshness still runs separately after a successful Seal gate.
165
+
107
166
  - `0`: passed or completed.
108
167
  - `2`: common invalid request or unreadable-state result.
109
168
  - `3`: common deterministic lifecycle or safety block.
package/engine/go.mod CHANGED
@@ -2,4 +2,4 @@ module github.com/devrites/devrites
2
2
 
3
3
  go 1.26
4
4
 
5
- toolchain go1.26.5
5
+ toolchain go1.26.7
@@ -6,11 +6,8 @@ package gate
6
6
 
7
7
  import (
8
8
  "fmt"
9
- "os"
10
- "path/filepath"
11
9
  "strings"
12
10
 
13
- "github.com/devrites/devrites/internal/devritespaths"
14
11
  "github.com/devrites/devrites/internal/reason"
15
12
  "github.com/devrites/devrites/internal/state"
16
13
  )
@@ -27,50 +24,82 @@ const (
27
24
  Seal Kind = "seal"
28
25
  )
29
26
 
30
- // Result is a gate outcome. Blocked is true iff a required section is missing;
31
- // Missing lists them in canonical order for an actionable message.
27
+ // Result is a gate outcome. Blocked is true iff a deterministic requirement
28
+ // failed; missing files and diagnostics retain their canonical policy order.
32
29
  type Result struct {
33
- Kind Kind
34
- Slug string
35
- Phase state.Phase
36
- Target state.Phase // the phase whose requirements were checked
37
- Missing []state.Section // compact legacy view
38
- MissingFiles []string // authoritative per-file view
39
- StateProblems []string // deterministic cross-file invariant failures
40
- Blocked bool
41
- ReasonID reason.ID
30
+ Kind Kind
31
+ Slug string
32
+ Phase state.Phase
33
+ Target state.Phase
34
+ Missing []state.Section
35
+ MissingFiles []string
36
+ Diagnostics []state.ArtifactDiagnostic
37
+ AddContentFiles []string
38
+ StateProblems []string
39
+ Blocked bool
40
+ ReasonID reason.ID
42
41
  }
43
42
 
44
- // Check runs a gate against feature <slug> under root. It reads workspace files
45
- // directly instead of trusting a cache. Invalid requests and unreadable state
46
- // return errors; missing required content returns a blocked Result.
43
+ // Check runs a gate against feature <slug> under root. One workspace observation
44
+ // supplies every lifecycle, question, and readiness fact used by the result.
47
45
  func Check(kind Kind, root, slug string) (*Result, error) {
48
- f, err := state.LoadFeature(root, slug)
46
+ observation, err := state.ObserveWorkspace(root, slug)
49
47
  if err != nil {
50
48
  return nil, fmt.Errorf("gate %s: %w", kind, err)
51
49
  }
52
- target := f.Phase
50
+ result, err := checkObservation(kind, observation)
51
+ if err != nil {
52
+ return nil, fmt.Errorf("gate %s: %w", kind, err)
53
+ }
54
+ return result, nil
55
+ }
56
+
57
+ func checkObservation(kind Kind, observation *state.WorkspaceObservation) (*Result, error) {
58
+ phase, err := observation.DeclaredPhase()
59
+ if err != nil {
60
+ return nil, err
61
+ }
62
+ target := phase
53
63
  if kind == Seal {
54
64
  target = state.PhaseSeal
55
65
  }
56
- missing := state.MissingFor(f, target)
57
- missingFiles := state.MissingWorkspaceFiles(f, target)
66
+ policy, ok := state.PolicyFor(target)
67
+ if !ok {
68
+ return nil, fmt.Errorf("unknown target phase %q", target)
69
+ }
70
+ target = policy.Target
71
+
72
+ missing := missingSections(observation, policy.RequiredSections)
73
+ missingArtifacts, diagnostics := observation.Missing(policy.RequiredArtifacts)
74
+ missingFiles := make([]string, len(missingArtifacts))
75
+ var addContentFiles []string
76
+ for i, artifact := range missingArtifacts {
77
+ missingFiles[i] = string(artifact)
78
+ fact, ok := observation.Fact(artifact)
79
+ if ok && (fact.State() == state.ArtifactAbsent || fact.State() == state.ArtifactEmpty) {
80
+ addContentFiles = append(addContentFiles, string(artifact))
81
+ }
82
+ }
58
83
  blocked := len(missingFiles) > 0
59
84
  readinessStale := false
60
85
  var stateProblems []string
61
- if gates, awaitingHuman := openHumanGates(devritespaths.FeatureDir(root, slug)); len(gates) > 0 {
62
- problem := fmt.Sprintf("open %s human question(s) remain in questions.md", strings.Join(gates, "/"))
63
- if !awaitingHuman {
64
- problem += " but state.md is not awaiting_human"
86
+
87
+ if policy.BlocksOpenQuestions {
88
+ if gates, awaitingHuman := retainedHumanGates(observation); len(gates) > 0 {
89
+ problem := fmt.Sprintf("open %s human question(s) remain in questions.md", strings.Join(gates, "/"))
90
+ if !awaitingHuman {
91
+ problem += " but state.md is not awaiting_human"
92
+ }
93
+ stateProblems = append(stateProblems, problem)
94
+ blocked = true
65
95
  }
66
- stateProblems = append(stateProblems, problem)
67
- blocked = true
68
96
  }
69
- if len(missingFiles) == 0 && phaseRequiresReadinessBinding(target) {
70
- expected, bindingErr := verifyReadinessBinding(root, slug)
97
+ if len(missingFiles) == 0 && phaseRequiresReadinessBinding(policy) {
98
+ expected, bindingErr := verifyReadinessBinding(observation)
71
99
  if bindingErr != nil {
72
100
  readinessStale = true
73
101
  blocked = true
102
+ diagnostics = append(diagnostics, readinessDiagnostics(observation)...)
74
103
  if expected == "" {
75
104
  stateProblems = append(stateProblems, bindingErr.Error()+"; repair the input and rerun /rite-vet")
76
105
  } else {
@@ -78,15 +107,18 @@ func Check(kind Kind, root, slug string) (*Result, error) {
78
107
  }
79
108
  }
80
109
  }
110
+
81
111
  result := &Result{
82
- Kind: kind,
83
- Slug: slug,
84
- Phase: f.Phase,
85
- Target: target,
86
- Missing: missing,
87
- MissingFiles: missingFiles,
88
- StateProblems: stateProblems,
89
- Blocked: blocked,
112
+ Kind: kind,
113
+ Slug: observation.Slug(),
114
+ Phase: phase,
115
+ Target: target,
116
+ Missing: missing,
117
+ MissingFiles: missingFiles,
118
+ Diagnostics: diagnostics,
119
+ AddContentFiles: addContentFiles,
120
+ StateProblems: stateProblems,
121
+ Blocked: blocked,
90
122
  }
91
123
  result.ReasonID = ResultReasonID(kind, blocked)
92
124
  if readinessStale {
@@ -95,6 +127,24 @@ func Check(kind Kind, root, slug string) (*Result, error) {
95
127
  return result, nil
96
128
  }
97
129
 
130
+ func missingSections(observation *state.WorkspaceObservation, required []state.Section) []state.Section {
131
+ var missing []state.Section
132
+ for _, section := range required {
133
+ artifact := state.ArtifactPath(string(section) + ".md")
134
+ switch section {
135
+ case state.SectionProof:
136
+ artifact = state.EvidenceFile
137
+ case state.SectionStatus:
138
+ artifact = state.LedgerFile
139
+ }
140
+ fact, ok := observation.Fact(artifact)
141
+ if !ok || fact.State() != state.ArtifactPresent {
142
+ missing = append(missing, section)
143
+ }
144
+ }
145
+ return missing
146
+ }
147
+
98
148
  // ResultReasonID returns the typed outcome owned by the lifecycle gate.
99
149
  func ResultReasonID(kind Kind, blocked bool) reason.ID {
100
150
  switch kind {
@@ -114,7 +164,7 @@ func ResultReasonID(kind Kind, blocked bool) reason.ID {
114
164
  }
115
165
 
116
166
  // Render returns stable, greppable output with a trailing newline. A blocked
117
- // result names the missing files and the command to rerun.
167
+ // result names missing files, selected diagnostics, recovery, and the retry.
118
168
  func (r *Result) Render() string {
119
169
  var b strings.Builder
120
170
  fmt.Fprintf(&b, "gate: %s\n", r.Kind)
@@ -135,8 +185,17 @@ func (r *Result) Render() string {
135
185
  fmt.Fprintf(&b, "result: blocked (missing to leave %q: %s)\n", r.Phase, strings.Join(missing, ", "))
136
186
  }
137
187
  fmt.Fprintf(&b, "reason: %s\n", r.ReasonID)
138
- if len(missing) > 0 {
139
- fmt.Fprintf(&b, "next: add real content to %s\n", strings.Join(missing, ", "))
188
+ for _, diagnostic := range r.Diagnostics {
189
+ fmt.Fprintf(&b, "artifact: %s: %s (%s)\n", diagnostic.Path, diagnostic.State, diagnostic.Code)
190
+ }
191
+ if len(r.AddContentFiles) > 0 {
192
+ fmt.Fprintf(&b, "next: add real content to %s\n", strings.Join(r.AddContentFiles, ", "))
193
+ }
194
+ for _, diagnostic := range r.Diagnostics {
195
+ if recovery := diagnosticRecovery(diagnostic, targetRequiresArtifact(r.Target, diagnostic.Path)); recovery != "" {
196
+ b.WriteString(recovery)
197
+ b.WriteByte('\n')
198
+ }
140
199
  }
141
200
  for _, problem := range r.StateProblems {
142
201
  fmt.Fprintf(&b, "invariant: %s\n", problem)
@@ -145,19 +204,68 @@ func (r *Result) Render() string {
145
204
  return b.String()
146
205
  }
147
206
 
207
+ func diagnosticRecovery(diagnostic state.ArtifactDiagnostic, required bool) string {
208
+ prefix := fmt.Sprintf("next: repair %s: ", diagnostic.Path)
209
+ repair := diagnosticRepair(diagnostic.Code)
210
+ if repair == "" {
211
+ return ""
212
+ }
213
+ if required && diagnostic.Code == state.DiagnosticMalformedMarkdown {
214
+ repair += "; required artifacts need substantive content"
215
+ }
216
+ if !required {
217
+ repair += "; optional readiness input may instead be removed"
218
+ }
219
+ return prefix + repair
220
+ }
221
+
222
+ func targetRequiresArtifact(target state.Phase, path state.ArtifactPath) bool {
223
+ policy, ok := state.PolicyFor(target)
224
+ if !ok {
225
+ return false
226
+ }
227
+ for _, required := range policy.RequiredArtifacts {
228
+ if required == path {
229
+ return true
230
+ }
231
+ }
232
+ return false
233
+ }
234
+
235
+ func diagnosticRepair(code state.DiagnosticCode) string {
236
+ switch code {
237
+ case state.DiagnosticMalformedMarkdown:
238
+ return "replace invalid Markdown with valid Markdown"
239
+ case state.DiagnosticParentSymlink:
240
+ return "replace the symlinked parent with a real directory"
241
+ case state.DiagnosticFinalSymlink:
242
+ return "replace the symlink with a regular file"
243
+ case state.DiagnosticNonRegular:
244
+ return "replace the non-regular entry with a regular file"
245
+ case state.DiagnosticFileTooLarge:
246
+ return "reduce the file to at most 1 MiB"
247
+ case state.DiagnosticPermissionDenied:
248
+ return "grant read permission"
249
+ case state.DiagnosticReadFailure:
250
+ return "restore a readable regular file"
251
+ default:
252
+ return ""
253
+ }
254
+ }
255
+
148
256
  const gateSpaceChars = " \t\n\v\f\r"
149
257
 
150
- func openHumanGates(featureDir string) ([]string, bool) {
151
- qdata, err := os.ReadFile(filepath.Join(featureDir, "questions.md"))
152
- if err != nil {
258
+ func retainedHumanGates(observation *state.WorkspaceObservation) ([]string, bool) {
259
+ questions, ok := observation.Fact("questions.md")
260
+ if !ok || (questions.State() != state.ArtifactPresent && questions.State() != state.ArtifactEmpty) {
153
261
  return nil, false
154
262
  }
155
- gates := openBlockingQuestionGates(qdata)
263
+ gates := openBlockingQuestionGates(questions.Bytes())
156
264
  if len(gates) == 0 {
157
265
  return nil, false
158
266
  }
159
- sdata, err := os.ReadFile(filepath.Join(featureDir, "state.md"))
160
- return gates, err == nil && stateAwaitingHuman(sdata)
267
+ ledger, ok := observation.Fact(state.LedgerFile)
268
+ return gates, ok && ledger.State() == state.ArtifactPresent && stateAwaitingHuman(ledger.Bytes())
161
269
  }
162
270
 
163
271
  func openBlockingQuestionGates(data []byte) []string {