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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +65 -6
- package/engine/go.mod +1 -1
- package/engine/internal/gate/gate.go +154 -46
- package/engine/internal/gate/gate_test.go +290 -2
- package/engine/internal/gate/readiness_binding.go +66 -100
- package/engine/internal/gate/readiness_binding_test.go +126 -3
- package/engine/internal/lib/resolve.go +6 -6
- package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
- package/engine/internal/state/feature.go +1 -100
- package/engine/internal/state/observation.go +584 -0
- package/engine/internal/state/observation_open_other.go +9 -0
- package/engine/internal/state/observation_open_unix.go +12 -0
- package/engine/internal/state/observation_test.go +960 -0
- package/engine/internal/state/observation_unix_test.go +68 -0
- package/engine/internal/state/schema.go +60 -160
- package/engine/internal/state/state_test.go +489 -101
- package/engine/internal/state/status.go +67 -48
- package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
- package/engine/tests/adr_0011_define_plan_test.go +14 -12
- package/engine/tests/gate_test.go +403 -28
- package/engine/tests/workspace_observation_migration_test.go +1065 -0
- package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/.claude/skills/rite-build/SKILL.md +51 -79
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
- package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
- package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
- package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
- package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
- package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/generated/claude/skills/rite-build/SKILL.md +51 -79
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
- package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
- package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
- package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
- package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
- package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
- package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
- package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
- package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
- package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
- package/pack/generated/codex/skills/rite-build/SKILL.md +51 -79
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -17
- package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
- package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
- package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
- package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
- package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
- package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
- package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
- package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
- package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
- package/package.json +1 -1
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
package state
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"io/fs"
|
|
8
|
+
"os"
|
|
9
|
+
"path"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"strings"
|
|
12
|
+
|
|
13
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
14
|
+
"github.com/devrites/devrites/internal/markdowntext"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const (
|
|
18
|
+
maxArtifactBytes = 1 << 20
|
|
19
|
+
maxObservationBytes = 8 << 20
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
type ArtifactState string
|
|
23
|
+
|
|
24
|
+
const (
|
|
25
|
+
ArtifactAbsent ArtifactState = "absent"
|
|
26
|
+
ArtifactEmpty ArtifactState = "empty"
|
|
27
|
+
ArtifactMalformed ArtifactState = "malformed"
|
|
28
|
+
ArtifactUnsafe ArtifactState = "unsafe"
|
|
29
|
+
ArtifactUnreadable ArtifactState = "unreadable"
|
|
30
|
+
ArtifactPresent ArtifactState = "present"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
type DiagnosticCode string
|
|
34
|
+
|
|
35
|
+
const (
|
|
36
|
+
DiagnosticMalformedMarkdown DiagnosticCode = "malformed_markdown"
|
|
37
|
+
DiagnosticParentSymlink DiagnosticCode = "parent_symlink"
|
|
38
|
+
DiagnosticFinalSymlink DiagnosticCode = "final_symlink"
|
|
39
|
+
DiagnosticNonRegular DiagnosticCode = "non_regular"
|
|
40
|
+
DiagnosticFileTooLarge DiagnosticCode = "file_too_large"
|
|
41
|
+
DiagnosticPermissionDenied DiagnosticCode = "permission_denied"
|
|
42
|
+
DiagnosticReadFailure DiagnosticCode = "read_failure"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
type ObservationFailure string
|
|
46
|
+
|
|
47
|
+
const (
|
|
48
|
+
ObservationWorkspaceInvalid ObservationFailure = "workspace_invalid"
|
|
49
|
+
ObservationAggregateTooLarge ObservationFailure = "aggregate_too_large"
|
|
50
|
+
ObservationConcurrentChange ObservationFailure = "concurrent_change"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
func (f ObservationFailure) Error() string {
|
|
54
|
+
switch f {
|
|
55
|
+
case ObservationWorkspaceInvalid:
|
|
56
|
+
return "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry"
|
|
57
|
+
case ObservationAggregateTooLarge:
|
|
58
|
+
return "workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry"
|
|
59
|
+
case ObservationConcurrentChange:
|
|
60
|
+
return "workspace observation: concurrent_change: workspace changed during acquisition; retry"
|
|
61
|
+
default:
|
|
62
|
+
return "workspace observation: " + string(f)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ArtifactDiagnostic struct {
|
|
67
|
+
Path ArtifactPath
|
|
68
|
+
State ArtifactState
|
|
69
|
+
Code DiagnosticCode
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type ArtifactFact struct {
|
|
73
|
+
path ArtifactPath
|
|
74
|
+
state ArtifactState
|
|
75
|
+
bytes []byte
|
|
76
|
+
code DiagnosticCode
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (f ArtifactFact) Path() ArtifactPath { return f.path }
|
|
80
|
+
|
|
81
|
+
func (f ArtifactFact) State() ArtifactState { return f.state }
|
|
82
|
+
|
|
83
|
+
func (f ArtifactFact) Bytes() []byte { return copyArtifactBytes(f.bytes) }
|
|
84
|
+
|
|
85
|
+
func (f ArtifactFact) Diagnostic() (ArtifactDiagnostic, bool) {
|
|
86
|
+
if f.code == "" {
|
|
87
|
+
return ArtifactDiagnostic{}, false
|
|
88
|
+
}
|
|
89
|
+
return ArtifactDiagnostic{Path: f.path, State: f.state, Code: f.code}, true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type WorkspaceObservation struct {
|
|
93
|
+
slug string
|
|
94
|
+
facts []ArtifactFact
|
|
95
|
+
index map[ArtifactPath]int
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func (o *WorkspaceObservation) Slug() string { return o.slug }
|
|
99
|
+
|
|
100
|
+
func (o *WorkspaceObservation) Facts() []ArtifactFact {
|
|
101
|
+
facts := make([]ArtifactFact, len(o.facts))
|
|
102
|
+
for i, fact := range o.facts {
|
|
103
|
+
facts[i] = cloneArtifactFact(fact)
|
|
104
|
+
}
|
|
105
|
+
return facts
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func (o *WorkspaceObservation) Fact(path ArtifactPath) (ArtifactFact, bool) {
|
|
109
|
+
index, ok := o.index[path]
|
|
110
|
+
if !ok {
|
|
111
|
+
return ArtifactFact{}, false
|
|
112
|
+
}
|
|
113
|
+
return cloneArtifactFact(o.facts[index]), true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (o *WorkspaceObservation) DeclaredPhase() (Phase, error) {
|
|
117
|
+
fact, _ := o.Fact(LedgerFile)
|
|
118
|
+
if fact.state != ArtifactPresent {
|
|
119
|
+
recovery := "repair state.md and retry"
|
|
120
|
+
if fact.state == ArtifactAbsent || fact.state == ArtifactEmpty {
|
|
121
|
+
recovery = "add real content to state.md and retry"
|
|
122
|
+
}
|
|
123
|
+
if diagnostic, ok := fact.Diagnostic(); ok {
|
|
124
|
+
return "", fmt.Errorf("feature %q: %s is %s (%s); %s", o.slug, LedgerFile, fact.state, diagnostic.Code, recovery)
|
|
125
|
+
}
|
|
126
|
+
return "", fmt.Errorf("feature %q: %s is %s; %s", o.slug, LedgerFile, fact.state, recovery)
|
|
127
|
+
}
|
|
128
|
+
structural, err := markdowntext.Structural(fact.bytes)
|
|
129
|
+
if err != nil {
|
|
130
|
+
return "", fmt.Errorf("feature %q: %s is %s (%s); repair state.md and retry", o.slug, LedgerFile, ArtifactMalformed, DiagnosticMalformedMarkdown)
|
|
131
|
+
}
|
|
132
|
+
value, ok := CursorField(strings.Split(string(structural), "\n"), CursorPhase)
|
|
133
|
+
if !ok {
|
|
134
|
+
return "", fmt.Errorf("feature %q: no phase in %s ledger; record phase in state.md and retry", o.slug, LedgerFile)
|
|
135
|
+
}
|
|
136
|
+
phase := Phase(firstPhaseWord(value))
|
|
137
|
+
policy, ok := PolicyFor(phase)
|
|
138
|
+
if !ok {
|
|
139
|
+
return "", fmt.Errorf("feature %q: unknown phase %q; record a known phase in state.md and retry", o.slug, phase)
|
|
140
|
+
}
|
|
141
|
+
return policy.Target, nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func (o *WorkspaceObservation) Missing(required []ArtifactPath) ([]ArtifactPath, []ArtifactDiagnostic) {
|
|
145
|
+
var missing []ArtifactPath
|
|
146
|
+
var diagnostics []ArtifactDiagnostic
|
|
147
|
+
for _, path := range required {
|
|
148
|
+
fact, ok := o.Fact(path)
|
|
149
|
+
if ok && fact.state == ArtifactPresent {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
missing = append(missing, path)
|
|
153
|
+
if ok {
|
|
154
|
+
if diagnostic, hasDiagnostic := fact.Diagnostic(); hasDiagnostic {
|
|
155
|
+
diagnostics = append(diagnostics, diagnostic)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return missing, diagnostics
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func cloneArtifactFact(fact ArtifactFact) ArtifactFact {
|
|
163
|
+
fact.bytes = copyArtifactBytes(fact.bytes)
|
|
164
|
+
return fact
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func copyArtifactBytes(raw []byte) []byte {
|
|
168
|
+
if raw == nil {
|
|
169
|
+
return nil
|
|
170
|
+
}
|
|
171
|
+
copied := make([]byte, len(raw))
|
|
172
|
+
copy(copied, raw)
|
|
173
|
+
return copied
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type observationStage uint8
|
|
177
|
+
|
|
178
|
+
const (
|
|
179
|
+
observationAfterResolve observationStage = iota + 1
|
|
180
|
+
observationAfterInspect
|
|
181
|
+
observationBeforeOpen
|
|
182
|
+
observationBeforeRead
|
|
183
|
+
observationAfterRead
|
|
184
|
+
observationBeforeFinalSweep
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
type observationCallback func(observationStage, ArtifactPath) error
|
|
188
|
+
|
|
189
|
+
type artifactLocation struct {
|
|
190
|
+
path ArtifactPath
|
|
191
|
+
root *os.Root
|
|
192
|
+
name string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type entryState uint8
|
|
196
|
+
|
|
197
|
+
const (
|
|
198
|
+
entryAbsent entryState = iota
|
|
199
|
+
entryPresent
|
|
200
|
+
entryUnreadable
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
type observedEntry struct {
|
|
204
|
+
state entryState
|
|
205
|
+
info os.FileInfo
|
|
206
|
+
code DiagnosticCode
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type namedEntry struct {
|
|
210
|
+
name string
|
|
211
|
+
entry observedEntry
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type artifactSnapshot struct {
|
|
215
|
+
parents []namedEntry
|
|
216
|
+
final *observedEntry
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type observationRoots struct {
|
|
220
|
+
devrites *os.Root
|
|
221
|
+
workspace *os.Root
|
|
222
|
+
rootInfo os.FileInfo
|
|
223
|
+
workspaceInfo os.FileInfo
|
|
224
|
+
workspaceEntry namedEntry
|
|
225
|
+
workspaceParents []namedEntry
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func ObserveWorkspace(root, slug string) (*WorkspaceObservation, error) {
|
|
229
|
+
return observeWorkspace(root, slug, nil)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func observeWorkspace(root, slug string, callback observationCallback) (*WorkspaceObservation, error) {
|
|
233
|
+
if !observationWorkspaceOverrideValid(root, slug) {
|
|
234
|
+
return nil, ObservationWorkspaceInvalid
|
|
235
|
+
}
|
|
236
|
+
workspacePath, err := devritespaths.ExistingFeatureDirChecked(root, slug)
|
|
237
|
+
if err != nil {
|
|
238
|
+
return nil, ObservationWorkspaceInvalid
|
|
239
|
+
}
|
|
240
|
+
rootInfo, err := os.Stat(root)
|
|
241
|
+
if err != nil || !rootInfo.IsDir() {
|
|
242
|
+
return nil, ObservationWorkspaceInvalid
|
|
243
|
+
}
|
|
244
|
+
workspaceInfo, err := os.Stat(workspacePath)
|
|
245
|
+
if err != nil || !workspaceInfo.IsDir() {
|
|
246
|
+
return nil, ObservationWorkspaceInvalid
|
|
247
|
+
}
|
|
248
|
+
if err := runObservationStage(callback, observationAfterResolve, ""); err != nil {
|
|
249
|
+
return nil, ObservationWorkspaceInvalid
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
roots, err := openObservationRoots(root, workspacePath, rootInfo, workspaceInfo)
|
|
253
|
+
if err != nil {
|
|
254
|
+
return nil, ObservationWorkspaceInvalid
|
|
255
|
+
}
|
|
256
|
+
defer roots.devrites.Close()
|
|
257
|
+
defer roots.workspace.Close()
|
|
258
|
+
|
|
259
|
+
inventory := observationInventory()
|
|
260
|
+
facts := make([]ArtifactFact, 0, len(inventory))
|
|
261
|
+
snapshots := make([]artifactSnapshot, 0, len(inventory))
|
|
262
|
+
locations := make([]artifactLocation, 0, len(inventory))
|
|
263
|
+
retainedBytes := 0
|
|
264
|
+
for _, artifactPath := range inventory {
|
|
265
|
+
location := artifactLocation{path: artifactPath, root: roots.workspace, name: string(artifactPath)}
|
|
266
|
+
if artifactPath == ".devrites/principles.md" {
|
|
267
|
+
location.root = roots.devrites
|
|
268
|
+
location.name = "principles.md"
|
|
269
|
+
}
|
|
270
|
+
fact, snapshot, observeErr := observeArtifact(location, callback)
|
|
271
|
+
if observeErr != nil {
|
|
272
|
+
return nil, observeErr
|
|
273
|
+
}
|
|
274
|
+
retainedBytes += len(fact.bytes)
|
|
275
|
+
if retainedBytes > maxObservationBytes {
|
|
276
|
+
return nil, ObservationAggregateTooLarge
|
|
277
|
+
}
|
|
278
|
+
facts = append(facts, fact)
|
|
279
|
+
snapshots = append(snapshots, snapshot)
|
|
280
|
+
locations = append(locations, location)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if err := runObservationStage(callback, observationBeforeFinalSweep, ""); err != nil {
|
|
284
|
+
return nil, ObservationConcurrentChange
|
|
285
|
+
}
|
|
286
|
+
if !observationRootsUnchanged(roots) {
|
|
287
|
+
return nil, ObservationConcurrentChange
|
|
288
|
+
}
|
|
289
|
+
for i, location := range locations {
|
|
290
|
+
if !artifactUnchanged(location, snapshots[i]) {
|
|
291
|
+
return nil, ObservationConcurrentChange
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
index := make(map[ArtifactPath]int, len(facts))
|
|
296
|
+
for i, fact := range facts {
|
|
297
|
+
index[fact.path] = i
|
|
298
|
+
}
|
|
299
|
+
return &WorkspaceObservation{slug: slug, facts: facts, index: index}, nil
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func observationWorkspaceOverrideValid(root, slug string) bool {
|
|
303
|
+
raw := os.Getenv("DEVRITES_WORKSPACE")
|
|
304
|
+
if raw == "" {
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
if strings.TrimSpace(raw) != raw || filepath.Clean(raw) != raw {
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
if !filepath.IsAbs(raw) && !filepath.IsLocal(raw) {
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
313
|
+
physicalRoot, err := filepath.EvalSymlinks(root)
|
|
314
|
+
if err != nil {
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
physicalRoot, err = filepath.Abs(physicalRoot)
|
|
318
|
+
if err != nil {
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
override := raw
|
|
322
|
+
if !filepath.IsAbs(override) {
|
|
323
|
+
override = filepath.Join(filepath.Dir(physicalRoot), override)
|
|
324
|
+
}
|
|
325
|
+
override, err = filepath.Abs(override)
|
|
326
|
+
if err != nil {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
want := filepath.Join(physicalRoot, "work", slug)
|
|
330
|
+
return filepath.Clean(override) == filepath.Clean(want)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
func observationInventory() []ArtifactPath {
|
|
334
|
+
seen := make(map[ArtifactPath]bool)
|
|
335
|
+
inventory := make([]ArtifactPath, 0, 21)
|
|
336
|
+
for _, policy := range PhasePolicies() {
|
|
337
|
+
for _, artifact := range policy.RequiredArtifacts {
|
|
338
|
+
if seen[artifact] {
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
seen[artifact] = true
|
|
342
|
+
inventory = append(inventory, artifact)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for _, artifact := range []ArtifactPath{"strategy.md", "design-brief.md", "ai-spec.md", ".devrites/principles.md"} {
|
|
346
|
+
if !seen[artifact] {
|
|
347
|
+
seen[artifact] = true
|
|
348
|
+
inventory = append(inventory, artifact)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return inventory
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
func openObservationRoots(rootPath, workspacePath string, rootInfo, workspaceInfo os.FileInfo) (*observationRoots, error) {
|
|
355
|
+
devritesRoot, err := os.OpenRoot(rootPath)
|
|
356
|
+
if err != nil {
|
|
357
|
+
return nil, err
|
|
358
|
+
}
|
|
359
|
+
workspaceRoot, err := os.OpenRoot(workspacePath)
|
|
360
|
+
if err != nil {
|
|
361
|
+
_ = devritesRoot.Close()
|
|
362
|
+
return nil, err
|
|
363
|
+
}
|
|
364
|
+
roots := &observationRoots{
|
|
365
|
+
devrites: devritesRoot,
|
|
366
|
+
workspace: workspaceRoot,
|
|
367
|
+
rootInfo: rootInfo,
|
|
368
|
+
workspaceInfo: workspaceInfo,
|
|
369
|
+
}
|
|
370
|
+
rootHandleInfo, rootErr := devritesRoot.Stat(".")
|
|
371
|
+
workspaceHandleInfo, workspaceErr := workspaceRoot.Stat(".")
|
|
372
|
+
if rootErr != nil || workspaceErr != nil || !sameEntry(rootInfo, rootHandleInfo) || !sameEntry(workspaceInfo, workspaceHandleInfo) {
|
|
373
|
+
_ = devritesRoot.Close()
|
|
374
|
+
_ = workspaceRoot.Close()
|
|
375
|
+
return nil, errors.New("observation root identity changed")
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
relativeWorkspace, err := filepath.Rel(rootPath, workspacePath)
|
|
379
|
+
if err != nil || relativeWorkspace == "." || filepath.IsAbs(relativeWorkspace) || strings.HasPrefix(relativeWorkspace, ".."+string(filepath.Separator)) {
|
|
380
|
+
_ = devritesRoot.Close()
|
|
381
|
+
_ = workspaceRoot.Close()
|
|
382
|
+
return nil, errors.New("observation workspace is outside root")
|
|
383
|
+
}
|
|
384
|
+
for _, parentName := range parentPaths(filepath.ToSlash(relativeWorkspace)) {
|
|
385
|
+
entry := inspectEntry(devritesRoot, filepath.FromSlash(parentName))
|
|
386
|
+
if entry.state != entryPresent || entry.info.Mode()&os.ModeSymlink != 0 || !entry.info.IsDir() {
|
|
387
|
+
_ = devritesRoot.Close()
|
|
388
|
+
_ = workspaceRoot.Close()
|
|
389
|
+
return nil, errors.New("observation workspace parent is invalid")
|
|
390
|
+
}
|
|
391
|
+
roots.workspaceParents = append(roots.workspaceParents, namedEntry{name: filepath.FromSlash(parentName), entry: entry})
|
|
392
|
+
}
|
|
393
|
+
workspaceEntry := inspectEntry(devritesRoot, relativeWorkspace)
|
|
394
|
+
if workspaceEntry.state != entryPresent || workspaceEntry.info.Mode()&os.ModeSymlink != 0 || !workspaceEntry.info.IsDir() || !sameEntry(workspaceInfo, workspaceEntry.info) {
|
|
395
|
+
_ = devritesRoot.Close()
|
|
396
|
+
_ = workspaceRoot.Close()
|
|
397
|
+
return nil, errors.New("observation workspace identity changed")
|
|
398
|
+
}
|
|
399
|
+
roots.workspaceEntry = namedEntry{name: relativeWorkspace, entry: workspaceEntry}
|
|
400
|
+
return roots, nil
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
func observationRootsUnchanged(roots *observationRoots) bool {
|
|
404
|
+
rootInfo, rootErr := roots.devrites.Stat(".")
|
|
405
|
+
workspaceInfo, workspaceErr := roots.workspace.Stat(".")
|
|
406
|
+
if rootErr != nil || workspaceErr != nil || !sameEntry(roots.rootInfo, rootInfo) || !sameEntry(roots.workspaceInfo, workspaceInfo) {
|
|
407
|
+
return false
|
|
408
|
+
}
|
|
409
|
+
for _, parent := range roots.workspaceParents {
|
|
410
|
+
if !sameObservedEntry(parent.entry, inspectEntry(roots.devrites, parent.name)) {
|
|
411
|
+
return false
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return sameObservedEntry(roots.workspaceEntry.entry, inspectEntry(roots.devrites, roots.workspaceEntry.name))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func observeArtifact(location artifactLocation, callback observationCallback) (ArtifactFact, artifactSnapshot, error) {
|
|
418
|
+
snapshot := artifactSnapshot{}
|
|
419
|
+
for _, parentName := range parentPaths(filepath.ToSlash(location.name)) {
|
|
420
|
+
entry := inspectEntry(location.root, filepath.FromSlash(parentName))
|
|
421
|
+
snapshot.parents = append(snapshot.parents, namedEntry{name: filepath.FromSlash(parentName), entry: entry})
|
|
422
|
+
switch {
|
|
423
|
+
case entry.state == entryUnreadable:
|
|
424
|
+
return unreadableFact(location.path, entry.code), snapshot, nil
|
|
425
|
+
case entry.state == entryAbsent:
|
|
426
|
+
return ArtifactFact{path: location.path, state: ArtifactAbsent}, snapshot, nil
|
|
427
|
+
case entry.info.Mode()&os.ModeSymlink != 0:
|
|
428
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticParentSymlink), snapshot, nil
|
|
429
|
+
case !entry.info.IsDir():
|
|
430
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticNonRegular), snapshot, nil
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
entry := inspectEntry(location.root, location.name)
|
|
435
|
+
snapshot.final = &entry
|
|
436
|
+
if err := runObservationStage(callback, observationAfterInspect, location.path); err != nil {
|
|
437
|
+
return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
|
|
438
|
+
}
|
|
439
|
+
switch {
|
|
440
|
+
case entry.state == entryAbsent:
|
|
441
|
+
return ArtifactFact{path: location.path, state: ArtifactAbsent}, snapshot, nil
|
|
442
|
+
case entry.state == entryUnreadable:
|
|
443
|
+
return unreadableFact(location.path, entry.code), snapshot, nil
|
|
444
|
+
case entry.info.Mode()&os.ModeSymlink != 0:
|
|
445
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFinalSymlink), snapshot, nil
|
|
446
|
+
case !entry.info.Mode().IsRegular():
|
|
447
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticNonRegular), snapshot, nil
|
|
448
|
+
case entry.info.Size() > maxArtifactBytes:
|
|
449
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFileTooLarge), snapshot, nil
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if err := runObservationStage(callback, observationBeforeOpen, location.path); err != nil {
|
|
453
|
+
return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
|
|
454
|
+
}
|
|
455
|
+
file, err := openObservedFile(location.root, location.name)
|
|
456
|
+
if err != nil {
|
|
457
|
+
return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
|
|
458
|
+
}
|
|
459
|
+
defer file.Close()
|
|
460
|
+
|
|
461
|
+
openedInfo, err := file.Stat()
|
|
462
|
+
if err != nil {
|
|
463
|
+
return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
|
|
464
|
+
}
|
|
465
|
+
if !sameEntry(entry.info, openedInfo) || !openedInfo.Mode().IsRegular() {
|
|
466
|
+
return ArtifactFact{}, snapshot, ObservationConcurrentChange
|
|
467
|
+
}
|
|
468
|
+
if err := runObservationStage(callback, observationBeforeRead, location.path); err != nil {
|
|
469
|
+
return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
|
|
470
|
+
}
|
|
471
|
+
raw, err := io.ReadAll(io.LimitReader(file, maxArtifactBytes+1))
|
|
472
|
+
if err != nil {
|
|
473
|
+
return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
|
|
474
|
+
}
|
|
475
|
+
if err := runObservationStage(callback, observationAfterRead, location.path); err != nil {
|
|
476
|
+
return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
|
|
477
|
+
}
|
|
478
|
+
postReadInfo, err := file.Stat()
|
|
479
|
+
if err != nil || !sameEntry(openedInfo, postReadInfo) || int64(len(raw)) != postReadInfo.Size() || !artifactUnchanged(location, snapshot) {
|
|
480
|
+
return ArtifactFact{}, snapshot, ObservationConcurrentChange
|
|
481
|
+
}
|
|
482
|
+
if len(raw) > maxArtifactBytes {
|
|
483
|
+
return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFileTooLarge), snapshot, nil
|
|
484
|
+
}
|
|
485
|
+
state, code := classifyArtifact(raw)
|
|
486
|
+
return ArtifactFact{path: location.path, state: state, bytes: copyArtifactBytes(raw), code: code}, snapshot, nil
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
func artifactFailureResult(path ArtifactPath, snapshot artifactSnapshot, unchanged bool, cause error) (ArtifactFact, artifactSnapshot, error) {
|
|
490
|
+
if !unchanged {
|
|
491
|
+
return ArtifactFact{}, snapshot, ObservationConcurrentChange
|
|
492
|
+
}
|
|
493
|
+
return unreadableFact(path, unreadableDiagnostic(cause)), snapshot, nil
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
func openedFileAndPathUnchanged(file *os.File, openedInfo os.FileInfo, location artifactLocation, snapshot artifactSnapshot) bool {
|
|
497
|
+
currentInfo, err := file.Stat()
|
|
498
|
+
return err == nil && sameEntry(openedInfo, currentInfo) && artifactUnchanged(location, snapshot)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
func artifactUnchanged(location artifactLocation, snapshot artifactSnapshot) bool {
|
|
502
|
+
for _, parent := range snapshot.parents {
|
|
503
|
+
if !sameObservedEntry(parent.entry, inspectEntry(location.root, parent.name)) {
|
|
504
|
+
return false
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if snapshot.final == nil {
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
return sameObservedEntry(*snapshot.final, inspectEntry(location.root, location.name))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
func inspectEntry(root *os.Root, name string) observedEntry {
|
|
514
|
+
info, err := root.Lstat(name)
|
|
515
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
516
|
+
return observedEntry{state: entryAbsent}
|
|
517
|
+
}
|
|
518
|
+
if err != nil {
|
|
519
|
+
return observedEntry{state: entryUnreadable, code: unreadableDiagnostic(err)}
|
|
520
|
+
}
|
|
521
|
+
return observedEntry{state: entryPresent, info: info}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
func sameObservedEntry(left, right observedEntry) bool {
|
|
525
|
+
if left.state != right.state || left.code != right.code {
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
if left.state != entryPresent {
|
|
529
|
+
return true
|
|
530
|
+
}
|
|
531
|
+
return sameEntry(left.info, right.info)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
func sameEntry(left, right os.FileInfo) bool {
|
|
535
|
+
return left != nil && right != nil && left.Mode().Type() == right.Mode().Type() && left.Size() == right.Size() && os.SameFile(left, right)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
func parentPaths(name string) []string {
|
|
539
|
+
clean := path.Clean(name)
|
|
540
|
+
if clean == "." || clean == "/" {
|
|
541
|
+
return nil
|
|
542
|
+
}
|
|
543
|
+
parts := strings.Split(clean, "/")
|
|
544
|
+
parents := make([]string, 0, len(parts)-1)
|
|
545
|
+
for i := 1; i < len(parts); i++ {
|
|
546
|
+
parents = append(parents, strings.Join(parts[:i], "/"))
|
|
547
|
+
}
|
|
548
|
+
return parents
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func classifyArtifact(raw []byte) (ArtifactState, DiagnosticCode) {
|
|
552
|
+
if _, err := markdowntext.Structural(raw); err != nil {
|
|
553
|
+
return ArtifactMalformed, DiagnosticMalformedMarkdown
|
|
554
|
+
}
|
|
555
|
+
body := stripFrontmatter(raw)
|
|
556
|
+
for _, line := range strings.Split(string(body), "\n") {
|
|
557
|
+
if !blankOrHash(strings.TrimSpace(line)) {
|
|
558
|
+
return ArtifactPresent, ""
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return ArtifactEmpty, ""
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
func diagnosticFact(path ArtifactPath, state ArtifactState, code DiagnosticCode) ArtifactFact {
|
|
565
|
+
return ArtifactFact{path: path, state: state, code: code}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
func unreadableFact(path ArtifactPath, code DiagnosticCode) ArtifactFact {
|
|
569
|
+
return diagnosticFact(path, ArtifactUnreadable, code)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func unreadableDiagnostic(err error) DiagnosticCode {
|
|
573
|
+
if errors.Is(err, fs.ErrPermission) {
|
|
574
|
+
return DiagnosticPermissionDenied
|
|
575
|
+
}
|
|
576
|
+
return DiagnosticReadFailure
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
func runObservationStage(callback observationCallback, stage observationStage, path ArtifactPath) error {
|
|
580
|
+
if callback == nil {
|
|
581
|
+
return nil
|
|
582
|
+
}
|
|
583
|
+
return callback(stage, path)
|
|
584
|
+
}
|