devrites 7.3.2 → 7.3.4

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 (69) hide show
  1. package/CHANGELOG.md +41 -2
  2. package/README.md +21 -1
  3. package/docs/cli.md +98 -13
  4. package/docs/command-map.md +2 -2
  5. package/docs/engine/commands.md +10 -5
  6. package/docs/engine/workspace-schema.md +4 -3
  7. package/engine/commands.go +1 -1
  8. package/engine/help.go +3 -1
  9. package/engine/internal/hostpack/hostpack.go +1 -1
  10. package/engine/internal/lib/apply_bundle.go +25 -8
  11. package/engine/internal/lib/continuation.go +33 -4
  12. package/engine/internal/lib/dispatch.go +46 -31
  13. package/engine/internal/lib/frontier.go +808 -0
  14. package/engine/internal/lib/handoff.go +2 -5
  15. package/engine/internal/lib/next.go +10 -0
  16. package/engine/internal/lib/regression.go +0 -5
  17. package/engine/internal/lib/resolve.go +33 -16
  18. package/engine/internal/parallel/cli.go +16 -5
  19. package/engine/internal/parallel/ops.go +7 -5
  20. package/engine/internal/parallel/repository.go +13 -0
  21. package/engine/internal/release/release.go +96 -5
  22. package/engine/internal/rootfacts/facts.go +11 -0
  23. package/engine/internal/state/lock_other.go +4 -0
  24. package/engine/internal/state/write.go +54 -13
  25. package/engine/main.go +6 -2
  26. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  27. package/pack/.claude/skills/rite-autocomplete/SKILL.md +10 -10
  28. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +6 -6
  29. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +7 -7
  30. package/pack/.claude/skills/rite-doctor/SKILL.md +70 -68
  31. package/pack/generated/README.md +1 -1
  32. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  33. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +10 -10
  34. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +6 -6
  35. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +7 -7
  36. package/pack/generated/claude/skills/rite-doctor/SKILL.md +70 -68
  37. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  38. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +10 -10
  39. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +6 -6
  40. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +7 -7
  41. package/pack/generated/codex/skills/rite-doctor/SKILL.md +70 -68
  42. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  43. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +10 -10
  44. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +6 -6
  45. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +7 -7
  46. package/pack/generated/devin/skills/rite-doctor/SKILL.md +70 -68
  47. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  48. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +10 -10
  49. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +6 -6
  50. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +7 -7
  51. package/pack/generated/dsh/skills/rite-doctor/SKILL.md +70 -68
  52. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  53. package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
  54. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +10 -10
  55. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +6 -6
  56. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +7 -7
  57. package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +1 -1
  58. package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
  59. package/pack/generated/pi/AGENTS.md +1 -1
  60. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
  61. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +10 -10
  62. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +6 -6
  63. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +7 -7
  64. package/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
  65. package/package.json +1 -1
  66. package/scripts/build-host-artifacts.sh +1 -1
  67. package/scripts/dsh-generate.py +5 -1
  68. package/scripts/omp-generate.sh +4 -4
  69. package/scripts/pi-generate.sh +3 -3
@@ -89,12 +89,9 @@ func RunHandoff(root string, args []string, stdout, stderr io.Writer) int {
89
89
  fmt.Fprintf(stdout, "missing_required: %s\n", strings.Join(report.MissingFiles, ", "))
90
90
  }
91
91
  fmt.Fprintf(stdout, "read_next: %s\n", strings.Join(readNext(featureDir), ", "))
