devrites 7.3.1 → 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 (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/engine/commands.md +35 -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 +50 -21
  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 +1 -1
  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/overhaul/SKILL.md +8 -4
  46. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  47. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  48. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  49. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  50. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  51. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  52. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  53. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  54. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  55. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  56. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  57. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  58. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  59. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  60. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  61. package/pack/generated/claude/skills/rite-clarify/SKILL.md +25 -25
  62. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  69. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  70. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  71. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  72. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  73. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  74. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  75. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  76. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  77. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  78. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  79. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  80. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  81. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  82. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  83. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  84. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  85. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  86. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  87. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  88. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  89. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  90. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  91. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  92. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  93. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  94. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  95. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  96. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  97. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +17 -17
  98. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  99. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  100. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  101. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  102. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  103. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  104. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  105. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  106. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  107. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  108. package/package.json +1 -1
  109. package/scripts/dsh-generate.py +1 -1
  110. package/scripts/install-lib.sh +29 -0
  111. package/uninstall.sh +1 -0
@@ -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") {
@@ -50,7 +50,7 @@ const (
50
50
  )
51
51
 
52
52
  func (r *runner) claudeSettingsMarkerLines() []string {
53
- data, err := os.ReadFile(filepath.Join(r.target, filepath.FromSlash(hostpack.ClaudeSettingsMerge.MarkerRel)))
53
+ data, err := r.readConfined(filepath.Join(r.target, filepath.FromSlash(hostpack.ClaudeSettingsMerge.MarkerRel)))
54
54
  if err != nil {
55
55
  return nil
56
56
  }
@@ -134,18 +134,24 @@ func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
134
134
  }
135
135
  }
136
136
  fmt.Fprintf(r.opts.Stdout, " [merge] %s (%s)\n", merge.TargetRel, verb)
137
- } else {
138
- dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
139
- if err := r.writeConfined(dest, next, 0o644); err != nil {
140
- return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
141
- }
142
- // Multiple marker blocks can share one target (AGENTS.md carries both
143
- // the Codex and pi blocks).
144
- if err := r.refreshPreflight(merge.TargetRel); err != nil {
145
- return err
146
- }
137
+ return r.installMarker(merge.MarkerRel, merge.MarkerText)
138
+ }
139
+ if err := r.installMarker(merge.MarkerRel, merge.MarkerText); err != nil {
140
+ return err
141
+ }
142
+ if r.records[merge.MarkerRel] == "" {
143
+ return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", merge.TargetRel, merge.MarkerRel)
144
+ }
145
+ if err := r.recheckPath(merge.TargetRel); err != nil {
146
+ return err
147
+ }
148
+ dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
149
+ if err := r.writeConfined(dest, next, 0o644); err != nil {
150
+ return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
147
151
  }
148
- return r.installMarker(merge.MarkerRel, merge.MarkerText)
152
+ // Multiple marker blocks can share one target (AGENTS.md carries both
153
+ // the Codex and pi blocks).
154
+ return r.refreshPreflight(merge.TargetRel)
149
155
  }
150
156
 
151
157
  // markerMergeNext returns the merged target bytes and the current bytes
@@ -156,8 +162,7 @@ func (r *runner) markerMergeNext(merge hostpack.MarkerMerge) ([]byte, []byte, er
156
162
  return nil, nil, fmt.Errorf("read payload %s: %w", merge.PayloadRel, err)
157
163
  }
158
164
  dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
159
- // #nosec G304 -- dest joins the operator target with a fixed manifest-relative record
160
- current, readErr := os.ReadFile(dest)
165
+ current, readErr := r.readConfined(dest)
161
166
  if errors.Is(readErr, fs.ErrNotExist) {
162
167
  return block, nil, nil
163
168
  }
@@ -208,11 +213,20 @@ func (r *runner) mergeCodexConfig() error {
208
213
  if err != nil {
209
214
  return err
210
215
  }
216
+ if err := r.installMarker(merge.MarkerRel, merge.MarkerText); err != nil {
217
+ return err
218
+ }
219
+ if r.records[merge.MarkerRel] == "" {
220
+ return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", merge.TargetRel, merge.MarkerRel)
221
+ }
222
+ if err := r.recheckPath(merge.TargetRel); err != nil {
223
+ return err
224
+ }
211
225
  dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
212
226
  if err := r.writeConfined(dest, next, 0o644); err != nil {
213
227
  return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
214
228
  }
215
- return r.installMarker(merge.MarkerRel, merge.MarkerText)
229
+ return nil
216
230
  }
