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
@@ -7,12 +7,12 @@ import (
7
7
  "fmt"
8
8
  "io"
9
9
  "os"
10
- "os/exec"
11
10
  "path"
12
11
  "path/filepath"
13
12
  "sort"
14
13
  "strings"
15
14
 
15
+ "github.com/devrites/devrites/internal/devritespaths"
16
16
  "github.com/devrites/devrites/internal/forge"
17
17
  "github.com/devrites/devrites/internal/safepath"
18
18
  )
@@ -38,7 +38,8 @@ const (
38
38
  // while retaining the original source baseline.
39
39
  // `check` compares the captured state with the current state and retains the
40
40
  // immutable baseline for the later test-integrity gate.
41
- // `close` explicitly ends the window and removes its private artifacts.
41
+ // `restore-check` rolls back only source drift introduced after the last clean
42
+ // check. `close` revalidates and then removes the private window artifacts.
42
43
  //
43
44
  // 0 clean check, snapshot/close completed, or skipped (not a git repo)
44
45
  // 5 VIOLATION: a path changed outside the orchestrator allowlist
@@ -50,8 +51,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
50
51
  slug = activeSlug(root)
51
52
  }
52
53
 
53
- d := featureDir(root, slug)
54
- if slug == "" || !isDir(d) {
54
+ if slug == "" {
55
55
  s := slug
56
56
  if s == "" {
57
57
  s = "<unset>"
@@ -59,6 +59,15 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
59
59
  fmt.Fprintf(stderr, "reconcile: no active workspace (slug=%s): nothing to reconcile.\n", s)
60
60
  return 6
61
61
  }
62
+ d, err := devritespaths.ExistingFeatureDirChecked(root, slug)
63
+ if err != nil {
64
+ if os.IsNotExist(err) {
65
+ fmt.Fprintf(stderr, "reconcile: no active workspace (slug=%s): nothing to reconcile.\n", slug)
66
+ } else {
67
+ fmt.Fprintf(stderr, "reconcile: invalid workspace for %s: %v\n", slug, err)
68
+ }
69
+ return 6
70
+ }
62
71
 
63
72
  base := filepath.Join(d, reconcileBaseName)
64
73
  capturedAllowlist := filepath.Join(d, reconcileAllowlistName)
@@ -84,6 +93,17 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
84
93
  }
85
94
 
86
95
  if mode == "close" {
96
+ info, err := os.Lstat(checked)
97
+ if err != nil || !info.Mode().IsRegular() {
98
+ fmt.Fprintln(stderr, `reconcile: active slice window has no clean check marker; run "reconcile check" before closing it.`)
99
+ return 6
100
+ }
101
+ // Defense in depth: a prior marker proves the explicit check occurred;
102
+ // rerunning it here binds close to the current source and canonical
103
+ // workspace state rather than trusting a stale marker.
104
+ if code := Reconcile(root, []string{"check", slug}, stdout, stderr); code != 0 {
105
+ return code
106
+ }
87
107
  if err := closeWindow(); err != nil {
88
108
  fmt.Fprintf(stderr, "reconcile: cannot close slice window: %v\n", err)
89
109
  return 6
@@ -93,10 +113,10 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
93
113
  }
94
114
 
95
115
  cwd, _ := os.Getwd()
96
- gitRoot := gitToplevel(cwd)
97
- if gitRoot == "" {
98
- fmt.Fprintln(stderr, "reconcile: not a git repo: gate skipped, verify the diff by hand.")
99
- return 0
116
+ gitRoot, err := gitToplevel(cwd)
117
+ if err != nil {
118
+ fmt.Fprintf(stderr, "reconcile: cannot resolve git worktree: %v\n", err)
119
+ return 6
100
120
  }
101
121
 
102
122
  switch mode {
@@ -200,6 +220,55 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
200
220
  fmt.Fprintf(stdout, "reconcile: snapshot captured for %s.\n", slug)
201
221
  return 0
202
222
 
223
+ case "restore-check":
224
+ _, env, activeObjects, active, err := loadReconcileBaseline(gitRoot, d)
225
+ if err != nil {
226
+ fmt.Fprintf(stderr, "reconcile: invalid snapshot: %v\n", err)
227
+ return 6
228
+ }
229
+ if !active {
230
+ fmt.Fprintln(stderr, `reconcile: no snapshot (.reconcile-base/.reconcile-objects): nothing can be restored.`)
231
+ return 6
232
+ }
233
+ checkedData, err := os.ReadFile(checked)
234
+ if err != nil {
235
+ fmt.Fprintln(stderr, `reconcile: active slice window has no clean check marker; nothing can be restored.`)
236
+ return 6
237
+ }
238
+ checkedTree := strings.TrimSpace(string(checkedData))
239
+ if checkedTree == "" || strings.ContainsAny(checkedTree, " \t\r\n") {
240
+ fmt.Fprintf(stderr, "reconcile: invalid %s: expected one tree id\n", reconcileCheckedName)
241
+ return 6
242
+ }
243
+ nowTree, err := worktreeTree(gitRoot, activeObjects, root)
244
+ if err != nil {
245
+ fmt.Fprintf(stderr, "reconcile: cannot capture worktree for restore: %v\n", err)
246
+ return 6
247
+ }
248
+ changed, err := restoreTreeDelta(gitRoot, env, checkedTree, nowTree)
249
+ if err != nil {
250
+ fmt.Fprintf(stderr, "reconcile: cannot restore post-check source drift: %v\n", err)
251
+ return 6
252
+ }
253
+ restoredTree, err := worktreeTree(gitRoot, activeObjects, root)
254
+ if err != nil {
255
+ fmt.Fprintf(stderr, "reconcile: cannot verify restored worktree: %v\n", err)
256
+ return 6
257
+ }
258
+ if restoredTree != checkedTree {
259
+ fmt.Fprintln(stderr, "reconcile: restored worktree does not match the last clean check; retained window left open.")
260
+ return 6
261
+ }
262
+ if len(changed) == 0 {
263
+ fmt.Fprintln(stdout, "reconcile: no post-check source drift to restore.")
264
+ return 0
265
+ }
266
+ fmt.Fprintln(stdout, "reconcile: restored only the post-check source delta:")
267
+ for _, changedPath := range changed {
268
+ fmt.Fprintf(stdout, " - %s\n", changedPath)
269
+ }
270
+ return 0
271
+
203
272
  case "check":
204
273
  baseTree, env, _, active, err := loadReconcileBaseline(gitRoot, d)
205
274
  if err != nil {
@@ -240,6 +309,29 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
240
309
  fmt.Fprintf(stderr, "reconcile: cannot capture worktree: %v\n", err)
241
310
  return 6
242
311
  }
312
+ if checkedData, checkedErr := os.ReadFile(checked); checkedErr == nil {
313
+ checkedTree := strings.TrimSpace(string(checkedData))
314
+ if checkedTree == "" || strings.ContainsAny(checkedTree, " \t\r\n") {
315
+ fmt.Fprintf(stderr, "reconcile: invalid %s: expected one tree id\n", reconcileCheckedName)
316
+ return 6
317
+ }
318
+ if checkedTree != nowTree {
319
+ changedAfterCheck, err := changedTreePaths(gitRoot, env, checkedTree, nowTree)
320
+ if err != nil {
321
+ fmt.Fprintf(stderr, "reconcile: cannot compare with the last clean check: %v\n", err)
322
+ return 6
323
+ }
324
+ sort.Strings(changedAfterCheck)
325
+ fmt.Fprintln(stderr, "reconcile: STOP: source changed after the last clean check; root-owned artifact gates must not edit source:")
326
+ for _, changedPath := range changedAfterCheck {
327
+ fmt.Fprintf(stderr, " - %s\n", changedPath)
328
+ }
329
+ return 5
330
+ }
331
+ } else if !os.IsNotExist(checkedErr) {
332
+ fmt.Fprintf(stderr, "reconcile: cannot read %s: %v\n", reconcileCheckedName, checkedErr)
333
+ return 6
334
+ }
243
335
  changed, err := changedTreePaths(gitRoot, env, baseTree, nowTree)
244
336
  if err != nil {
245
337
  fmt.Fprintf(stderr, "reconcile: cannot compare worktree: %v\n", err)
@@ -282,7 +374,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
282
374
  return 0
283
375
 
284
376
  default:
285
- fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|close> [slug]")
377
+ fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|restore-check|close> [slug]")
286
378
  return 6
287
379
  }
288
380
  }
@@ -692,6 +784,92 @@ func changedDevritesPaths(before, after []devritesStateEntry) []string {
692
784
  return paths
693
785
  }
694
786
 
787
+ // restoreTreeDelta materializes the last clean tree in a private directory and
788
+ // copies back only paths that changed afterward. It never touches the real Git
789
+ // index and rejects parent symlink escapes before deleting or writing.
790
+ func restoreTreeDelta(gitRoot string, env []string, checkedTree, currentTree string) ([]string, error) {
791
+ changed, err := changedTreePaths(gitRoot, env, checkedTree, currentTree)
792
+ if err != nil {
793
+ return nil, err
794
+ }
795
+ sort.Strings(changed)
796
+ if len(changed) == 0 {
797
+ return nil, nil
798
+ }
799
+
800
+ materialized, err := os.MkdirTemp("", "devrites-reconcile-restore-*")
801
+ if err != nil {
802
+ return nil, fmt.Errorf("create restore directory: %w", err)
803
+ }
804
+ defer func() { _ = os.RemoveAll(materialized) }()
805
+ index := filepath.Join(materialized, "index")
806
+ restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
807
+ if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", checkedTree); err != nil {
808
+ return nil, err
809
+ }
810
+ materializedTree := filepath.Join(materialized, "tree")
811
+ if err := os.MkdirAll(materializedTree, 0o700); err != nil {
812
+ return nil, fmt.Errorf("create restore tree: %w", err)
813
+ }
814
+ gitPrefix := filepath.ToSlash(materializedTree) + "/"
815
+ if _, err := reconcileGitOutput(
816
+ gitRoot,
817
+ restoreEnv,
818
+ "-c", "core.autocrlf=false",
819
+ "checkout-index", "--all", "--force", "--prefix="+gitPrefix,
820
+ ); err != nil {
821
+ return nil, err
822
+ }
823
+
824
+ for _, changedPath := range changed {
825
+ if changedPath == "." || changedPath == ".devrites" ||
826
+ strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
827
+ path.Clean(changedPath) != changedPath {
828
+ return nil, fmt.Errorf("unsafe restore path %q", changedPath)
829
+ }
830
+ target := filepath.Join(gitRoot, filepath.FromSlash(changedPath))
831
+ targetParent := filepath.Dir(target)
832
+ if !safepath.WithinResolved(targetParent, gitRoot) {
833
+ return nil, fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
834
+ }
835
+ source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
836
+ sourceInfo, sourceErr := os.Lstat(source)
837
+ if sourceErr != nil && !os.IsNotExist(sourceErr) {
838
+ return nil, fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
839
+ }
840
+ if err := os.RemoveAll(target); err != nil {
841
+ return nil, fmt.Errorf("remove post-check path %s: %w", changedPath, err)
842
+ }
843
+ if os.IsNotExist(sourceErr) {
844
+ continue
845
+ }
846
+ if err := os.MkdirAll(targetParent, 0o755); err != nil {
847
+ return nil, fmt.Errorf("create restore parent for %s: %w", changedPath, err)
848
+ }
849
+ switch {
850
+ case sourceInfo.Mode()&os.ModeSymlink != 0:
851
+ link, err := os.Readlink(source)
852
+ if err != nil {
853
+ return nil, fmt.Errorf("read clean symlink %s: %w", changedPath, err)
854
+ }
855
+ if err := os.Symlink(link, target); err != nil {
856
+ return nil, fmt.Errorf("restore symlink %s: %w", changedPath, err)
857
+ }
858
+ case sourceInfo.Mode().IsRegular():
859
+ data, err := os.ReadFile(source)
860
+ if err != nil {
861
+ return nil, fmt.Errorf("read clean file %s: %w", changedPath, err)
862
+ }
863
+ if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
864
+ return nil, fmt.Errorf("restore file %s: %w", changedPath, err)
865
+ }
866
+ default:
867
+ return nil, fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
868
+ }
869
+ }
870
+ return changed, nil
871
+ }
872
+
695
873
  // worktreeTree captures the working tree (tracked + untracked, .gitignore
696
874
  // honored) as a git tree object via a throwaway index, excluding separately
697
875
  // fingerprinted DevRites roots. Seeding from HEAD keeps committed paths tracked
@@ -715,49 +893,29 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
715
893
  }