92
- verdict, err := classifyContinuation(root, slug)
93
- if err != nil {
94
- fmt.Fprintf(stderr, "handoff: continuation: %v\n", err)
95
- return 2
92
+ if code := RunCheckContinuation(root, []string{slug}, stdout, stderr); code != 0 {
93
+ return code
96
94
  }
97
- writeContinuation(stdout, verdict)
98
95
  if resume {
99
96
  recordMetric(root, slug, string(report.Phase), "resume", "root", 0)
100
97
  }
@@ -73,6 +73,16 @@ func RunNext(root string, args []string, stdout, stderr io.Writer) int {
73
73
  fmt.Fprintf(stdout, "blocked: %s\n", unattributedMessage(slug, flagged))
74
74
  exit = 3
75
75
  }
76
+ if afkPresent(root) {
77
+ lines, err := FrontierLines(root, slug)
78
+ if err != nil {
79
+ fmt.Fprintf(stderr, "next: frontier: %v\n", err)
80
+ return 2
81
+ }
82
+ for _, line := range lines {
83
+ fmt.Fprintln(stdout, line)
84
+ }
85
+ }
76
86
  if report.Phase == state.PhaseBuild {
77
87
  printPlanBatch(stdout, root, slug)
78
88
  }
@@ -361,11 +361,6 @@ func updateRegressionBaseline(root, slug, baselinePath string, base regressionBa
361
361
  return 3
362
362
  }
363
363
  }
