devrites 7.3.0 → 7.3.2

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 (153) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +41 -3
  5. package/docs/release.md +5 -2
  6. package/engine/Makefile +2 -2
  7. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  8. package/engine/internal/acceptance/preconditions.go +17 -11
  9. package/engine/internal/acceptance/transcript.go +23 -4
  10. package/engine/internal/gate/gate.go +8 -6
  11. package/engine/internal/gate/readiness_binding.go +54 -24
  12. package/engine/internal/install/apply.go +36 -21
  13. package/engine/internal/install/binary.go +67 -9
  14. package/engine/internal/install/install.go +63 -21
  15. package/engine/internal/install/manifest.go +4 -5
  16. package/engine/internal/install/merge.go +89 -33
  17. package/engine/internal/install/preflight.go +60 -19
  18. package/engine/internal/install/uninstall.go +19 -9
  19. package/engine/internal/install/update.go +0 -4
  20. package/engine/internal/lib/claims.go +170 -4
  21. package/engine/internal/lib/claims_holder.go +13 -16
  22. package/engine/internal/lib/closeout.go +52 -28
  23. package/engine/internal/lib/context_io.go +88 -4
  24. package/engine/internal/lib/continuation.go +111 -24
  25. package/engine/internal/lib/dispatch.go +157 -24
  26. package/engine/internal/lib/final_prerequisites.go +137 -0
  27. package/engine/internal/lib/manifest_union.go +62 -25
  28. package/engine/internal/lib/next.go +10 -10
  29. package/engine/internal/lib/packet.go +32 -8
  30. package/engine/internal/lib/packet_integrity.go +194 -2
  31. package/engine/internal/lib/resolve.go +128 -142
  32. package/engine/internal/lib/treebaseline.go +0 -1
  33. package/engine/internal/markdowntext/markdowntext.go +110 -31
  34. package/engine/internal/overhaul/records/check.go +45 -5
  35. package/engine/internal/overhaul/records/consistency.go +34 -8
  36. package/engine/internal/overhaul/records/run.go +55 -11
  37. package/engine/internal/parallel/landing.go +17 -0
  38. package/engine/internal/state/acceptance.go +35 -5
  39. package/engine/internal/state/feature.go +3 -2
  40. package/engine/internal/state/observation.go +50 -3
  41. package/engine/internal/state/write.go +20 -44
  42. package/engine/internal/workflow/commands.go +25 -3
  43. package/engine/main.go +19 -0
  44. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  45. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  46. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  47. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  48. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  49. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  50. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  51. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  52. package/pack/.claude/skills/rite-autocomplete/SKILL.md +9 -10
  53. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +8 -1
  54. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  55. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  56. package/pack/.claude/skills/rite-prove/SKILL.md +15 -7
  57. package/pack/.claude/skills/rite-review/SKILL.md +7 -0
  58. package/pack/.claude/skills/rite-vet/SKILL.md +10 -1
  59. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  60. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  61. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  62. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  63. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  64. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  65. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  66. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  67. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +9 -10
  68. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +8 -1
  69. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/claude/skills/rite-prove/SKILL.md +15 -7
  72. package/pack/generated/claude/skills/rite-review/SKILL.md +7 -0
  73. package/pack/generated/claude/skills/rite-vet/SKILL.md +10 -1
  74. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  75. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  76. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  77. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  78. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  79. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  80. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  81. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  82. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +9 -10
  83. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +8 -1
  84. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  85. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  86. package/pack/generated/codex/skills/rite-prove/SKILL.md +15 -7
  87. package/pack/generated/codex/skills/rite-review/SKILL.md +7 -0
  88. package/pack/generated/codex/skills/rite-vet/SKILL.md +10 -1
  89. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  90. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  91. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +9 -10
  98. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +8 -1
  99. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  100. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  101. package/pack/generated/devin/skills/rite-prove/SKILL.md +15 -7
  102. package/pack/generated/devin/skills/rite-review/SKILL.md +7 -0
  103. package/pack/generated/devin/skills/rite-vet/SKILL.md +10 -1
  104. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  105. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  106. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  107. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  108. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  109. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  110. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  111. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  112. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  113. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +9 -10
  114. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +8 -1
  115. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  116. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  117. package/pack/generated/dsh/skills/rite-prove/SKILL.md +15 -7
  118. package/pack/generated/dsh/skills/rite-review/SKILL.md +7 -0
  119. package/pack/generated/dsh/skills/rite-vet/SKILL.md +10 -1
  120. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  121. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  122. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  123. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  124. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  125. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  126. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  127. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  128. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +9 -10
  129. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +8 -1
  130. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  131. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  132. package/pack/generated/omp/skills/rite-prove/SKILL.md +15 -7
  133. package/pack/generated/omp/skills/rite-review/SKILL.md +7 -0
  134. package/pack/generated/omp/skills/rite-vet/SKILL.md +10 -1
  135. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  136. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +46 -12
  137. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  138. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  139. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  140. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  141. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  142. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  143. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +9 -10
  144. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +8 -1
  145. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  146. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  147. package/pack/generated/pi/skills/rite-prove/SKILL.md +15 -7
  148. package/pack/generated/pi/skills/rite-review/SKILL.md +7 -0
  149. package/pack/generated/pi/skills/rite-vet/SKILL.md +10 -1
  150. package/package.json +1 -1
  151. package/scripts/dsh-generate.py +1 -1
  152. package/scripts/install-lib.sh +29 -0
  153. package/uninstall.sh +1 -0
