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
@@ -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
@@ -5,6 +5,7 @@ import (
5
5
  "crypto/rand"
6
6
  "encoding/hex"
7
7
  "encoding/json"
8
+ "errors"
8
9
  "fmt"
9
10
  "io"
10
11
  "os"
@@ -17,6 +18,7 @@ import (
17
18
 
18
19
  "github.com/devrites/devrites/internal/devritespaths"
19
20
  "github.com/devrites/devrites/internal/parallel"
21
+ "github.com/devrites/devrites/internal/safepath"
20
22
  "github.com/devrites/devrites/internal/state"
21
23
  )
22
24
 
@@ -262,6 +264,137 @@ func claimPathsOverlap(a, b string) bool {
262
264
  return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
263
265
  }
264
266
 
267
+ // Retained writer ownership belongs to the repository ledger, not a session or
268
+ // TTL. Caller holds its lock; workspace moves use the same lock.
269
+ func retainedWriterConflicts(root string, latest map[string]*claimRecord, paths []string, probe holderProbe) ([]*claimRecord, error) {
270
+ var conflicts []*claimRecord
271
+ var physical []string
272
+ seen := map[string]bool{}
273
+ for _, parent := range []string{"work", "archive"} {
274
+ base := filepath.Join(root, parent)
275
+ if parent == "archive" {
276
+ var err error
277
+ base, err = devritespaths.ArchiveDirChecked(root)
278
+ if err != nil {
279
+ return nil, err
280
+ }
281
+ }
282
+ if info, err := os.Lstat(base); os.IsNotExist(err) {
283
+ continue
284
+ } else if err != nil {
285
+ return nil, err
286
+ } else if !info.IsDir() {
287
+ return nil, fmt.Errorf("claim: workspace collection %s is not a canonical directory", base)
288
+ }
289
+ entries, err := os.ReadDir(base)
290
+ if err != nil {
291
+ return nil, err
292
+ }
293
+ for _, entry := range entries {
294
+ if entry.Type().IsRegular() {
295
+ continue
296
+ }
297
+ var dir string
298
+ if parent == "work" {
299
+ dir, err = devritespaths.ExistingCanonicalFeatureDirChecked(root, entry.Name())
300
+ } else {
301
+ dir, err = devritespaths.ExistingArchivedFeatureDirChecked(root, entry.Name())
302
+ }
303
+ if err != nil {
304
+ return nil, err
305
+ }
306
+ path := filepath.Join(dir, "dispatch.json")
307
+ if info, err := os.Lstat(path); os.IsNotExist(err) {
308
+ continue
309
+ } else if err != nil {
310
+ return nil, err
311
+ } else if !info.Mode().IsRegular() {
312
+ return nil, fmt.Errorf("claim: %s is not a regular dispatch ledger", path)
313
+ }
314
+ f, _, err := loadDispatchFile(path)
315
+ if err != nil {
316
+ return nil, fmt.Errorf("%s: %w", path, err)
317
+ }
318
+ for name, w := range f.Waves {
319
+ // A declared claim is write intent regardless of the host role label.
320
+ if w.State != "abandoned" || w.Claim == "" {
321
+ continue
322
+ }
323
+ rec := latest[w.Claim]
324
+ if rec == nil || (w.ClaimSession != "" && w.ClaimSession != rec.Session) {
325
+ return nil, fmt.Errorf("abandoned writer wave %s/%s has unknown claim %q", entry.Name(), name, w.Claim)
326
+ }
327
+ if rec.Released || seen[rec.ID] {
328
+ continue
329
+ }
330
+ seen[rec.ID] = true
331
+ if retainedWriterGone(probe, rec) {
332
+ continue
333
+ }
334
+ held, err := normalizeClaimPaths(rec.Paths)
335
+ if err != nil {
336
+ return nil, fmt.Errorf("abandoned writer claim %s has invalid paths: %w", rec.ID, err)
337
+ }
338
+ if physical == nil {
339
+ physical, err = physicalClaimPaths(root, paths)
340
+ if err != nil {
341
+ return nil, err
342
+ }
343
+ }
344
+ held, err = physicalClaimPaths(root, held)
345
+ if err != nil {
346
+ return nil, err
347
+ }
348
+ if slices.ContainsFunc(held, func(h string) bool {
349
+ return slices.ContainsFunc(physical, func(p string) bool { return claimPathsOverlap(h, p) })
350
+ }) {
351
+ conflicts = append(conflicts, rec)
352
+ }
353
+ }
354
+ }
355
+ }
356
+ return conflicts, nil
357
+ }
358
+
359
+ func retainedWriterGone(probe holderProbe, rec *claimRecord) bool {
360
+ valid := func(p claimProc) bool {
361
+ _, err := time.Parse(time.RFC3339, p.Start)
362
+ return p.PID > 1 && err == nil
363
+ }
364
+ if rec.Holder != nil {
365
+ if !valid(*rec.Holder) {
366
+ return false
367
+ }
368
+ } else {
369
+ for _, anchor := range rec.Anchors {
370
+ if !valid(anchor) {
371
+ return false
372
+ }
373
+ }
374
+ }
375
+ return probe.gone(rec)
376
+ }
377
+
378
+ func physicalClaimPaths(root string, paths []string) ([]string, error) {
379
+ project := root
380
+ if filepath.Base(root) == devritespaths.DevritesRootName {
381
+ project = filepath.Dir(root)
382
+ }
383
+ out := make([]string, 0, len(paths))
384
+ for _, path := range paths {
385
+ normalized, err := parallel.NormalizePath(path)
386
+ if err != nil {
387
+ return nil, err
388
+ }
389
+ resolved, err := safepath.ResolveExisting(filepath.Join(project, filepath.FromSlash(normalized)))
390
+ if err != nil {
391
+ return nil, err
392
+ }
393
+ out = append(out, filepath.ToSlash(resolved))
394
+ }
395
+ return out, nil
396
+ }
397
+
265
398
  // An unreadable line may be a live claim, so the conflict gates refuse to
266
399
  // answer rather than report a path free.
267
400
  const unparsedLedgerMsg = "claim: %d unparsed ledger line(s); cannot rule out a conflict, repair the claims ledger\n"
@@ -340,7 +473,7 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
340
473
  // sessions cannot both pass the check and claim the same path.
341
474
  id, code := "", 0
342
475
  if err := state.WithLock(path+".lock", func() error {
343
- id, code = claimAddLocked(path, session, reason, ttl, paths, stdout, stderr)
476
+ id, code = claimAddLocked(path, session, reason, ttl, paths, paths, stdout, stderr)
344
477
  return nil
345
478
  }); err != nil {
346
479
  fmt.Fprintf(stderr, "claim: %v\n", err)
@@ -349,7 +482,7 @@ func addClaimTargets(root, session, reason string, ttl int, raw, workspaces []st
349
482
  return id, code
350
483
  }
351
484
 
352
- func claimAddLocked(path, session, reason string, ttl int, paths []string, stdout, stderr io.Writer) (string, int) {
485
+ func claimAddLocked(path, session, reason string, ttl int, paths, admissionPaths []string, stdout, stderr io.Writer) (string, int) {
353
486
  latest, order, bad, err := readClaims(path)
354
487
  if err != nil {
355
488
  fmt.Fprintf(stderr, "claim: %v\n", err)
@@ -361,6 +494,15 @@ func claimAddLocked(path, session, reason string, ttl int, paths []string, stdou
361
494
  }
362
495
  now := time.Now().UTC()
363
496
  probe := newHolderProbe()
497
+ retained, err := retainedWriterConflicts(filepath.Dir(path), latest, admissionPaths, probe)
498
+ if err != nil {
499
+ fmt.Fprintf(stderr, "claim: %v\n", err)
500
+ return "", 3
501
+ }
502
+ if len(retained) > 0 {
503
+ printConflicts(stderr, retained)
504
+ return "", 3
505
+ }
364
506
  conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, now)), session, paths)
365
507
  if len(conflicts) > 0 {
366
508
  printConflicts(stderr, conflicts)
@@ -413,6 +555,8 @@ func claimRelease(root string, args []string, stdout, stderr io.Writer) int {
413
555
  return 0
414
556
  }
415
557
 
558
+ var errClaimAlreadyReleased = errors.New("already released")
559
+
416
560
  // releaseClaim marks session's claim id released and returns its path count.
417
561
  func releaseClaim(root, session, id string) (int, error) {
418
562
  path := claimsPath(root)
@@ -428,7 +572,7 @@ func releaseClaim(root, session, id string) (int, error) {
428
572
  return 0, fmt.Errorf("%q belongs to session %q, not %q", id, rec.Session, session)
429
573
  }
430
574
  if rec.Released {
431
- return 0, fmt.Errorf("%q already released", id)
575
+ return 0, fmt.Errorf("%q %w", id, errClaimAlreadyReleased)
432
576
  }
433
577
  released := *rec
434
578
  released.Released = true
@@ -523,6 +667,18 @@ func claimCheck(root string, args []string, stdout, stderr io.Writer) int {
523
667
  fmt.Fprintf(stderr, "claim: %v\n", err)
524
668
  return 2
525
669
  }
670
+ code := 0
671
+ if err := state.WithLock(claimsPath(root)+".lock", func() error {
672
+ code = claimCheckLocked(root, session, paths, stdout, stderr)
673
+ return nil
674
+ }); err != nil {
675
+ fmt.Fprintf(stderr, "claim: %v\n", err)
676
+ return 2
677
+ }
678
+ return code
679
+ }
680
+
681
+ func claimCheckLocked(root, session string, paths []string, stdout, stderr io.Writer) int {
526
682
  latest, order, bad, err := readClaims(claimsPath(root))
527
683
  if err != nil {
528
684
  fmt.Fprintf(stderr, "claim: %v\n", err)
@@ -532,7 +688,17 @@ func claimCheck(root string, args []string, stdout, stderr io.Writer) int {
532
688
  fmt.Fprintf(stderr, unparsedLedgerMsg, bad)
533
689
  return 2
534
690
  }
535
- conflicts := claimConflicts(newHolderProbe().withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
691
+ probe := newHolderProbe()
692
+ retained, err := retainedWriterConflicts(root, latest, paths, probe)
693
+ if err != nil {
694
+ fmt.Fprintf(stderr, "claim: %v\n", err)
695
+ return 2
696
+ }
697
+ if len(retained) > 0 {
698
+ printConflicts(stdout, retained)
699
+ return 3
700
+ }
701
+ conflicts := claimConflicts(probe.withoutGone(liveClaims(latest, order, time.Now().UTC())), session, paths)
536
702
  if len(conflicts) > 0 {
537
703
  printConflicts(stdout, conflicts)
538
704
  return 3
@@ -8,6 +8,8 @@ import (
8
8
  "strconv"
9
9
  "strings"
10
10
  "time"
11
+
12
+ "github.com/devrites/devrites/internal/hostpack"
11
13
  )
12
14
 
13
15
  // claimAnchorAge separates a claiming session's long-lived host processes from
@@ -27,13 +29,6 @@ type procInfo struct {
27
29
  comm string
28
30
  }
29
31
 
30
- // shells outlive the agent sessions they launch (a terminal pane, a
31
- // multiplexer's login shell), so a shell never stands for the holder.
32
- var shells = map[string]bool{
33
- "sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, "ksh": true,
34
- "tcsh": true, "csh": true, "nu": true, "pwsh": true, "powershell": true, "cmd": true,
35
- }
36
-
37
32
  // processTable lists running processes keyed by pid, or nil when the host
38
33
  // cannot say. Tests replace it.
39
34
  var processTable = psProcessTable
@@ -95,9 +90,12 @@ func newHolderProbe() holderProbe {
95
90
  return holderProbe{table: table, host: host, self: lineage(table, os.Getppid())}
96
91
  }
97
92
 
98
- // anchors returns the caller's ancestors that started more than
99
- // claimAnchorAge before now: the session's host processes.
93
+ // anchors retains long-lived ancestry only for an identified session.
94
+ // Unknown writers must not be released merely because their launchers exit.
100
95
  func (p holderProbe) anchors(now time.Time) []claimProc {
96
+ if p.holder(now) == nil {
97
+ return nil
98
+ }
101
99
  var out []claimProc
102
100
  for _, proc := range p.self {
103
101
  start, err := time.Parse(time.RFC3339, proc.Start)
@@ -108,13 +106,12 @@ func (p holderProbe) anchors(now time.Time) []claimProc {
108
106
  return out
109
107
  }
110
108
 
111
- // holder returns the nearest anchor that is not a shell: the agent session
112
- // process that owns the claim. A shell or terminal multiplexer above it can
113
- // outlive the session, so only the session's own exit proves release.
114
- func (p holderProbe) holder(now time.Time) *claimProc {
115
- for _, proc := range p.anchors(now) {
116
- name := strings.TrimPrefix(filepath.Base(p.table[proc.PID].comm), "-")
117
- if name != "" && !shells[name] {
109
+ // holder identifies the native session independently of its age or wrappers.
110
+ // Only native executable identities prove a session; generic runtimes do not.
111
+ func (p holderProbe) holder(_ time.Time) *claimProc {
112
+ for _, proc := range p.self {
113
+ switch filepath.Base(p.table[proc.PID].comm) {
114
+ case "claude", "codex", hostpack.PiSettings.Host, hostpack.OmpConfig.Host, "devin", "dsh":
118
115
  return &proc
119
116
  }
120
117
  }
@@ -7,30 +7,27 @@ import (
7
7
  "io/fs"
8
8
  "os"
9
9
  "path/filepath"
10
- "runtime"
10
+ "strings"
11
11
 
12
12
  "github.com/devrites/devrites/internal/devritespaths"
13
+ "github.com/devrites/devrites/internal/gate"
14
+ "github.com/devrites/devrites/internal/notes"
13
15
  "github.com/devrites/devrites/internal/parallel"
14
16
  "github.com/devrites/devrites/internal/state"
15
17
  )
16
18
 
17
- // releaseLockBeforeRename drops the feature lock before the workspace rename on
18
- // Windows, which refuses to rename a directory while the lock file inside it is
19
- // open. renameWorkspace is the rename; both are variables so tests can inject the
20
- // platform decision and observe the lock at the moment of the rename.
21
- var (
22
- releaseLockBeforeRename = runtime.GOOS == "windows"
23
- renameWorkspace = os.Rename
24
- )
19
+ // renameWorkspace lets tests observe exclusion at the archive boundary.
20
+ var renameWorkspace = os.Rename
25
21
 
26
- // CloseOut retires a shipped feature: it moves the feature directory into the
27
- // archive and, when the feature is still the active one, clears the ACTIVE cursor
28
- // so the next /rite-spec starts clean. The workspace is relocated, never deleted:
29
- // the audit trail is preserved under archive/<slug>.
22
+ // CloseOut physically archives a workspace, including owner-authorized nonfinal
23
+ // exits. Seal, Ship, Done, and shipped/sealed/done statuses must first satisfy
24
+ // the final gates and retained prerequisites. The workspace is relocated, never
25
+ // deleted: the audit trail is preserved under archive/<slug>.
30
26
  //
31
27
  // args is `<slug>`; root is the already-resolved .devrites directory. Exit codes:
32
28
  //
33
29
  // 0 archived (ACTIVE cleared if it pointed here)
30
+ // 3 final prerequisites or a retained parallel lease block archiving
34
31
  // 4 no slug given, or the feature has no workspace
35
32
  // 5 an archive already exists at the destination: refuse to clobber it
36
33
  func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
@@ -70,14 +67,8 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
70
67
  return 5
71
68
  }
72
69
 
73
- // Hold the per-feature lock across the rename and the ACTIVE clear, as
74
- // dispatch and the parallel lease ops do across their load-modify-save. A
75
- // writer already holding the lock finishes its save before the rename, so
76
- // that save is archived. On unix a writer queued behind this close-out
77
- // acquires a lock file that now lives in the archive; state.WithFeatureLock
78
- // reports state.ErrWorkspaceMoved and the writer does not run, so it cannot
79
- // re-create work/<slug>. Windows cannot rename a directory that holds an
80
- // open file, so there the lock is released just before the rename.
70
+ // Hold the stable feature lock through verification, rename, ACTIVE clear,
71
+ // and rollback so queued writers cannot revive an archived workspace.
81
72
  code := 1
82
73
  if err := parallel.WithControlLock(dv, func() error {
83
74
  leased, err := parallel.WorkspaceLeased(dv, slug)
@@ -89,9 +80,11 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
89
80
  code = 3
90
81
  return nil
91
82
  }
92
- return state.WithFeatureLockRelease(dv, slug, func(release func()) error {
93
- code = closeOutLocked(release, work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
94
- return nil
83
+ return state.WithFeatureLock(dv, slug, func() error {
84
+ return state.WithLock(claimsPath(dv)+".lock", func() error {
85
+ code = closeOutLocked(work, arch, archiveDir, archDisplay, active, slug, stdout, stderr)
86
+ return nil
87
+ })
95
88
  })
96
89
  }); err != nil {
97
90
  fmt.Fprintf(stderr, "state close: %v\n", err)
@@ -103,7 +96,41 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
103
96
  // closeOutLocked performs the rename and the conditional ACTIVE clear. It runs
104
97
  // under the feature lock held by CloseOut and reports an exit code rather than
105
98
  // an error, preserving CloseOut's documented exit codes.
106
- func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
99
+ func closeOutLocked(work, arch, archiveDir, archDisplay, active, slug string, stdout, stderr io.Writer) int {
100
+ root := filepath.Dir(active)
101
+ ledger, err := readWorkspaceArtifact(root, slug, "state.md")
102
+ if err != nil {
103
+ fmt.Fprintf(stderr, "state close: BLOCKED: %v\n", err)
104
+ return 3
105
+ }
106
+ lines := strings.Split(string(ledger), "\n")
107
+ phase, _ := state.CursorField(lines, "phase")
108
+ status, _ := state.CursorField(lines, "status")
109
+ phase = string(state.CanonicalPhase(phase))
110
+ final := phase == string(state.PhaseSeal) || phase == string(state.PhaseShip) || phase == string(state.PhaseDone) ||
111
+ status == "sealed" || status == "shipped" || status == "done"
112
+ if final {
113
+ result, err := gate.Check(gate.Seal, root, slug)
114
+ if err != nil {
115
+ fmt.Fprintf(stderr, "state close: BLOCKED: %v\n", err)
116
+ return 3
117
+ }
118
+ if result.Blocked {
119
+ fmt.Fprint(stdout, result.Render())
120
+ return 3
121
+ }
122
+ if err := VerifyReleaseUnion(root, slug); err != nil {
123
+ fmt.Fprintf(stderr, "state close: BLOCKED: release-union: %v\n", err)
124
+ return 3
125
+ }
126
+ if err := CheckFinalPrerequisites(root, slug); err != nil {
127
+ fmt.Fprintf(stderr, "state close: BLOCKED: final-prerequisites: %v\n", err)
128
+ return 3
129
+ }
130
+ if EvidenceFresh(root, []string{slug}, stdout, stderr) != 0 || !notes.SealCheck(root, slug, stdout) {
131
+ return 3
132
+ }
133
+ }
107
134
  activeSlug, err := devritespaths.PhysicalActiveSlug(filepath.Dir(active))
108
135
  if err != nil {
109
136
  fmt.Fprintf(stderr, "state close: invalid ACTIVE cursor: %v\n", err)
@@ -115,9 +142,6 @@ func closeOutLocked(release func(), work, arch, archiveDir, archDisplay, active,
115
142
  fmt.Fprintf(stderr, "state close: cannot create archive dir: %v\n", err)
116
143
  return 1
117
144
  }
118
- if releaseLockBeforeRename {
119
- release()
120
- }
121
145
  if err := renameWorkspace(work, arch); err != nil {
122
146
  fmt.Fprintf(stderr, "state close: cannot archive %s -> %s: %v\n", work, arch, err)
123
147
  return 1