devrites 5.2.3 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/engine/internal/install/uninstall.go +3 -0
  4. package/engine/internal/lib/resolve.go +7 -0
  5. package/engine/internal/parallel/cli.go +62 -8
  6. package/engine/internal/parallel/git.go +46 -7
  7. package/engine/internal/parallel/lease.go +7 -0
  8. package/engine/internal/parallel/ops.go +256 -72
  9. package/engine/internal/parallel/ops_test.go +223 -6
  10. package/engine/internal/parallel/parallel_test.go +229 -6
  11. package/engine/internal/parallel/pathdisjoint.go +18 -3
  12. package/engine/internal/parallel/pathdisjoint_test.go +21 -0
  13. package/pack/.claude/agents/devrites-plan-drafter.md +9 -0
  14. package/pack/.claude/agents/devrites-plan-reviewer.md +4 -1
  15. package/pack/.claude/skills/devrites-api-interface/SKILL.md +3 -1
  16. package/pack/.claude/skills/devrites-doubt/SKILL.md +3 -2
  17. package/pack/.claude/skills/rite-autocomplete/SKILL.md +2 -3
  18. package/pack/.claude/skills/rite-build/SKILL.md +7 -3
  19. package/pack/.claude/skills/rite-build/reference/checkpoint.md +13 -15
  20. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +4 -2
  21. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +75 -20
  22. package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +8 -1
  23. package/pack/.claude/skills/rite-plan/SKILL.md +26 -1
  24. package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +8 -4
  25. package/pack/.claude/skills/rite-review/SKILL.md +10 -6
  26. package/pack/.claude/skills/rite-seal/SKILL.md +3 -1
  27. package/pack/.claude/skills/rite-vet/SKILL.md +19 -5
  28. package/pack/generated/claude/agents/devrites-plan-drafter.md +9 -0
  29. package/pack/generated/claude/agents/devrites-plan-reviewer.md +4 -1
  30. package/pack/generated/claude/skills/devrites-api-interface/SKILL.md +3 -1
  31. package/pack/generated/claude/skills/devrites-doubt/SKILL.md +3 -2
  32. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
  33. package/pack/generated/claude/skills/rite-build/SKILL.md +7 -3
  34. package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +13 -15
  35. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +4 -2
  36. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +75 -20
  37. package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +8 -1
  38. package/pack/generated/claude/skills/rite-plan/SKILL.md +26 -1
  39. package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +8 -4
  40. package/pack/generated/claude/skills/rite-review/SKILL.md +10 -6
  41. package/pack/generated/claude/skills/rite-seal/SKILL.md +3 -1
  42. package/pack/generated/claude/skills/rite-vet/SKILL.md +19 -5
  43. package/pack/generated/codex/agents/devrites-plan-drafter.toml +9 -0
  44. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +4 -1
  45. package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +3 -1
  46. package/pack/generated/codex/skills/devrites-doubt/SKILL.md +3 -2
  47. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
  48. package/pack/generated/codex/skills/rite-build/SKILL.md +7 -3
  49. package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +13 -15
  50. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +4 -2
  51. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +75 -20
  52. package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +8 -1
  53. package/pack/generated/codex/skills/rite-plan/SKILL.md +26 -1
  54. package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +8 -4
  55. package/pack/generated/codex/skills/rite-review/SKILL.md +10 -6
  56. package/pack/generated/codex/skills/rite-seal/SKILL.md +3 -1
  57. package/pack/generated/codex/skills/rite-vet/SKILL.md +19 -5
  58. package/pack/generated/omp/agents/devrites-plan-drafter.md +9 -0
  59. package/pack/generated/omp/agents/devrites-plan-reviewer.md +4 -1
  60. package/pack/generated/omp/skills/devrites-api-interface/SKILL.md +3 -1
  61. package/pack/generated/omp/skills/devrites-doubt/SKILL.md +3 -2
  62. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
  63. package/pack/generated/omp/skills/rite-build/SKILL.md +7 -3
  64. package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +13 -15
  65. package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +4 -2
  66. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +75 -20
  67. package/pack/generated/omp/skills/rite-build/reference/spec-drift-guard.md +8 -1
  68. package/pack/generated/omp/skills/rite-plan/SKILL.md +26 -1
  69. package/pack/generated/omp/skills/rite-plan/reference/replan-and-repair.md +8 -4
  70. package/pack/generated/omp/skills/rite-review/SKILL.md +10 -6
  71. package/pack/generated/omp/skills/rite-seal/SKILL.md +3 -1
  72. package/pack/generated/omp/skills/rite-vet/SKILL.md +19 -5
  73. package/package.json +1 -1
