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.
- package/CHANGELOG.md +44 -3
- package/README.md +21 -1
- package/docs/adr/0039-autocomplete-structural-admission.md +87 -0
- package/docs/adr/README.md +1 -0
- package/docs/cli.md +182 -7
- package/docs/command-map.md +2 -2
- package/docs/engine/commands.md +53 -3
- package/engine/commands.go +1 -1
- package/engine/help.go +3 -1
- package/engine/internal/lib/claims.go +65 -8
- package/engine/internal/lib/continuation.go +38 -4
- package/engine/internal/lib/dispatch.go +124 -63
- package/engine/internal/lib/frontier.go +864 -0
- package/engine/internal/lib/frontier_controller.go +652 -0
- package/engine/internal/lib/frontier_roadmap.go +168 -0
- package/engine/internal/lib/handoff.go +2 -5
- package/engine/internal/lib/next.go +19 -0
- package/engine/internal/parallel/cli.go +16 -5
- package/engine/internal/parallel/ops.go +7 -5
- package/engine/internal/parallel/repository.go +23 -0
- package/engine/internal/release/release.go +96 -5
- package/engine/main.go +4 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/devin/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/devin/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/devin/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/dsh/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/dsh/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/dsh/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/omp/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/omp/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/omp/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +19 -17
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +106 -114
- package/pack/generated/pi/skills/devrites-lib/reference/standards/afk-hitl.md +18 -17
- package/pack/generated/pi/skills/devrites-lib/reference/workspace-artifact-schema.md +41 -38
- package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +33 -38
- package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +46 -52
- package/pack/generated/pi/skills/rite-autocomplete/reference/stop-conditions.md +28 -28
- package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +19 -17
- 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
|
-
|
|
93
|
-
|
|
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 :=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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":
|