217
231
 
218
232
  // codexConfigNext returns the merged .codex/config.toml without writing it.
@@ -227,8 +241,7 @@ func (r *runner) codexConfigNext() ([]byte, error) {
227
241
  }
228
242
  dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
229
243
  var current []byte
230
- // #nosec G304 -- dest joins the operator target with a fixed manifest-relative record
231
- if data, readErr := os.ReadFile(dest); readErr == nil {
244
+ if data, readErr := r.readConfined(dest); readErr == nil {
232
245
  // Drop a UTF-8 BOM: the block may go at offset 0, and a BOM after
233
246
  // it is invalid TOML.
234
247
  data = bytes.TrimPrefix(data, utf8BOM)
@@ -423,7 +436,7 @@ func (r *runner) claudeSettingsNext() (map[string]any, claudeSettingsMerge, erro
423
436
  }
424
437
  current := map[string]any{}
425
438
  state := claudeSettingsMerge{created: r.claudeSettingsCreated()}
426
- if data, readErr := readJSON(dest); readErr == nil {
439
+ if data, readErr := r.readJSON(dest); readErr == nil {
427
440
  current = data
428
441
  } else if errors.Is(readErr, fs.ErrNotExist) {
429
442
  state.created = true
@@ -443,13 +456,13 @@ func (r *runner) seedClaudeSettings() error {
443
456
  }
444
457
 
445
458
  func (r *runner) stripHooksConfined(path string) error {
446
- return stripHooksWith(path, func(data []byte) error {
459
+ return r.stripHooksWith(path, func(data []byte) error {
447
460
  return r.writeConfined(path, data, 0o644)
448
461
  }, func() error { return r.removeConfined(path) })
449
462
  }
450
463
 
451
- func stripHooksWith(path string, write func([]byte) error, remove func() error) error {
452
- current, err := readJSON(path)
464
+ func (r *runner) stripHooksWith(path string, write func([]byte) error, remove func() error) error {
465
+ current, err := r.readJSON(path)
453
466
  if err != nil {
454
467
  if errors.Is(err, fs.ErrNotExist) {
455
468
  return nil
@@ -475,7 +488,7 @@ func (r *runner) stripClaudeSettings(path string) error {
475
488
  }
476
489
 
477
490
  func (r *runner) stripClaudeSettingsWith(path string, write func([]byte) error, remove func() error) error {
478
- current, err := readJSON(path)
491
+ current, err := r.readJSON(path)
479
492
  if err != nil {
480
493
  if errors.Is(err, fs.ErrNotExist) {
481
494
  return nil
@@ -495,14 +508,13 @@ func (r *runner) stripClaudeSettingsWith(path string, write func([]byte) error,
495
508
  }
496
509
 
497
510
  func (r *runner) stripMarkerConfined(path, begin, end string) error {
498
- return stripMarkerWith(path, begin, end, func(data []byte) error {
511
+ return r.stripMarkerWith(path, begin, end, func(data []byte) error {
499
512
  return r.writeConfined(path, data, 0o644)
500
513
  }, func() error { return r.removeConfined(path) })
501
514
  }
502
515
 
503
- func stripMarkerWith(path, begin, end string, write func([]byte) error, remove func() error) error {
504
- // #nosec G304 -- managed file path from the install manifest
505
- data, err := os.ReadFile(path)
516
+ func (r *runner) stripMarkerWith(path, begin, end string, write func([]byte) error, remove func() error) error {
517
+ data, err := r.readConfined(path)
506
518
  if err != nil {
507
519
  if os.IsNotExist(err) {
508
520
  return nil
@@ -536,9 +548,8 @@ func hasTopLevelTOMLKey(data []byte, key string) bool {
536
548
  return found
537
549
  }
538
550
 
539
- func readJSON(path string) (map[string]any, error) {
540
- // #nosec G304 -- engine-written workspace or metadata JSON
541
- data, err := os.ReadFile(path)
551
+ func (r *runner) readJSON(path string) (map[string]any, error) {
552
+ data, err := r.readConfined(path)
542
553
  if err != nil {
543
554
  return nil, fmt.Errorf("read %s: %w", path, err)
544
555
  }
@@ -567,10 +578,55 @@ func isDevritesHookCommand(v any) bool {
567
578
  return false
568
579
  }
569
580
  command, _ := hook["command"].(string)
570
- return strings.Contains(command, "devrites-engine hook ") ||
571
- strings.Contains(command, ".claude/hooks/devrites-") ||
572
- strings.Contains(command, ".codex/hooks/devrites-") ||
573
- (strings.Contains(command, "printf ") && strings.Contains(command, "DevRites:"))
581
+ // Match shipped command templates, not quoted or logged mentions in user commands.
582
+ switch command {
583
+ case `printf '%s\n' 'DevRites: this subagent is the single write-capable slice-wright. Stay within the current slice contract and touched-files.md; write no .devrites bookkeeping.'`,
584
+ `printf '%s\n' 'DevRites: this subagent is read-only. Inspect with read/search/test commands and return findings only; do not mutate the tree.'`,
585
+ `printf '%s\n' 'DevRites: reconcile this subagent result against the active DevRites skill contract before claiming completion.'`,
586
+ `root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; cd "$root" && DEVRITES_REVIEWER_AGENT_REQUIRED=1 bash "$root/.codex/hooks/devrites-reviewer-readonly.sh"`,
587
+ `root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; cd "$root" && DEVRITES_WRIGHT_AGENT_REQUIRED=1 bash "$root/.codex/hooks/devrites-wright-scope.sh"`:
588
+ return true
589
+ }
590
+ for _, prefix := range [...]string{
591
+ `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/devrites-`,
592
+ `bash "${CLAUDE_PLUGIN_ROOT}/pack/.claude/hooks/devrites-`,
593
+ `root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; cd "$root" && bash "$root/.codex/hooks/devrites-`,
594
+ } {
595
+ if script, ok := strings.CutPrefix(command, prefix); ok {
596
+ switch script {
597
+ case `a1-guard.sh"`, `allow.sh"`, `cursor.sh"`, `orient.sh"`,
598
+ `redwatch.sh"`, `refresh-indexes.sh"`, `stop-gate.sh"`:
599
+ return true
600
+ }
601
+ return false
602
+ }
603
+ }
604
+ if rest, ok := strings.CutPrefix(command, "command -v devrites-engine >/dev/null 2>&1 && exec "); ok {
605
+ var complete bool
606
+ command, complete = strings.CutSuffix(rest, " || exit 0")
607
+ if !complete {
608
+ return false
609
+ }
610
+ }
611
+ command = strings.TrimPrefix(command, `command -v devrites-engine >/dev/null 2>&1 || exit 0; cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" 2>/dev/null || exit 0; exec `)
612
+ switch command {
613
+ case "env DEVRITES_REVIEWER_AGENT_REQUIRED=1 devrites-engine hook reviewer-readonly --harness=codex",
614
+ "env DEVRITES_WRIGHT_AGENT_REQUIRED=1 devrites-engine hook wright-scope --harness=codex":
615
+ return true
616
+ }
617
+ args, ok := strings.CutPrefix(command, "devrites-engine hook ")
618
+ if !ok {
619
+ return false
620
+ }
621
+ args = strings.TrimSuffix(strings.TrimSuffix(args, " --harness=claude"), " --harness=codex")
622
+ switch args {
623
+ case "a1-guard", "allow", "auq", "cursor", "event session-end", "event subagent-stop",
624
+ "git-guard", "handoff-snapshot", "old-gate", "orient", "redwatch", "refresh-indexes",
625
+ "reviewer-readonly", "source-cache-post", "source-cache-pre", "statusline",
626
+ "stop-gate", "subagent-orient", "wright-scope":
627
+ return true
628
+ }
629
+ return false
574
630
  }
575
631
 
576
632
  func isDevritesHooksComment(comment string) bool {