716
894
  env = append(env, "GIT_INDEX_FILE="+idx)
717
895
 
718
- read := exec.Command("git", "-C", gitRoot, "read-tree", "HEAD")
719
- read.Env = env
720
- readOut, readErr := read.CombinedOutput()
896
+ _, readErr := runGitCommand(gitRoot, env, "read-tree", "HEAD")
721
897
  if readErr != nil {
722
898
  readFailure := fmt.Errorf("git read-tree HEAD: %w", readErr)
723
- if detail := strings.TrimSpace(string(readOut)); detail != "" {
724
- readFailure = fmt.Errorf("git read-tree HEAD: %w: %s", readErr, detail)
725
- }
726
899
  // An unborn branch is the one valid reason HEAD cannot seed the index.
727
900
  // Distinguish it from a corrupt or unreadable existing ref so the gate
728
901
  // still fails closed on real repository errors.
729
- symbolic := exec.Command("git", "-C", gitRoot, "symbolic-ref", "--quiet", "HEAD")
730
- symbolic.Env = env
731
- refOut, symbolicErr := symbolic.CombinedOutput()
902
+ refOut, symbolicErr := runGitCommand(gitRoot, env, "symbolic-ref", "--quiet", "HEAD")
732
903
  if symbolicErr != nil {
733
904
  return "", readFailure
734
905
  }
735
906
  ref := strings.TrimSpace(string(refOut))
736
- exists := exec.Command("git", "-C", gitRoot, "show-ref", "--verify", "--quiet", ref)
737
- exists.Env = env
738
- if err := exists.Run(); err == nil {
907
+ if _, err := runGitCommand(gitRoot, env, "show-ref", "--verify", "--quiet", ref); err == nil {
739
908
  return "", readFailure
740
- } else if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 {
909
+ } else if code, ok := gitErrorExitCode(err); !ok || code != 1 {
741
910
  return "", fmt.Errorf("inspect HEAD ref %s: %w", ref, err)
742
911
  }
743
912
 
744
- empty := exec.Command("git", "-C", gitRoot, "read-tree", "--empty")
745
- empty.Env = env
746
- if out, err := empty.CombinedOutput(); err != nil {
747
- if detail := strings.TrimSpace(string(out)); detail != "" {
748
- return "", fmt.Errorf("git read-tree --empty: %w: %s", err, detail)
749
- }
913
+ if _, err := runGitCommand(gitRoot, env, "read-tree", "--empty"); err != nil {
750
914
  return "", fmt.Errorf("git read-tree --empty: %w", err)
751
915
  }
752
916
  }
753
917
 
754
- add := exec.Command("git", "-C", gitRoot, "add", "-A")
755
- add.Env = env
756
- addOut, err := add.CombinedOutput()
757
- if err != nil {
758
- if detail := strings.TrimSpace(string(addOut)); detail != "" {
759
- return "", fmt.Errorf("git add -A: %w: %s", err, detail)
760
- }
918
+ if _, err := runGitCommand(gitRoot, env, "add", "-A"); err != nil {
761
919
  return "", fmt.Errorf("git add -A: %w", err)
762
920
  }
763
921
  for _, excludedRoot := range excludedRoots {
@@ -765,23 +923,16 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
765
923
  if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
766
924
  continue
767
925
  }
768
- rm := exec.Command(
769
- "git", "-C", gitRoot, "rm", "-r", "--cached", "--ignore-unmatch", "--",
926
+ if _, err := runGitCommand(
927
+ gitRoot, env, "rm", "-r", "--cached", "--ignore-unmatch", "--",
770
928
  ":(top,literal)"+filepath.ToSlash(rel),
771
- )
772
- rm.Env = env
773
- if out, err := rm.CombinedOutput(); err != nil {
774
- return "", fmt.Errorf("exclude %s from worktree snapshot: %w: %s", rel, err, strings.TrimSpace(string(out)))
929
+ ); err != nil {
930
+ return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
775
931
  }
776
932
  }
777
933
 
778
- write := exec.Command("git", "-C", gitRoot, "write-tree")
779
- write.Env = env
780
- out, err := write.CombinedOutput()
934
+ out, err := runGitCommand(gitRoot, env, "write-tree")
781
935
  if err != nil {
782
- if detail := strings.TrimSpace(string(out)); detail != "" {
783
- return "", fmt.Errorf("git write-tree: %w: %s", err, detail)
784
- }
785
936
  return "", fmt.Errorf("git write-tree: %w", err)
786
937
  }
787
938
  tree := strings.TrimSpace(string(out))
@@ -792,10 +943,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
792
943
  }
793
944
 
794
945
  func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
795
- common := exec.Command("git", "-C", gitRoot, "rev-parse", "--git-common-dir")
796
- out, err := common.CombinedOutput()
946
+ out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
797
947
  if err != nil {
798
- return nil, fmt.Errorf("git rev-parse --git-common-dir: %w: %s", err, strings.TrimSpace(string(out)))
948
+ return nil, fmt.Errorf("git rev-parse --git-common-dir: %w", err)
799
949
  }
800
950
  commonDir := strings.TrimSpace(string(out))
801
951
  if !filepath.IsAbs(commonDir) {
@@ -935,18 +1085,5 @@ func treeFileContent(gitRoot string, env []string, tree, filename string) ([]byt
935
1085
  }
936
1086
 
937
1087
  func reconcileGitOutput(gitRoot string, env []string, args ...string) ([]byte, error) {
938
- commandArgs := append([]string{"-C", gitRoot}, args...)
939
- cmd := exec.Command("git", commandArgs...)
940
- if env != nil {
941
- cmd.Env = env
942
- }
943
- out, err := cmd.CombinedOutput()
944
- if err != nil {
945
- detail := strings.TrimSpace(string(out))
946
- if detail != "" {
947
- return nil, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, detail)
948
- }
949
- return nil, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
950
- }
951
- return out, nil
1088
+ return runGitCommand(gitRoot, env, args...)
952
1089
  }
@@ -93,6 +93,20 @@ func TestWorktreeTreeErrorsOutsideGitRepo(t *testing.T) {
93
93
  }
94
94
  }
95
95
 
96
+ func TestReconcileFailsClosedWhenGitIsUnavailable(t *testing.T) {
97
+ newGitRepo(t)
98
+ root := workspace(t, "slice")
99
+ t.Setenv("PATH", t.TempDir())
100
+
101
+ code, output := runReconcile(t, root, "snapshot", "slice")
102
+ if code != 6 {
103
+ t.Fatalf("reconcile snapshot code=%d, want 6 when git is unavailable\n%s", code, output)
104
+ }
105
+ if !strings.Contains(output, "cannot resolve git worktree") {
106
+ t.Fatalf("missing fail-closed diagnostic:\n%s", output)
107
+ }
108
+ }
109
+
96
110
  func TestWorktreeTreeCapturesUntrackedFiles(t *testing.T) {
97
111
  dir := newGitRepo(t)
98
112
  objects := t.TempDir()
@@ -199,6 +213,86 @@ func TestReconcileSnapshotThenCleanCheck(t *testing.T) {
199
213
  }
200
214
  }
201
215
 
216
+ func TestReconcileRepeatedCheckRejectsAllowlistedSourceDrift(t *testing.T) {
217
+ gitRoot := newGitRepo(t)
218
+ root := workspace(t, "feat")
219
+ writeWrightAllowlist(t, root, "feat", "seed.go")
220
+
221
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
222
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
223
+ }
224
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc builtByWright() {}\n")
225
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
226
+ t.Fatalf("first check = %d, want 0\n%s", code, out)
227
+ }
228
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
229
+ t.Fatalf("unchanged repeated check = %d, want 0\n%s", code, out)
230
+ }
231
+
232
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc changedByArtifactGate() {}\n")
233
+ code, out := runReconcile(t, root, "check", "feat")
234
+ if code != 5 {
235
+ t.Fatalf("post-gate drift check = %d, want 5\n%s", code, out)
236
+ }
237
+ if !strings.Contains(out, "source changed after the last clean check") ||
238
+ !strings.Contains(out, "seed.go") {
239
+ t.Fatalf("post-gate drift diagnostic missing reason/path:\n%s", out)
240
+ }
241
+ }
242
+
243
+ func TestReconcileRestoreCheckRollsBackOnlyPostCheckDelta(t *testing.T) {
244
+ gitRoot := newGitRepo(t)
245
+ if _, err := runGitCommand(gitRoot, nil, "config", "core.autocrlf", "true"); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ root := workspace(t, "feat")
249
+ writeWrightAllowlist(t, root, "feat", "seed.go", "kept.go")
250
+ writeFile(t, filepath.Join(gitRoot, "kept.go"), "package main\n\nfunc beforeWright() {}\n")
251
+
252
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
253
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
254
+ }
255
+ wrightSeed := "package main\n\nfunc builtByWright() {}\n"
256
+ wrightKept := "package main\n\nfunc keptByWright() {}\n"
257
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), wrightSeed)
258
+ writeFile(t, filepath.Join(gitRoot, "kept.go"), wrightKept)
259
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
260
+ t.Fatalf("check = %d, want 0\n%s", code, out)
261
+ }
262
+
263
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc corruptedByGate() {}\n")
264
+ if err := os.Remove(filepath.Join(gitRoot, "kept.go")); err != nil {
265
+ t.Fatal(err)
266
+ }
267
+ writeFile(t, filepath.Join(gitRoot, "gate-added.go"), "package main\n")
268
+
269
+ code, out := runReconcile(t, root, "restore-check", "feat")
270
+ if code != 0 {
271
+ t.Fatalf("restore-check = %d, want 0\n%s", code, out)
272
+ }
273
+ for _, changedPath := range []string{"gate-added.go", "kept.go", "seed.go"} {
274
+ if !strings.Contains(out, changedPath) {
275
+ t.Errorf("restore output omitted %s:\n%s", changedPath, out)
276
+ }
277
+ }
278
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "seed.go")); err != nil {
279
+ t.Fatal(err)
280
+ } else if string(data) != wrightSeed {
281
+ t.Fatalf("seed.go = %q, want retained wright content %q", data, wrightSeed)
282
+ }
283
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "kept.go")); err != nil {
284
+ t.Fatal(err)
285
+ } else if string(data) != wrightKept {
286
+ t.Fatalf("kept.go = %q, want retained wright content %q", data, wrightKept)
287
+ }
288
+ if _, err := os.Stat(filepath.Join(gitRoot, "gate-added.go")); !os.IsNotExist(err) {
289
+ t.Fatalf("post-check added source remains: %v", err)
290
+ }
291
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
292
+ t.Fatalf("post-restore check = %d, want 0\n%s", code, out)
293
+ }
294
+ }
295
+
202
296
  func TestReconcileCheckFlagsUnauthorizedChange(t *testing.T) {
203
297
  gitRoot := newGitRepo(t)
204
298
  root := workspace(t, "feat")
@@ -698,25 +792,78 @@ func TestReconcileRefreshRequiresPriorCleanCheck(t *testing.T) {
698
792
  }
699
793
  }