@@ -6,6 +6,7 @@ import (
6
6
  "os"
7
7
  "path/filepath"
8
8
  "strings"
9
+ "sync"
9
10
  "testing"
10
11
  )
11
12
 
@@ -50,15 +51,18 @@ func greenBatch(t *testing.T, repo, base, slug, batch string) *Lease {
50
51
  }
51
52
 
52
53
  type seamRestore struct {
53
- origLease func(string, *Lease) error
54
- origRemove func(string, string) error
55
- origWarn func(string, ...any)
54
+ origLease func(string, *Lease) error
55
+ origRemove func(string, string) error
56
+ origSalvage func(LeaseSlice, string) (string, error)
57
+ origWarn func(string, ...any)
56
58
  }
57
59
 
58
60
  func swapSeams(t *testing.T) {
59
61
  t.Helper()
60
- s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origWarn: warnf}
61
- t.Cleanup(func() { writeLease, removeWorktree, warnf = s.origLease, s.origRemove, s.origWarn })
62
+ s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origSalvage: salvage, origWarn: warnf}
63
+ t.Cleanup(func() {
64
+ writeLease, removeWorktree, salvage, warnf = s.origLease, s.origRemove, s.origSalvage, s.origWarn
65
+ })
62
66
  }
63
67
 
64
68
  // TestIntegrateFailedTransitionSurvivesTransientWriteFailure proves the
@@ -162,6 +166,219 @@ func TestIntegrateSurfacesStuckStagingWorktree(t *testing.T) {
162
166
  }
163
167
  }
164
168
 
