devrites 3.2.16 → 3.2.18

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 (122) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/engine/Makefile +3 -3
  4. package/engine/hooks.go +20 -4
  5. package/engine/hooks_agent_dispatch.go +297 -48
  6. package/engine/hooks_events_test.go +40 -0
  7. package/engine/hooks_workspace.go +26 -6
  8. package/engine/internal/devritespaths/paths.go +80 -0
  9. package/engine/internal/install/install.go +36 -16
  10. package/engine/internal/install/install_test.go +50 -1
  11. package/engine/internal/iohooks/iohooks.go +5 -3
  12. package/engine/internal/iohooks/iohooks_test.go +11 -0
  13. package/engine/internal/lib/adoption.go +36 -11
  14. package/engine/internal/lib/adoption_test.go +30 -0
  15. package/engine/internal/lib/analyze.go +10 -2
  16. package/engine/internal/lib/archivesearch.go +41 -9
  17. package/engine/internal/lib/archivesearch_test.go +28 -0
  18. package/engine/internal/lib/buildreadiness.go +5 -1
  19. package/engine/internal/lib/closeout.go +34 -11
  20. package/engine/internal/lib/closeout_test.go +99 -0
  21. package/engine/internal/lib/context.go +6 -1
  22. package/engine/internal/lib/dispatchwaive.go +31 -0
  23. package/engine/internal/lib/dispatchwaive_test.go +23 -0
  24. package/engine/internal/lib/extensions.go +5 -1
  25. package/engine/internal/lib/learnings.go +8 -2
  26. package/engine/internal/lib/ledger.go +17 -4
  27. package/engine/internal/lib/ledger_test.go +24 -0
  28. package/engine/internal/lib/mutationgate.go +6 -2
  29. package/engine/internal/lib/reconcile.go +201 -64
  30. package/engine/internal/lib/reconcile_test.go +159 -12
  31. package/engine/internal/lib/resolve.go +8 -3
  32. package/engine/internal/lib/resolve_remediation_test.go +14 -0
  33. package/engine/internal/lib/runbook.go +4 -1
  34. package/engine/internal/lib/runbook_context_test.go +43 -0
  35. package/engine/internal/lib/testintegrity.go +4 -4
  36. package/engine/internal/lib/testintegrity_test.go +15 -0
  37. package/engine/internal/lib/tickafk.go +6 -2
  38. package/engine/internal/lib/util.go +128 -23
  39. package/engine/internal/profile/profile.go +27 -13
  40. package/engine/internal/profile/profile_test.go +13 -0
  41. package/engine/main.go +4 -1
  42. package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
  43. package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
  44. package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
  45. package/engine/tests/hook_test.go +353 -17
  46. package/engine/tests/parity_reconcile_test.go +4 -1
  47. package/engine/tests/parity_testintegrity_test.go +1 -1
  48. package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
  49. package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
  50. package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
  51. package/pack/.claude/agents/devrites-forge-judge.md +4 -1
  52. package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
  53. package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
  54. package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
  55. package/pack/.claude/agents/devrites-proof-runner.md +14 -14
  56. package/pack/.claude/agents/devrites-retrospector.md +3 -0
  57. package/pack/.claude/agents/devrites-security-auditor.md +3 -0
  58. package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
  59. package/pack/.claude/agents/devrites-slice-wright.md +4 -2
  60. package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
  61. package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
  62. package/pack/.claude/agents/devrites-test-analyst.md +3 -0
  63. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  64. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  65. package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
  66. package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
  67. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  68. package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
  69. package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
  70. package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
  71. package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
  72. package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
  73. package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
  74. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
  75. package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
  76. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
  77. package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
  78. package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
  79. package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
  80. package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
  81. package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
  82. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
  83. package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
  84. package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
  85. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
  86. package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
  87. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  88. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  89. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
  90. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
  91. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  92. package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
  93. package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
  94. package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
  95. package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
  96. package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
  97. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
  98. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
  99. package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
  100. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
  101. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
  102. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
  103. package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
  104. package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
  105. package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
  106. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
  107. package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
  108. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
  109. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
  110. package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
  111. package/pack/generated/codex/hooks.json +1 -1
  112. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  113. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
  114. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
  115. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
  116. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
  117. package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
  118. package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
  119. package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
  120. package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
  121. package/package.json +1 -1
  122. package/scripts/codex-generate.sh +1 -1
@@ -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
+ }
@@ -2,8 +2,10 @@ package lib
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "errors"
5
6
  "fmt"
6
7
  "io"
8
+ "io/fs"
7
9
  "os"
8
10
  "path/filepath"
9
11
  "strings"
@@ -258,7 +260,10 @@ func upsertContextBlock(path, block string) error {
258
260
  if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
259
261
  return fmt.Errorf("create context dir: %w", err)
260
262
  }
261
- existingBytes, _ := os.ReadFile(path)
263
+ existingBytes, err := os.ReadFile(path)
264
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
265
+ return fmt.Errorf("read existing context file: %w", err)
266
+ }
262
267
  existing := string(existingBytes)
263
268
  var next string
264
269
  start := strings.Index(existing, contextStart)
