devrites 5.2.4 → 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 (33) hide show
  1. package/CHANGELOG.md +6 -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/skills/rite-autocomplete/SKILL.md +2 -3
  14. package/pack/.claude/skills/rite-build/SKILL.md +7 -3
  15. package/pack/.claude/skills/rite-build/reference/checkpoint.md +13 -15
  16. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
  17. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +75 -20
  18. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
  19. package/pack/generated/claude/skills/rite-build/SKILL.md +7 -3
  20. package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +13 -15
  21. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
  22. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +75 -20
  23. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
  24. package/pack/generated/codex/skills/rite-build/SKILL.md +7 -3
  25. package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +13 -15
  26. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +2 -2
  27. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +75 -20
  28. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
  29. package/pack/generated/omp/skills/rite-build/SKILL.md +7 -3
  30. package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +13 -15
  31. package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +2 -2
  32. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +75 -20
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [5.3.0](https://github.com/ViktorsBaikers/DevRites/compare/v5.2.4...v5.3.0) (2026-09-13)
6
+
7
+ ### Added
8
+
9
+ * **rite:** preserve parallel batch work, harden lease lifecycle ([#66](https://github.com/ViktorsBaikers/DevRites/issues/66)) ([f8d3e2e](https://github.com/ViktorsBaikers/DevRites/commit/f8d3e2e9870b2adaa11da0384f2b0ff23ee8785b))
10
+
5
11
  ## [5.2.4](https://github.com/ViktorsBaikers/DevRites/compare/v5.2.3...v5.2.4) (2026-09-12)
6
12
 
7
13
  ### Fixed
package/README.md CHANGED
@@ -33,7 +33,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
33
33
  Unattended runs may create local WIP checkpoint commits along the way, but they
34
34
  remain local unless Ship's disclosed plan includes an approved remote action.
35
35
 
36
- **Status:** [`v5.2.4`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v5.2.4): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
36
+ **Status:** [`v5.3.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v5.3.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
37
37
 
38
38
  This is the latest published release; `main` may contain unreleased work.
39
39
 
@@ -115,5 +115,8 @@ func (r *runner) uninstall() error {
115
115
  if exists(filepath.Join(r.target, ".devrites", "ACTIVE")) {
116
116
  fmt.Fprintln(r.opts.Stdout, " kept .devrites/ACTIVE (active-feature cursor)")
117
117
  }
118
+ if exists(filepath.Join(r.target, ".scratch", "parallel-wt")) {
119
+ fmt.Fprintln(r.opts.Stdout, " kept .scratch/parallel-wt/ (live parallel worktrees — inspect before removing)")
120
+ }
118
121
  return nil
119
122
  }
@@ -140,6 +140,12 @@ func fail(stderr io.Writer, msg string, code int) int {
140
140
  return code
141
141
  }
142
142
 
143
+ // oneLine flattens an operator-supplied answer to a single line so it cannot
144
+ // corrupt the questions.md field structure (answer: is a one-line field).
145
+ func oneLine(s string) string {
146
+ return strings.Join(strings.Fields(s), " ")
147
+ }
148
+
143
149
  func isDir(path string) bool {
144
150
  info, err := os.Stat(path)
145
151
  return err == nil && info.IsDir()
@@ -195,6 +201,7 @@ func resolveQuestion(qfile, qid, status, answer string, stderr io.Writer) int {
195
201
  return fail(stderr, "questions.md missing at "+qfile, 2)
196
202
  }
197
203
  ts := nowUTC()
204
+ answer = oneLine(answer)
198
205
  target := regexp.MustCompile(`^## ` + regexp.QuoteMeta(qid) + `([[:space:]]|$)`)
199
206
 
200
207
  updated, found, notOpen := rewriteQuestionFields(splitLinesNoTrailing(data), target, status, answer, ts)
@@ -113,8 +113,8 @@ func parallelUsage() string {
113
113
  Subcommands:
114
114
  create --root --slug --batch --base --json
115
115
  record-green --root --slug --slice --commit
116
- abort --root --slug [--force]
117
- integrate --root --slug [--apply-to-control] [--force]
116
+ abort --root --slug
117
+ integrate --root --slug [--apply-to-control]
118
118
  cleanup --root --slug [--force]
119
119
  status --root --slug
120
120
  lease-write --root --slug --json
@@ -203,7 +203,11 @@ func parseFlags(args []string, stderr io.Writer) (flagSet, []string, int) {
203
203
  case "--force":
204
204
  f.Force = true
205
205
  default:
206
- return f, args[i:], ExitOK
206
+ // No parallel subcommand takes positional args; anything
207
+ // unrecognized (typo'd flag, stray word) is a usage error, not
208
+ // something to silently drop.
209
+ fmt.Fprintf(stderr, "parallel: unknown argument %q\n", a)
210
+ return f, nil, ExitUsage
207
211
  }
208
212
  i++
209
213
  }
@@ -237,6 +241,9 @@ func cmdCreate(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
237
241
  if code := requireRootSlug(f, stderr); code != ExitOK {
238
242
  return code
239
243
  }
244
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
245
+ return code
246
+ }
240
247
  if f.Batch == "" || f.Base == "" || f.JSON == "" {
241
248
  fmt.Fprintln(stderr, "usage: parallel create --root --slug --batch --base --json")
242
249
  return ExitUsage
@@ -267,6 +274,21 @@ func cmdCreate(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
267
274
  return ExitOK
268
275
  }
269
276
 
277
+ // rejectUnsupportedFlags reports flags a subcommand parses but must not
278
+ // silently ignore: --force only exists for cleanup, --apply-to-control only
279
+ // for integrate.
280
+ func rejectUnsupportedFlags(f flagSet, stderr io.Writer, force, applyToControl bool) int {
281
+ if f.Force && !force {
282
+ fmt.Fprintln(stderr, "parallel: --force is only supported by cleanup")
283
+ return ExitUsage
284
+ }
285
+ if f.ApplyToControl && !applyToControl {
286
+ fmt.Fprintln(stderr, "parallel: --apply-to-control is only supported by integrate")
287
+ return ExitUsage
288
+ }
289
+ return ExitOK
290
+ }
291
+
270
292
  func cmdRecordGreen(args []string, stdout, stderr io.Writer) int {
271
293
  f, _, code := parseFlags(args, stderr)
272
294
  if code != ExitOK {
@@ -275,6 +297,9 @@ func cmdRecordGreen(args []string, stdout, stderr io.Writer) int {
275
297
  if code := requireRootSlug(f, stderr); code != ExitOK {
276
298
  return code
277
299
  }
300
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
301
+ return code
302
+ }
278
303
  if f.Slice == "" || f.Commit == "" {
279
304
  fmt.Fprintln(stderr, "usage: parallel record-green --root --slug --slice --commit")
280
305
  return ExitUsage
@@ -296,7 +321,10 @@ func cmdAbort(args []string, stdout, stderr io.Writer) int {
296
321
  if code := requireRootSlug(f, stderr); code != ExitOK {
297
322
  return code
298
323
  }
299
- lease, err := Abort(f.Root, f.Slug, f.Force)
324
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
325
+ return code
326
+ }
327
+ lease, err := Abort(f.Root, f.Slug)
300
328
  if err != nil {
301
329
  fmt.Fprintf(stderr, "parallel abort: %v\n", err)
302
330
  return ExitBlocked
@@ -313,11 +341,13 @@ func cmdIntegrate(args []string, stdout, stderr io.Writer) int {
313
341
  if code := requireRootSlug(f, stderr); code != ExitOK {
314
342
  return code
315
343
  }
344
+ if code := rejectUnsupportedFlags(f, stderr, false, true); code != ExitOK {
345
+ return code
346
+ }
316
347
  tip, lease, err := Integrate(IntegrateOpts{
317
348
  Root: f.Root,
318
349
  Slug: f.Slug,
319
350
  ApplyToControl: f.ApplyToControl,
320
- Force: f.Force,
321
351
  })
322
352
  if err != nil {
323
353
  fmt.Fprintf(stderr, "parallel integrate: %v\n", err)
@@ -335,10 +365,21 @@ func cmdCleanup(args []string, stdout, stderr io.Writer) int {
335
365
  if code := requireRootSlug(f, stderr); code != ExitOK {
336
366
  return code
337
367
  }
338
- if err := Cleanup(f.Root, f.Slug, f.Force); err != nil {
368
+ if code := rejectUnsupportedFlags(f, stderr, true, false); code != ExitOK {
369
+ return code
370
+ }
371
+ salvaged, err := Cleanup(f.Root, f.Slug, f.Force)
372
+ if err != nil {
339
373
  fmt.Fprintf(stderr, "parallel cleanup: %v\n", err)
340
374
  return ExitBlocked
341
375
  }
376
+ for _, s := range salvaged {
377
+ fmt.Fprintf(stdout, "salvaged: slice=%s branch=%s commit=%s", s.SliceID, s.Branch, s.Commit)
378
+ if s.Worktree != "" {
379
+ fmt.Fprintf(stdout, " worktree=%s(kept)", s.Worktree)
380
+ }
381
+ fmt.Fprintln(stdout)
382
+ }
342
383
  fmt.Fprintln(stdout, "cleanup: done")
343
384
  return ExitOK
344
385
  }
@@ -351,6 +392,9 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int {
351
392
  if code := requireRootSlug(f, stderr); code != ExitOK {
352
393
  return code
353
394
  }
395
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
396
+ return code
397
+ }
354
398
  text, err := StatusReport(f.Root, f.Slug)
355
399
  if err != nil {
356
400
  fmt.Fprintf(stderr, "parallel status: %v\n", err)
@@ -385,12 +429,16 @@ func cmdLeaseWrite(args []string, stdin io.Reader, stdout, stderr io.Writer) int
385
429
  if lease.CreatedAt == "" {
386
430
  lease.CreatedAt = NowUTC()
387
431
  }
432
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
433
+ return code
434
+ }
388
435
  path, err := LeasePath(f.Root, f.Slug)
389
436
  if err != nil {
390
437
  fmt.Fprintf(stderr, "parallel lease-write: %v\n", err)
391
438
  return ExitUsage
392
439
  }
393
- if err := WriteLease(path, &lease); err != nil {
440
+ err = withLeaseLock(f.Root, f.Slug, func() error { return WriteLease(path, &lease) })
441
+ if err != nil {
394
442
  fmt.Fprintf(stderr, "parallel lease-write: %v\n", err)
395
443
  return ExitBlocked
396
444
  }
@@ -406,6 +454,9 @@ func cmdLeaseRead(args []string, stdout, stderr io.Writer) int {
406
454
  if code := requireRootSlug(f, stderr); code != ExitOK {
407
455
  return code
408
456
  }
457
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
458
+ return code
459
+ }
409
460
  path, err := LeasePath(f.Root, f.Slug)
410
461
  if err != nil {
411
462
  fmt.Fprintf(stderr, "parallel lease-read: %v\n", err)
@@ -454,12 +505,15 @@ func cmdLeaseClear(args []string, stdout, stderr io.Writer) int {
454
505
  if code := requireRootSlug(f, stderr); code != ExitOK {
455
506
  return code
456
507
  }
508
+ if code := rejectUnsupportedFlags(f, stderr, false, false); code != ExitOK {
509
+ return code
510
+ }
457
511
  path, err := LeasePath(f.Root, f.Slug)
458
512
  if err != nil {
459
513
  fmt.Fprintf(stderr, "parallel lease-clear: %v\n", err)
460
514
  return ExitUsage
461
515
  }
462
- if err := ClearLease(path); err != nil {
516
+ if err := withLeaseLock(f.Root, f.Slug, func() error { return ClearLease(path) }); err != nil {
463
517
  fmt.Fprintf(stderr, "parallel lease-clear: %v\n", err)
464
518
  return ExitBlocked
465
519
  }
@@ -7,6 +7,7 @@ import (
7
7
  "fmt"
8
8
  "os"
9
9
  "os/exec"
10
+ "path/filepath"
10
11
  "strings"
11
12
  "time"
12
13
 
@@ -120,19 +121,57 @@ func headSHA(repo string) (string, error) {
120
121
  return revParse(repo, "HEAD")
121
122
  }
122
123
 
123
- func resetHard(repo, sha string) error {
124
- _, err := git(repo, "reset", "--hard", sha)
125
- return err
124
+ // ensureScratchExcluded lists the parallel worktree root in .git/info/exclude
125
+ // so control `git status`/`git add -A` never sees nested worker worktrees.
126
+ // Best-effort: an unwritable exclude file must not fail create.
127
+ func ensureScratchExcluded(repo string) {
128
+ gitDir, err := git(repo, "rev-parse", "--git-common-dir")
129
+ if err != nil {
130
+ return
131
+ }
132
+ if !filepath.IsAbs(gitDir) {
133
+ gitDir = filepath.Join(repo, gitDir)
134
+ }
135
+ excludePath := filepath.Join(gitDir, "info", "exclude")
136
+ const line = ".scratch/parallel-wt/"
137
+ // #nosec G304 -- exclude file inside the repo's own resolved git dir
138
+ if data, err := os.ReadFile(excludePath); err == nil && strings.Contains(string(data), line) {
139
+ return
140
+ }
141
+ if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
142
+ return
143
+ }
144
+ // #nosec G304 -- exclude file inside the repo's own resolved git dir
145
+ f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
146
+ if err != nil {
147
+ return
148
+ }
149
+ defer func() { _ = f.Close() }()
150
+ _, _ = fmt.Fprintln(f, line)
126
151
  }
127
152
 
128
- func porcelainDirty(repo string) (bool, error) {
129
- out, err := git(repo, "status", "--porcelain", "--untracked-files=no")
153
+ func porcelainDirtyPaths(repo string, paths []string) (bool, error) {
154
+ args := append([]string{"status", "--porcelain", "--untracked-files=all", "--"}, paths...)
155
+ out, err := git(repo, args...)
130
156
  if err != nil {
131
157
  return false, err
132
158
  }
133
159
  return out != "", nil
134
160
  }
135
161
 
162
+ // stagePathsAndCommit commits every change under paths (modifications,
163
+ // deletions, and untracked files) and returns the new HEAD sha.
164
+ func stagePathsAndCommit(repo, msg string, paths []string) (string, error) {
165
+ addArgs := append([]string{"add", "-A", "--ignore-errors", "--"}, paths...)
166
+ if _, err := git(repo, addArgs...); err != nil {
167
+ return "", err
168
+ }
169
+ if _, err := git(repo, "commit", "-m", msg); err != nil {
170
+ return "", err
171
+ }
172
+ return headSHA(repo)
173
+ }
174
+
136
175
  func branchExists(repo, branch string) (bool, error) {
137
176
  _, err := git(repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
138
177
  if err == nil {
@@ -172,8 +211,8 @@ func mergeFFOnly(repo, commit string) error {
172
211
  return err
173
212
  }
174
213
 
175
- func cherryPickRange(repo, fromExclusive, toInclusive string) error {
176
- _, err := git(repo, "cherry-pick", "-x", fromExclusive+".."+toInclusive)
214
+ func cherryPickNoCommit(repo, fromExclusive, toInclusive string) error {
215
+ _, err := git(repo, "cherry-pick", "-n", fromExclusive+".."+toInclusive)
177
216
  return err
178
217
  }
179
218
 
@@ -73,6 +73,13 @@ func IntegrateBranchName(slug, batchID string) string {
73
73
  return fmt.Sprintf("devrites/parallel/%s/%s/integrate", slug, batchID)
74
74
  }
75
75
 
76
+ // ownedBranchName reports whether branch is inside this batch's namespace —
77
+ // devrites/parallel/<slug>/<batch>/…. Cleanup deletes only owned branches;
78
+ // a lease naming anything else (e.g. a forged "main") is left alone.
79
+ func ownedBranchName(slug, batchID, branch string) bool {
80
+ return strings.HasPrefix(branch, fmt.Sprintf("devrites/parallel/%s/%s/", slug, batchID))
81
+ }
82
+
76
83
  func WorkerWorktreePath(repoRoot, batchID, sliceID string) string {
77
84
  return filepath.Join(ScratchRoot(repoRoot), batchID, sliceID)
78
85
  }