169
+ // TestCleanupNeverTouchesForeignWorktreePath proves a lease whose
170
+ // worktree_path points outside the batch scratch layout is never salvaged
171
+ // into nor deleted — the lease field is advisory, not a RemoveAll license.
172
+ func TestCleanupNeverTouchesForeignWorktreePath(t *testing.T) {
173
+ repo, base := setupRepo(t)
174
+ slug, batch := "demo-feature", "batch1"
175
+ lease, err := Create(CreateOpts{
176
+ Root: repo,
177
+ Slug: slug,
178
+ BatchID: batch,
179
+ BaseSHA: base,
180
+ Slices: []SlicePaths{
181
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
182
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
183
+ },
184
+ })
185
+ if err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ foreign := filepath.Join(t.TempDir(), "precious")
189
+ if err := os.MkdirAll(foreign, 0o755); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ if err := os.WriteFile(filepath.Join(foreign, "keep.txt"), []byte("x"), 0o644); err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ lease.Slices[0].WorktreePath = foreign
196
+ // A forged branch outside the batch namespace must survive too.
197
+ gitOk(t, repo, "branch", "user/topic", base)
198
+ lease.Slices[0].Branch = "user/topic"
199
+ leasePath, err := LeasePath(repo, slug)
200
+ if err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ if err := WriteLease(leasePath, lease); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+
207
+ var warnings []string
208
+ swapSeams(t)
209
+ warnf = func(format string, args ...any) {
210
+ warnings = append(warnings, fmt.Sprintf(format, args...))
211
+ }
212
+ if _, err := Cleanup(repo, slug, true); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ if _, err := os.Stat(filepath.Join(foreign, "keep.txt")); err != nil {
216
+ t.Fatalf("foreign path was touched: %v", err)
217
+ }
218
+ if tip := gitOk(t, repo, "rev-parse", "user/topic"); tip != base {
219
+ t.Fatalf("foreign branch user/topic moved to %s, want %s", tip, base)
220
+ }
221
+ joined := strings.Join(warnings, "\n")
222
+ if !strings.Contains(joined, "outside") {
223
+ t.Fatalf("expected warnings naming foreign path and branch, got:\n%s", warnings)
224
+ }
225
+ }
226
+
227
+ // TestCleanupFailedSalvageKeepsWorktreeDir proves a salvage error keeps the
228
+ // worktree on disk as the only remaining copy — the batch-level scratch
229
+ // removal must skip kept slice dirs instead of destroying them.
230
+ func TestCleanupFailedSalvageKeepsWorktreeDir(t *testing.T) {
231
+ repo, base := setupRepo(t)
232
+ slug, batch := "demo-feature", "batch1"
233
+ if _, err := Create(CreateOpts{
234
+ Root: repo,
235
+ Slug: slug,
236
+ BatchID: batch,
237
+ BaseSHA: base,
238
+ Slices: []SlicePaths{
239
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
240
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
241
+ },
242
+ }); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ wtA := WorkerWorktreePath(repo, batch, "slice-a")
246
+ wtB := WorkerWorktreePath(repo, batch, "slice-b")
247
+
248
+ swapSeams(t)
249
+ realSalvage := salvage
250
+ salvage = func(sl LeaseSlice, batchID string) (string, error) {
251
+ if sl.ID == "slice-a" {
252
+ return "", errors.New("injected salvage failure")
253
+ }
254
+ return realSalvage(sl, batchID)
255
+ }
256
+ salvaged, err := Cleanup(repo, slug, true)
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if _, err := os.Stat(wtA); err != nil {
261
+ t.Fatalf("kept worktree was removed: %v", err)
262
+ }
263
+ if _, err := os.Stat(wtB); !os.IsNotExist(err) {
264
+ t.Fatalf("clean slice-b worktree should be removed, stat err=%v", err)
265
+ }
266
+ if _, err := git(repo, "rev-parse", "--verify", "devrites/parallel/"+slug+"/"+batch+"/slice-a"); err != nil {
267
+ t.Fatalf("slice-a branch should be kept: %v", err)
268
+ }
269
+ found := false
270
+ for _, s := range salvaged {
271
+ if s.SliceID == "slice-a" && s.Worktree == wtA {
272
+ found = true
273
+ }
274
+ }
275
+ if !found {
276
+ t.Fatalf("salvage output should report kept worktree for slice-a, got %+v", salvaged)
277
+ }
278
+ }
279
+
280
+ // TestConcurrentRecordGreenKeepsEverySlice proves the feature lock serializes
281
+ // concurrent record-green calls so no sibling's transfer commit is lost to a
282
+ // read-modify-write race on the lease.
283
+ func TestConcurrentRecordGreenKeepsEverySlice(t *testing.T) {
284
+ repo, base := setupRepo(t)
285
+ slug, batch := "demo-feature", "batch1"
286
+ lease, err := Create(CreateOpts{
287
+ Root: repo,
288
+ Slug: slug,
289
+ BatchID: batch,
290
+ BaseSHA: base,
291
+ Slices: []SlicePaths{
292
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
293
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
294
+ },
295
+ })
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ tcA := commitIn(t, lease.Slices[0].WorktreePath, "src/a.go", "A")
300
+ tcB := commitIn(t, lease.Slices[1].WorktreePath, "src/b.go", "B")
301
+
302
+ var wg sync.WaitGroup
303
+ errs := make(chan error, 2)
304
+ for _, item := range [][2]string{{"slice-a", tcA}, {"slice-b", tcB}} {
305
+ wg.Add(1)
306
+ go func(sliceID, commit string) {
307
+ defer wg.Done()
308
+ _, err := RecordGreen(repo, slug, sliceID, commit)
309
+ errs <- err
310
+ }(item[0], item[1])
311
+ }
312
+ wg.Wait()
313
+ close(errs)
314
+ for err := range errs {
315
+ if err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ }
319
+
320
+ leasePath, err := LeasePath(repo, slug)
321
+ if err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ onDisk, err := ReadLease(leasePath)
325
+ if err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ got := map[string]string{}
329
+ for _, sl := range onDisk.Slices {
330
+ got[sl.ID] = sl.TransferCommit
331
+ }
332
+ if got["slice-a"] != tcA || got["slice-b"] != tcB {
333
+ t.Fatalf("lost update: transfer commits %v want a=%s b=%s", got, tcA, tcB)
334
+ }
335
+ }
336
+
337
+ // TestConcurrentCreateSingleWinner proves the feature lock serializes create
338
+ // so two racing creates cannot both pass the lease-existence check.
339
+ func TestConcurrentCreateSingleWinner(t *testing.T) {
340
+ repo, base := setupRepo(t)
341
+ opts := CreateOpts{
342
+ Root: repo,
343
+ Slug: "demo-feature",
344
+ BatchID: "batch1",
345
+ BaseSHA: base,
346
+ Slices: []SlicePaths{
347
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
348
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
349
+ },
350
+ }
351
+ var wg sync.WaitGroup
352
+ errs := make(chan error, 2)
353
+ wins := make(chan *Lease, 2)
354
+ for i := 0; i < 2; i++ {
355
+ wg.Add(1)
356
+ go func() {
357
+ defer wg.Done()
358
+ lease, err := Create(opts)
359
+ if err != nil {
360
+ errs <- err
361
+ return
362
+ }
363
+ wins <- lease
364
+ }()
365
+ }
366
+ wg.Wait()
367
+ close(errs)
368
+ close(wins)
369
+ succeeded := 0
370
+ for range wins {
371
+ succeeded++
372
+ }
373
+ failed := 0
374
+ for range errs {
375
+ failed++
376
+ }
377
+ if succeeded != 1 || failed != 1 {
378
+ t.Fatalf("concurrent create: %d succeeded, %d failed; want exactly 1/1", succeeded, failed)
379
+ }
380
+ }
381
+
165
382
  // TestCleanupWarnsOnFailedBranchCleanup proves Cleanup reports branch cleanup
