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
@@ -262,14 +262,15 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
262
262
  // insensitive, and FAIL/PASS scans are line oriented.
263
263
  // pack-scan-ignore: these are the hook's own red/green heuristics, not a payload.
264
264
  var (
265
- redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
266
- redFailRe = regexp.MustCompile(`(?im)(^|[^A-Za-z])FAIL([^A-Za-z]|$)|[1-9][0-9]*[[:space:]]+(failed|failing|failures?|errors?)|not ok|panic:|error TS[0-9]|Traceback|AssertionError|✗|✘|BUILD FAILED|exit code [1-9]`)
267
- redPassRe = regexp.MustCompile(`(?im)PASS(ED)?|0[[:space:]]+(failed|failing|errors?)|all tests passed|BUILD SUCC(ESS|EEDED)|succeeded|no (errors|problems|issues)|(^|[^a-z])ok([^a-z]|$)|✓`)
265
+ redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
266
+ redFailRe = regexp.MustCompile(`(?im)(^|[^A-Za-z])FAIL([^A-Za-z]|$)|[1-9][0-9]*[[:space:]]+(failed|failing|failures?|errors?)|not ok|panic:|error TS[0-9]|Traceback|AssertionError|✗|✘|BUILD FAILED|exit code [1-9]`)
267
+ redPassRe = regexp.MustCompile(`(?im)PASS(ED)?|0[[:space:]]+(failed|failing|errors?)|all tests passed|BUILD SUCC(ESS|EEDED)|succeeded|no (errors|problems|issues)|(^|[^a-z])ok([^a-z]|$)|✓`)
268
+ reconcileRestoreCheckRe = regexp.MustCompile(`^\s*(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+restore-check(?:\s+[A-Za-z0-9._-]+)?\s*$`)
268
269
  )
269
270
 
270
271
  const redReasonFmt = "DevRites: tests/build are RED (%s). Fix to green or record the failure + next step in state.md before stopping: the Stop gate blocks an end-of-turn while .red is set."
271
272
 
272
- // hookRedwatch handles PostToolUse Bash events for test, build, lint, and type
273
+ // hookRedwatch handles PostToolUse shell events for test, build, lint, and type
273
274
  // checking commands. It sets or clears <featureDir>/.red so the Stop gate can
274
275
  // catch a known failing suite. Its command and output checks match
275
276
  // devrites-redwatch.sh, and uncertain input leaves state unchanged.
@@ -286,7 +287,7 @@ func hookRedwatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer)
286
287
  return exitOK
287
288
  }
288
289
  in := h.ParseGuardInput(bytes.NewReader(data))
289
- if in.ToolName != "Bash" {
290
+ if !isShellTool(in.ToolName) {
290
291
  return exitOK
291
292
  }
292
293
  // Ignore commands unrelated to tests, builds, linting, or type checking.
@@ -562,7 +563,8 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
562
563
  return exitOK
563
564
  }
564
565
  in := h.ParseGuardInput(bytes.NewReader(data))
565
- kind := devritesAgentForGuard(h, in)
566
+ binding := devritesAgentBindingForGuard(h, in)
567
+ kind := binding.Kind
566
568
  genericCompat := os.Getenv("DEVRITES_CODEX_GENERIC_AGENT_COMPAT") == "1"
