devrites 7.3.3 → 7.3.5

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 (72) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/README.md +21 -1
  3. package/docs/adr/0039-autocomplete-structural-admission.md +87 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/cli.md +182 -7
  6. package/docs/command-map.md +2 -2
  7. package/docs/engine/commands.md +53 -3
  8. package/engine/commands.go +1 -1
  9. package/engine/help.go +3 -1
  10. package/engine/internal/lib/claims.go +65 -8
  11. package/engine/internal/lib/continuation.go +38 -4
  12. package/engine/internal/lib/dispatch.go +124 -63
  13. package/engine/internal/lib/frontier.go +864 -0
  14. package/engine/internal/lib/frontier_controller.go +652 -0
  15. package/engine/internal/lib/frontier_roadmap.go +168 -0
  16. package/engine/internal/lib/handoff.go +2 -5
  17. package/engine/internal/lib/next.go +19 -0
  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 +23 -0
  21. package/engine/internal/release/release.go +96 -5
  22. package/engine/main.go +4 -0
  23. package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  24. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  25. package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  26. package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -38
  27. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +46 -52
  28. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  29. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +19 -17
  30. package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  31. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  32. package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  33. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -38
  34. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +46 -52
  35. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  36. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +19 -17
  37. package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  38. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  39. package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  40. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +33 -38
  41. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +46 -52
  42. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  43. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +19 -17
  44. package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  45. package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  46. package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  47. package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +33 -38
  48. package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +46 -52
  49. package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  50. package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +19 -17
  51. package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  52. package/pack/generated/dsh/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  53. package/pack/generated/dsh/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  54. package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +33 -38
  55. package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +46 -52
  56. package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  57. package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +19 -17
  58. package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  59. package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  60. package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  61. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +33 -38
  62. package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +46 -52
  63. package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  64. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +19 -17
  65. package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
  66. package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
  67. package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
  68. package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +33 -38
  69. package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +46 -52
  70. package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
  71. package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +19 -17
  72. package/package.json +1 -1
