devrites 3.2.16 → 3.2.17
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 +8 -0
- package/README.md +1 -1
- package/engine/hooks.go +20 -4
- package/engine/hooks_agent_dispatch.go +297 -48
- package/engine/hooks_events_test.go +40 -0
- package/engine/hooks_workspace.go +26 -6
- package/engine/internal/devritespaths/paths.go +80 -0
- package/engine/internal/lib/adoption.go +22 -7
- package/engine/internal/lib/adoption_test.go +30 -0
- package/engine/internal/lib/closeout.go +34 -11
- package/engine/internal/lib/closeout_test.go +99 -0
- package/engine/internal/lib/dispatchwaive.go +31 -0
- package/engine/internal/lib/dispatchwaive_test.go +23 -0
- package/engine/internal/lib/mutationgate.go +6 -2
- package/engine/internal/lib/reconcile.go +201 -64
- package/engine/internal/lib/reconcile_test.go +159 -12
- package/engine/internal/lib/testintegrity.go +4 -4
- package/engine/internal/lib/testintegrity_test.go +15 -0
- package/engine/internal/lib/util.go +128 -23
- package/engine/main.go +4 -1
- package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
- package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
- package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
- package/engine/tests/hook_test.go +353 -17
- package/engine/tests/parity_reconcile_test.go +4 -1
- package/engine/tests/parity_testintegrity_test.go +1 -1
- package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-forge-judge.md +4 -1
- package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-proof-runner.md +14 -14
- package/pack/.claude/agents/devrites-retrospector.md +3 -0
- package/pack/.claude/agents/devrites-security-auditor.md +3 -0
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-slice-wright.md +4 -2
- package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-test-analyst.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
- package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
- package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
- package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
- package/pack/generated/codex/hooks.json +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
- package/package.json +1 -1
- package/scripts/codex-generate.sh +1 -1
|
@@ -93,6 +93,86 @@ func FeatureDir(root, slug string) string {
|
|
|
93
93
|
return work
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ExistingFeatureDirChecked resolves an existing canonical feature workspace
|
|
97
|
+
// without following a work/<slug> or features/<slug> symlink. Mutating
|
|
98
|
+
// control-plane commands use this instead of FeatureDir so an attacker cannot
|
|
99
|
+
// redirect archive/removal operations outside the selected DevRites root.
|
|
100
|
+
func ExistingFeatureDirChecked(root, slug string) (string, error) {
|
|
101
|
+
if !validSlug(slug) {
|
|
102
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: %q is not a feature slug", slug)
|
|
103
|
+
}
|
|
104
|
+
if ws, err := WorkspaceOverrideChecked(root, slug); err != nil {
|
|
105
|
+
return "", err
|
|
106
|
+
} else if ws != "" {
|
|
107
|
+
return ws, nil
|
|
108
|
+
}
|
|
109
|
+
for _, parent := range []string{"work", "features"} {
|
|
110
|
+
candidate := filepath.Join(root, parent, slug)
|
|
111
|
+
resolved, err := checkedCanonicalDir(root, candidate, parent, slug)
|
|
112
|
+
if err == nil {
|
|
113
|
+
return resolved, nil
|
|
114
|
+
}
|
|
115
|
+
if !errors.Is(err, os.ErrNotExist) {
|
|
116
|
+
return "", err
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return "", os.ErrNotExist
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ArchiveDirChecked resolves (or safely prepares) the canonical archive
|
|
123
|
+
// directory. Existing symlinked archive components are rejected before a
|
|
124
|
+
// close-out can move a workspace through them.
|
|
125
|
+
func ArchiveDirChecked(root string) (string, error) {
|
|
126
|
+
return checkedCanonicalDir(root, filepath.Join(root, "archive"), "archive", "")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func checkedCanonicalDir(root, candidate, parent, slug string) (string, error) {
|
|
130
|
+
resolvedRoot, err := safepath.ResolveExisting(root)
|
|
131
|
+
if err != nil {
|
|
132
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: resolve DevRites root %q: %w", root, err)
|
|
133
|
+
}
|
|
134
|
+
info, err := os.Lstat(candidate)
|
|
135
|
+
if err != nil {
|
|
136
|
+
if !os.IsNotExist(err) || parent != "archive" {
|
|
137
|
+
return "", err
|
|
138
|
+
}
|
|
139
|
+
resolved, resolveErr := safepath.ResolveExisting(candidate)
|
|
140
|
+
if resolveErr != nil {
|
|
141
|
+
return "", fmt.Errorf("DRV-ARCHIVE-INVALID: resolve archive %q: %w", candidate, resolveErr)
|
|
142
|
+
}
|
|
143
|
+
if !safepath.WithinResolved(resolved, resolvedRoot) {
|
|
144
|
+
return "", fmt.Errorf("DRV-ARCHIVE-OUTSIDE-ROOT: archive %q escapes %q", candidate, root)
|
|
145
|
+
}
|
|
146
|
+
rel, relErr := filepath.Rel(resolvedRoot, resolved)
|
|
147
|
+
if relErr != nil || filepath.Clean(rel) != "archive" {
|
|
148
|
+
return "", fmt.Errorf("DRV-ARCHIVE-INVALID: archive %q is not the canonical archive directory", candidate)
|
|
149
|
+
}
|
|
150
|
+
return filepath.Clean(candidate), nil
|
|
151
|
+
}
|
|
152
|
+
if info.Mode()&os.ModeSymlink != 0 {
|
|
153
|
+
return "", fmt.Errorf("DRV-WORKSPACE-SYMLINK: canonical directory %q must not be a symlink", candidate)
|
|
154
|
+
}
|
|
155
|
+
if !info.IsDir() {
|
|
156
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: canonical directory %q is not a directory", candidate)
|
|
157
|
+
}
|
|
158
|
+
resolved, err := safepath.ResolveExisting(candidate)
|
|
159
|
+
if err != nil {
|
|
160
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: resolve canonical directory %q: %w", candidate, err)
|
|
161
|
+
}
|
|
162
|
+
if !safepath.WithinResolved(resolved, resolvedRoot) {
|
|
163
|
+
return "", fmt.Errorf("DRV-WORKSPACE-OUTSIDE-ROOT: canonical directory %q escapes %q", candidate, root)
|
|
164
|
+
}
|
|
165
|
+
want := parent
|
|
166
|
+
if slug != "" {
|
|
167
|
+
want = filepath.Join(parent, slug)
|
|
168
|
+
}
|
|
169
|
+
rel, err := filepath.Rel(resolvedRoot, resolved)
|
|
170
|
+
if err != nil || filepath.Clean(rel) != filepath.Clean(want) {
|
|
171
|
+
return "", fmt.Errorf("DRV-WORKSPACE-INVALID: canonical directory %q resolves to unexpected path %q", candidate, resolved)
|
|
172
|
+
}
|
|
173
|
+
return filepath.Clean(candidate), nil
|
|
174
|
+
}
|
|
175
|
+
|
|
96
176
|
// ActiveSlug reads and trims the active-feature pointer. A missing pointer file
|
|
97
177
|
// is not an error; it yields the empty slug.
|
|
98
178
|
func ActiveSlug(root string) (string, error) {
|
|
@@ -414,7 +414,12 @@ func SecretScan(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
414
414
|
for i := 0; i < len(args); i++ {
|
|
415
415
|
switch args[i] {
|
|
416
416
|
case "--staged":
|
|
417
|
-
|
|
417
|
+
staged, err := gitStagedNames(project)
|
|
418
|
+
if err != nil {
|
|
419
|
+
fmt.Fprintf(stderr, "secret-scan: cannot inspect staged paths: %v\n", err)
|
|
420
|
+
return 2
|
|
421
|
+
}
|
|
422
|
+
paths = append(paths, staged...)
|
|
418
423
|
case "--text":
|
|
419
424
|
i++
|
|
420
425
|
if i < len(args) {
|
|
@@ -433,7 +438,12 @@ func SecretScan(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
433
438
|
paths = append(paths, touchedFiles(root, slug)...)
|
|
434
439
|
}
|
|
435
440
|
if len(paths) == 0 {
|
|
436
|
-
|
|
441
|
+
changed, err := gitDiffNames(project)
|
|
442
|
+
if err != nil {
|
|
443
|
+
fmt.Fprintf(stderr, "secret-scan: cannot inspect changed paths: %v\n", err)
|
|
444
|
+
return 2
|
|
445
|
+
}
|
|
446
|
+
paths = append(paths, changed...)
|
|
437
447
|
}
|
|
438
448
|
findings := []secretFinding{}
|
|
439
449
|
seen := map[string]bool{}
|
|
@@ -484,12 +494,12 @@ func scanSecrets(path, text string) []secretFinding {
|
|
|
484
494
|
return out
|
|
485
495
|
}
|
|
486
496
|
|
|
487
|
-
func gitStagedNames(project string) []string {
|
|
488
|
-
out, err :=
|
|
497
|
+
func gitStagedNames(project string) ([]string, error) {
|
|
498
|
+
out, err := runGitCommand(project, nil, "diff", "--cached", "--name-only")
|
|
489
499
|
if err != nil {
|
|
490
|
-
return nil
|
|
500
|
+
return nil, err
|
|
491
501
|
}
|
|
492
|
-
return splitLinesNoTrailing(out)
|
|
502
|
+
return splitLinesNoTrailing(out), nil
|
|
493
503
|
}
|
|
494
504
|
|
|
495
505
|
func touchedFiles(root, slug string) []string {
|
|
@@ -515,7 +525,12 @@ func DocsStale(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
515
525
|
slug := slugOrActive(root, args)
|
|
516
526
|
changed := touchedFiles(root, slug)
|
|
517
527
|
if len(changed) == 0 {
|
|
518
|
-
|
|
528
|
+
var err error
|
|
529
|
+
changed, err = gitDiffNames(project)
|
|
530
|
+
if err != nil {
|
|
531
|
+
fmt.Fprintf(stderr, "docs-stale: cannot inspect changed paths: %v\n", err)
|
|
532
|
+
return 2
|
|
533
|
+
}
|
|
519
534
|
}
|
|
520
535
|
docsTouched, surfaceTouched := false, []string{}
|
|
521
536
|
for _, p := range changed {
|
|
@@ -53,6 +53,36 @@ func TestSecretScanBlocksHighSeverityTouchedFile(t *testing.T) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
func TestSecretScanFailsClosedWhenGitIsUnavailable(t *testing.T) {
|
|
57
|
+
project := t.TempDir()
|
|
58
|
+
root := filepath.Join(project, ".devrites")
|
|
59
|
+
t.Chdir(project)
|
|
60
|
+
t.Setenv("PATH", t.TempDir())
|
|
61
|
+
|
|
62
|
+
var out, err bytes.Buffer
|
|
63
|
+
if code := SecretScan(root, nil, &out, &err); code != 2 {
|
|
64
|
+
t.Fatalf("want rc=2 got %d stdout=%s stderr=%s", code, out.String(), err.String())
|
|
65
|
+
}
|
|
66
|
+
if !strings.Contains(err.String(), "cannot inspect changed paths") {
|
|
67
|
+
t.Fatalf("missing fail-closed diagnostic: %s", err.String())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestDocsStaleFailsClosedWhenGitIsUnavailable(t *testing.T) {
|
|
72
|
+
project := t.TempDir()
|
|
73
|
+
root := filepath.Join(project, ".devrites")
|
|
74
|
+
t.Chdir(project)
|
|
75
|
+
t.Setenv("PATH", t.TempDir())
|
|
76
|
+
|
|
77
|
+
var out, err bytes.Buffer
|
|
78
|
+
if code := DocsStale(root, nil, &out, &err); code != 2 {
|
|
79
|
+
t.Fatalf("want rc=2 got %d stdout=%s stderr=%s", code, out.String(), err.String())
|
|
80
|
+
}
|
|
81
|
+
if !strings.Contains(err.String(), "cannot inspect changed paths") {
|
|
82
|
+
t.Fatalf("missing fail-closed diagnostic: %s", err.String())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
func TestSpecDedupeSearchesScratchPRDs(t *testing.T) {
|
|
57
87
|
project := t.TempDir()
|
|
58
88
|
root := filepath.Join(project, ".devrites")
|
|
@@ -5,6 +5,9 @@ import (
|
|
|
5
5
|
"io"
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
|
|
9
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
10
|
+
"github.com/devrites/devrites/internal/state"
|
|
8
11
|
)
|
|
9
12
|
|
|
10
13
|
// CloseOut retires a shipped feature: it moves the feature directory into the
|
|
@@ -30,13 +33,27 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
30
33
|
return 4
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
work :=
|
|
34
|
-
|
|
36
|
+
work, err := devritespaths.ExistingFeatureDirChecked(dv, slug)
|
|
37
|
+
if err != nil {
|
|
38
|
+
if os.IsNotExist(err) {
|
|
39
|
+
fmt.Fprintf(stderr, "close-out: no workspace for %s\n", slug)
|
|
40
|
+
return 4
|
|
41
|
+
}
|
|
42
|
+
fmt.Fprintf(stderr, "close-out: invalid workspace for %s: %v\n", slug, err)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
archiveDir, err := devritespaths.ArchiveDirChecked(dv)
|
|
46
|
+
if err != nil {
|
|
47
|
+
fmt.Fprintf(stderr, "close-out: invalid archive directory: %v\n", err)
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
arch := filepath.Join(archiveDir, slug)
|
|
35
51
|
active := filepath.Join(dv, "ACTIVE")
|
|
36
52
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
activeSlug, err := devritespaths.ActiveSlug(dv)
|
|
54
|
+
if err != nil {
|
|
55
|
+
fmt.Fprintf(stderr, "close-out: invalid ACTIVE cursor: %v\n", err)
|
|
56
|
+
return 1
|
|
40
57
|
}
|
|
41
58
|
// A broken symlink at the destination still counts as "already there", so use
|
|
42
59
|
// Lstat rather than Stat to avoid silently overwriting it.
|
|
@@ -47,7 +64,7 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
47
64
|
|
|
48
65
|
// Fail closed: if the move does not happen, leave the cursor alone and do not
|
|
49
66
|
// claim success: a lost audit trail must never look like a clean close-out.
|
|
50
|
-
if err := os.MkdirAll(
|
|
67
|
+
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
|
51
68
|
fmt.Fprintf(stderr, "close-out: cannot create archive dir: %v\n", err)
|
|
52
69
|
return 1
|
|
53
70
|
}
|
|
@@ -57,12 +74,18 @@ func CloseOut(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
// Clear ACTIVE only if it still points at the slug we just archived.
|
|
60
|
-
if
|
|
61
|
-
if
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
if activeSlug == slug {
|
|
78
|
+
if err := state.AtomicWrite(active, nil, 0o644); err != nil {
|
|
79
|
+
rollbackErr := os.Rename(arch, work)
|
|
80
|
+
if rollbackErr != nil {
|
|
81
|
+
fmt.Fprintf(stderr, "close-out: cannot clear ACTIVE after archiving: %v; rollback %s -> %s also failed: %v\n", err, arch, work, rollbackErr)
|
|
82
|
+
} else {
|
|
83
|
+
fmt.Fprintf(stderr, "close-out: cannot clear ACTIVE; archive move rolled back: %v\n", err)
|
|
84
|
+
}
|
|
85
|
+
return 1
|
|
65
86
|
}
|
|
87
|
+
fmt.Fprintf(stdout, "close-out: archived %s -> %s and cleared ACTIVE\n", slug, filepath.ToSlash(arch))
|
|
88
|
+
return 0
|
|
66
89
|
}
|
|
67
90
|
fmt.Fprintf(stdout, "close-out: archived %s -> %s (ACTIVE pointed elsewhere: left as-is)\n", slug, filepath.ToSlash(arch))
|
|
68
91
|
return 0
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"runtime"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func runCloseOut(t *testing.T, root string, args ...string) (int, string) {
|
|
13
|
+
t.Helper()
|
|
14
|
+
stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
|
|
15
|
+
code := CloseOut(root, args, stdout, stderr)
|
|
16
|
+
return code, stdout.String() + stderr.String()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func TestCloseOutRejectsWorkspaceSymlinkEscape(t *testing.T) {
|
|
20
|
+
root := t.TempDir()
|
|
21
|
+
outside := t.TempDir()
|
|
22
|
+
if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
if err := os.Symlink(outside, filepath.Join(root, "work", "feat")); err != nil {
|
|
26
|
+
t.Skipf("symlinks unavailable: %v", err)
|
|
27
|
+
}
|
|
28
|
+
writeFile(t, filepath.Join(outside, "state.md"), "outside\n")
|
|
29
|
+
|
|
30
|
+
code, out := runCloseOut(t, root, "feat")
|
|
31
|
+
if code != 1 {
|
|
32
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
33
|
+
}
|
|
34
|
+
if !strings.Contains(out, "invalid workspace") {
|
|
35
|
+
t.Fatalf("missing workspace diagnostic:\n%s", out)
|
|
36
|
+
}
|
|
37
|
+
if !isFile(filepath.Join(outside, "state.md")) {
|
|
38
|
+
t.Fatal("close-out moved a workspace through an external symlink")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestCloseOutRejectsArchiveSymlinkEscape(t *testing.T) {
|
|
43
|
+
root := t.TempDir()
|
|
44
|
+
outside := t.TempDir()
|
|
45
|
+
writeFile(t, filepath.Join(root, "work", "feat", "state.md"), "inside\n")
|
|
46
|
+
if err := os.Symlink(outside, filepath.Join(root, "archive")); err != nil {
|
|
47
|
+
t.Skipf("symlinks unavailable: %v", err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
code, out := runCloseOut(t, root, "feat")
|
|
51
|
+
if code != 1 {
|
|
52
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
53
|
+
}
|
|
54
|
+
if !strings.Contains(out, "invalid archive") {
|
|
55
|
+
t.Fatalf("missing archive diagnostic:\n%s", out)
|
|
56
|
+
}
|
|
57
|
+
if !isFile(filepath.Join(root, "work", "feat", "state.md")) {
|
|
58
|
+
t.Fatal("close-out moved the workspace through an external archive symlink")
|
|
59
|
+
}
|
|
60
|
+
if entries, err := os.ReadDir(outside); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
} else if len(entries) != 0 {
|
|
63
|
+
t.Fatalf("outside archive changed: %v", entries)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestCloseOutRollsBackArchiveWhenActiveCannotBeCleared(t *testing.T) {
|
|
68
|
+
if runtime.GOOS == "windows" {
|
|
69
|
+
t.Skip("permission model differs on Windows")
|
|
70
|
+
}
|
|
71
|
+
root := t.TempDir()
|
|
72
|
+
writeFile(t, filepath.Join(root, "work", "feat", "state.md"), "inside\n")
|
|
73
|
+
writeFile(t, filepath.Join(root, "ACTIVE"), "feat\n")
|
|
74
|
+
if err := os.MkdirAll(filepath.Join(root, "archive"), 0o755); err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
if err := os.Chmod(root, 0o555); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
defer func() { _ = os.Chmod(root, 0o755) }()
|
|
81
|
+
|
|
82
|
+
code, out := runCloseOut(t, root, "feat")
|
|
83
|
+
if code != 1 {
|
|
84
|
+
t.Fatalf("close-out = %d, want 1\n%s", code, out)
|
|
85
|
+
}
|
|
86
|
+
if !strings.Contains(out, "archive move rolled back") {
|
|
87
|
+
t.Fatalf("missing rollback diagnostic:\n%s", out)
|
|
88
|
+
}
|
|
89
|
+
if !isFile(filepath.Join(root, "work", "feat", "state.md")) {
|
|
90
|
+
t.Fatal("workspace was not restored after ACTIVE clear failed")
|
|
91
|
+
}
|
|
92
|
+
if _, err := os.Lstat(filepath.Join(root, "archive", "feat")); !os.IsNotExist(err) {
|
|
93
|
+
t.Fatalf("archive remained after rollback: %v", err)
|
|
94
|
+
}
|
|
95
|
+
data, err := os.ReadFile(filepath.Join(root, "ACTIVE"))
|
|
96
|
+
if err != nil || string(data) != "feat\n" {
|
|
97
|
+
t.Fatalf("ACTIVE changed after rollback: %q, %v", data, err)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
var dispatchWaiverReasons = map[string]struct{}{
|
|
9
|
+
"human-gate-before-dispatch": {},
|
|
10
|
+
"no-active-workspace": {},
|
|
11
|
+
"no-eligible-work": {},
|
|
12
|
+
"readiness-failed": {},
|
|
13
|
+
"wrong-phase": {},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func ValidDispatchWaiver(reason string) bool {
|
|
17
|
+
_, ok := dispatchWaiverReasons[reason]
|
|
18
|
+
return ok
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// DispatchWaive validates the reason token used by the agent-dispatch hook.
|
|
22
|
+
// The hook records the successful PostToolUse receipt; this command deliberately
|
|
23
|
+
// owns no separate state.
|
|
24
|
+
func DispatchWaive(args []string, stdout, stderr io.Writer) int {
|
|
25
|
+
if len(args) != 1 || !ValidDispatchWaiver(args[0]) {
|
|
26
|
+
fmt.Fprintln(stderr, "usage: devrites-engine dispatch-waive <human-gate-before-dispatch|no-active-workspace|no-eligible-work|readiness-failed|wrong-phase>")
|
|
27
|
+
return 2
|
|
28
|
+
}
|
|
29
|
+
fmt.Fprintf(stdout, "dispatch-waive: accepted %s\n", args[0])
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestDispatchWaiveAcceptsOnlyKnownSingleReason(t *testing.T) {
|
|
10
|
+
var stdout, stderr bytes.Buffer
|
|
11
|
+
if code := DispatchWaive([]string{"wrong-phase"}, &stdout, &stderr); code != 0 {
|
|
12
|
+
t.Fatalf("valid reason code=%d stderr=%s", code, stderr.String())
|
|
13
|
+
}
|
|
14
|
+
if !strings.Contains(stdout.String(), "accepted wrong-phase") {
|
|
15
|
+
t.Fatalf("missing receipt: %s", stdout.String())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
stdout.Reset()
|
|
19
|
+
stderr.Reset()
|
|
20
|
+
if code := DispatchWaive([]string{"anything"}, &stdout, &stderr); code != 2 {
|
|
21
|
+
t.Fatalf("invalid reason code=%d, want 2", code)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package lib
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
4
5
|
"fmt"
|
|
5
6
|
"io"
|
|
6
7
|
"os"
|
|
@@ -32,8 +33,11 @@ func MutationGate(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
32
33
|
// Config files are looked up relative to the repo root, falling back to the
|
|
33
34
|
// working directory when this is not a git repo.
|
|
34
35
|
cwd, _ := os.Getwd()
|
|
35
|
-
base := gitToplevel(cwd)
|
|
36
|
-
if
|
|
36
|
+
base, err := gitToplevel(cwd)
|
|
37
|
+
if errors.Is(err, errNotGitRepository) {
|
|
38
|
+
base = "."
|
|
39
|
+
} else if err != nil {
|
|
40
|
+
fmt.Fprintf(stderr, "mutation-gate: cannot resolve git worktree; using current directory: %v\n", err)
|
|
37
41
|
base = "."
|
|
38
42
|
}
|
|
39
43
|
|