700
794
 
701
- func TestReconcileCloseClearsPrivateWindowState(t *testing.T) {
795
+ func TestReconcileCloseRequiresCleanCheck(t *testing.T) {
702
796
  newGitRepo(t)
703
797
  root := workspace(t, "feat")
704
- writeFile(t, filepath.Join(root, "work", "feat", reconcileBaseName), "stale\n")
705
- writeFile(t, filepath.Join(root, "work", "feat", reconcileWrightStateName), "stale\n")
706
- if err := os.MkdirAll(filepath.Join(root, "work", "feat", reconcileObjectsName), 0o755); err != nil {
798
+ writeWrightAllowlist(t, root, "feat", "seed.go")
799
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
800
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
801
+ }
802
+
803
+ code, out := runReconcile(t, root, "close", "feat")
804
+ if code != 6 {
805
+ t.Fatalf("unchecked close = %d, want 6\n%s", code, out)
806
+ }
807
+ if !strings.Contains(out, "no clean check marker") {
808
+ t.Fatalf("missing clean-check diagnostic:\n%s", out)
809
+ }
810
+ if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
811
+ !isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
812
+ t.Fatal("unchecked close destroyed the retained baseline")
813
+ }
814
+ }
815
+
816
+ func TestReconcileCloseRejectsPostCheckSourceDrift(t *testing.T) {
817
+ gitRoot := newGitRepo(t)
818
+ root := workspace(t, "feat")
819
+ writeWrightAllowlist(t, root, "feat", "seed.go")
820
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
821
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
822
+ }
823
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc builtByWright() {}\n")
824
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
825
+ t.Fatalf("check = %d, want 0\n%s", code, out)
826
+ }
827
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc changedAfterCheck() {}\n")
828
+
829
+ code, out := runReconcile(t, root, "close", "feat")
830
+ if code != 5 {
831
+ t.Fatalf("drifted close = %d, want 5\n%s", code, out)
832
+ }
833
+ if !strings.Contains(out, "source changed after the last clean check") ||
834
+ !strings.Contains(out, "seed.go") {
835
+ t.Fatalf("missing post-check drift diagnostic:\n%s", out)
836
+ }
837
+ if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
838
+ !isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
839
+ t.Fatal("drifted close destroyed the retained baseline")
840
+ }
841
+ }
842
+
843
+ func TestReconcileCloseRejectsWorkspaceSymlinkEscape(t *testing.T) {
844
+ newGitRepo(t)
845
+ root := t.TempDir()
846
+ outside := t.TempDir()
847
+ if err := os.MkdirAll(filepath.Join(root, "work"), 0o755); err != nil {
848
+ t.Fatal(err)
849
+ }
850
+ if err := os.Symlink(outside, filepath.Join(root, "work", "feat")); err != nil {
851
+ t.Skipf("symlinks unavailable: %v", err)
852
+ }
853
+ writeFile(t, filepath.Join(outside, reconcileBaseName), "outside\n")
854
+ if err := os.MkdirAll(filepath.Join(outside, reconcileObjectsName), 0o700); err != nil {
707
855
  t.Fatal(err)
708
856
  }
709
857
 
710
858
  code, out := runReconcile(t, root, "close", "feat")
711
- if code != 0 {
712
- t.Fatalf("close = %d, want 0\n%s", code, out)
859
+ if code != 6 {
860
+ t.Fatalf("symlinked close = %d, want 6\n%s", code, out)
713
861
  }
714
- if !strings.Contains(out, "closed slice window") {
715
- t.Errorf("expected close message:\n%s", out)
862
+ if !strings.Contains(out, "workspace") {
863
+ t.Fatalf("missing workspace-path diagnostic:\n%s", out)
716
864
  }
717
- if isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
718
- isFile(filepath.Join(featureDir(root, "feat"), reconcileWrightStateName)) ||
719
- isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
720
- t.Fatal("close retained private baseline state")
865
+ if !isFile(filepath.Join(outside, reconcileBaseName)) ||
866
+ !isDir(filepath.Join(outside, reconcileObjectsName)) {
867
+ t.Fatal("symlinked close deleted state outside the DevRites root")
721
868
  }
722
869
  }
@@ -52,10 +52,10 @@ func TestIntegrity(root string, args []string, stdout, stderr io.Writer) int {
52
52
  }
53
53
 
54
54
  cwd, _ := os.Getwd()
55
- gitRoot := gitToplevel(cwd)
56
- if gitRoot == "" {
57
- fmt.Fprintln(stderr, "test-integrity: not a git repo: gate skipped; inspect the test diff manually.")
58
- return 0
55
+ gitRoot, err := gitToplevel(cwd)
56
+ if err != nil {
57
+ fmt.Fprintf(stderr, "test-integrity: cannot resolve git worktree: %v\n", err)
58
+ return 2
59
59
  }
60
60
 
61
61
  trees, err := captureSliceTreeRange(gitRoot, root, d)
@@ -77,6 +77,21 @@ func TestIsSourceFileRecognisesCode(t *testing.T) {
77
77
  }
78
78
  }
79
79
 
80
+ func TestIntegrityFailsClosedWhenGitIsUnavailable(t *testing.T) {
81
+ newGitRepo(t)
82
+ root := workspace(t, "feat")
83
+ t.Setenv("PATH", t.TempDir())
84
+
85
+ var stdout, stderr bytes.Buffer
86
+ code := TestIntegrity(root, []string{"feat"}, &stdout, &stderr)
87
+ if code != 2 {
88
+ t.Fatalf("test-integrity = %d, want 2\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
89
+ }
90
+ if !strings.Contains(stderr.String(), "cannot resolve git worktree") {
91
+ t.Fatalf("missing fail-closed diagnostic: %s", stderr.String())
92
+ }
93
+ }
94
+
80
95
  func TestIntegrityDetectsDeletionFromDirtySliceBaseline(t *testing.T) {
81
96
  gitRoot := newGitRepo(t)
82
97
  root := workspace(t, "feat")