@@ -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
+ }
@@ -481,7 +481,11 @@ func extensionsSync(extDir, projectDir string, stdout, stderr io.Writer) int {
481
481
  fmt.Fprintln(stderr, "extensions: not syncing: validation failed (run `devrites-engine extensions validate`)")
482
482
  return code
483
483
  }
484
- exts, _ := discoverExtensions(extDir)
484
+ exts, err := discoverExtensions(extDir)
485
+ if err != nil {
486
+ fmt.Fprintf(stderr, "extensions: discovery failed: %v\n", err)
487
+ return 1
488
+ }
485
489
  if len(exts) == 0 {
486
490
  fmt.Fprintln(stdout, "extensions: nothing to sync")
487
491
  return 0
@@ -367,8 +367,11 @@ func countSubdirs(arch string) int {
367
367
  // anyFileNewerThan reports whether any regular file under arch was modified after t.
368
368
  func anyFileNewerThan(arch string, t time.Time) bool {
369
369
  newer := false
370
- _ = filepath.WalkDir(arch, func(_ string, d os.DirEntry, err error) error {
371
- if err != nil || d.IsDir() {
370
+ err := filepath.WalkDir(arch, func(_ string, d os.DirEntry, err error) error {
371
+ if err != nil {
372
+ return err
373
+ }
374
+ if d.IsDir() {
372
375
  return nil
373
376
  }
374
377
  if info, err := d.Info(); err == nil && info.Mode().IsRegular() && info.ModTime().After(t) {
@@ -377,6 +380,9 @@ func anyFileNewerThan(arch string, t time.Time) bool {
377
380
  }
378
381
  return nil
379
382
  })
383
+ if err != nil && !os.IsNotExist(err) {
384
+ return true
385
+ }
380
386
  return newer
381
387
  }
382
388
 
@@ -92,7 +92,11 @@ func planFold(root, workspaceDir string, stderr io.Writer) ([]capabilityFold, in
92
92
 
93
93
  var folds []capabilityFold
94
94
  for _, capability := range order {
95
- fold := foldCapability(root, capability, byCap[capability])
95
+ fold, err := foldCapability(root, capability, byCap[capability])
96
+ if err != nil {
97
+ fmt.Fprintf(stderr, "ledger: %v\n", err)
98
+ return nil, 2
99
+ }
96
100
  folds = append(folds, fold)
97
101
  }
98
102
  return folds, 0
@@ -102,8 +106,17 @@ func planFold(root, workspaceDir string, stderr io.Writer) ([]capabilityFold, in
102
106
  // A requirement with no delta kind (a flat feature spec) folds as ADDED. ADDED and
103
107
  // MODIFIED are both an upsert (replace in place if the header exists, else append);
104
108
  // REMOVED deletes. Ordering: existing blocks keep their position, new blocks append.
105
- func foldCapability(root, capability string, deltas []Requirement) capabilityFold {
106
- existing, _ := ParseSpec(ledgerCapabilityPath(root, capability))
109
+ func foldCapability(root, capability string, deltas []Requirement) (capabilityFold, error) {
110
+ path := ledgerCapabilityPath(root, capability)
111
+ var existing *SpecDoc
112
+ if _, err := os.Stat(path); err == nil {
113
+ existing, err = ParseSpec(path)
114
+ if err != nil {
115
+ return capabilityFold{}, fmt.Errorf("read capability %s: %w", capability, err)
116
+ }
117
+ } else if !os.IsNotExist(err) {
118
+ return capabilityFold{}, fmt.Errorf("inspect capability %s: %w", capability, err)
119
+ }
107
120
  var blocks []Requirement
108
121
  idx := map[string]int{}
109
122
  if existing != nil {
@@ -140,7 +153,7 @@ func foldCapability(root, capability string, deltas []Requirement) capabilityFol
140
153
  }
141
154
  }
142
155
  fold.blocks = blocks
143
- return fold
156
+ return fold, nil
144
157
  }
145
158
 
146
159
  // renderLedgerSpec assembles a capability's ledger spec.md from its resolved blocks.
@@ -160,3 +160,27 @@ The system SHALL group appearance controls together.
160
160
  }
161
161
  }
162
162
  }
163
+
164
+ func TestLedgerSyncDoesNotOverwriteUnreadableCapability(t *testing.T) {
165
+ root := t.TempDir()
166
+ capability := ledgerCapabilityPath(root, "billing")
167
+ if err := os.MkdirAll(capability, 0o755); err != nil {
168
+ t.Fatal(err)
169
+ }
170
+ wsParent := t.TempDir()
171
+ writeSpec(t, wsParent, "feat", `## ADDED Requirements — capability: billing
172
+ ### Requirement: Invoice export
173
+ The system SHALL export invoices.
174
+ `)
175
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
176
+ code := Ledger(root, []string{"sync", filepath.Join(wsParent, "feat")}, stdout, stderr)
177
+ if code != 2 {
178
+ t.Fatalf("sync code=%d, want 2; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
179
+ }
180
+ if !strings.Contains(stderr.String(), "read capability billing") {
181
+ t.Fatalf("stderr=%q, want capability read error", stderr.String())
182
+ }
183
+ if info, err := os.Stat(capability); err != nil || !info.IsDir() {
184
+ t.Fatalf("capability was overwritten: info=%v err=%v", info, err)
185
+ }
186
+ }
@@ -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