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
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"strconv"
|
|
10
|
+
"strings"
|
|
11
|
+
|
|
12
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
13
|
+
"github.com/devrites/devrites/internal/gate"
|
|
14
|
+
"github.com/devrites/devrites/internal/markdowntext"
|
|
15
|
+
"github.com/devrites/devrites/internal/parallel"
|
|
16
|
+
"github.com/devrites/devrites/internal/state"
|
|
17
|
+
"github.com/devrites/devrites/internal/workflow"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const frontierWorkspaceLimit = 512
|
|
21
|
+
|
|
22
|
+
type frontierWorkspace struct {
|
|
23
|
+
slug, dir, location, parent, phase, status, next, question string
|
|
24
|
+
position, remaining int
|
|
25
|
+
hasPosition, hasRemaining bool
|
|
26
|
+
selectionHeld bool
|
|
27
|
+
problem error
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// RunFrontier observes the recorded sequence without creating or selecting work.
|
|
31
|
+
func RunFrontier(root string, args []string, stdout, stderr io.Writer) int {
|
|
32
|
+
if len(args) > 1 || len(args) == 1 && strings.HasPrefix(args[0], "-") {
|
|
33
|
+
fmt.Fprintln(stderr, "usage: devrites-engine frontier [slug]")
|
|
34
|
+
return 2
|
|
35
|
+
}
|
|
36
|
+
slug := ""
|
|
37
|
+
if len(args) == 1 {
|
|
38
|
+
slug = args[0]
|
|
39
|
+
}
|
|
40
|
+
lines, err := FrontierLines(root, slug)
|
|
41
|
+
if err != nil {
|
|
42
|
+
fmt.Fprintf(stderr, "frontier: %v\n", err)
|
|
43
|
+
return 2
|
|
44
|
+
}
|
|
45
|
+
for _, line := range lines {
|
|
46
|
+
fmt.Fprintln(stdout, line)
|
|
47
|
+
}
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// FrontierNotices preserves source-local selection; callers must explicitly opt into the pool.
|
|
52
|
+
func FrontierNotices(repoRoot, slug string) ([]string, error) {
|
|
53
|
+
return FrontierLines(filepath.Join(repoRoot, devritespaths.DevritesRootName), slug)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func frontierField(lines []string, key string) (string, bool, error) {
|
|
57
|
+
value, present := "", false
|
|
58
|
+
for _, line := range lines {
|
|
59
|
+
if v, ok := state.CursorField([]string{line}, key); ok {
|
|
60
|
+
if present {
|
|
61
|
+
return value, true, fmt.Errorf("duplicate %s cursor", key)
|
|
62
|
+
}
|
|
63
|
+
value, present = v, true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return value, present, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func frontierRead(root, slug, location string) *frontierWorkspace {
|
|
70
|
+
n := &frontierWorkspace{slug: slug, location: location}
|
|
71
|
+
if location == "live" {
|
|
72
|
+
n.dir, n.problem = devritespaths.ExistingCanonicalFeatureDirChecked(root, slug)
|
|
73
|
+
} else {
|
|
74
|
+
n.dir, n.problem = devritespaths.ExistingArchivedFeatureDirChecked(root, slug)
|
|
75
|
+
}
|
|
76
|
+
if n.problem != nil {
|
|
77
|
+
return n
|
|
78
|
+
}
|
|
79
|
+
raw, err := readBoundedRegularFile(filepath.Join(n.dir, "state.md"), workspaceArtifactLimit)
|
|
80
|
+
if err != nil {
|
|
81
|
+
n.problem = err
|
|
82
|
+
return n
|
|
83
|
+
}
|
|
84
|
+
raw, err = markdowntext.Structural(raw)
|
|
85
|
+
if err != nil {
|
|
86
|
+
n.problem = err
|
|
87
|
+
return n
|
|
88
|
+
}
|
|
89
|
+
lines := strings.Split(string(raw), "\n")
|
|
90
|
+
for _, field := range []struct {
|
|
91
|
+
key string
|
|
92
|
+
dst *string
|
|
93
|
+
}{
|
|
94
|
+
{state.CursorSequenceParent, &n.parent}, {state.CursorPhase, &n.phase}, {state.CursorStatus, &n.status}, {state.CursorNextAction, &n.next}, {state.CursorQuestionID, &n.question},
|
|
95
|
+
} {
|
|
96
|
+
value, _, err := frontierField(lines, field.key)
|
|
97
|
+
*field.dst = value
|
|
98
|
+
if err != nil {
|
|
99
|
+
n.problem = err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for _, field := range []struct {
|
|
103
|
+
key string
|
|
104
|
+
dst *int
|
|
105
|
+
present *bool
|
|
106
|
+
positive bool
|
|
107
|
+
}{
|
|
108
|
+
{state.CursorSequencePosition, &n.position, &n.hasPosition, true},
|
|
109
|
+
{state.CursorSequenceWorkspacesRemain, &n.remaining, &n.hasRemaining, false},
|
|
110
|
+
} {
|
|
111
|
+
value, present, err := frontierField(lines, field.key)
|
|
112
|
+
*field.present = present
|
|
113
|
+
if err != nil {
|
|
114
|
+
n.problem = err
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if !present {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
number, err := strconv.Atoi(value)
|
|
121
|
+
if err != nil || number < 0 || field.positive && number == 0 || strconv.Itoa(number) != value {
|
|
122
|
+
n.problem = fmt.Errorf("%s must be a canonical %sdecimal", field.key, map[bool]string{true: "positive ", false: "nonnegative "}[field.positive])
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
*field.dst = number
|
|
126
|
+
}
|
|
127
|
+
if n.parent != "" && !devritespaths.ValidSlug(n.parent) {
|
|
128
|
+
n.problem = fmt.Errorf("invalid sequence_parent")
|
|
129
|
+
}
|
|
130
|
+
if _, known := state.PolicyFor(state.Phase(n.phase)); !known && n.problem == nil {
|
|
131
|
+
n.problem = fmt.Errorf("unknown or missing native phase %q", n.phase)
|
|
132
|
+
}
|
|
133
|
+
return n
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func frontierScan(root string) (map[string]*frontierWorkspace, error) {
|
|
137
|
+
nodes := map[string]*frontierWorkspace{}
|
|
138
|
+
for _, location := range []string{"work", "archive"} {
|
|
139
|
+
path := filepath.Join(root, location)
|
|
140
|
+
info, err := os.Lstat(path)
|
|
141
|
+
if os.IsNotExist(err) {
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if err != nil {
|
|
145
|
+
return nil, err
|
|
146
|
+
}
|
|
147
|
+
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
148
|
+
return nil, fmt.Errorf("%s must be a canonical directory", location)
|
|
149
|
+
}
|
|
150
|
+
f, err := os.Open(path) // #nosec G304 -- canonical directory checked above
|
|
151
|
+
if err != nil {
|
|
152
|
+
return nil, err
|
|
153
|
+
}
|
|
154
|
+
entries, err := f.ReadDir(frontierWorkspaceLimit + 1)
|
|
155
|
+
_ = f.Close()
|
|
156
|
+
if err != nil && err != io.EOF {
|
|
157
|
+
return nil, err
|
|
158
|
+
}
|
|
159
|
+
if len(entries)+len(nodes) > frontierWorkspaceLimit {
|
|
160
|
+
return nil, fmt.Errorf("frontier exceeds %d workspace entries; native sequence review required", frontierWorkspaceLimit)
|
|
161
|
+
}
|
|
162
|
+
for _, entry := range entries {
|
|
163
|
+
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
slug := entry.Name()
|
|
167
|
+
where := "live"
|
|
168
|
+
if location == "archive" {
|
|
169
|
+
where = "archive"
|
|
170
|
+
}
|
|
171
|
+
n := frontierRead(root, slug, where)
|
|
172
|
+
if old := nodes[slug]; old != nil {
|
|
173
|
+
old.problem = fmt.Errorf("duplicate live/archive identity")
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
nodes[slug] = n
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return nodes, nil
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func frontierChain(nodes map[string]*frontierWorkspace, slug string) ([]string, error) {
|
|
183
|
+
var chain []string
|
|
184
|
+
seen := map[string]bool{}
|
|
185
|
+
for len(chain) < maxSequenceChain {
|
|
186
|
+
if seen[slug] {
|
|
187
|
+
return chain, fmt.Errorf("sequence_parent cycle at %s", slug)
|
|
188
|
+
}
|
|
189
|
+
seen[slug] = true
|
|
190
|
+
n := nodes[slug]
|
|
191
|
+
if n == nil {
|
|
192
|
+
return chain, fmt.Errorf("sequence_parent %s missing", slug)
|
|
193
|
+
}
|
|
194
|
+
chain = append(chain, slug)
|
|
195
|
+
if n.problem != nil {
|
|
196
|
+
return chain, fmt.Errorf("%s: %w", slug, n.problem)
|
|
197
|
+
}
|
|
198
|
+
if n.parent == "" {
|
|
199
|
+
if n.hasPosition && n.position != 1 {
|
|
200
|
+
return chain, fmt.Errorf("%s position %d has no sequence_parent", slug, n.position)
|
|
201
|
+
}
|
|
202
|
+
return chain, nil
|
|
203
|
+
}
|
|
204
|
+
if p := nodes[n.parent]; p != nil && (n.hasPosition || p.hasPosition) && (!n.hasPosition || !p.hasPosition || n.position != p.position+1) {
|
|
205
|
+
return chain, fmt.Errorf("%s sequence_position does not follow %s", slug, n.parent)
|
|
206
|
+
}
|
|
207
|
+
slug = n.parent
|
|
208
|
+
}
|
|
209
|
+
return chain, fmt.Errorf("sequence_parent chain exceeds %d workspaces", maxSequenceChain)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func frontierSpecTarget(n *frontierWorkspace) string {
|
|
213
|
+
command := workflow.ForAction(n.next)
|
|
214
|
+
if command.Verb != "spec" {
|
|
215
|
+
return ""
|
|
216
|
+
}
|
|
217
|
+
fields := strings.Fields(n.next)
|
|
218
|
+
if len(fields) < 2 || fields[0] != command.Claude && fields[0] != command.Codex && fields[0] != command.Skill {
|
|
219
|
+
return ""
|
|
220
|
+
}
|
|
221
|
+
if workflow.ForAction(strings.Join(fields[1:], " ")).Verb != "" {
|
|
222
|
+
return ""
|
|
223
|
+
}
|
|
224
|
+
for i, field := range fields {
|
|
225
|
+
if field == "—" {
|
|
226
|
+
fields = fields[:i]
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if len(fields) != 2 || !devritespaths.ValidSlug(fields[1]) || strings.HasPrefix(fields[1], "-") || fields[1] == n.slug {
|
|
231
|
+
return ""
|
|
232
|
+
}
|
|
233
|
+
return fields[1]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func frontierTerminal(n *frontierWorkspace) bool {
|
|
237
|
+
return n.phase == "done" || n.status == "shipped" || n.status == "done" || n.status == "archived"
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func frontierQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
|
|
241
|
+
|
|
242
|
+
// FrontierLines reports structural obligations, never semantic authorization or Ship approval.
|
|
243
|
+
func FrontierLines(root, slug string) ([]string, error) {
|
|
244
|
+
if slug == "" {
|
|
245
|
+
var err error
|
|
246
|
+
slug, err = devritespaths.ActiveSlug(root)
|
|
247
|
+
if err != nil {
|
|
248
|
+
return nil, err
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if slug != "" {
|
|
252
|
+
if !devritespaths.ValidSlug(slug) {
|
|
253
|
+
return nil, fmt.Errorf("invalid source slug")
|
|
254
|
+
}
|
|
255
|
+
if _, err := devritespaths.WorkspaceOverrideChecked(root, slug); err != nil {
|
|
256
|
+
return nil, err
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
nodes, err := frontierScan(root)
|
|
260
|
+
if err != nil {
|
|
261
|
+
return nil, err
|
|
262
|
+
}
|
|
263
|
+
if slug == "" {
|
|
264
|
+
var tips []string
|
|
265
|
+
for name, n := range nodes {
|
|
266
|
+
if n.location != "archive" || !frontierTerminal(n) || frontierSpecTarget(n) == "" {
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if _, err := frontierChain(nodes, name); err != nil {
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
hasChild := false
|
|
273
|
+
for _, child := range nodes {
|
|
274
|
+
hasChild = hasChild || child.parent == name
|
|
275
|
+
}
|
|
276
|
+
if !hasChild {
|
|
277
|
+
tips = append(tips, name)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if len(tips) != 1 {
|
|
281
|
+
return nil, fmt.Errorf("empty ACTIVE: need a unique archived terminal sequence tip with exact next Spec edge; found %d", len(tips))
|
|
282
|
+
}
|
|
283
|
+
slug = tips[0]
|
|
284
|
+
}
|
|
285
|
+
source := nodes[slug]
|
|
286
|
+
if source == nil {
|
|
287
|
+
return nil, fmt.Errorf("source %s missing", slug)
|
|
288
|
+
}
|
|
289
|
+
chain, chainErr := frontierChain(nodes, slug)
|
|
290
|
+
if chainErr != nil {
|
|
291
|
+
return []string{"frontier: blocked", "frontier-source: " + slug, fmt.Sprintf("frontier-workspace: %s location=%s action=repair reason=%s", slug, source.location, chainErr)}, nil
|
|
292
|
+
}
|
|
293
|
+
anchor := chain[len(chain)-1]
|
|
294
|
+
related := map[string]bool{anchor: true}
|
|
295
|
+
for range maxSequenceChain {
|
|
296
|
+
changed := false
|
|
297
|
+
for name, n := range nodes {
|
|
298
|
+
if related[n.parent] && !related[name] {
|
|
299
|
+
related[name] = true
|
|
300
|
+
changed = true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if !changed {
|
|
304
|
+
break
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if len(related) == 1 && source.parent == "" && !source.hasPosition && !source.hasRemaining && frontierSpecTarget(source) == "" {
|
|
308
|
+
return []string{"frontier: none"}, nil
|
|
309
|
+
}
|
|
310
|
+
ordered := make([]string, 0, len(related))
|
|
311
|
+
for name := range related {
|
|
312
|
+
ordered = append(ordered, name)
|
|
313
|
+
}
|
|
314
|
+
depth := make(map[string]int, len(ordered))
|
|
315
|
+
for _, name := range ordered {
|
|
316
|
+
chain, _ := frontierChain(nodes, name)
|
|
317
|
+
depth[name] = len(chain)
|
|
318
|
+
}
|
|
319
|
+
sort.Slice(ordered, func(i, j int) bool {
|
|
320
|
+
a, b := nodes[ordered[i]], nodes[ordered[j]]
|
|
321
|
+
if depth[a.slug] != depth[b.slug] {
|
|
322
|
+
return depth[a.slug] < depth[b.slug]
|
|
323
|
+
}
|
|
324
|
+
if a.position != b.position {
|
|
325
|
+
return a.position < b.position
|
|
326
|
+
}
|
|
327
|
+
return a.slug < b.slug
|
|
328
|
+
})
|
|
329
|
+
controller := ""
|
|
330
|
+
for _, name := range ordered {
|
|
331
|
+
if nodes[name].location == "live" {
|
|
332
|
+
if _, err := frontierChain(nodes, name); err == nil {
|
|
333
|
+
controller = name
|
|
334
|
+
break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
retainedController, lease, err := parallel.ControllerLease(filepath.Dir(root))
|
|
339
|
+
if err != nil {
|
|
340
|
+
return []string{"frontier: blocked", "frontier-source: " + slug, "frontier-selection: blocked reason=" + err.Error()}, nil
|
|
341
|
+
}
|
|
342
|
+
controllerProblem := ""
|
|
343
|
+
if retainedController != "" {
|
|
344
|
+
controller = retainedController
|
|
345
|
+
if !related[controller] || nodes[controller].location != "live" {
|
|
346
|
+
controllerProblem = "repository controller " + controller + " is outside the source sequence; recover retained controller before admission"
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
allowFuture, capN, err := frontierPolicy(root)
|
|
350
|
+
if err != nil {
|
|
351
|
+
return nil, err
|
|
352
|
+
}
|
|
353
|
+
if lease != nil && lease.Capacity > 0 && capN > lease.Capacity {
|
|
354
|
+
capN = lease.Capacity
|
|
355
|
+
}
|
|
356
|
+
lines := []string{"", "frontier-source: " + slug}
|
|
357
|
+
actionable, waiting, blocked := false, false, false
|
|
358
|
+
if controllerProblem != "" {
|
|
359
|
+
lines = append(lines, "frontier-selection: blocked reason="+controllerProblem)
|
|
360
|
+
blocked = true
|
|
361
|
+
}
|
|
362
|
+
var live []string
|
|
363
|
+
var candidates []parallel.SlicePaths
|
|
364
|
+
if source.hasRemaining && source.remaining == 0 {
|
|
365
|
+
allowFuture = false
|
|
366
|
+
}
|
|
367
|
+
maps, artifacts, mapProblem := frontierRoadmap(nodes, ordered)
|
|
368
|
+
if mapProblem != "" {
|
|
369
|
+
lines = append(lines, "frontier-review-sequence: "+strings.Join(artifacts, ",")+" reason=native authority-normalization required: "+mapProblem)
|
|
370
|
+
blocked = true
|
|
371
|
+
}
|
|
372
|
+
prepared := map[string]bool{}
|
|
373
|
+
for _, name := range ordered {
|
|
374
|
+
n := nodes[name]
|
|
375
|
+
action, reason := "complete", "archived predecessor; no writer admission"
|
|
376
|
+
held := false
|
|
377
|
+
_, invalid := frontierChain(nodes, name)
|
|
378
|
+
if invalid != nil {
|
|
379
|
+
action, reason, blocked = "repair", invalid.Error(), true
|
|
380
|
+
} else if gateReason, gateErr := frontierQuestionHold(n); gateErr != nil {
|
|
381
|
+
action, reason, held, blocked = "repair", gateErr.Error(), true, true
|
|
382
|
+
} else if gateReason != "" {
|
|
383
|
+
action, reason, held, blocked = "wait", gateReason, true, true
|
|
384
|
+
} else if n.location == "live" {
|
|
385
|
+
action, reason, err = frontierLocal(root, controller, n, &candidates)
|
|
386
|
+
if err != nil {
|
|
387
|
+
action, reason, n.selectionHeld = "repair", err.Error(), true
|
|
388
|
+
}
|
|
389
|
+
if !n.selectionHeld {
|
|
390
|
+
live = append(live, name)
|
|
391
|
+
}
|
|
392
|
+
held = action == "blocked" || err != nil
|
|
393
|
+
switch action {
|
|
394
|
+
case "inspect", "prepare", "repair":
|
|
395
|
+
actionable = true
|
|
396
|
+
case "wait":
|
|
397
|
+
waiting = true
|
|
398
|
+
case "blocked":
|
|
399
|
+
blocked = true
|
|
400
|
+
action = "wait"
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
lines = append(lines, fmt.Sprintf("frontier-workspace: %s location=%s action=%s reason=%s", name, n.location, action, reason))
|
|
404
|
+
if invalid != nil || held || !allowFuture || n.hasRemaining && n.remaining == 0 || n.status == "awaiting_human" || n.status == "blocked" || n.question != "" {
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
target := frontierSpecTarget(n)
|
|
408
|
+
if target == "" && n.hasRemaining && n.remaining > 0 && n.hasPosition && mapProblem == "" {
|
|
409
|
+
target = maps[n.position+1]
|
|
410
|
+
}
|
|
411
|
+
if mapProblem != "" {
|
|
412
|
+
if target != "" || n.hasRemaining && n.remaining > 0 {
|
|
413
|
+
actionable = true
|
|
414
|
+
}
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
if target != "" && frontierConflictingSuccessor(nodes, n, target) {
|
|
418
|
+
lines = append(lines, "frontier-review-sequence: "+strings.Join(artifacts, ",")+" source="+name+" reason=conflicting successor identities; native authority-normalization required")
|
|
419
|
+
actionable, blocked = true, true
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
if target != "" && n.hasPosition && maps[n.position+1] != "" && maps[n.position+1] != target {
|
|
423
|
+
lines = append(lines, "frontier-review-sequence: "+strings.Join(artifacts, ",")+" source="+name+" reason=next Spec conflicts with recorded successor; native authority-normalization required")
|
|
424
|
+
actionable, blocked = true, true
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
if target != "" && nodes[target] != nil && nodes[target].parent != name {
|
|
428
|
+
lines = append(lines, fmt.Sprintf("frontier-workspace: %s location=%s action=repair reason=recorded successor is not bound to source %s; native authority-normalization required", target, nodes[target].location, name))
|
|
429
|
+
actionable, blocked = true, true
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
if target != "" && nodes[target] == nil && !prepared[target] {
|
|
433
|
+
prepared[target] = true
|
|
434
|
+
// A table identifies a preparation obligation, not approval to implement it.
|
|
435
|
+
lines = append(lines, fmt.Sprintf("frontier-workspace: %s location=missing action=prepare reason=recorded next workspace; native scope and independence review required", target), fmt.Sprintf("frontier-prepare: /rite-spec %s source=%s reason=read recorded sequence authority before preparation; no workspace created", frontierQuote(target), name))
|
|
436
|
+
actionable = true
|
|
437
|
+
} else if target == "" && n.hasRemaining && n.remaining > 0 {
|
|
438
|
+
lines = append(lines, "frontier-review-sequence: "+strings.Join(artifacts, ",")+" source="+name+" reason=remaining work unresolved; native authority-normalization required")
|
|
439
|
+
actionable, blocked = true, true
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if controller != "" && controllerProblem == "" && len(live) > 0 {
|
|
443
|
+
lines = append(lines, fmt.Sprintf("frontier-select-command: devrites-engine parallel select --root %s --slug %s --from-plan --workspaces %s --cap %d", frontierQuote(filepath.Dir(root)), frontierQuote(controller), frontierQuote(strings.Join(live, ",")), capN))
|
|
444
|
+
if len(candidates) > 0 {
|
|
445
|
+
_, picks, running, err := parallel.SelectForSlug(filepath.Dir(root), controller, capN, candidates)
|
|
446
|
+
if err != nil {
|
|
447
|
+
lines = append(lines, "frontier-selection: blocked reason="+err.Error())
|
|
448
|
+
blocked = true
|
|
449
|
+
} else if len(picks) > 0 {
|
|
450
|
+
actionable = true
|
|
451
|
+
var ids []string
|
|
452
|
+
for _, pick := range picks {
|
|
453
|
+
ids = append(ids, parallel.SliceKey(pick.Workspace, pick.ID))
|
|
454
|
+
}
|
|
455
|
+
lines = append(lines, "frontier-picks: "+strings.Join(ids, ","))
|
|
456
|
+
} else if running > 0 {
|
|
457
|
+
waiting = true
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
verdict := "clear"
|
|
462
|
+
if blocked {
|
|
463
|
+
verdict = "blocked"
|
|
464
|
+
}
|
|
465
|
+
if waiting {
|
|
466
|
+
verdict = "waiting"
|
|
467
|
+
}
|
|
468
|
+
if actionable {
|
|
469
|
+
verdict = "actionable"
|
|
470
|
+
}
|
|
471
|
+
lines[0] = "frontier: " + verdict
|
|
472
|
+
return lines, nil
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
func frontierPolicy(root string) (bool, int, error) {
|
|
476
|
+
capN := parallel.MaxParallelSlices
|
|
477
|
+
raw, err := readBoundedRegularFile(filepath.Join(root, "AFK"), workspaceArtifactLimit)
|
|
478
|
+
if os.IsNotExist(err) {
|
|
479
|
+
return true, capN, nil
|
|
480
|
+
}
|
|
481
|
+
if err != nil {
|
|
482
|
+
return false, 0, err
|
|
483
|
+
}
|
|
484
|
+
seen := map[string]bool{}
|
|
485
|
+
allow := true
|
|
486
|
+
for _, line := range strings.Split(string(raw), "\n") {
|
|
487
|
+
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
|
488
|
+
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
|
489
|
+
if !ok || key != "continue_sequence" && key != "max_parallel" {
|
|
490
|
+
continue
|
|
491
|
+
}
|
|
492
|
+
for i := 1; i < len(value); i++ {
|
|
493
|
+
if value[i] == '#' && (value[i-1] == ' ' || value[i-1] == '\t') {
|
|
494
|
+
value = strings.TrimSpace(value[:i])
|
|
495
|
+
break
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if seen[key] {
|
|
499
|
+
return false, 0, fmt.Errorf("duplicate AFK %s", key)
|
|
500
|
+
}
|
|
501
|
+
seen[key] = true
|
|
502
|
+
if key == "continue_sequence" {
|
|
503
|
+
switch value {
|
|
504
|
+
case "true":
|
|
505
|
+
case "false":
|
|
506
|
+
allow = false
|
|
507
|
+
default:
|
|
508
|
+
return false, 0, fmt.Errorf("invalid AFK continue_sequence")
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
capN, err = strconv.Atoi(value)
|
|
512
|
+
if err != nil || capN < 1 || capN > parallel.MaxParallelSlices || strconv.Itoa(capN) != value {
|
|
513
|
+
return false, 0, fmt.Errorf("invalid AFK max_parallel")
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return allow, capN, nil
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
func frontierQuestionHold(n *frontierWorkspace) (string, error) {
|
|
521
|
+
if n.status == "awaiting_human" || n.status == "blocked" || n.question != "" {
|
|
522
|
+
return "human/blocked cursor; retain native gate", nil
|
|
523
|
+
}
|
|
524
|
+
questions, err := readBoundedRegularFile(filepath.Join(n.dir, "questions.md"), workspaceArtifactLimit)
|
|
525
|
+
if err != nil && !os.IsNotExist(err) {
|
|
526
|
+
return "", err
|
|
527
|
+
}
|
|
528
|
+
for _, kind := range gate.OpenBlockingQuestionGates(questions) {
|
|
529
|
+
if kind == "blocking" || kind == "escalating" {
|
|
530
|
+
return "open " + kind + " question; retain native gate", nil
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return "", nil
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
func frontierLocal(root, controller string, n *frontierWorkspace, candidates *[]parallel.SlicePaths) (string, string, error) {
|
|
537
|
+
n.selectionHeld = true
|
|
538
|
+
if frontierTerminal(n) {
|
|
539
|
+
return "complete", "terminal workspace", nil
|
|
540
|
+
}
|
|
541
|
+
if _, err := devritespaths.WorkspaceOverrideChecked(root, n.slug); err != nil {
|
|
542
|
+
return "blocked", "DEVRITES_WORKSPACE scope retained; unset override before global admission", nil
|
|
543
|
+
}
|
|
544
|
+
if n.status != "running" && n.status != "ready" {
|
|
545
|
+
return "blocked", "status lacks an actionable native cursor; normalize state without inventing authority", nil
|
|
546
|
+
}
|
|
547
|
+
raw, err := readBoundedRegularFile(filepath.Join(n.dir, "dispatch.json"), workspaceArtifactLimit)
|
|
548
|
+
if err != nil && !os.IsNotExist(err) {
|
|
549
|
+
return "", "", err
|
|
550
|
+
}
|
|
551
|
+
if err == nil {
|
|
552
|
+
dispatch, err := decodeDispatch(raw)
|
|
553
|
+
if err != nil {
|
|
554
|
+
return "", "", err
|
|
555
|
+
}
|
|
556
|
+
retained, pending := false, false
|
|
557
|
+
var built map[string]bool
|
|
558
|
+
for _, wave := range dispatch.Waves {
|
|
559
|
+
if wave.State == "complete" {
|
|
560
|
+
if !isBuildWrightWave(wave) {
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
if built == nil {
|
|
564
|
+
built, err = builtSlicesFrom(func(name string) ([]byte, error) {
|
|
565
|
+
return readBoundedRegularFile(filepath.Join(n.dir, name), workspaceArtifactLimit)
|
|
566
|
+
})
|
|
567
|
+
if err != nil {
|
|
568
|
+
return "", "", err
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if len(wave.Slices) > 0 && !slicesUnbuilt(wave.Slices, built) {
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if wave.State == "abandoned" && wave.TerminalAt != "" {
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
wavePending := false
|
|
579
|
+
for _, role := range wave.Roles {
|
|
580
|
+
if wave.State != "abandoned" || role.Handle != "" || role.StartedAt != "" {
|
|
581
|
+
wavePending = wavePending || !role.Returned
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if wave.State == "abandoned" && !wavePending {
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
retained = true
|
|
588
|
+
pending = pending || wavePending
|
|
589
|
+
}
|
|
590
|
+
if pending {
|
|
591
|
+
return "wait", "retained dispatch work; reconcile handles before another launch; isolated lease IDs do not bind actor checkout", nil
|
|
592
|
+
}
|
|
593
|
+
if retained {
|
|
594
|
+
return "inspect", "retained dispatch returns need native reconciliation before another launch", nil
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
n.selectionHeld = false
|
|
598
|
+
if n.phase == "vet" && n.status == "ready" || n.phase == "build" {
|
|
599
|
+
ready, notices, err := WorkspaceReady(filepath.Dir(root), controller, n.slug)
|
|
600
|
+
if err != nil {
|
|
601
|
+
return "", "", err
|
|
602
|
+
}
|
|
603
|
+
*candidates = append(*candidates, ready...)
|
|
604
|
+
if len(notices) > 0 {
|
|
605
|
+
action := "wait"
|
|
606
|
+
for _, notice := range notices {
|
|
607
|
+
if !strings.Contains(notice, ": waiting for durable BUILT/integrated producer ") {
|
|
608
|
+
action = "repair"
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return action, "native Plan/Vet readiness: " + strings.Join(notices, "; "), nil
|
|
612
|
+
}
|
|
613
|
+
return "wait", "writer candidates require mixed selection; retained leases and capacity remain authoritative", nil
|
|
614
|
+
}
|
|
615
|
+
if frontierSpecTarget(n) != "" {
|
|
616
|
+
return "complete", "native next Spec cursor recorded", nil
|
|
617
|
+
}
|
|
618
|
+
if _, internal, err := internalPhaseEdge(n.next, n.slug); err != nil {
|
|
619
|
+
return "", "", err
|
|
620
|
+
} else if internal && n.status == "running" {
|
|
621
|
+
return "prepare", "native internal phase cursor: " + n.next, nil
|
|
622
|
+
}
|
|
623
|
+
if command := workflow.ForAction(n.next); command.Verb == "ship" {
|
|
624
|
+
fields := strings.Fields(n.next)
|
|
625
|
+
valid := len(fields) > 0 && (fields[0] == command.Claude || fields[0] == command.Codex || fields[0] == command.Skill)
|
|
626
|
+
valid = valid && workflow.ForAction(strings.Join(fields[1:], " ")).Verb == ""
|
|
627
|
+
for i, field := range fields {
|
|
628
|
+
if field == "—" {
|
|
629
|
+
fields = fields[:i]
|
|
630
|
+
break
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
valid = valid && (len(fields) == 1 || len(fields) == 2 && fields[1] == n.slug)
|
|
634
|
+
if !valid {
|
|
635
|
+
return "blocked", "invalid native Ship cursor; retain native gate", nil
|
|
636
|
+
}
|
|
637
|
+
// Ship approval holds this writer, not independently recorded future preparation.
|
|
638
|
+
n.selectionHeld = true
|
|
639
|
+
return "wait", "Ship requires existing native authority; controller invocation is not approval", nil
|
|
640
|
+
}
|
|
641
|
+
return "blocked", "no actionable native internal cursor; review state without inventing authority", nil
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Only exact position/slug or position/workspace tables are structural authority.
|
|
645
|
+
// Different maps require native normalization, even if prose claims one supersedes another.
|
|
646
|
+
func frontierRoadmap(nodes map[string]*frontierWorkspace, ordered []string) (map[int]string, []string, string) {
|
|
647
|
+
result := map[int]string{}
|
|
648
|
+
var artifacts []string
|
|
649
|
+
problem := ""
|
|
650
|
+
var first map[int]string
|
|
651
|
+
for _, slug := range ordered {
|
|
652
|
+
for _, name := range []string{"decisions.md", "roadmap.md", "spec.md"} {
|
|
653
|
+
path := filepath.Join(nodes[slug].dir, name)
|
|
654
|
+
if nodes[slug].dir == "" {
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
raw, err := readBoundedRegularFile(path, workspaceArtifactLimit)
|
|
658
|
+
if os.IsNotExist(err) {
|
|
659
|
+
continue
|
|
660
|
+
}
|
|
661
|
+
artifacts = append(artifacts, path)
|
|
662
|
+
if err != nil {
|
|
663
|
+
problem = "recorded artifact unreadable"
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
raw, err = markdowntext.Structural(raw)
|
|
667
|
+
if err != nil {
|
|
668
|
+
problem = "recorded artifact malformed"
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
lines := strings.Split(string(raw), "\n")
|
|
672
|
+
for i := 0; i < len(lines); i++ {
|
|
673
|
+
cells := frontierCells(lines[i])
|
|
674
|
+
pos, workspace, headings := -1, -1, 0
|
|
675
|
+
for j, cell := range cells {
|
|
676
|
+
switch strings.ToLower(cell) {
|
|
677
|
+
case "position":
|
|
678
|
+
pos = j
|
|
679
|
+
headings++
|
|
680
|
+
case "slug", "workspace":
|
|
681
|
+
workspace = j
|
|
682
|
+
headings++
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if pos < 0 || workspace < 0 {
|
|
686
|
+
continue
|
|
687
|
+
}
|
|
688
|
+
if headings != 2 {
|
|
689
|
+
problem = "ambiguous position/workspace headers"
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
table := map[int]string{}
|
|
693
|
+
seen := map[string]bool{}
|
|
694
|
+
for i++; i < len(lines); i++ {
|
|
695
|
+
row := frontierCells(lines[i])
|
|
696
|
+
if len(row) != len(cells) {
|
|
697
|
+
break
|
|
698
|
+
}
|
|
699
|
+
if strings.Trim(row[pos], "-: ") == "" {
|
|
700
|
+
continue
|
|
701
|
+
}
|
|
702
|
+
n, err := strconv.Atoi(row[pos])
|
|
703
|
+
target := strings.Trim(row[workspace], "`")
|
|
704
|
+
if err != nil || n < 1 || strconv.Itoa(n) != row[pos] || !devritespaths.ValidSlug(target) || strings.ContainsAny(target, " \t") || strings.HasPrefix(target, "-") {
|
|
705
|
+
problem = "ambiguous position/workspace table"
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
if table[n] != "" || seen[target] {
|
|
709
|
+
problem = "duplicate sequence position or workspace"
|
|
710
|
+
}
|
|
711
|
+
table[n], seen[target] = target, true
|
|
712
|
+
}
|
|
713
|
+
if len(table) == 0 {
|
|
714
|
+
problem = "empty position/workspace table"
|
|
715
|
+
continue
|
|
716
|
+
}
|
|
717
|
+
if first == nil {
|
|
718
|
+
first = table
|
|
719
|
+
} else {
|
|
720
|
+
if len(first) != len(table) {
|
|
721
|
+
problem = "conflicting or amended sequence maps"
|
|
722
|
+
}
|
|
723
|
+
for n, target := range table {
|
|
724
|
+
if first[n] != target {
|
|
725
|
+
problem = "conflicting or amended sequence maps"
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
for n, target := range table {
|
|
730
|
+
result[n] = target
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
for _, slug := range ordered {
|
|
736
|
+
n := nodes[slug]
|
|
737
|
+
if n.hasPosition && result[n.position] != "" && result[n.position] != slug {
|
|
738
|
+
problem = "live cursor conflicts with recorded sequence map"
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if len(artifacts) == 0 {
|
|
742
|
+
for _, slug := range ordered {
|
|
743
|
+
artifacts = append(artifacts, filepath.Join(nodes[slug].dir, "state.md"))
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return result, artifacts, problem
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
func frontierCells(line string) []string {
|
|
750
|
+
line = strings.TrimSpace(line)
|
|
751
|
+
if !strings.HasPrefix(line, "|") || !strings.HasSuffix(line, "|") {
|
|
752
|
+
return nil
|
|
753
|
+
}
|
|
754
|
+
cells := strings.Split(strings.Trim(line, "|"), "|")
|
|
755
|
+
for i := range cells {
|
|
756
|
+
cells[i] = strings.TrimSpace(cells[i])
|
|
757
|
+
}
|
|
758
|
+
return cells
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
func frontierBoundSpec(root, slug, action string) bool {
|
|
762
|
+
nodes, err := frontierScan(root)
|
|
763
|
+
if err != nil {
|
|
764
|
+
return false
|
|
765
|
+
}
|
|
766
|
+
n := nodes[slug]
|
|
767
|
+
if n == nil || n.next != action || n.parent == "" && !n.hasPosition && !n.hasRemaining {
|
|
768
|
+
return false
|
|
769
|
+
}
|
|
770
|
+
target := frontierSpecTarget(n)
|
|
771
|
+
if target == "" {
|
|
772
|
+
return false
|
|
773
|
+
}
|
|
774
|
+
chain, err := frontierChain(nodes, slug)
|
|
775
|
+
if frontierConflictingSuccessor(nodes, n, target) {
|
|
776
|
+
return false
|
|
777
|
+
}
|
|
778
|
+
if err != nil {
|
|
779
|
+
return false
|
|
780
|
+
}
|
|
781
|
+
allow, _, err := frontierPolicy(root)
|
|
782
|
+
if err != nil {
|
|
783
|
+
return false
|
|
784
|
+
}
|
|
785
|
+
if child := nodes[target]; child != nil {
|
|
786
|
+
if child.parent != slug {
|
|
787
|
+
return false
|
|
788
|
+
}
|
|
789
|
+
if _, err := frontierChain(nodes, target); err != nil {
|
|
790
|
+
return false
|
|
791
|
+
}
|
|
792
|
+
return true
|
|
793
|
+
}
|
|
794
|
+
mapping, _, problem := frontierRoadmap(nodes, chain)
|
|
795
|
+
if problem != "" || n.hasPosition && mapping[n.position+1] != "" && mapping[n.position+1] != target {
|
|
796
|
+
return false
|
|
797
|
+
}
|
|
798
|
+
return allow && (!n.hasRemaining || n.remaining > 0)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
func frontierConflictingSuccessor(nodes map[string]*frontierWorkspace, source *frontierWorkspace, target string) bool {
|
|
802
|
+
for _, child := range nodes {
|
|
803
|
+
if child.parent == source.slug && child.slug != target && source.hasPosition && child.hasPosition && child.position == source.position+1 {
|
|
804
|
+
return true
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return false
|
|
808
|
+
}
|