devrites 7.3.1 → 7.3.3

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 (131) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +19 -3
  3. package/bin/devrites.mjs +31 -5
  4. package/docs/cli.md +15 -7
  5. package/docs/engine/commands.md +37 -5
  6. package/docs/engine/workspace-schema.md +4 -3
  7. package/docs/release.md +5 -2
  8. package/engine/Makefile +2 -2
  9. package/engine/internal/acceptance/newfinal_origins.go +39 -0
  10. package/engine/internal/acceptance/preconditions.go +17 -11
  11. package/engine/internal/acceptance/transcript.go +23 -4
  12. package/engine/internal/gate/gate.go +8 -6
  13. package/engine/internal/gate/readiness_binding.go +50 -21
  14. package/engine/internal/hostpack/hostpack.go +1 -1
  15. package/engine/internal/install/apply.go +36 -21
  16. package/engine/internal/install/binary.go +67 -9
  17. package/engine/internal/install/install.go +63 -21
  18. package/engine/internal/install/manifest.go +4 -5
  19. package/engine/internal/install/merge.go +89 -33
  20. package/engine/internal/install/preflight.go +60 -19
  21. package/engine/internal/install/uninstall.go +19 -9
  22. package/engine/internal/install/update.go +0 -4
  23. package/engine/internal/lib/apply_bundle.go +25 -8
  24. package/engine/internal/lib/claims.go +170 -4
  25. package/engine/internal/lib/claims_holder.go +13 -16
  26. package/engine/internal/lib/closeout.go +52 -28
  27. package/engine/internal/lib/context_io.go +88 -4
  28. package/engine/internal/lib/continuation.go +111 -24
  29. package/engine/internal/lib/dispatch.go +157 -24
  30. package/engine/internal/lib/final_prerequisites.go +137 -0
  31. package/engine/internal/lib/manifest_union.go +62 -25
  32. package/engine/internal/lib/next.go +10 -10
  33. package/engine/internal/lib/packet.go +32 -8
  34. package/engine/internal/lib/packet_integrity.go +194 -2
  35. package/engine/internal/lib/regression.go +0 -5
  36. package/engine/internal/lib/resolve.go +148 -145
  37. package/engine/internal/lib/treebaseline.go +0 -1
  38. package/engine/internal/markdowntext/markdowntext.go +110 -31
  39. package/engine/internal/overhaul/records/check.go +45 -5
  40. package/engine/internal/overhaul/records/consistency.go +34 -8
  41. package/engine/internal/overhaul/records/run.go +55 -11
  42. package/engine/internal/parallel/landing.go +17 -0
  43. package/engine/internal/rootfacts/facts.go +11 -0
  44. package/engine/internal/state/acceptance.go +35 -5
  45. package/engine/internal/state/feature.go +3 -2
  46. package/engine/internal/state/lock_other.go +4 -0
  47. package/engine/internal/state/observation.go +1 -1
  48. package/engine/internal/state/write.go +56 -39
  49. package/engine/internal/workflow/commands.go +25 -3
  50. package/engine/main.go +21 -2
  51. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  52. package/pack/.claude/skills/overhaul/SKILL.md +8 -4
  53. package/pack/.claude/skills/overhaul/references/lifecycle.md +13 -6
  54. package/pack/.claude/skills/overhaul/references/orchestration.md +32 -21
  55. package/pack/.claude/skills/overhaul/references/records.md +23 -8
  56. package/pack/.claude/skills/overhaul/references/scope-and-safety.md +7 -2
  57. package/pack/.claude/skills/overhaul/references/scoring.md +7 -0
  58. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +17 -17
  59. package/pack/.claude/skills/rite-clarify/SKILL.md +25 -25
  60. package/pack/.claude/skills/rite-doctor/SKILL.md +70 -68
  61. package/pack/generated/README.md +1 -1
  62. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  63. package/pack/generated/claude/skills/overhaul/SKILL.md +8 -4
  64. package/pack/generated/claude/skills/overhaul/references/lifecycle.md +13 -6
  65. package/pack/generated/claude/skills/overhaul/references/orchestration.md +32 -21
  66. package/pack/generated/claude/skills/overhaul/references/records.md +23 -8
  67. package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +7 -2
  68. package/pack/generated/claude/skills/overhaul/references/scoring.md +7 -0
  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-doctor/SKILL.md +70 -68
  72. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  73. package/pack/generated/codex/skills/overhaul/SKILL.md +8 -4
  74. package/pack/generated/codex/skills/overhaul/references/lifecycle.md +13 -6
  75. package/pack/generated/codex/skills/overhaul/references/orchestration.md +32 -21
  76. package/pack/generated/codex/skills/overhaul/references/records.md +23 -8
  77. package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +7 -2
  78. package/pack/generated/codex/skills/overhaul/references/scoring.md +7 -0
  79. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +17 -17
  80. package/pack/generated/codex/skills/rite-clarify/SKILL.md +25 -25
  81. package/pack/generated/codex/skills/rite-doctor/SKILL.md +70 -68
  82. package/pack/generated/devin/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  83. package/pack/generated/devin/skills/overhaul/SKILL.md +8 -4
  84. package/pack/generated/devin/skills/overhaul/references/lifecycle.md +13 -6
  85. package/pack/generated/devin/skills/overhaul/references/orchestration.md +32 -21
  86. package/pack/generated/devin/skills/overhaul/references/records.md +23 -8
  87. package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +7 -2
  88. package/pack/generated/devin/skills/overhaul/references/scoring.md +7 -0
  89. package/pack/generated/devin/skills/rite-build/reference/wright-dispatch.md +17 -17
  90. package/pack/generated/devin/skills/rite-clarify/SKILL.md +25 -25
  91. package/pack/generated/devin/skills/rite-doctor/SKILL.md +70 -68
  92. package/pack/generated/dsh/runtime/cordis.patch.yml +28 -14
  93. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  94. package/pack/generated/dsh/skills/overhaul/SKILL.md +8 -4
  95. package/pack/generated/dsh/skills/overhaul/references/lifecycle.md +13 -6
  96. package/pack/generated/dsh/skills/overhaul/references/orchestration.md +32 -21
  97. package/pack/generated/dsh/skills/overhaul/references/records.md +23 -8
  98. package/pack/generated/dsh/skills/overhaul/references/scope-and-safety.md +7 -2
  99. package/pack/generated/dsh/skills/overhaul/references/scoring.md +6 -0
  100. package/pack/generated/dsh/skills/rite-build/reference/wright-dispatch.md +17 -17
  101. package/pack/generated/dsh/skills/rite-clarify/SKILL.md +25 -25
  102. package/pack/generated/dsh/skills/rite-doctor/SKILL.md +70 -68
  103. package/pack/generated/omp/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  104. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
  105. package/pack/generated/omp/skills/overhaul/SKILL.md +8 -4
  106. package/pack/generated/omp/skills/overhaul/references/lifecycle.md +13 -6
  107. package/pack/generated/omp/skills/overhaul/references/orchestration.md +32 -21
  108. package/pack/generated/omp/skills/overhaul/references/records.md +23 -8
  109. package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +7 -2
  110. package/pack/generated/omp/skills/overhaul/references/scoring.md +7 -0
  111. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +18 -18
  112. package/pack/generated/omp/skills/rite-clarify/SKILL.md +25 -25
  113. package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
  114. package/pack/generated/pi/AGENTS.md +1 -1
  115. package/pack/generated/pi/skills/devrites-lib/reference/parallel-dispatch.md +41 -39
  116. package/pack/generated/pi/skills/overhaul/SKILL.md +8 -4
  117. package/pack/generated/pi/skills/overhaul/references/lifecycle.md +13 -6
  118. package/pack/generated/pi/skills/overhaul/references/orchestration.md +32 -21
  119. package/pack/generated/pi/skills/overhaul/references/records.md +23 -8
  120. package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +7 -2
  121. package/pack/generated/pi/skills/overhaul/references/scoring.md +7 -0
  122. package/pack/generated/pi/skills/rite-build/reference/wright-dispatch.md +17 -17
  123. package/pack/generated/pi/skills/rite-clarify/SKILL.md +25 -25
  124. package/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
  125. package/package.json +1 -1
  126. package/scripts/build-host-artifacts.sh +1 -1
  127. package/scripts/dsh-generate.py +6 -2
  128. package/scripts/install-lib.sh +29 -0
  129. package/scripts/omp-generate.sh +4 -4
  130. package/scripts/pi-generate.sh +3 -3
  131. package/uninstall.sh +1 -0