364
- // Keep the phase high-water mark: a baseline never forgets the furthest phase.
365
- if hasBaseline && base.PhaseOrdinal > current.PhaseOrdinal {
366
- current.PhaseOrdinal = base.PhaseOrdinal
367
- current.Phase = base.Phase
368
- }
369
364
  next := regressionBaseline{
370
365
  Version: 1,
371
366
  UpdatedAt: time.Now().UTC().Format(time.RFC3339),
@@ -11,7 +11,7 @@ import (
11
11
  "strings"
12
12
  "time"
13
13
 
14
- "github.com/devrites/devrites/internal/fsutil"
14
+ "github.com/devrites/devrites/internal/devritespaths"
15
15
  "github.com/devrites/devrites/internal/markdowntext"
16
16
  "github.com/devrites/devrites/internal/state"
17
17
  "github.com/devrites/devrites/internal/workflow"
@@ -25,11 +25,24 @@ import (
25
25
  // 0 resolved 2 no active workspace 3 qid not found
26
26
  // 4 qid not open 5 bad arguments
27
27
  func Resolve(root string, args []string, stdout, stderr io.Writer) int {
28
+ return resolveWithLock(root, args, stdout, stderr, state.WithFeatureLockIn)
29
+ }
30
+
31
+ func resolveWithLock(root string, args []string, stdout, stderr io.Writer, withLock func(*os.Root, string, func() error) error) int {
28
32
  slug := activeSlug(root)
29
33
  if slug == "" {
30
34
  return fail(stderr, "No active workspace. Run "+workflow.ForVerb("spec").Both()+" <feature> first.", 2)
31
35
  }
32
- work := featureDir(root, slug)
36
+ work, err := devritespaths.ExistingFeatureDirChecked(root, slug)
37
+ if err != nil {
38
+ return fail(stderr, err.Error(), 2)
39
+ }
40
+ // Pin the selected root before waiting; workspace paths may change meanwhile.
41
+ selectedRoot, err := os.OpenRoot(root)
42
+ if err != nil {
43
+ return fail(stderr, err.Error(), 2)
44
+ }
45
+ defer func() { _ = selectedRoot.Close() }()
33
46
  qfile := filepath.Join(work, "questions.md")
34
47
  sfile := filepath.Join(work, "state.md")
35
48
  if !isFile(qfile) {
@@ -79,8 +92,13 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
79
92
  }
80
93
 
81
94
  code := 0
82
- if err := state.WithFeatureLock(root, slug, func() error {
83
- code = resolveMutation(slug, mode, qid, payload, qfile, sfile, human, stdout, stderr)
95
+ if err := withLock(selectedRoot, slug, func() error {
96
+ ws, err := selectedRoot.OpenRoot(filepath.Join("work", slug))
97
+ if err != nil {
98
+ return err
99
+ }
100
+ defer func() { _ = ws.Close() }()
101
+ code = resolveMutation(ws, slug, mode, qid, payload, human, stdout, stderr, writeBundleTargets)
84
102
  return nil
85
103
  }); err != nil {
86
104
  return fail(stderr, err.Error(), 1)
@@ -88,7 +106,7 @@ func Resolve(root string, args []string, stdout, stderr io.Writer) int {
88
106
  return code
89
107
  }
90
108
 
91
- func resolveMutation(slug, mode, qid, payload, qfile, sfile string, human bool, stdout, stderr io.Writer) int {
109
+ func resolveMutation(ws *os.Root, slug, mode, qid, payload string, human bool, stdout, stderr io.Writer, publish func(*os.Root, []*bundleTarget) error) int {
92
110
  type resolution struct{ qid, status, text string }
93
111
  var entries []resolution
94
112
  switch mode {
@@ -125,15 +143,13 @@ func resolveMutation(slug, mode, qid, payload, qfile, sfile string, human bool,
125
143
  if len(entries) == 0 {
126
144
  return 0
127
145
  }
128
- // #nosec G304 -- questions.md path inside the feature workspace
129
- qdata, err := os.ReadFile(qfile)
146
+ qdata, err := ws.ReadFile("questions.md")
130
147
  if err != nil {
131
- return fail(stderr, "questions.md missing at "+qfile, 2)
148
+ return fail(stderr, fmt.Sprintf("read questions.md: %v", err), 2)
132
149
  }
133
- // #nosec G304 -- state.md path inside the feature workspace
134
- sdata, err := os.ReadFile(sfile)
150
+ sdata, err := ws.ReadFile("state.md")
135
151
  if err != nil {
136
- return fail(stderr, fmt.Sprintf("read state %s: %v", sfile, err), 1)
152
+ return fail(stderr, fmt.Sprintf("read state.md: %v", err), 1)
137
153
  }
138
154
  lines := splitLinesNoTrailing(qdata)
139
155
  nextState := sdata
@@ -147,13 +163,14 @@ func resolveMutation(slug, mode, qid, payload, qfile, sfile string, human bool,
147
163
  return fail(stderr, err.Error(), 1)
148
164
  }
149
165
  }
150
- if err := fsutil.WriteFileAtomic(qfile, joinRecords(lines), 0o644); err != nil {
151
- return fail(stderr, err.Error(), 1)
166
+ targets := []*bundleTarget{
167
+ {rel: "questions.md", before: qdata, existed: true, after: joinRecords(lines)},
152
168
  }
153
169
  if !bytes.Equal(nextState, sdata) {
154
- if err := fsutil.WriteFileAtomic(sfile, nextState, 0o644); err != nil {
155
- return fail(stderr, fmt.Sprintf("update state %s: %v", sfile, err), 1)
156
- }
170
+ targets = append(targets, &bundleTarget{rel: "state.md", before: sdata, existed: true, after: nextState})
171
+ }
172
+ if err := publish(ws, targets); err != nil {
173
+ return fail(stderr, err.Error(), 1)
157
174
  }
158
175
  switch mode {
159
176
  case "answer":
@@ -73,6 +73,10 @@ func runPathDisjoint(args []string, stdin io.Reader, stdout, stderr io.Writer) i
73
73
  return ExitOK
74
74
  }
75
75
 
76
+ // FrontierNotices exposes related work without broadening source-local selection.
77
+ // main wires it from lib to avoid an import cycle.
78
+ var FrontierNotices func(root, slug string) ([]string, error)
79
+
76
80
  func cmdSelect(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
77
81
  root := ""
78
82
  slug := ""
@@ -148,21 +152,18 @@ func cmdSelect(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
148
152
  return ExitUsage
149
153
  }
150
154
  if sawWorkspaces {
151
- order := []string{slug}
155
+ order := strings.Split(workspaces, ",")
152
156
  seen := make(map[string]bool)
153
157
  if err := validateSlug(slug); err != nil {
154
158
  fmt.Fprintf(stderr, "parallel select: %v\n", err)
155
159
  return ExitUsage
156
160
  }
157
- for _, target := range strings.Split(workspaces, ",") {
161
+ for _, target := range order {
158
162
  if err := validateSlug(target); err != nil || seen[target] {
159
163
  fmt.Fprintf(stderr, "parallel select: invalid or duplicate workspace %q\n", target)
160
164
  return ExitUsage
161
165
  }
162
166
  seen[target] = true
163
- if target != slug {
164
- order = append(order, target)
165
- }
166
167
  }
167
168
  if _, err := controllerLease(root, slug); err != nil {
168
169
  fmt.Fprintf(stderr, "parallel select: %v\n", err)
@@ -199,6 +200,16 @@ func cmdSelect(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
199
200
  slices = append(slices, ready...)
200
201
  }
201
202
  } else {
203
+ if FrontierNotices != nil {
204
+ notices, err := FrontierNotices(root, slug)
205
+ for _, notice := range notices {
206
+ fmt.Fprintln(stderr, notice)
207
+ }
208
+ if err != nil {
209
+ fmt.Fprintf(stderr, "parallel select: frontier: %v\n", err)
210
+ return ExitBlocked
211
+ }
212
+ }
202
213
  if PlanReady == nil {
203
214
  fmt.Fprintln(stderr, "parallel select: --from-plan is unavailable in this build")
204
215
  return ExitBlocked
@@ -217,8 +217,8 @@ type AddOpts struct {
217
217
  Capacity int
218
218
  }
219
219
 
220
- // Add refills freed slots of a running lease: each new slice gets a worktree
221
- // from the current control tip, under the same path-disjoint and cap rules as
220
+ // Add refills freed slots of a running, integrate-failed, or complete lease:
221
+ // each new slice gets a worktree from the current control tip, under the same path-disjoint and cap rules as
222
222
  // the slices still active. Landed (integrated) slices no longer claim paths.
223
223
  // An explicit Capacity with no Slices changes only the bound, not lease status.
224
224
  func Add(opts AddOpts) (lease *Lease, err error) {
@@ -262,8 +262,8 @@ func addLocked(opts AddOpts) (*Lease, error) {
262
262
  if opts.Capacity == 0 {
263
263
  opts.Capacity = leaseCapacity(lease)
264
264
  }
265
- if lease.Status != StatusRunning && lease.Status != StatusComplete {
266
- return nil, fmt.Errorf("add requires status=running|complete (have %s)", lease.Status)
265
+ if lease.Status != StatusRunning && lease.Status != StatusIntegrateFailed && lease.Status != StatusComplete {
266
+ return nil, fmt.Errorf("add requires status=running|integrate-failed|complete (have %s)", lease.Status)
267
267
  }
268
268
  head, err := revParse(opts.Root, expectedControl(lease))
269
269
  if err != nil {
@@ -329,7 +329,9 @@ func addLocked(opts AddOpts) (*Lease, error) {
329
329
  lease.Slices = append(lease.Slices, made...)
330
330
  lease.N = len(lease.Slices)
331
331
  lease.Capacity = opts.Capacity
332
- lease.Status = StatusRunning
332
+ if lease.Status == StatusComplete {
333
+ lease.Status = StatusRunning
334
+ }
333
335
  if err := WriteLease(leasePath, lease); err != nil {
334
336
  rollback()
335
337
  return nil, err
@@ -167,6 +167,19 @@ func controllerLease(root, controller string) (*repositoryLease, error) {
167
167
  return found, nil
168
168
  }
169
169
 
170
+ // ControllerLease returns the retained repository controller without changing its lease.
171
+ // A lease in another checkout requires recovery at that explicit control root.
172
+ func ControllerLease(repoRoot string) (string, *Lease, error) {
173
+ found, err := controllerLease(repoRoot, "")
174
+ if err != nil || found == nil {
175
+ return "", nil, err
176
+ }
177
+ if found.root != resolvedPath(repoRoot) {
178
+ return "", nil, fmt.Errorf("repository controller lease retained by %s at %s; recover at its explicit root", found.controller, found.root)
179
+ }
180
+ return found.controller, found.lease, nil
181
+ }
182
+
170
183
  // WorkspaceLeased protects both controller and source workspaces until cleanup.
171
184
  // devritesRoot is explicit; closeout holds WithControlLock through its rename.
172
185
  func WorkspaceLeased(devritesRoot, workspace string) (bool, error) {
@@ -16,10 +16,12 @@ import (
16
16
  "net/netip"
17
17
  "net/url"
18
18
  "os"
19
+ "os/exec"
19
20
  "path"
20
21
  "path/filepath"
21
22
  "regexp"
22
23
  "runtime"
24
+ "strconv"
23
25
  "strings"
24
26
  "time"
25
27
  )
@@ -162,13 +164,13 @@ func binaryNameFor(goos, goarch string) (string, error) {
162
164
  }
163
165
 
164
166
  func fetchJSON(ctx context.Context, rawURL string, out any) error {
165
- resp, err := get(ctx, rawURL)
167
+ resp, err := get(ctx, rawURL, true)
166
168
  if err != nil {
167
169
  return err
168
170
  }
169
171
  defer func() { _ = resp.Body.Close() }()
170
172
  if resp.StatusCode < 200 || resp.StatusCode > 299 {
171
- return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
173
+ return responseError(resp)
172
174
  }
173
175
  if resp.ContentLength > maxJSONBytes {
174
176
  return fmt.Errorf("JSON response exceeds %d bytes", maxJSONBytes)
@@ -228,13 +230,13 @@ func downloadVerified(ctx context.Context, rawURL, destination, filename string,
228
230
  }
229
231
 
230
232
  func download(ctx context.Context, rawURL, destination string, maxBytes int64, mode fs.FileMode) error {
231
- resp, err := get(ctx, rawURL)
233
+ resp, err := get(ctx, rawURL, false)
232
234
  if err != nil {
233
235
  return err
234
236
  }
235
237
  defer func() { _ = resp.Body.Close() }()
236
238
  if resp.StatusCode < 200 || resp.StatusCode > 299 {
237
- return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
239
+ return responseError(resp)
238
240
  }
239
241
  if resp.ContentLength > maxBytes {
240
242
  return fmt.Errorf("download exceeds %d bytes", maxBytes)
@@ -274,7 +276,7 @@ func download(ctx context.Context, rawURL, destination string, maxBytes int64, m
274
276
  return nil
275
277
  }
276
278
 
277
- func get(ctx context.Context, rawURL string) (*http.Response, error) {
279
+ func get(ctx context.Context, rawURL string, authenticate bool) (*http.Response, error) {
278
280
  req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
279
281
  if err != nil {
280
282
  return nil, fmt.Errorf("build release request: %w", err)
@@ -284,9 +286,94 @@ func get(ctx context.Context, rawURL string) (*http.Response, error) {
284
286
  }
285
287
  req.Header.Set("Accept", "application/vnd.github+json")
286
288
  req.Header.Set("User-Agent", "devrites-engine")
289
+ if authenticate && isGitHubAPI(req.URL) {
290
+ token, err := githubToken(ctx)
291
+ if err != nil {
292
+ return nil, err
293
+ }
294
+ if token != "" {
295
+ req.Header.Set("Authorization", "Bearer "+token)
296
+ }
297
+ }
287
298
  return httpClient.Do(req)
288
299
  }
289
300
 
301
+ func isGitHubAPI(u *url.URL) bool {
302
+ return u != nil && u.User == nil && strings.EqualFold(u.Scheme, "https") &&
303
+ (strings.EqualFold(u.Host, "api.github.com") || strings.EqualFold(u.Host, "api.github.com:443"))
304
+ }
305
+
306
+ // credentialOutput bounds untrusted helper output without accepting a truncated token.
307
+ type credentialOutput struct {
308
+ data [4096]byte
309
+ n int
310
+ }
311
+
312
+ func (out *credentialOutput) Write(p []byte) (int, error) {
313
+ if len(p) > len(out.data)-out.n {
314
+ return 0, errors.New("GitHub credential helper output exceeds limit")
315
+ }
316
+ out.n += copy(out.data[out.n:], p)
317
+ return len(p), nil
318
+ }
319
+
320
+ // githubToken follows gh's environment precedence without requiring gh to be installed.
321
+ func githubToken(ctx context.Context) (string, error) {
322
+ token := os.Getenv("GH_TOKEN")
323
+ if token == "" {
324
+ token = os.Getenv("GITHUB_TOKEN")
325
+ }
326
+ if token == "" {
327
+ lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
328
+ defer cancel()
329
+ cmd := exec.CommandContext(lookupCtx, "gh", "auth", "token", "--hostname", "github.com")
330
+ cmd.Env = append(os.Environ(), "GH_PROMPT_DISABLED=1", "GIT_TERMINAL_PROMPT=0")
331
+ cmd.Stderr = io.Discard
332
+ cmd.WaitDelay = 100 * time.Millisecond
333
+ var output credentialOutput
334
+ cmd.Stdout = &output
335
+ err := cmd.Run()
336
+ if ctx.Err() != nil {
337
+ return "", ctx.Err()
338
+ }
339
+ if err != nil {
340
+ return "", nil
341
+ }
342
+ token = strings.TrimSpace(string(output.data[:output.n]))
343
+ }
344
+ for _, char := range token {
345
+ if char < '!' || char > '~' {
346
+ return "", errors.New("invalid GitHub credential; check GH_TOKEN, GITHUB_TOKEN, or gh auth login --hostname github.com")
347
+ }
348
+ }
349
+ return token, nil
350
+ }
351
+
352
+ func responseError(resp *http.Response) error {
353
+ message := fmt.Sprintf("release request returned HTTP %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
354
+ switch resp.StatusCode {
355
+ case http.StatusUnauthorized, http.StatusForbidden:
356
+ message += "; check GH_TOKEN/GITHUB_TOKEN (environment tokens take precedence) or run gh auth login --hostname github.com"
357
+ case http.StatusTooManyRequests:
358
+ message += "; GitHub API rate limit reached; use GH_TOKEN/GITHUB_TOKEN or gh auth login --hostname github.com, or wait before retrying"
359
+ }
360
+ if resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0" {
361
+ message += "; GitHub API rate limit reached"
362
+ }
363
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
364
+ if reset, err := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil && reset >= 0 && reset <= 253402300799 {
365
+ message += "; rate limit resets at " + time.Unix(reset, 0).UTC().Format(time.RFC3339)
366
+ }
367
+ retry := resp.Header.Get("Retry-After")
368
+ if seconds, err := strconv.ParseUint(retry, 10, 32); err == nil {
369
+ message += fmt.Sprintf("; retry after %d seconds", seconds)
370
+ } else if date, err := http.ParseTime(retry); err == nil {
371
+ message += "; retry after " + date.UTC().Format(time.RFC3339)
372
+ }
373
+ }
374
+ return errors.New(message)
375
+ }
376
+
290
377
  func validateURL(u *url.URL) error {
291
378
  if u == nil || u.Hostname() == "" {
292
379
  return errors.New("release URL has no host")
@@ -303,6 +390,10 @@ func validateURL(u *url.URL) error {
303
390
  }
304
391
 
305
392
  func checkRedirect(req *http.Request, via []*http.Request) error {
393
+ // net/http forwards sensitive headers to subdomains; GitHub tokens must not follow.
394
+ if !isGitHubAPI(req.URL) {
395
+ req.Header.Del("Authorization")
396
+ }
306
397
  if len(via) >= 10 {
307
398
  return errors.New("stopped after 10 redirects")
308
399
  }
@@ -257,6 +257,17 @@ func inspectWorkspaceState(facts *Facts) {
257
257
  if root == "" {
258
258
  return
259
259
  }
260
+ work := filepath.Join(root, "work")
261
+ if info, err := os.Lstat(work); err == nil && info.Mode()&os.ModeSymlink != 0 && !safepath.WithinResolved(work, root) {
262
+ facts.Hazards = append(facts.Hazards, Hazard{
263
+ ID: "DRV-WORKSPACE-SYMLINK-ESCAPE",
264
+ Severity: "error",
265
+ Message: fmt.Sprintf("workspace path %q resolves outside the selected root", work),
266
+ Remediation: fmt.Sprintf("rm %s", shellQuote(work)),
267
+ Refuse: true,
268
+ })
269
+ return
270
+ }
260
271
  entries, err := os.ReadDir(filepath.Join(root, "work"))
261
272
  if err == nil {
262
273
  for _, entry := range entries {
@@ -18,6 +18,10 @@ func acquireLock(path string) (*fileLock, error) {
18
18
  if err != nil {
19
19
  return nil, fmt.Errorf("acquire lock: %w", err)
20
20
  }
21
+ return lockOpened(f, path)
22
+ }
23
+
24
+ func lockOpened(f *os.File, path string) (*fileLock, error) {
21
25
  return &fileLock{f: f}, nil
22
26
  }
23
27
 
@@ -76,25 +76,66 @@ func AppendLog(path, record string) error {
76
76
  // is released automatically even if the process dies mid-operation (crash-safe);
77
77
  // see lock_unix.go / lock_other.go.
78
78
  func WithFeatureLock(root, slug string, fn func() error) error {
79
+ if !devritespaths.ValidSlug(slug) {
80
+ return fmt.Errorf("lock feature %q: not a feature slug", slug)
81
+ }
82
+ // The caller-selected root may be new; descendants are created through it.
83
+ if err := os.MkdirAll(root, 0o755); err != nil {
84
+ return fmt.Errorf("lock feature %q: %w", slug, err)
85
+ }
86
+ selected, err := os.OpenRoot(root)
87
+ if err != nil {
88
+ return fmt.Errorf("lock feature %q: %w", slug, err)
89
+ }
90
+ defer func() { _ = selected.Close() }()
91
+ return WithFeatureLockIn(selected, slug, fn)
92
+ }
93
+
94
+ // WithFeatureLockIn locks a feature within root without following escaping
95
+ // symlinks. The caller retains ownership of root.
96
+ func WithFeatureLockIn(root *os.Root, slug string, fn func() error) error {
79
97
  if !devritespaths.ValidSlug(slug) {
80
98
  return fmt.Errorf("lock feature %q: not a feature slug", slug)
81
99
  }
82
100
  // Keep the lock outside the movable workspace; never unlink it on release.
83
- lockDir := filepath.Join(root, ".locks", "features", slug)
84
- if err := os.MkdirAll(lockDir, 0o755); err != nil {
101
+ lockDir := filepath.Join(".locks", "features", slug)
102
+ if err := root.MkdirAll(lockDir, 0o755); err != nil {
85
103
  return fmt.Errorf("lock feature %q: %w", slug, err)
86
104
  }
87
- return WithLock(filepath.Join(lockDir, ".lock"), func() error {
88
- if _, err := os.Lstat(filepath.Join(root, "archive", slug)); err == nil {
89
- return fmt.Errorf("lock feature %q: workspace is closed (archived)", slug)
90
- } else if !errors.Is(err, fs.ErrNotExist) {
91
- return fmt.Errorf("lock feature %q: %w", slug, err)
92
- }
93
- if err := os.MkdirAll(featureDir(root, slug), 0o755); err != nil {
94
- return fmt.Errorf("lock feature %q: %w", slug, err)
95
- }
96
- return fn()
97
- })
105
+ lockPath := filepath.Join(lockDir, ".lock")
106
+ // Separate atomic creation from opening a concurrent creator's lock.
107
+ f, err := root.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o644)
108
+ if errors.Is(err, fs.ErrExist) {
109
+ f, err = root.OpenFile(lockPath, os.O_RDWR, 0)
110
+ }
111
+ if err != nil {
112
+ return fmt.Errorf("lock feature %q: %w", slug, err)
113
+ }
114
+ l, err := lockOpened(f, filepath.Join(root.Name(), lockPath))
115
+ if err != nil {
116
+ return fmt.Errorf("lock feature %q: %w", slug, err)
117
+ }
118
+ defer func() { _ = l.release() }()
119
+ held, err := f.Stat()
120
+ if err != nil {
121
+ return fmt.Errorf("lock feature %q: %w", slug, err)
122
+ }
123
+ current, err := root.Lstat(lockPath)
124
+ if errors.Is(err, fs.ErrNotExist) || (err == nil && !os.SameFile(held, current)) {
125
+ return fmt.Errorf("lock feature %q: %w", slug, ErrWorkspaceMoved)
126
+ }
127
+ if err != nil {
128
+ return fmt.Errorf("lock feature %q: %w", slug, err)
129
+ }
130
+ if _, err := root.Lstat(filepath.Join("archive", slug)); err == nil {
131
+ return fmt.Errorf("lock feature %q: workspace is closed (archived)", slug)
132
+ } else if !errors.Is(err, fs.ErrNotExist) {
133
+ return fmt.Errorf("lock feature %q: %w", slug, err)
134
+ }
135
+ if err := root.MkdirAll(filepath.Join("work", slug), 0o755); err != nil {
136
+ return fmt.Errorf("lock feature %q: %w", slug, err)
137
+ }
138
+ return fn()
98
139
  }
99
140
 
100
141
  // WithLock runs fn while holding the same exclusive advisory lock as
package/engine/main.go CHANGED
@@ -26,6 +26,7 @@ Usage:
26
26
  devrites-engine install [flags] Install DevRites skills, agents, and host configuration
27
27
  devrites-engine update [flags] Update an existing DevRites install in place
28
28
  devrites-engine uninstall [flags] Remove a DevRites install, preserving runtime state
29
+ devrites-engine frontier [slug] Discover related ready work and native preparation without writes
29
30
  devrites-engine check candidate <slug> Validate and hash the closed project candidate
30
31
  devrites-engine check readiness <slug> Check required files, tasks.md graph, and Build-input binding
31
32
  devrites-engine check readiness --emit-binding <slug> Emit the stable Build-input binding for Vet
@@ -76,10 +77,10 @@ Usage:
76
77
  Resolve test/lint/vet/build commands from the repository's own wiring
77
78
  devrites-engine parallel <subcommand> Deterministic parallel worktree lease/create/integrate/prune/cleanup
78
79
  devrites-engine gates <subcommand> <slug> Machine-checked gates.md ledger: scaffold/status/run/reverify/lint/precheck/attest/abandon
79
- devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
80
+ devrites-engine state resolve <qid> "<ans>" Resolve an open question with locked, rollback-protected state updates
80
81
  devrites-engine state merge-manifest <slug> [pred...] Fold the recorded predecessor chain's manifests into the release candidate manifest
81
82
  devrites-engine state apply-bundle <slug> <bundle.json>... [--dry-run]
82
- Validate and apply plan-drafter hunk bundles atomically
83
+ Validate and apply plan-drafter hunk bundles with locked, rollback-protected updates
83
84
  devrites-engine state set <slug> <key>=<value>... [--unset <key>]
84
85
  Write state.md cursor fields atomically (known phase, no return-cursor overwrite)
85
86
  devrites-engine state append <slug> <ledger.md> --heading "<text>" --body-file <path|->
@@ -126,6 +127,7 @@ func init() {
126
127
  // parallel cannot import lib (lib imports it), so `parallel select --from-plan` gets the plan-derived ready set through this hook.
127
128
  parallel.PlanReady = lib.PlanReady
128
129
  parallel.WorkspaceReady = lib.WorkspaceReady
130
+ parallel.FrontierNotices = lib.FrontierNotices
129
131
  // `gates precheck` runs `check windows` through this hook for the same reason.
130
132
  acceptance.WindowsCheck = lib.RunCheckWindows
131
133
  // ...and `gates precheck --slice` learns which writer gates to skip.
@@ -169,6 +171,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
169
171
  return cmdOrient(root, args[1:], stdout, stderr)
170
172
  case "next":
171
173
  return lib.RunNext(root, args[1:], stdout, stderr)
174
+ case "frontier":
175
+ return lib.RunFrontier(root, args[1:], stdout, stderr)
172
176
  case "handoff":
173
177
  return lib.RunHandoff(root, args[1:], stdout, stderr)
174
178
  case "recall":