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.
- package/CHANGELOG.md +41 -2
- package/README.md +21 -1
- package/docs/cli.md +98 -13
- package/docs/command-map.md +2 -2
- package/docs/engine/commands.md +10 -5
- package/docs/engine/workspace-schema.md +4 -3
- package/engine/commands.go +1 -1
- package/engine/help.go +3 -1
- package/engine/internal/hostpack/hostpack.go +1 -1
- package/engine/internal/lib/apply_bundle.go +25 -8
- package/engine/internal/lib/continuation.go +33 -4
- package/engine/internal/lib/dispatch.go +46 -31
- package/engine/internal/lib/frontier.go +808 -0
- package/engine/internal/lib/handoff.go +2 -5
- package/engine/internal/lib/next.go +10 -0
- package/engine/internal/lib/regression.go +0 -5
- package/engine/internal/lib/resolve.go +33 -16
- package/engine/internal/parallel/cli.go +16 -5
- package/engine/internal/parallel/ops.go +7 -5
- package/engine/internal/parallel/repository.go +13 -0
- package/engine/internal/release/release.go +96 -5
- package/engine/internal/rootfacts/facts.go +11 -0
- package/engine/internal/state/lock_other.go +4 -0
- package/engine/internal/state/write.go +54 -13
- package/engine/main.go +6 -2
- package/pack/.claude/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/.claude/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/README.md +1 -1
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/devin/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/devin/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/devin/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/devin/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/devin/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/dsh/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/dsh/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/dsh/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/dsh/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/dsh/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/omp/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/omp/skills/devrites-lib/reference/standards/agents.md +1 -1
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/omp/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/omp/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/pack/generated/omp/skills/rite-doctor/SKILL.md +70 -68
- package/pack/generated/pi/AGENTS.md +1 -1
- package/pack/generated/pi/skills/devrites-lib/reference/parallel-workspaces.md +41 -40
- package/pack/generated/pi/skills/rite-autocomplete/SKILL.md +10 -10
- package/pack/generated/pi/skills/rite-autocomplete/reference/loop.md +6 -6
- package/pack/generated/pi/skills/rite-build/reference/parallel-batch.md +7 -7
- package/pack/generated/pi/skills/rite-doctor/SKILL.md +70 -68
- package/package.json +1 -1
- package/scripts/build-host-artifacts.sh +1 -1
- package/scripts/dsh-generate.py +5 -1
- package/scripts/omp-generate.sh +4 -4
- 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
|
-
|
|
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,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/
|
|
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 :=
|
|
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 :=
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
qdata, err := os.ReadFile(qfile)
|
|
146
|
+
qdata, err := ws.ReadFile("questions.md")
|
|
130
147
|
if err != nil {
|
|
131
|
-
return fail(stderr, "questions.md
|
|
148
|
+
return fail(stderr, fmt.Sprintf("read questions.md: %v", err), 2)
|
|
132
149
|
}
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
166
|
+
targets := []*bundleTarget{
|
|
167
|
+
{rel: "questions.md", before: qdata, existed: true, after: joinRecords(lines)},
|
|
152
168
|
}
|
|
153
169
|
if !bytes.Equal(nextState, sdata) {
|
|
154
|
-
|
|
155
|
-
|
|
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 :=
|
|
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,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
|
|
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
|
}
|
|
@@ -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(
|
|
84
|
-
if err :=
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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":
|