@@ -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 {
@@ -14,7 +14,6 @@ import (
14
14
  "strings"
15
15
 
16
16
  "github.com/devrites/devrites/internal/hostpack"
17
- "github.com/devrites/devrites/internal/safepath"
18
17
  )
19
18
 
20
19
  func (r *runner) preflightInstall() error {
@@ -46,10 +45,15 @@ func (r *runner) preflightInstall() error {
46
45
  return err
47
46
  }
48
47
  if !snapshot.missing && r.isPinnedAlias(rel) {
49
- // scripts/pin.sh aliases are not in the payload; carry them
50
- // forward (hashed) so prune keeps them and uninstall removes them.
48
+ // Carry pins without adopting edits; only legacy pins need a baseline.
49
+ if record.Hash == "" {
50
+ record.Hash = snapshot.hash
51
+ }
52
+ if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
53
+ conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
54
+ }
51
55
  r.addManifest(rel)
52
- r.addInstallRecord(rel, snapshot.hash)
56
+ r.addInstallRecord(rel, record.Hash)
53
57
  continue
54
58
  }
55
59
  if !snapshot.missing {
@@ -101,23 +105,33 @@ func (r *runner) preflightMerges() error {
101
105
  if _, _, err := r.markerMergeNext(m.merge); err != nil {
102
106
  return fmt.Errorf("merge %s: %w", m.merge.TargetRel, err)
103
107
  }
108
+ if err := r.preflightMarkerOwnership(m.merge.TargetRel, m.merge.MarkerRel, m.merge.MarkerText); err != nil {
109
+ return err
110
+ }
104
111
  }
105
112
  if r.opts.WithCodex {
106
113
  if _, err := r.codexConfigNext(); err != nil {
107
114
  return fmt.Errorf("merge %s: %w", hostpack.CodexConfigMerge.TargetRel, err)
108
115
  }
116
+ merge := hostpack.CodexConfigMerge
117
+ if err := r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, merge.MarkerText); err != nil {
118
+ return err
119
+ }
109
120
  }
110
121
  _, settings, err := r.claudeSettingsNext()
111
122
  if err != nil {
112
123
  return fmt.Errorf("seed claude settings: %w", err)
113
124
  }
114
- marker := hostpack.ClaudeSettingsMerge.MarkerRel
125
+ merge := hostpack.ClaudeSettingsMerge
126
+ return r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, claudeSettingsMarkerText(r.pendingClaudeSettingsOwnership(settings)))
127
+ }
128
+
129
+ func (r *runner) preflightMarkerOwnership(target, marker, text string) error {
115
130
  if snapshot := r.preflight[marker]; !snapshot.missing && !r.opts.Force {
116
131
  if _, managed := r.prev[marker]; !managed {
117
- text := claudeSettingsMarkerText(r.pendingClaudeSettingsOwnership(settings)) + "\n"
118
- sum := sha256.Sum256([]byte(text))
132
+ sum := sha256.Sum256([]byte(text + "\n"))
119
133
  if snapshot.hash != fmt.Sprintf("sha256:%x", sum[:]) {
120
- return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", hostpack.ClaudeSettingsMerge.TargetRel, marker)
134
+ return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", target, marker)
121
135
  }
122
136
  }
123
137
  }
@@ -249,10 +263,17 @@ func (r *runner) installMergeTargets() []string {
249
263
 
250
264
  func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
251
265
  rel = filepath.ToSlash(rel)
266
+ if r.targetInfo == nil {
267
+ info, err := os.Stat(r.target)
268
+ if err != nil {
269
+ return pathSnapshot{}, err
270
+ }
271
+ r.targetInfo = info
272
+ }
252
273
  if snapshot, ok := r.preflight[rel]; ok {
253
274
  return snapshot, nil
254
275
  }
255
- snapshot, err := inspectManagedPath(r.target, rel)
276
+ snapshot, err := r.inspectManagedPath(rel)
256
277
  if err != nil {
257
278
  return pathSnapshot{}, err
258
279
  }
@@ -264,7 +285,7 @@ func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
264
285
  // verified write (marker merge, strip). Needed when several managed merges
265
286
  // share one target such as AGENTS.md.
266
287
  func (r *runner) refreshPreflight(rel string) error {
267
- snap, err := inspectManagedPath(r.target, rel)
288
+ snap, err := r.inspectManagedPath(rel)
268
289
  if err != nil {
269
290
  return err
270
291
  }
@@ -273,12 +294,15 @@ func (r *runner) refreshPreflight(rel string) error {
273
294
  }
274
295
 
275
296
  func (r *runner) recheckPath(rel string) error {
297
+ if err := r.checkTargetIdentity(); err != nil {
298
+ return err
299
+ }
276
300
  rel = filepath.ToSlash(rel)
277
301
  before, ok := r.preflight[rel]
278
302
  if !ok {
279
303
  return fmt.Errorf("path %s was not preflighted", rel)
280
304
  }
281
- now, err := inspectManagedPath(r.target, rel)
305
+ now, err := r.inspectManagedPath(rel)
282
306
  if err != nil {
283
307
  return err
284
308
  }
@@ -287,6 +311,9 @@ func (r *runner) recheckPath(rel string) error {
287
311
  }
288
312
  if afterRecheckPath != nil {
289
313
  afterRecheckPath(rel)
314
+ if err := r.checkTargetIdentity(); err != nil {
315
+ return err
316
+ }
290
317
  }
291
318
  return nil
292
319
  }
@@ -295,20 +322,35 @@ func (r *runner) recheckPath(rel string) error {
295
322
  // recheck and the write that follows it.
296
323
  var afterRecheckPath func(rel string)
297
324
 
325
+ func (r *runner) inspectManagedPath(rel string) (pathSnapshot, error) {
326
+ if r.targetRoot != nil {
327
+ return inspectManagedPathIn(r.targetRoot, rel)
328
+ }
329
+ return inspectManagedPath(r.target, rel)
330
+ }
331
+
298
332
  func inspectManagedPath(target, rel string) (pathSnapshot, error) {
333
+ root, err := os.OpenRoot(target)
334
+ if err != nil {
335
+ return pathSnapshot{}, err
336
+ }
337
+ defer func() { _ = root.Close() }()
338
+ return inspectManagedPathIn(root, rel)
339
+ }
340
+
341
+ func inspectManagedPathIn(root *os.Root, rel string) (pathSnapshot, error) {
299
342
  native := filepath.FromSlash(rel)
300
343
  if !filepath.IsLocal(native) {
301
344
  return pathSnapshot{}, fmt.Errorf("refusing unsafe managed path %q", rel)
302
345
  }
303
- dest := filepath.Join(target, native)
304
- if !safepath.WithinResolved(dest, target) {
305
- return pathSnapshot{}, fmt.Errorf("refusing managed path outside target: %s", rel)
346
+ if root == nil {
347
+ return pathSnapshot{}, fmt.Errorf("install target is not open")
306
348
  }
307
- walk := target
349
+ walk := ""
308
350
  parts := strings.Split(native, string(filepath.Separator))
309
351
  for _, part := range parts {
310
352
  walk = filepath.Join(walk, part)
311
- info, err := os.Lstat(walk)
353
+ info, err := root.Lstat(walk)
312
354
  if os.IsNotExist(err) {
313
355
  return pathSnapshot{missing: true}, nil
314
356
  }
@@ -319,7 +361,7 @@ func inspectManagedPath(target, rel string) (pathSnapshot, error) {
319
361
  return pathSnapshot{}, fmt.Errorf("refusing symlink or junction in managed path: %s", rel)
320
362
  }
321
363
  }
322
- info, err := os.Lstat(dest)
364
+ info, err := root.Lstat(native)
323
365
  if os.IsNotExist(err) {
324
366
  return pathSnapshot{missing: true}, nil
325
367
  }
@@ -329,8 +371,7 @@ func inspectManagedPath(target, rel string) (pathSnapshot, error) {
329
371
  if !info.Mode().IsRegular() {
330
372
  return pathSnapshot{}, fmt.Errorf("refusing non-regular managed path: %s", rel)
331
373
  }
332
- // #nosec G304 -- managed path snapshot; regular-file check above
333
- f, err := os.Open(dest)
374
+ f, err := root.Open(native)
334
375
  if err != nil {
335
376
  return pathSnapshot{}, fmt.Errorf("read managed path %s: %w", rel, err)
336
377
  }
@@ -12,13 +12,20 @@ import (
12
12
 
13
13
  func (r *runner) uninstall() error {
14
14
  mf := filepath.Join(r.target, ManifestName)
15
- entries, err := readManifestList(mf)
15
+ data, err := r.readConfined(mf)
16
+ if err != nil && !os.IsNotExist(err) {
17
+ return fmt.Errorf("read manifest %s: %w", mf, err)
18
+ }
19
+ _, entries, err := parseManifestData(data)
16
20
  if err != nil {
17
21
  return err
18
22
  }
19
23
  if len(entries) == 0 {
20
24
  return fmt.Errorf("no DevRites manifest at %s - nothing to uninstall", mf)
21
25
  }
26
+ if err := r.preflightBinaryRemoval(); err != nil {
27
+ return err
28
+ }
22
29
  fmt.Fprintln(r.opts.Stdout, "DevRites uninstaller")
23
30
  fmt.Fprintf(r.opts.Stdout, " target : %s\n", r.target)
24
31
  fmt.Fprintf(r.opts.Stdout, " manifest: %s\n", mf)
@@ -99,6 +106,15 @@ func (r *runner) uninstall() error {
99
106
  r.stats.missing++
100
107
  }
101
108
  }
109
+ if !r.opts.DryRun {
110
+ for _, d := range dirs {
111
+ r.pruneEmptyDirs(d)
112
+ }
113
+ }
114
+ // Keep ownership available for a retry if the external binary cannot be removed.
115
+ if err := r.removeBinary(); err != nil {
116
+ return fmt.Errorf("remove engine binary: %w", err)
117
+ }
102
118
  if r.opts.DryRun {
103
119
  fmt.Fprintf(r.opts.Stdout, " [remove] %s\n", ManifestName)
104
120
  } else {
@@ -106,13 +122,7 @@ func (r *runner) uninstall() error {
106
122
  return err
107
123
  }
108
124
  _ = r.removeConfined(mf)
109
- dirs = append(dirs, filepath.Dir(mf))
110
- for _, d := range dirs {
111
- pruneEmptyDirs(d, r.target)
112
- }
113
- }
114
- if err := r.removeBinary(); err != nil {
115
- return fmt.Errorf("remove engine binary: %w", err)
125
+ r.pruneEmptyDirs(filepath.Dir(mf))
116
126
  }
117
127
  fmt.Fprintln(r.opts.Stdout)
118
128
  if r.opts.DryRun {
@@ -141,7 +151,7 @@ func (r *runner) dryRunStripMarker(tree map[string][]byte, merge hostpack.Manage
141
151
  write := func(data []byte) error { tree[path] = data; return nil }
142
152
  remove := func() error { tree[path] = nil; return nil }
143
153
  if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
144
- return stripHooksWith(path, write, remove)
154
+ return r.stripHooksWith(path, write, remove)
145
155
  }
146
156
  if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
147
157
  return r.stripClaudeSettingsWith(path, write, remove)
@@ -9,7 +9,6 @@ import (
9
9
  "strings"
10
10
  "time"
11
11
 
12
- "github.com/devrites/devrites/internal/hostpack"
13
12
  "github.com/devrites/devrites/internal/release"
14
13
  )
15
14
 
@@ -65,9 +64,6 @@ func runUpdate(opts Options) error {
65
64
  if err != nil {
66
65
  return fmt.Errorf("resolve payload %s: %w", opts.PayloadDir, err)
67
66
  }
68
- if err := hostpack.ValidatePayload(os.DirFS(payload), true, true, true, true, true); err != nil {
69
- return fmt.Errorf("local update payload under %s is invalid: %w", payload, err)
70
- }
71
67
  installFlags := manifestHeader(mf, "devrites-flags")
72
68
  next := DefaultOptions(ModeInstall)
73
69
  next.Stdout = opts.Stdout
@@ -437,20 +437,37 @@ func checkBundleResult(targets []*bundleTarget, retired map[string]bool) []strin
437
437
  return problems
438
438
  }
439
439
 
440
- // writeBundleTargets writes every target atomically and, if one write fails,
441
- // restores the files already written so the workspace never holds half a
442
- // bundle.
440
+ // writeBundleTargets writes every target atomically. A failed write may already
441
+ // have published its bytes, so rollback covers every attempted target and reports
442
+ // any restoration failures.
443
443
  func writeBundleTargets(ws *os.Root, targets []*bundleTarget) error {
444
+ return writeBundleTargetsWith(ws, targets, fsutil.WriteFileAtomicIn)
445
+ }
446
+
447
+ func writeBundleTargetsWith(ws *os.Root, targets []*bundleTarget, write func(*os.Root, string, []byte, fs.FileMode) error) error {
444
448
  for i, target := range targets {
445
- if err := fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(target.rel), target.after, 0o644); err != nil {
446
- for _, done := range targets[:i] {
449
+ if err := write(ws, filepath.FromSlash(target.rel), target.after, 0o644); err != nil {
450
+ writeErr := fmt.Errorf("write %s: %w", target.rel, err)
451
+ var rollbackErrors []error
452
+ for j := i; j >= 0; j-- {
453
+ done := targets[j]
454
+ var rollbackErr error
447
455
  if done.existed {
448
- _ = fsutil.WriteFileAtomicIn(ws, filepath.FromSlash(done.rel), done.before, 0o644)
456
+ rollbackErr = write(ws, filepath.FromSlash(done.rel), done.before, 0o644)
449
457
  } else {
450
- _ = ws.Remove(filepath.FromSlash(done.rel))
458
+ rollbackErr = ws.Remove(filepath.FromSlash(done.rel))
459
+ if errors.Is(rollbackErr, fs.ErrNotExist) {
460
+ rollbackErr = nil
461
+ }
451
462
  }
463
+ if rollbackErr != nil {
464
+ rollbackErrors = append(rollbackErrors, fmt.Errorf("rollback %s: %w", done.rel, rollbackErr))
465
+ }
466
+ }
467
+ if len(rollbackErrors) > 0 {
468
+ return errors.Join(writeErr, fmt.Errorf("rollback incomplete: %w", errors.Join(rollbackErrors...)))
452
469
  }
453
- return fmt.Errorf("write %s: %w (earlier files restored)", target.rel, err)
470
+ return fmt.Errorf("%w (attempted files restored)", writeErr)
454
471
  }
455
472
  }
456
473
  return nil