166
383
  // failures instead of erasing the lease while orphans remain.
167
384
  func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
@@ -194,7 +411,7 @@ func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
194
411
  warnings = append(warnings, fmt.Sprintf(format, args...))
195
412
  }
196
413
 
197
- if err := Cleanup(repo, slug, true); err != nil {
414
+ if _, err := Cleanup(repo, slug, true); err != nil {
198
415
  t.Fatalf("cleanup continues after reporting failures: %v", err)
199
416
  }
200
417
  joined := strings.Join(warnings, "\n")
@@ -112,17 +112,19 @@ func TestCreateAbortCleanup(t *testing.T) {
112
112
  t.Fatalf("control moved after create: %s", head)
113
113
  }
114
114
 
115
- // Drift control tip, then abort should restore base.
115
+ // Drift control tip; abort must never rewind external commits — the drift
116
+ // commit survives and the lease still marks aborted.
116
117
  if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
117
118
  t.Fatal(err)
118
119
  }
119
120
  gitOk(t, repo, "add", "README.md")
120
121
  gitOk(t, repo, "commit", "-m", "drift")
121
- if _, err := Abort(repo, slug, true); err != nil {
122
+ drift := gitOk(t, repo, "rev-parse", "HEAD")
123
+ if _, err := Abort(repo, slug); err != nil {
122
124
  t.Fatal(err)
123
125
  }
124
- if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
125
- t.Fatalf("abort left control at %s want %s", head, base)
126
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != drift {
127
+ t.Fatalf("abort rewound control: head %s want drift tip %s", head, drift)
126
128
  }
127
129
  leasePath, _ := LeasePath(repo, slug)
128
130
  lease, err = ReadLease(leasePath)
@@ -136,7 +138,7 @@ func TestCreateAbortCleanup(t *testing.T) {
136
138
  t.Fatalf("abort should preserve worktree: %v", err)
137
139
  }
138
140
 
139
- if err := Cleanup(repo, slug, true); err != nil {
141
+ if _, err := Cleanup(repo, slug, true); err != nil {
140
142
  t.Fatal(err)
141
143
  }
142
144
  if _, err := os.Stat(wtA); !os.IsNotExist(err) {
@@ -147,6 +149,88 @@ func TestCreateAbortCleanup(t *testing.T) {
147
149
  }
148
150
  }
149
151
 
152
+ // TestCleanupSalvagesAbortedWork proves a forced cleanup of an aborted batch
153
+ // never destroys wright work: committed transfers keep their branches and
154
+ // uncommitted allowlisted changes are committed onto the slice branch before
155
+ // the worktree is removed.
156
+ func TestCleanupSalvagesAbortedWork(t *testing.T) {
157
+ repo, base := setupRepo(t)
158
+ slug, batch := "demo-feature", "batch1"
159
+ lease, err := Create(CreateOpts{
160
+ Root: repo,
161
+ Slug: slug,
162
+ BatchID: batch,
163
+ BaseSHA: base,
164
+ Slices: []SlicePaths{
165
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
166
+ {ID: "slice-b", Paths: []string{"src/b.go", "src/b_new.go"}},
167
+ {ID: "slice-c", Paths: []string{"src/c.go"}},
168
+ },
169
+ })
170
+ if err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ wtA := lease.Slices[0].WorktreePath
174
+ wtB := lease.Slices[1].WorktreePath
175
+ wtC := lease.Slices[2].WorktreePath
176
+
177
+ // slice-a: committed transfer (red at review — work must survive).
178
+ tcA := commitIn(t, wtA, "src/a.go", "A")
179
+ // slice-b: uncommitted WIP — modified tracked file plus a new untracked
180
+ // file inside the allowlist.
181
+ if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc Bwip() {}\n"), 0o644); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if err := os.WriteFile(filepath.Join(wtB, "src", "b_new.go"), []byte("package main\n\nfunc Bnew() {}\n"), 0o644); err != nil {
185
+ t.Fatal(err)
186
+ }
187
+ // slice-c: untouched — nothing to salvage, branch should be deleted.
188
+
189
+ if _, err := Abort(repo, slug); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ salvaged, err := Cleanup(repo, slug, true)
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+
197
+ bySlice := map[string]Salvage{}
198
+ for _, s := range salvaged {
199
+ bySlice[s.SliceID] = s
200
+ }
201
+ if got := bySlice["slice-a"].Commit; got != tcA {
202
+ t.Fatalf("slice-a salvage commit %s want transfer %s", got, tcA)
203
+ }
204
+ sb := bySlice["slice-b"]
205
+ if sb.Branch == "" || sb.Commit == "" || sb.Commit == base {
206
+ t.Fatalf("slice-b should salvage WIP onto its branch, got %+v", sb)
207
+ }
208
+ if _, ok := bySlice["slice-c"]; ok {
209
+ t.Fatalf("slice-c carried no work; its branch should be deleted, got %+v", bySlice["slice-c"])
210
+ }
211
+
212
+ // The salvaged WIP commit actually contains the uncommitted changes.
213
+ if names := gitOk(t, repo, "diff", "--name-only", base, sb.Commit); !strings.Contains(names, "src/b.go") || !strings.Contains(names, "src/b_new.go") {
214
+ t.Fatalf("salvage commit missing WIP paths: %s", names)
215
+ }
216
+ if body := gitOk(t, repo, "show", sb.Commit+":src/b_new.go"); !strings.Contains(body, "Bnew") {
217
+ t.Fatalf("salvaged file content missing: %q", body)
218
+ }
219
+
220
+ // Worktrees removed, lease cleared, kept branches still resolve.
221
+ for _, wt := range []string{wtA, wtB, wtC} {
222
+ if _, err := os.Stat(wt); !os.IsNotExist(err) {
223
+ t.Fatalf("cleanup should remove worktree %s", wt)
224
+ }
225
+ }
226
+ leasePath, _ := LeasePath(repo, slug)
227
+ if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
228
+ t.Fatalf("cleanup should clear lease")
229
+ }
230
+ gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+bySlice["slice-a"].Branch)
231
+ gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+sb.Branch)
232
+ }
233
+
150
234
  func TestCreateAcceptsFourSlices(t *testing.T) {
151
235
  t.Parallel()
152
236
  repo, _ := setupRepo(t)
@@ -322,13 +406,20 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
322
406
  if head != tip {
323
407
  t.Fatalf("control head %s want tip %s", head, tip)
324
408
  }
409
+ if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
410
+ t.Fatalf("control should gain exactly one squash commit, got %s", n)
411
+ }
412
+ subj := gitOk(t, repo, "log", "-1", "--format=%s")
413
+ if !strings.HasPrefix(subj, "WIP(demo-feature):") {
414
+ t.Fatalf("squash subject %q missing WIP(demo-feature): prefix", subj)
415
+ }
325
416
  a := mustRead(t, filepath.Join(repo, "src", "a.go"))