@@ -1,15 +1,18 @@
1
1
  package gate
2
2
 
3
3
  import (
4
+ "bytes"
4
5
  "crypto/sha256"
5
6
  "encoding/binary"
6
7
  "encoding/hex"
7
8
  "errors"
8
9
  "fmt"
9
- "hash"
10
+ "io"
10
11
  "regexp"
12
+ "slices"
11
13
  "strings"
12
14
 
15
+ "github.com/devrites/devrites/internal/acceptance"
13
16
  "github.com/devrites/devrites/internal/markdowntext"
14
17
  "github.com/devrites/devrites/internal/state"
15
18
  )
@@ -17,7 +20,7 @@ import (
17
20
  const (
18
21
  readinessBindingLabel = "Readiness inputs SHA-256: "
19
22
  readinessBindingDomain = "devrites-readiness-inputs"
20
- readinessBindingVersion = "1"
23
+ readinessBindingVersion = "2"
21
24
  )
22
25
 
23
26
  type readinessInput struct {
@@ -37,11 +40,15 @@ var readinessInputs = [...]readinessInput{
37
40
  {logical: "design-brief.md"},
38
41
  {logical: "ai-spec.md"},
39
42
  {logical: ".devrites/principles.md"},
43
+ {logical: acceptance.GatesFile},
40
44
  }
41
45
 
42
46
  // ReadinessBinding returns the exact line Vet records in eng-review.md.
47
+ // Explicit sources resolve live first, then safely archived planning bytes.
48
+ // Gate definitions are bound independently of proof progress. Older bindings
49
+ // omitted those definitions and require normal re-Vet, never automatic refresh.
43
50
  func ReadinessBinding(root, slug string) (string, error) {
44
- observation, err := state.ObserveWorkspace(root, slug)
51
+ observation, err := state.ObserveReferencedWorkspace(root, slug)
45
52
  if err != nil {
46
53
  return "", err
47
54
  }
@@ -49,10 +56,6 @@ func ReadinessBinding(root, slug string) (string, error) {
49
56
  }
50
57
 
51
58
  func readinessBindingFromObservation(observation *state.WorkspaceObservation) (string, error) {
52
- return bindReadinessInputs(observation, true)
53
- }
54
-
55
- func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress bool) (string, error) {
56
59
  digest := sha256.New()
57
60
  writeReadinessField(digest, readinessBindingDomain)
58
61
  writeReadinessField(digest, readinessBindingVersion)
@@ -62,7 +65,7 @@ func bindReadinessInputs(observation *state.WorkspaceObservation, blankProgress
62
65
  if err != nil {
63
66
  return "", err
64
67
  }
65
- if blankProgress && input.logical == "traceability.md" {
68
+ if input.logical == "traceability.md" {
66
69
  content = withoutProgressCells(content)
67
70
  }
68
71
  writeReadinessField(digest, string(input.logical))
@@ -84,12 +87,26 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
84
87
  }
85
88
  switch fact.State() {
86
89
  case state.ArtifactAbsent:
87
- if input.required {
90
+ required := input.required
91
+ if input.logical == acceptance.GatesFile {
92
+ phase, err := observation.DeclaredPhase()
93
+ if err != nil {
94
+ return nil, false, err
95
+ }
96
+ policy, _ := state.PolicyFor(phase)
97
+ required = policyRequires(policy, input.logical)
98
+ }
99
+ if required {
88
100
  return nil, false, fmt.Errorf("readiness input %s is missing", input.logical)
89
101
  }
90
102
  return nil, false, nil
91
103
  case state.ArtifactEmpty, state.ArtifactPresent:
92
- return fact.Bytes(), true, nil
104
+ content := fact.Bytes()
105
+ if input.logical == acceptance.GatesFile {
106
+ content, err := normalizedReadinessGates(content)
107
+ return content, true, err
108
+ }
109
+ return content, true, nil
93
110
  case state.ArtifactMalformed, state.ArtifactUnsafe, state.ArtifactUnreadable:
94
111
  diagnostic, _ := fact.Diagnostic()
95
112
  return nil, false, readinessDiagnosticError(diagnostic)
@@ -98,6 +115,25 @@ func retainedReadinessInput(observation *state.WorkspaceObservation, input readi
98
115
  }
99
116
  }
100
117
 
118
+ // Vet approves obligations, not their checkbox, evidence, or progress prose.
119
+ func normalizedReadinessGates(content []byte) ([]byte, error) {
120
+ doc := acceptance.ParseLedger(string(content))
121
+ if len(doc.Errors) > 0 {
122
+ return nil, fmt.Errorf("readiness input gates.md is malformed: %s", strings.Join(doc.Errors, "; "))
123
+ }
124
+ slices.SortFunc(doc.Gates, func(a, b *acceptance.Gate) int {
125
+ return strings.Compare(a.ID, b.ID)
126
+ })
127
+ var normalized bytes.Buffer
128
+ writeReadinessLength(&normalized, uint64(len(doc.Gates)))
129
+ for _, gate := range doc.Gates {
130
+ for _, field := range [...]string{gate.ID, gate.Title, gate.Check, gate.Expect, gate.Cwd, gate.Inputs, gate.Run} {
131
+ writeReadinessField(&normalized, field)
132
+ }
133
+ }
134
+ return normalized.Bytes(), nil
135
+ }
136
+
101
137
  func readinessDiagnostics(observation *state.WorkspaceObservation) []state.ArtifactDiagnostic {
102
138
  var diagnostics []state.ArtifactDiagnostic
103
139
  for _, input := range readinessInputs {
@@ -117,12 +153,6 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
117
153
  if err != nil {
118
154
  return "", err
119
155
  }
120
- // A line recorded over the raw bytes, before progress cells were blanked,
121
- // still verifies while those bytes are unchanged.
122
- legacy, err := bindReadinessInputs(observation, false)
123
- if err != nil {
124
- return "", err
125
- }
126
156
  fact, ok := observation.Fact("eng-review.md")
127
157
  if !ok || fact.State() == state.ArtifactAbsent {
128
158
  return expected, errors.New("readiness input eng-review.md is missing")
@@ -143,7 +173,7 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
143
173
  continue
144
174
  }
145
175
  attempts++
146
- if line == expected || line == legacy {
176
+ if line == expected {
147
177
  matches++
148
178
  }
149
179
  }
@@ -154,9 +184,9 @@ func verifyReadinessBinding(observation *state.WorkspaceObservation) (string, er
154
184
  }
155
185
 
156
186
  // ReadinessInputDigest is one readiness input's content identity at a moment
157
- // in time. The aggregate binding answers "did anything change"; the per-input
158
- // digest answers "which artifact changed", which is what a drift diagnosis and
159
- // a re-vet route need.
187
+ // in time (canonical definitions only for gates.md). The aggregate binding
188
+ // answers "did anything change"; the per-input digest answers "which artifact
189
+ // changed", which is what a drift diagnosis and a re-vet route need.
160
190
  type ReadinessInputDigest struct {
161
191
  Path string `json:"path"`
162
192
  Present bool `json:"present"`
@@ -180,9 +210,9 @@ func withoutReviewedIdentity(content []byte) []byte {
180
210
  // ReadinessInputDigests returns each readiness input's content digest plus the
181
211
  // aggregate binding line Vet records in eng-review.md. A required input that
182
212
  // is missing, or any unreadable input, is an error — the same refusal the
183
- // readiness gate applies.
213
+ // readiness gate applies. Source selection matches ReadinessBinding.
184
214
  func ReadinessInputDigests(root, slug string) ([]ReadinessInputDigest, string, error) {
185
- observation, err := state.ObserveWorkspace(root, slug)
215
+ observation, err := state.ObserveReferencedWorkspace(root, slug)
186
216
  if err != nil {
187
217
  return nil, "", err
188
218
  }
@@ -321,12 +351,12 @@ func policyRequires(policy state.PhasePolicy, name state.ArtifactPath) bool {
321
351
  return false
322
352
  }
323
353
 
324
- func writeReadinessField(digest hash.Hash, value string) {
354
+ func writeReadinessField(digest io.Writer, value string) {
325
355
  writeReadinessLength(digest, uint64(len(value)))
326
356
  _, _ = digest.Write([]byte(value))
327
357
  }
328
358
 
329
- func writeReadinessLength(digest hash.Hash, value uint64) {
359
+ func writeReadinessLength(digest io.Writer, value uint64) {
330
360
  var encoded [8]byte
331
361
  binary.BigEndian.PutUint64(encoded[:], value)
332
362
  _, _ = digest.Write(encoded[:])
@@ -16,6 +16,10 @@ import (
16
16
  )
17
17
 
18
18
  func (r *runner) install() error {
19
+ if err := r.openTargetRoot(); err != nil {
20
+ return err
21
+ }
22
+ defer r.closeTargetRoot()
19
23
  fmt.Fprintln(r.opts.Stdout, "DevRites installer")
20
24
  fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
21
25
  fmt.Fprintf(r.opts.Stdout, " payload: %s\n", r.payload)
@@ -43,6 +47,10 @@ func (r *runner) install() error {
43
47
  if err := r.preflightInstall(); err != nil {
44
48
  return err
45
49
  }
50
+ // Complete binary installation before changing payload ownership or version.
51
+ if err := r.installBinary(); err != nil {
52
+ return fmt.Errorf("install engine binary: %w", err)
53
+ }
46
54
 
47
55
  for _, tree := range hostpack.InstallTrees(r.opts.WithSkills, r.opts.WithAgents, r.opts.WithCodex, r.opts.WithOmp, r.opts.WithPi, r.opts.WithDevin, r.opts.WithDsh) {
48
56
  if err := r.installTree(tree.PayloadPrefix, tree.TargetPrefix); err != nil {
@@ -106,9 +114,6 @@ func (r *runner) install() error {
106
114
  return fmt.Errorf("write manifest: %w", err)
107
115
  }
108
116
  }
109
- if err := r.installBinary(); err != nil {
110
- return fmt.Errorf("install engine binary: %w", err)
111
- }
112
117
 
113
118
  fmt.Fprintln(r.opts.Stdout)
114
119
  if r.opts.DryRun {
@@ -218,19 +223,32 @@ func (r *runner) installData(data []byte, rel string) error {
218
223
  return nil
219
224
  }
220
225
 
221
- // writeConfined writes dest, which must lie under the install target, through
222
- // a root opened on the target so a directory component replaced by a symlink
223
- // after the path recheck cannot redirect the write.
224
- func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error {
225
- rel, err := filepath.Rel(r.target, dest)
226
+ func (r *runner) readConfined(path string) ([]byte, error) {
227
+ rel, err := filepath.Rel(r.target, path)
226
228
  if err != nil {
227
- return err
229
+ return nil, err
230
+ }
231
+ if r.targetRoot != nil {
232
+ return r.targetRoot.ReadFile(rel)
228
233
  }
229
234
  root, err := os.OpenRoot(r.target)
230
235
  if err != nil {
231
- return err
236
+ return nil, err
232
237
  }
233
238
  defer func() { _ = root.Close() }()
239
+ return root.ReadFile(rel)
240
+ }
241
+
242
+ // writeConfined uses the operation's validated root, not its mutable pathname.
243
+ func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error {
244
+ rel, err := filepath.Rel(r.target, dest)
245
+ if err != nil {
246
+ return err
247
+ }
248
+ root := r.targetRoot
249
+ if root == nil {
250
+ return fmt.Errorf("install target is not open")
251
+ }
234
252
  if info, err := root.Stat(rel); err == nil {
235
253
  perm = info.Mode().Perm()
236
254
  } else if !os.IsNotExist(err) {
@@ -239,18 +257,16 @@ func (r *runner) writeConfined(dest string, data []byte, perm fs.FileMode) error
239
257
  return fsutil.WriteFileAtomicIn(root, rel, data, perm)
240
258
  }
241
259
 
242
- // removeConfined removes dest, which must lie under the install target,
243
- // through a root opened on the target.
260
+ // removeConfined uses the same retained root as writes and managed reads.
244
261
  func (r *runner) removeConfined(dest string) error {
245
262
  rel, err := filepath.Rel(r.target, dest)
246
263
  if err != nil {
247
264
  return err
248
265
  }
249
- root, err := os.OpenRoot(r.target)
250
- if err != nil {
251
- return err
266
+ root := r.targetRoot
267
+ if root == nil {
268
+ return fmt.Errorf("install target is not open")
252
269
  }
253
- defer func() { _ = root.Close() }()
254
270
  return root.Remove(rel)
255
271
  }
256
272
 
@@ -285,11 +301,10 @@ func (r *runner) seedDevrites() error {
285
301
  fmt.Fprintln(r.opts.Stdout, " [seed] .devrites/ACTIVE (runtime state - preserved on uninstall)")
286
302
  return nil
287
303
  }
288
- if err := os.MkdirAll(filepath.Dir(active), 0o755); err != nil {
304
+ if err := r.targetRoot.MkdirAll(".devrites", 0o755); err != nil {
289
305
  return fmt.Errorf("create %s: %w", filepath.Dir(active), err)
290
306
  }
291
- // #nosec G304 -- active path is the operator-selected install dir (O_EXCL create)
292
- f, err := os.OpenFile(active, os.O_CREATE|os.O_EXCL, 0o644)
307
+ f, err := r.targetRoot.OpenFile(filepath.Join(".devrites", "ACTIVE"), os.O_CREATE|os.O_EXCL, 0o644)
293
308
  if err != nil {
294
309
  if os.IsExist(err) {
295
310
  return nil
@@ -342,7 +357,7 @@ func (r *runner) relinquishEditedHostConfigs() (map[string]bool, error) {
342
357
  func (r *runner) seedHostConfig(config hostpack.HostConfig) error {
343
358
  if _, managed := r.prev[config.Rel]; !managed {
344
359
  // #nosec G304 -- fixed host config path under the operator target
345
- data, err := os.ReadFile(filepath.Join(r.target, filepath.FromSlash(config.Rel)))
360
+ data, err := r.readConfined(filepath.Join(r.target, filepath.FromSlash(config.Rel)))
346
361
  if err == nil && string(data) != config.Content {
347
362
  if !strings.Contains(string(data), config.Applied) {
348
363
  fmt.Fprintf(r.opts.Stderr, "warning: %s exists and was not changed; to hide duplicate DevRites skills in %s, add: %s\n", config.Rel, config.Host, config.Advice)
@@ -408,7 +423,7 @@ func (r *runner) pruneDropped() error {
408
423
  if err := r.removeConfined(dead); err != nil && !os.IsNotExist(err) {
409
424
  return fmt.Errorf("remove %s: %w", rel, err)
410
425
  }
411
- pruneEmptyDirs(filepath.Dir(dead), r.target)
426
+ r.pruneEmptyDirs(filepath.Dir(dead))
412
427
  fmt.Fprintf(r.opts.Stdout, " [%s] %s\n", action, rel)
413
428
  }
414
429
  r.stats.pruned++
@@ -63,11 +63,20 @@ func (r *runner) installBinary() error {
63
63
  if err != nil {
64
64
  return err
65
65
  }
66
+ removeBackup := true
66
67
  if backup != "" {
67
- defer func() { _ = os.Remove(backup) }()
68
+ defer func() {
69
+ if removeBackup {
70
+ _ = os.Remove(backup)
71
+ }
72
+ }()
68
73
  }
69
74
  if err := fsutil.WriteFileAtomic(dest, data, 0o755); err != nil {
70
75
  if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
76
+ removeBackup = false
77
+ if backup != "" {
78
+ restoreErr = fmt.Errorf("%w (previous binary retained at %s)", restoreErr, backup)
79
+ }
71
80
  return fmt.Errorf("install binary: %v; restore previous binary: %w", err, restoreErr)
72
81
  }
73
82
  return err
@@ -75,6 +84,10 @@ func (r *runner) installBinary() error {
75
84
  _ = os.Chmod(dest, 0o755)
76
85
  if _, err := verifyEngineBinary(dest, tag, 30*time.Second); err != nil {
77
86
  if restoreErr := restoreBinary(dest, backup, oldMode, hadOld); restoreErr != nil {
87
+ removeBackup = false
88
+ if backup != "" {
89
+ restoreErr = fmt.Errorf("%w (previous binary retained at %s)", restoreErr, backup)
90
+ }
78
91
  return fmt.Errorf("verify installed engine binary: %v; restore previous binary: %w", err, restoreErr)
79
92
  }
80
93
  if hadOld {
@@ -173,6 +186,44 @@ func (r *runner) acquireBinary(tag string) (string, func(), error) {
173
186
  return path, func() {}, nil
174
187
  }
175
188
 
189
+ func (r *runner) preflightBinaryRemoval() error {
190
+ if runtime.GOOS != "windows" || r.opts.DryRun || r.opts.KeepBinary || !r.opts.WithBinary || os.Getenv("DEVRITES_NO_BINARY") == "1" {
191
+ return nil
192
+ }
193
+ for _, path := range binaryCandidates() {
194
+ info, err := os.Lstat(path)
195
+ if os.IsNotExist(err) {
196
+ continue
197
+ }
198
+ if err != nil {
199
+ return fmt.Errorf("inspect engine binary %s: %w", path, err)
200
+ }
201
+ if info.Mode()&os.ModeSymlink != 0 {
202
+ info, err = os.Stat(path)
203
+ if os.IsNotExist(err) {
204
+ return nil
205
+ }
206
+ if err != nil {
207
+ return fmt.Errorf("inspect engine binary %s: %w", path, err)
208
+ }
209
+ }
210
+ executable, err := os.Executable()
211
+ if err != nil {
212
+ return fmt.Errorf("locate running engine binary: %w", err)
213
+ }
214
+ running, err := os.Stat(executable)
215
+ if err != nil {
216
+ return fmt.Errorf("inspect running engine binary %s: %w", executable, err)
217
+ }
218
+ if os.SameFile(info, running) {
219
+ return fmt.Errorf("cannot remove running engine binary %s on Windows; use npx devrites@latest uninstall --target %q or --keep-binary", path, r.target)
220
+ }
221
+ // Removal stops at the first existing candidate, even if another is running.
222
+ return nil
223
+ }
224
+ return nil
225
+ }
226
+
176
227
  func (r *runner) removeBinary() error {
177
228
  if r.opts.KeepBinary {
178
229
  fmt.Fprintln(r.opts.Stdout, " kept the global devrites-engine binary (--keep-binary).")
@@ -187,17 +238,24 @@ func (r *runner) removeBinary() error {
187
238
  return nil
188
239
  }
189
240
  for _, p := range binaryCandidates() {
190
- if p == "" || !exists(p) {
191
- continue
192
- }
193
241
  if r.opts.DryRun {
242
+ if _, err := os.Lstat(p); err != nil {
243
+ if os.IsNotExist(err) {
244
+ continue
245
+ }
246
+ return fmt.Errorf("inspect engine binary %s: %w", p, err)
247
+ }
194
248
  fmt.Fprintf(r.opts.Stdout, " [remove] %s (global engine binary)\n", p)
195
249
  continue
196
250
  }
197
- if err := os.Remove(p); err == nil {
198
- fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", p)
199
- return nil
251
+ if err := os.Remove(p); err != nil {
252
+ if os.IsNotExist(err) {
253
+ continue
254
+ }
255
+ return fmt.Errorf("remove %s: %w", p, err)
200
256
  }
257
+ fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", p)
258
+ return nil
201
259
  }
202
260
  return nil
203
261
  }
@@ -220,10 +278,10 @@ func engineBinaryName() string {
220
278
  }
221
279
 
222
280
  func binaryCandidates() []string {
223
- candidates := []string{}
224
281
  if dir := os.Getenv("DEVRITES_BIN_DIR"); dir != "" {
225
- candidates = append(candidates, filepath.Join(dir, engineBinaryName()))
282
+ return []string{filepath.Join(dir, engineBinaryName())}
226
283
  }
284
+ candidates := []string{}
227
285
  home, _ := os.UserHomeDir()
228
286
  if home != "" {
229
287
  candidates = append(candidates, filepath.Join(home, ".local", "bin", engineBinaryName()))
@@ -61,6 +61,8 @@ type stats struct {
61
61
  type runner struct {
62
62
  opts Options
63
63
  target string
64
+ targetInfo fs.FileInfo
65
+ targetRoot *os.Root
64
66
  payload string
65
67
  payloadFS fs.FS
66
68
  source string
@@ -246,6 +248,10 @@ func Apply(opts Options) error {
246
248
  return fmt.Errorf("prepare installer: %w", err)
247
249
  }
248
250
  if opts.Mode == ModeUninstall {
251
+ if err := r.openTargetRoot(); err != nil {
252
+ return err
253
+ }
254
+ defer r.closeTargetRoot()
249
255
  return r.uninstall()
250
256
  }
251
257
  if err := r.install(); err != nil {
@@ -297,27 +303,75 @@ func newRunner(opts Options) (*runner, error) {
297
303
  }
298
304
  }
299
305
  r := &runner{
300
- opts: opts,
301
- target: target,
302
- payload: payload,
303
- payloadFS: os.DirFS(payload),
304
- source: source,
305
- prev: map[string]managedRecord{},
306
- records: map[string]string{},
307
- preflight: map[string]pathSnapshot{},
306
+ opts: opts,
307
+ target: target,
308
+ targetInfo: info,
309
+ payload: payload,
310
+ payloadFS: os.DirFS(payload),
311
+ source: source,
312
+ prev: map[string]managedRecord{},
313
+ records: map[string]string{},
314
+ preflight: map[string]pathSnapshot{},
308
315
  }
309
316
  if opts.Mode != ModeUninstall {
310
317
  if err := r.validatePayload(); err != nil {
311
318
  return nil, fmt.Errorf("validate payload: %w", err)
312
319
  }
313
320
  }
314
- r.prev, err = readManifest(filepath.Join(target, ManifestName))
321
+ if err := r.openTargetRoot(); err != nil {
322
+ return nil, err
323
+ }
324
+ defer r.closeTargetRoot()
325
+ data, err := r.readConfined(filepath.Join(target, ManifestName))
326
+ if err != nil && !os.IsNotExist(err) {
327
+ return nil, fmt.Errorf("read manifest %s: %w", ManifestName, err)
328
+ }
329
+ r.prev, _, err = parseManifestData(data)
315
330
  if err != nil {
316
331
  return nil, err
317
332
  }
318
333
  return r, nil
319
334
  }
320
335
 
336
+ func (r *runner) openTargetRoot() error {
337
+ root, err := os.OpenRoot(r.target)
338
+ if err != nil {
339
+ return err
340
+ }
341
+ info, err := root.Stat(".")
342
+ if err != nil || r.targetInfo == nil || !os.SameFile(r.targetInfo, info) {
343
+ _ = root.Close()
344
+ return fmt.Errorf("install target changed before operation: %s", r.target)
345
+ }
346
+ r.targetRoot = root
347
+ return nil
348
+ }
349
+
350
+ func (r *runner) closeTargetRoot() {
351
+ if r.targetRoot != nil {
352
+ _ = r.targetRoot.Close()
353
+ r.targetRoot = nil
354
+ }
355
+ }
356
+
357
+ func (r *runner) checkTargetIdentity() error {
358
+ info, err := os.Stat(r.target)
359
+ if err != nil || r.targetInfo == nil || !os.SameFile(r.targetInfo, info) {
360
+ return fmt.Errorf("install target changed during operation: %s", r.target)
361
+ }
362
+ return nil
363
+ }
364
+
365
+ func (r *runner) pruneEmptyDirs(start string) {
366
+ for start != r.target && start != filepath.Dir(start) {
367
+ rel, err := filepath.Rel(r.target, start)
368
+ if err != nil || r.targetRoot == nil || r.targetRoot.Remove(rel) != nil {
369
+ return
370
+ }
371
+ start = filepath.Dir(start)
372
+ }
373
+ }
374
+
321
375
  func inferSourceDir() string {
322
376
  if v := os.Getenv("DEVRITES_SOURCE_DIR"); v != "" {
323
377
  return v
@@ -390,18 +444,6 @@ func isGlobalTarget(target string) bool {
390
444
  return false
391
445
  }
392
446
 
393
- func pruneEmptyDirs(start, stop string) {
394
- for {
395
- if start == stop || start == filepath.Dir(start) || start == string(os.PathSeparator) {
396
- return
397
- }
398
- if err := os.Remove(start); err != nil {
399
- return
400
- }
401
- start = filepath.Dir(start)
402
- }
403
- }
404
-
405
447
  func exists(path string) bool {
406
448
  // #nosec G703 -- existence probe on managed install paths
407
449
  _, err := os.Lstat(path)
@@ -19,11 +19,6 @@ func readManifest(path string) (map[string]managedRecord, error) {
19
19
  return records, err
20
20
  }
21
21
 
22
- func readManifestList(path string) ([]string, error) {
23
- _, entries, err := parseManifest(path)
24
- return entries, err
25
- }
26
-
27
22
  func parseManifest(path string) (map[string]managedRecord, []string, error) {
28
23
  // #nosec G304 -- install manifest under the operator-selected target
29
24
  data, err := os.ReadFile(path)
@@ -33,6 +28,10 @@ func parseManifest(path string) (map[string]managedRecord, []string, error) {
33
28
  }
34
29
  return nil, nil, fmt.Errorf("read manifest %s: %w", path, err)
35
30
  }
31
+ return parseManifestData(data)
32
+ }
33
+
34
+ func parseManifestData(data []byte) (map[string]managedRecord, []string, error) {
36
35
  hashes := map[string]string{}
37
36
  var out []string
38
37
  for _, line := range strings.Split(string(data), "\n") {