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.
Files changed (101) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/engine/hooks.go +20 -4
  4. package/engine/hooks_agent_dispatch.go +297 -48
  5. package/engine/hooks_events_test.go +40 -0
  6. package/engine/hooks_workspace.go +26 -6
  7. package/engine/internal/devritespaths/paths.go +80 -0
  8. package/engine/internal/lib/adoption.go +22 -7
  9. package/engine/internal/lib/adoption_test.go +30 -0
  10. package/engine/internal/lib/closeout.go +34 -11
  11. package/engine/internal/lib/closeout_test.go +99 -0
  12. package/engine/internal/lib/dispatchwaive.go +31 -0
  13. package/engine/internal/lib/dispatchwaive_test.go +23 -0
  14. package/engine/internal/lib/mutationgate.go +6 -2
  15. package/engine/internal/lib/reconcile.go +201 -64
  16. package/engine/internal/lib/reconcile_test.go +159 -12
  17. package/engine/internal/lib/testintegrity.go +4 -4
  18. package/engine/internal/lib/testintegrity_test.go +15 -0
  19. package/engine/internal/lib/util.go +128 -23
  20. package/engine/main.go +4 -1
  21. package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
  22. package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
  23. package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
  24. package/engine/tests/hook_test.go +353 -17
  25. package/engine/tests/parity_reconcile_test.go +4 -1
  26. package/engine/tests/parity_testintegrity_test.go +1 -1
  27. package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
  28. package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
  29. package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
  30. package/pack/.claude/agents/devrites-forge-judge.md +4 -1
  31. package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
  32. package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
  33. package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
  34. package/pack/.claude/agents/devrites-proof-runner.md +14 -14
  35. package/pack/.claude/agents/devrites-retrospector.md +3 -0
  36. package/pack/.claude/agents/devrites-security-auditor.md +3 -0
  37. package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
  38. package/pack/.claude/agents/devrites-slice-wright.md +4 -2
  39. package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
  40. package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
  41. package/pack/.claude/agents/devrites-test-analyst.md +3 -0
  42. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  43. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  44. package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
  45. package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
  46. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  47. package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
  48. package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
  49. package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
  50. package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
  51. package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
  52. package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
  53. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
  54. package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
  55. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
  56. package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
  57. package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
  58. package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
  59. package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
  60. package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
  61. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
  62. package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
  63. package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
  64. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
  65. package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
  66. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  67. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  68. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
  69. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
  70. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  71. package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
  72. package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
  73. package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
  74. package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
  75. package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
  76. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
  77. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
  78. package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
  79. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
  80. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
  81. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
  82. package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
  83. package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
  84. package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
  85. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
  86. package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
  87. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
  88. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
  89. package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
  90. package/pack/generated/codex/hooks.json +1 -1
  91. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  92. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
  93. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
  94. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
  95. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
  96. package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
  97. package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
  98. package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
  99. package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
  100. package/package.json +1 -1
  101. 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
- paths = append(paths, gitStagedNames(project)...)
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
- paths = append(paths, gitDiffNames(project)...)
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 := boundedCommandOutput(15*time.Second, "", "git", "-C", project, "diff", "--cached", "--name-only")
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
- changed = gitDiffNames(project)
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 := featureDir(dv, slug)
34
- arch := filepath.Join(dv, "archive", slug)
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
- if !isDir(work) {
38
- fmt.Fprintf(stderr, "close-out: no workspace at %s\n", work)
39
- return 4
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(filepath.Join(dv, "archive"), 0o755); err != nil {
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 isFile(active) {
61
- if b, err := os.ReadFile(active); err == nil && stripASCIISpace(string(b)) == slug {
62
- _ = os.WriteFile(active, nil, 0o644)
63
- fmt.Fprintf(stdout, "close-out: archived %s -> %s and cleared ACTIVE\n", slug, filepath.ToSlash(arch))
64
- return 0
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 base == "" {
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