567
569
  if in.AgentType == "" && in.AgentID == "" {
568
570
  if code, handled := guardRootBuildWindow(h, data, in, stdout, stderr); handled {
@@ -620,6 +622,21 @@ func hookWrightScope(h harness.Harness, stdin io.Reader, stdout, stderr io.Write
620
622
  return exitOK
621
623
  }
622
624
  if genericWright && !forgeDeclared {
625
+ if binding.Role != "devrites-slice-wright" {
626
+ if wsIsFile(filepath.Join(dir, ".reconcile-base")) {
627
+ return denyOrObserveWright(
628
+ h,
629
+ stdout,
630
+ stderr,
631
+ dir,
632
+ true,
633
+ wrightForbiddenReason,
634
+ drvreason.HookWrightForbiddenDenied,
635
+ []string{"unbound generic worker"},
636
+ )
637
+ }
638
+ return exitOK
639
+ }
623
640
  if !wsIsFile(filepath.Join(dir, ".reconcile-base")) {
624
641
  return exitOK
625
642
  }
@@ -1044,6 +1061,9 @@ func classifyRootBuildWindowOperation(root string, data []byte, in harness.Guard
1044
1061
  if isOpaqueExecutionTool(in.ToolName) {
1045
1062
  return true, true, in.ToolName
1046
1063
  }
1064
+ if isShellTool(in.ToolName) && strings.Contains(in.Command, "reconcile restore-check") {
1065
+ return true, !reconcileRestoreCheckRe.MatchString(in.Command), in.Command
1066
+ }
1047
1067
 
1048
1068
  projectDir := filepath.Dir(root)
1049
1069
  targets, suspicious := rootMutationTargets(data, in)
@@ -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) {
@@ -280,14 +280,20 @@ func newRunner(opts Options) (*runner, error) {
280
280
  source = inferSourceDir()
281
281
  }
282
282
  if source != "" {
283
- source, _ = filepath.Abs(source)
283
+ source, err = filepath.Abs(source)
284
+ if err != nil {
285
+ return nil, fmt.Errorf("resolve source %s: %w", opts.SourceDir, err)
286
+ }
284
287
  }
285
288
  payload := opts.PayloadDir
286
289
  if payload == "" {
287
290
  payload = inferPayloadDir(source)
288
291
  }
289
292
  if payload != "" {
290
- payload, _ = filepath.Abs(payload)
293
+ payload, err = filepath.Abs(payload)
294
+ if err != nil {
295
+ return nil, fmt.Errorf("resolve payload %s: %w", opts.PayloadDir, err)
296
+ }
291
297
  }
292
298
  r := &runner{
293
299
  opts: opts,
@@ -304,7 +310,10 @@ func newRunner(opts Options) (*runner, error) {
304
310
  return nil, fmt.Errorf("validate payload: %w", err)
305
311
  }
306
312
  }
307
- r.prev = readManifest(filepath.Join(target, ManifestName))
313
+ r.prev, err = readManifest(filepath.Join(target, ManifestName))
314
+ if err != nil {
315
+ return nil, err
316
+ }
308
317
  return r, nil
309
318
  }
310
319
 
@@ -737,13 +746,15 @@ func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
737
746
  dest := filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))
738
747
  if r.opts.DryRun {
739
748
  verb := "create DevRites block"
740
- if exists(dest) {
741
- current, _ := os.ReadFile(dest)
749
+ current, readErr := os.ReadFile(dest)
750
+ if readErr == nil {
742
751
  if bytes.Contains(current, []byte(merge.Begin)) {
743
752
  verb = "refresh DevRites block"
744
753
  } else {
745
754
  verb = "append DevRites block"
746
755
  }
756
+ } else if !errors.Is(readErr, fs.ErrNotExist) {
757
+ return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
747
758
  }
748
759
  fmt.Fprintf(r.opts.Stdout, " [merge] %s (%s)\n", merge.TargetRel, verb)
749
760
  } else {
@@ -751,8 +762,11 @@ func (r *runner) mergeMarkerFile(merge hostpack.MarkerMerge) error {
751
762
  return err
752
763
  }
753
764
  next := block
754
- if current, err := os.ReadFile(dest); err == nil {
765
+ current, readErr := os.ReadFile(dest)
766
+ if readErr == nil {
755
767
  next = hostpack.MergeMarkerBlock(current, block, merge.Begin, merge.End)
768
+ } else if !errors.Is(readErr, fs.ErrNotExist) {
769
+ return fmt.Errorf("cannot read %s: %w", merge.TargetRel, readErr)
756
770
  }
757
771
  if err := fsutil.WriteFileAtomic(dest, next, 0o644); err != nil {
758
772
  return fmt.Errorf("cannot write %s: %w", merge.TargetRel, err)
@@ -974,7 +988,10 @@ func (r *runner) flagsString() string {
974
988
 
975
989
  func (r *runner) uninstall() error {
976
990
  mf := filepath.Join(r.target, ManifestName)
977
- entries := readManifestList(mf)
991
+ entries, err := readManifestList(mf)
992
+ if err != nil {
993
+ return err
994
+ }
978
995
  if len(entries) == 0 {
979
996
  return fmt.Errorf("no DevRites manifest at %s - nothing to uninstall", mf)
980
997
  }
@@ -1922,20 +1939,23 @@ func readEngineVersion(path string, timeout time.Duration) (string, error) {
1922
1939
  return line, nil
1923
1940
  }
1924
1941
 
1925
- func readManifest(path string) map[string]managedRecord {
1926
- records, _ := parseManifest(path)
1927
- return records
1942
+ func readManifest(path string) (map[string]managedRecord, error) {
1943
+ records, _, err := parseManifest(path)
1944
+ return records, err
1928
1945
  }
1929
1946
 
1930
- func readManifestList(path string) []string {
1931
- _, entries := parseManifest(path)
1932
- return entries
1947
+ func readManifestList(path string) ([]string, error) {
1948
+ _, entries, err := parseManifest(path)
1949
+ return entries, err
1933
1950
  }
1934
1951
 
1935
- func parseManifest(path string) (map[string]managedRecord, []string) {
1952
+ func parseManifest(path string) (map[string]managedRecord, []string, error) {
1936
1953
  data, err := os.ReadFile(path)
1937
1954
  if err != nil {
1938
- return map[string]managedRecord{}, nil
1955
+ if errors.Is(err, fs.ErrNotExist) {
1956
+ return map[string]managedRecord{}, nil, nil
1957
+ }
1958
+ return nil, nil, fmt.Errorf("read manifest %s: %w", path, err)
1939
1959
  }
1940
1960
  hashes := map[string]string{}
1941
1961
  var out []string
@@ -1967,7 +1987,7 @@ func parseManifest(path string) (map[string]managedRecord, []string) {
1967
1987
  for _, rel := range out {
1968
1988
  records[rel] = managedRecord{Hash: hashes[rel]}
1969
1989
  }
1970
- return records, out
1990
+ return records, out, nil
1971
1991
  }
1972
1992
 
1973
1993
  func validManagedHash(hash string) bool {
@@ -18,6 +18,7 @@ import (
18
18
  "testing"
19
19
  "time"
20
20
 
21
+ "github.com/devrites/devrites/internal/hostpack"
21
22
  "github.com/devrites/devrites/internal/testutil"
22
23
  )
23
24
 
@@ -188,6 +189,50 @@ func TestMarkerMergeAndUninstallPreserveUserContent(t *testing.T) {
188
189
  }
189
190
  }
190
191
 
192
+ func TestMarkerMergeDryRunReportsUnreadableTarget(t *testing.T) {
193
+ payload := t.TempDir()
194
+ if err := os.WriteFile(filepath.Join(payload, "block.md"), []byte("DevRites\n"), 0o644); err != nil {
195
+ t.Fatal(err)
196
+ }
197
+ target := t.TempDir()
198
+ if err := os.Mkdir(filepath.Join(target, "AGENTS.md"), 0o755); err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ r := runner{
202
+ opts: Options{DryRun: true, Stdout: &bytes.Buffer{}},
203
+ target: target,
204
+ payloadFS: os.DirFS(payload),
205
+ }
206
+ err := r.mergeMarkerFile(hostpack.MarkerMerge{
207
+ TargetRel: "AGENTS.md",
208
+ PayloadRel: "block.md",
209
+ Begin: "<!-- BEGIN -->",
210
+ End: "<!-- END -->",
211
+ })
212
+ if err == nil || !strings.Contains(err.Error(), "cannot read AGENTS.md") {
213
+ t.Fatalf("mergeMarkerFile error = %v, want target read error", err)
214
+ }
215
+ }
216
+
217
+ func TestUninstallReportsUnreadableManifest(t *testing.T) {
218
+ target := t.TempDir()
219
+ if err := os.Mkdir(filepath.Dir(filepath.Join(target, ManifestName)), 0o755); err != nil {
220
+ t.Fatal(err)
221
+ }
222
+ if err := os.Mkdir(filepath.Join(target, ManifestName), 0o755); err != nil {
223
+ t.Fatal(err)
224
+ }
225
+ opts := DefaultOptions(ModeUninstall)
226
+ opts.Target = target
227
+ err := Apply(opts)
228
+ if err == nil || !strings.Contains(err.Error(), "read manifest") {
229
+ t.Fatalf("Apply error = %v, want manifest read error", err)
230
+ }
231
+ if info, statErr := os.Stat(filepath.Join(target, ManifestName)); statErr != nil || !info.IsDir() {
232
+ t.Fatalf("manifest was replaced: info=%v err=%v", info, statErr)
233
+ }
234
+ }
235
+
191
236
  func TestInstallMergesClaudeHooksIntoExistingSettings(t *testing.T) {
192
237
  t.Setenv("DEVRITES_NO_BINARY", "1")
193
238
  payload := testPayload(t)
@@ -259,7 +304,11 @@ func TestInstallKeepsClaudeMergeOwnershipAcrossReinstall(t *testing.T) {
259
304
  if !exists(filepath.Join(target, filepath.FromSlash(markerRel))) {
260
305
  t.Fatal("first install did not create the Claude hook merge marker")
261
306
  }
262
- if _, ok := readManifest(filepath.Join(target, ManifestName))[markerRel]; !ok {
307
+ manifestRecords, err := readManifest(filepath.Join(target, ManifestName))
308
+ if err != nil {
309
+ t.Fatal(err)
310
+ }
311
+ if _, ok := manifestRecords[markerRel]; !ok {
263
312
  t.Fatalf("first install did not record the Claude hook merge marker:\n%s", testutil.ReadFile(t, filepath.Join(target, ManifestName)))
264
313
  }
265
314
  runInstall(t, target, payload, func(o *Options) {})
@@ -690,9 +690,9 @@ func repoChangedSince(root, stamp string) bool {
690
690
  }
691
691
  stampTime := info.ModTime()
692
692
  changed := false
693
- _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
693
+ if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
694
694
  if err != nil {
695
- return nil
695
+ return err
696
696
  }
697
697
  if d.IsDir() {
698
698
  if path != root && refreshExcludedDirs[d.Name()] {
@@ -705,7 +705,9 @@ func repoChangedSince(root, stamp string) bool {
705
705
  return filepath.SkipAll
706
706
  }
707
707
  return nil
708
- })
708
+ }); err != nil {
709
+ return true
710
+ }
709
711
  return changed
710
712
  }
711
713
 
@@ -548,3 +548,14 @@ func TestRefreshStateAndChangeScan(t *testing.T) {
548
548
  t.Fatalf("repoChangedSince=false after tracked file write, want true")
549
549
  }
550
550
  }
551
+
552
+ func TestRepoChangedSinceTreatsScanFailureAsChanged(t *testing.T) {
553
+ base := t.TempDir()
554
+ stamp := filepath.Join(base, "stamp")
555
+ if err := os.WriteFile(stamp, nil, 0o644); err != nil {
556
+ t.Fatal(err)
557
+ }
558
+ if !repoChangedSince(filepath.Join(base, "missing"), stamp) {
559
+ t.Fatal("repoChangedSince=false after repository scan failure, want conservative refresh")
560
+ }
561
+ }
@@ -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 {
@@ -569,8 +584,11 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
569
584
  }
570
585
  var hits []hit
571
586
  for _, base := range targets {
572
- _ = filepath.WalkDir(base, func(p string, d os.DirEntry, err error) error {
573
- if err != nil || d.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".md") {
587
+ err := filepath.WalkDir(base, func(p string, d os.DirEntry, err error) error {
588
+ if err != nil {
589
+ return err
590
+ }
591
+ if d.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".md") {
574
592
  return nil
575
593
  }
576
594
  lowName := strings.ToLower(filepath.Base(p))
@@ -579,7 +597,7 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
579
597
  }
580
598
  b, err := os.ReadFile(p) // #nosec G122 -- scoring walk over the project's own .scratch tree; a symlink race requires an attacker already writing to the checkout
581
599
  if err != nil {
582
- return nil
600
+ return err
583
601
  }
584
602
  text := strings.ToLower(string(b))
585
603
  score := 0
@@ -589,11 +607,18 @@ func SpecDedupe(root string, args []string, stdout, stderr io.Writer) int {
589
607
  }
590
608
  }
591
609
  if score >= 2 || score == len(terms) {
592
- rel, _ := filepath.Rel(project, p)
610
+ rel, err := filepath.Rel(project, p)
611
+ if err != nil {
612
+ return err
613
+ }
593
614
  hits = append(hits, hit{score: score, path: filepath.ToSlash(rel), line: firstLine(string(b))})
594
615
  }
595
616
  return nil
596
617
  })
618
+ if err != nil && !os.IsNotExist(err) {
619
+ fmt.Fprintf(stderr, "spec-dedupe: search %s: %v\n", base, err)
620
+ return 1
621
+ }
597
622
  }
598
623
  sort.Slice(hits, func(i, j int) bool {
599
624
  if hits[i].score == hits[j].score {
@@ -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")
@@ -53,8 +53,16 @@ func Analyze(root string, args []string, stdout, stderr io.Writer) int {
53
53
  return 2
54
54
  }
55
55
 
56
- specData, _ := os.ReadFile(spec)
57
- tasksData, _ := os.ReadFile(tasks)
56
+ specData, err := os.ReadFile(spec)
57
+ if err != nil {
58
+ fmt.Fprintf(stderr, "analyze: read spec.md: %v\n", err)
59
+ return 2
60
+ }
61
+ tasksData, err := os.ReadFile(tasks)
62
+ if err != nil {
63
+ fmt.Fprintf(stderr, "analyze: read tasks.md: %v\n", err)
64
+ return 2
65
+ }
58
66
 
59
67
  specACs := sortedACIDs(acIDRe, specData, true) // legacy brackets stripped: "[AC1]" -> "AC1"
60
68
  taskACs := sortedACIDs(taskACRe, tasksData, false)
@@ -1,8 +1,10 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "errors"
4
5
  "fmt"
5
6
  "io"
7
+ "io/fs"
6
8
  "os"
7
9
  "path/filepath"
8
10
  "sort"
@@ -11,11 +13,11 @@ import (
11
13
 
12
14
  // ArchiveSearch surfaces shipped features whose spec.md overlaps a query, so
13
15
  // /rite-spec can spot prior art before writing a new spec: an extension, a
14
- // conflict, or a re-spec of solved work. It is advisory, not a gate: it always
15
- // exits 0 when it runs (empty output means no overlap), and reserves exit 2 for
16
- // a missing query. The query is the feature's key nouns, one or many args; a
17
- // quoted phrase is split on whitespace. Ranking is by the count of distinct query
18
- // terms a spec contains, highest first, ties broken by slug.
16
+ // conflict, or a re-spec of solved work. It is advisory, not a gate: empty
17
+ // output means no overlap. Exit 2 means a missing query; exit 3 means the
18
+ // archive could not be read. The query is the feature's key nouns, one or many
19
+ // args; a quoted phrase is split on whitespace. Ranking is by the count of
20
+ // distinct query terms a spec contains, highest first, ties broken by slug.
19
21
  //
20
22
  // args is `<term>...`. Terms shorter than three characters are dropped as noise.
21
23
  func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
@@ -34,10 +36,24 @@ func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
34
36
  return 2 // usage
35
37
  }
36
38
 
37
- entries, err := os.ReadDir(filepath.Join(root, "archive"))
39
+ archive := filepath.Join(root, "archive")
40
+ info, err := os.Stat(archive)
38
41
  if err != nil {
39
- // No archive yet: no prior art to surface. Silent, successful no-op.
40
- return 0
42
+ if errors.Is(err, fs.ErrNotExist) {
43
+ // No archive yet: no prior art to surface. Silent, successful no-op.
44
+ return 0
45
+ }
46
+ fmt.Fprintf(stderr, "archive-search: read archive: %v\n", err)
47
+ return 3
48
+ }
49
+ if !info.IsDir() {
50
+ fmt.Fprintf(stderr, "archive-search: read archive: %s is not a directory\n", archive)
51
+ return 3
52
+ }
53
+ entries, err := os.ReadDir(archive)
54
+ if err != nil {
55
+ fmt.Fprintf(stderr, "archive-search: read archive: %v\n", err)
56
+ return 3
41
57
  }
42
58
 
43
59
  type hit struct {
@@ -51,9 +67,25 @@ func ArchiveSearch(root string, args []string, stdout, stderr io.Writer) int {
51
67
  continue
52
68
  }
53
69
  spec := filepath.Join(root, "archive", e.Name(), "spec.md")
70
+ info, err := os.Stat(spec)
71
+ if err != nil {
72
+ if errors.Is(err, fs.ErrNotExist) {
73
+ continue
74
+ }
75
+ fmt.Fprintf(stderr, "archive-search: read %s: %v\n", spec, err)
76
+ return 3
77
+ }
78
+ if !info.Mode().IsRegular() {
79
+ fmt.Fprintf(stderr, "archive-search: read %s: not a regular file\n", spec)
80
+ return 3
81
+ }
54
82
  b, err := os.ReadFile(spec)
55
83
  if err != nil {
56
- continue
84
+ if errors.Is(err, fs.ErrNotExist) {
85
+ continue
86
+ }
87
+ fmt.Fprintf(stderr, "archive-search: read %s: %v\n", spec, err)
88
+ return 3
57
89
  }
58
90
  body := strings.ToLower(string(b))
59
91
  n := 0
@@ -98,6 +98,34 @@ func TestArchiveSearchNoArchive(t *testing.T) {
98
98
  }
99
99
  }
100
100
 
101
+ func TestArchiveSearchReportsUnreadableArchive(t *testing.T) {
102
+ root := t.TempDir()
103
+ if err := os.WriteFile(filepath.Join(root, "archive"), []byte("not a directory"), 0o644); err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
107
+ if code := ArchiveSearch(root, []string{"anything"}, stdout, stderr); code != 3 {
108
+ t.Fatalf("code=%d, want 3; stderr=%q", code, stderr.String())
109
+ }
110
+ if !strings.Contains(stderr.String(), "read archive") {
111
+ t.Fatalf("stderr=%q, want archive read error", stderr.String())
112
+ }
113
+ }
114
+
115
+ func TestArchiveSearchReportsUnreadableSpec(t *testing.T) {
116
+ root := t.TempDir()
117
+ if err := os.MkdirAll(filepath.Join(root, "archive", "broken", "spec.md"), 0o755); err != nil {
118
+ t.Fatal(err)
119
+ }
120
+ stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{}
121
+ if code := ArchiveSearch(root, []string{"anything"}, stdout, stderr); code != 3 {
122
+ t.Fatalf("code=%d, want 3; stderr=%q", code, stderr.String())
123
+ }
124
+ if !strings.Contains(stderr.String(), "archive-search: read") {
125
+ t.Fatalf("stderr=%q, want spec read error", stderr.String())
126
+ }
127
+ }
128
+
101
129
  func writeArchiveSpec(t *testing.T, root, slug, content string) {
102
130
  t.Helper()
103
131
  dir := filepath.Join(root, "archive", slug)
@@ -32,7 +32,11 @@ func BuildReadiness(root string, args []string, stdout, stderr io.Writer) int {
32
32
  return readinessCode("workspace-missing")
33
33
  }
34
34
 
35
- data, _ := os.ReadFile(s)
35
+ data, err := os.ReadFile(s)
36
+ if err != nil {
37
+ fmt.Fprintf(stderr, "readiness: cannot read state.md: %v\n", err)
38
+ return readinessCode("workspace-missing")
39
+ }
36
40
  lines := splitLinesNoTrailing(data)
37
41
 
38
42
  status := readinessField(lines, "Status")