@@ -0,0 +1,168 @@
1
+ package lib
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "github.com/devrites/devrites/internal/devritespaths"
11
+ "github.com/devrites/devrites/internal/markdowntext"
12
+ )
13
+
14
+ // frontierCanonicalRoadmap reads current authority from the full related sequence.
15
+ // A nil map without an error leaves headerless historical maps to legacy parsing.
16
+ func frontierCanonicalRoadmap(nodes map[string]*frontierWorkspace, ordered []string) (mapping map[int]string, artifacts []string, err error) {
17
+ var owner *frontierWorkspace
18
+ var lines []string
19
+ for _, slug := range ordered {
20
+ n := nodes[slug]
21
+ if n == nil || n.dir == "" {
22
+ continue
23
+ }
24
+ path := filepath.Join(n.dir, "roadmap.md")
25
+ raw, readErr := readBoundedRegularFile(path, workspaceArtifactLimit)
26
+ if os.IsNotExist(readErr) {
27
+ continue
28
+ }
29
+ if readErr != nil {
30
+ return nil, append(artifacts, path), fmt.Errorf("roadmap %s unreadable: %w", path, readErr)
31
+ }
32
+ structural, parseErr := markdowntext.Structural(raw)
33
+ if parseErr != nil {
34
+ return nil, append(artifacts, path), fmt.Errorf("roadmap %s malformed: %w", path, parseErr)
35
+ }
36
+ candidate := strings.Split(string(structural), "\n")
37
+ explicit := false
38
+ for _, line := range candidate {
39
+ line = strings.TrimSpace(line)
40
+ if strings.HasPrefix(line, "Sequence root:") || strings.HasPrefix(line, "Decision:") {
41
+ explicit = true
42
+ break
43
+ }
44
+ }
45
+ if !explicit {
46
+ continue
47
+ }
48
+ artifacts = append(artifacts, path)
49
+ if owner != nil {
50
+ return nil, artifacts, fmt.Errorf("multiple canonical roadmap owners: %s and %s", owner.slug, slug)
51
+ }
52
+ owner, lines = n, candidate
53
+ }
54
+ if owner == nil {
55
+ return nil, nil, nil
56
+ }
57
+ root, decision := "", ""
58
+ rootCount, decisionCount := 0, 0
59
+ for _, line := range lines {
60
+ line = strings.TrimSpace(line)
61
+ if value, ok := strings.CutPrefix(line, "Sequence root:"); ok {
62
+ root, rootCount = strings.TrimSpace(value), rootCount+1
63
+ }
64
+ if value, ok := strings.CutPrefix(line, "Decision:"); ok {
65
+ decision, decisionCount = strings.TrimSpace(value), decisionCount+1
66
+ }
67
+ }
68
+ if rootCount != 1 || decisionCount != 1 || !devritespaths.ValidSlug(root) || strings.ContainsAny(root, " \t") || strings.HasPrefix(root, "-") {
69
+ return nil, artifacts, fmt.Errorf("canonical roadmap requires one valid Sequence root and Decision header")
70
+ }
71
+ if len(decision) != 7 || !strings.HasPrefix(decision, "DEC-") || strings.Trim(decision[4:], "0123456789") != "" {
72
+ return nil, artifacts, fmt.Errorf("canonical roadmap Decision must be DEC-NNN")
73
+ }
74
+ decisionPath := filepath.Join(owner.dir, "decisions.md")
75
+ artifacts = append(artifacts, decisionPath)
76
+ raw, readErr := readBoundedRegularFile(decisionPath, workspaceArtifactLimit)
77
+ if readErr != nil {
78
+ return nil, artifacts, fmt.Errorf("canonical roadmap decision unreadable: %w", readErr)
79
+ }
80
+ structural, parseErr := markdowntext.Structural(raw)
81
+ if parseErr != nil {
82
+ return nil, artifacts, fmt.Errorf("canonical roadmap decision malformed: %w", parseErr)
83
+ }
84
+ headings := 0
85
+ for _, line := range strings.Split(string(structural), "\n") {
86
+ fields := strings.Fields(line)
87
+ if len(fields) >= 2 && fields[0] == "##" && fields[1] == decision {
88
+ headings++
89
+ }
90
+ }
91
+ if headings != 1 {
92
+ return nil, artifacts, fmt.Errorf("canonical roadmap Decision %s requires one exact owner-local heading, found %d", decision, headings)
93
+ }
94
+ mapping = map[int]string{}
95
+ positions := map[string]int{}
96
+ tables := 0
97
+ for i := 0; i < len(lines); i++ {
98
+ cells := frontierCells(lines[i])
99
+ if len(cells) != 0 && (len(cells) != 2 || cells[0] != "Position" || cells[1] != "Workspace") {
100
+ return nil, artifacts, fmt.Errorf("canonical roadmap contains an unexpected table")
101
+ }
102
+ if len(cells) != 2 || cells[0] != "Position" || cells[1] != "Workspace" {
103
+ continue
104
+ }
105
+ tables++
106
+ if tables != 1 || i+1 >= len(lines) {
107
+ return nil, artifacts, fmt.Errorf("canonical roadmap requires exactly one Position/Workspace table")
108
+ }
109
+ separator := frontierCells(lines[i+1])
110
+ if len(separator) != 2 {
111
+ return nil, artifacts, fmt.Errorf("canonical roadmap table separator malformed")
112
+ }
113
+ for _, cell := range separator {
114
+ if len(strings.Trim(cell, ":")) < 3 || strings.Trim(cell, "-:") != "" {
115
+ return nil, artifacts, fmt.Errorf("canonical roadmap table separator malformed")
116
+ }
117
+ }
118
+ i += 2
119
+ for ; i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "|"); i++ {
120
+ row := frontierCells(lines[i])
121
+ if len(row) != 2 {
122
+ return nil, artifacts, fmt.Errorf("canonical roadmap table row malformed")
123
+ }
124
+ position, numberErr := strconv.Atoi(row[0])
125
+ slug := row[1]
126
+ if numberErr != nil || position != len(mapping)+1 || strconv.Itoa(position) != row[0] {
127
+ return nil, artifacts, fmt.Errorf("canonical roadmap positions must be unique and contiguous from 1")
128
+ }
129
+ if !devritespaths.ValidSlug(slug) || strings.ContainsAny(slug, " \t") || strings.HasPrefix(slug, "-") || positions[slug] != 0 {
130
+ return nil, artifacts, fmt.Errorf("canonical roadmap requires unique valid workspace slugs")
131
+ }
132
+ mapping[position], positions[slug] = slug, position
133
+ }
134
+ i--
135
+ }
136
+ if tables != 1 || len(mapping) == 0 || mapping[1] != root {
137
+ return nil, artifacts, fmt.Errorf("canonical roadmap requires one nonempty table rooted at Sequence root")
138
+ }
139
+ for _, slug := range ordered {
140
+ n := nodes[slug]
141
+ chain, chainErr := frontierChain(nodes, slug)
142
+ if chainErr != nil {
143
+ return nil, artifacts, fmt.Errorf("canonical roadmap recorded chain invalid: %w", chainErr)
144
+ }
145
+ if chain[len(chain)-1] != root {
146
+ return nil, artifacts, fmt.Errorf("canonical roadmap root %s does not match actual chain anchor", root)
147
+ }
148
+ position := positions[slug]
149
+ if position == 0 || n.hasPosition && n.position != position {
150
+ return nil, artifacts, fmt.Errorf("canonical roadmap conflicts with recorded position for %s", slug)
151
+ }
152
+ if n.parent != mapping[position-1] {
153
+ return nil, artifacts, fmt.Errorf("canonical roadmap conflicts with recorded predecessor for %s", slug)
154
+ }
155
+ if target := frontierSpecTarget(n); target != "" && mapping[position+1] != target {
156
+ return nil, artifacts, fmt.Errorf("canonical roadmap conflicts with next Spec for %s", slug)
157
+ }
158
+ }
159
+ for _, slug := range mapping {
160
+ if n := nodes[slug]; n != nil {
161
+ position := positions[slug]
162
+ if n.problem != nil || n.hasPosition && n.position != position || n.parent != mapping[position-1] {
163
+ return nil, artifacts, fmt.Errorf("canonical roadmap workspace %s is not bound to its recorded sequence position", slug)
164
+ }
165
+ }
166
+ }
167
+ return mapping, artifacts, nil
168
+ }
@@ -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,25 @@ 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
+ mode, err := autocompleteMode(root)
86
+ if err != nil {
87
+ fmt.Fprintf(stderr, "next: %v\n", err)
88
+ return 2
89
+ }
90
+ if mode && frontierGlobalPriority(slug, lines) {
91
+ fmt.Fprintln(stdout, "next-command: /rite-autocomplete")
92
+ return exit
93
+ }
94
+ }
76
95
  if report.Phase == state.PhaseBuild {
77
96
  printPlanBatch(stdout, root, slug)
78
97
  }
@@ -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,29 @@ 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
+ // Recorded checkout/ref identity must still match; legacy leases lack that evidence.
173
+ // Active reservations must remain path-disjoint; integrated history holds no slot.
174
+ func ControllerLease(repoRoot string) (string, *Lease, error) {
175
+ found, err := controllerLease(repoRoot, "")
176
+ if err != nil || found == nil {
177
+ return "", nil, err
178
+ }
179
+ if found.root != resolvedPath(repoRoot) {
180
+ return "", nil, fmt.Errorf("repository controller lease retained by %s at %s; recover at its explicit root", found.controller, found.root)
181
+ }
182
+ if err := ensureControllerIdentity(repoRoot, found.lease); err != nil {
183
+ return "", nil, err
184
+ }
185
+ if active := activeSlices(found.lease); len(active) >= 2 {
186
+ if _, err := CheckPathDisjoint(active, repoRoot); err != nil {
187
+ return "", nil, err
188
+ }
189
+ }
190
+ return found.controller, found.lease, nil
191
+ }
192
+
170
193
  // WorkspaceLeased protects both controller and source workspaces until cleanup.
171
194
  // devritesRoot is explicit; closeout holds WithControlLock through its rename.
172
195
  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
  }
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
@@ -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":