326
417
  b := mustRead(t, filepath.Join(repo, "src", "b.go"))
327
418
  if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
328
419
  t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
329
420
  }
330
421
 
331
- if err := Cleanup(repo, slug, false); err != nil {
422
+ if _, err := Cleanup(repo, slug, false); err != nil {
332
423
  t.Fatal(err)
333
424
  }
334
425
  if _, err := os.Stat(wtA); !os.IsNotExist(err) {
@@ -336,6 +427,138 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
336
427
  }
337
428
  }
338
429
 
430
+ // TestIntegrateWithoutApplyKeepsLeaseRunning proves a flag-less integrate
431
+ // stages the squash commit on the integrate branch but leaves the lease
432
+ // running and control at base, so a later non-forced cleanup cannot discard
433
+ // the only refs holding the integrated tree.
434
+ func TestIntegrateWithoutApplyKeepsLeaseRunning(t *testing.T) {
435
+ repo, base := setupRepo(t)
436
+ slug, batch := "demo-feature", "batch1"
437
+ greenBatch(t, repo, base, slug, batch)
438
+
439
+ tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug})
440
+ if err != nil {
441
+ t.Fatal(err)
442
+ }
443
+ if tip == "" || tip == base {
444
+ t.Fatalf("expected staged squash tip, got %s", tip)
445
+ }
446
+ if lease.Status != StatusRunning {
447
+ t.Fatalf("status=%s want running without --apply-to-control", lease.Status)
448
+ }
449
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
450
+ t.Fatalf("control moved without apply: %s", head)
451
+ }
452
+ ibranch := IntegrateBranchName(slug, batch)
453
+ if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
454
+ t.Fatalf("integrate branch %s want squash tip %s", got, tip)
455
+ }
456
+ if _, err := Cleanup(repo, slug, false); err == nil {
457
+ t.Fatal("cleanup on running lease must refuse without --force")
458
+ }
459
+ // Forced cleanup salvages then removes; the integrate branch holds the
460
+ // squash tip, so it is kept and reported rather than deleted.
461
+ if _, err := Cleanup(repo, slug, true); err != nil {
462
+ t.Fatal(err)
463
+ }
464
+ if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
465
+ t.Fatalf("forced cleanup dropped integrate branch: %s want %s", got, tip)
466
+ }
467
+ }
468
+
469
+ // TestIntegrateFailedLeaseRepairsAndRetries proves integrate-failed is not a
470
+ // dead end: a repaired transfer re-records on the failed lease and a retried
471
+ // integrate completes into one squash commit on control.
472
+ func TestIntegrateFailedLeaseRepairsAndRetries(t *testing.T) {
473
+ repo, base := setupRepo(t)
474
+ slug, batch := "demo-feature", "batch1"
475
+ greenBatch(t, repo, base, slug, batch)
476
+ realTC := gitOk(t, repo, "rev-parse", WorkerBranch(slug, batch, "slice-a"))
477
+
478
+ leasePath, err := LeasePath(repo, slug)
479
+ if err != nil {
480
+ t.Fatal(err)
481
+ }
482
+ bad, err := ReadLease(leasePath)
483
+ if err != nil {
484
+ t.Fatal(err)
485
+ }
486
+ bad.Slices[0].TransferCommit = strings.Repeat("deadbeef", 5)
487
+ if err := WriteLease(leasePath, bad); err != nil {
488
+ t.Fatal(err)
489
+ }
490
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
491
+ t.Fatal("expected integrate failure")
492
+ }
493
+ onDisk, err := ReadLease(leasePath)
494
+ if err != nil {
495
+ t.Fatal(err)
496
+ }
497
+ if onDisk.Status != StatusIntegrateFailed {
498
+ t.Fatalf("status=%s want integrate-failed", onDisk.Status)
499
+ }
500
+
501
+ if _, err := RecordGreen(repo, slug, "slice-a", realTC); err != nil {
502
+ t.Fatalf("record-green on integrate-failed lease: %v", err)
503
+ }
504
+ if _, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
505
+ t.Fatalf("integrate retry: %v", err)
506
+ } else if lease.Status != StatusComplete {
507
+ t.Fatalf("status=%s want complete", lease.Status)
508
+ }
509
+ if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
510
+ t.Fatalf("expected one squash commit on control, got %s", n)
511
+ }
512
+ }
513
+
514
+ // TestIntegrateRefusesDirtyControlOnSlicePaths proves --apply-to-control
515
+ // neither absorbs nor clobbers uncommitted user work on slice paths: overlap
516
+ // refuses with the lease still running, while unrelated dirty files are left
517
+ // alone and the FF proceeds.
518
+ func TestIntegrateRefusesDirtyControlOnSlicePaths(t *testing.T) {
519
+ repo, base := setupRepo(t)
520
+ slug, batch := "demo-feature", "batch1"
521
+ greenBatch(t, repo, base, slug, batch)
522
+
523
+ if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
524
+ t.Fatal(err)
525
+ }
526
+ userWIP := filepath.Join(repo, "src", "a.go")
527
+ if err := os.WriteFile(userWIP, []byte("user wip\n"), 0o644); err != nil {
528
+ t.Fatal(err)
529
+ }
530
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
531
+ t.Fatal("expected dirty-control refusal")
532
+ }
533
+ leasePath, err := LeasePath(repo, slug)
534
+ if err != nil {
535
+ t.Fatal(err)
536
+ }
537
+ lease, err := ReadLease(leasePath)
538
+ if err != nil {
539
+ t.Fatal(err)
540
+ }
541
+ if lease.Status != StatusRunning {
542
+ t.Fatalf("lease status=%s want running after precondition refusal", lease.Status)
543
+ }
544
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
545
+ t.Fatalf("control moved despite refusal: %s", head)
546
+ }
547
+
548
+ // Unrelated uncommitted work is not part of the batch and must survive.
549
+ gitOk(t, repo, "checkout", "--", "src/a.go")
550
+ keep := filepath.Join(repo, "unrelated.txt")
551
+ if err := os.WriteFile(keep, []byte("keep me\n"), 0o644); err != nil {
552
+ t.Fatal(err)
553
+ }
554
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
555
+ t.Fatalf("integrate with non-overlapping dirty file: %v", err)
556
+ }
557
+ if b, err := os.ReadFile(keep); err != nil || string(b) != "keep me\n" {
558
+ t.Fatalf("unrelated dirty file lost: err=%v body=%q", err, b)
559
+ }
560
+ }
561
+
339
562
  func mustRead(t *testing.T, path string) string {
340
563
  t.Helper()
341
564
  b, err := os.ReadFile(path)
@@ -68,15 +68,30 @@ func validateSlicePaths(paths []string, label, root string) ([]string, error) {
68
68
  if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
69
69
  return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
70
70
  }
71
+ // Same for git internals: a transfer commit can never carry .git/**
72
+ // anyway, and inside a worktree .git is a file, so claiming it is
73
+ // pure footgun.
74
+ if path == ".git" || strings.HasPrefix(path, ".git/") {
75
+ return nil, fmt.Errorf("%s: path must not include .git: %q", label, path)
76
+ }
71
77
  if _, ok := seen[path]; ok {
72
78
  return nil, fmt.Errorf("%s: duplicate path %q", label, path)
73
79
  }
74
80
  seen[path] = struct{}{}
75
81
  normalized = append(normalized, path)
76
82
  if root != "" {
77
- full := filepath.Join(root, filepath.FromSlash(path))
78
- if info, err := os.Lstat(full); err == nil && info.Mode()&os.ModeSymlink != 0 {
79
- return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
83
+ // Check every component, not just the leaf: a symlinked directory
84
+ // in the chain would let a worktree write outside itself.
85
+ cur := root
86
+ for _, seg := range strings.Split(path, "/") {
87
+ cur = filepath.Join(cur, seg)
88
+ info, err := os.Lstat(cur)
89
+ if err != nil {
90
+ break // missing components are fine; git creates them
91
+ }
92
+ if info.Mode()&os.ModeSymlink != 0 {
93
+ return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
94
+ }
80
95
  }
81
96
  }
82
97
  }
@@ -87,6 +87,27 @@ func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
87
87
  }
