devrites 4.2.0 → 4.4.0
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 +6 -0
- package/README.md +1 -1
- package/docs/cli.md +15 -0
- package/docs/engine/commands.md +20 -2
- package/docs/orchestration.md +6 -3
- package/engine/commands.go +1 -1
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/open_visual.go +254 -0
- package/engine/internal/lib/open_visual_test.go +280 -0
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/visual_outline.go +163 -0
- package/engine/internal/lib/visual_outline_test.go +161 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- package/engine/internal/state/status.go +41 -16
- package/engine/main.go +76 -3
- package/engine/root_routing_test.go +50 -15
- package/engine/testdata/visual/open-visual-smoke.html +675 -0
- package/engine/testdata/visual/open-visual-smoke.outline.md +54 -0
- package/pack/.claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/.claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/.claude/skills/rite-build/SKILL.md +29 -36
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-define/SKILL.md +10 -1
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/.claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/.claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/claude/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/claude/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/claude/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +26 -3
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +2 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/code.md +46 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/comparison.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/diagram.md +53 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/index.md +57 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/input.md +47 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/outline-template.md +106 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/plan.md +49 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/slides.md +42 -0
- package/pack/generated/codex/skills/devrites-lib/reference/visual-playbooks/table.md +44 -0
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +24 -9
- package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-define/SKILL.md +10 -1
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/codex/skills/rite-explain/SKILL.md +23 -7
- package/pack/generated/codex/skills/rite-explain/reference/intake.md +11 -2
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +5 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +26 -3
- package/package.json +1 -1
- package/scripts/install-lib.sh +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func gitOk(t *testing.T, dir string, args ...string) string {
|
|
12
|
+
t.Helper()
|
|
13
|
+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
14
|
+
cmd.Env = append(os.Environ(),
|
|
15
|
+
"GIT_CONFIG_NOSYSTEM=1",
|
|
16
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
17
|
+
"GIT_AUTHOR_NAME=DevRites Test",
|
|
18
|
+
"GIT_AUTHOR_EMAIL=devrites@example.invalid",
|
|
19
|
+
"GIT_COMMITTER_NAME=DevRites Test",
|
|
20
|
+
"GIT_COMMITTER_EMAIL=devrites@example.invalid",
|
|
21
|
+
)
|
|
22
|
+
out, err := cmd.CombinedOutput()
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
25
|
+
}
|
|
26
|
+
return strings.TrimSpace(string(out))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func setupRepo(t *testing.T) (repo, base string) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
repo = t.TempDir()
|
|
32
|
+
gitOk(t, repo, "init")
|
|
33
|
+
gitOk(t, repo, "checkout", "-b", "main")
|
|
34
|
+
if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "a.go"), []byte("package main\n"), 0o644); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "b.go"), []byte("package main\n"), 0o644); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("base\n"), 0o644); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
gitOk(t, repo, "add", ".")
|
|
47
|
+
gitOk(t, repo, "commit", "-m", "base")
|
|
48
|
+
base = gitOk(t, repo, "rev-parse", "HEAD")
|
|
49
|
+
return repo, base
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestLeaseRoundTrip(t *testing.T) {
|
|
53
|
+
t.Parallel()
|
|
54
|
+
dir := t.TempDir()
|
|
55
|
+
path := filepath.Join(dir, "parallel-lease.md")
|
|
56
|
+
lease := &Lease{
|
|
57
|
+
BatchID: "batch1",
|
|
58
|
+
CreatedAt: "2026-08-23T00:00:00Z",
|
|
59
|
+
BaseSHA: "abcdef1",
|
|
60
|
+
N: 2,
|
|
61
|
+
Status: StatusRunning,
|
|
62
|
+
ControlPIDOrSession: "test",
|
|
63
|
+
Slices: []LeaseSlice{
|
|
64
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}, WorktreePath: "/tmp/a", Branch: "devrites/parallel/x/batch1/slice-a", WrightStatus: WrightPending},
|
|
65
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}, WorktreePath: "/tmp/b", Branch: "devrites/parallel/x/batch1/slice-b", WrightStatus: WrightPending},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
if err := WriteLease(path, lease); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
got, err := ReadLease(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
if got.BatchID != "batch1" || got.Status != StatusRunning || len(got.Slices) != 2 {
|
|
76
|
+
t.Fatalf("unexpected lease: %#v", got)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestCreateAbortCleanup(t *testing.T) {
|
|
81
|
+
repo, base := setupRepo(t)
|
|
82
|
+
slug := "demo-feature"
|
|
83
|
+
batch := "batch1"
|
|
84
|
+
lease, err := Create(CreateOpts{
|
|
85
|
+
Root: repo,
|
|
86
|
+
Slug: slug,
|
|
87
|
+
BatchID: batch,
|
|
88
|
+
BaseSHA: base,
|
|
89
|
+
Slices: []SlicePaths{
|
|
90
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
91
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
if err != nil {
|
|
95
|
+
t.Fatal(err)
|
|
96
|
+
}
|
|
97
|
+
if lease.Status != StatusRunning || lease.N != 2 {
|
|
98
|
+
t.Fatalf("lease=%#v", lease)
|
|
99
|
+
}
|
|
100
|
+
wtA := lease.Slices[0].WorktreePath
|
|
101
|
+
wtB := lease.Slices[1].WorktreePath
|
|
102
|
+
for _, wt := range []string{wtA, wtB} {
|
|
103
|
+
if _, err := os.Stat(wt); err != nil {
|
|
104
|
+
t.Fatalf("missing worktree %s: %v", wt, err)
|
|
105
|
+
}
|
|
106
|
+
if head := gitOk(t, wt, "rev-parse", "HEAD"); head != base {
|
|
107
|
+
t.Fatalf("worktree head %s want %s", head, base)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
111
|
+
t.Fatalf("control moved after create: %s", head)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Drift control tip, then abort should restore base.
|
|
115
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
gitOk(t, repo, "add", "README.md")
|
|
119
|
+
gitOk(t, repo, "commit", "-m", "drift")
|
|
120
|
+
if _, err := Abort(repo, slug, true); err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
124
|
+
t.Fatalf("abort left control at %s want %s", head, base)
|
|
125
|
+
}
|
|
126
|
+
leasePath, _ := LeasePath(repo, slug)
|
|
127
|
+
lease, err = ReadLease(leasePath)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
if lease.Status != StatusAborted {
|
|
132
|
+
t.Fatalf("status=%s", lease.Status)
|
|
133
|
+
}
|
|
134
|
+
if _, err := os.Stat(wtA); err != nil {
|
|
135
|
+
t.Fatalf("abort should preserve worktree: %v", err)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if err := Cleanup(repo, slug, true); err != nil {
|
|
139
|
+
t.Fatal(err)
|
|
140
|
+
}
|
|
141
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
142
|
+
t.Fatalf("cleanup should remove worktree A")
|
|
143
|
+
}
|
|
144
|
+
if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
|
|
145
|
+
t.Fatalf("cleanup should clear lease")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestCreateRefusesOverlap(t *testing.T) {
|
|
150
|
+
repo, base := setupRepo(t)
|
|
151
|
+
_, err := Create(CreateOpts{
|
|
152
|
+
Root: repo,
|
|
153
|
+
Slug: "demo",
|
|
154
|
+
BatchID: "batch1",
|
|
155
|
+
BaseSHA: base,
|
|
156
|
+
Slices: []SlicePaths{
|
|
157
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
158
|
+
{ID: "slice-b", Paths: []string{"src/a.go"}},
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
if err == nil || !strings.Contains(err.Error(), "overlap") {
|
|
162
|
+
t.Fatalf("expected overlap refusal, got %v", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestIntegrateDivergentSiblings(t *testing.T) {
|
|
167
|
+
repo, base := setupRepo(t)
|
|
168
|
+
slug := "demo-feature"
|
|
169
|
+
batch := "batch1"
|
|
170
|
+
lease, err := Create(CreateOpts{
|
|
171
|
+
Root: repo,
|
|
172
|
+
Slug: slug,
|
|
173
|
+
BatchID: batch,
|
|
174
|
+
BaseSHA: base,
|
|
175
|
+
Slices: []SlicePaths{
|
|
176
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
177
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
if err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
wtA := lease.Slices[0].WorktreePath
|
|
184
|
+
wtB := lease.Slices[1].WorktreePath
|
|
185
|
+
|
|
186
|
+
if err := os.WriteFile(filepath.Join(wtA, "src", "a.go"), []byte("package main\n\nfunc A() {}\n"), 0o644); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
gitOk(t, wtA, "add", "src/a.go")
|
|
190
|
+
gitOk(t, wtA, "commit", "-m", "slice-a green")
|
|
191
|
+
tcA := gitOk(t, wtA, "rev-parse", "HEAD")
|
|
192
|
+
|
|
193
|
+
if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc B() {}\n"), 0o644); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
gitOk(t, wtB, "add", "src/b.go")
|
|
197
|
+
gitOk(t, wtB, "commit", "-m", "slice-b green")
|
|
198
|
+
tcB := gitOk(t, wtB, "rev-parse", "HEAD")
|
|
199
|
+
|
|
200
|
+
if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
|
|
208
|
+
if err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
if lease.Status != StatusComplete {
|
|
212
|
+
t.Fatalf("status=%s", lease.Status)
|
|
213
|
+
}
|
|
214
|
+
if tip == "" || tip == base {
|
|
215
|
+
t.Fatalf("expected advanced tip, got %s", tip)
|
|
216
|
+
}
|
|
217
|
+
head := gitOk(t, repo, "rev-parse", "HEAD")
|
|
218
|
+
if head != tip {
|
|
219
|
+
t.Fatalf("control head %s want tip %s", head, tip)
|
|
220
|
+
}
|
|
221
|
+
a := mustRead(t, filepath.Join(repo, "src", "a.go"))
|
|
222
|
+
b := mustRead(t, filepath.Join(repo, "src", "b.go"))
|
|
223
|
+
if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
|
|
224
|
+
t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if err := Cleanup(repo, slug, false); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
231
|
+
t.Fatalf("cleanup should remove workers")
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func mustRead(t *testing.T, path string) string {
|
|
236
|
+
t.Helper()
|
|
237
|
+
b, err := os.ReadFile(path)
|
|
238
|
+
if err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
return string(b)
|
|
242
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Package parallel implements deterministic parallel-worktree control ops:
|
|
2
|
+
// path-disjoint checks, advisory leases, and git worktree create/integrate/cleanup.
|
|
3
|
+
package parallel
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
var winAbsRE = regexp.MustCompile(`(?i)^[A-Za-z]:[/\\]`)
|
|
16
|
+
|
|
17
|
+
// SlicePaths is one path-disjoint eligibility unit.
|
|
18
|
+
type SlicePaths struct {
|
|
19
|
+
ID string `json:"id"`
|
|
20
|
+
Paths []string `json:"paths"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NormalizePath mirrors scripts/check-path-disjoint.py: slash-normalize, reject
|
|
24
|
+
// empty/absolute/.. paths, and drop empty/"." segments.
|
|
25
|
+
func NormalizePath(raw string) (string, error) {
|
|
26
|
+
path := strings.TrimSpace(strings.ReplaceAll(raw, `\`, "/"))
|
|
27
|
+
if path == "" {
|
|
28
|
+
return "", fmt.Errorf("empty path is not allowed")
|
|
29
|
+
}
|
|
30
|
+
if strings.HasPrefix(path, "/") || winAbsRE.MatchString(path) {
|
|
31
|
+
return "", fmt.Errorf("path must be project-relative, not absolute: %q", raw)
|
|
32
|
+
}
|
|
33
|
+
parts := make([]string, 0, strings.Count(path, "/")+1)
|
|
34
|
+
for part := range strings.SplitSeq(path, "/") {
|
|
35
|
+
if part == "" || part == "." {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if part == ".." {
|
|
39
|
+
return "", fmt.Errorf("path must not contain '..': %q", raw)
|
|
40
|
+
}
|
|
41
|
+
parts = append(parts, part)
|
|
42
|
+
}
|
|
43
|
+
if len(parts) == 0 {
|
|
44
|
+
return "", fmt.Errorf("empty path is not allowed")
|
|
45
|
+
}
|
|
46
|
+
return strings.Join(parts, "/"), nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func validateSlicePaths(paths []string, label, root string) ([]string, error) {
|
|
50
|
+
if paths == nil {
|
|
51
|
+
return nil, fmt.Errorf("%s: paths must be a list", label)
|
|
52
|
+
}
|
|
53
|
+
normalized := make([]string, 0, len(paths))
|
|
54
|
+
seen := make(map[string]struct{}, len(paths))
|
|
55
|
+
for _, raw := range paths {
|
|
56
|
+
path, err := NormalizePath(raw)
|
|
57
|
+
if err != nil {
|
|
58
|
+
return nil, fmt.Errorf("%s: %w", label, err)
|
|
59
|
+
}
|
|
60
|
+
// Parallel eligibility must reject workspace control metadata so slices
|
|
61
|
+
// cannot claim control-tree / SSOT files (docs: ".devrites/**").
|
|
62
|
+
if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
|
|
63
|
+
return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
|
|
64
|
+
}
|
|
65
|
+
if _, ok := seen[path]; ok {
|
|
66
|
+
return nil, fmt.Errorf("%s: duplicate path %q", label, path)
|
|
67
|
+
}
|
|
68
|
+
seen[path] = struct{}{}
|
|
69
|
+
normalized = append(normalized, path)
|
|
70
|
+
if root != "" {
|
|
71
|
+
full := filepath.Join(root, filepath.FromSlash(path))
|
|
72
|
+
if info, err := os.Lstat(full); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
73
|
+
return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return normalized, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CheckPathDisjoint returns slice ids when every pair of path sets is disjoint.
|
|
81
|
+
func CheckPathDisjoint(slices []SlicePaths, root string) ([]string, error) {
|
|
82
|
+
if len(slices) < 2 {
|
|
83
|
+
return nil, fmt.Errorf("need at least two slices to check path-disjoint eligibility")
|
|
84
|
+
}
|
|
85
|
+
owners := make(map[string][]string)
|
|
86
|
+
ids := make([]string, 0, len(slices))
|
|
87
|
+
for index, item := range slices {
|
|
88
|
+
label := fmt.Sprintf("slice %d", index)
|
|
89
|
+
if item.ID != "" {
|
|
90
|
+
label = fmt.Sprintf("slice %q", item.ID)
|
|
91
|
+
ids = append(ids, item.ID)
|
|
92
|
+
} else {
|
|
93
|
+
ids = append(ids, fmt.Sprintf("%d", index))
|
|
94
|
+
}
|
|
95
|
+
paths, err := validateSlicePaths(item.Paths, label, root)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return nil, err
|
|
98
|
+
}
|
|
99
|
+
for _, path := range paths {
|
|
100
|
+
owners[path] = append(owners[path], label)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var overlaps []string
|
|
104
|
+
for path, labels := range owners {
|
|
105
|
+
if len(labels) > 1 {
|
|
106
|
+
overlaps = append(overlaps, fmt.Sprintf("%q shared by %s", path, strings.Join(labels, ", ")))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if len(overlaps) > 0 {
|
|
110
|
+
sort.Strings(overlaps)
|
|
111
|
+
return nil, fmt.Errorf("path sets overlap: %s", strings.Join(overlaps, "; "))
|
|
112
|
+
}
|
|
113
|
+
return ids, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ParseSlicesJSON accepts {"slices":[...]} or a top-level slices array.
|
|
117
|
+
func ParseSlicesJSON(data []byte) ([]SlicePaths, error) {
|
|
118
|
+
var asObject struct {
|
|
119
|
+
Slices []SlicePaths `json:"slices"`
|
|
120
|
+
}
|
|
121
|
+
if err := json.Unmarshal(data, &asObject); err == nil && asObject.Slices != nil {
|
|
122
|
+
return asObject.Slices, nil
|
|
123
|
+
}
|
|
124
|
+
var asList []SlicePaths
|
|
125
|
+
if err := json.Unmarshal(data, &asList); err == nil {
|
|
126
|
+
return asList, nil
|
|
127
|
+
}
|
|
128
|
+
return nil, fmt.Errorf(`input must be {"slices": [...]} or a top-level slices array`)
|
|
129
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestNormalizePathRejectsDirty(t *testing.T) {
|
|
11
|
+
t.Parallel()
|
|
12
|
+
cases := []string{"", " ", "/", "/abs", `C:\windows`, `..`, "a/../b", "../x"}
|
|
13
|
+
for _, raw := range cases {
|
|
14
|
+
if _, err := NormalizePath(raw); err == nil {
|
|
15
|
+
t.Fatalf("NormalizePath(%q) should fail", raw)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestNormalizePathAcceptsRelative(t *testing.T) {
|
|
21
|
+
t.Parallel()
|
|
22
|
+
got, err := NormalizePath(`src\foo.go`)
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
if got != "src/foo.go" {
|
|
27
|
+
t.Fatalf("got %q", got)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestCheckPathDisjointOverlap(t *testing.T) {
|
|
32
|
+
t.Parallel()
|
|
33
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
34
|
+
{ID: "a", Paths: []string{"src/a.go"}},
|
|
35
|
+
{ID: "b", Paths: []string{"src/a.go"}},
|
|
36
|
+
}, "")
|
|
37
|
+
if err == nil || !strings.Contains(err.Error(), "overlap") {
|
|
38
|
+
t.Fatalf("expected overlap error, got %v", err)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestCheckPathDisjointOK(t *testing.T) {
|
|
43
|
+
t.Parallel()
|
|
44
|
+
ids, err := CheckPathDisjoint([]SlicePaths{
|
|
45
|
+
{ID: "a", Paths: []string{"src/a.go"}},
|
|
46
|
+
{ID: "b", Paths: []string{"src/b.go"}},
|
|
47
|
+
}, "")
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatal(err)
|
|
50
|
+
}
|
|
51
|
+
if len(ids) != 2 {
|
|
52
|
+
t.Fatalf("ids=%v", ids)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestCheckPathDisjointRejectsDevritesPaths(t *testing.T) {
|
|
57
|
+
t.Parallel()
|
|
58
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
59
|
+
{ID: "a", Paths: []string{".devrites/work/state.md"}},
|
|
60
|
+
{ID: "b", Paths: []string{"src/b.go"}},
|
|
61
|
+
}, "")
|
|
62
|
+
if err == nil || !strings.Contains(err.Error(), ".devrites") {
|
|
63
|
+
t.Fatalf("expected .devrites rejection error, got %v", err)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
|
|
68
|
+
t.Parallel()
|
|
69
|
+
dir := t.TempDir()
|
|
70
|
+
target := filepath.Join(dir, "real.go")
|
|
71
|
+
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
link := filepath.Join(dir, "link.go")
|
|
75
|
+
if err := os.Symlink(target, link); err != nil {
|
|
76
|
+
t.Skip("symlinks unavailable")
|
|
77
|
+
}
|
|
78
|
+
_, err := CheckPathDisjoint([]SlicePaths{
|
|
79
|
+
{ID: "a", Paths: []string{"link.go"}},
|
|
80
|
+
{ID: "b", Paths: []string{"other.go"}},
|
|
81
|
+
}, dir)
|
|
82
|
+
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
|
83
|
+
t.Fatalf("expected symlink error, got %v", err)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func TestParseSlicesJSONShapes(t *testing.T) {
|
|
88
|
+
t.Parallel()
|
|
89
|
+
a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
|
|
90
|
+
if err != nil || len(a) != 2 {
|
|
91
|
+
t.Fatalf("object shape: %v %#v", err, a)
|
|
92
|
+
}
|
|
93
|
+
b, err := ParseSlicesJSON([]byte(`[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]`))
|
|
94
|
+
if err != nil || len(b) != 2 {
|
|
95
|
+
t.Fatalf("array shape: %v %#v", err, b)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -7,14 +7,17 @@ import (
|
|
|
7
7
|
|
|
8
8
|
// Report is the computed completeness status of a feature at its current phase.
|
|
9
9
|
type Report struct {
|
|
10
|
-
Slug
|
|
11
|
-
Phase
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
Slug string
|
|
11
|
+
Phase Phase
|
|
12
|
+
Status string
|
|
13
|
+
NextAction string
|
|
14
|
+
PrinciplesPresent bool
|
|
15
|
+
Required map[Section]bool
|
|
16
|
+
Missing []Section // required-but-empty sections, in policy order
|
|
17
|
+
RequiredFiles map[string]bool
|
|
18
|
+
MissingFiles []string // required-but-empty workspace files, in lifecycle order
|
|
19
|
+
present map[Section]bool
|
|
20
|
+
diagnostics []ArtifactDiagnostic
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
// Status computes phase-relative completeness from one retained workspace observation.
|
|
@@ -35,6 +38,24 @@ func statusWithCallback(root, slug string, callback observationCallback) (*Repor
|
|
|
35
38
|
return newObservationReport(observation, phase, policy), nil
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
func observationCursorFields(observation *WorkspaceObservation) (status, nextAction string, principlesPresent bool) {
|
|
42
|
+
if fact, ok := observation.Fact(".devrites/principles.md"); ok && fact.State() == ArtifactPresent {
|
|
43
|
+
principlesPresent = true
|
|
44
|
+
}
|
|
45
|
+
fact, ok := observation.Fact(LedgerFile)
|
|
46
|
+
if !ok || fact.State() != ArtifactPresent {
|
|
47
|
+
return "", "", principlesPresent
|
|
48
|
+
}
|
|
49
|
+
lines := strings.Split(string(fact.Bytes()), "\n")
|
|
50
|
+
if value, ok := CursorField(lines, CursorStatus); ok {
|
|
51
|
+
status = value
|
|
52
|
+
}
|
|
53
|
+
if value, ok := CursorField(lines, CursorNextAction); ok {
|
|
54
|
+
nextAction = value
|
|
55
|
+
}
|
|
56
|
+
return status, nextAction, principlesPresent
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
func newObservationReport(observation *WorkspaceObservation, phase Phase, policy PhasePolicy) *Report {
|
|
39
60
|
present := observationSectionPresence(observation)
|
|
40
61
|
required := requiredSections(policy)
|
|
@@ -44,15 +65,19 @@ func newObservationReport(observation *WorkspaceObservation, phase Phase, policy
|
|
|
44
65
|
for i, artifact := range missingArtifacts {
|
|
45
66
|
missingFiles[i] = string(artifact)
|
|
46
67
|
}
|
|
68
|
+
status, nextAction, principlesPresent := observationCursorFields(observation)
|
|
47
69
|
return &Report{
|
|
48
|
-
Slug:
|
|
49
|
-
Phase:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
Slug: observation.Slug(),
|
|
71
|
+
Phase: phase,
|
|
72
|
+
Status: status,
|
|
73
|
+
NextAction: nextAction,
|
|
74
|
+
PrinciplesPresent: principlesPresent,
|
|
75
|
+
Required: required,
|
|
76
|
+
Missing: missingObservationSections(present, policy.RequiredSections),
|
|
77
|
+
RequiredFiles: requiredFiles,
|
|
78
|
+
MissingFiles: missingFiles,
|
|
79
|
+
present: present,
|
|
80
|
+
diagnostics: diagnostics,
|
|
56
81
|
}
|
|
57
82
|
}
|
|
58
83
|
|
package/engine/main.go
CHANGED
|
@@ -10,6 +10,7 @@ import (
|
|
|
10
10
|
"github.com/devrites/devrites/internal/gate"
|
|
11
11
|
"github.com/devrites/devrites/internal/install"
|
|
12
12
|
"github.com/devrites/devrites/internal/lib"
|
|
13
|
+
"github.com/devrites/devrites/internal/parallel"
|
|
13
14
|
"github.com/devrites/devrites/internal/state"
|
|
14
15
|
"github.com/devrites/devrites/internal/version"
|
|
15
16
|
)
|
|
@@ -24,9 +25,17 @@ Usage:
|
|
|
24
25
|
devrites-engine check readiness <slug> Check required files and the stable Build-input binding
|
|
25
26
|
devrites-engine check readiness --emit-binding <slug> Emit the stable Build-input binding for Vet
|
|
26
27
|
devrites-engine check seal <slug> Recheck the Build-input binding, final files, and evidence freshness
|
|
28
|
+
devrites-engine check path-disjoint [--root <dir>] [<json-file>|-]
|
|
29
|
+
Verify slice path sets are pairwise disjoint
|
|
30
|
+
devrites-engine check task-graph <slug> Validate tasks.md slice dependency graph
|
|
31
|
+
devrites-engine check skill-trust <path> Scan one skill/agent Markdown for trust violations
|
|
32
|
+
devrites-engine observe summary <slug> Emit sanitized JSON workspace summary
|
|
33
|
+
devrites-engine parallel <subcommand> Deterministic parallel worktree lease/create/integrate/cleanup
|
|
27
34
|
devrites-engine state resolve <qid> "<ans>" Resolve an open question and update state atomically
|
|
28
35
|
devrites-engine state close <slug> Archive a shipped feature and clear ACTIVE
|
|
29
36
|
devrites-engine secret-scan [--staged] [--stdin] [slug] Scan exact staged blobs, stdin, or touched files; HIGH blocks
|
|
37
|
+
devrites-engine open-visual <path-or-name> [--slug <slug>] [--no-open]
|
|
38
|
+
Resolve a visual HTML file, optionally open it locally, print agent paths
|
|
30
39
|
devrites-engine version Print the engine binary's version
|
|
31
40
|
Exit codes:
|
|
32
41
|
0 ok / gate passed
|
|
@@ -71,11 +80,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
|
71
80
|
case "uninstall":
|
|
72
81
|
return install.Run(args[1:], stdout, stderr, install.ModeUninstall)
|
|
73
82
|
case "check":
|
|
74
|
-
return cmdCheck(args[1:], stdout, stderr)
|
|
83
|
+
return cmdCheck(root, args[1:], stdin, stdout, stderr)
|
|
84
|
+
case "parallel":
|
|
85
|
+
return parallel.Run("parallel", args[1:], stdin, stdout, stderr)
|
|
86
|
+
case "observe":
|
|
87
|
+
return cmdObserve(root, args[1:], stdout, stderr)
|
|
75
88
|
case "state":
|
|
76
89
|
return cmdState(root, args[1:], stdout, stderr)
|
|
77
90
|
case "secret-scan":
|
|
78
91
|
return lib.SecretScan(root, args[1:], stdin, stdout, stderr)
|
|
92
|
+
case "open-visual":
|
|
93
|
+
return lib.OpenVisual(root, args[1:], stdout, stderr)
|
|
79
94
|
case "version", "--version":
|
|
80
95
|
fmt.Fprintln(stdout, version.Version)
|
|
81
96
|
return exitOK
|
|
@@ -88,9 +103,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
func cmdCheck(args []string, stdout, stderr io.Writer) int {
|
|
106
|
+
func cmdCheck(root string, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
92
107
|
if len(args) == 0 {
|
|
93
|
-
fmt.Fprintln(stderr, "usage: devrites-engine check <candidate|readiness|seal> ...")
|
|
108
|
+
fmt.Fprintln(stderr, "usage: devrites-engine check <candidate|readiness|seal|path-disjoint|task-graph|skill-trust> ...")
|
|
94
109
|
return exitUsage
|
|
95
110
|
}
|
|
96
111
|
sub, rest := args[0], args[1:]
|
|
@@ -99,6 +114,12 @@ func cmdCheck(args []string, stdout, stderr io.Writer) int {
|
|
|
99
114
|
return cmdCandidate(rest, stdout, stderr)
|
|
100
115
|
case "readiness", "seal":
|
|
101
116
|
return cmdGate(gate.Kind(sub), rest, stdout, stderr)
|
|
117
|
+
case "path-disjoint":
|
|
118
|
+
return parallel.Run("path-disjoint", rest, stdin, stdout, stderr)
|
|
119
|
+
case "task-graph":
|
|
120
|
+
return cmdTaskGraph(root, rest, stdout, stderr)
|
|
121
|
+
case "skill-trust":
|
|
122
|
+
return cmdSkillTrust(rest, stdout, stderr)
|
|
102
123
|
default:
|
|
103
124
|
fmt.Fprintf(stderr, "devrites: unknown check %q\n", sub)
|
|
104
125
|
return exitUsage
|
|
@@ -185,3 +206,55 @@ func cmdGate(kind gate.Kind, args []string, stdout, stderr io.Writer) int {
|
|
|
185
206
|
fmt.Fprint(stdout, result.Render())
|
|
186
207
|
return exitOK
|
|
187
208
|
}
|
|
209
|
+
|
|
210
|
+
func cmdTaskGraph(root string, args []string, stdout, stderr io.Writer) int {
|
|
211
|
+
if len(args) != 1 {
|
|
212
|
+
fmt.Fprintln(stderr, "usage: devrites-engine check task-graph <slug>")
|
|
213
|
+
return exitUsage
|
|
214
|
+
}
|
|
215
|
+
if root == "" {
|
|
216
|
+
var err error
|
|
217
|
+
root, err = state.ResolveRoot(os.Getenv("DEVRITES_ROOT"))
|
|
218
|
+
if err != nil {
|
|
219
|
+
fmt.Fprintf(stderr, "devrites: %v\n", err)
|
|
220
|
+
return exitUsage
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return lib.RunTaskGraphCheck(root, args[0], stdout, stderr)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func cmdSkillTrust(args []string, stdout, stderr io.Writer) int {
|
|
227
|
+
if len(args) != 1 {
|
|
228
|
+
fmt.Fprintln(stderr, "usage: devrites-engine check skill-trust <path>")
|
|
229
|
+
return exitUsage
|
|
230
|
+
}
|
|
231
|
+
return lib.RunSkillTrustCheck(args[0], stdout, stderr)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func cmdObserve(root string, args []string, stdout, stderr io.Writer) int {
|
|
235
|
+
if len(args) == 0 {
|
|
236
|
+
fmt.Fprintln(stderr, "usage: devrites-engine observe summary <slug>")
|
|
237
|
+
return exitUsage
|
|
238
|
+
}
|
|
239
|
+
if args[0] != "summary" {
|
|
240
|
+
fmt.Fprintf(stderr, "devrites: unknown observe command %q\n", args[0])
|
|
241
|
+
return exitUsage
|
|
242
|
+
}
|
|
243
|
+
if root == "" {
|
|
244
|
+
var err error
|
|
245
|
+
root, err = state.ResolveRoot(os.Getenv("DEVRITES_ROOT"))
|
|
246
|
+
if err != nil {
|
|
247
|
+
fmt.Fprintf(stderr, "devrites: %v\n", err)
|
|
248
|
+
return exitUsage
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
slug, code, err := lib.ActiveSlug(root, args[1:])
|
|
252
|
+
if err != nil {
|
|
253
|
+
fmt.Fprintf(stderr, "observe: %v\n", err)
|
|
254
|
+
if code == 0 {
|
|
255
|
+
code = exitUsage
|
|
256
|
+
}
|
|
257
|
+
return code
|
|
258
|
+
}
|
|
259
|
+
return lib.RunObserveSummary(root, slug, stdout, stderr)
|
|
260
|
+
}
|