88
88
  }
89
89
 
90
+ // A symlinked directory inside a claimed path must also be rejected: the
91
+ // leaf Lstat alone misses it, and a worktree would then write outside itself.
92
+ func TestCheckPathDisjointSymlinkedDir(t *testing.T) {
93
+ t.Parallel()
94
+ dir := t.TempDir()
95
+ target := filepath.Join(dir, "outside")
96
+ if err := os.MkdirAll(target, 0o755); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if err := os.Symlink(target, filepath.Join(dir, "linkdir")); err != nil {
100
+ t.Skip("symlinks unavailable")
101
+ }
102
+ _, err := CheckPathDisjoint([]SlicePaths{
103
+ {ID: "a", Paths: []string{"linkdir/x.go"}},
104
+ {ID: "b", Paths: []string{"other.go"}},
105
+ }, dir)
106
+ if err == nil || !strings.Contains(err.Error(), "symlink") {
107
+ t.Fatalf("expected symlink error for symlinked dir, got %v", err)
108
+ }
109
+ }
110
+
90
111
  func TestParseSlicesJSONShapes(t *testing.T) {
91
112
  t.Parallel()
92
113
  a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
@@ -60,6 +60,14 @@ principle-exception questions. List them for the root.
60
60
  re-dispatches the rest. A finding blocked on human-owned information goes in
61
61
  `blocked_findings` with the needed input named — never `remaining_findings`,
62
62
  which is reserved for what another dispatch can complete.
63
+ - In `repair`, before returning, self-attack the delta: probe every clause the
64
+ hunks touch for the attack classes Vet's reviewers hunt — race/observation
65
+ windows, identity/alias escapes, durability/ordering gaps,
66
+ platform-capability gaps, unbounded blocking, happy-path-only coverage.
67
+ Close each real exposure inside the same delta or return it in
68
+ `self_attack_exposures` with its bound; never silently drop one. This
69
+ narrows the next Vet round; it never replaces independent review and never
70
+ self-approves the repair.
63
71
 
64
72
  ## Output format
65
73
 
@@ -77,6 +85,7 @@ technical_decisions: []
77
85
  human_owned_choices: []
78
86
  validation_commands: []
79
87
  applicability_disposition: []
88
+ self_attack_exposures: []
80
89
  ```
81
90
 
82
91
  The root validates the cross-file bundle and persists accepted content.
@@ -104,7 +104,10 @@ criterion, slice, or proof.
104
104
  - `/rite-vet` may request one initial pass and one narrow recheck per correction/fingerprint.
105
105
  - Label each finding **Critical / Important / Suggestion / Nit / FYI** and include
106
106
  the relevant plan or task section, confidence score, and a concrete fix. Do not
107
- pad the report with praise.
107
+ pad the report with praise. In a recheck, also mark each finding
108
+ `new_failure_mode` or name the already-pinned clause/fingerprint it refines —
109
+ the packet lists prior repairs; the mark makes alias-detection and
110
+ `convergence_pressure` eligibility mechanical.
108
111
  - When a dimension has no issue, say "strong: <why>" instead of inventing a
109
112
  finding.
110
113
  - If a claim cannot be verified, such as blast radius without an index, say so and
@@ -51,7 +51,9 @@ Before standing the interface, run `devrites-doubt`.
51
51
  ## Done when
52
52
  The contract is complete only when **every** field carries a type + optionality + unit,
53
53
  **every** success and error status code is enumerated with its error-body shape, the
54
- `devrites-doubt` verdict is accept (on reject: revise the contract and re-doubt), and the
54
+ `devrites-doubt` verdict is accept (on reject: revise the contract and re-doubt under
55
+ `devrites-doubt`'s per-fingerprint bounds and distinct-findings ceiling — a contract
56
+ that resists repair becomes a blocker or human gate, not an endless revise loop), and the
55
57
  contract + rationale are recorded in `decisions.md`. A contract that pins only the
56
58
  happy-path shape is not done.
57
59
 
@@ -27,7 +27,7 @@ code · claiming "this is safe", "this scales", or "this matches the spec".
27
27
  issues but classify **zero** as actionable, the review is too agreeable. Sharpen the
28
28
  prompt or use a fresh reviewer. Accept a clean pass only after a genuine attempt to
29
29
  disprove the claim.
30
- - [ ] **5. STOP**: met a stop condition (only trivial findings, 3 cycles done, or user override). Emit a **binary gate verdict** the orchestrator must clear: **accept** (no valid-&-actionable findings remain) or **reject + the specific required changes**. On reject, the orchestrator loops the wright on those changes before the slice is accepted. Still reject after the 3-cycle cap classify by decision ownership: human-owned product/risk uncertainty escalates; objective required changes become a technical blocker, never a retry-authorization question.
30
+ - [ ] **5. STOP**: met a stop condition (only trivial findings, no open fingerprint has budget left, the distinct-findings ceiling, or user override). Emit a **binary gate verdict** the orchestrator must clear: **accept** (no valid-&-actionable findings remain) or **reject + the specific required changes**. On reject, the orchestrator loops the wright on those changes before the slice is accepted. Account per finding, not per round: a reject citing a genuinely new defect is progress and the loop continues; a reject re-citing a fingerprint the wright already failed on is no-progress and burns that fingerprint's budget. A finding that only refines a clause a prior cycle already pinned may close as an explicit bounded declared residual. Ceiling: three distinct actionable findings on one decision mean the decision resists repair — classify by decision ownership: human-owned product/risk uncertainty escalates; objective required changes become a technical blocker, never a retry-authorization question.
31
31
 
32
32
  ## Deletion-test lens (for "is this abstraction load-bearing?" doubts)
33
33
 
@@ -72,7 +72,8 @@ real time. Map the verdict to a `questions.md` entry instead of a synchronous pr
72
72
  `questions.md` entry with `gate: blocking`, set `state.md` `Status: awaiting_human`,
73
73
  fire the `notify:` hook, and STOP. AFK never silently accepts irreversible risk.
74
74
 
75
- The 3-loop limit still applies. After 3 cycles, human-owned uncertainty becomes a blocking
75
+ The same bounds apply in AFK. Once no open fingerprint has budget left or the
76
+ distinct-findings ceiling trips, human-owned uncertainty becomes a blocking
76
77
  question; an unresolved objective technical finding becomes `Status: blocked` with its exact
77
78
  required changes and `/rite-plan unblock`, regardless of AFK config.
78
79